-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdotProcessor.ts
More file actions
307 lines (266 loc) · 9.66 KB
/
dotProcessor.ts
File metadata and controls
307 lines (266 loc) · 9.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
import {
BaseProcessor,
ProcessorOptions,
ExtractStringsResult,
TranslatedString,
SourceString,
} from '../core/baseProcessor';
import { AACTree, AACPage, AACButton, AACSemanticIntent } from '../core/treeStructure';
// Removed unused import: FileProcessor
import {
ValidationFailureError,
buildValidationResultFromMessage,
} from '../validation/validationTypes';
import {
ProcessorInput,
getBasename,
readBinaryFromInput,
readTextFromInput,
writeBinaryToPath,
writeTextToPath,
encodeText,
} from '../utils/io';
interface DotNode {
id: string;
label: string;
}
interface DotEdge {
from: string;
to: string;
label?: string;
}
class DotProcessor extends BaseProcessor {
constructor(options?: ProcessorOptions) {
super(options);
}
private parseDotFile(content: string): {
nodes: Array<DotNode & { id: string; label: string }>;
edges: Array<DotEdge & { from: string; to: string }>;
} {
const nodes = new Map<string, DotNode>();
const edges: DotEdge[] = [];
// Extract all edge statements using regex to handle single-line DOT files
const edgeRegex = /"?([^"\s]+)"?\s*->\s*"?([^"\s]+)"?(?:\s*\[label="([^"]+)"\])?/g;
// We need to find nodes, but avoid matching the target of an edge which might look like a node definition
// e.g. A -> B [label="L"] -- "B [label="L"]" looks like a node def
// Strategy: Find all edges, record them, and then "mask" them in the content to avoid false positives for nodes
let maskedContent = content;
let edgeMatch;
// Find all edge definitions
while ((edgeMatch = edgeRegex.exec(content)) !== null) {
const [fullMatch, from, to, label] = edgeMatch;
edges.push({ from, to, label });
// Add nodes if they don't exist (implicit definition)
if (!nodes.has(from)) {
nodes.set(from, { id: from, label: from });
}
if (!nodes.has(to)) {
nodes.set(to, { id: to, label: to });
}
// Mask this edge in the content so we don't match it as a node
// We replace it with spaces to preserve indices if needed, but simple replacement is enough here
maskedContent = maskedContent.replace(fullMatch, ' '.repeat(fullMatch.length));
}
// Now find explicit node definitions in the masked content
// This regex matches: ID [label="LABEL"]
// We use a non-greedy match for the label content to handle escaped quotes if possible,
// but the previous regex `[^"]+` was too simple.
// Better regex for quoted string content: (?:[^"\\]|\\.)*
const nodeRegex = /"?([^"\s]+)"?\s*\[label="((?:[^"\\]|\\.)*)"\]/g;
let nodeMatch;
while ((nodeMatch = nodeRegex.exec(maskedContent)) !== null) {
const [, id, rawLabel] = nodeMatch;
// Unescape the label: replace \" with " and \\ with \
const label = rawLabel.replace(/\\"/g, '"').replace(/\\\\/g, '\\');
// Only update if not already defined or if we want to override the implicit label
nodes.set(id, { id, label });
}
return { nodes: Array.from(nodes.values()), edges };
}
async extractTexts(filePathOrBuffer: ProcessorInput): Promise<string[]> {
await Promise.resolve();
const content = readTextFromInput(filePathOrBuffer);
const { nodes, edges } = this.parseDotFile(content);
const texts: string[] = [];
// Collect node labels
for (const node of nodes) {
texts.push(node.label);
}
// Collect edge labels
for (const edge of edges) {
if (edge.label) {
texts.push(edge.label);
}
}
return texts;
}
async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise<AACTree> {
await Promise.resolve();
const filename =
typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.dot';
const buffer = readBinaryFromInput(filePathOrBuffer);
const filesize = buffer.byteLength;
try {
const content = readTextFromInput(buffer);
if (!content || content.trim().length === 0) {
const validation = buildValidationResultFromMessage({
filename,
filesize,
format: 'dot',
message: 'DOT file is empty',
type: 'content',
description: 'DOT file content',
});
throw new ValidationFailureError('Empty DOT content', validation);
}
// Check for binary data (contains null bytes or non-printable characters)
const head = content.substring(0, 100);
for (let i = 0; i < head.length; i++) {
const code = head.charCodeAt(i);
if (code === 0 || (code >= 0 && code <= 8) || (code >= 14 && code <= 31)) {
const validation = buildValidationResultFromMessage({
filename,
filesize,
format: 'dot',
message: 'DOT appears to be binary data',
type: 'content',
description: 'DOT file content',
});
throw new ValidationFailureError('Invalid DOT content', validation);
}
}
const { nodes, edges } = this.parseDotFile(content);
const tree = new AACTree();
tree.metadata.format = 'dot';
// Create pages for each node and add a self button representing the node label
for (const node of nodes) {
const page = new AACPage({
id: node.id,
name: node.label,
grid: [],
buttons: [],
parentId: null,
});
tree.addPage(page);
// Add a self button so single-node graphs yield one button
page.addButton(
new AACButton({
id: `${node.id}_self`,
label: node.label,
message: node.label,
semanticAction: {
intent: AACSemanticIntent.SPEAK_TEXT,
text: node.label,
fallback: { type: 'SPEAK', message: node.label },
},
})
);
}
// Create navigation buttons based on edges
for (const edge of edges) {
const fromPage = tree.getPage(edge.from);
if (fromPage) {
const button = new AACButton({
id: `nav_${edge.from}_${edge.to}`,
label: edge.label || edge.to,
message: '',
targetPageId: edge.to,
});
fromPage.addButton(button);
}
}
return tree;
} catch (error: any) {
if (error instanceof ValidationFailureError) {
throw error;
}
const validation = buildValidationResultFromMessage({
filename,
filesize,
format: 'dot',
message: error?.message || 'Failed to parse DOT file',
type: 'parse',
description: 'Parse DOT graph',
});
throw new ValidationFailureError('Failed to load DOT file', validation, error);
}
}
async processTexts(
filePathOrBuffer: ProcessorInput,
translations: Map<string, string>,
outputPath: string,
_targetLocale?: string
): Promise<Uint8Array> {
await Promise.resolve();
const content = readTextFromInput(filePathOrBuffer);
let translatedContent = content;
translations.forEach((translation, text) => {
if (typeof text === 'string' && typeof translation === 'string') {
// Escape special regex characters in the text
const escapedText = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedTranslation = translation.replace(/\$/g, '$$$$'); // Escape $ in replacement
translatedContent = translatedContent.replace(
new RegExp(`label="${escapedText}"`, 'g'),
`label="${escapedTranslation}"`
);
}
});
const resultBuffer = encodeText(translatedContent || '');
writeBinaryToPath(outputPath, resultBuffer);
return resultBuffer;
}
async saveFromTree(tree: AACTree, _outputPath: string): Promise<void> {
await Promise.resolve();
let dotContent = `digraph "${tree.metadata?.name || 'AACBoard'}" {\n`;
// Helper to escape DOT string
const escapeDotString = (str: string): string => {
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
};
if (tree.metadata?.name) {
dotContent += ` label="${escapeDotString(tree.metadata.name)}";\n`;
}
// Add nodes
for (const pageId in tree.pages) {
const page = tree.pages[pageId];
dotContent += ` "${page.id}" [label="${escapeDotString(page.name)}"]\n`;
}
// Add edges from navigation buttons (semantic intent or legacy targetPageId)
for (const pageId in tree.pages) {
const page = tree.pages[pageId];
page.buttons
.filter((btn: AACButton) => {
const intentStr = String(btn.semanticAction?.intent);
return (
intentStr === 'NAVIGATE_TO' || !!btn.targetPageId || !!btn.semanticAction?.targetId
);
})
.forEach((btn: AACButton) => {
const target = btn.semanticAction?.targetId || btn.targetPageId;
if (target) {
dotContent += ` "${page.id}" -> "${target}" [label="${escapeDotString(btn.label)}"]\n`;
}
});
}
dotContent += '}\n';
writeTextToPath(_outputPath, dotContent);
}
/**
* Extract strings with metadata for aac-tools-platform compatibility
* Uses the generic implementation from BaseProcessor
*/
async extractStringsWithMetadata(filePath: string): Promise<ExtractStringsResult> {
return this.extractStringsWithMetadataGeneric(filePath);
}
/**
* Generate translated download for aac-tools-platform compatibility
* Uses the generic implementation from BaseProcessor
*/
async generateTranslatedDownload(
filePath: string,
translatedStrings: TranslatedString[],
sourceStrings: SourceString[]
): Promise<string> {
return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings);
}
}
export { DotProcessor };