@kaydash9999/banker-plugins 0.5.0 → 0.7.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.
@@ -0,0 +1,554 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * obsidizer canonicalizer: markdown -> canonical markdown, zero per-page input.
4
+ *
5
+ * Byte-deterministic and idempotent by construction: a 2nd run is a no-op.
6
+ * Pure filesystem, zero deps, node >=16.7. The LLM makes the semantic edits
7
+ * (which mentions to link, which backlinks are genuine) BEFORE this runs; this
8
+ * file only makes the result canonical. It never calls a wiki_* tool.
9
+ *
10
+ * Write discipline (PM-7), inherited by both entry points (bare run and the
11
+ * --enable hook): an unchanged page is never written at all; a changed one is
12
+ * staged to a temp file, re-read and SKIPPED if it moved under us (CAS), then
13
+ * published by rename. A torn read makes a page vanish from OMC's index.md; a
14
+ * lost update silently eats a concurrent wiki_ingest merge. The rename kills
15
+ * the torn read outright; the CAS only narrows the lost-update window to that
16
+ * one syscall — a residual race remains, and is accepted (see writeIfUnchanged).
17
+ *
18
+ * Usage: node obsidize.mjs <path> [--profile=auto|omc|generic] [--dry-run] [--json]
19
+ */
20
+ import crypto from 'node:crypto';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const RESERVED_FILES = new Set(['index.md', 'log.md', 'environment.md']);
26
+ const FM_KEYS = ['title', 'tags', 'created', 'updated', 'sources', 'links', 'category', 'confidence', 'schemaVersion'];
27
+ const SCHEMA_VERSION = 1;
28
+ // Bounds GROWTH, not the footer: how many slugs obsidizer may ADD to one. It never costs a
29
+ // footer a link it already carries — see renderFooter.
30
+ const FOOTER_CAP = 12;
31
+ const FOOTER_PREFIX = '관련: ';
32
+ const FOOTER_SEP = ' · ';
33
+ // mergePage appends `\n\n---\n\n## Update (<ts>)\n\n...`; everything from here on is preserve-only.
34
+ const UPDATE_BOUNDARY = /\n\n---\n\n## Update \(/;
35
+ const TMP_SUFFIX = '.obsidizer.tmp';
36
+ // Any `[[...]]`, matching OMC's extractWikiLinks regex (ingest.ts:132-139) exactly.
37
+ const WIKILINK = /\[\[([^\]]+)\]\]/g;
38
+ // D8's positive whitelist, as a form test rather than "whatever names a file on disk".
39
+ // titleToSlug can only ever emit this shape, so it costs an OMC tree nothing.
40
+ const BARE_SLUG = /^[a-z0-9][a-z0-9-]*$/;
41
+ // Opt-out marker. Obsidian renders `%%...%%` as a comment, so it is invisible in the vault.
42
+ const IGNORE_MARKER = '%%obsidizer:ignore%%';
43
+
44
+ /**
45
+ * Slug-ish normalizer for the alias divergence test. Deliberately WITHOUT
46
+ * titleToSlug's 64-char cap: the cap is an OMC filename artifact, and mirroring
47
+ * it here would suppress the alias that restores a cap-truncated title.
48
+ */
49
+ export function norm(x) {
50
+ return String(x).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
51
+ }
52
+
53
+ /** Escape a string for use inside YAML double quotes (mirrors OMC escapeYaml). */
54
+ function escapeYaml(s) {
55
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
56
+ }
57
+
58
+ function unescapeYaml(s) {
59
+ return s.replace(/\\(\\|"|n|r)/g, (_, ch) => (ch === 'n' ? '\n' : ch === 'r' ? '\r' : ch));
60
+ }
61
+
62
+ /** Line-based `key: value` parse, mirroring OMC parseSimpleYaml (a line with no `:` is skipped). */
63
+ function parseSimpleYaml(yaml) {
64
+ const map = {};
65
+ const order = [];
66
+ for (const line of yaml.split('\n')) {
67
+ const colonIdx = line.indexOf(':');
68
+ if (colonIdx === -1) continue;
69
+ const key = line.slice(0, colonIdx).trim();
70
+ if (!key) continue;
71
+ let value = line.slice(colonIdx + 1).trim();
72
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
73
+ value = unescapeYaml(value.slice(1, -1));
74
+ }
75
+ if (!(key in map)) order.push(key);
76
+ map[key] = value;
77
+ }
78
+ return { map, order };
79
+ }
80
+
81
+ /** Parse a FLOW YAML array `[a, b]` (mirrors OMC parseYamlArray — flow form only). */
82
+ function parseYamlArray(value) {
83
+ if (!value) return [];
84
+ const trimmed = value.trim();
85
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
86
+ return trimmed.slice(1, -1).split(',')
87
+ .map((s) => unescapeYaml(s.trim().replace(/^["']|["']$/g, '')))
88
+ .filter(Boolean);
89
+ }
90
+ return trimmed ? [trimmed] : [];
91
+ }
92
+
93
+ /**
94
+ * Split a `---\n...\n---\n` frontmatter block off the body.
95
+ * Returns null when the note has no frontmatter block (we never create one).
96
+ */
97
+ export function parseFrontmatter(raw) {
98
+ const normalized = String(raw).replace(/\r\n/g, '\n');
99
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
100
+ if (!match) return null;
101
+ const { map, order } = parseSimpleYaml(match[1]);
102
+ return { lines: match[1].split('\n'), map, order, body: match[2] };
103
+ }
104
+
105
+ /** The 9 OMC keys, parsed. Returns null unless every key is present — we never fabricate one. */
106
+ export function parseOmcFrontmatter(map) {
107
+ if (!FM_KEYS.every((k) => k in map)) return null;
108
+ return {
109
+ title: String(map.title || ''),
110
+ tags: parseYamlArray(map.tags),
111
+ created: String(map.created || ''),
112
+ updated: String(map.updated || ''),
113
+ sources: parseYamlArray(map.sources),
114
+ links: parseYamlArray(map.links),
115
+ category: String(map.category || 'reference'),
116
+ confidence: String(map.confidence || 'medium'),
117
+ schemaVersion: Number(map.schemaVersion) || SCHEMA_VERSION,
118
+ };
119
+ }
120
+
121
+ /** Emit EXACTLY the 9 keys in OMC's order (mirrors serializePage). Never add, drop, or reorder. */
122
+ export function serializeFrontmatter(fm) {
123
+ return [
124
+ `title: "${escapeYaml(fm.title)}"`,
125
+ `tags: [${fm.tags.map((t) => `"${escapeYaml(t)}"`).join(', ')}]`,
126
+ `created: ${fm.created}`,
127
+ `updated: ${fm.updated}`,
128
+ `sources: [${fm.sources.map((s) => `"${escapeYaml(s)}"`).join(', ')}]`,
129
+ `links: [${fm.links.map((l) => `"${escapeYaml(l)}"`).join(', ')}]`,
130
+ `category: ${fm.category}`,
131
+ `confidence: ${fm.confidence}`,
132
+ `schemaVersion: ${fm.schemaVersion}`,
133
+ ].join('\n');
134
+ }
135
+
136
+ /** Walk lines tracking fenced-code state, so ``` blocks are never mistaken for prose. */
137
+ function walkOutsideFences(text, visit) {
138
+ let fence = null;
139
+ for (const line of text.split('\n')) {
140
+ const m = line.match(/^\s{0,3}(`{3,}|~{3,})/);
141
+ if (m) {
142
+ const ch = m[1][0];
143
+ if (!fence) fence = ch;
144
+ else if (fence === ch) fence = null;
145
+ visit(line, true);
146
+ continue;
147
+ }
148
+ visit(line, fence !== null);
149
+ }
150
+ }
151
+
152
+ /** H1 texts, ignoring `#` lines inside code fences. */
153
+ export function findH1s(body) {
154
+ const out = [];
155
+ walkOutsideFences(body, (line, inFence) => {
156
+ if (inFence) return;
157
+ const m = line.match(/^# (.+)$/);
158
+ if (m) out.push(m[1].trim());
159
+ });
160
+ return out;
161
+ }
162
+
163
+ /** Collapse runs of 3+ blank lines to 2, outside fences only. */
164
+ function collapseBlankLines(text) {
165
+ const out = [];
166
+ let blanks = 0;
167
+ walkOutsideFences(text, (line, inFence) => {
168
+ if (!inFence && line.trim() === '') {
169
+ blanks += 1;
170
+ if (blanks > 2) return;
171
+ } else {
172
+ blanks = 0;
173
+ }
174
+ out.push(line);
175
+ });
176
+ return out.join('\n');
177
+ }
178
+
179
+ /** A trailing `관련: [[a]] · [[b]]` line — the vault's existing footer convention. */
180
+ function isFooterLine(line) {
181
+ return /^관련:\s*\[\[/.test(line);
182
+ }
183
+
184
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
185
+
186
+ /**
187
+ * Obsidian's link target: everything before the first `#` (heading/block anchor) or `|`
188
+ * (display text). `[[a#S]]` and `[[a|A]]` carry the same graph edge as `[[a]]`.
189
+ */
190
+ const linkTarget = (inner) => inner.split(/[#|]/)[0].trim();
191
+
192
+ /**
193
+ * Body wikilinks that resolve to a page on disk, as `<basename>.md`.
194
+ *
195
+ * Read liberally, write strictly: every form Obsidian resolves is recognized here, so no
196
+ * edge is missed; renderFooter still emits bare form only. Fenced code is skipped —
197
+ * Obsidian renders `[[x]]` inside a fence literally and puts no edge in the graph, so
198
+ * absorbing it would mint an edge the vault does not have. Inner text that resolves to
199
+ * nothing on disk is dropped rather than slugified into a phantom (PM-4).
200
+ */
201
+ function extractBodyLinks(body, basenames) {
202
+ const found = new Set();
203
+ walkOutsideFences(body, (line, inFence) => {
204
+ if (inFence) return;
205
+ for (const [, inner] of line.matchAll(WIKILINK)) {
206
+ const file = `${linkTarget(inner)}.md`;
207
+ if (basenames.has(file)) found.add(file);
208
+ }
209
+ });
210
+ return found;
211
+ }
212
+
213
+ /** The link targets the head's current footer already carries; empty when it has no footer. */
214
+ function existingFooterSlugs(head) {
215
+ const lines = head.split('\n');
216
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
217
+ const last = lines.length ? lines[lines.length - 1] : '';
218
+ if (!isFooterLine(last)) return new Set();
219
+ return new Set([...last.matchAll(WIKILINK)].map(([, inner]) => linkTarget(inner)));
220
+ }
221
+
222
+ /**
223
+ * Render the Related footer from the resolvable subset of links[] — bare [[slug]]
224
+ * form only (D8: OMC's extractWikiLinks slugifies any inner text, so a piped or
225
+ * heading form mints a phantom slug into links[]).
226
+ *
227
+ * BARE_SLUG makes D8 a test of the form itself rather than "whatever happens to name a
228
+ * file on disk". Nothing an OMC tree can hold is rejected by it (titleToSlug emits only
229
+ * this shape), and an entry it does reject keeps its OMC edge in links[] rather than being
230
+ * emitted in a form that mints a phantom on next ingest. Precisely: OMC's graph keeps that
231
+ * edge, Obsidian's loses it — `links: ["Weird Name.md"]` is a quoted YAML string, not a
232
+ * `[[…]]` Obsidian reads as an edge. Two graphs, and only one of them survives here.
233
+ *
234
+ * `present` (what the footer already shows) is kept whole however far past FOOTER_CAP it
235
+ * runs; the cap only limits what gets ADDED to it. Truncating instead would delete a
236
+ * resolvable bare [[link]] out of the body — a third way for a link to leave the footer,
237
+ * which "never delete a resolvable bare-form link" has no room for (its only two exceptions
238
+ * are a broken ref and the D8 rejection above, and both are enumerated). The two rules never
239
+ * actually compete: only a hand-written footer arrives over the cap, obsidizer's never does.
240
+ *
241
+ * Idempotent either way: run 2 reads run 1's output as `present`, which leaves no room
242
+ * under the cap, so it adds nothing and re-renders that same sorted set.
243
+ */
244
+ function renderFooter(links, basenames, present = new Set()) {
245
+ const resolvable = [...new Set(
246
+ links.filter((l) => basenames.has(l))
247
+ .map((l) => l.replace(/\.md$/, ''))
248
+ .filter((slug) => BARE_SLUG.test(slug)),
249
+ )].sort(byCodeUnit);
250
+ const kept = resolvable.filter((slug) => present.has(slug));
251
+ const room = Math.max(0, FOOTER_CAP - kept.length);
252
+ const added = resolvable.filter((slug) => !present.has(slug)).slice(0, room);
253
+ const slugs = [...kept, ...added].sort(byCodeUnit);
254
+ return slugs.length ? FOOTER_PREFIX + slugs.map((s) => `[[${s}]]`).join(FOOTER_SEP) : null;
255
+ }
256
+
257
+ /** Replace/append the footer at the end of the head region (never inside a `## Update` section). */
258
+ function applyFooter(head, footer) {
259
+ const lines = head.split('\n');
260
+ const dropTrailingBlanks = () => { while (lines.length && lines[lines.length - 1].trim() === '') lines.pop(); };
261
+ dropTrailingBlanks();
262
+ if (lines.length && isFooterLine(lines[lines.length - 1])) {
263
+ lines.pop();
264
+ dropTrailingBlanks();
265
+ }
266
+ if (footer) lines.push('', footer);
267
+ return lines.join('\n');
268
+ }
269
+
270
+ /** tags: strip a leading `#`, dedupe, keep authorial order (plan §3.6: "tags deduped + stable"). */
271
+ function normalizeTags(tags) {
272
+ const seen = new Set();
273
+ const out = [];
274
+ for (const t of tags) {
275
+ const tag = String(t).replace(/^#/, '').trim();
276
+ if (!tag || seen.has(tag)) continue;
277
+ seen.add(tag);
278
+ out.push(tag);
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * A dir is OMC-managed when it has index.md AND log.md AND at least one
285
+ * non-reserved page carrying the full 9-key frontmatter signature.
286
+ */
287
+ export function detectProfile(target) {
288
+ const dir = fs.statSync(target).isDirectory() ? target : path.dirname(target);
289
+ if (!fs.existsSync(path.join(dir, 'index.md')) || !fs.existsSync(path.join(dir, 'log.md'))) return 'generic';
290
+ for (const f of listMarkdown(dir)) {
291
+ if (RESERVED_FILES.has(f)) continue;
292
+ const fm = parseFrontmatter(read(path.join(dir, f)));
293
+ if (fm && FM_KEYS.every((k) => k in fm.map)) return 'omc';
294
+ }
295
+ return 'generic';
296
+ }
297
+
298
+ const read = (p) => fs.readFileSync(p, 'utf8');
299
+
300
+ /** Sorted explicitly — readdir order is not deterministic across filesystems. */
301
+ function listMarkdown(dir) {
302
+ return fs.readdirSync(dir)
303
+ .filter((f) => f.endsWith('.md') && !f.endsWith('.excalidraw.md'))
304
+ .sort(byCodeUnit);
305
+ }
306
+
307
+ /** Slug index + H1 index for the whole vault: alias uniqueness and footer resolvability need both. */
308
+ export function buildVaultModel(dir) {
309
+ const files = listMarkdown(dir);
310
+ const basenames = new Set(files);
311
+ const pages = new Map();
312
+ const h1Counts = new Map();
313
+ for (const file of files) {
314
+ const raw = read(path.join(dir, file));
315
+ const fm = parseFrontmatter(raw);
316
+ const h1s = findH1s(fm ? fm.body : raw);
317
+ pages.set(file, { file, base: file.replace(/\.md$/, ''), raw, fm, h1s });
318
+ for (const h1 of h1s) h1Counts.set(h1, (h1Counts.get(h1) || 0) + 1);
319
+ }
320
+ return { dir, files, basenames, pages, h1Counts };
321
+ }
322
+
323
+ /**
324
+ * Which notes get `aliases: ["<H1>"]` — generic profile only, all six conditions.
325
+ * The value is the note's own H1, byte for byte; it is never invented.
326
+ */
327
+ export function computeAliases(model, profile) {
328
+ const out = new Map();
329
+ if (profile !== 'generic') return out; // (a)
330
+ for (const page of model.pages.values()) {
331
+ if (!page.fm || page.h1s.length !== 1) continue; // (b)
332
+ const h1 = page.h1s[0];
333
+ if (norm(h1) === norm(page.base)) continue; // (c)
334
+ const others = [...model.pages.values()].filter((p) => p.file !== page.file);
335
+ if (others.some((p) => p.base === norm(h1))) continue; // (d)
336
+ if ((model.h1Counts.get(h1) || 0) > 1) continue; // (e)
337
+ if (page.fm.order.some((k) => k.toLowerCase() === 'aliases')) continue; // (f)
338
+ out.set(page.file, h1);
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /**
344
+ * markdown -> canonical markdown. Pure: no clock unless `now` is passed, and
345
+ * `now` is only consumed when the content actually changed (§3.6).
346
+ */
347
+ export function canonicalizeText(raw, { profile, model, alias = null, now = null } = {}) {
348
+ const source = String(raw).replace(/\r\n/g, '\n');
349
+ const fm = parseFrontmatter(source);
350
+ if (!fm) return { text: source, changed: source !== String(raw), skipped: 'no-frontmatter' };
351
+
352
+ const basenames = model && model.basenames ? model.basenames : new Set();
353
+ let out;
354
+
355
+ if (profile === 'omc') {
356
+ const omc = parseOmcFrontmatter(fm.map);
357
+ if (!omc) return { text: source, changed: source !== String(raw), skipped: 'not-9-key' };
358
+ omc.tags = normalizeTags(omc.tags);
359
+ // Absorb the body's resolvable wikilinks into links[] BEFORE the footer is rebuilt.
360
+ // The footer is rendered from links[] alone, so without this a footer link that never
361
+ // entered links[] — a hand-added one; OMC-written footers always satisfy
362
+ // links[] ⊇ footer because OMC derives links[] via extractWikiLinks(content) — would
363
+ // be silently dropped and the graph edge lost. Same monotonic shape as OMC's own
364
+ // mergePage (`[...existing, ...extractWikiLinks(new)]`): links[] only ever grows, so
365
+ // this stays idempotent (run 2 finds them present ⇒ no-op) and invents nothing — the
366
+ // edge is already in the body, and only resolvable targets are absorbed.
367
+ omc.links = [...new Set([...omc.links, ...extractBodyLinks(fm.body, basenames)])].sort(byCodeUnit);
368
+ const footer = renderFooter(omc.links, basenames, existingFooterSlugs(splitUpdate(fm.body).head));
369
+ const body = canonicalizeBody(fm.body, footer);
370
+ out = `---\n${serializeFrontmatter(omc)}\n---\n${body}`;
371
+ if (now && out !== source) {
372
+ omc.updated = now;
373
+ out = `---\n${serializeFrontmatter(omc)}\n---\n${body}`;
374
+ }
375
+ } else {
376
+ // Generic: existing keys and their order are preserved verbatim; `aliases` is
377
+ // the only permitted addition, appended last, always double-quoted, FLOW form.
378
+ const lines = fm.lines.slice();
379
+ if (alias) lines.push(`aliases: ["${escapeYaml(alias)}"]`);
380
+ const body = canonicalizeBody(fm.body, null);
381
+ out = `---\n${lines.join('\n')}\n---\n${body}`;
382
+ }
383
+
384
+ return { text: out, changed: out !== String(raw), skipped: null };
385
+ }
386
+
387
+ /** Split off the head: everything from a `## Update (<ts>)` heading on is preserve-only. */
388
+ function splitUpdate(body) {
389
+ const m = body.match(UPDATE_BOUNDARY);
390
+ return m ? { head: body.slice(0, m.index), tail: body.slice(m.index) } : { head: body, tail: '' };
391
+ }
392
+
393
+ /** Head region is canonicalized; a `## Update (<ts>)` section is copied through byte for byte. */
394
+ function canonicalizeBody(body, footer) {
395
+ const { head, tail } = splitUpdate(body);
396
+ let out = collapseBlankLines(head);
397
+ if (footer !== null) out = applyFooter(out, footer);
398
+ return `${out}${tail}`.replace(/\s+$/, '') + '\n';
399
+ }
400
+
401
+ /** Remove a temp file. A no-op once it has been renamed into place. */
402
+ function discardTemp(tmp) {
403
+ try { fs.unlinkSync(tmp); } catch { /* renamed away, or never created */ }
404
+ }
405
+
406
+ /**
407
+ * Write `text` into a fresh temp file beside `file` and fsync it; the caller renames it
408
+ * into place. Never a partial in-place write (PM-7).
409
+ *
410
+ * Mirrors the shape of OMC's atomicWriteFileSync (atomic-write.ts:166-206) — the bar the
411
+ * plan holds obsidizer's own writes to — reimplemented rather than borrowed (§6.2):
412
+ * randomUUID so two concurrent obsidizer processes can never collide on one temp path,
413
+ * `wx` so the open fails instead of truncating a temp file somebody else created, fsync
414
+ * so a rename cannot publish a short write (NFS).
415
+ */
416
+ function writeTemp(file, text) {
417
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}${TMP_SUFFIX}.${crypto.randomUUID()}`);
418
+ // A failed `wx` open either found somebody else's file or created nothing, so there is
419
+ // deliberately no cleanup on this line — only past it do we own `tmp`.
420
+ const fd = fs.openSync(tmp, 'wx', 0o600);
421
+ try {
422
+ fs.writeFileSync(fd, text, 'utf8');
423
+ fs.fsyncSync(fd);
424
+ } catch (err) {
425
+ fs.closeSync(fd);
426
+ discardTemp(tmp);
427
+ throw err;
428
+ }
429
+ fs.closeSync(fd);
430
+ return tmp;
431
+ }
432
+
433
+ /**
434
+ * Stage the write, then read-back CAS, then rename (PM-7). `baseline` is the exact bytes
435
+ * the canonical text was computed from; if the file no longer matches, another writer
436
+ * (a concurrent wiki_ingest merging a `## Update` section) touched it since we read it,
437
+ * so writing would silently clobber that merge. Skip instead — the next run converges the
438
+ * page to identical bytes (one canonicalizer, one fixed point).
439
+ *
440
+ * The temp file is written BEFORE the CAS re-read on purpose: it moves the whole staging
441
+ * write out of the race window, leaving a single `rename` syscall between the check and
442
+ * the publish.
443
+ *
444
+ * That window is narrowed, NOT closed. A writer landing between the re-read and the
445
+ * rename is still clobbered. The residue is accepted rather than solved: closing it needs
446
+ * a lock the other writer honors, and depending on OMC's internal `.wiki-lock` is refused
447
+ * (§6.2 / ADR-c) — borrowing another plugin's private file breaks silently the day it is
448
+ * renamed. So this is a real reduction, not an elimination; do not read it as one.
449
+ *
450
+ * Returns false when the write was skipped.
451
+ */
452
+ function writeIfUnchanged(file, baseline, text) {
453
+ const tmp = writeTemp(file, text);
454
+ try {
455
+ let current;
456
+ try {
457
+ current = fs.readFileSync(file, 'utf8');
458
+ } catch {
459
+ return false; // deleted mid-run; nothing of ours to preserve
460
+ }
461
+ if (current !== baseline) return false;
462
+ fs.renameSync(tmp, file);
463
+ return true;
464
+ } finally {
465
+ discardTemp(tmp); // a no-op once the rename above consumed it
466
+ }
467
+ }
468
+
469
+ export function run(target, { profile = 'auto', dryRun = false, now = null } = {}) {
470
+ if (!fs.existsSync(target)) throw new Error(`path not found: ${target}`);
471
+ const isFile = fs.statSync(target).isFile();
472
+ const dir = isFile ? path.dirname(target) : target;
473
+ const resolved = profile === 'auto' ? detectProfile(target) : profile;
474
+ const model = buildVaultModel(dir);
475
+ const aliases = computeAliases(model, resolved);
476
+ const stamp = now || new Date().toISOString();
477
+
478
+ const targets = isFile ? [path.basename(target)] : model.files;
479
+ const report = { profile: resolved, root: dir, dryRun, scanned: 0, changed: [], unchanged: 0, skipped: [], aliasesAdded: 0 };
480
+
481
+ for (const file of targets) {
482
+ const page = model.pages.get(file);
483
+ if (!page) throw new Error(`not a markdown file in ${dir}: ${file}`);
484
+ if (resolved === 'omc' && RESERVED_FILES.has(file)) {
485
+ report.skipped.push({ file, reason: 'reserved' });
486
+ continue;
487
+ }
488
+ report.scanned += 1;
489
+ // Opt-out: the marker anywhere in the file means hands off — no canonicalize, no
490
+ // write. The page still counts in the vault model (its slug resolves links, its H1
491
+ // still gates an alias elsewhere): "do not modify" is not "does not exist".
492
+ if (page.raw.includes(IGNORE_MARKER)) {
493
+ report.skipped.push({ file, reason: 'ignored' });
494
+ continue;
495
+ }
496
+ const alias = aliases.get(file) || null;
497
+ const res = canonicalizeText(page.raw, { profile: resolved, model, alias, now: stamp });
498
+ if (res.skipped) {
499
+ report.skipped.push({ file, reason: res.skipped });
500
+ continue;
501
+ }
502
+ if (!res.changed) {
503
+ report.unchanged += 1;
504
+ continue;
505
+ }
506
+ if (!dryRun && !writeIfUnchanged(path.join(dir, file), page.raw, res.text)) {
507
+ report.skipped.push({ file, reason: 'changed concurrently' });
508
+ continue;
509
+ }
510
+ report.changed.push(file);
511
+ if (alias) report.aliasesAdded += 1;
512
+ }
513
+ return report;
514
+ }
515
+
516
+ function main(argv) {
517
+ const args = argv.filter((a) => !a.startsWith('--'));
518
+ const flag = (name) => argv.includes(`--${name}`);
519
+ const opt = (name, def) => {
520
+ const hit = argv.find((a) => a.startsWith(`--${name}=`));
521
+ return hit ? hit.slice(name.length + 3) : def;
522
+ };
523
+ if (args.length !== 1) {
524
+ console.error('usage: obsidize.mjs <path> [--profile=auto|omc|generic] [--dry-run] [--json]');
525
+ return 2;
526
+ }
527
+ const profile = opt('profile', 'auto');
528
+ if (!['auto', 'omc', 'generic'].includes(profile)) {
529
+ console.error(`unknown profile: ${profile}`);
530
+ return 2;
531
+ }
532
+ const report = run(path.resolve(args[0]), { profile, dryRun: flag('dry-run') });
533
+ if (flag('json')) {
534
+ console.log(JSON.stringify(report));
535
+ } else {
536
+ const tag = report.dryRun ? ' (dry-run)' : '';
537
+ console.log(`profile: ${report.profile}${tag}`);
538
+ console.log(`scanned: ${report.scanned} · changed: ${report.changed.length} · unchanged: ${report.unchanged} · aliases: ${report.aliasesAdded} · skipped: ${report.skipped.length}`);
539
+ for (const f of report.changed) console.log(` changed ${f}`);
540
+ for (const s of report.skipped) console.log(` skipped ${s.file} (${s.reason})`);
541
+ }
542
+ return 0;
543
+ }
544
+
545
+ // fileURLToPath, not URL.pathname: the latter yields "/C:/..." on Windows and never matches argv[1].
546
+ const invoked = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
547
+ if (invoked) {
548
+ try {
549
+ process.exit(main(process.argv.slice(2)));
550
+ } catch (err) {
551
+ console.error(`obsidize: ${err.message}`);
552
+ process.exit(1);
553
+ }
554
+ }