@gmickel/gno 1.27.1 → 1.29.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.
Files changed (195) hide show
  1. package/README.md +46 -20
  2. package/assets/skill/SKILL.md +59 -1
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.29.0.zip.sha256 +1 -0
  5. package/browser-extension/dist/chunk-b2zm0jjd.js +50 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/browser-extension/dist/preview.html +1 -1
  8. package/browser-extension/dist/service-worker.js +6 -6
  9. package/package.json +2 -2
  10. package/spec/cli.md +82 -5
  11. package/spec/db/schema.sql +31 -0
  12. package/spec/mcp.md +71 -0
  13. package/spec/output-schemas/ask.schema.json +156 -1
  14. package/spec/output-schemas/browser-clip-preview.schema.json +30 -0
  15. package/spec/output-schemas/collection-egress-check.schema.json +91 -0
  16. package/spec/output-schemas/collection-egress-policy-set.schema.json +56 -0
  17. package/spec/output-schemas/collection-egress-policy.schema.json +41 -0
  18. package/spec/output-schemas/context-capsule-v1.schema.json +183 -3
  19. package/spec/output-schemas/context-capsule-verification.schema.json +1 -1
  20. package/spec/output-schemas/egress-audit-management.schema.json +88 -0
  21. package/spec/output-schemas/get.schema.json +94 -0
  22. package/spec/output-schemas/mcp-http-error.schema.json +113 -2
  23. package/spec/output-schemas/mcp-job-status.schema.json +28 -0
  24. package/spec/output-schemas/multi-get.schema.json +128 -0
  25. package/spec/output-schemas/publish-artifact.schema.json +32 -0
  26. package/spec/output-schemas/record-import.schema.json +223 -0
  27. package/spec/output-schemas/retrieval-trace-common.schema.json +31 -0
  28. package/spec/output-schemas/retrieval-trace-export.schema.json +3 -0
  29. package/spec/output-schemas/retrieval-trace-qrels.schema.json +3 -0
  30. package/spec/output-schemas/retrieval-trace-summary.schema.json +4 -0
  31. package/spec/output-schemas/search-result.schema.json +94 -0
  32. package/spec/output-schemas/search-results.schema.json +155 -0
  33. package/spec/project-profile.schema.json +66 -0
  34. package/src/app/context-format.ts +1 -1
  35. package/src/app/context-runtime-contract.ts +16 -7
  36. package/src/cli/commands/ask.ts +7 -0
  37. package/src/cli/commands/collection/index.ts +5 -0
  38. package/src/cli/commands/collection/policy.ts +126 -0
  39. package/src/cli/commands/context-build.ts +5 -0
  40. package/src/cli/commands/daemon.ts +45 -3
  41. package/src/cli/commands/egress-audit.ts +68 -0
  42. package/src/cli/commands/embed.ts +2 -0
  43. package/src/cli/commands/get.ts +12 -2
  44. package/src/cli/commands/index-cmd.ts +13 -0
  45. package/src/cli/commands/multi-get.ts +9 -2
  46. package/src/cli/commands/query.ts +12 -0
  47. package/src/cli/commands/replay.ts +13 -3
  48. package/src/cli/commands/shared.ts +28 -0
  49. package/src/cli/commands/update.ts +5 -0
  50. package/src/cli/commands/vsearch.ts +3 -1
  51. package/src/cli/program.ts +127 -0
  52. package/src/config/index.ts +9 -0
  53. package/src/config/project-profile.ts +2 -0
  54. package/src/config/types.ts +66 -0
  55. package/src/converters/adapters/browser-export/adapter.ts +199 -0
  56. package/src/converters/adapters/browser-export/formats.ts +358 -0
  57. package/src/converters/adapters/email/adapter.ts +429 -0
  58. package/src/converters/adapters/email/html.ts +162 -0
  59. package/src/converters/adapters/email/mime.ts +454 -0
  60. package/src/converters/adapters/email/parameters.ts +77 -0
  61. package/src/converters/adapters/ical/adapter.ts +475 -0
  62. package/src/converters/adapters/ical/recurrence.ts +186 -0
  63. package/src/converters/adapters/jsonl/adapter.ts +238 -0
  64. package/src/converters/adapters/jsonl/config.ts +105 -0
  65. package/src/converters/adapters/shared/html-text.ts +165 -0
  66. package/src/converters/adapters/shared/record-utils.ts +79 -0
  67. package/src/converters/adapters/shared/utf8-lines.ts +141 -0
  68. package/src/converters/adapters/transcript/adapter.ts +295 -0
  69. package/src/converters/adapters/transcript/json.ts +184 -0
  70. package/src/converters/adapters/transcript/model.ts +171 -0
  71. package/src/converters/adapters/transcript/text.ts +58 -0
  72. package/src/converters/adapters/transcript/timed.ts +152 -0
  73. package/src/converters/index.ts +11 -1
  74. package/src/converters/mime.ts +8 -0
  75. package/src/converters/pipeline.ts +15 -1
  76. package/src/converters/registry.ts +37 -1
  77. package/src/converters/types.ts +136 -0
  78. package/src/core/browser-clip.ts +16 -1
  79. package/src/core/collection-egress-policy-projection.ts +28 -0
  80. package/src/core/collection-egress-policy-service.ts +443 -0
  81. package/src/core/collection-egress-policy-validation.ts +242 -0
  82. package/src/core/config-mutation.ts +31 -19
  83. package/src/core/context-capsule-schema.ts +198 -1
  84. package/src/core/context-capsule-validation.ts +4 -4
  85. package/src/core/context-capsule-verification.ts +5 -1
  86. package/src/core/context-capsule.ts +177 -112
  87. package/src/core/context-evidence.ts +93 -15
  88. package/src/core/destination-classifier.ts +402 -0
  89. package/src/core/document-capabilities.ts +12 -0
  90. package/src/core/egress-audit.ts +239 -0
  91. package/src/core/egress-authorization.ts +50 -0
  92. package/src/core/egress-enforcement.ts +264 -0
  93. package/src/core/egress-policy.ts +440 -0
  94. package/src/core/egress-provenance.ts +336 -0
  95. package/src/core/job-manager.ts +37 -0
  96. package/src/core/network-boundary-inventory.ts +261 -0
  97. package/src/core/project-profile-apply-state.ts +5 -0
  98. package/src/core/project-profile.ts +4 -0
  99. package/src/core/record-metadata.ts +49 -0
  100. package/src/core/retrieval-qrels.ts +6 -0
  101. package/src/core/retrieval-replay.ts +18 -4
  102. package/src/core/retrieval-trace-export.ts +23 -4
  103. package/src/core/retrieval-trace-management-helpers.ts +1 -0
  104. package/src/core/retrieval-trace-management-types.ts +3 -0
  105. package/src/core/retrieval-trace-management.ts +9 -1
  106. package/src/core/retrieval-trace-session.ts +94 -0
  107. package/src/core/retrieval-trace.ts +22 -0
  108. package/src/ingestion/record-adapter-canonical.ts +433 -0
  109. package/src/ingestion/record-adapter.ts +448 -0
  110. package/src/ingestion/record-container.ts +704 -0
  111. package/src/ingestion/record-path.ts +20 -0
  112. package/src/ingestion/record-sync.ts +70 -0
  113. package/src/ingestion/sync.ts +243 -36
  114. package/src/ingestion/types.ts +71 -1
  115. package/src/ingestion/walker.ts +31 -11
  116. package/src/llm/errors.ts +10 -0
  117. package/src/llm/http-inference.ts +175 -0
  118. package/src/llm/http-policy.ts +537 -0
  119. package/src/llm/httpEmbedding.ts +47 -28
  120. package/src/llm/httpGeneration.ts +31 -18
  121. package/src/llm/httpRerank.ts +30 -18
  122. package/src/llm/index.ts +1 -0
  123. package/src/llm/nodeLlamaCpp/adapter.ts +55 -20
  124. package/src/llm/pinned-http-connection.ts +177 -0
  125. package/src/mcp/context.ts +53 -1
  126. package/src/mcp/http-egress.ts +220 -0
  127. package/src/mcp/http-security.ts +32 -21
  128. package/src/mcp/http-session.ts +13 -2
  129. package/src/mcp/http-transport.ts +96 -6
  130. package/src/mcp/sync-egress.ts +24 -0
  131. package/src/mcp/tools/add-collection.ts +4 -0
  132. package/src/mcp/tools/ask.ts +1 -1
  133. package/src/mcp/tools/context.ts +9 -2
  134. package/src/mcp/tools/egress.ts +247 -0
  135. package/src/mcp/tools/embed.ts +1 -0
  136. package/src/mcp/tools/get.ts +10 -2
  137. package/src/mcp/tools/index-cmd.ts +7 -0
  138. package/src/mcp/tools/index.ts +98 -0
  139. package/src/mcp/tools/multi-get.ts +9 -2
  140. package/src/mcp/tools/query.ts +10 -0
  141. package/src/mcp/tools/remove-collection.ts +4 -0
  142. package/src/mcp/tools/sync.ts +6 -0
  143. package/src/mcp/tools/trace.ts +7 -1
  144. package/src/mcp/tools/vsearch.ts +1 -0
  145. package/src/mcp/tools/workspace-write.ts +2 -0
  146. package/src/pipeline/egress-lineage.ts +124 -0
  147. package/src/pipeline/filters.ts +1 -1
  148. package/src/pipeline/graph-retrieval.ts +7 -4
  149. package/src/pipeline/hybrid.ts +21 -4
  150. package/src/pipeline/result-context.ts +12 -4
  151. package/src/pipeline/search.ts +23 -4
  152. package/src/pipeline/types.ts +6 -0
  153. package/src/pipeline/vsearch.ts +67 -28
  154. package/src/publish/artifact.ts +31 -3
  155. package/src/publish/export-service.ts +25 -0
  156. package/src/sdk/client.ts +119 -2
  157. package/src/sdk/documents.ts +13 -5
  158. package/src/sdk/embed.ts +1 -0
  159. package/src/sdk/types.ts +33 -0
  160. package/src/serve/browse-tree.ts +4 -2
  161. package/src/serve/clipper-capture.ts +5 -1
  162. package/src/serve/closed-json.ts +61 -0
  163. package/src/serve/config-sync.ts +4 -1
  164. package/src/serve/context.ts +1 -0
  165. package/src/serve/doc-events.ts +110 -39
  166. package/src/serve/public/components/CollectionEgressCheckPanel.tsx +411 -0
  167. package/src/serve/public/components/CollectionEgressPolicyEditor.tsx +78 -0
  168. package/src/serve/public/components/CollectionModelDialog.tsx +93 -9
  169. package/src/serve/public/components/EgressAuditPanel.tsx +278 -0
  170. package/src/serve/public/globals.built.css +1 -1
  171. package/src/serve/public/pages/Collections.tsx +3 -0
  172. package/src/serve/resident-request.ts +80 -3
  173. package/src/serve/resident-runtime.ts +63 -1
  174. package/src/serve/routes/api.ts +237 -65
  175. package/src/serve/routes/clipper.ts +64 -1
  176. package/src/serve/routes/mcp.ts +6 -0
  177. package/src/serve/routes/traces.ts +27 -4
  178. package/src/serve/server.ts +118 -7
  179. package/src/store/migrations/022-record-export-lineage.ts +42 -0
  180. package/src/store/migrations/023-collection-egress-policy.ts +61 -0
  181. package/src/store/migrations/024-egress-derived-lineage.ts +121 -0
  182. package/src/store/migrations/025-collection-egress-policy-revision.ts +40 -0
  183. package/src/store/migrations/index.ts +8 -0
  184. package/src/store/retrieval-trace-codec.ts +15 -0
  185. package/src/store/sqlite/adapter.ts +284 -14
  186. package/src/store/sqlite/change-journal-store.ts +41 -2
  187. package/src/store/sqlite/egress-audit-store.ts +485 -0
  188. package/src/store/sqlite/retrieval-trace-management-store.ts +47 -6
  189. package/src/store/sqlite/retrieval-trace-retention.ts +2 -0
  190. package/src/store/sqlite/retrieval-trace-rows.ts +8 -1
  191. package/src/store/sqlite/retrieval-trace-store.ts +104 -5
  192. package/src/store/types.ts +166 -1
  193. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip +0 -0
  194. package/browser-extension/artifacts/gno-browser-clipper-v1.27.1.zip.sha256 +0 -1
  195. package/browser-extension/dist/chunk-vn5f663b.js +0 -50
