@gmickel/gno 1.27.1 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/assets/skill/SKILL.md +26 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v1.27.1.zip → gno-browser-clipper-v1.28.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.28.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/spec/cli.md +41 -4
- package/spec/db/schema.sql +12 -0
- package/spec/mcp.md +5 -0
- package/spec/output-schemas/ask.schema.json +125 -0
- package/spec/output-schemas/context-capsule-v1.schema.json +94 -1
- package/spec/output-schemas/get.schema.json +94 -0
- package/spec/output-schemas/mcp-job-status.schema.json +28 -0
- package/spec/output-schemas/multi-get.schema.json +128 -0
- package/spec/output-schemas/record-import.schema.json +193 -0
- package/spec/output-schemas/search-result.schema.json +94 -0
- package/spec/output-schemas/search-results.schema.json +125 -0
- package/spec/project-profile.schema.json +66 -0
- package/src/app/context-format.ts +1 -1
- package/src/cli/commands/get.ts +12 -2
- package/src/cli/commands/index-cmd.ts +13 -0
- package/src/cli/commands/multi-get.ts +9 -2
- package/src/cli/commands/shared.ts +28 -0
- package/src/cli/commands/update.ts +5 -0
- package/src/cli/program.ts +4 -0
- package/src/config/project-profile.ts +2 -0
- package/src/config/types.ts +16 -0
- package/src/converters/adapters/browser-export/adapter.ts +199 -0
- package/src/converters/adapters/browser-export/formats.ts +358 -0
- package/src/converters/adapters/email/adapter.ts +429 -0
- package/src/converters/adapters/email/html.ts +162 -0
- package/src/converters/adapters/email/mime.ts +454 -0
- package/src/converters/adapters/email/parameters.ts +77 -0
- package/src/converters/adapters/ical/adapter.ts +475 -0
- package/src/converters/adapters/ical/recurrence.ts +186 -0
- package/src/converters/adapters/jsonl/adapter.ts +238 -0
- package/src/converters/adapters/jsonl/config.ts +105 -0
- package/src/converters/adapters/shared/html-text.ts +165 -0
- package/src/converters/adapters/shared/record-utils.ts +79 -0
- package/src/converters/adapters/shared/utf8-lines.ts +141 -0
- package/src/converters/adapters/transcript/adapter.ts +295 -0
- package/src/converters/adapters/transcript/json.ts +184 -0
- package/src/converters/adapters/transcript/model.ts +171 -0
- package/src/converters/adapters/transcript/text.ts +58 -0
- package/src/converters/adapters/transcript/timed.ts +152 -0
- package/src/converters/index.ts +11 -1
- package/src/converters/mime.ts +8 -0
- package/src/converters/pipeline.ts +15 -1
- package/src/converters/registry.ts +37 -1
- package/src/converters/types.ts +136 -0
- package/src/core/context-capsule-schema.ts +87 -0
- package/src/core/context-capsule.ts +20 -0
- package/src/core/context-evidence.ts +7 -1
- package/src/core/document-capabilities.ts +12 -0
- package/src/core/project-profile-apply-state.ts +5 -0
- package/src/core/project-profile.ts +4 -0
- package/src/core/record-metadata.ts +49 -0
- package/src/ingestion/record-adapter-canonical.ts +433 -0
- package/src/ingestion/record-adapter.ts +437 -0
- package/src/ingestion/record-container.ts +688 -0
- package/src/ingestion/record-path.ts +20 -0
- package/src/ingestion/record-sync.ts +70 -0
- package/src/ingestion/sync.ts +228 -36
- package/src/ingestion/types.ts +69 -1
- package/src/ingestion/walker.ts +31 -11
- package/src/mcp/tools/get.ts +10 -2
- package/src/mcp/tools/multi-get.ts +9 -2
- package/src/mcp/tools/workspace-write.ts +2 -0
- package/src/pipeline/filters.ts +1 -1
- package/src/pipeline/graph-retrieval.ts +7 -4
- package/src/pipeline/hybrid.ts +7 -4
- package/src/pipeline/result-context.ts +12 -4
- package/src/pipeline/search.ts +11 -4
- package/src/pipeline/types.ts +3 -0
- package/src/pipeline/vsearch.ts +26 -8
- package/src/sdk/documents.ts +13 -5
- package/src/serve/browse-tree.ts +4 -2
- package/src/serve/routes/api.ts +63 -65
- package/src/store/migrations/022-record-export-lineage.ts +42 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +114 -7
- package/src/store/types.ts +40 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip.sha256 +0 -1
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeHtmlEntitiesOnce,
|
|
3
|
+
htmlFragmentToText,
|
|
4
|
+
} from "../shared/html-text";
|
|
5
|
+
|
|
6
|
+
export type BrowserExportKind = "bookmark" | "history" | "reading-list";
|
|
7
|
+
|
|
8
|
+
export interface BrowserExportRecord {
|
|
9
|
+
kind: BrowserExportKind;
|
|
10
|
+
url: string;
|
|
11
|
+
title?: string;
|
|
12
|
+
folder?: string;
|
|
13
|
+
tags?: string[];
|
|
14
|
+
dates?: Record<string, string>;
|
|
15
|
+
externalId?: string;
|
|
16
|
+
sourceLocator: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BrowserExportParseResult {
|
|
20
|
+
records: BrowserExportRecord[];
|
|
21
|
+
failures: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const HTML_TOKEN_PATTERN =
|
|
25
|
+
/<H3\b[^>]*>([\s\S]*?)<\/H3\s*>|<A\b([^>]*)>([\s\S]*?)<\/A\s*>|<DL\b[^>]*>|<\/DL\s*>/gi;
|
|
26
|
+
const ATTRIBUTE_PATTERN = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
|
27
|
+
const MAX_JSON_DEPTH = 32;
|
|
28
|
+
const WEBKIT_TO_UNIX_EPOCH_MS = 11_644_473_600_000;
|
|
29
|
+
const SENSITIVE_JSON_KEYS = new Set([
|
|
30
|
+
"cookies",
|
|
31
|
+
"passwords",
|
|
32
|
+
"logins",
|
|
33
|
+
"sessions",
|
|
34
|
+
"creditcards",
|
|
35
|
+
]);
|
|
36
|
+
const SUPPORTED_JSON_KEYS = new Set([
|
|
37
|
+
"roots",
|
|
38
|
+
"children",
|
|
39
|
+
"items",
|
|
40
|
+
"bookmarks",
|
|
41
|
+
"history",
|
|
42
|
+
"readingList",
|
|
43
|
+
"reading_list",
|
|
44
|
+
]);
|
|
45
|
+
const BOOKMARK_EXPORT_DOCUMENT_PATTERN =
|
|
46
|
+
/<!DOCTYPE\s+(?:NETSCAPE-Bookmark-file-1\b[^>]*|html\s*)>/i;
|
|
47
|
+
|
|
48
|
+
const plainText = (value: string): string =>
|
|
49
|
+
htmlFragmentToText(value).replace(/\s+/g, " ").trim();
|
|
50
|
+
|
|
51
|
+
const attributes = (value: string): Map<string, string> => {
|
|
52
|
+
const result = new Map<string, string>();
|
|
53
|
+
for (const match of value.matchAll(ATTRIBUTE_PATTERN)) {
|
|
54
|
+
const key = match[1]?.toLowerCase();
|
|
55
|
+
const item = match[2] ?? match[3] ?? match[4];
|
|
56
|
+
if (key && item !== undefined)
|
|
57
|
+
result.set(key, decodeHtmlEntitiesOnce(item));
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const hasSensitiveJsonKey = (value: Record<string, unknown>): boolean =>
|
|
63
|
+
Object.keys(value).some((key) => SENSITIVE_JSON_KEYS.has(key.toLowerCase()));
|
|
64
|
+
|
|
65
|
+
const tagCount = (source: string, pattern: RegExp): number =>
|
|
66
|
+
source.match(pattern)?.length ?? 0;
|
|
67
|
+
|
|
68
|
+
const hasCompleteNetscapeStructure = (source: string): boolean => {
|
|
69
|
+
if (!BOOKMARK_EXPORT_DOCUMENT_PATTERN.test(source)) return false;
|
|
70
|
+
const openingDl = tagCount(source, /<DL\b[^>]*>/gi);
|
|
71
|
+
const closingDl = tagCount(source, /<\/DL\s*>/gi);
|
|
72
|
+
const openingAnchors = tagCount(source, /<A\b[^>]*>/gi);
|
|
73
|
+
const closingAnchors = tagCount(source, /<\/A\s*>/gi);
|
|
74
|
+
const openingHeadings = tagCount(source, /<H3\b[^>]*>/gi);
|
|
75
|
+
const closingHeadings = tagCount(source, /<\/H3\s*>/gi);
|
|
76
|
+
return (
|
|
77
|
+
openingDl > 0 &&
|
|
78
|
+
openingDl === closingDl &&
|
|
79
|
+
openingAnchors === closingAnchors &&
|
|
80
|
+
openingHeadings === closingHeadings
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const exportDate = (value: string | undefined): string | undefined => {
|
|
85
|
+
if (!value) return undefined;
|
|
86
|
+
const epoch = Number(value);
|
|
87
|
+
if (Number.isFinite(epoch) && epoch >= 0) {
|
|
88
|
+
let milliseconds: number;
|
|
89
|
+
if (epoch >= 10_000_000_000_000_000) {
|
|
90
|
+
milliseconds = epoch / 1_000 - WEBKIT_TO_UNIX_EPOCH_MS;
|
|
91
|
+
} else {
|
|
92
|
+
milliseconds = epoch > 10_000_000_000 ? epoch : epoch * 1_000;
|
|
93
|
+
}
|
|
94
|
+
const date = new Date(milliseconds);
|
|
95
|
+
if (!Number.isNaN(date.getTime())) return date.toISOString();
|
|
96
|
+
}
|
|
97
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
|
98
|
+
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value)) return undefined;
|
|
99
|
+
const date = new Date(value);
|
|
100
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function parseNetscapeBookmarks(
|
|
104
|
+
source: string,
|
|
105
|
+
maxRecords: number,
|
|
106
|
+
maxFailures = 1_000
|
|
107
|
+
): BrowserExportParseResult {
|
|
108
|
+
const records: BrowserExportRecord[] = [];
|
|
109
|
+
const failures: string[] = [];
|
|
110
|
+
const folders: string[] = [];
|
|
111
|
+
let pendingFolder: string | undefined;
|
|
112
|
+
let anchorNumber = 0;
|
|
113
|
+
let failureCapReached = false;
|
|
114
|
+
const addFailure = (locator: string): boolean => {
|
|
115
|
+
if (failureCapReached) return true;
|
|
116
|
+
if (failures.length < maxFailures) failures.push(locator);
|
|
117
|
+
if (failures.length < maxFailures) return false;
|
|
118
|
+
failures.push("failure-limit");
|
|
119
|
+
failureCapReached = true;
|
|
120
|
+
return failureCapReached;
|
|
121
|
+
};
|
|
122
|
+
if (!hasCompleteNetscapeStructure(source)) {
|
|
123
|
+
addFailure("unsupported-html");
|
|
124
|
+
return { records, failures };
|
|
125
|
+
}
|
|
126
|
+
let dlDepth = 0;
|
|
127
|
+
for (const token of source.matchAll(HTML_TOKEN_PATTERN)) {
|
|
128
|
+
const raw = token[0].toLowerCase();
|
|
129
|
+
if (token[1] !== undefined) {
|
|
130
|
+
pendingFolder = plainText(token[1]);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (raw.startsWith("<dl")) {
|
|
134
|
+
dlDepth += 1;
|
|
135
|
+
if (pendingFolder) folders.push(pendingFolder);
|
|
136
|
+
pendingFolder = undefined;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (raw.startsWith("</dl")) {
|
|
140
|
+
if (dlDepth === 0) {
|
|
141
|
+
addFailure("html-structure");
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
dlDepth -= 1;
|
|
145
|
+
folders.pop();
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (token[2] === undefined || token[3] === undefined) continue;
|
|
149
|
+
anchorNumber += 1;
|
|
150
|
+
if (records.length >= maxRecords) {
|
|
151
|
+
addFailure(`bookmark:${anchorNumber}/record-limit`);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
const attrs = attributes(token[2]);
|
|
155
|
+
const url = attrs.get("href");
|
|
156
|
+
if (!url) {
|
|
157
|
+
if (addFailure(`bookmark:${anchorNumber}`)) break;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const rawAdded = attrs.get("add_date");
|
|
161
|
+
const rawModified = attrs.get("last_modified");
|
|
162
|
+
const added = exportDate(rawAdded);
|
|
163
|
+
const modified = exportDate(rawModified);
|
|
164
|
+
if ((rawAdded && !added) || (rawModified && !modified)) {
|
|
165
|
+
if (addFailure(`bookmark:${anchorNumber}/date`)) break;
|
|
166
|
+
}
|
|
167
|
+
records.push({
|
|
168
|
+
kind: "bookmark",
|
|
169
|
+
url,
|
|
170
|
+
title: plainText(token[3]),
|
|
171
|
+
folder: folders.join("/"),
|
|
172
|
+
tags: attrs
|
|
173
|
+
.get("tags")
|
|
174
|
+
?.split(",")
|
|
175
|
+
.map((tag) => tag.trim())
|
|
176
|
+
.filter(Boolean),
|
|
177
|
+
dates:
|
|
178
|
+
added || modified
|
|
179
|
+
? {
|
|
180
|
+
...(added ? { added } : {}),
|
|
181
|
+
...(modified ? { modified } : {}),
|
|
182
|
+
}
|
|
183
|
+
: undefined,
|
|
184
|
+
sourceLocator: `bookmark:${anchorNumber}`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (dlDepth !== 0 && !failureCapReached) addFailure("html-structure");
|
|
188
|
+
return { records, failures };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const stringValue = (
|
|
192
|
+
value: Record<string, unknown>,
|
|
193
|
+
keys: string[]
|
|
194
|
+
): string | undefined => {
|
|
195
|
+
for (const key of keys) {
|
|
196
|
+
if (typeof value[key] === "string") return value[key] as string;
|
|
197
|
+
if (typeof value[key] === "number") return String(value[key]);
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const kindValue = (value: Record<string, unknown>): BrowserExportKind => {
|
|
203
|
+
const raw = stringValue(value, ["kind", "type"])?.toLowerCase();
|
|
204
|
+
if (raw?.includes("history") || raw === "visit") return "history";
|
|
205
|
+
if (raw?.includes("reading") || raw === "read") return "reading-list";
|
|
206
|
+
return "bookmark";
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const itemRecord = (
|
|
210
|
+
value: Record<string, unknown>,
|
|
211
|
+
locator: string,
|
|
212
|
+
folders: string[]
|
|
213
|
+
): BrowserExportRecord | undefined => {
|
|
214
|
+
const url = stringValue(value, ["url", "href", "uri"]);
|
|
215
|
+
if (!url) return undefined;
|
|
216
|
+
const tags = Array.isArray(value.tags)
|
|
217
|
+
? value.tags.filter((tag): tag is string => typeof tag === "string")
|
|
218
|
+
: typeof value.tags === "string"
|
|
219
|
+
? value.tags.split(",").map((tag) => tag.trim())
|
|
220
|
+
: undefined;
|
|
221
|
+
const rawAdded = stringValue(value, ["date_added", "dateAdded", "added"]);
|
|
222
|
+
const rawVisited = stringValue(value, [
|
|
223
|
+
"last_visit_time",
|
|
224
|
+
"lastVisitTime",
|
|
225
|
+
"visited_at",
|
|
226
|
+
]);
|
|
227
|
+
const rawRead = stringValue(value, ["read_at", "readAt"]);
|
|
228
|
+
const added = exportDate(rawAdded);
|
|
229
|
+
const visited = exportDate(rawVisited);
|
|
230
|
+
const read = exportDate(rawRead);
|
|
231
|
+
if ((rawAdded && !added) || (rawVisited && !visited) || (rawRead && !read)) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
const dateEntries: Array<[string, string]> = [];
|
|
235
|
+
if (added) dateEntries.push(["added", added]);
|
|
236
|
+
if (visited) dateEntries.push(["visited", visited]);
|
|
237
|
+
if (read) dateEntries.push(["read", read]);
|
|
238
|
+
return {
|
|
239
|
+
kind: kindValue(value),
|
|
240
|
+
url,
|
|
241
|
+
title: stringValue(value, ["name", "title"]),
|
|
242
|
+
folder: stringValue(value, ["folder", "path"]) ?? folders.join("/"),
|
|
243
|
+
tags,
|
|
244
|
+
dates: dateEntries.length > 0 ? Object.fromEntries(dateEntries) : undefined,
|
|
245
|
+
externalId: stringValue(value, ["id", "guid", "uuid"]),
|
|
246
|
+
sourceLocator: locator,
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
export function parseBrowserJson(
|
|
251
|
+
source: string,
|
|
252
|
+
maxRecords: number,
|
|
253
|
+
maxFailures = 1_000
|
|
254
|
+
): BrowserExportParseResult {
|
|
255
|
+
let parsed: unknown;
|
|
256
|
+
try {
|
|
257
|
+
parsed = JSON.parse(source);
|
|
258
|
+
} catch {
|
|
259
|
+
return { records: [], failures: ["json"] };
|
|
260
|
+
}
|
|
261
|
+
if (!Array.isArray(parsed) && (!parsed || typeof parsed !== "object")) {
|
|
262
|
+
return { records: [], failures: ["unsupported-json"] };
|
|
263
|
+
}
|
|
264
|
+
if (!Array.isArray(parsed)) {
|
|
265
|
+
const rootKeys = Object.keys(parsed as Record<string, unknown>);
|
|
266
|
+
if (hasSensitiveJsonKey(parsed as Record<string, unknown>)) {
|
|
267
|
+
return { records: [], failures: ["sensitive-json"] };
|
|
268
|
+
}
|
|
269
|
+
if (!rootKeys.some((key) => SUPPORTED_JSON_KEYS.has(key))) {
|
|
270
|
+
return { records: [], failures: ["unsupported-json"] };
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const records: BrowserExportRecord[] = [];
|
|
274
|
+
const failures: string[] = [];
|
|
275
|
+
let capReached = false;
|
|
276
|
+
let failureCapReached = false;
|
|
277
|
+
const addFailure = (locator: string): boolean => {
|
|
278
|
+
if (failureCapReached) return true;
|
|
279
|
+
if (failures.length < maxFailures) failures.push(locator);
|
|
280
|
+
if (failures.length < maxFailures) return false;
|
|
281
|
+
failures.push("failure-limit");
|
|
282
|
+
failureCapReached = true;
|
|
283
|
+
capReached = true;
|
|
284
|
+
return true;
|
|
285
|
+
};
|
|
286
|
+
const stopAtCap = (locator: string): boolean => {
|
|
287
|
+
if (records.length < maxRecords) return false;
|
|
288
|
+
if (!capReached) addFailure(`${locator}/record-limit`);
|
|
289
|
+
capReached = true;
|
|
290
|
+
return true;
|
|
291
|
+
};
|
|
292
|
+
const visit = (
|
|
293
|
+
value: unknown,
|
|
294
|
+
locator: string,
|
|
295
|
+
folders: string[],
|
|
296
|
+
depth: number
|
|
297
|
+
): void => {
|
|
298
|
+
if (capReached) return;
|
|
299
|
+
if (depth > MAX_JSON_DEPTH) {
|
|
300
|
+
addFailure(locator);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (Array.isArray(value)) {
|
|
304
|
+
for (const [index, child] of value.entries()) {
|
|
305
|
+
if (stopAtCap(`${locator}/${index}`)) break;
|
|
306
|
+
visit(child, `${locator}/${index}`, folders, depth + 1);
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!value || typeof value !== "object") return;
|
|
311
|
+
const object = value as Record<string, unknown>;
|
|
312
|
+
if (hasSensitiveJsonKey(object)) {
|
|
313
|
+
addFailure(`${locator}/sensitive`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const record = itemRecord(object, locator, folders);
|
|
317
|
+
if (record) {
|
|
318
|
+
if (stopAtCap(locator)) return;
|
|
319
|
+
records.push(record);
|
|
320
|
+
}
|
|
321
|
+
const folderName =
|
|
322
|
+
!record && typeof object.name === "string" ? object.name : undefined;
|
|
323
|
+
const nextFolders = folderName ? [...folders, folderName] : folders;
|
|
324
|
+
const supportedChildren = [...SUPPORTED_JSON_KEYS].filter((key) =>
|
|
325
|
+
Object.hasOwn(object, key)
|
|
326
|
+
);
|
|
327
|
+
if (!record && supportedChildren.length === 0) {
|
|
328
|
+
addFailure(locator);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
for (const key of supportedChildren) {
|
|
332
|
+
const child = object[key];
|
|
333
|
+
const validRoots =
|
|
334
|
+
key === "roots" &&
|
|
335
|
+
child !== null &&
|
|
336
|
+
typeof child === "object" &&
|
|
337
|
+
!Array.isArray(child);
|
|
338
|
+
const validItems = key !== "roots" && Array.isArray(child);
|
|
339
|
+
if (!(validRoots || validItems)) {
|
|
340
|
+
if (addFailure(`${locator}/${key}`)) break;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (validRoots) {
|
|
344
|
+
for (const [rootName, root] of Object.entries(
|
|
345
|
+
child as Record<string, unknown>
|
|
346
|
+
)) {
|
|
347
|
+
if (capReached) break;
|
|
348
|
+
visit(root, `${locator}/roots/${rootName}`, nextFolders, depth + 1);
|
|
349
|
+
}
|
|
350
|
+
} else {
|
|
351
|
+
visit(child, `${locator}/${key}`, nextFolders, depth + 1);
|
|
352
|
+
}
|
|
353
|
+
if (capReached) break;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
visit(parsed, "json", [], 0);
|
|
357
|
+
return { records, failures };
|
|
358
|
+
}
|