@parall/cli 1.33.0 → 1.35.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/dist/commands/mcp.d.ts +5 -0
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +31 -482
- package/dist/commands/wiki.d.ts.map +1 -1
- package/dist/commands/wiki.js +38 -22
- package/dist/lib/wiki-frontmatter.d.ts +9 -0
- package/dist/lib/wiki-frontmatter.d.ts.map +1 -0
- package/dist/lib/wiki-frontmatter.js +64 -0
- package/dist/lib/wiki-tools.d.ts +33 -0
- package/dist/lib/wiki-tools.d.ts.map +1 -0
- package/dist/lib/wiki-tools.js +540 -0
- package/dist/lib/wiki.d.ts +33 -15
- package/dist/lib/wiki.d.ts.map +1 -1
- package/dist/lib/wiki.js +436 -147
- package/package.json +11 -3
package/dist/lib/wiki.js
CHANGED
|
@@ -2,17 +2,28 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ApiError, } from '@parall/sdk';
|
|
5
|
+
import { structuredPatch } from 'diff';
|
|
6
|
+
import { hasFrontMatter, parseFrontMatter } from './wiki-frontmatter.js';
|
|
7
|
+
// Re-exported so consumers (and tests) can reach the frontmatter parser via the
|
|
8
|
+
// wiki module surface alongside detectFrontMatterEnd.
|
|
9
|
+
export { parseFrontMatter } from './wiki-frontmatter.js';
|
|
5
10
|
// ---------------------------------------------------------------------------
|
|
6
11
|
// Constants
|
|
7
12
|
// ---------------------------------------------------------------------------
|
|
8
13
|
const MARKDOWN_HEADING_RE = /^\s{0,3}(#{1,6})[ \t]+(.+?)\s*$/;
|
|
9
14
|
const TRAILING_FENCE_RE = /\s+#+\s*$/;
|
|
10
|
-
|
|
15
|
+
// Bumped 2 → 3 for the frontmatter (type/description/tags) fields: invalidates
|
|
16
|
+
// caches built before frontmatter parsing so existing local mounts re-parse and
|
|
17
|
+
// pick up the metadata even when a file's size / mtime is unchanged.
|
|
18
|
+
const LOCAL_NODE_SECTION_ARTIFACT_VERSION = 3;
|
|
11
19
|
const LOCAL_MANIFEST_FILE = 'manifest.json';
|
|
12
20
|
const PRLL_WIKI_DIR = '.parall-wiki';
|
|
13
21
|
const CONFLICTS_DIR = 'conflicts';
|
|
22
|
+
const OBJECTS_DIR = 'objects';
|
|
14
23
|
const REMOTE_CONFLICT_SUFFIX = '.remote';
|
|
15
24
|
const REMOTE_DELETED_MARKER_SUFFIX = '.remote-deleted';
|
|
25
|
+
/** Server-side text ceiling for changeset file content (mirrors filetype.TextMaxBytes). */
|
|
26
|
+
const TEXT_MAX_BYTES = 10 * 1024 * 1024;
|
|
16
27
|
// ---------------------------------------------------------------------------
|
|
17
28
|
// Wiki resolution helpers
|
|
18
29
|
// ---------------------------------------------------------------------------
|
|
@@ -34,7 +45,10 @@ export async function resolveWikiRef(ctx, wikiRef) {
|
|
|
34
45
|
if (fuzzy) {
|
|
35
46
|
return fuzzy;
|
|
36
47
|
}
|
|
37
|
-
|
|
48
|
+
const available = wikis.length > 0
|
|
49
|
+
? ` Available wikis: ${wikis.map((w) => w.slug).join(', ')}`
|
|
50
|
+
: ' This organization has no wikis yet.';
|
|
51
|
+
throw new Error(`wiki not found: ${wikiRef}.${available}`);
|
|
38
52
|
}
|
|
39
53
|
/**
|
|
40
54
|
* Resolve wiki reference. If wikiRef is omitted or empty, auto-resolves to
|
|
@@ -70,22 +84,55 @@ export async function getWikiTree(ctx, wikiRef, options = {}) {
|
|
|
70
84
|
}
|
|
71
85
|
export async function getWikiBlob(ctx, wikiRef, options) {
|
|
72
86
|
const wiki = await resolveWikiRef(ctx, wikiRef);
|
|
87
|
+
const normalizedPath = normalizeRequiredWikiPath(options.path);
|
|
88
|
+
// Local mount first (same data source as search/section/outline) so reads
|
|
89
|
+
// reflect the agent's own in-progress edits. Files absent locally AND
|
|
90
|
+
// absent from the manifest (never synced, or ACL-filtered) fall through to
|
|
91
|
+
// the API; files in the manifest but missing on disk are pending local
|
|
92
|
+
// deletes — surfacing the server copy there would make a delete look like
|
|
93
|
+
// it failed.
|
|
94
|
+
if (!options.remote) {
|
|
95
|
+
const mount = await resolveLocalWikiMount(ctx, wiki);
|
|
96
|
+
if (mount) {
|
|
97
|
+
const target = resolvePathInsideRoot(mount.root, normalizedPath);
|
|
98
|
+
const stat = await fs.stat(target).catch(() => null);
|
|
99
|
+
if (stat?.isFile()) {
|
|
100
|
+
const content = await fs.readFile(target);
|
|
101
|
+
if (looksBinary(content)) {
|
|
102
|
+
throw new Error(`wiki file is binary, not printable text: ${normalizedPath}. ` +
|
|
103
|
+
'Binary files are viewable in the web UI.');
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
wiki,
|
|
107
|
+
content: content.toString('utf8'),
|
|
108
|
+
size: content.length,
|
|
109
|
+
source: 'local_mount',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const manifest = await readLocalManifest(mount.root);
|
|
113
|
+
if (manifest.files[normalizedPath]) {
|
|
114
|
+
throw new Error(`${normalizedPath} is deleted in your local workspace (pending delete). ` +
|
|
115
|
+
'Run `parall wiki changeset create` to propose the deletion, ' +
|
|
116
|
+
'`parall wiki reset` to restore it, or `parall wiki cat --remote` to read the server version.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
73
120
|
const blob = await ctx.client.getWikiBlob(ctx.orgId, wiki.id, {
|
|
74
|
-
path:
|
|
121
|
+
path: normalizedPath,
|
|
75
122
|
});
|
|
76
123
|
// Phase 2 widened WikiBlob to allow encoding='signed_url' (binary served
|
|
77
124
|
// via preview URL) and still admits the legacy 'base64' inline shape,
|
|
78
125
|
// both of which omit or encode `content` in ways the CLI's string surface
|
|
79
|
-
// cannot use. Only utf-8 text is safe to return verbatim
|
|
80
|
-
// gets a clear error — callers that want bytes should fetch via the
|
|
81
|
-
// signed_url flow.
|
|
126
|
+
// cannot use. Only utf-8 text is safe to return verbatim.
|
|
82
127
|
if (blob.encoding !== 'utf-8' || blob.content == null) {
|
|
83
|
-
throw new Error(`wiki
|
|
128
|
+
throw new Error(`wiki file is binary, not printable text (encoding=${blob.encoding}): ${normalizedPath}. ` +
|
|
129
|
+
'Binary files are viewable in the web UI.');
|
|
84
130
|
}
|
|
85
131
|
return {
|
|
86
132
|
wiki,
|
|
87
133
|
content: blob.content,
|
|
88
134
|
size: blob.size,
|
|
135
|
+
source: 'api',
|
|
89
136
|
};
|
|
90
137
|
}
|
|
91
138
|
// ---------------------------------------------------------------------------
|
|
@@ -259,20 +306,17 @@ export async function getAfcsStatus(ctx, wikiRef) {
|
|
|
259
306
|
catch {
|
|
260
307
|
// Changesets API may fail — non-fatal for status
|
|
261
308
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
};
|
|
309
|
+
// No permissions block: per-path access is queryable via `parall wiki
|
|
310
|
+
// access <path>`; a whole-tree writability summary needs the path-scope
|
|
311
|
+
// semantics still moving in the restriction-layer work.
|
|
266
312
|
return {
|
|
267
313
|
wiki_id: wiki.id,
|
|
268
314
|
wiki_slug: wiki.slug,
|
|
269
315
|
wiki_name: wiki.name,
|
|
270
316
|
mount_path: mount.root,
|
|
271
|
-
mode: 'read_write',
|
|
272
317
|
default_ref: wiki.default_branch,
|
|
273
318
|
local_changes: localChanges,
|
|
274
319
|
changesets,
|
|
275
|
-
permissions,
|
|
276
320
|
};
|
|
277
321
|
}
|
|
278
322
|
export async function getAfcsDiff(ctx, wikiRef) {
|
|
@@ -295,7 +339,7 @@ export async function getAfcsDiff(ctx, wikiRef) {
|
|
|
295
339
|
// ---------------------------------------------------------------------------
|
|
296
340
|
export async function proposeWikiChangeset(ctx, wikiRef, options) {
|
|
297
341
|
const wiki = await resolveWikiRef(ctx, wikiRef);
|
|
298
|
-
const mount = await requireLocalWikiMount(ctx, wiki
|
|
342
|
+
const mount = await requireLocalWikiMount(ctx, wiki);
|
|
299
343
|
const title = options.title.trim();
|
|
300
344
|
if (!title) {
|
|
301
345
|
throw new Error('title is required');
|
|
@@ -303,7 +347,38 @@ export async function proposeWikiChangeset(ctx, wikiRef, options) {
|
|
|
303
347
|
const manifest = await readLocalManifest(mount.root);
|
|
304
348
|
const diff = await computeLocalDiff(mount, manifest);
|
|
305
349
|
if (diff.changedPaths.length === 0) {
|
|
306
|
-
throw new Error(`no changes to propose for wiki ${wiki.slug}`
|
|
350
|
+
throw new Error(`no changes to propose for wiki ${wiki.slug} — edit files under ${mount.root} first, ` +
|
|
351
|
+
'then run `parall wiki diff` to confirm what will be proposed');
|
|
352
|
+
}
|
|
353
|
+
// Stale-base precheck (fail-closed): compare each changed path's sync
|
|
354
|
+
// baseline against the server's CURRENT manifest. Proposing from a stale
|
|
355
|
+
// baseline would upload full content that silently rolls back concurrent
|
|
356
|
+
// edits. The server re-validates via base_sha (authoritative — this check
|
|
357
|
+
// just fails earlier with the complete file list).
|
|
358
|
+
const serverManifest = (await wikiServiceFetch(mount.manifestUrl, requireMountToken(mount)));
|
|
359
|
+
const serverShaByPath = new Map(serverManifest.data.map((e) => [e.path, e.sha]));
|
|
360
|
+
const staleLines = [];
|
|
361
|
+
for (const filePath of diff.changedPaths) {
|
|
362
|
+
const baseSha = diff.baseShaByPath.get(filePath);
|
|
363
|
+
const serverSha = serverShaByPath.get(filePath);
|
|
364
|
+
if (baseSha === undefined) {
|
|
365
|
+
// Locally created file — stale if the server got one in the meantime.
|
|
366
|
+
if (serverSha !== undefined) {
|
|
367
|
+
staleLines.push(`${filePath} (created on the server since your last sync)`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
else if (serverSha === undefined) {
|
|
371
|
+
staleLines.push(`${filePath} (deleted on the server since your last sync)`);
|
|
372
|
+
}
|
|
373
|
+
else if (serverSha !== baseSha) {
|
|
374
|
+
staleLines.push(`${filePath} (modified on the server since your last sync)`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (staleLines.length > 0) {
|
|
378
|
+
throw new Error(`cannot propose: ${staleLines.length} file(s) changed on the server since your last sync:\n` +
|
|
379
|
+
staleLines.map((l) => ` ${l}`).join('\n') +
|
|
380
|
+
'\nRun `parall wiki sync` to pull the latest content (your local edits are preserved; ' +
|
|
381
|
+
'conflicting files get a marker under .parall-wiki/conflicts/), resolve if needed, then propose again.');
|
|
307
382
|
}
|
|
308
383
|
// Build file_changes for the changeset API
|
|
309
384
|
const fileChanges = [];
|
|
@@ -316,25 +391,43 @@ export async function proposeWikiChangeset(ctx, wikiRef, options) {
|
|
|
316
391
|
fileChanges.push({
|
|
317
392
|
path: filePath,
|
|
318
393
|
action: 'delete',
|
|
394
|
+
base_sha: baseEntry?.sha,
|
|
319
395
|
});
|
|
320
396
|
}
|
|
321
397
|
else {
|
|
322
398
|
const content = await fs.readFile(localPath);
|
|
399
|
+
// The changeset API is text-only (the server rejects binary with 422
|
|
400
|
+
// USE_UPLOAD, pointing at an endpoint the CLI does not wrap). Reject
|
|
401
|
+
// locally with a clear boundary statement instead.
|
|
402
|
+
if (looksBinary(content)) {
|
|
403
|
+
throw new Error(`cannot propose ${filePath}: wiki changesets support text files only. ` +
|
|
404
|
+
'Remove the binary from the workspace (binary uploads go through the web UI).');
|
|
405
|
+
}
|
|
406
|
+
if (content.length > TEXT_MAX_BYTES) {
|
|
407
|
+
throw new Error(`cannot propose ${filePath}: file is ${content.length} bytes, ` +
|
|
408
|
+
`over the ${TEXT_MAX_BYTES}-byte changeset limit. Split the file or remove it from the workspace.`);
|
|
409
|
+
}
|
|
323
410
|
fileChanges.push({
|
|
324
411
|
path: filePath,
|
|
325
412
|
action: baseEntry ? 'update' : 'create',
|
|
326
413
|
content_base64: content.toString('base64'),
|
|
414
|
+
base_sha: baseEntry?.sha,
|
|
327
415
|
});
|
|
328
416
|
}
|
|
329
417
|
}
|
|
330
418
|
let changeset;
|
|
331
419
|
const existingChangesetId = normalizeOptionalString(options.changesetId);
|
|
332
420
|
if (existingChangesetId) {
|
|
333
|
-
// Re-propose
|
|
421
|
+
// Re-propose: replace_files makes the workspace diff define the
|
|
422
|
+
// changeset's FULL content — the server resets the feature branch to
|
|
423
|
+
// default HEAD first, so a change withdrawn locally (file reverted to
|
|
424
|
+
// baseline) actually disappears from the proposal instead of silently
|
|
425
|
+
// surviving on the branch.
|
|
334
426
|
changeset = await ctx.client.updateWikiChangeset(ctx.orgId, wiki.id, existingChangesetId, {
|
|
335
427
|
title,
|
|
336
428
|
message: normalizeOptionalString(options.message),
|
|
337
429
|
file_changes: fileChanges,
|
|
430
|
+
replace_files: true,
|
|
338
431
|
});
|
|
339
432
|
}
|
|
340
433
|
else {
|
|
@@ -407,34 +500,52 @@ function describeProposeNextAction(changeset, postMergeSync, postMergeSyncError)
|
|
|
407
500
|
// ---------------------------------------------------------------------------
|
|
408
501
|
/**
|
|
409
502
|
* Discard all local changes and restore files to the last synced state.
|
|
410
|
-
*
|
|
503
|
+
* Restores from the local baseline object store (network only as a
|
|
504
|
+
* SHA-verified fallback for pre-objects mounts), so the workspace lands
|
|
505
|
+
* exactly on the manifest baseline — `wiki status` reports clean afterwards.
|
|
411
506
|
*/
|
|
412
507
|
export async function resetWikiWorkspace(ctx, wikiRef) {
|
|
413
508
|
const wiki = await resolveWikiRef(ctx, wikiRef);
|
|
414
509
|
const mount = await requireLocalWikiMount(ctx, wiki);
|
|
415
510
|
const manifest = await readLocalManifest(mount.root);
|
|
416
|
-
//
|
|
511
|
+
// Only touch files that actually diverged from the baseline.
|
|
417
512
|
const changes = await computeLocalChanges(mount);
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
await fs.
|
|
429
|
-
await
|
|
513
|
+
if (changes.length === 0) {
|
|
514
|
+
return { wiki_id: wiki.id, wiki_slug: wiki.slug, files_restored: 0, unrestorable_paths: [] };
|
|
515
|
+
}
|
|
516
|
+
let restored = 0;
|
|
517
|
+
const unrestorable = [];
|
|
518
|
+
for (const change of changes) {
|
|
519
|
+
const entry = manifest.files[change.path];
|
|
520
|
+
const fullPath = resolvePathInsideRoot(mount.root, change.path);
|
|
521
|
+
if (!entry) {
|
|
522
|
+
// Locally created file — discard.
|
|
523
|
+
await fs.rm(fullPath, { force: true });
|
|
524
|
+
await removeEmptyParents(mount.root, path.dirname(fullPath));
|
|
525
|
+
restored++;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const baseline = await loadBaselineContent(mount, change.path, entry.sha);
|
|
529
|
+
if (!baseline) {
|
|
530
|
+
// No baseline available (pre-objects mount, server moved on). Leave the
|
|
531
|
+
// local file untouched rather than overwriting it with non-baseline
|
|
532
|
+
// content; `parall wiki sync` is the path that reconciles this state.
|
|
533
|
+
unrestorable.push(change.path);
|
|
534
|
+
continue;
|
|
430
535
|
}
|
|
536
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
537
|
+
await fs.writeFile(fullPath, baseline);
|
|
538
|
+
restored++;
|
|
539
|
+
}
|
|
540
|
+
if (unrestorable.length > 0) {
|
|
541
|
+
console.warn(`[wiki reset] ${unrestorable.length} file(s) had no recoverable baseline and were left as-is: ` +
|
|
542
|
+
`${unrestorable.join(', ')} — run \`parall wiki sync\` to reconcile`);
|
|
431
543
|
}
|
|
432
|
-
// Remove any local file not in manifest
|
|
433
|
-
await removeFilesNotInManifest(mount.root, manifest);
|
|
434
544
|
return {
|
|
435
545
|
wiki_id: wiki.id,
|
|
436
546
|
wiki_slug: wiki.slug,
|
|
437
|
-
files_restored:
|
|
547
|
+
files_restored: restored,
|
|
548
|
+
unrestorable_paths: unrestorable,
|
|
438
549
|
};
|
|
439
550
|
}
|
|
440
551
|
export async function listWikiChangesets(ctx, wikiRef) {
|
|
@@ -495,10 +606,32 @@ export async function requestWikiAccess(ctx, wikiRef, targetPath, reason) {
|
|
|
495
606
|
message: 'Access request submitted. Waiting for approval.',
|
|
496
607
|
};
|
|
497
608
|
}
|
|
609
|
+
/**
|
|
610
|
+
* Wiki history. Without a path: recent wiki operations (audit log). With a
|
|
611
|
+
* path: that file's commit history from the default branch.
|
|
612
|
+
*/
|
|
498
613
|
export async function getWikiLog(ctx, wikiRef, filePath) {
|
|
499
614
|
const wiki = await resolveWikiRef(ctx, wikiRef);
|
|
500
|
-
|
|
501
|
-
|
|
615
|
+
// Same normalization as every other path entry point — `./docs/a.md` or
|
|
616
|
+
// `/docs/a.md` would otherwise reach the server un-normalized and read
|
|
617
|
+
// back as an empty history.
|
|
618
|
+
const normalizedPath = filePath?.trim() ? normalizeRequiredWikiPath(filePath) : undefined;
|
|
619
|
+
if (normalizedPath) {
|
|
620
|
+
const commits = await ctx.client.getWikiFileCommits(ctx.orgId, wiki.id, normalizedPath);
|
|
621
|
+
return {
|
|
622
|
+
wiki_id: wiki.id,
|
|
623
|
+
wiki_slug: wiki.slug,
|
|
624
|
+
wiki_name: wiki.name,
|
|
625
|
+
path: normalizedPath,
|
|
626
|
+
entries: commits.data.map((c) => ({
|
|
627
|
+
type: 'commit',
|
|
628
|
+
id: c.sha,
|
|
629
|
+
path: normalizedPath,
|
|
630
|
+
action: c.title,
|
|
631
|
+
author_name: c.author_name,
|
|
632
|
+
created_at: c.date,
|
|
633
|
+
})),
|
|
634
|
+
};
|
|
502
635
|
}
|
|
503
636
|
const ops = await ctx.client.getWikiOperations(ctx.orgId, wiki.id);
|
|
504
637
|
const entries = ops.data.map((op) => ({
|
|
@@ -516,12 +649,16 @@ export async function getWikiLog(ctx, wikiRef, filePath) {
|
|
|
516
649
|
};
|
|
517
650
|
}
|
|
518
651
|
/**
|
|
519
|
-
*
|
|
520
|
-
*
|
|
652
|
+
* Sync wiki workspaces via REST (manifest comparison + bulk download).
|
|
653
|
+
* Without `wikiRef`: every accessible wiki is synced and stale mount
|
|
654
|
+
* directories are pruned. With `wikiRef`: only that wiki is synced and
|
|
655
|
+
* pruning is skipped — other wikis' workspaces must survive a scoped sync.
|
|
521
656
|
* Mount path is determined client-side: {mountRoot}/{slug}/
|
|
522
657
|
*/
|
|
523
|
-
export async function syncAllMounts(ctx) {
|
|
524
|
-
const
|
|
658
|
+
export async function syncAllMounts(ctx, wikiRef) {
|
|
659
|
+
const allWikis = await ctx.client.getWikis(ctx.orgId);
|
|
660
|
+
const target = wikiRef?.trim() ? await resolveWikiRef(ctx, wikiRef) : undefined;
|
|
661
|
+
const wikis = target ? allWikis.filter((w) => w.id === target.id) : allWikis;
|
|
525
662
|
const mountRoot = ctx.mountRoot?.trim() || process.env.PRLL_WIKI_MOUNT_ROOT?.trim();
|
|
526
663
|
const activeMountPaths = new Set();
|
|
527
664
|
const synced = [];
|
|
@@ -538,7 +675,9 @@ export async function syncAllMounts(ctx) {
|
|
|
538
675
|
failed: outcome.failed,
|
|
539
676
|
});
|
|
540
677
|
}
|
|
541
|
-
|
|
678
|
+
if (!target) {
|
|
679
|
+
await pruneStaleMounts(mountRoot, activeMountPaths);
|
|
680
|
+
}
|
|
542
681
|
const ok = synced.every((s) => s.failed.length === 0);
|
|
543
682
|
return { ok, mounts: wikis.length, synced };
|
|
544
683
|
}
|
|
@@ -695,6 +834,7 @@ export async function syncSingleWiki(ctx, wiki, mountPath) {
|
|
|
695
834
|
}
|
|
696
835
|
// 5. Apply actions and start with the previous manifest, mutating per action.
|
|
697
836
|
const newFiles = { ...localManifest.files };
|
|
837
|
+
const downloadedBySha = new Map();
|
|
698
838
|
const conflicts = [];
|
|
699
839
|
const failed = [];
|
|
700
840
|
let applied = 0;
|
|
@@ -763,6 +903,7 @@ export async function syncSingleWiki(ctx, wiki, mountPath) {
|
|
|
763
903
|
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
764
904
|
await fs.writeFile(fullPath, buf);
|
|
765
905
|
newFiles[action.path] = { sha: action.remoteSha, size: action.remoteSize };
|
|
906
|
+
downloadedBySha.set(action.remoteSha, buf);
|
|
766
907
|
applied++;
|
|
767
908
|
break;
|
|
768
909
|
}
|
|
@@ -853,13 +994,16 @@ export async function syncSingleWiki(ctx, wiki, mountPath) {
|
|
|
853
994
|
}
|
|
854
995
|
}
|
|
855
996
|
}
|
|
856
|
-
// 6. Persist new manifest
|
|
997
|
+
// 6. Persist new manifest + refresh the baseline object store so diff and
|
|
998
|
+
// reset can always reproduce the exact synced content locally.
|
|
857
999
|
const newManifest = {
|
|
858
1000
|
wiki_id: wiki.id,
|
|
859
1001
|
synced_at: new Date().toISOString(),
|
|
860
1002
|
files: newFiles,
|
|
861
1003
|
};
|
|
862
1004
|
await writeLocalManifest(mountPath, newManifest);
|
|
1005
|
+
await ensureBaselineObjects(mountPath, newFiles, downloadedBySha);
|
|
1006
|
+
await gcBaselineObjects(mountPath, newFiles);
|
|
863
1007
|
if (conflicts.length > 0) {
|
|
864
1008
|
for (const c of conflicts) {
|
|
865
1009
|
const where = c.remoteCopyPath ? ` → ${path.relative(mountPath, c.remoteCopyPath)}` : '';
|
|
@@ -949,6 +1093,104 @@ async function writeLocalManifest(mountRoot, manifest) {
|
|
|
949
1093
|
await fs.writeFile(path.join(metaDir, LOCAL_MANIFEST_FILE), JSON.stringify(manifest, null, 2), 'utf8');
|
|
950
1094
|
}
|
|
951
1095
|
// ---------------------------------------------------------------------------
|
|
1096
|
+
// Baseline object store (.parall-wiki/objects/<sha>)
|
|
1097
|
+
//
|
|
1098
|
+
// Content-addressed copies of the last-synced version of every manifest file.
|
|
1099
|
+
// diff/status/reset read the true sync baseline from here instead of fetching
|
|
1100
|
+
// the server's CURRENT content (which is the wrong base whenever the server
|
|
1101
|
+
// has moved past the local sync point).
|
|
1102
|
+
// ---------------------------------------------------------------------------
|
|
1103
|
+
function baselineObjectPath(mountRoot, sha) {
|
|
1104
|
+
return path.join(mountRoot, PRLL_WIKI_DIR, OBJECTS_DIR, sha);
|
|
1105
|
+
}
|
|
1106
|
+
async function readBaselineObject(mountRoot, sha) {
|
|
1107
|
+
try {
|
|
1108
|
+
return await fs.readFile(baselineObjectPath(mountRoot, sha));
|
|
1109
|
+
}
|
|
1110
|
+
catch {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
async function writeBaselineObject(mountRoot, sha, content) {
|
|
1115
|
+
const target = baselineObjectPath(mountRoot, sha);
|
|
1116
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
1117
|
+
await fs.writeFile(target, content);
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Make sure every manifest entry has its baseline object on disk.
|
|
1121
|
+
* Content sources, in order: bytes downloaded during this sync
|
|
1122
|
+
* (`contentBySha`), then the local working-tree file when its blob SHA still
|
|
1123
|
+
* matches the manifest (clean file — typical for pre-objects mounts being
|
|
1124
|
+
* upgraded). Files that are locally dirty AND missing an object stay absent;
|
|
1125
|
+
* diff falls back per-file (see loadBaselineContent).
|
|
1126
|
+
*/
|
|
1127
|
+
async function ensureBaselineObjects(mountRoot, files, contentBySha) {
|
|
1128
|
+
for (const [relPath, entry] of Object.entries(files)) {
|
|
1129
|
+
if (await pathExists(baselineObjectPath(mountRoot, entry.sha))) {
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const downloaded = contentBySha?.get(entry.sha);
|
|
1133
|
+
if (downloaded) {
|
|
1134
|
+
await writeBaselineObject(mountRoot, entry.sha, downloaded);
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
const probe = await probeLocalGitBlob(mountRoot, relPath);
|
|
1138
|
+
if (probe.kind === 'file' && probe.sha === entry.sha) {
|
|
1139
|
+
const fullPath = resolvePathInsideRoot(mountRoot, relPath);
|
|
1140
|
+
await writeBaselineObject(mountRoot, entry.sha, await fs.readFile(fullPath));
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
/** Drop baseline objects no longer referenced by any manifest entry. */
|
|
1145
|
+
async function gcBaselineObjects(mountRoot, files) {
|
|
1146
|
+
const objectsDir = path.join(mountRoot, PRLL_WIKI_DIR, OBJECTS_DIR);
|
|
1147
|
+
let names;
|
|
1148
|
+
try {
|
|
1149
|
+
names = await fs.readdir(objectsDir);
|
|
1150
|
+
}
|
|
1151
|
+
catch {
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const live = new Set(Object.values(files).map((e) => e.sha));
|
|
1155
|
+
for (const name of names) {
|
|
1156
|
+
if (!live.has(name)) {
|
|
1157
|
+
await fs.rm(path.join(objectsDir, name), { force: true });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Load the baseline (last-synced) content for a manifest entry. Objects store
|
|
1163
|
+
* first; when absent (mount predates the objects store and the file is
|
|
1164
|
+
* locally dirty), fall back to downloading the server's current content and
|
|
1165
|
+
* accept it ONLY if its blob SHA matches the manifest base — otherwise the
|
|
1166
|
+
* server has moved and no true baseline is available (returns null).
|
|
1167
|
+
*/
|
|
1168
|
+
async function loadBaselineContent(mount, relPath, expectedSha) {
|
|
1169
|
+
const cached = await readBaselineObject(mount.root, expectedSha);
|
|
1170
|
+
if (cached) {
|
|
1171
|
+
return cached;
|
|
1172
|
+
}
|
|
1173
|
+
if (!mount.token) {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
try {
|
|
1177
|
+
const entries = await bulkDownloadFiles(mount.bulkDownloadUrl, mount.token, [relPath]);
|
|
1178
|
+
const entry = entries[0];
|
|
1179
|
+
if (!entry || entry.error) {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
const content = Buffer.from(entry.content_base64, 'base64');
|
|
1183
|
+
if (computeGitBlobSHA(content) !== expectedSha) {
|
|
1184
|
+
return null;
|
|
1185
|
+
}
|
|
1186
|
+
await writeBaselineObject(mount.root, expectedSha, content);
|
|
1187
|
+
return content;
|
|
1188
|
+
}
|
|
1189
|
+
catch {
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
// ---------------------------------------------------------------------------
|
|
952
1194
|
// REST helpers for wiki-service
|
|
953
1195
|
// ---------------------------------------------------------------------------
|
|
954
1196
|
function resolveApiToken(ctx) {
|
|
@@ -1011,54 +1253,92 @@ export function computeGitBlobSHA(content) {
|
|
|
1011
1253
|
const header = Buffer.from(`blob ${content.length}\0`, 'utf8');
|
|
1012
1254
|
return createHash('sha1').update(header).update(content).digest('hex');
|
|
1013
1255
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1256
|
+
// Exact mirror of the magic signatures where the server's classifier
|
|
1257
|
+
// (filetype.IsText → Go http.DetectContentType → isBinaryContentType)
|
|
1258
|
+
// rejects content that could still be NUL-free, UTF-8-decodable bytes.
|
|
1259
|
+
// Signatures whose match requires NUL or non-UTF-8 bytes (PNG, JPEG, OGG,
|
|
1260
|
+
// MIDI, ICO, …) are deliberately omitted — the UTF-8 + NUL checks below
|
|
1261
|
+
// already cover them. Keep this table in lockstep with the Go sniffing
|
|
1262
|
+
// rules; test/testdata/text-classifier-cases.json is the shared contract
|
|
1263
|
+
// both sides verify against.
|
|
1264
|
+
const BINARY_MAGIC_PREFIXES = [
|
|
1265
|
+
Buffer.from('GIF87a'), // image/gif
|
|
1266
|
+
Buffer.from('GIF89a'), // image/gif
|
|
1267
|
+
Buffer.from('%PDF-'), // application/pdf
|
|
1268
|
+
Buffer.from('BM'), // image/bmp (yes, bare "BM" — Go sniffs it as BMP)
|
|
1269
|
+
Buffer.from('ID3'), // audio/mpeg
|
|
1270
|
+
Buffer.from([0x50, 0x4b, 0x03, 0x04]), // application/zip
|
|
1271
|
+
Buffer.from([0x1f, 0x8b, 0x08]), // application/x-gzip
|
|
1272
|
+
];
|
|
1273
|
+
// Container formats Go sniffs via offset-masked signatures: 4-byte container
|
|
1274
|
+
// tag at offset 0, format tag at offset 8.
|
|
1275
|
+
const BINARY_CONTAINER_SIGS = [
|
|
1276
|
+
{ head: 'RIFF', at8: 'WAVE' }, // audio/wave
|
|
1277
|
+
{ head: 'RIFF', at8: 'AVI ' }, // video/avi
|
|
1278
|
+
{ head: 'RIFF', at8: 'WEBPVP' }, // image/webp
|
|
1279
|
+
{ head: 'FORM', at8: 'AIFF' }, // audio/aiff
|
|
1280
|
+
];
|
|
1281
|
+
const utf8Strict = new TextDecoder('utf-8', { fatal: true });
|
|
1282
|
+
/**
|
|
1283
|
+
* Mirror of the server's text classifier (filetype.IsText): content-type
|
|
1284
|
+
* magic sniff, valid UTF-8, and no NUL bytes — kept aligned so content the
|
|
1285
|
+
* CLI accepts cannot bounce off the server's 422 USE_UPLOAD later. The
|
|
1286
|
+
* shared fixture table (test/testdata/text-classifier-cases.json) is
|
|
1287
|
+
* verified by both this implementation's tests and the Go package's tests.
|
|
1288
|
+
*/
|
|
1289
|
+
export function isWikiTextContent(content) {
|
|
1290
|
+
for (const magic of BINARY_MAGIC_PREFIXES) {
|
|
1291
|
+
if (content.subarray(0, magic.length).equals(magic)) {
|
|
1292
|
+
return false;
|
|
1039
1293
|
}
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
diffFiles.push({ path: filePath, additions: 0, deletions });
|
|
1294
|
+
}
|
|
1295
|
+
for (const sig of BINARY_CONTAINER_SIGS) {
|
|
1296
|
+
if (content.subarray(0, 4).equals(Buffer.from(sig.head)) &&
|
|
1297
|
+
content.subarray(8, 8 + sig.at8.length).equals(Buffer.from(sig.at8))) {
|
|
1298
|
+
return false;
|
|
1046
1299
|
}
|
|
1047
1300
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1301
|
+
if (content.includes(0)) {
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
try {
|
|
1305
|
+
utf8Strict.decode(content);
|
|
1306
|
+
}
|
|
1307
|
+
catch {
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
return true;
|
|
1050
1311
|
}
|
|
1051
|
-
|
|
1312
|
+
function looksBinary(content) {
|
|
1313
|
+
return !isWikiTextContent(content);
|
|
1314
|
+
}
|
|
1315
|
+
/** Compute list of changed files by comparing local disk to local manifest. */
|
|
1316
|
+
async function computeLocalChanges(mount) {
|
|
1317
|
+
const manifest = await readLocalManifest(mount.root);
|
|
1318
|
+
const diff = await computeLocalDiff(mount, manifest);
|
|
1319
|
+
return diff.diffFiles;
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Compute the local workspace diff against the last-synced baseline. Base
|
|
1323
|
+
* content comes from the local objects store (with a SHA-verified network
|
|
1324
|
+
* fallback for pre-objects mounts), so the diff base is always the true sync
|
|
1325
|
+
* point — never the server's current content.
|
|
1326
|
+
*/
|
|
1052
1327
|
async function computeLocalDiff(mount, manifest) {
|
|
1053
1328
|
const diffFiles = [];
|
|
1054
1329
|
const patchParts = [];
|
|
1055
1330
|
const visited = new Set();
|
|
1056
|
-
const
|
|
1331
|
+
const baseShaByPath = new Map();
|
|
1332
|
+
const loadBase = async (relPath, entry) => {
|
|
1333
|
+
const base = await loadBaselineContent(mount, relPath, entry.sha);
|
|
1334
|
+
// A missing baseline (pre-objects mount with a locally dirty file and a
|
|
1335
|
+
// server that has since moved) degrades to an empty base: the patch shows
|
|
1336
|
+
// the full local content as additions. propose() still carries the right
|
|
1337
|
+
// base_sha, so staleness is caught server-side regardless.
|
|
1338
|
+
return base ? base.toString('utf8') : '';
|
|
1339
|
+
};
|
|
1057
1340
|
// Walk local files to find modified/added
|
|
1058
1341
|
await walkLocalFiles(mount.root, mount.root, async (fullPath, relativePath) => {
|
|
1059
|
-
if (!matchesAllowedPrefix(relativePath, [])) {
|
|
1060
|
-
return;
|
|
1061
|
-
}
|
|
1062
1342
|
visited.add(relativePath);
|
|
1063
1343
|
const localContent = await fs.readFile(fullPath);
|
|
1064
1344
|
const localSha = computeGitBlobSHA(localContent);
|
|
@@ -1066,83 +1346,62 @@ async function computeLocalDiff(mount, manifest) {
|
|
|
1066
1346
|
if (!manifestEntry) {
|
|
1067
1347
|
// New file
|
|
1068
1348
|
const localText = localContent.toString('utf8');
|
|
1069
|
-
|
|
1070
|
-
diffFiles.push({ path: relativePath, additions: localLines.length, deletions: 0 });
|
|
1071
|
-
patchParts.push(buildUnifiedDiff(relativePath, '', localText));
|
|
1349
|
+
appendFileDiff(diffFiles, patchParts, relativePath, '', localText, 'create');
|
|
1072
1350
|
}
|
|
1073
1351
|
else if (localSha !== manifestEntry.sha) {
|
|
1074
|
-
// Modified file
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
const baseEntries = await bulkDownloadFiles(mount.bulkDownloadUrl, token, [relativePath]);
|
|
1079
|
-
if (baseEntries.length > 0) {
|
|
1080
|
-
baseText = Buffer.from(baseEntries[0].content_base64, 'base64').toString('utf8');
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
catch {
|
|
1084
|
-
// If we can't fetch base, treat as full addition
|
|
1085
|
-
}
|
|
1086
|
-
const localLines = localText.split('\n');
|
|
1087
|
-
const baseLines = baseText.split('\n');
|
|
1088
|
-
diffFiles.push({
|
|
1089
|
-
path: relativePath,
|
|
1090
|
-
additions: localLines.length,
|
|
1091
|
-
deletions: baseLines.length,
|
|
1092
|
-
});
|
|
1093
|
-
patchParts.push(buildUnifiedDiff(relativePath, baseText, localText));
|
|
1352
|
+
// Modified file
|
|
1353
|
+
baseShaByPath.set(relativePath, manifestEntry.sha);
|
|
1354
|
+
const baseText = await loadBase(relativePath, manifestEntry);
|
|
1355
|
+
appendFileDiff(diffFiles, patchParts, relativePath, baseText, localContent.toString('utf8'), 'update');
|
|
1094
1356
|
}
|
|
1095
1357
|
});
|
|
1096
1358
|
// Find deleted files
|
|
1097
1359
|
for (const [filePath, entry] of Object.entries(manifest.files)) {
|
|
1098
1360
|
if (!visited.has(filePath)) {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
if (baseEntries.length > 0) {
|
|
1103
|
-
baseText = Buffer.from(baseEntries[0].content_base64, 'base64').toString('utf8');
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
catch {
|
|
1107
|
-
// Estimate
|
|
1108
|
-
}
|
|
1109
|
-
const baseLines = baseText.split('\n');
|
|
1110
|
-
diffFiles.push({ path: filePath, additions: 0, deletions: baseLines.length });
|
|
1111
|
-
patchParts.push(buildUnifiedDiff(filePath, baseText, ''));
|
|
1361
|
+
baseShaByPath.set(filePath, entry.sha);
|
|
1362
|
+
const baseText = await loadBase(filePath, entry);
|
|
1363
|
+
appendFileDiff(diffFiles, patchParts, filePath, baseText, '', 'delete');
|
|
1112
1364
|
}
|
|
1113
1365
|
}
|
|
1114
1366
|
diffFiles.sort((a, b) => a.path.localeCompare(b.path));
|
|
1115
1367
|
const changedPaths = diffFiles.map((f) => f.path);
|
|
1116
1368
|
const patch = patchParts.join('');
|
|
1117
|
-
return { changedPaths, diffFiles, patch };
|
|
1118
|
-
}
|
|
1119
|
-
/**
|
|
1120
|
-
function
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1369
|
+
return { changedPaths, diffFiles, patch, baseShaByPath };
|
|
1370
|
+
}
|
|
1371
|
+
/** Append one file's real (Myers) unified diff and +/- counts. */
|
|
1372
|
+
function appendFileDiff(diffFiles, patchParts, filePath, oldText, newText, action) {
|
|
1373
|
+
const hunks = structuredPatch(filePath, filePath, oldText, newText, undefined, undefined, {
|
|
1374
|
+
context: 3,
|
|
1375
|
+
}).hunks;
|
|
1376
|
+
let additions = 0;
|
|
1377
|
+
let deletions = 0;
|
|
1125
1378
|
const parts = [`diff --git a/${filePath} b/${filePath}\n`];
|
|
1126
|
-
if (
|
|
1379
|
+
if (action === 'create') {
|
|
1127
1380
|
parts.push('new file mode 100644\n');
|
|
1128
1381
|
}
|
|
1129
|
-
else if (
|
|
1382
|
+
else if (action === 'delete') {
|
|
1130
1383
|
parts.push('deleted file mode 100644\n');
|
|
1131
1384
|
}
|
|
1132
|
-
parts.push(`--- ${
|
|
1133
|
-
parts.push(`+++ ${
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1385
|
+
parts.push(`--- ${action === 'create' ? '/dev/null' : `a/${filePath}`}\n`);
|
|
1386
|
+
parts.push(`+++ ${action === 'delete' ? '/dev/null' : `b/${filePath}`}\n`);
|
|
1387
|
+
for (const hunk of hunks) {
|
|
1388
|
+
parts.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n`);
|
|
1389
|
+
for (const line of hunk.lines) {
|
|
1390
|
+
parts.push(`${line}\n`);
|
|
1391
|
+
if (line.startsWith('+'))
|
|
1392
|
+
additions++;
|
|
1393
|
+
else if (line.startsWith('-'))
|
|
1394
|
+
deletions++;
|
|
1395
|
+
}
|
|
1137
1396
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1397
|
+
diffFiles.push({ path: filePath, action, additions, deletions });
|
|
1398
|
+
// An empty-file create/delete produces zero hunks but is still a real
|
|
1399
|
+
// change — emit the header-only entry so the patch lists every changed
|
|
1400
|
+
// path. Only a hunk-less UPDATE (identical content, e.g. mode-only noise)
|
|
1401
|
+
// is genuinely nothing to show.
|
|
1402
|
+
if (hunks.length > 0 || action !== 'update') {
|
|
1403
|
+
patchParts.push(parts.join(''));
|
|
1140
1404
|
}
|
|
1141
|
-
return parts.join('');
|
|
1142
|
-
}
|
|
1143
|
-
/** Estimate number of lines from file size (rough: ~40 bytes per line). */
|
|
1144
|
-
function estimateLines(sizeBytes) {
|
|
1145
|
-
return Math.max(1, Math.round(sizeBytes / 40));
|
|
1146
1405
|
}
|
|
1147
1406
|
// ---------------------------------------------------------------------------
|
|
1148
1407
|
// File system helpers
|
|
@@ -1244,13 +1503,11 @@ async function resolveLocalWikiMount(ctx, wiki) {
|
|
|
1244
1503
|
changesetsUrl: `${wikiBase}/changesets`,
|
|
1245
1504
|
};
|
|
1246
1505
|
}
|
|
1247
|
-
async function requireLocalWikiMount(ctx, wiki
|
|
1506
|
+
async function requireLocalWikiMount(ctx, wiki) {
|
|
1248
1507
|
const mount = await resolveLocalWikiMount(ctx, wiki);
|
|
1249
1508
|
if (!mount) {
|
|
1250
|
-
throw new Error(`wiki ${wiki.slug}
|
|
1251
|
-
|
|
1252
|
-
if (options.requireWrite && 'read_write' !== 'read_write') {
|
|
1253
|
-
throw new Error(`wiki ${wiki.slug} mount is read-only`);
|
|
1509
|
+
throw new Error(`wiki ${wiki.slug} has no local workspace yet — run \`parall wiki sync\` to download it ` +
|
|
1510
|
+
'(the sync output prints the workspace path)');
|
|
1254
1511
|
}
|
|
1255
1512
|
return mount;
|
|
1256
1513
|
}
|
|
@@ -1539,14 +1796,21 @@ function stripNodeSectionContent(section) {
|
|
|
1539
1796
|
}
|
|
1540
1797
|
function buildIndexNodes(sections) {
|
|
1541
1798
|
const nodes = [];
|
|
1799
|
+
// File-level frontmatter folds into only the first section of each file
|
|
1800
|
+
// (matching the indexed search-indexer's section-0 gating) so a
|
|
1801
|
+
// frontmatter-only query returns one hit per document, not one per section.
|
|
1802
|
+
const seenFile = new Set();
|
|
1542
1803
|
for (const section of sections) {
|
|
1543
|
-
|
|
1804
|
+
const includeFrontmatter = !seenFile.has(section.path);
|
|
1805
|
+
seenFile.add(section.path);
|
|
1806
|
+
nodes.push(buildIndexedNode(section, includeFrontmatter));
|
|
1544
1807
|
}
|
|
1545
1808
|
return nodes;
|
|
1546
1809
|
}
|
|
1547
1810
|
function parseMarkdownFile(wiki, file) {
|
|
1548
1811
|
const lines = file.content.split('\n');
|
|
1549
1812
|
const frontMatterEnd = detectFrontMatterEnd(lines);
|
|
1813
|
+
const fm = parseFrontMatter(lines, frontMatterEnd);
|
|
1550
1814
|
const headings = extractMarkdownHeadings(lines, frontMatterEnd);
|
|
1551
1815
|
const fileTitle = defaultFileTitle(file.path);
|
|
1552
1816
|
if (headings.length === 0) {
|
|
@@ -1560,7 +1824,11 @@ function parseMarkdownFile(wiki, file) {
|
|
|
1560
1824
|
endLine: lines.length,
|
|
1561
1825
|
content: lines.slice(frontMatterEnd).join('\n').trim(),
|
|
1562
1826
|
});
|
|
1563
|
-
|
|
1827
|
+
// Keep an empty-body doc node when it carries frontmatter, so a
|
|
1828
|
+
// frontmatter-only page still exposes its metadata — matching the Go
|
|
1829
|
+
// parser, which always returns the doc section for a no-heading file.
|
|
1830
|
+
const keep = !!docNode.content || hasFrontMatter(fm);
|
|
1831
|
+
return applyFrontMatter(keep ? [docNode] : [], fm);
|
|
1564
1832
|
}
|
|
1565
1833
|
const nodes = [];
|
|
1566
1834
|
const firstHeadingLine = headings[0].line;
|
|
@@ -1597,7 +1865,24 @@ function parseMarkdownFile(wiki, file) {
|
|
|
1597
1865
|
.trim(),
|
|
1598
1866
|
}));
|
|
1599
1867
|
}
|
|
1600
|
-
return nodes;
|
|
1868
|
+
return applyFrontMatter(nodes, fm);
|
|
1869
|
+
}
|
|
1870
|
+
// applyFrontMatter copies file-level frontmatter onto every section parsed from
|
|
1871
|
+
// the file (frontmatter is a document-level property in OKF). Mirrors the Go
|
|
1872
|
+
// implementation in server/pkg/markdown/section.go. No-op when empty.
|
|
1873
|
+
function applyFrontMatter(sections, fm) {
|
|
1874
|
+
if (!hasFrontMatter(fm)) {
|
|
1875
|
+
return sections;
|
|
1876
|
+
}
|
|
1877
|
+
for (const section of sections) {
|
|
1878
|
+
if (fm.type !== undefined)
|
|
1879
|
+
section.type = fm.type;
|
|
1880
|
+
if (fm.description !== undefined)
|
|
1881
|
+
section.description = fm.description;
|
|
1882
|
+
if (fm.tags !== undefined)
|
|
1883
|
+
section.tags = fm.tags;
|
|
1884
|
+
}
|
|
1885
|
+
return sections;
|
|
1601
1886
|
}
|
|
1602
1887
|
function buildNodeSection(wiki, input) {
|
|
1603
1888
|
const content = input.content.trim();
|
|
@@ -1617,11 +1902,14 @@ function buildNodeSection(wiki, input) {
|
|
|
1617
1902
|
content,
|
|
1618
1903
|
};
|
|
1619
1904
|
}
|
|
1620
|
-
function buildIndexedNode(section) {
|
|
1905
|
+
function buildIndexedNode(section, includeFrontmatter) {
|
|
1621
1906
|
const searchText = [
|
|
1622
1907
|
section.path,
|
|
1623
1908
|
section.title,
|
|
1624
1909
|
section.heading_path?.join(' ') ?? '',
|
|
1910
|
+
...(includeFrontmatter
|
|
1911
|
+
? [section.type ?? '', section.description ?? '', section.tags?.join(' ') ?? '']
|
|
1912
|
+
: []),
|
|
1625
1913
|
section.content,
|
|
1626
1914
|
]
|
|
1627
1915
|
.join('\n')
|
|
@@ -1965,7 +2253,8 @@ function extractMarkdownHeadings(lines, startIndex) {
|
|
|
1965
2253
|
}
|
|
1966
2254
|
return headings;
|
|
1967
2255
|
}
|
|
1968
|
-
|
|
2256
|
+
// Exported for unit testing; mirrors server/pkg/markdown/section.go.
|
|
2257
|
+
export function detectFrontMatterEnd(lines) {
|
|
1969
2258
|
if (lines.length === 0) {
|
|
1970
2259
|
return 0;
|
|
1971
2260
|
}
|