@@ -0,0 +1,199 @@
1
+ import type {
2
+ RecordAdapter,
3
+ RecordAdapterEvent,
4
+ RecordAdapterInput,
5
+ } from "../../types";
6
+
7
+ import {
8
+ type BrowserExportRecord,
9
+ parseBrowserJson,
10
+ parseNetscapeBookmarks,
11
+ } from "./formats";
12
+
13
+ const LIVE_PROFILE_MARKERS = [
14
+ "/library/safari/",
15
+ "/chrome/user data/",
16
+ "/google/chrome/",
17
+ "/chromium/user data/",
18
+ "/.config/google-chrome/",
19
+ "/.config/chromium/",
20
+ "/appdata/local/google/chrome/user data/",
21
+ "/firefox/profiles/",
22
+ "/.mozilla/firefox/",
23
+ "/mozilla/firefox/",
24
+ "/appdata/roaming/mozilla/firefox/profiles/",
25
+ ];
26
+
27
+ const safeMarkdown = (value: string): string =>
28
+ value
29
+ .normalize("NFC")
30
+ .replaceAll("&", "&")
31
+ .replaceAll("<", "&lt;")
32
+ .replaceAll(">", "&gt;")
33
+ .replace(/([\\`*_[\]#])/g, "\\$1")
34
+ .trim();
35
+
36
+ const liveProfileReason = (input: RecordAdapterInput): string | undefined => {
37
+ const normalized = input.sourcePath.replaceAll("\\", "/").toLowerCase();
38
+ if (LIVE_PROFILE_MARKERS.some((marker) => normalized.includes(marker))) {
39
+ return "live-profile";
40
+ }
41
+ const basename = normalized.split("/").at(-1);
42
+ if (
43
+ basename === "cookies" ||
44
+ basename === "history" ||
45
+ basename?.endsWith(".sqlite") ||
46
+ basename?.endsWith(".db")
47
+ ) {
48
+ return "live-database";
49
+ }
50
+ return undefined;
51
+ };
52
+
53
+ const normalizeUrl = (value: string): string | undefined => {
54
+ try {
55
+ const url = new URL(value.trim());
56
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
57
+ return undefined;
58
+ }
59
+ if (url.username || url.password) return undefined;
60
+ url.hash = "";
61
+ return url.toString();
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ };
66
+
67
+ const readSource = async (input: RecordAdapterInput): Promise<string> => {
68
+ const decoder = new TextDecoder("utf-8", { fatal: true });
69
+ let source = "";
70
+ for await (const chunk of input.open()) {
71
+ source += decoder.decode(chunk, { stream: true });
72
+ }
73
+ return source + decoder.decode();
74
+ };
75
+
76
+ const recordEvent = (
77
+ record: BrowserExportRecord
78
+ ): RecordAdapterEvent | undefined => {
79
+ const url = normalizeUrl(record.url);
80
+ if (!url) return undefined;
81
+ const title = safeMarkdown(record.title || url);
82
+ const folder = record.folder ? safeMarkdown(record.folder) : undefined;
83
+ const tags = record.tags?.map(safeMarkdown).filter(Boolean);
84
+ const lines = [`# ${title}`, "", url];
85
+ if (folder) lines.push("", `Folder: ${folder}`);
86
+ if (tags && tags.length > 0) lines.push(`Tags: ${tags.join(", ")}`);
87
+ for (const [name, value] of Object.entries(record.dates ?? {}).sort(
88
+ ([left], [right]) => left.localeCompare(right)
89
+ )) {
90
+ lines.push(`${safeMarkdown(name)}: ${safeMarkdown(value)}`);
91
+ }
92
+ const identity = record.externalId
93
+ ? `${record.kind}:id:${record.externalId}`
94
+ : `${record.kind}:url:${url}`;
95
+ return {
96
+ type: "record",
97
+ record: {
98
+ stableId: `browser:${identity}`,
99
+ sourceLocator: record.sourceLocator,
100
+ markdown: lines.join("\n"),
101
+ title,
102
+ metadata: {
103
+ categories: ["browser-export", record.kind, ...(tags ?? [])],
104
+ dateFields: record.dates,
105
+ },
106
+ anchors: [{ kind: "record", value: record.sourceLocator }],
107
+ },
108
+ };
109
+ };
110
+
111
+ async function* parseBrowserExport(
112
+ input: RecordAdapterInput
113
+ ): AsyncGenerator<RecordAdapterEvent> {
114
+ const denied = liveProfileReason(input);
115
+ if (denied) {
116
+ yield {
117
+ type: "failure",
118
+ failure: {
119
+ code: "ADAPTER_FAILURE",
120
+ message: "Live browser profile access is not allowed.",
121
+ retryable: false,
122
+ sourceLocator: denied,
123
+ },
124
+ };
125
+ yield { type: "snapshot", state: "partial" };
126
+ return;
127
+ }
128
+
129
+ let source: string;
130
+ try {
131
+ source = await readSource(input);
132
+ } catch {
133
+ yield {
134
+ type: "failure",
135
+ failure: {
136
+ code: "MALFORMED_RECORD",
137
+ message: "Browser export could not be decoded.",
138
+ retryable: true,
139
+ sourceLocator: "export",
140
+ },
141
+ };
142
+ yield { type: "snapshot", state: "partial" };
143
+ return;
144
+ }
145
+ const trimmed = source.trimStart();
146
+ const parsed = trimmed.startsWith("<")
147
+ ? parseNetscapeBookmarks(
148
+ source,
149
+ input.limits.maxRecords,
150
+ input.limits.maxFailures
151
+ )
152
+ : parseBrowserJson(
153
+ source,
154
+ input.limits.maxRecords,
155
+ input.limits.maxFailures
156
+ );
157
+ let failures = parsed.failures.length;
158
+ for (const candidate of parsed.records) {
159
+ const event = recordEvent(candidate);
160
+ if (event) yield event;
161
+ else {
162
+ failures += 1;
163
+ yield {
164
+ type: "failure",
165
+ failure: {
166
+ code: "MALFORMED_RECORD",
167
+ message: "Browser export contained an invalid or unsafe URL.",
168
+ retryable: false,
169
+ sourceLocator: candidate.sourceLocator,
170
+ },
171
+ };
172
+ }
173
+ }
174
+ for (const locator of parsed.failures) {
175
+ yield {
176
+ type: "failure",
177
+ failure: {
178
+ code: "MALFORMED_RECORD",
179
+ message: "Browser export record could not be parsed.",
180
+ retryable: false,
181
+ sourceLocator: locator,
182
+ },
183
+ };
184
+ }
185
+ yield {
186
+ type: "snapshot",
187
+ state: failures === 0 ? "complete" : "partial",
188
+ };
189
+ }
190
+
191
+ export const browserExportAdapter: RecordAdapter = {
192
+ id: "adapter/browser-export",
193
+ version: "1.0.0",
194
+ canHandle: (mime, ext) =>
195
+ mime === "application/x-gno-browser-export+json" ||
196
+ mime === "text/x-gno-browser-bookmarks+html" ||
197
+ ext === ".browser-export",
198
+ records: parseBrowserExport,
199
+ };
@@ -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
+ }