@gmickel/gno 1.7.1 → 1.9.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 +10 -1
- package/assets/skill/SKILL.md +41 -0
- package/assets/skill/cli-reference.md +34 -0
- package/assets/skill/examples.md +37 -0
- package/assets/skill/mcp-reference.md +21 -0
- package/package.json +1 -1
- package/src/cli/commands/capture.ts +282 -0
- package/src/cli/commands/index-cmd.ts +17 -6
- package/src/cli/commands/shared.ts +17 -1
- package/src/cli/commands/update.ts +17 -6
- package/src/cli/options.ts +2 -0
- package/src/cli/program.ts +64 -0
- package/src/config/content-types.ts +140 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +14 -0
- package/src/config/loader.ts +11 -2
- package/src/config/types.ts +37 -1
- package/src/core/capture-write.ts +38 -0
- package/src/core/capture.ts +746 -0
- package/src/core/config-mutation.ts +14 -2
- package/src/core/file-ops.ts +21 -2
- package/src/core/note-presets.ts +61 -5
- package/src/ingestion/frontmatter.ts +77 -2
- package/src/ingestion/index.ts +2 -0
- package/src/ingestion/sync-options.ts +29 -0
- package/src/ingestion/sync.ts +104 -16
- package/src/ingestion/types.ts +14 -0
- package/src/mcp/tools/add-collection.ts +8 -5
- package/src/mcp/tools/capture.ts +137 -191
- package/src/mcp/tools/index-cmd.ts +13 -7
- package/src/mcp/tools/index.ts +43 -9
- package/src/mcp/tools/sync.ts +12 -6
- package/src/mcp/tools/workspace-write.ts +16 -10
- package/src/pipeline/hybrid.ts +2 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +2 -0
- package/src/pipeline/vsearch.ts +4 -0
- package/src/sdk/client.ts +156 -20
- package/src/sdk/index.ts +2 -0
- package/src/sdk/types.ts +6 -0
- package/src/serve/background-runtime.ts +18 -4
- package/src/serve/config-sync.ts +9 -2
- package/src/serve/public/components/CaptureModal.tsx +248 -10
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/routes/api.ts +238 -26
- package/src/serve/server.ts +12 -0
- package/src/serve/watch-service.ts +12 -2
- package/src/store/migrations/009-content-type-rule-fingerprint.ts +36 -0
- package/src/store/migrations/index.ts +12 -1
- package/src/store/sqlite/adapter.ts +29 -14
- package/src/store/types.ts +6 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared capture planning, provenance, and receipt contracts.
|
|
3
|
+
*
|
|
4
|
+
* Server-side capture adapters call this before transport-specific write/sync.
|
|
5
|
+
*
|
|
6
|
+
* @module src/core/capture
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// node:path has no Bun equivalent
|
|
10
|
+
import { posix as pathPosix } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { buildUri } from "../app/constants";
|
|
13
|
+
import {
|
|
14
|
+
resolveNoteCreatePlan,
|
|
15
|
+
type NoteCollisionPolicy,
|
|
16
|
+
} from "./note-creation";
|
|
17
|
+
import {
|
|
18
|
+
getNotePreset,
|
|
19
|
+
resolveNotePreset,
|
|
20
|
+
type NotePresetId,
|
|
21
|
+
} from "./note-presets";
|
|
22
|
+
import { normalizeTag, validateTag } from "./tags";
|
|
23
|
+
import { validateRelPath } from "./validation";
|
|
24
|
+
|
|
25
|
+
export const CAPTURE_MAX_TEXT_BYTES = 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
export type CaptureSourceKind =
|
|
28
|
+
| "direct"
|
|
29
|
+
| "web"
|
|
30
|
+
| "email"
|
|
31
|
+
| "meeting"
|
|
32
|
+
| "chat"
|
|
33
|
+
| "file"
|
|
34
|
+
| "api"
|
|
35
|
+
| "unknown";
|
|
36
|
+
|
|
37
|
+
export type CaptureStatus =
|
|
38
|
+
| "not_requested"
|
|
39
|
+
| "pending"
|
|
40
|
+
| "running"
|
|
41
|
+
| "completed"
|
|
42
|
+
| "skipped"
|
|
43
|
+
| "failed"
|
|
44
|
+
| "unknown";
|
|
45
|
+
|
|
46
|
+
export type CaptureCollisionPolicyResult =
|
|
47
|
+
| "created"
|
|
48
|
+
| "opened_existing"
|
|
49
|
+
| "created_with_suffix"
|
|
50
|
+
| "overwritten"
|
|
51
|
+
| "conflict";
|
|
52
|
+
|
|
53
|
+
export interface CaptureSource {
|
|
54
|
+
kind: CaptureSourceKind;
|
|
55
|
+
title?: string;
|
|
56
|
+
url?: string;
|
|
57
|
+
uri?: string;
|
|
58
|
+
docid?: string;
|
|
59
|
+
mime?: string;
|
|
60
|
+
ext?: string;
|
|
61
|
+
author?: string;
|
|
62
|
+
observedAt?: string;
|
|
63
|
+
capturedAt: string;
|
|
64
|
+
externalId?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CaptureIndexStatus {
|
|
68
|
+
status: CaptureStatus;
|
|
69
|
+
jobId?: string | null;
|
|
70
|
+
reason?: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface CaptureReceipt {
|
|
75
|
+
uri: string;
|
|
76
|
+
docid?: string;
|
|
77
|
+
collection: string;
|
|
78
|
+
relPath: string;
|
|
79
|
+
absPath?: string;
|
|
80
|
+
created: boolean;
|
|
81
|
+
openedExisting: boolean;
|
|
82
|
+
createdWithSuffix: boolean;
|
|
83
|
+
overwritten?: boolean;
|
|
84
|
+
contentHash: string;
|
|
85
|
+
source: CaptureSource;
|
|
86
|
+
tags: string[];
|
|
87
|
+
sync: CaptureIndexStatus;
|
|
88
|
+
embed: CaptureIndexStatus;
|
|
89
|
+
collisionPolicyResult: CaptureCollisionPolicyResult;
|
|
90
|
+
serverInstanceId?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CaptureInput {
|
|
94
|
+
collection: string;
|
|
95
|
+
content?: string;
|
|
96
|
+
title?: string;
|
|
97
|
+
relPath?: string;
|
|
98
|
+
folderPath?: string;
|
|
99
|
+
collisionPolicy?: NoteCollisionPolicy;
|
|
100
|
+
presetId?: NotePresetId;
|
|
101
|
+
tags?: string[];
|
|
102
|
+
source?: Partial<CaptureSource>;
|
|
103
|
+
overwrite?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type PublicCaptureInput = Omit<CaptureInput, "overwrite">;
|
|
107
|
+
|
|
108
|
+
export interface CapturePlan {
|
|
109
|
+
collection: string;
|
|
110
|
+
relPath: string;
|
|
111
|
+
filename: string;
|
|
112
|
+
content: string;
|
|
113
|
+
body: string;
|
|
114
|
+
contentHash: string;
|
|
115
|
+
title: string;
|
|
116
|
+
tags: string[];
|
|
117
|
+
source: CaptureSource;
|
|
118
|
+
openedExisting: boolean;
|
|
119
|
+
createdWithSuffix: boolean;
|
|
120
|
+
collisionPolicy: NoteCollisionPolicy;
|
|
121
|
+
collisionPolicyResult: CaptureCollisionPolicyResult;
|
|
122
|
+
overwrite: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface PlanCaptureOptions {
|
|
126
|
+
input: CaptureInput;
|
|
127
|
+
existingRelPaths: Iterable<string>;
|
|
128
|
+
diskRelPaths?: Iterable<string>;
|
|
129
|
+
now?: Date;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)(?:\r?\n)?---(?:\r?\n|$)/;
|
|
133
|
+
const VALID_SOURCE_KINDS = new Set<CaptureSourceKind>([
|
|
134
|
+
"direct",
|
|
135
|
+
"web",
|
|
136
|
+
"email",
|
|
137
|
+
"meeting",
|
|
138
|
+
"chat",
|
|
139
|
+
"file",
|
|
140
|
+
"api",
|
|
141
|
+
"unknown",
|
|
142
|
+
]);
|
|
143
|
+
const VALID_COLLISION_POLICIES = new Set<NoteCollisionPolicy>([
|
|
144
|
+
"error",
|
|
145
|
+
"open_existing",
|
|
146
|
+
"create_with_suffix",
|
|
147
|
+
]);
|
|
148
|
+
const URL_SOURCE_FIELDS = new Set(["url", "uri"]);
|
|
149
|
+
const LEGACY_SOURCE_FIELD_MAP: Record<string, keyof CaptureSource> = {
|
|
150
|
+
gno_source_docid: "docid",
|
|
151
|
+
gno_source_uri: "uri",
|
|
152
|
+
gno_source_mime: "mime",
|
|
153
|
+
gno_source_ext: "ext",
|
|
154
|
+
};
|
|
155
|
+
const CAPTURE_SOURCE_STRING_KEYS = new Set([
|
|
156
|
+
"title",
|
|
157
|
+
"url",
|
|
158
|
+
"uri",
|
|
159
|
+
"docid",
|
|
160
|
+
"mime",
|
|
161
|
+
"ext",
|
|
162
|
+
"author",
|
|
163
|
+
"externalId",
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
function normalizeContentForHash(content: string): string {
|
|
167
|
+
return content.replace(/\r\n/g, "\n").trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function hashCaptureContent(content: string): string {
|
|
171
|
+
return new Bun.CryptoHasher("sha256")
|
|
172
|
+
.update(normalizeContentForHash(content))
|
|
173
|
+
.digest("hex");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function listCaptureDiskRelPaths(
|
|
177
|
+
collectionPath: string
|
|
178
|
+
): Promise<string[]> {
|
|
179
|
+
const paths: string[] = [];
|
|
180
|
+
const glob = new Bun.Glob("**/*");
|
|
181
|
+
for await (const relPath of glob.scan({
|
|
182
|
+
cwd: collectionPath,
|
|
183
|
+
onlyFiles: true,
|
|
184
|
+
followSymlinks: false,
|
|
185
|
+
})) {
|
|
186
|
+
try {
|
|
187
|
+
paths.push(validateRelPath(relPath.split("\\").join("/")));
|
|
188
|
+
} catch {
|
|
189
|
+
// Ignore unsafe/unrepresentable disk paths for collision planning.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return paths;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function validateTextContent(content: string): void {
|
|
196
|
+
if (content.includes("\0")) {
|
|
197
|
+
throw new Error("Capture content contains a NUL byte.");
|
|
198
|
+
}
|
|
199
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
200
|
+
const code = content.charCodeAt(index);
|
|
201
|
+
const isAllowedWhitespace = code === 9 || code === 10 || code === 13;
|
|
202
|
+
if ((code < 32 || code === 127) && !isAllowedWhitespace) {
|
|
203
|
+
throw new Error("Capture content must be text, not binary-like data.");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (new TextEncoder().encode(content).byteLength > CAPTURE_MAX_TEXT_BYTES) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Capture content exceeds ${CAPTURE_MAX_TEXT_BYTES} byte limit.`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function normalizeCollisionPolicy(
|
|
214
|
+
policy: CaptureInput["collisionPolicy"] | undefined,
|
|
215
|
+
fallback: NoteCollisionPolicy
|
|
216
|
+
): NoteCollisionPolicy {
|
|
217
|
+
if (policy === undefined) {
|
|
218
|
+
return fallback;
|
|
219
|
+
}
|
|
220
|
+
if (!VALID_COLLISION_POLICIES.has(policy)) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
"collisionPolicy must be one of: error, open_existing, create_with_suffix"
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return policy;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
229
|
+
return (
|
|
230
|
+
typeof value === "object" &&
|
|
231
|
+
value !== null &&
|
|
232
|
+
!Array.isArray(value) &&
|
|
233
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function normalizeIsoDate(value: string, field: string): string {
|
|
238
|
+
const parsed = new Date(value);
|
|
239
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
240
|
+
throw new Error(`${field} must be an ISO-like date/time.`);
|
|
241
|
+
}
|
|
242
|
+
return parsed.toISOString();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeSource(
|
|
246
|
+
source: Partial<CaptureSource> | undefined,
|
|
247
|
+
capturedAt: string
|
|
248
|
+
): CaptureSource {
|
|
249
|
+
if (source !== undefined && !isPlainObject(source)) {
|
|
250
|
+
throw new Error("source must be an object.");
|
|
251
|
+
}
|
|
252
|
+
const kind = source?.kind ?? "direct";
|
|
253
|
+
if (!VALID_SOURCE_KINDS.has(kind)) {
|
|
254
|
+
throw new Error(`Unsupported source.kind: ${kind}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const normalized: CaptureSource = {
|
|
258
|
+
kind,
|
|
259
|
+
capturedAt,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
for (const [key, value] of Object.entries(source ?? {})) {
|
|
263
|
+
if (value === undefined || value === null || key === "kind") {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (key === "capturedAt") {
|
|
267
|
+
normalized.capturedAt = normalizeIsoDate(
|
|
268
|
+
String(value),
|
|
269
|
+
"source.capturedAt"
|
|
270
|
+
);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (key === "observedAt") {
|
|
274
|
+
normalized.observedAt = normalizeIsoDate(
|
|
275
|
+
String(value),
|
|
276
|
+
"source.observedAt"
|
|
277
|
+
);
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (URL_SOURCE_FIELDS.has(key)) {
|
|
281
|
+
try {
|
|
282
|
+
new URL(String(value));
|
|
283
|
+
} catch {
|
|
284
|
+
throw new Error(`source.${key} must be a valid URL.`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (CAPTURE_SOURCE_STRING_KEYS.has(key)) {
|
|
288
|
+
normalized[
|
|
289
|
+
key as keyof Pick<
|
|
290
|
+
CaptureSource,
|
|
291
|
+
| "title"
|
|
292
|
+
| "url"
|
|
293
|
+
| "uri"
|
|
294
|
+
| "docid"
|
|
295
|
+
| "mime"
|
|
296
|
+
| "ext"
|
|
297
|
+
| "author"
|
|
298
|
+
| "externalId"
|
|
299
|
+
>
|
|
300
|
+
] = String(value);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return normalized;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function normalizeCaptureTags(tags: string[] | undefined): string[] {
|
|
308
|
+
if (tags !== undefined && !Array.isArray(tags)) {
|
|
309
|
+
throw new Error("tags must be an array of strings.");
|
|
310
|
+
}
|
|
311
|
+
const normalized: string[] = [];
|
|
312
|
+
for (const tag of tags ?? []) {
|
|
313
|
+
if (typeof tag !== "string") {
|
|
314
|
+
throw new Error("tags must be an array of strings.");
|
|
315
|
+
}
|
|
316
|
+
const value = normalizeTag(tag);
|
|
317
|
+
if (!validateTag(value)) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Invalid tag "${tag}". Tags must be lowercase, alphanumeric with hyphens/dots/slashes.`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
normalized.push(value);
|
|
323
|
+
}
|
|
324
|
+
return [...new Set(normalized)];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function chooseTitle(input: CaptureInput, fallback: string): string {
|
|
328
|
+
return (
|
|
329
|
+
input.title?.trim() ||
|
|
330
|
+
input.source?.title?.trim() ||
|
|
331
|
+
pathPosix.basename(input.relPath ?? "").replace(/\.[^.]+$/u, "") ||
|
|
332
|
+
fallback
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function generatedCaptureRelPath(
|
|
337
|
+
capturedAt: string,
|
|
338
|
+
contentHash: string
|
|
339
|
+
): string {
|
|
340
|
+
const day = capturedAt.slice(0, 10);
|
|
341
|
+
return `inbox/${day}/capture-${contentHash.slice(0, 12)}.md`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function buildExistingSet(
|
|
345
|
+
indexedRelPaths: Iterable<string>,
|
|
346
|
+
diskRelPaths: Iterable<string> | undefined
|
|
347
|
+
): Set<string> {
|
|
348
|
+
const existing = new Set<string>();
|
|
349
|
+
for (const relPath of indexedRelPaths) {
|
|
350
|
+
existing.add(validateRelPath(relPath));
|
|
351
|
+
}
|
|
352
|
+
for (const relPath of diskRelPaths ?? []) {
|
|
353
|
+
existing.add(validateRelPath(relPath));
|
|
354
|
+
}
|
|
355
|
+
return existing;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function splitFrontmatter(source: string): {
|
|
359
|
+
lines: string[];
|
|
360
|
+
body: string;
|
|
361
|
+
hasFrontmatter: boolean;
|
|
362
|
+
} {
|
|
363
|
+
const match = FRONTMATTER_REGEX.exec(source);
|
|
364
|
+
if (!match) {
|
|
365
|
+
return { lines: [], body: source, hasFrontmatter: false };
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
lines: (match[1] ?? "").split("\n"),
|
|
369
|
+
body: source.slice(match[0].length),
|
|
370
|
+
hasFrontmatter: true,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function stripYamlString(value: string): string {
|
|
375
|
+
const trimmed = value.trim();
|
|
376
|
+
if (
|
|
377
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
378
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
379
|
+
) {
|
|
380
|
+
try {
|
|
381
|
+
return JSON.parse(trimmed);
|
|
382
|
+
} catch {
|
|
383
|
+
return trimmed.slice(1, -1);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return trimmed;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function parseInlineTagList(value: string): string[] {
|
|
390
|
+
const trimmed = value.trim();
|
|
391
|
+
if (!trimmed || trimmed === "[]") {
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
if (!(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
395
|
+
return [stripYamlString(trimmed)];
|
|
396
|
+
}
|
|
397
|
+
return trimmed
|
|
398
|
+
.slice(1, -1)
|
|
399
|
+
.split(",")
|
|
400
|
+
.map((tag) => stripYamlString(tag))
|
|
401
|
+
.filter((tag) => tag.length > 0);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function readFrontmatterTags(lines: string[]): string[] {
|
|
405
|
+
const tags: string[] = [];
|
|
406
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
407
|
+
const line = lines[index];
|
|
408
|
+
if (!line?.startsWith("tags:")) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
tags.push(...parseInlineTagList(line.slice("tags:".length)));
|
|
412
|
+
for (
|
|
413
|
+
let nestedIndex = index + 1;
|
|
414
|
+
nestedIndex < lines.length;
|
|
415
|
+
nestedIndex += 1
|
|
416
|
+
) {
|
|
417
|
+
const nested = lines[nestedIndex];
|
|
418
|
+
if (!nested?.startsWith(" ")) {
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
const item = nested.trim();
|
|
422
|
+
if (item.startsWith("-")) {
|
|
423
|
+
const tag = stripYamlString(item.slice(1));
|
|
424
|
+
if (tag) {
|
|
425
|
+
tags.push(tag);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return normalizeCaptureTags(tags);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function shouldSkipNestedFrontmatterLine(line: string): boolean {
|
|
434
|
+
return line.startsWith(" ") || line.trim() === "";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function extractCaptureSourceFromFrontmatter(
|
|
438
|
+
content: string
|
|
439
|
+
): Partial<CaptureSource> {
|
|
440
|
+
const { lines } = splitFrontmatter(content);
|
|
441
|
+
const source: Partial<CaptureSource> = {};
|
|
442
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
443
|
+
const line = lines[index];
|
|
444
|
+
if (line === undefined) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const colonIndex = line.indexOf(":");
|
|
448
|
+
if (colonIndex <= 0) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const key = line.slice(0, colonIndex).trim();
|
|
452
|
+
const rawValue = line.slice(colonIndex + 1).trim();
|
|
453
|
+
const legacyKey = LEGACY_SOURCE_FIELD_MAP[key];
|
|
454
|
+
if (legacyKey && rawValue) {
|
|
455
|
+
switch (legacyKey) {
|
|
456
|
+
case "docid":
|
|
457
|
+
source.docid = stripYamlString(rawValue);
|
|
458
|
+
break;
|
|
459
|
+
case "uri":
|
|
460
|
+
source.uri = stripYamlString(rawValue);
|
|
461
|
+
break;
|
|
462
|
+
case "mime":
|
|
463
|
+
source.mime = stripYamlString(rawValue);
|
|
464
|
+
break;
|
|
465
|
+
case "ext":
|
|
466
|
+
source.ext = stripYamlString(rawValue);
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (key !== "source") {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
for (
|
|
475
|
+
let nestedIndex = index + 1;
|
|
476
|
+
nestedIndex < lines.length;
|
|
477
|
+
nestedIndex += 1
|
|
478
|
+
) {
|
|
479
|
+
const nested = lines[nestedIndex];
|
|
480
|
+
if (!nested?.startsWith(" ")) {
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
const nestedColon = nested.indexOf(":");
|
|
484
|
+
if (nestedColon <= 0) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const nestedKey = nested
|
|
488
|
+
.slice(0, nestedColon)
|
|
489
|
+
.trim() as keyof CaptureSource;
|
|
490
|
+
const nestedValue = nested.slice(nestedColon + 1).trim();
|
|
491
|
+
if (nestedValue) {
|
|
492
|
+
source[nestedKey] = stripYamlString(nestedValue) as never;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return source;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function sourceFrontmatterLines(source: CaptureSource): string[] {
|
|
500
|
+
const lines = ["source:"];
|
|
501
|
+
for (const [key, value] of Object.entries(source)) {
|
|
502
|
+
if (value === undefined || value === "") {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
lines.push(` ${key}: ${JSON.stringify(value)}`);
|
|
506
|
+
}
|
|
507
|
+
return lines;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function mergeCaptureFrontmatter(input: {
|
|
511
|
+
content: string;
|
|
512
|
+
source: CaptureSource;
|
|
513
|
+
tags: string[];
|
|
514
|
+
title?: string;
|
|
515
|
+
}): string {
|
|
516
|
+
return mergeCaptureFrontmatterAndTags(input).content;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function mergeCaptureFrontmatterAndTags(input: {
|
|
520
|
+
content: string;
|
|
521
|
+
source: CaptureSource;
|
|
522
|
+
tags: string[];
|
|
523
|
+
title?: string;
|
|
524
|
+
}): { content: string; tags: string[] } {
|
|
525
|
+
const { lines, body, hasFrontmatter } = splitFrontmatter(input.content);
|
|
526
|
+
const nextLines: string[] = [];
|
|
527
|
+
let skippingSource = false;
|
|
528
|
+
let skippingTags = false;
|
|
529
|
+
let hasTitle = false;
|
|
530
|
+
const mergedTags = [
|
|
531
|
+
...new Set([...readFrontmatterTags(lines), ...input.tags]),
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
for (const line of lines) {
|
|
535
|
+
if (skippingSource) {
|
|
536
|
+
if (shouldSkipNestedFrontmatterLine(line)) {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
skippingSource = false;
|
|
540
|
+
}
|
|
541
|
+
if (skippingTags) {
|
|
542
|
+
if (shouldSkipNestedFrontmatterLine(line)) {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
skippingTags = false;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (line.startsWith("source:")) {
|
|
549
|
+
skippingSource = true;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
if (line.startsWith("tags:")) {
|
|
553
|
+
skippingTags = true;
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (line.startsWith("title:")) {
|
|
557
|
+
hasTitle = true;
|
|
558
|
+
}
|
|
559
|
+
nextLines.push(line);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (input.title && !hasTitle) {
|
|
563
|
+
nextLines.unshift(`title: ${JSON.stringify(input.title)}`);
|
|
564
|
+
}
|
|
565
|
+
if (mergedTags.length > 0) {
|
|
566
|
+
nextLines.push("tags:");
|
|
567
|
+
for (const tag of mergedTags) {
|
|
568
|
+
nextLines.push(` - ${JSON.stringify(tag)}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
nextLines.push(...sourceFrontmatterLines(input.source));
|
|
572
|
+
|
|
573
|
+
const normalizedBody = hasFrontmatter ? body : input.content;
|
|
574
|
+
return {
|
|
575
|
+
content:
|
|
576
|
+
`---\n${nextLines.join("\n")}\n---\n\n${normalizedBody.trimStart()}`.trimEnd() +
|
|
577
|
+
"\n",
|
|
578
|
+
tags: mergedTags,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function buildCaptureContent(input: {
|
|
583
|
+
captureInput: CaptureInput;
|
|
584
|
+
title: string;
|
|
585
|
+
tags: string[];
|
|
586
|
+
source: CaptureSource;
|
|
587
|
+
}): { content: string; body: string; tags: string[] } {
|
|
588
|
+
const presetId = input.captureInput.presetId;
|
|
589
|
+
if (presetId && !getNotePreset(presetId)) {
|
|
590
|
+
throw new Error(`Unknown presetId: ${presetId}`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const body = input.captureInput.content;
|
|
594
|
+
if (body !== undefined) {
|
|
595
|
+
validateTextContent(body);
|
|
596
|
+
}
|
|
597
|
+
if (!presetId && (!body || body.trim().length === 0)) {
|
|
598
|
+
throw new Error(
|
|
599
|
+
"Capture content is required unless presetId scaffolds it."
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const resolvedPreset = resolveNotePreset({
|
|
604
|
+
presetId,
|
|
605
|
+
title: input.title,
|
|
606
|
+
tags: input.tags,
|
|
607
|
+
frontmatter: {
|
|
608
|
+
source: [],
|
|
609
|
+
},
|
|
610
|
+
body,
|
|
611
|
+
});
|
|
612
|
+
const rawContent =
|
|
613
|
+
resolvedPreset?.content ?? body ?? `# ${input.title || "Untitled"}\n`;
|
|
614
|
+
const merged = mergeCaptureFrontmatterAndTags({
|
|
615
|
+
content: rawContent,
|
|
616
|
+
source: input.source,
|
|
617
|
+
tags: resolvedPreset?.tags ?? input.tags,
|
|
618
|
+
title: input.title,
|
|
619
|
+
});
|
|
620
|
+
return {
|
|
621
|
+
content: merged.content,
|
|
622
|
+
body: body ?? resolvedPreset?.body ?? "",
|
|
623
|
+
tags: merged.tags,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function planCapture(options: PlanCaptureOptions): CapturePlan {
|
|
628
|
+
const capturedAt = (options.now ?? new Date()).toISOString();
|
|
629
|
+
const source = normalizeSource(options.input.source, capturedAt);
|
|
630
|
+
const title = chooseTitle(options.input, "Captured Note");
|
|
631
|
+
const tags = normalizeCaptureTags(options.input.tags);
|
|
632
|
+
const {
|
|
633
|
+
content,
|
|
634
|
+
body,
|
|
635
|
+
tags: contentTags,
|
|
636
|
+
} = buildCaptureContent({
|
|
637
|
+
captureInput: options.input,
|
|
638
|
+
title,
|
|
639
|
+
tags,
|
|
640
|
+
source,
|
|
641
|
+
});
|
|
642
|
+
const contentHash = hashCaptureContent(body || content);
|
|
643
|
+
const generatedRelPath =
|
|
644
|
+
!options.input.relPath && !options.input.folderPath && !options.input.title
|
|
645
|
+
? generatedCaptureRelPath(source.capturedAt, contentHash)
|
|
646
|
+
: undefined;
|
|
647
|
+
const existing = buildExistingSet(
|
|
648
|
+
options.existingRelPaths,
|
|
649
|
+
options.diskRelPaths
|
|
650
|
+
);
|
|
651
|
+
const collisionPolicy = normalizeCollisionPolicy(
|
|
652
|
+
options.input.collisionPolicy,
|
|
653
|
+
generatedRelPath ? "open_existing" : "error"
|
|
654
|
+
);
|
|
655
|
+
const overwrite = options.input.overwrite === true;
|
|
656
|
+
const createPlan = resolveNoteCreatePlan(
|
|
657
|
+
{
|
|
658
|
+
collection: options.input.collection,
|
|
659
|
+
relPath: options.input.relPath ?? generatedRelPath,
|
|
660
|
+
title,
|
|
661
|
+
folderPath: options.input.folderPath,
|
|
662
|
+
collisionPolicy: overwrite ? "error" : collisionPolicy,
|
|
663
|
+
},
|
|
664
|
+
overwrite ? [] : existing
|
|
665
|
+
);
|
|
666
|
+
const overwritten = overwrite && existing.has(createPlan.relPath);
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
collection: options.input.collection,
|
|
670
|
+
relPath: createPlan.relPath,
|
|
671
|
+
filename: createPlan.filename,
|
|
672
|
+
content,
|
|
673
|
+
body,
|
|
674
|
+
contentHash,
|
|
675
|
+
title,
|
|
676
|
+
tags: contentTags,
|
|
677
|
+
source,
|
|
678
|
+
openedExisting: createPlan.openedExisting,
|
|
679
|
+
createdWithSuffix: createPlan.createdWithSuffix,
|
|
680
|
+
collisionPolicy,
|
|
681
|
+
collisionPolicyResult: overwritten
|
|
682
|
+
? "overwritten"
|
|
683
|
+
: createPlan.openedExisting
|
|
684
|
+
? "opened_existing"
|
|
685
|
+
: createPlan.createdWithSuffix
|
|
686
|
+
? "created_with_suffix"
|
|
687
|
+
: "created",
|
|
688
|
+
overwrite,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export function buildCaptureReceipt(input: {
|
|
693
|
+
plan: CapturePlan;
|
|
694
|
+
absPath?: string;
|
|
695
|
+
docid?: string;
|
|
696
|
+
sync?: CaptureIndexStatus;
|
|
697
|
+
embed?: CaptureIndexStatus;
|
|
698
|
+
overwritten?: boolean;
|
|
699
|
+
serverInstanceId?: string;
|
|
700
|
+
}): CaptureReceipt {
|
|
701
|
+
const overwritten = input.overwritten ?? false;
|
|
702
|
+
return {
|
|
703
|
+
uri: buildUri(input.plan.collection, input.plan.relPath),
|
|
704
|
+
docid: input.docid,
|
|
705
|
+
collection: input.plan.collection,
|
|
706
|
+
relPath: input.plan.relPath,
|
|
707
|
+
absPath: input.absPath,
|
|
708
|
+
created: !input.plan.openedExisting && !overwritten,
|
|
709
|
+
openedExisting: input.plan.openedExisting,
|
|
710
|
+
createdWithSuffix: input.plan.createdWithSuffix,
|
|
711
|
+
overwritten,
|
|
712
|
+
contentHash: input.plan.contentHash,
|
|
713
|
+
source: input.plan.source,
|
|
714
|
+
tags: input.plan.tags,
|
|
715
|
+
sync: input.sync ?? { status: "not_requested" },
|
|
716
|
+
embed: input.embed ?? {
|
|
717
|
+
status: "not_requested",
|
|
718
|
+
reason: "Capture does not embed automatically.",
|
|
719
|
+
},
|
|
720
|
+
collisionPolicyResult: overwritten
|
|
721
|
+
? "overwritten"
|
|
722
|
+
: input.plan.collisionPolicyResult,
|
|
723
|
+
serverInstanceId: input.serverInstanceId,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function buildLegacyEditableCopySource(input: {
|
|
728
|
+
docid: string;
|
|
729
|
+
uri: string;
|
|
730
|
+
mime: string;
|
|
731
|
+
ext: string;
|
|
732
|
+
capturedAt?: string;
|
|
733
|
+
}): CaptureSource {
|
|
734
|
+
return {
|
|
735
|
+
kind: "file",
|
|
736
|
+
docid: input.docid,
|
|
737
|
+
uri: input.uri,
|
|
738
|
+
mime: input.mime,
|
|
739
|
+
ext: input.ext,
|
|
740
|
+
capturedAt: input.capturedAt ?? new Date().toISOString(),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export function serializeCaptureReceipt(receipt: CaptureReceipt): string {
|
|
745
|
+
return JSON.stringify(receipt, null, 2);
|
|
746
|
+
}
|