@gmickel/gno 1.24.0 → 1.25.1
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 +9 -1
- package/assets/skill/SKILL.md +11 -0
- package/assets/skill/recipes/capture-and-file.md +20 -5
- package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.25.1.zip.sha256 +1 -0
- package/browser-extension/dist/PRIVACY.md +55 -0
- package/browser-extension/dist/chunk-vn5f663b.js +50 -0
- package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
- package/browser-extension/dist/content.js +1 -0
- package/browser-extension/dist/manifest.json +25 -0
- package/browser-extension/dist/preview.html +13 -0
- package/browser-extension/dist/service-worker.js +40 -0
- package/package.json +13 -3
- package/spec/cli.md +50 -0
- package/spec/db/schema.sql +101 -0
- package/spec/mcp.md +10 -0
- package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
- package/spec/output-schemas/browser-clip.schema.json +586 -0
- package/spec/output-schemas/capture-receipt.schema.json +22 -1
- package/spec/output-schemas/clipper-csrf.schema.json +12 -0
- package/spec/output-schemas/clipper-error.schema.json +46 -0
- package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
- package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
- package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
- package/spec/output-schemas/clipper-revoke.schema.json +28 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
- package/src/core/browser-clip-provenance.ts +139 -0
- package/src/core/browser-clip.ts +473 -0
- package/src/core/capture-write.ts +5 -0
- package/src/core/capture.ts +75 -18
- package/src/core/file-lock.ts +20 -6
- package/src/serve/capture-service.ts +420 -0
- package/src/serve/clipper-body.ts +62 -0
- package/src/serve/clipper-capture.ts +248 -0
- package/src/serve/clipper-contract.ts +57 -0
- package/src/serve/clipper-idempotency.ts +35 -0
- package/src/serve/clipper-pairing.ts +297 -0
- package/src/serve/clipper-security-errors.ts +23 -0
- package/src/serve/clipper-security.ts +449 -0
- package/src/serve/public/app.tsx +8 -1
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/index.html +1 -0
- package/src/serve/public/lib/clipper-approval.ts +206 -0
- package/src/serve/public/pages/ClipperPairing.tsx +210 -0
- package/src/serve/routes/api.ts +19 -115
- package/src/serve/routes/clipper.ts +394 -0
- package/src/serve/server.ts +22 -0
- package/src/store/migrations/020-browser-clipper-security.ts +128 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/clipper-store-types.ts +104 -0
- package/src/store/sqlite/clipper-store.ts +496 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import { canonicalize } from "../converters/canonicalize";
|
|
4
|
+
import {
|
|
5
|
+
BROWSER_CLIP_MAX_BYTES,
|
|
6
|
+
BROWSER_CLIP_SCHEMA_VERSION,
|
|
7
|
+
BROWSER_CLIP_WARNING_CODES,
|
|
8
|
+
browserClipHttpUrlSchema,
|
|
9
|
+
findDisallowedBrowserClipControlPath,
|
|
10
|
+
type BrowserClipProvenance,
|
|
11
|
+
type BrowserClipWarningCode,
|
|
12
|
+
} from "./browser-clip-provenance";
|
|
13
|
+
import {
|
|
14
|
+
hashCaptureContent,
|
|
15
|
+
type CaptureInput,
|
|
16
|
+
type CaptureSource,
|
|
17
|
+
} from "./capture";
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
BROWSER_CLIP_MAX_BYTES,
|
|
21
|
+
BROWSER_CLIP_SCHEMA_VERSION,
|
|
22
|
+
} from "./browser-clip-provenance";
|
|
23
|
+
export type {
|
|
24
|
+
BrowserClipProvenance,
|
|
25
|
+
BrowserClipWarningCode,
|
|
26
|
+
} from "./browser-clip-provenance";
|
|
27
|
+
|
|
28
|
+
const sha256 = (value: string): string =>
|
|
29
|
+
new Bun.CryptoHasher("sha256").update(value).digest("hex");
|
|
30
|
+
|
|
31
|
+
const stableJsonValue = (value: unknown): unknown => {
|
|
32
|
+
if (Array.isArray(value)) return value.map(stableJsonValue);
|
|
33
|
+
if (value !== null && typeof value === "object") {
|
|
34
|
+
return Object.fromEntries(
|
|
35
|
+
Object.entries(value as Record<string, unknown>)
|
|
36
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
37
|
+
.map(([key, child]) => [key, stableJsonValue(child)])
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const stableJson = (value: unknown): string =>
|
|
44
|
+
JSON.stringify(stableJsonValue(value));
|
|
45
|
+
|
|
46
|
+
const strictDateTime = z.string().datetime({ offset: true });
|
|
47
|
+
const strictPublishedDate = z
|
|
48
|
+
.union([z.string().date(), z.string().datetime({ offset: true })])
|
|
49
|
+
.nullable();
|
|
50
|
+
|
|
51
|
+
const safeInlineText = z.string().min(1).max(32_768);
|
|
52
|
+
const inlineNodeSchema = z.discriminatedUnion("type", [
|
|
53
|
+
z.object({ type: z.literal("text"), text: safeInlineText }).strict(),
|
|
54
|
+
z
|
|
55
|
+
.object({
|
|
56
|
+
type: z.literal("link"),
|
|
57
|
+
text: safeInlineText,
|
|
58
|
+
href: browserClipHttpUrlSchema,
|
|
59
|
+
})
|
|
60
|
+
.strict(),
|
|
61
|
+
]);
|
|
62
|
+
const inlineContentSchema = z.array(inlineNodeSchema).min(1).max(256);
|
|
63
|
+
|
|
64
|
+
const readerBlockSchema = z.discriminatedUnion("type", [
|
|
65
|
+
z
|
|
66
|
+
.object({ type: z.literal("paragraph"), content: inlineContentSchema })
|
|
67
|
+
.strict(),
|
|
68
|
+
z
|
|
69
|
+
.object({
|
|
70
|
+
type: z.literal("heading"),
|
|
71
|
+
level: z.number().int().min(1).max(6),
|
|
72
|
+
content: inlineContentSchema,
|
|
73
|
+
})
|
|
74
|
+
.strict(),
|
|
75
|
+
z.object({ type: z.literal("quote"), content: inlineContentSchema }).strict(),
|
|
76
|
+
z
|
|
77
|
+
.object({
|
|
78
|
+
type: z.literal("list"),
|
|
79
|
+
ordered: z.boolean(),
|
|
80
|
+
items: z.array(inlineContentSchema).min(1).max(256),
|
|
81
|
+
})
|
|
82
|
+
.strict(),
|
|
83
|
+
z
|
|
84
|
+
.object({
|
|
85
|
+
type: z.literal("code"),
|
|
86
|
+
language: z
|
|
87
|
+
.string()
|
|
88
|
+
.regex(/^[A-Za-z0-9_+-]{1,32}$/)
|
|
89
|
+
.nullable(),
|
|
90
|
+
text: z.string().max(131_072),
|
|
91
|
+
})
|
|
92
|
+
.strict(),
|
|
93
|
+
z.object({ type: z.literal("horizontal_rule") }).strict(),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const commonPayload = z
|
|
97
|
+
.object({
|
|
98
|
+
schemaVersion: z.literal(BROWSER_CLIP_SCHEMA_VERSION),
|
|
99
|
+
sourceUrl: browserClipHttpUrlSchema,
|
|
100
|
+
canonicalUrl: browserClipHttpUrlSchema.nullable(),
|
|
101
|
+
title: z.string().min(1).max(2048),
|
|
102
|
+
author: z.string().max(1024).nullable(),
|
|
103
|
+
site: z.string().max(1024).nullable(),
|
|
104
|
+
publishedAt: strictPublishedDate,
|
|
105
|
+
observedAt: strictDateTime,
|
|
106
|
+
browser: z
|
|
107
|
+
.object({
|
|
108
|
+
name: z.string().min(1).max(128),
|
|
109
|
+
version: z.string().max(128).nullable(),
|
|
110
|
+
platform: z.string().max(128).nullable(),
|
|
111
|
+
})
|
|
112
|
+
.strict(),
|
|
113
|
+
extraction: z
|
|
114
|
+
.object({
|
|
115
|
+
visibility: z.literal("user_visible"),
|
|
116
|
+
authenticated: z.boolean(),
|
|
117
|
+
extractorVersion: z.string().min(1).max(128),
|
|
118
|
+
warnings: z.array(z.enum(BROWSER_CLIP_WARNING_CODES)).max(16),
|
|
119
|
+
})
|
|
120
|
+
.strict(),
|
|
121
|
+
destination: z
|
|
122
|
+
.object({
|
|
123
|
+
collection: z.string().min(1).max(128),
|
|
124
|
+
relPath: z.string().min(1).max(2048).nullable(),
|
|
125
|
+
folderPath: z.string().min(1).max(2048).nullable(),
|
|
126
|
+
collisionPolicy: z.enum([
|
|
127
|
+
"error",
|
|
128
|
+
"open_existing",
|
|
129
|
+
"create_with_suffix",
|
|
130
|
+
]),
|
|
131
|
+
})
|
|
132
|
+
.strict(),
|
|
133
|
+
tags: z.array(z.string().min(1).max(256)).max(128),
|
|
134
|
+
note: z.string().max(32_768).nullable(),
|
|
135
|
+
})
|
|
136
|
+
.strict();
|
|
137
|
+
|
|
138
|
+
const selectionPayload = commonPayload
|
|
139
|
+
.extend({
|
|
140
|
+
mode: z.literal("selection"),
|
|
141
|
+
selection: z
|
|
142
|
+
.object({
|
|
143
|
+
exactText: z.string().min(1).max(BROWSER_CLIP_MAX_BYTES),
|
|
144
|
+
editedMarkdown: z.string().max(BROWSER_CLIP_MAX_BYTES).nullable(),
|
|
145
|
+
})
|
|
146
|
+
.strict(),
|
|
147
|
+
})
|
|
148
|
+
.strict();
|
|
149
|
+
|
|
150
|
+
const readerPayload = commonPayload
|
|
151
|
+
.extend({
|
|
152
|
+
mode: z.literal("reader"),
|
|
153
|
+
reader: z
|
|
154
|
+
.object({
|
|
155
|
+
blocks: z.array(readerBlockSchema).min(1).max(4096),
|
|
156
|
+
editedMarkdown: z.string().max(BROWSER_CLIP_MAX_BYTES).nullable(),
|
|
157
|
+
})
|
|
158
|
+
.strict(),
|
|
159
|
+
})
|
|
160
|
+
.strict();
|
|
161
|
+
|
|
162
|
+
export const browserClipPayloadSchema = z
|
|
163
|
+
.discriminatedUnion("mode", [selectionPayload, readerPayload])
|
|
164
|
+
.superRefine((value, context) => {
|
|
165
|
+
const controlPath = findDisallowedBrowserClipControlPath(value);
|
|
166
|
+
if (controlPath !== null) {
|
|
167
|
+
context.addIssue({
|
|
168
|
+
code: "custom",
|
|
169
|
+
message: "Browser clip text cannot contain C0 or C1 control characters",
|
|
170
|
+
path: controlPath,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
new TextEncoder().encode(stableJson(value)).byteLength >
|
|
175
|
+
BROWSER_CLIP_MAX_BYTES
|
|
176
|
+
) {
|
|
177
|
+
context.addIssue({
|
|
178
|
+
code: "custom",
|
|
179
|
+
message: "Browser clip payload is too large",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (
|
|
183
|
+
new Set(value.extraction.warnings).size !==
|
|
184
|
+
value.extraction.warnings.length
|
|
185
|
+
) {
|
|
186
|
+
context.addIssue({
|
|
187
|
+
code: "custom",
|
|
188
|
+
message: "Warning codes must be unique",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (
|
|
192
|
+
value.destination.relPath !== null &&
|
|
193
|
+
value.destination.folderPath !== null
|
|
194
|
+
) {
|
|
195
|
+
context.addIssue({
|
|
196
|
+
code: "custom",
|
|
197
|
+
message: "Use relPath or folderPath, not both",
|
|
198
|
+
path: ["destination"],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
export type BrowserClipPayload = z.infer<typeof browserClipPayloadSchema>;
|
|
204
|
+
export type BrowserClipBlock = z.infer<typeof readerBlockSchema>;
|
|
205
|
+
|
|
206
|
+
export interface PreparedBrowserClip {
|
|
207
|
+
payload: BrowserClipPayload;
|
|
208
|
+
captureInput: CaptureInput;
|
|
209
|
+
provenance: BrowserClipProvenance;
|
|
210
|
+
preview: {
|
|
211
|
+
body: string;
|
|
212
|
+
digest: string;
|
|
213
|
+
source: CaptureSource;
|
|
214
|
+
destination: BrowserClipPayload["destination"];
|
|
215
|
+
tags: string[];
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const normalizeUrl = (value: string): string => {
|
|
220
|
+
const url = new URL(value);
|
|
221
|
+
url.hash = "";
|
|
222
|
+
url.searchParams.sort();
|
|
223
|
+
return url.toString();
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const normalizePublishedAt = (value: string | null): string | null => {
|
|
227
|
+
if (value === null || /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
|
228
|
+
return new Date(value).toISOString();
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const escapeInline = (value: string): string =>
|
|
232
|
+
value
|
|
233
|
+
.replaceAll("<", "<")
|
|
234
|
+
.replaceAll(">", ">")
|
|
235
|
+
.replace(/([\\*_[\]~`])/g, "\\$1");
|
|
236
|
+
|
|
237
|
+
const normalizeLink = (value: string): string =>
|
|
238
|
+
normalizeUrl(value).replaceAll("(", "%28").replaceAll(")", "%29");
|
|
239
|
+
|
|
240
|
+
const renderInline = (content: z.infer<typeof inlineContentSchema>): string =>
|
|
241
|
+
content
|
|
242
|
+
.map((node) =>
|
|
243
|
+
node.type === "text"
|
|
244
|
+
? escapeInline(node.text)
|
|
245
|
+
: `[${escapeInline(node.text)}](${normalizeLink(node.href)})`
|
|
246
|
+
)
|
|
247
|
+
.join("");
|
|
248
|
+
|
|
249
|
+
const codeFence = (text: string): string => {
|
|
250
|
+
const longest = Math.max(
|
|
251
|
+
0,
|
|
252
|
+
...[...text.matchAll(/`+/g)].map((match) => match[0].length)
|
|
253
|
+
);
|
|
254
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export const renderBrowserClipReader = (
|
|
258
|
+
blocks: readonly BrowserClipBlock[]
|
|
259
|
+
): string =>
|
|
260
|
+
canonicalize(
|
|
261
|
+
blocks
|
|
262
|
+
.map((block) => {
|
|
263
|
+
switch (block.type) {
|
|
264
|
+
case "paragraph":
|
|
265
|
+
return renderInline(block.content);
|
|
266
|
+
case "heading":
|
|
267
|
+
return `${"#".repeat(block.level)} ${renderInline(block.content)}`;
|
|
268
|
+
case "quote":
|
|
269
|
+
return renderInline(block.content)
|
|
270
|
+
.split("\n")
|
|
271
|
+
.map((line) => `> ${line}`)
|
|
272
|
+
.join("\n");
|
|
273
|
+
case "list":
|
|
274
|
+
return block.items
|
|
275
|
+
.map(
|
|
276
|
+
(item, index) =>
|
|
277
|
+
`${block.ordered ? `${index + 1}.` : "-"} ${renderInline(item)}`
|
|
278
|
+
)
|
|
279
|
+
.join("\n");
|
|
280
|
+
case "code": {
|
|
281
|
+
const fence = codeFence(block.text);
|
|
282
|
+
return `${fence}${block.language ?? ""}\n${block.text}\n${fence}`;
|
|
283
|
+
}
|
|
284
|
+
case "horizontal_rule":
|
|
285
|
+
return "---";
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
.join("\n\n")
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const INLINE_MARKDOWN_LINK = /(?<!!)\[[^\]\r\n]*\]\(([^()\s]+)\)/gu;
|
|
292
|
+
const REFERENCE_LINK = /\[[^\]\r\n]*\]\s*\[[^\]\r\n]*\]/u;
|
|
293
|
+
const REFERENCE_DEFINITION = /^[ \t]{0,3}\[[^\]\r\n]+\]:/mu;
|
|
294
|
+
|
|
295
|
+
const assertSafeEditedMarkdown = (markdown: string): void => {
|
|
296
|
+
if (markdown.includes("<")) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
"Edited clip Markdown cannot contain HTML, comments, or autolinks."
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (markdown.includes(") {
|
|
315
|
+
throw new Error(
|
|
316
|
+
"Edited clip Markdown links must use a simple absolute HTTP(S) destination."
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const notePrefix = (note: string | null): string => {
|
|
322
|
+
const trimmed = note?.trim();
|
|
323
|
+
if (!trimmed) return "";
|
|
324
|
+
return `> **Clip note:** ${escapeInline(trimmed).replaceAll("\n", "\n> ")}\n\n`;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const dedupeWarnings = (
|
|
328
|
+
values: readonly BrowserClipWarningCode[]
|
|
329
|
+
): BrowserClipWarningCode[] => [...new Set(values)].sort();
|
|
330
|
+
|
|
331
|
+
export const prepareBrowserClip = (
|
|
332
|
+
input: unknown,
|
|
333
|
+
options: { now?: Date } = {}
|
|
334
|
+
): PreparedBrowserClip => {
|
|
335
|
+
const payload = browserClipPayloadSchema.parse(input);
|
|
336
|
+
const capturedAt = (options.now ?? new Date()).toISOString();
|
|
337
|
+
const sourceUrl = normalizeUrl(payload.sourceUrl);
|
|
338
|
+
const canonicalUrl =
|
|
339
|
+
payload.canonicalUrl === null ? null : normalizeUrl(payload.canonicalUrl);
|
|
340
|
+
const extracted =
|
|
341
|
+
payload.mode === "selection"
|
|
342
|
+
? payload.selection.exactText
|
|
343
|
+
: stableJson(payload.reader.blocks);
|
|
344
|
+
const edited =
|
|
345
|
+
payload.mode === "selection"
|
|
346
|
+
? payload.selection.editedMarkdown
|
|
347
|
+
: payload.reader.editedMarkdown;
|
|
348
|
+
if (edited !== null) assertSafeEditedMarkdown(edited);
|
|
349
|
+
const uneditedBody =
|
|
350
|
+
payload.mode === "selection"
|
|
351
|
+
? escapeInline(payload.selection.exactText)
|
|
352
|
+
: renderBrowserClipReader(payload.reader.blocks);
|
|
353
|
+
const body = canonicalize(
|
|
354
|
+
`${notePrefix(payload.note)}${edited ?? uneditedBody}`
|
|
355
|
+
);
|
|
356
|
+
const extractionHash = sha256(extracted);
|
|
357
|
+
const finalBodyHash = hashCaptureContent(body);
|
|
358
|
+
const clipIdentity = sha256(
|
|
359
|
+
stableJson({
|
|
360
|
+
canonicalUrl,
|
|
361
|
+
extractionHash,
|
|
362
|
+
finalBodyHash,
|
|
363
|
+
mode: payload.mode,
|
|
364
|
+
schemaVersion: payload.schemaVersion,
|
|
365
|
+
sourceUrl,
|
|
366
|
+
})
|
|
367
|
+
);
|
|
368
|
+
const warnings: BrowserClipWarningCode[] = [...payload.extraction.warnings];
|
|
369
|
+
if (payload.extraction.authenticated)
|
|
370
|
+
warnings.push("authenticated_visible_content");
|
|
371
|
+
if (canonicalUrl !== null && canonicalUrl !== sourceUrl) {
|
|
372
|
+
warnings.push("canonical_url_differs");
|
|
373
|
+
}
|
|
374
|
+
if (edited !== null) warnings.push("edited_content");
|
|
375
|
+
if (extracted.includes("\r")) warnings.push("line_endings_normalized");
|
|
376
|
+
if (extracted.normalize("NFC") !== extracted)
|
|
377
|
+
warnings.push("unicode_normalized");
|
|
378
|
+
const extractionWarnings = dedupeWarnings(warnings);
|
|
379
|
+
const publishedAt = normalizePublishedAt(payload.publishedAt);
|
|
380
|
+
const observedAt = new Date(payload.observedAt).toISOString();
|
|
381
|
+
const exactSelection =
|
|
382
|
+
payload.mode === "selection" ? payload.selection.exactText : null;
|
|
383
|
+
const previewSource = {
|
|
384
|
+
kind: "web",
|
|
385
|
+
title: payload.title,
|
|
386
|
+
url: sourceUrl,
|
|
387
|
+
author: payload.author,
|
|
388
|
+
observedAt,
|
|
389
|
+
canonicalUrl,
|
|
390
|
+
site: payload.site,
|
|
391
|
+
publishedAt,
|
|
392
|
+
};
|
|
393
|
+
const previewProvenance = {
|
|
394
|
+
schemaVersion: payload.schemaVersion,
|
|
395
|
+
mode: payload.mode,
|
|
396
|
+
sourceUrl,
|
|
397
|
+
canonicalUrl,
|
|
398
|
+
title: payload.title,
|
|
399
|
+
author: payload.author,
|
|
400
|
+
site: payload.site,
|
|
401
|
+
publishedAt,
|
|
402
|
+
observedAt,
|
|
403
|
+
extractionHash,
|
|
404
|
+
finalBodyHash,
|
|
405
|
+
clipIdentity,
|
|
406
|
+
exactSelection,
|
|
407
|
+
extractionWarnings,
|
|
408
|
+
browser: payload.browser,
|
|
409
|
+
};
|
|
410
|
+
const previewDigest = sha256(
|
|
411
|
+
stableJson({
|
|
412
|
+
body,
|
|
413
|
+
destination: payload.destination,
|
|
414
|
+
extraction: payload.extraction,
|
|
415
|
+
provenance: previewProvenance,
|
|
416
|
+
source: previewSource,
|
|
417
|
+
tags: payload.tags,
|
|
418
|
+
})
|
|
419
|
+
);
|
|
420
|
+
const provenance: BrowserClipProvenance = {
|
|
421
|
+
schemaVersion: payload.schemaVersion,
|
|
422
|
+
mode: payload.mode,
|
|
423
|
+
sourceUrl,
|
|
424
|
+
canonicalUrl,
|
|
425
|
+
title: payload.title,
|
|
426
|
+
author: payload.author,
|
|
427
|
+
site: payload.site,
|
|
428
|
+
publishedAt,
|
|
429
|
+
observedAt,
|
|
430
|
+
capturedAt,
|
|
431
|
+
extractionHash,
|
|
432
|
+
finalBodyHash,
|
|
433
|
+
clipIdentity,
|
|
434
|
+
previewDigest,
|
|
435
|
+
exactSelection,
|
|
436
|
+
extractionWarnings,
|
|
437
|
+
browser: payload.browser,
|
|
438
|
+
};
|
|
439
|
+
const source: CaptureSource = {
|
|
440
|
+
kind: "web",
|
|
441
|
+
title: payload.title,
|
|
442
|
+
url: sourceUrl,
|
|
443
|
+
author: payload.author ?? undefined,
|
|
444
|
+
observedAt: provenance.observedAt,
|
|
445
|
+
capturedAt,
|
|
446
|
+
canonicalUrl: canonicalUrl ?? undefined,
|
|
447
|
+
site: payload.site ?? undefined,
|
|
448
|
+
publishedAt: provenance.publishedAt ?? undefined,
|
|
449
|
+
browserClip: provenance,
|
|
450
|
+
};
|
|
451
|
+
const captureInput: CaptureInput = {
|
|
452
|
+
collection: payload.destination.collection,
|
|
453
|
+
content: body,
|
|
454
|
+
title: payload.title,
|
|
455
|
+
relPath: payload.destination.relPath ?? undefined,
|
|
456
|
+
folderPath: payload.destination.folderPath ?? undefined,
|
|
457
|
+
collisionPolicy: payload.destination.collisionPolicy,
|
|
458
|
+
tags: payload.tags,
|
|
459
|
+
source,
|
|
460
|
+
};
|
|
461
|
+
return {
|
|
462
|
+
payload,
|
|
463
|
+
captureInput,
|
|
464
|
+
provenance,
|
|
465
|
+
preview: {
|
|
466
|
+
body,
|
|
467
|
+
digest: previewDigest,
|
|
468
|
+
source,
|
|
469
|
+
destination: payload.destination,
|
|
470
|
+
tags: payload.tags,
|
|
471
|
+
},
|
|
472
|
+
};
|
|
473
|
+
};
|
|
@@ -21,6 +21,11 @@ export async function writeCapturePlanFile(
|
|
|
21
21
|
plan: CapturePlan,
|
|
22
22
|
absPath: string
|
|
23
23
|
): Promise<void> {
|
|
24
|
+
if (plan.provenanceConflict) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"Existing capture has different provenance. Use create_with_suffix or a different destination."
|
|
27
|
+
);
|
|
28
|
+
}
|
|
24
29
|
try {
|
|
25
30
|
if (plan.overwrite) {
|
|
26
31
|
await atomicWrite(absPath, plan.content);
|
package/src/core/capture.ts
CHANGED
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
import { posix as pathPosix } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { buildUri } from "../app/constants";
|
|
13
|
+
import {
|
|
14
|
+
browserClipProvenanceSchema,
|
|
15
|
+
type BrowserClipProvenance,
|
|
16
|
+
} from "./browser-clip-provenance";
|
|
13
17
|
import {
|
|
14
18
|
resolveNoteCreatePlan,
|
|
15
19
|
type NoteCollisionPolicy,
|
|
@@ -59,9 +63,13 @@ export interface CaptureSource {
|
|
|
59
63
|
mime?: string;
|
|
60
64
|
ext?: string;
|
|
61
65
|
author?: string;
|
|
66
|
+
canonicalUrl?: string;
|
|
67
|
+
site?: string;
|
|
68
|
+
publishedAt?: string;
|
|
62
69
|
observedAt?: string;
|
|
63
70
|
capturedAt: string;
|
|
64
71
|
externalId?: string;
|
|
72
|
+
browserClip?: BrowserClipProvenance;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
export interface CaptureIndexStatus {
|
|
@@ -117,6 +125,7 @@ export interface CapturePlan {
|
|
|
117
125
|
source: CaptureSource;
|
|
118
126
|
openedExisting: boolean;
|
|
119
127
|
createdWithSuffix: boolean;
|
|
128
|
+
provenanceConflict: boolean;
|
|
120
129
|
collisionPolicy: NoteCollisionPolicy;
|
|
121
130
|
collisionPolicyResult: CaptureCollisionPolicyResult;
|
|
122
131
|
overwrite: boolean;
|
|
@@ -126,6 +135,7 @@ export interface PlanCaptureOptions {
|
|
|
126
135
|
input: CaptureInput;
|
|
127
136
|
existingRelPaths: Iterable<string>;
|
|
128
137
|
diskRelPaths?: Iterable<string>;
|
|
138
|
+
existingProvenanceByRelPath?: ReadonlyMap<string, string>;
|
|
129
139
|
now?: Date;
|
|
130
140
|
}
|
|
131
141
|
|
|
@@ -145,7 +155,7 @@ const VALID_COLLISION_POLICIES = new Set<NoteCollisionPolicy>([
|
|
|
145
155
|
"open_existing",
|
|
146
156
|
"create_with_suffix",
|
|
147
157
|
]);
|
|
148
|
-
const URL_SOURCE_FIELDS = new Set(["url", "uri"]);
|
|
158
|
+
const URL_SOURCE_FIELDS = new Set(["url", "uri", "canonicalUrl"]);
|
|
149
159
|
const LEGACY_SOURCE_FIELD_MAP: Record<string, keyof CaptureSource> = {
|
|
150
160
|
gno_source_docid: "docid",
|
|
151
161
|
gno_source_uri: "uri",
|
|
@@ -160,6 +170,9 @@ const CAPTURE_SOURCE_STRING_KEYS = new Set([
|
|
|
160
170
|
"mime",
|
|
161
171
|
"ext",
|
|
162
172
|
"author",
|
|
173
|
+
"canonicalUrl",
|
|
174
|
+
"site",
|
|
175
|
+
"publishedAt",
|
|
163
176
|
"externalId",
|
|
164
177
|
]);
|
|
165
178
|
|
|
@@ -264,27 +277,46 @@ function normalizeSource(
|
|
|
264
277
|
continue;
|
|
265
278
|
}
|
|
266
279
|
if (key === "capturedAt") {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
);
|
|
280
|
+
if (typeof value !== "string") {
|
|
281
|
+
throw new Error("source.capturedAt must be a string.");
|
|
282
|
+
}
|
|
283
|
+
normalized.capturedAt = normalizeIsoDate(value, "source.capturedAt");
|
|
271
284
|
continue;
|
|
272
285
|
}
|
|
273
286
|
if (key === "observedAt") {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
);
|
|
287
|
+
if (typeof value !== "string") {
|
|
288
|
+
throw new Error("source.observedAt must be a string.");
|
|
289
|
+
}
|
|
290
|
+
normalized.observedAt = normalizeIsoDate(value, "source.observedAt");
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (key === "publishedAt") {
|
|
294
|
+
if (typeof value !== "string") {
|
|
295
|
+
throw new Error("source.publishedAt must be a string.");
|
|
296
|
+
}
|
|
297
|
+
normalized.publishedAt = /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
298
|
+
? value
|
|
299
|
+
: normalizeIsoDate(value, "source.publishedAt");
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (key === "browserClip") {
|
|
303
|
+
normalized.browserClip = browserClipProvenanceSchema.parse(value);
|
|
278
304
|
continue;
|
|
279
305
|
}
|
|
280
306
|
if (URL_SOURCE_FIELDS.has(key)) {
|
|
307
|
+
if (typeof value !== "string") {
|
|
308
|
+
throw new Error(`source.${key} must be a string.`);
|
|
309
|
+
}
|
|
281
310
|
try {
|
|
282
|
-
new URL(
|
|
311
|
+
new URL(value);
|
|
283
312
|
} catch {
|
|
284
313
|
throw new Error(`source.${key} must be a valid URL.`);
|
|
285
314
|
}
|
|
286
315
|
}
|
|
287
316
|
if (CAPTURE_SOURCE_STRING_KEYS.has(key)) {
|
|
317
|
+
if (typeof value !== "string") {
|
|
318
|
+
throw new Error(`source.${key} must be a string.`);
|
|
319
|
+
}
|
|
288
320
|
normalized[
|
|
289
321
|
key as keyof Pick<
|
|
290
322
|
CaptureSource,
|
|
@@ -295,9 +327,11 @@ function normalizeSource(
|
|
|
295
327
|
| "mime"
|
|
296
328
|
| "ext"
|
|
297
329
|
| "author"
|
|
330
|
+
| "canonicalUrl"
|
|
331
|
+
| "site"
|
|
298
332
|
| "externalId"
|
|
299
333
|
>
|
|
300
|
-
] =
|
|
334
|
+
] = value;
|
|
301
335
|
}
|
|
302
336
|
}
|
|
303
337
|
|
|
@@ -489,6 +523,16 @@ export function extractCaptureSourceFromFrontmatter(
|
|
|
489
523
|
.trim() as keyof CaptureSource;
|
|
490
524
|
const nestedValue = nested.slice(nestedColon + 1).trim();
|
|
491
525
|
if (nestedValue) {
|
|
526
|
+
if (nestedKey === "browserClip") {
|
|
527
|
+
try {
|
|
528
|
+
const parsed = JSON.parse(nestedValue) as unknown;
|
|
529
|
+
const provenance = browserClipProvenanceSchema.safeParse(parsed);
|
|
530
|
+
if (provenance.success) source.browserClip = provenance.data;
|
|
531
|
+
} catch {
|
|
532
|
+
// Ignore malformed optional browser provenance.
|
|
533
|
+
}
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
492
536
|
source[nestedKey] = stripYamlString(nestedValue) as never;
|
|
493
537
|
}
|
|
494
538
|
}
|
|
@@ -664,6 +708,13 @@ export function planCapture(options: PlanCaptureOptions): CapturePlan {
|
|
|
664
708
|
overwrite ? [] : existing
|
|
665
709
|
);
|
|
666
710
|
const overwritten = overwrite && existing.has(createPlan.relPath);
|
|
711
|
+
const clipIdentity = source.browserClip?.clipIdentity;
|
|
712
|
+
const provenanceConflict =
|
|
713
|
+
createPlan.openedExisting &&
|
|
714
|
+
clipIdentity !== undefined &&
|
|
715
|
+
options.existingProvenanceByRelPath?.get(createPlan.relPath) !==
|
|
716
|
+
clipIdentity;
|
|
717
|
+
const openedExisting = createPlan.openedExisting && !provenanceConflict;
|
|
667
718
|
|
|
668
719
|
return {
|
|
669
720
|
collection: options.input.collection,
|
|
@@ -675,16 +726,19 @@ export function planCapture(options: PlanCaptureOptions): CapturePlan {
|
|
|
675
726
|
title,
|
|
676
727
|
tags: contentTags,
|
|
677
728
|
source,
|
|
678
|
-
openedExisting
|
|
729
|
+
openedExisting,
|
|
679
730
|
createdWithSuffix: createPlan.createdWithSuffix,
|
|
731
|
+
provenanceConflict,
|
|
680
732
|
collisionPolicy,
|
|
681
733
|
collisionPolicyResult: overwritten
|
|
682
734
|
? "overwritten"
|
|
683
|
-
:
|
|
684
|
-
? "
|
|
685
|
-
:
|
|
686
|
-
? "
|
|
687
|
-
:
|
|
735
|
+
: provenanceConflict
|
|
736
|
+
? "conflict"
|
|
737
|
+
: openedExisting
|
|
738
|
+
? "opened_existing"
|
|
739
|
+
: createPlan.createdWithSuffix
|
|
740
|
+
? "created_with_suffix"
|
|
741
|
+
: "created",
|
|
688
742
|
overwrite,
|
|
689
743
|
};
|
|
690
744
|
}
|
|
@@ -705,7 +759,10 @@ export function buildCaptureReceipt(input: {
|
|
|
705
759
|
collection: input.plan.collection,
|
|
706
760
|
relPath: input.plan.relPath,
|
|
707
761
|
absPath: input.absPath,
|
|
708
|
-
created:
|
|
762
|
+
created:
|
|
763
|
+
!input.plan.openedExisting &&
|
|
764
|
+
!input.plan.provenanceConflict &&
|
|
765
|
+
!overwritten,
|
|
709
766
|
openedExisting: input.plan.openedExisting,
|
|
710
767
|
createdWithSuffix: input.plan.createdWithSuffix,
|
|
711
768
|
overwritten,
|