dead-links 0.2.0 → 0.4.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 +61 -242
- package/dist/index.js +389 -681
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command } from "commander";
|
|
4
|
+
import { Command, Option } from "commander";
|
|
5
|
+
import { readFileSync } from "fs";
|
|
5
6
|
import { writeFile } from "fs/promises";
|
|
6
7
|
import ora from "ora";
|
|
7
8
|
|
|
@@ -24,6 +25,7 @@ async function loadVault(vaultPath, customIgnore = []) {
|
|
|
24
25
|
const notes = [];
|
|
25
26
|
for (const file of files) {
|
|
26
27
|
const fullPath = resolve(absolutePath, file);
|
|
28
|
+
const relativePath = file.replace(/\\/g, "/");
|
|
27
29
|
const content = await readFile(fullPath, "utf-8");
|
|
28
30
|
const parsed = matter(content);
|
|
29
31
|
const aliases = [];
|
|
@@ -36,7 +38,7 @@ async function loadVault(vaultPath, customIgnore = []) {
|
|
|
36
38
|
}
|
|
37
39
|
notes.push({
|
|
38
40
|
path: fullPath,
|
|
39
|
-
relativePath
|
|
41
|
+
relativePath,
|
|
40
42
|
content: parsed.content,
|
|
41
43
|
aliases,
|
|
42
44
|
links: []
|
|
@@ -155,8 +157,42 @@ var AliasResolver = class {
|
|
|
155
157
|
}
|
|
156
158
|
};
|
|
157
159
|
|
|
160
|
+
// src/core/link-resolver.ts
|
|
161
|
+
import { dirname, isAbsolute, normalize, resolve as resolve2 } from "path";
|
|
162
|
+
var REMOTE_TARGET = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
|
|
163
|
+
function isExternalTarget(target) {
|
|
164
|
+
return REMOTE_TARGET.test(target) || target.startsWith("#");
|
|
165
|
+
}
|
|
166
|
+
function resolveNoteLink(link, source, notes, aliases, vaultPath) {
|
|
167
|
+
if (isExternalTarget(link.target)) return null;
|
|
168
|
+
if (link.type === "markdown") {
|
|
169
|
+
const target = decodeTarget(link.target).replace(/\\/g, "/");
|
|
170
|
+
const sourceRelativePath = normalize(resolve2(dirname(source.path), target));
|
|
171
|
+
const exact = notes.find((note) => normalize(note.path) === sourceRelativePath);
|
|
172
|
+
if (exact) return exact.path;
|
|
173
|
+
const vaultRelativePath = normalize(resolve2(vaultPath, target.replace(/^\/+/, "")));
|
|
174
|
+
const vaultRelative = notes.find((note) => normalize(note.path) === vaultRelativePath);
|
|
175
|
+
if (vaultRelative) return vaultRelative.path;
|
|
176
|
+
}
|
|
177
|
+
return aliases.resolve(decodeTarget(link.target));
|
|
178
|
+
}
|
|
179
|
+
function resolveAttachmentPath(target, source, vaultPath) {
|
|
180
|
+
const decoded = decodeTarget(target).replace(/\\/g, "/");
|
|
181
|
+
if (isAbsolute(decoded)) return normalize(decoded);
|
|
182
|
+
const relativeToSource = normalize(resolve2(dirname(source.path), decoded));
|
|
183
|
+
if (relativeToSource.startsWith(normalize(vaultPath))) return relativeToSource;
|
|
184
|
+
return normalize(resolve2(vaultPath, decoded.replace(/^\/+/, "")));
|
|
185
|
+
}
|
|
186
|
+
function decodeTarget(target) {
|
|
187
|
+
try {
|
|
188
|
+
return decodeURIComponent(target);
|
|
189
|
+
} catch {
|
|
190
|
+
return target;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
158
194
|
// src/core/graph-builder.ts
|
|
159
|
-
function buildGraph(notes, resolver) {
|
|
195
|
+
function buildGraph(notes, resolver, vaultPath) {
|
|
160
196
|
const notePathMap = /* @__PURE__ */ new Map();
|
|
161
197
|
for (const note of notes) {
|
|
162
198
|
notePathMap.set(note.path, {
|
|
@@ -168,10 +204,8 @@ function buildGraph(notes, resolver) {
|
|
|
168
204
|
for (const note of notes) {
|
|
169
205
|
const node = notePathMap.get(note.path);
|
|
170
206
|
for (const link of note.links) {
|
|
171
|
-
if (link.
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
const resolvedPath = resolver.resolve(link.target);
|
|
207
|
+
if (isExternalTarget(link.target)) continue;
|
|
208
|
+
const resolvedPath = resolveNoteLink(link, note, notes, resolver, vaultPath);
|
|
175
209
|
if (resolvedPath && notePathMap.has(resolvedPath)) {
|
|
176
210
|
if (!node.outgoing.includes(resolvedPath)) {
|
|
177
211
|
node.outgoing.push(resolvedPath);
|
|
@@ -233,23 +267,34 @@ function isAttachment(target) {
|
|
|
233
267
|
const ext = target.split(".").pop()?.toLowerCase();
|
|
234
268
|
return ext ? ATTACHMENT_EXTENSIONS.has(ext) : false;
|
|
235
269
|
}
|
|
236
|
-
function analyze(notes, vaultPath, checkAttachments = false) {
|
|
270
|
+
function analyze(notes, vaultPath, checkAttachments = false, ignoreFolders = []) {
|
|
237
271
|
const resolver = new AliasResolver(notes);
|
|
238
|
-
const graph = buildGraph(notes, resolver);
|
|
272
|
+
const graph = buildGraph(notes, resolver, vaultPath);
|
|
239
273
|
const brokenLinks = [];
|
|
240
274
|
const orphanNotes = [];
|
|
241
275
|
for (const node of graph) {
|
|
242
276
|
if (node.incoming.length === 0 && node.outgoing.length === 0) {
|
|
243
|
-
|
|
277
|
+
if (!isIgnoredFolder(node.note.relativePath, ignoreFolders)) {
|
|
278
|
+
orphanNotes.push(node.note);
|
|
279
|
+
}
|
|
244
280
|
}
|
|
245
281
|
for (const link of node.note.links) {
|
|
246
|
-
if (link.
|
|
282
|
+
if (isExternalTarget(link.target)) {
|
|
247
283
|
continue;
|
|
248
284
|
}
|
|
249
|
-
if (
|
|
285
|
+
if (isAttachment(link.target)) {
|
|
286
|
+
if (!checkAttachments) continue;
|
|
287
|
+
const attachmentPath = resolveAttachmentPath(link.target, node.note, vaultPath);
|
|
288
|
+
if (!existsSync(attachmentPath)) {
|
|
289
|
+
brokenLinks.push({
|
|
290
|
+
source: node.note.relativePath,
|
|
291
|
+
link,
|
|
292
|
+
reason: `File "${link.target}" does not exist`
|
|
293
|
+
});
|
|
294
|
+
}
|
|
250
295
|
continue;
|
|
251
296
|
}
|
|
252
|
-
const resolvedPath =
|
|
297
|
+
const resolvedPath = resolveNoteLink(link, node.note, notes, resolver, vaultPath);
|
|
253
298
|
if (resolvedPath === node.note.path) {
|
|
254
299
|
continue;
|
|
255
300
|
}
|
|
@@ -259,14 +304,6 @@ function analyze(notes, vaultPath, checkAttachments = false) {
|
|
|
259
304
|
link,
|
|
260
305
|
reason: `Target "${link.target}" not found in vault`
|
|
261
306
|
});
|
|
262
|
-
} else if (link.type === "embed" || link.type === "markdown") {
|
|
263
|
-
if (!existsSync(resolvedPath)) {
|
|
264
|
-
brokenLinks.push({
|
|
265
|
-
source: node.note.relativePath,
|
|
266
|
-
link,
|
|
267
|
-
reason: `File "${link.target}" does not exist`
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
307
|
}
|
|
271
308
|
}
|
|
272
309
|
}
|
|
@@ -285,6 +322,13 @@ function analyze(notes, vaultPath, checkAttachments = false) {
|
|
|
285
322
|
}
|
|
286
323
|
};
|
|
287
324
|
}
|
|
325
|
+
function isIgnoredFolder(relativePath, ignoreFolders) {
|
|
326
|
+
const folder = relativePath.includes("/") ? relativePath.split("/").slice(0, -1).join("/") : "";
|
|
327
|
+
return ignoreFolders.some((ignored) => {
|
|
328
|
+
const normalized = ignored.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
329
|
+
return folder === normalized || folder.startsWith(`${normalized}/`);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
288
332
|
function countConnectedComponents(graph) {
|
|
289
333
|
if (graph.length === 0) return 0;
|
|
290
334
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -312,72 +356,6 @@ function countConnectedComponents(graph) {
|
|
|
312
356
|
return components;
|
|
313
357
|
}
|
|
314
358
|
|
|
315
|
-
// src/utils/suggestions.ts
|
|
316
|
-
function levenshteinDistance(a, b) {
|
|
317
|
-
const matrix = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(0));
|
|
318
|
-
for (let i = 0; i <= a.length; i++) matrix[0][i] = i;
|
|
319
|
-
for (let j = 0; j <= b.length; j++) matrix[j][0] = j;
|
|
320
|
-
for (let j = 1; j <= b.length; j++) {
|
|
321
|
-
for (let i = 1; i <= a.length; i++) {
|
|
322
|
-
if (a[i - 1] === b[j - 1]) {
|
|
323
|
-
matrix[j][i] = matrix[j - 1][i - 1];
|
|
324
|
-
} else {
|
|
325
|
-
matrix[j][i] = Math.min(
|
|
326
|
-
matrix[j][i - 1] + 1,
|
|
327
|
-
matrix[j - 1][i] + 1,
|
|
328
|
-
matrix[j - 1][i - 1] + 1
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
return matrix[b.length][a.length];
|
|
334
|
-
}
|
|
335
|
-
function calculateSimilarity(a, b) {
|
|
336
|
-
const distance = levenshteinDistance(
|
|
337
|
-
a.toLowerCase(),
|
|
338
|
-
b.toLowerCase()
|
|
339
|
-
);
|
|
340
|
-
const maxLen = Math.max(a.length, b.length);
|
|
341
|
-
return Math.max(0, 1 - distance / maxLen);
|
|
342
|
-
}
|
|
343
|
-
function generateSuggestions(brokenTargets, allNotes, threshold = 0.6) {
|
|
344
|
-
const availableNotes = allNotes.map((n) => ({
|
|
345
|
-
name: n.relativePath.replace(/\.(md|canvas)$/, ""),
|
|
346
|
-
basename: n.relativePath.split("/").pop()?.replace(/\.(md|canvas)$/, "") || "",
|
|
347
|
-
aliases: n.aliases
|
|
348
|
-
}));
|
|
349
|
-
const suggestions = [];
|
|
350
|
-
for (const broken of brokenTargets) {
|
|
351
|
-
const brokenBasename = broken.split("/").pop() || broken;
|
|
352
|
-
const candidates = [];
|
|
353
|
-
for (const note of availableNotes) {
|
|
354
|
-
const basenameScore = calculateSimilarity(brokenBasename, note.basename);
|
|
355
|
-
const fullScore = calculateSimilarity(broken, note.name);
|
|
356
|
-
for (const alias of note.aliases) {
|
|
357
|
-
const aliasScore = calculateSimilarity(broken, alias);
|
|
358
|
-
if (aliasScore > basenameScore && aliasScore > fullScore) {
|
|
359
|
-
candidates.push({ suggestion: note.name, similarity: aliasScore });
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
if (basenameScore >= fullScore && basenameScore > threshold) {
|
|
363
|
-
candidates.push({ suggestion: note.name, similarity: basenameScore });
|
|
364
|
-
} else if (fullScore > threshold) {
|
|
365
|
-
candidates.push({ suggestion: note.name, similarity: fullScore });
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
if (candidates.length > 0) {
|
|
369
|
-
const best = candidates.sort((a, b) => b.similarity - a.similarity)[0];
|
|
370
|
-
suggestions.push({
|
|
371
|
-
broken,
|
|
372
|
-
suggested: best.suggestion,
|
|
373
|
-
similarity: best.similarity,
|
|
374
|
-
source: broken
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
return suggestions.sort((a, b) => b.similarity - a.similarity);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
359
|
// src/utils/engine.ts
|
|
382
360
|
var DeadLinksEngine = class {
|
|
383
361
|
async analyze(vaultPath, config = {}) {
|
|
@@ -389,13 +367,7 @@ var DeadLinksEngine = class {
|
|
|
389
367
|
return Promise.resolve();
|
|
390
368
|
})
|
|
391
369
|
);
|
|
392
|
-
const report = analyze(notes, vaultPath, config.checkAttachments);
|
|
393
|
-
if (config.suggestions) {
|
|
394
|
-
const brokenTargets = Array.from(
|
|
395
|
-
new Set(report.brokenLinks.map((b) => b.link.target))
|
|
396
|
-
);
|
|
397
|
-
report.suggestions = generateSuggestions(brokenTargets, notes, 0.65);
|
|
398
|
-
}
|
|
370
|
+
const report = analyze(notes, vaultPath, config.checkAttachments, config.ignoreFolders);
|
|
399
371
|
report.duration = Date.now() - startTime;
|
|
400
372
|
return report;
|
|
401
373
|
}
|
|
@@ -403,11 +375,11 @@ var DeadLinksEngine = class {
|
|
|
403
375
|
|
|
404
376
|
// src/utils/config-loader.ts
|
|
405
377
|
import { readFile as readFile2, access } from "fs/promises";
|
|
406
|
-
import { resolve as
|
|
378
|
+
import { resolve as resolve3, join } from "path";
|
|
407
379
|
var CONFIG_FILES = [".deadlinksrc.json", ".deadlinksrc", "dead-links.config.json"];
|
|
408
380
|
var ConfigLoader = class {
|
|
409
381
|
async loadConfig(vaultPath) {
|
|
410
|
-
const absolutePath =
|
|
382
|
+
const absolutePath = resolve3(vaultPath);
|
|
411
383
|
for (const configFile of CONFIG_FILES) {
|
|
412
384
|
const configPath = join(absolutePath, configFile);
|
|
413
385
|
try {
|
|
@@ -432,21 +404,21 @@ var ConfigLoader = class {
|
|
|
432
404
|
ignore: cliOptions.ignore || fileConfig.ignore,
|
|
433
405
|
checkAttachments: cliOptions.attachments ?? fileConfig.checkAttachments ?? false,
|
|
434
406
|
ignoreFolders: cliOptions.ignoreFolders || fileConfig.ignoreFolders,
|
|
435
|
-
|
|
436
|
-
debug: cliOptions.debug ?? fileConfig.debug ?? false,
|
|
437
|
-
fix: cliOptions.fix ?? fileConfig.fix ?? false
|
|
407
|
+
debug: cliOptions.debug ?? fileConfig.debug ?? false
|
|
438
408
|
};
|
|
439
409
|
}
|
|
440
410
|
};
|
|
441
411
|
|
|
442
412
|
// src/reporters/terminal-reporter.ts
|
|
443
|
-
import
|
|
413
|
+
import { Chalk } from "chalk";
|
|
444
414
|
var TerminalReporter = class {
|
|
445
415
|
ignoreFolders;
|
|
446
416
|
quiet;
|
|
447
|
-
|
|
417
|
+
chalk;
|
|
418
|
+
constructor(ignoreFolders = [], quiet = false, noColor = false) {
|
|
448
419
|
this.ignoreFolders = ignoreFolders;
|
|
449
420
|
this.quiet = quiet;
|
|
421
|
+
this.chalk = new Chalk({ level: noColor ? 0 : void 0 });
|
|
450
422
|
}
|
|
451
423
|
report(result) {
|
|
452
424
|
if (this.quiet) {
|
|
@@ -456,9 +428,6 @@ var TerminalReporter = class {
|
|
|
456
428
|
output += this.printStats(result);
|
|
457
429
|
output += this.printBrokenLinks(result.brokenLinks);
|
|
458
430
|
output += this.printOrphanNotes(result.orphanNotes);
|
|
459
|
-
if (result.suggestions && result.suggestions.length > 0) {
|
|
460
|
-
output += this.printSuggestions(result.suggestions);
|
|
461
|
-
}
|
|
462
431
|
output += this.printDuration(result.duration);
|
|
463
432
|
return output;
|
|
464
433
|
}
|
|
@@ -488,12 +457,13 @@ var TerminalReporter = class {
|
|
|
488
457
|
return out;
|
|
489
458
|
}
|
|
490
459
|
statLine(label, value, color = "white") {
|
|
491
|
-
|
|
460
|
+
const chalk = this.chalk;
|
|
461
|
+
return ` ${this.chalk.gray(label)} ${color === "red" ? this.chalk.red.bold(value) : color === "yellow" ? this.chalk.yellow.bold(value) : this.chalk.white.bold(value)}
|
|
492
462
|
`;
|
|
493
463
|
}
|
|
494
464
|
printBrokenLinks(brokenLinks) {
|
|
495
465
|
if (brokenLinks.length === 0) {
|
|
496
|
-
return chalk.green(" \u2713 No broken links\n\n");
|
|
466
|
+
return this.chalk.green(" \u2713 No broken links\n\n");
|
|
497
467
|
}
|
|
498
468
|
const grouped = /* @__PURE__ */ new Map();
|
|
499
469
|
for (const broken of brokenLinks) {
|
|
@@ -502,18 +472,18 @@ var TerminalReporter = class {
|
|
|
502
472
|
grouped.set(broken.source, existing);
|
|
503
473
|
}
|
|
504
474
|
const sorted = [...grouped.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
505
|
-
let out = chalk.bold.red(`
|
|
475
|
+
let out = this.chalk.bold.red(`
|
|
506
476
|
\u2716 Broken links (${brokenLinks.length})
|
|
507
477
|
|
|
508
478
|
`);
|
|
509
479
|
for (const [source, links] of sorted) {
|
|
510
480
|
const count = links.length;
|
|
511
481
|
const isMultiple = count >= 2;
|
|
512
|
-
const countStr = isMultiple ? chalk.red.bold(`\u2716 ${count}`) : chalk.yellow.bold(`\u2716 ${count}`);
|
|
513
|
-
out += ` ${countStr} ${chalk.white.bold(source)}
|
|
482
|
+
const countStr = isMultiple ? this.chalk.red.bold(`\u2716 ${count}`) : this.chalk.yellow.bold(`\u2716 ${count}`);
|
|
483
|
+
out += ` ${countStr} ${this.chalk.white.bold(source)}
|
|
514
484
|
`;
|
|
515
485
|
for (const link of links) {
|
|
516
|
-
const pill2 = isMultiple ? chalk.redBright(`[${link.link.target}]`) : chalk.yellowBright(`[${link.link.target}]`);
|
|
486
|
+
const pill2 = isMultiple ? this.chalk.redBright(`[${link.link.target}]`) : this.chalk.yellowBright(`[${link.link.target}]`);
|
|
517
487
|
out += ` ${pill2}
|
|
518
488
|
`;
|
|
519
489
|
}
|
|
@@ -523,7 +493,7 @@ var TerminalReporter = class {
|
|
|
523
493
|
}
|
|
524
494
|
printOrphanNotes(orphanNotes) {
|
|
525
495
|
if (orphanNotes.length === 0) {
|
|
526
|
-
return chalk.green(" \u2713 No orphan notes\n\n");
|
|
496
|
+
return this.chalk.green(" \u2713 No orphan notes\n\n");
|
|
527
497
|
}
|
|
528
498
|
const byFolder = /* @__PURE__ */ new Map();
|
|
529
499
|
for (const note of orphanNotes) {
|
|
@@ -535,43 +505,27 @@ var TerminalReporter = class {
|
|
|
535
505
|
}
|
|
536
506
|
const sorted = [...byFolder.entries()].sort((a, b) => b[1] - a[1]);
|
|
537
507
|
if (sorted.length === 0) {
|
|
538
|
-
return chalk.green(" \u2713 No orphan notes (all in ignored folders)\n\n");
|
|
508
|
+
return this.chalk.green(" \u2713 No orphan notes (all in ignored folders)\n\n");
|
|
539
509
|
}
|
|
540
510
|
const maxCount = sorted[0][1];
|
|
541
511
|
const barWidth = 10;
|
|
542
|
-
let out = chalk.bold.yellow(`
|
|
512
|
+
let out = this.chalk.bold.yellow(`
|
|
543
513
|
\u26A0 Orphan notes (${orphanNotes.length} total)
|
|
544
514
|
|
|
545
515
|
`);
|
|
546
516
|
for (const [folder, count] of sorted) {
|
|
547
517
|
const filled = Math.round(count / maxCount * barWidth);
|
|
548
518
|
const empty = barWidth - filled;
|
|
549
|
-
const bar2 = chalk.gray("\u2588".repeat(filled) + "\u2591".repeat(empty));
|
|
550
|
-
out += ` ${chalk.gray(folder.padEnd(40))} ${bar2} ${chalk.white.bold(String(count))}
|
|
519
|
+
const bar2 = this.chalk.gray("\u2588".repeat(filled) + "\u2591".repeat(empty));
|
|
520
|
+
out += ` ${this.chalk.gray(folder.padEnd(40))} ${bar2} ${this.chalk.white.bold(String(count))}
|
|
551
521
|
`;
|
|
552
522
|
}
|
|
553
523
|
out += "\n";
|
|
554
524
|
return out;
|
|
555
525
|
}
|
|
556
526
|
printDuration(ms) {
|
|
557
|
-
return chalk.dim(` Completed in ${ms}ms
|
|
558
|
-
`);
|
|
559
|
-
}
|
|
560
|
-
printSuggestions(suggestions) {
|
|
561
|
-
let out = chalk.bold.cyan(`
|
|
562
|
-
\u{1F4A1} Suggestions (${suggestions.length})
|
|
563
|
-
|
|
527
|
+
return this.chalk.dim(` Completed in ${ms}ms
|
|
564
528
|
`);
|
|
565
|
-
for (const suggestion of suggestions.slice(0, 5)) {
|
|
566
|
-
const confidence = Math.round(suggestion.similarity * 100);
|
|
567
|
-
const bar2 = confidence >= 80 ? chalk.green("\u2588\u2588\u2588\u2588\u2588") : confidence >= 60 ? chalk.yellow("\u2588\u2588\u2588\u2588\u2588") : chalk.gray("\u2588\u2588\u2588\u2588\u2588");
|
|
568
|
-
out += ` ${chalk.gray(suggestion.broken)} \u2192 ${chalk.cyan(suggestion.suggested)}
|
|
569
|
-
`;
|
|
570
|
-
out += ` ${bar2} ${confidence}%
|
|
571
|
-
|
|
572
|
-
`;
|
|
573
|
-
}
|
|
574
|
-
return out;
|
|
575
529
|
}
|
|
576
530
|
};
|
|
577
531
|
|
|
@@ -639,194 +593,225 @@ var CsvReporter = class {
|
|
|
639
593
|
// src/reporters/html-reporter.ts
|
|
640
594
|
var HtmlReporter = class {
|
|
641
595
|
report(result) {
|
|
596
|
+
const summaryHtml = this.renderSummary(result);
|
|
597
|
+
const statsHtml = this.renderStats(result);
|
|
642
598
|
const brokenLinksHtml = this.renderBrokenLinks(result.brokenLinks);
|
|
643
599
|
const orphanNotesHtml = this.renderOrphanNotes(result.orphanNotes);
|
|
644
600
|
const graphHtml = this.renderGraph(result.graph);
|
|
645
|
-
const statsHtml = this.renderStats(result.stats, result.duration);
|
|
646
601
|
return `<!DOCTYPE html>
|
|
647
602
|
<html lang="en">
|
|
648
603
|
<head>
|
|
649
604
|
<meta charset="UTF-8">
|
|
650
605
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
651
|
-
<
|
|
606
|
+
<meta name="theme-color" content="#ECEBE8">
|
|
607
|
+
<title>Vault report | Dead Links</title>
|
|
652
608
|
<style>
|
|
653
|
-
|
|
609
|
+
:root {
|
|
610
|
+
color-scheme: light;
|
|
611
|
+
--canvas: #ECEBE8;
|
|
612
|
+
--surface: #FFFFFF;
|
|
613
|
+
--surface-raised: #F7F5FC;
|
|
614
|
+
--ink: #0A0A0A;
|
|
615
|
+
--body: #2A2620;
|
|
616
|
+
--muted: #5F5D57;
|
|
617
|
+
--accent: #7C5CFF;
|
|
618
|
+
--accent-ink: #4C3897;
|
|
619
|
+
--accent-wash: #F4F0FF;
|
|
620
|
+
--accent-border: #DCD3FF;
|
|
621
|
+
--border: #D9D6CD;
|
|
622
|
+
--border-soft: #EBE9E3;
|
|
623
|
+
--space-1: 4px;
|
|
624
|
+
--space-2: 8px;
|
|
625
|
+
--space-3: 12px;
|
|
626
|
+
--space-4: 16px;
|
|
627
|
+
--space-5: 20px;
|
|
628
|
+
--space-6: 24px;
|
|
629
|
+
--space-7: 32px;
|
|
630
|
+
--space-section: 40px;
|
|
631
|
+
--font-ui: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
632
|
+
--font-display: 'Instrument Serif', Georgia, serif;
|
|
633
|
+
--font-mono: 'JetBrains Mono', 'Courier New', monospace;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
637
|
+
html { background: var(--canvas); scroll-behavior: smooth; }
|
|
654
638
|
body {
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
639
|
+
margin: 0;
|
|
640
|
+
color: var(--body);
|
|
641
|
+
background: var(--canvas);
|
|
642
|
+
font-family: var(--font-ui);
|
|
643
|
+
font-size: 15px;
|
|
644
|
+
line-height: 1.6;
|
|
645
|
+
-webkit-font-smoothing: antialiased;
|
|
646
|
+
text-rendering: optimizeLegibility;
|
|
659
647
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
648
|
+
::selection { color: var(--surface); background: var(--accent); }
|
|
649
|
+
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
|
650
|
+
.page { width: min(100% - 48px, 1200px); margin: 0 auto; }
|
|
651
|
+
.masthead {
|
|
652
|
+
position: relative;
|
|
653
|
+
padding: 48px 0 32px;
|
|
654
|
+
border-bottom: 1px solid var(--border);
|
|
655
|
+
background-image:
|
|
656
|
+
radial-gradient(rgba(124, 92, 255, .13) 1px, transparent 1.5px),
|
|
657
|
+
radial-gradient(rgba(124, 92, 255, .06) 1px, transparent 1.5px);
|
|
658
|
+
background-position: 0 0, 8px 8px;
|
|
659
|
+
background-size: 16px 16px;
|
|
667
660
|
}
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
661
|
+
.masthead-inner { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; }
|
|
662
|
+
.masthead h1 {
|
|
663
|
+
margin: 0;
|
|
664
|
+
color: var(--ink);
|
|
665
|
+
font-family: var(--font-display);
|
|
666
|
+
font-size: 44px;
|
|
667
|
+
font-weight: 400;
|
|
668
|
+
line-height: 1.04;
|
|
669
|
+
letter-spacing: -.03em;
|
|
673
670
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
.
|
|
677
|
-
.
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
671
|
+
.masthead p { max-width: 62ch; margin: 12px 0 0; color: var(--muted); }
|
|
672
|
+
main { padding: 32px 0 48px; }
|
|
673
|
+
.overview { margin-bottom: var(--space-section); }
|
|
674
|
+
.overview-intro { display: flex; align-items: baseline; justify-content: space-between; gap: 20px; margin-bottom: 16px; }
|
|
675
|
+
.overview-intro h2, .section-heading h2 {
|
|
676
|
+
margin: 0;
|
|
677
|
+
color: var(--ink);
|
|
678
|
+
font-size: 18.4px;
|
|
679
|
+
font-weight: 600;
|
|
680
|
+
line-height: 1.2;
|
|
681
|
+
letter-spacing: -.02em;
|
|
681
682
|
}
|
|
682
|
-
.
|
|
683
|
-
|
|
684
|
-
margin
|
|
685
|
-
|
|
683
|
+
.overview-intro p { margin: 0; color: var(--muted); font-size: 12px; }
|
|
684
|
+
.summary {
|
|
685
|
+
margin: 0 0 20px;
|
|
686
|
+
padding: 16px 20px;
|
|
687
|
+
border: 1px solid var(--accent-border);
|
|
688
|
+
background: var(--accent-wash);
|
|
689
|
+
color: var(--ink);
|
|
690
|
+
font-weight: 500;
|
|
686
691
|
}
|
|
687
|
-
.
|
|
692
|
+
.metrics {
|
|
688
693
|
display: grid;
|
|
689
|
-
grid-template-columns: repeat(
|
|
690
|
-
gap:
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
.stat-card {
|
|
694
|
-
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
695
|
-
color: white;
|
|
696
|
-
padding: 20px;
|
|
697
|
-
border-radius: 8px;
|
|
698
|
-
text-align: center;
|
|
694
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
695
|
+
gap: 1px;
|
|
696
|
+
border: 1px solid var(--border);
|
|
697
|
+
background: var(--border);
|
|
699
698
|
}
|
|
700
|
-
.
|
|
701
|
-
.
|
|
702
|
-
|
|
699
|
+
.metric { min-height: 110px; padding: 18px 20px; background: var(--surface); }
|
|
700
|
+
.metric-value { display: block; color: var(--ink); font-size: 28px; font-weight: 600; line-height: 1.15; font-variant-numeric: tabular-nums; letter-spacing: -.03em; }
|
|
701
|
+
.metric-label { display: block; margin-top: 8px; color: var(--muted); font-size: 12px; }
|
|
702
|
+
.metric:nth-child(3) .metric-value, .metric:nth-child(4) .metric-value { color: var(--accent-ink); }
|
|
703
|
+
.section { margin-top: var(--space-section); }
|
|
704
|
+
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
|
705
|
+
.section-count { color: var(--accent-ink); background: var(--accent-wash); padding: 3px 8px; font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
706
|
+
.data-surface { border: 1px solid var(--border); background: var(--surface); }
|
|
707
|
+
.filter-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border-soft); }
|
|
708
|
+
.filter-row label { flex: 0 0 auto; color: var(--muted); font-size: 12px; }
|
|
709
|
+
.filter-row input {
|
|
703
710
|
width: 100%;
|
|
704
|
-
|
|
705
|
-
|
|
711
|
+
min-width: 0;
|
|
712
|
+
min-height: 42px;
|
|
713
|
+
padding: 9px 12px;
|
|
714
|
+
border: 1px solid var(--border);
|
|
715
|
+
border-radius: 0;
|
|
716
|
+
color: var(--ink);
|
|
717
|
+
background: var(--surface);
|
|
718
|
+
font: inherit;
|
|
706
719
|
}
|
|
720
|
+
.filter-row input::placeholder { color: var(--muted); opacity: 1; }
|
|
721
|
+
.filter-row input:focus-visible { border-color: var(--accent); outline-offset: 2px; }
|
|
722
|
+
.table-scroll { overflow-x: auto; }
|
|
723
|
+
.bounded-table-scroll {
|
|
724
|
+
max-height: min(55vh, 520px);
|
|
725
|
+
overflow: auto;
|
|
726
|
+
overscroll-behavior: contain;
|
|
727
|
+
scrollbar-color: var(--accent) var(--surface-raised);
|
|
728
|
+
scrollbar-width: thin;
|
|
729
|
+
}
|
|
730
|
+
.bounded-table-scroll:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
731
|
+
.bounded-table-scroll thead th { position: sticky; top: 0; z-index: 1; }
|
|
732
|
+
table { width: 100%; border-collapse: collapse; text-align: left; }
|
|
707
733
|
th {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
border-bottom:
|
|
734
|
+
padding: 11px 16px;
|
|
735
|
+
color: var(--muted);
|
|
736
|
+
background: var(--surface-raised);
|
|
737
|
+
border-bottom: 1px solid var(--border);
|
|
738
|
+
font-size: 11px;
|
|
712
739
|
font-weight: 600;
|
|
740
|
+
letter-spacing: .04em;
|
|
741
|
+
text-transform: uppercase;
|
|
742
|
+
white-space: nowrap;
|
|
713
743
|
}
|
|
714
|
-
td {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
.
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
gap: 0;
|
|
734
|
-
border-bottom: 2px solid #eee;
|
|
735
|
-
margin-bottom: 20px;
|
|
736
|
-
}
|
|
737
|
-
.tab-button {
|
|
738
|
-
padding: 12px 20px;
|
|
739
|
-
border: none;
|
|
740
|
-
background: none;
|
|
741
|
-
cursor: pointer;
|
|
742
|
-
font-size: 1em;
|
|
743
|
-
border-bottom: 3px solid transparent;
|
|
744
|
-
color: #666;
|
|
745
|
-
transition: all 0.3s;
|
|
746
|
-
}
|
|
747
|
-
.tab-button.active {
|
|
748
|
-
color: #667eea;
|
|
749
|
-
border-bottom-color: #667eea;
|
|
750
|
-
}
|
|
751
|
-
.tab-content { display: none; }
|
|
752
|
-
.tab-content.active { display: block; }
|
|
753
|
-
.filter-box {
|
|
754
|
-
margin: 20px 0;
|
|
755
|
-
padding: 15px;
|
|
756
|
-
background: #f9f9f9;
|
|
757
|
-
border-radius: 8px;
|
|
744
|
+
td { padding: 13px 16px; border-bottom: 1px solid var(--border-soft); color: var(--body); vertical-align: top; }
|
|
745
|
+
tbody tr:last-child td { border-bottom: 0; }
|
|
746
|
+
tbody tr:hover { background: var(--surface-raised); }
|
|
747
|
+
td:first-child { color: var(--ink); font-weight: 500; }
|
|
748
|
+
.type-label { color: var(--muted); font-family: var(--font-mono); font-size: 11px; }
|
|
749
|
+
.numeric { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
750
|
+
.empty-state { margin: 0; padding: 22px 16px; color: var(--muted); }
|
|
751
|
+
.empty-state strong { color: var(--ink); font-weight: 500; }
|
|
752
|
+
.connections-table td:not(:first-child), .connections-table th:not(:first-child) { text-align: right; }
|
|
753
|
+
footer { padding: 18px 0 28px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; }
|
|
754
|
+
footer time { font-variant-numeric: tabular-nums; }
|
|
755
|
+
|
|
756
|
+
@media (max-width: 768px) {
|
|
757
|
+
.page { width: min(100% - 32px, 600px); }
|
|
758
|
+
.masthead { padding-top: 32px; }
|
|
759
|
+
.masthead-inner { align-items: flex-start; }
|
|
760
|
+
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
761
|
+
.section-heading { align-items: flex-start; }
|
|
762
|
+
th, td { padding-right: 12px; padding-left: 12px; }
|
|
758
763
|
}
|
|
759
|
-
|
|
760
|
-
width: 100
|
|
761
|
-
padding:
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
764
|
+
@media (max-width: 420px) {
|
|
765
|
+
.page { width: min(100% - 24px, 600px); }
|
|
766
|
+
.masthead { padding: 28px 0 24px; }
|
|
767
|
+
.masthead h1 { font-size: 40px; }
|
|
768
|
+
main { padding-top: 24px; }
|
|
769
|
+
.overview-intro { display: block; }
|
|
770
|
+
.overview-intro p { margin-top: 5px; }
|
|
771
|
+
.metric { min-height: 96px; padding: 14px 12px; }
|
|
772
|
+
.metric-value { font-size: 18.4px; }
|
|
773
|
+
.filter-row { align-items: stretch; flex-direction: column; gap: 6px; }
|
|
765
774
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
text-align: center;
|
|
770
|
-
color: #666;
|
|
771
|
-
font-size: 0.9em;
|
|
775
|
+
@media (prefers-reduced-motion: reduce) {
|
|
776
|
+
html { scroll-behavior: auto; }
|
|
777
|
+
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
|
772
778
|
}
|
|
773
779
|
</style>
|
|
774
780
|
</head>
|
|
775
781
|
<body>
|
|
776
|
-
<
|
|
777
|
-
<
|
|
778
|
-
<
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
<div class="content">
|
|
783
|
-
${statsHtml}
|
|
784
|
-
${brokenLinksHtml}
|
|
785
|
-
${orphanNotesHtml}
|
|
786
|
-
${graphHtml}
|
|
782
|
+
<header class="masthead">
|
|
783
|
+
<div class="page masthead-inner">
|
|
784
|
+
<div>
|
|
785
|
+
<h1>Dead Links Report</h1>
|
|
786
|
+
<p>Obsidian Vault Analysis</p>
|
|
787
|
+
</div>
|
|
787
788
|
</div>
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
789
|
+
</header>
|
|
790
|
+
<main class="page">
|
|
791
|
+
${summaryHtml}
|
|
792
|
+
${statsHtml}
|
|
793
|
+
${brokenLinksHtml}
|
|
794
|
+
${orphanNotesHtml}
|
|
795
|
+
${graphHtml}
|
|
796
|
+
</main>
|
|
797
|
+
<footer>
|
|
798
|
+
<div class="page">Generated <time>${this.escapeHtml((/* @__PURE__ */ new Date()).toLocaleString())}</time> \xB7 Completed in ${result.duration}ms</div>
|
|
799
|
+
</footer>
|
|
794
800
|
<script>
|
|
795
|
-
function setupTabs() {
|
|
796
|
-
const buttons = document.querySelectorAll('.tab-button');
|
|
797
|
-
const contents = document.querySelectorAll('.tab-content');
|
|
798
|
-
|
|
799
|
-
buttons.forEach((button, index) => {
|
|
800
|
-
button.addEventListener('click', () => {
|
|
801
|
-
buttons.forEach(b => b.classList.remove('active'));
|
|
802
|
-
contents.forEach(c => c.classList.remove('active'));
|
|
803
|
-
button.classList.add('active');
|
|
804
|
-
contents[index].classList.add('active');
|
|
805
|
-
});
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
// Activate first tab
|
|
809
|
-
if (buttons.length > 0) buttons[0].classList.add('active');
|
|
810
|
-
if (contents.length > 0) contents[0].classList.add('active');
|
|
811
|
-
}
|
|
812
|
-
|
|
813
801
|
function setupFilter(tableId, inputId) {
|
|
814
802
|
const input = document.getElementById(inputId);
|
|
815
803
|
const table = document.getElementById(tableId);
|
|
816
804
|
if (!input || !table) return;
|
|
817
805
|
|
|
818
|
-
input.addEventListener('
|
|
819
|
-
const filter =
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const text = row.textContent.toLowerCase();
|
|
823
|
-
row.style.display = text.includes(filter) ? '' : 'none';
|
|
806
|
+
input.addEventListener('input', (event) => {
|
|
807
|
+
const filter = event.currentTarget.value.toLocaleLowerCase();
|
|
808
|
+
table.querySelectorAll('tbody tr').forEach((row) => {
|
|
809
|
+
row.hidden = !row.textContent.toLocaleLowerCase().includes(filter);
|
|
824
810
|
});
|
|
825
811
|
});
|
|
826
812
|
}
|
|
827
813
|
|
|
828
814
|
document.addEventListener('DOMContentLoaded', () => {
|
|
829
|
-
setupTabs();
|
|
830
815
|
setupFilter('broken-table', 'broken-filter');
|
|
831
816
|
setupFilter('orphan-table', 'orphan-filter');
|
|
832
817
|
});
|
|
@@ -834,147 +819,124 @@ var HtmlReporter = class {
|
|
|
834
819
|
</body>
|
|
835
820
|
</html>`;
|
|
836
821
|
}
|
|
837
|
-
|
|
838
|
-
const
|
|
822
|
+
renderSummary(result) {
|
|
823
|
+
const brokenCount = result.stats.brokenCount;
|
|
824
|
+
const orphanCount = result.stats.orphanCount;
|
|
825
|
+
const message = brokenCount === 0 && orphanCount === 0 ? "No broken links or orphan notes found." : `${brokenCount} broken link${brokenCount === 1 ? "" : "s"} \xB7 ${orphanCount} orphan note${orphanCount === 1 ? "" : "s"}`;
|
|
839
826
|
return `
|
|
840
|
-
<
|
|
841
|
-
<
|
|
842
|
-
|
|
843
|
-
<
|
|
844
|
-
<div class="value">${stats.totalNotes}</div>
|
|
845
|
-
<div class="label">Total Notes</div>
|
|
846
|
-
</div>
|
|
847
|
-
<div class="stat-card">
|
|
848
|
-
<div class="value">${stats.totalLinks}</div>
|
|
849
|
-
<div class="label">Total Links</div>
|
|
850
|
-
</div>
|
|
851
|
-
<div class="stat-card" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
|
|
852
|
-
<div class="value">${stats.brokenCount}</div>
|
|
853
|
-
<div class="label">Broken Links</div>
|
|
854
|
-
</div>
|
|
855
|
-
<div class="stat-card" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
|
|
856
|
-
<div class="value">${stats.orphanCount}</div>
|
|
857
|
-
<div class="label">Orphan Notes</div>
|
|
858
|
-
</div>
|
|
859
|
-
<div class="stat-card" style="background: linear-gradient(135deg, ${healthColor} 0%, ${healthColor === "#16a34a" ? "#15803d" : "#991b1b"} 100%);">
|
|
860
|
-
<div class="value">${stats.connectedComponents}</div>
|
|
861
|
-
<div class="label">Connected Components</div>
|
|
862
|
-
</div>
|
|
863
|
-
<div class="stat-card" style="background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);">
|
|
864
|
-
<div class="value">${duration}ms</div>
|
|
865
|
-
<div class="label">Scan Duration</div>
|
|
866
|
-
</div>
|
|
827
|
+
<section class="overview" aria-labelledby="overview-title">
|
|
828
|
+
<div class="overview-intro">
|
|
829
|
+
<h2 id="overview-title">At a glance</h2>
|
|
830
|
+
<p>Scan completed in ${result.duration}ms</p>
|
|
867
831
|
</div>
|
|
868
|
-
|
|
832
|
+
<p class="summary">${this.escapeHtml(message)}</p>
|
|
833
|
+
</section>
|
|
834
|
+
`;
|
|
835
|
+
}
|
|
836
|
+
renderStats(result) {
|
|
837
|
+
const { stats } = result;
|
|
838
|
+
const averageLinks = stats.totalNotes === 0 ? 0 : stats.totalLinks / stats.totalNotes;
|
|
839
|
+
const healthScore = stats.totalLinks === 0 ? 100 : 100 - stats.brokenCount / stats.totalLinks * 100;
|
|
840
|
+
const metrics = [
|
|
841
|
+
["Total Notes", String(stats.totalNotes)],
|
|
842
|
+
["Links", String(stats.totalLinks)],
|
|
843
|
+
["Broken links", String(stats.brokenCount)],
|
|
844
|
+
["Orphan notes", String(stats.orphanCount)],
|
|
845
|
+
["Avg. links per note", averageLinks.toFixed(2)],
|
|
846
|
+
["Vault health", `${healthScore.toFixed(1)}%`],
|
|
847
|
+
["Connected components", String(stats.connectedComponents)]
|
|
848
|
+
];
|
|
849
|
+
return `
|
|
850
|
+
<section class="overview" aria-label="Vault statistics">
|
|
851
|
+
<div class="metrics">
|
|
852
|
+
${metrics.map(([label, value]) => `
|
|
853
|
+
<div class="metric">
|
|
854
|
+
<span class="metric-value">${this.escapeHtml(value)}</span>
|
|
855
|
+
<span class="metric-label">${this.escapeHtml(label)}</span>
|
|
856
|
+
</div>`).join("")}
|
|
857
|
+
</div>
|
|
858
|
+
</section>
|
|
869
859
|
`;
|
|
870
860
|
}
|
|
871
861
|
renderBrokenLinks(brokenLinks) {
|
|
872
|
-
|
|
873
|
-
return `
|
|
874
|
-
<div class="section">
|
|
875
|
-
<h2>\u2705 Broken Links</h2>
|
|
876
|
-
<p class="success">No broken links found!</p>
|
|
877
|
-
</div>
|
|
878
|
-
`;
|
|
879
|
-
}
|
|
880
|
-
const rows = brokenLinks.map(
|
|
881
|
-
(link) => `
|
|
862
|
+
const rows = brokenLinks.map((link) => `
|
|
882
863
|
<tr>
|
|
883
864
|
<td>${this.escapeHtml(link.source)}</td>
|
|
884
865
|
<td>${this.escapeHtml(link.link.target)}</td>
|
|
885
|
-
<td><span class="
|
|
886
|
-
<td>${link.link.line}</td>
|
|
866
|
+
<td><span class="type-label">${this.escapeHtml(link.link.type)}</span></td>
|
|
867
|
+
<td class="numeric">${link.link.line}</td>
|
|
887
868
|
<td>${this.escapeHtml(link.reason)}</td>
|
|
888
869
|
</tr>
|
|
889
|
-
|
|
890
|
-
).join("");
|
|
870
|
+
`).join("");
|
|
891
871
|
return `
|
|
892
|
-
<
|
|
893
|
-
<
|
|
894
|
-
|
|
895
|
-
<
|
|
872
|
+
<section class="section" aria-labelledby="broken-title">
|
|
873
|
+
<div class="section-heading">
|
|
874
|
+
<h2 id="broken-title">Broken links</h2>
|
|
875
|
+
<span class="section-count">${brokenLinks.length} found</span>
|
|
896
876
|
</div>
|
|
897
|
-
<
|
|
898
|
-
<
|
|
899
|
-
|
|
900
|
-
<
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
<
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
</table>
|
|
911
|
-
</div>
|
|
877
|
+
<div class="data-surface">
|
|
878
|
+
${brokenLinks.length === 0 ? '<p class="empty-state">No broken links found.</p>' : `<div class="filter-row">
|
|
879
|
+
<label for="broken-filter">Filter broken links</label>
|
|
880
|
+
<input type="search" id="broken-filter" aria-label="Filter broken links" placeholder="Search source, target, type, or reason">
|
|
881
|
+
</div>
|
|
882
|
+
<div class="table-scroll bounded-table-scroll" tabindex="0" role="region" aria-label="Scrollable broken links">
|
|
883
|
+
<table id="broken-table">
|
|
884
|
+
<thead><tr><th>Source</th><th>Target</th><th>Type</th><th>Line</th><th>Reason</th></tr></thead>
|
|
885
|
+
<tbody>${rows}</tbody>
|
|
886
|
+
</table>
|
|
887
|
+
</div>`}
|
|
888
|
+
</div>
|
|
889
|
+
</section>
|
|
912
890
|
`;
|
|
913
891
|
}
|
|
914
892
|
renderOrphanNotes(orphanNotes) {
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
<h2>\u2705 Orphan Notes</h2>
|
|
919
|
-
<p class="success">No orphan notes found!</p>
|
|
920
|
-
</div>
|
|
921
|
-
`;
|
|
922
|
-
}
|
|
923
|
-
const rows = orphanNotes.map(
|
|
924
|
-
(note) => `
|
|
925
|
-
<tr>
|
|
926
|
-
<td>${this.escapeHtml(note.relativePath)}</td>
|
|
927
|
-
</tr>
|
|
928
|
-
`
|
|
929
|
-
).join("");
|
|
893
|
+
const rows = orphanNotes.map((note) => `
|
|
894
|
+
<tr><td>${this.escapeHtml(note.relativePath)}</td></tr>
|
|
895
|
+
`).join("");
|
|
930
896
|
return `
|
|
931
|
-
<
|
|
932
|
-
<
|
|
933
|
-
|
|
934
|
-
<
|
|
897
|
+
<section class="section" aria-labelledby="orphans-title">
|
|
898
|
+
<div class="section-heading">
|
|
899
|
+
<h2 id="orphans-title">Orphan notes</h2>
|
|
900
|
+
<span class="section-count">${orphanNotes.length} found</span>
|
|
935
901
|
</div>
|
|
936
|
-
<
|
|
937
|
-
<
|
|
938
|
-
|
|
939
|
-
<
|
|
940
|
-
</
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
902
|
+
<div class="data-surface">
|
|
903
|
+
${orphanNotes.length === 0 ? '<p class="empty-state">No orphan notes found!</p>' : `<div class="filter-row">
|
|
904
|
+
<label for="orphan-filter">Filter orphan notes</label>
|
|
905
|
+
<input type="search" id="orphan-filter" aria-label="Filter orphan notes" placeholder="Search note paths">
|
|
906
|
+
</div>
|
|
907
|
+
<div class="table-scroll bounded-table-scroll" tabindex="0" role="region" aria-label="Scrollable orphan notes">
|
|
908
|
+
<table id="orphan-table">
|
|
909
|
+
<thead><tr><th>Note path</th></tr></thead>
|
|
910
|
+
<tbody>${rows}</tbody>
|
|
911
|
+
</table>
|
|
912
|
+
</div>`}
|
|
913
|
+
</div>
|
|
914
|
+
</section>
|
|
947
915
|
`;
|
|
948
916
|
}
|
|
949
917
|
renderGraph(graph) {
|
|
950
918
|
const mostConnected = [...graph].sort((a, b) => b.incoming.length + b.outgoing.length - (a.incoming.length + a.outgoing.length)).slice(0, 5);
|
|
951
|
-
const rows = mostConnected.map(
|
|
952
|
-
(node) => `
|
|
919
|
+
const rows = mostConnected.map((node) => `
|
|
953
920
|
<tr>
|
|
954
921
|
<td>${this.escapeHtml(node.note.relativePath)}</td>
|
|
955
|
-
<td>${node.incoming.length}</td>
|
|
956
|
-
<td>${node.outgoing.length}</td>
|
|
957
|
-
<td>${node.incoming.length + node.outgoing.length}</td>
|
|
922
|
+
<td class="numeric">${node.incoming.length}</td>
|
|
923
|
+
<td class="numeric">${node.outgoing.length}</td>
|
|
924
|
+
<td class="numeric">${node.incoming.length + node.outgoing.length}</td>
|
|
958
925
|
</tr>
|
|
959
|
-
|
|
960
|
-
).join("");
|
|
926
|
+
`).join("");
|
|
961
927
|
return `
|
|
962
|
-
<
|
|
963
|
-
<
|
|
964
|
-
|
|
965
|
-
<
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
<th>Outgoing</th>
|
|
970
|
-
<
|
|
971
|
-
</
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
${rows}
|
|
975
|
-
</tbody>
|
|
976
|
-
</table>
|
|
977
|
-
</div>
|
|
928
|
+
<section class="section" aria-labelledby="connections-title">
|
|
929
|
+
<div class="section-heading">
|
|
930
|
+
<h2 id="connections-title">Most connected notes</h2>
|
|
931
|
+
<span class="section-count">Top ${mostConnected.length}</span>
|
|
932
|
+
</div>
|
|
933
|
+
<div class="data-surface table-scroll">
|
|
934
|
+
${mostConnected.length === 0 ? '<p class="empty-state">No connections to show.</p>' : `<table class="connections-table">
|
|
935
|
+
<thead><tr><th>Note</th><th>Incoming</th><th>Outgoing</th><th>Total</th></tr></thead>
|
|
936
|
+
<tbody>${rows}</tbody>
|
|
937
|
+
</table>`}
|
|
938
|
+
</div>
|
|
939
|
+
</section>
|
|
978
940
|
`;
|
|
979
941
|
}
|
|
980
942
|
escapeHtml(text) {
|
|
@@ -982,123 +944,6 @@ var HtmlReporter = class {
|
|
|
982
944
|
}
|
|
983
945
|
};
|
|
984
946
|
|
|
985
|
-
// src/reporters/mermaid-reporter.ts
|
|
986
|
-
var MermaidReporter = class {
|
|
987
|
-
report(result, maxNodes = 50) {
|
|
988
|
-
let output = "graph LR\n";
|
|
989
|
-
const nodesToShow = result.graph.slice(0, maxNodes);
|
|
990
|
-
const nodeMap = new Map(nodesToShow.map((n) => [n.note.path, n.note.relativePath]));
|
|
991
|
-
for (const node of nodesToShow) {
|
|
992
|
-
const id = this.sanitizeId(node.note.relativePath);
|
|
993
|
-
const label = this.escapeLabel(node.note.relativePath);
|
|
994
|
-
if (result.orphanNotes.some((o) => o.path === node.note.path)) {
|
|
995
|
-
output += ` ${id}["<b>\u{1F534} ${label}</b>"]:::orphan
|
|
996
|
-
`;
|
|
997
|
-
} else if (node.incoming.length === 0) {
|
|
998
|
-
output += ` ${id}["\u{1F4C4} ${label}"]:::source
|
|
999
|
-
`;
|
|
1000
|
-
} else if (node.outgoing.length === 0) {
|
|
1001
|
-
output += ` ${id}["\u{1F4CC} ${label}"]:::sink
|
|
1002
|
-
`;
|
|
1003
|
-
} else {
|
|
1004
|
-
output += ` ${id}["${label}"]
|
|
1005
|
-
`;
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
const addedEdges = /* @__PURE__ */ new Set();
|
|
1009
|
-
for (const node of nodesToShow) {
|
|
1010
|
-
const sourceId = this.sanitizeId(node.note.relativePath);
|
|
1011
|
-
for (const targetPath of node.outgoing) {
|
|
1012
|
-
const targetName = nodeMap.get(targetPath);
|
|
1013
|
-
if (!targetName) continue;
|
|
1014
|
-
const targetId = this.sanitizeId(targetName);
|
|
1015
|
-
const edgeKey = `${sourceId}-${targetId}`;
|
|
1016
|
-
if (!addedEdges.has(edgeKey)) {
|
|
1017
|
-
output += ` ${sourceId} --> ${targetId}
|
|
1018
|
-
`;
|
|
1019
|
-
addedEdges.add(edgeKey);
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
output += "\n classDef orphan fill:#ff6b6b,stroke:#c92a2a,color:#fff\n";
|
|
1024
|
-
output += " classDef source fill:#4dabf7,stroke:#1971c2,color:#fff\n";
|
|
1025
|
-
output += " classDef sink fill:#51cf66,stroke:#2f9e44,color:#fff\n";
|
|
1026
|
-
if (result.graph.length > maxNodes) {
|
|
1027
|
-
output += `
|
|
1028
|
-
note["Graph limited to ${maxNodes} of ${result.graph.length} nodes for readability"]
|
|
1029
|
-
`;
|
|
1030
|
-
}
|
|
1031
|
-
return output;
|
|
1032
|
-
}
|
|
1033
|
-
sanitizeId(name) {
|
|
1034
|
-
return name.replace(/\.(md|canvas)$/, "").replace(/[^a-zA-Z0-9_]/g, "_").substring(0, 30);
|
|
1035
|
-
}
|
|
1036
|
-
escapeLabel(name) {
|
|
1037
|
-
return name.replace(/"/g, '\\"').substring(0, 50);
|
|
1038
|
-
}
|
|
1039
|
-
};
|
|
1040
|
-
|
|
1041
|
-
// src/reporters/graphviz-reporter.ts
|
|
1042
|
-
var GraphVizReporter = class {
|
|
1043
|
-
report(result, maxNodes = 100) {
|
|
1044
|
-
let output = "digraph VaultGraph {\n";
|
|
1045
|
-
output += " rankdir=LR;\n";
|
|
1046
|
-
output += " node [shape=box, style=rounded];\n";
|
|
1047
|
-
output += " edge [arrowsize=0.5];\n\n";
|
|
1048
|
-
const nodesToShow = result.graph.slice(0, maxNodes);
|
|
1049
|
-
const nodeMap = new Map(nodesToShow.map((n) => [n.note.path, n.note.relativePath]));
|
|
1050
|
-
for (const node of nodesToShow) {
|
|
1051
|
-
const id = this.sanitizeId(node.note.relativePath);
|
|
1052
|
-
const label = node.note.relativePath;
|
|
1053
|
-
let style = "filled";
|
|
1054
|
-
let color = "#ffffff";
|
|
1055
|
-
let fontColor = "#000000";
|
|
1056
|
-
let shape = "box";
|
|
1057
|
-
if (result.orphanNotes.some((o) => o.path === node.note.path)) {
|
|
1058
|
-
color = "#ff6b6b";
|
|
1059
|
-
fontColor = "#ffffff";
|
|
1060
|
-
shape = "box";
|
|
1061
|
-
} else if (node.incoming.length === 0) {
|
|
1062
|
-
color = "#4dabf7";
|
|
1063
|
-
fontColor = "#ffffff";
|
|
1064
|
-
} else if (node.outgoing.length === 0) {
|
|
1065
|
-
color = "#51cf66";
|
|
1066
|
-
fontColor = "#ffffff";
|
|
1067
|
-
}
|
|
1068
|
-
output += ` "${id}" [label="${label}", fillcolor="${color}", fontcolor="${fontColor}", shape=${shape}];
|
|
1069
|
-
`;
|
|
1070
|
-
}
|
|
1071
|
-
output += "\n";
|
|
1072
|
-
const addedEdges = /* @__PURE__ */ new Set();
|
|
1073
|
-
for (const node of nodesToShow) {
|
|
1074
|
-
const sourceId = this.sanitizeId(node.note.relativePath);
|
|
1075
|
-
for (const targetPath of node.outgoing) {
|
|
1076
|
-
const targetName = nodeMap.get(targetPath);
|
|
1077
|
-
if (!targetName) continue;
|
|
1078
|
-
const targetId = this.sanitizeId(targetName);
|
|
1079
|
-
const edgeKey = `${sourceId}->${targetId}`;
|
|
1080
|
-
if (!addedEdges.has(edgeKey)) {
|
|
1081
|
-
output += ` "${sourceId}" -> "${targetId}";
|
|
1082
|
-
`;
|
|
1083
|
-
addedEdges.add(edgeKey);
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
output += "\n // Legend\n";
|
|
1088
|
-
output += " subgraph legend {\n";
|
|
1089
|
-
output += " rank=sink;\n";
|
|
1090
|
-
output += ' orphan [label="\u{1F534} Orphan", fillcolor="#ff6b6b", fontcolor="white"];\n';
|
|
1091
|
-
output += ' source [label="\u{1F4C4} Source (no incoming)", fillcolor="#4dabf7", fontcolor="white"];\n';
|
|
1092
|
-
output += ' sink [label="\u{1F4CC} Sink (no outgoing)", fillcolor="#51cf66", fontcolor="white"];\n';
|
|
1093
|
-
output += " }\n";
|
|
1094
|
-
output += "}\n";
|
|
1095
|
-
return output;
|
|
1096
|
-
}
|
|
1097
|
-
sanitizeId(name) {
|
|
1098
|
-
return name.replace(/\.(md|canvas)$/, "").replace(/[^a-zA-Z0-9_]/g, "_");
|
|
1099
|
-
}
|
|
1100
|
-
};
|
|
1101
|
-
|
|
1102
947
|
// src/utils/markdown-report.ts
|
|
1103
948
|
function pill(target, isMultiple) {
|
|
1104
949
|
return isMultiple ? `[${target}]` : `[${target}]`;
|
|
@@ -1121,6 +966,10 @@ function generateMarkdownReport(report) {
|
|
|
1121
966
|
lines.push(`|--------|-------|`);
|
|
1122
967
|
lines.push(`| Notes | ${report.stats.totalNotes} |`);
|
|
1123
968
|
lines.push(`| Links | ${report.stats.totalLinks} |`);
|
|
969
|
+
const averageLinks = report.stats.totalNotes === 0 ? 0 : report.stats.totalLinks / report.stats.totalNotes;
|
|
970
|
+
const healthScore = report.stats.totalLinks === 0 ? 100 : 100 - report.stats.brokenCount / report.stats.totalLinks * 100;
|
|
971
|
+
lines.push(`| Avg links per note | ${averageLinks.toFixed(2)} |`);
|
|
972
|
+
lines.push(`| Health score | ${healthScore.toFixed(1)}% |`);
|
|
1124
973
|
lines.push(`| Broken | ${report.stats.brokenCount} |`);
|
|
1125
974
|
lines.push(`| Orphans | ${report.stats.orphanCount} |`);
|
|
1126
975
|
lines.push(`| Components | ${report.stats.connectedComponents} |`);
|
|
@@ -1164,12 +1013,30 @@ function generateMarkdownReport(report) {
|
|
|
1164
1013
|
}
|
|
1165
1014
|
}
|
|
1166
1015
|
lines.push("");
|
|
1016
|
+
lines.push("## Connections");
|
|
1017
|
+
lines.push("");
|
|
1018
|
+
lines.push("| Note | Incoming | Outgoing | Total |");
|
|
1019
|
+
lines.push("|------|----------|----------|-------|");
|
|
1020
|
+
const mostConnected = [...report.graph].sort((a, b) => b.incoming.length + b.outgoing.length - (a.incoming.length + a.outgoing.length)).slice(0, 10);
|
|
1021
|
+
if (mostConnected.length === 0) {
|
|
1022
|
+
lines.push("| No connected notes | 0 | 0 | 0 |");
|
|
1023
|
+
} else {
|
|
1024
|
+
for (const node of mostConnected) {
|
|
1025
|
+
const incoming = node.incoming.length;
|
|
1026
|
+
const outgoing = node.outgoing.length;
|
|
1027
|
+
lines.push(`| ${node.note.relativePath} | ${incoming} | ${outgoing} | ${incoming + outgoing} |`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
lines.push("");
|
|
1167
1031
|
return lines.join("\n");
|
|
1168
1032
|
}
|
|
1169
1033
|
|
|
1170
1034
|
// src/index.ts
|
|
1035
|
+
var packageMetadata = JSON.parse(
|
|
1036
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
1037
|
+
);
|
|
1171
1038
|
var program = new Command();
|
|
1172
|
-
program.name("dead-links").description("Scan Obsidian vaults for broken links, orphan notes, and connection graphs").version(
|
|
1039
|
+
program.name("dead-links").description("Scan Obsidian vaults for broken links, orphan notes, and connection graphs").version(packageMetadata.version);
|
|
1173
1040
|
async function loadConfigAndAnalyze(vaultPath, cliOptions) {
|
|
1174
1041
|
const configLoader = new ConfigLoader();
|
|
1175
1042
|
const fileConfig = await configLoader.loadConfig(vaultPath);
|
|
@@ -1177,25 +1044,9 @@ async function loadConfigAndAnalyze(vaultPath, cliOptions) {
|
|
|
1177
1044
|
const engine = new DeadLinksEngine();
|
|
1178
1045
|
return engine.analyze(vaultPath, config);
|
|
1179
1046
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
const existing = grouped.get(b.source) || [];
|
|
1184
|
-
existing.push(b);
|
|
1185
|
-
grouped.set(b.source, existing);
|
|
1186
|
-
}
|
|
1187
|
-
for (const [source, links] of grouped) {
|
|
1188
|
-
for (const link of links) {
|
|
1189
|
-
console.log(` ${source} \u2192 ${link.link.target} (line ${link.link.line})`);
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
function printOrphansVerbose(orphanNotes) {
|
|
1194
|
-
for (const note of orphanNotes) {
|
|
1195
|
-
console.log(` ${note.relativePath}`);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
program.command("scan").description("Scan the vault for all issues").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--format <type>", "Output format: text, json, markdown, csv, html, mermaid, dot", "text").option("--output <file>", "Output file path").option("--ignore <patterns...>", "Ignore patterns (glob)").option("--attachments", "Also check embedded attachments (images, PDFs, etc.)").option("--ignore-folders <folders...>", "Exclude folders from orphan report").option("--quiet", "Only show issues, no stats or summary").option("--no-color", "Disable colored output").option("--suggestions", "Show smart suggestions for broken links").option("--debug", "Enable debug mode with verbose output").option("--fix", "Automatically fix broken links (requires suggestions)").action(async (options) => {
|
|
1047
|
+
program.command("scan").description("Scan the vault for all issues").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).addOption(
|
|
1048
|
+
new Option("--format <type>", "Output format: text, json, markdown, csv, html").choices(["text", "json", "markdown", "csv", "html"]).default("text")
|
|
1049
|
+
).option("--output <file>", "Output file path").option("--ignore <patterns...>", "Ignore patterns (glob)").option("--attachments", "Also check embedded attachments (images, PDFs, etc.)").option("--ignore-folders <folders...>", "Exclude folders from orphan report").option("--quiet", "Only show issues, no stats or summary").option("--no-color", "Disable colored output").option("--debug", "Enable debug mode with verbose output").action(async (options) => {
|
|
1199
1050
|
const spinner = ora("Scanning vault...").start();
|
|
1200
1051
|
try {
|
|
1201
1052
|
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
@@ -1218,21 +1069,17 @@ program.command("scan").description("Scan the vault for all issues").option("--v
|
|
|
1218
1069
|
} else if (options.format === "html") {
|
|
1219
1070
|
const reporter = new HtmlReporter();
|
|
1220
1071
|
output = reporter.report(report);
|
|
1221
|
-
} else if (options.format === "mermaid") {
|
|
1222
|
-
const reporter = new MermaidReporter();
|
|
1223
|
-
output = reporter.report(report);
|
|
1224
|
-
} else if (options.format === "dot") {
|
|
1225
|
-
const reporter = new GraphVizReporter();
|
|
1226
|
-
output = reporter.report(report);
|
|
1227
1072
|
} else {
|
|
1228
|
-
const reporter = new TerminalReporter(
|
|
1073
|
+
const reporter = new TerminalReporter(
|
|
1074
|
+
options.ignoreFolders || [],
|
|
1075
|
+
options.quiet || false,
|
|
1076
|
+
options.color === false
|
|
1077
|
+
);
|
|
1229
1078
|
output = reporter.report(report);
|
|
1230
1079
|
}
|
|
1231
1080
|
if (options.output) {
|
|
1232
1081
|
await writeFile(options.output, output);
|
|
1233
1082
|
console.log(`Report written to ${options.output}`);
|
|
1234
|
-
} else if (options.format === "text") {
|
|
1235
|
-
console.log(output);
|
|
1236
1083
|
} else {
|
|
1237
1084
|
console.log(output);
|
|
1238
1085
|
}
|
|
@@ -1243,112 +1090,6 @@ program.command("scan").description("Scan the vault for all issues").option("--v
|
|
|
1243
1090
|
process.exit(1);
|
|
1244
1091
|
}
|
|
1245
1092
|
});
|
|
1246
|
-
program.command("broken").description("Show only broken links").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--ignore <patterns...>", "Ignore patterns (glob)").option("--attachments", "Also check embedded attachments (images, PDFs, etc.)").option("--verbose", "Show line numbers and full details").option("--suggestions", "Show smart suggestions for broken links").option("--debug", "Enable debug mode with verbose output").action(async (options) => {
|
|
1247
|
-
const spinner = ora("Scanning vault...").start();
|
|
1248
|
-
try {
|
|
1249
|
-
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
1250
|
-
spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
|
|
1251
|
-
if (report.brokenLinks.length === 0) {
|
|
1252
|
-
console.log("No broken links found.");
|
|
1253
|
-
} else {
|
|
1254
|
-
console.log(`
|
|
1255
|
-
Broken links (${report.brokenLinks.length})
|
|
1256
|
-
`);
|
|
1257
|
-
if (options.verbose) {
|
|
1258
|
-
printBrokenVerbose(report.brokenLinks);
|
|
1259
|
-
} else {
|
|
1260
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
1261
|
-
for (const b of report.brokenLinks) {
|
|
1262
|
-
const existing = grouped.get(b.source) || [];
|
|
1263
|
-
existing.push(b);
|
|
1264
|
-
grouped.set(b.source, existing);
|
|
1265
|
-
}
|
|
1266
|
-
for (const [source, links] of grouped) {
|
|
1267
|
-
const targets = links.map((l) => l.link.target).join(", ");
|
|
1268
|
-
console.log(` ${source} \u2192 ${targets}`);
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
console.log();
|
|
1272
|
-
if (report.suggestions && report.suggestions.length > 0) {
|
|
1273
|
-
console.log("\u{1F4A1} Suggestions:");
|
|
1274
|
-
for (const suggestion of report.suggestions.slice(0, 5)) {
|
|
1275
|
-
const confidence = Math.round(suggestion.similarity * 100);
|
|
1276
|
-
console.log(` ${suggestion.broken} \u2192 ${suggestion.suggested} (${confidence}%)`);
|
|
1277
|
-
}
|
|
1278
|
-
console.log();
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
process.exit(report.stats.brokenCount > 0 ? 1 : 0);
|
|
1282
|
-
} catch (error) {
|
|
1283
|
-
spinner.fail("Scan failed");
|
|
1284
|
-
console.error(error instanceof Error ? error.message : error);
|
|
1285
|
-
process.exit(1);
|
|
1286
|
-
}
|
|
1287
|
-
});
|
|
1288
|
-
program.command("orphans").description("Show only orphan notes").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--ignore <patterns...>", "Ignore patterns (glob)").option("--ignore-folders <folders...>", "Exclude folders from report").option("--verbose", "Show full list of orphan notes").action(async (options) => {
|
|
1289
|
-
const spinner = ora("Scanning vault...").start();
|
|
1290
|
-
try {
|
|
1291
|
-
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
1292
|
-
spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
|
|
1293
|
-
if (report.orphanNotes.length === 0) {
|
|
1294
|
-
console.log("No orphan notes found.");
|
|
1295
|
-
} else {
|
|
1296
|
-
console.log(`
|
|
1297
|
-
Orphan notes (${report.orphanNotes.length})
|
|
1298
|
-
`);
|
|
1299
|
-
if (options.verbose) {
|
|
1300
|
-
printOrphansVerbose(report.orphanNotes);
|
|
1301
|
-
} else {
|
|
1302
|
-
const ignoreFolders = options.ignoreFolders || [];
|
|
1303
|
-
const byFolder = /* @__PURE__ */ new Map();
|
|
1304
|
-
for (const note of report.orphanNotes) {
|
|
1305
|
-
const folder = note.relativePath.includes("/") ? note.relativePath.split("/").slice(0, -1).join("/") + "/" : "./";
|
|
1306
|
-
if (ignoreFolders.some((f) => folder.startsWith(f + "/") || folder === f + "/")) {
|
|
1307
|
-
continue;
|
|
1308
|
-
}
|
|
1309
|
-
byFolder.set(folder, (byFolder.get(folder) || 0) + 1);
|
|
1310
|
-
}
|
|
1311
|
-
for (const [folder, count] of [...byFolder.entries()].sort((a, b) => b[1] - a[1])) {
|
|
1312
|
-
console.log(` ${folder} ${count}`);
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
console.log();
|
|
1316
|
-
}
|
|
1317
|
-
process.exit(report.stats.orphanCount > 0 ? 1 : 0);
|
|
1318
|
-
} catch (error) {
|
|
1319
|
-
spinner.fail("Scan failed");
|
|
1320
|
-
console.error(error instanceof Error ? error.message : error);
|
|
1321
|
-
process.exit(1);
|
|
1322
|
-
}
|
|
1323
|
-
});
|
|
1324
|
-
program.command("graph").description("Show connection graph").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--format <type>", "Output format: text, json", "text").option("--ignore <patterns...>", "Ignore patterns (glob)").action(async (options) => {
|
|
1325
|
-
const spinner = ora("Scanning vault...").start();
|
|
1326
|
-
try {
|
|
1327
|
-
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
1328
|
-
spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
|
|
1329
|
-
if (options.format === "json") {
|
|
1330
|
-
console.log(JSON.stringify(report.graph, null, 2));
|
|
1331
|
-
} else {
|
|
1332
|
-
console.log(
|
|
1333
|
-
`Graph: ${report.stats.totalNotes} notes, ${report.stats.totalLinks} links (${report.duration}ms)
|
|
1334
|
-
`
|
|
1335
|
-
);
|
|
1336
|
-
const topConnected = [...report.graph].sort((a, b) => b.incoming.length - a.incoming.length).slice(0, 10).filter((n) => n.incoming.length > 0);
|
|
1337
|
-
if (topConnected.length > 0) {
|
|
1338
|
-
console.log("Most linked-to notes:");
|
|
1339
|
-
for (const node of topConnected) {
|
|
1340
|
-
console.log(
|
|
1341
|
-
` ${node.note.relativePath} (${node.incoming.length} incoming, ${node.outgoing.length} outgoing)`
|
|
1342
|
-
);
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
} catch (error) {
|
|
1347
|
-
spinner.fail("Scan failed");
|
|
1348
|
-
console.error(error instanceof Error ? error.message : error);
|
|
1349
|
-
process.exit(1);
|
|
1350
|
-
}
|
|
1351
|
-
});
|
|
1352
1093
|
program.command("report").description("Generate a markdown report").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--output <file>", "Output file path", "dead-links-report.md").option("--ignore <patterns...>", "Ignore patterns (glob)").action(async (options) => {
|
|
1353
1094
|
const spinner = ora("Scanning vault...").start();
|
|
1354
1095
|
try {
|
|
@@ -1363,38 +1104,5 @@ program.command("report").description("Generate a markdown report").option("--va
|
|
|
1363
1104
|
process.exit(1);
|
|
1364
1105
|
}
|
|
1365
1106
|
});
|
|
1366
|
-
program.command("stats").description("Show detailed vault statistics").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--ignore <patterns...>", "Ignore patterns (glob)").option("--debug", "Enable debug mode").action(async (options) => {
|
|
1367
|
-
const spinner = ora("Analyzing vault...").start();
|
|
1368
|
-
try {
|
|
1369
|
-
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
1370
|
-
spinner.succeed(`Analysis complete in ${report.duration}ms`);
|
|
1371
|
-
console.log("\n\u{1F4CA} Vault Statistics\n");
|
|
1372
|
-
console.log(` Total Notes: ${report.stats.totalNotes}`);
|
|
1373
|
-
console.log(` Total Links: ${report.stats.totalLinks}`);
|
|
1374
|
-
console.log(` Avg Links per Note: ${(report.stats.totalLinks / report.stats.totalNotes).toFixed(2)}`);
|
|
1375
|
-
console.log(` Broken Links: ${report.stats.brokenCount}`);
|
|
1376
|
-
console.log(` Orphan Notes: ${report.stats.orphanCount}`);
|
|
1377
|
-
console.log(` Connected Components: ${report.stats.connectedComponents}`);
|
|
1378
|
-
console.log(` Health Score: ${(100 - report.stats.brokenCount / report.stats.totalLinks * 100).toFixed(1)}%`);
|
|
1379
|
-
const mostConnected = [...report.graph].sort((a, b) => b.incoming.length + b.outgoing.length - (a.incoming.length + a.outgoing.length)).slice(0, 5);
|
|
1380
|
-
console.log("\n\u{1F310} Most Connected Notes:");
|
|
1381
|
-
for (const node of mostConnected) {
|
|
1382
|
-
const total = node.incoming.length + node.outgoing.length;
|
|
1383
|
-
console.log(` ${node.note.relativePath} (${total} connections: ${node.incoming.length} in, ${node.outgoing.length} out)`);
|
|
1384
|
-
}
|
|
1385
|
-
const lonelyNotes = report.graph.filter((n) => n.incoming.length === 0 && n.outgoing.length === 0).slice(0, 5);
|
|
1386
|
-
if (lonelyNotes.length > 0) {
|
|
1387
|
-
console.log("\n\u{1F534} Orphan Notes:");
|
|
1388
|
-
for (const node of lonelyNotes) {
|
|
1389
|
-
console.log(` ${node.note.relativePath}`);
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
console.log();
|
|
1393
|
-
} catch (error) {
|
|
1394
|
-
spinner.fail("Analysis failed");
|
|
1395
|
-
console.error(error instanceof Error ? error.message : error);
|
|
1396
|
-
process.exit(1);
|
|
1397
|
-
}
|
|
1398
|
-
});
|
|
1399
1107
|
program.parse();
|
|
1400
1108
|
//# sourceMappingURL=index.js.map
|