dead-links 0.1.4 → 0.3.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 +127 -10
- package/dist/index.js +882 -61
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import {
|
|
5
|
+
import { readFileSync } from "fs";
|
|
6
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
6
7
|
import ora from "ora";
|
|
7
8
|
|
|
8
9
|
// src/core/vault-loader.ts
|
|
@@ -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,15 +356,90 @@ function countConnectedComponents(graph) {
|
|
|
312
356
|
return components;
|
|
313
357
|
}
|
|
314
358
|
|
|
359
|
+
// src/utils/suggestions.ts
|
|
360
|
+
function levenshteinDistance(a, b) {
|
|
361
|
+
const matrix = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(0));
|
|
362
|
+
for (let i = 0; i <= a.length; i++) matrix[0][i] = i;
|
|
363
|
+
for (let j = 0; j <= b.length; j++) matrix[j][0] = j;
|
|
364
|
+
for (let j = 1; j <= b.length; j++) {
|
|
365
|
+
for (let i = 1; i <= a.length; i++) {
|
|
366
|
+
if (a[i - 1] === b[j - 1]) {
|
|
367
|
+
matrix[j][i] = matrix[j - 1][i - 1];
|
|
368
|
+
} else {
|
|
369
|
+
matrix[j][i] = Math.min(
|
|
370
|
+
matrix[j][i - 1] + 1,
|
|
371
|
+
matrix[j - 1][i] + 1,
|
|
372
|
+
matrix[j - 1][i - 1] + 1
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return matrix[b.length][a.length];
|
|
378
|
+
}
|
|
379
|
+
function calculateSimilarity(a, b) {
|
|
380
|
+
const distance = levenshteinDistance(
|
|
381
|
+
a.toLowerCase(),
|
|
382
|
+
b.toLowerCase()
|
|
383
|
+
);
|
|
384
|
+
const maxLen = Math.max(a.length, b.length);
|
|
385
|
+
return Math.max(0, 1 - distance / maxLen);
|
|
386
|
+
}
|
|
387
|
+
function generateSuggestions(brokenTargets, allNotes, threshold = 0.6) {
|
|
388
|
+
const availableNotes = allNotes.map((n) => ({
|
|
389
|
+
name: n.relativePath.replace(/\.(md|canvas)$/, ""),
|
|
390
|
+
basename: n.relativePath.split("/").pop()?.replace(/\.(md|canvas)$/, "") || "",
|
|
391
|
+
aliases: n.aliases
|
|
392
|
+
}));
|
|
393
|
+
const suggestions = [];
|
|
394
|
+
for (const broken of brokenTargets) {
|
|
395
|
+
const brokenBasename = broken.split("/").pop() || broken;
|
|
396
|
+
const candidates = [];
|
|
397
|
+
for (const note of availableNotes) {
|
|
398
|
+
const basenameScore = calculateSimilarity(brokenBasename, note.basename);
|
|
399
|
+
const fullScore = calculateSimilarity(broken, note.name);
|
|
400
|
+
for (const alias of note.aliases) {
|
|
401
|
+
const aliasScore = calculateSimilarity(broken, alias);
|
|
402
|
+
if (aliasScore > basenameScore && aliasScore > fullScore) {
|
|
403
|
+
candidates.push({ suggestion: note.name, similarity: aliasScore });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (basenameScore >= fullScore && basenameScore > threshold) {
|
|
407
|
+
candidates.push({ suggestion: note.name, similarity: basenameScore });
|
|
408
|
+
} else if (fullScore > threshold) {
|
|
409
|
+
candidates.push({ suggestion: note.name, similarity: fullScore });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (candidates.length > 0) {
|
|
413
|
+
const best = candidates.sort((a, b) => b.similarity - a.similarity)[0];
|
|
414
|
+
suggestions.push({
|
|
415
|
+
broken,
|
|
416
|
+
suggested: best.suggestion,
|
|
417
|
+
similarity: best.similarity,
|
|
418
|
+
source: broken
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return suggestions.sort((a, b) => b.similarity - a.similarity);
|
|
423
|
+
}
|
|
424
|
+
|
|
315
425
|
// src/utils/engine.ts
|
|
316
426
|
var DeadLinksEngine = class {
|
|
317
427
|
async analyze(vaultPath, config = {}) {
|
|
318
428
|
const startTime = Date.now();
|
|
319
429
|
const notes = await loadVault(vaultPath, config.ignore);
|
|
320
|
-
|
|
321
|
-
|
|
430
|
+
await Promise.all(
|
|
431
|
+
notes.map((note) => {
|
|
432
|
+
note.links = parseLinks(note.content);
|
|
433
|
+
return Promise.resolve();
|
|
434
|
+
})
|
|
435
|
+
);
|
|
436
|
+
const report = analyze(notes, vaultPath, config.checkAttachments, config.ignoreFolders);
|
|
437
|
+
if (config.suggestions || config.fix) {
|
|
438
|
+
const brokenTargets = Array.from(
|
|
439
|
+
new Set(report.brokenLinks.map((b) => b.link.target))
|
|
440
|
+
);
|
|
441
|
+
report.suggestions = generateSuggestions(brokenTargets, notes, 0.65);
|
|
322
442
|
}
|
|
323
|
-
const report = analyze(notes, vaultPath, config.checkAttachments);
|
|
324
443
|
report.duration = Date.now() - startTime;
|
|
325
444
|
return report;
|
|
326
445
|
}
|
|
@@ -328,11 +447,11 @@ var DeadLinksEngine = class {
|
|
|
328
447
|
|
|
329
448
|
// src/utils/config-loader.ts
|
|
330
449
|
import { readFile as readFile2, access } from "fs/promises";
|
|
331
|
-
import { resolve as
|
|
450
|
+
import { resolve as resolve3, join } from "path";
|
|
332
451
|
var CONFIG_FILES = [".deadlinksrc.json", ".deadlinksrc", "dead-links.config.json"];
|
|
333
452
|
var ConfigLoader = class {
|
|
334
453
|
async loadConfig(vaultPath) {
|
|
335
|
-
const absolutePath =
|
|
454
|
+
const absolutePath = resolve3(vaultPath);
|
|
336
455
|
for (const configFile of CONFIG_FILES) {
|
|
337
456
|
const configPath = join(absolutePath, configFile);
|
|
338
457
|
try {
|
|
@@ -356,19 +475,24 @@ var ConfigLoader = class {
|
|
|
356
475
|
return {
|
|
357
476
|
ignore: cliOptions.ignore || fileConfig.ignore,
|
|
358
477
|
checkAttachments: cliOptions.attachments ?? fileConfig.checkAttachments ?? false,
|
|
359
|
-
ignoreFolders: cliOptions.ignoreFolders || fileConfig.ignoreFolders
|
|
478
|
+
ignoreFolders: cliOptions.ignoreFolders || fileConfig.ignoreFolders,
|
|
479
|
+
suggestions: cliOptions.suggestions ?? fileConfig.suggestions ?? false,
|
|
480
|
+
debug: cliOptions.debug ?? fileConfig.debug ?? false,
|
|
481
|
+
fix: cliOptions.fix ?? fileConfig.fix ?? false
|
|
360
482
|
};
|
|
361
483
|
}
|
|
362
484
|
};
|
|
363
485
|
|
|
364
486
|
// src/reporters/terminal-reporter.ts
|
|
365
|
-
import
|
|
487
|
+
import { Chalk } from "chalk";
|
|
366
488
|
var TerminalReporter = class {
|
|
367
489
|
ignoreFolders;
|
|
368
490
|
quiet;
|
|
369
|
-
|
|
491
|
+
chalk;
|
|
492
|
+
constructor(ignoreFolders = [], quiet = false, noColor = false) {
|
|
370
493
|
this.ignoreFolders = ignoreFolders;
|
|
371
494
|
this.quiet = quiet;
|
|
495
|
+
this.chalk = new Chalk({ level: noColor ? 0 : void 0 });
|
|
372
496
|
}
|
|
373
497
|
report(result) {
|
|
374
498
|
if (this.quiet) {
|
|
@@ -378,6 +502,9 @@ var TerminalReporter = class {
|
|
|
378
502
|
output += this.printStats(result);
|
|
379
503
|
output += this.printBrokenLinks(result.brokenLinks);
|
|
380
504
|
output += this.printOrphanNotes(result.orphanNotes);
|
|
505
|
+
if (result.suggestions && result.suggestions.length > 0) {
|
|
506
|
+
output += this.printSuggestions(result.suggestions);
|
|
507
|
+
}
|
|
381
508
|
output += this.printDuration(result.duration);
|
|
382
509
|
return output;
|
|
383
510
|
}
|
|
@@ -407,12 +534,13 @@ var TerminalReporter = class {
|
|
|
407
534
|
return out;
|
|
408
535
|
}
|
|
409
536
|
statLine(label, value, color = "white") {
|
|
410
|
-
|
|
537
|
+
const chalk = this.chalk;
|
|
538
|
+
return ` ${this.chalk.gray(label)} ${color === "red" ? this.chalk.red.bold(value) : color === "yellow" ? this.chalk.yellow.bold(value) : this.chalk.white.bold(value)}
|
|
411
539
|
`;
|
|
412
540
|
}
|
|
413
541
|
printBrokenLinks(brokenLinks) {
|
|
414
542
|
if (brokenLinks.length === 0) {
|
|
415
|
-
return chalk.green(" \u2713 No broken links\n\n");
|
|
543
|
+
return this.chalk.green(" \u2713 No broken links\n\n");
|
|
416
544
|
}
|
|
417
545
|
const grouped = /* @__PURE__ */ new Map();
|
|
418
546
|
for (const broken of brokenLinks) {
|
|
@@ -421,18 +549,18 @@ var TerminalReporter = class {
|
|
|
421
549
|
grouped.set(broken.source, existing);
|
|
422
550
|
}
|
|
423
551
|
const sorted = [...grouped.entries()].sort((a, b) => b[1].length - a[1].length);
|
|
424
|
-
let out = chalk.bold.red(`
|
|
552
|
+
let out = this.chalk.bold.red(`
|
|
425
553
|
\u2716 Broken links (${brokenLinks.length})
|
|
426
554
|
|
|
427
555
|
`);
|
|
428
556
|
for (const [source, links] of sorted) {
|
|
429
557
|
const count = links.length;
|
|
430
558
|
const isMultiple = count >= 2;
|
|
431
|
-
const countStr = isMultiple ? chalk.red.bold(`\u2716 ${count}`) : chalk.yellow.bold(`\u2716 ${count}`);
|
|
432
|
-
out += ` ${countStr} ${chalk.white.bold(source)}
|
|
559
|
+
const countStr = isMultiple ? this.chalk.red.bold(`\u2716 ${count}`) : this.chalk.yellow.bold(`\u2716 ${count}`);
|
|
560
|
+
out += ` ${countStr} ${this.chalk.white.bold(source)}
|
|
433
561
|
`;
|
|
434
562
|
for (const link of links) {
|
|
435
|
-
const pill2 = isMultiple ? chalk.redBright(`[${link.link.target}]`) : chalk.yellowBright(`[${link.link.target}]`);
|
|
563
|
+
const pill2 = isMultiple ? this.chalk.redBright(`[${link.link.target}]`) : this.chalk.yellowBright(`[${link.link.target}]`);
|
|
436
564
|
out += ` ${pill2}
|
|
437
565
|
`;
|
|
438
566
|
}
|
|
@@ -442,7 +570,7 @@ var TerminalReporter = class {
|
|
|
442
570
|
}
|
|
443
571
|
printOrphanNotes(orphanNotes) {
|
|
444
572
|
if (orphanNotes.length === 0) {
|
|
445
|
-
return chalk.green(" \u2713 No orphan notes\n\n");
|
|
573
|
+
return this.chalk.green(" \u2713 No orphan notes\n\n");
|
|
446
574
|
}
|
|
447
575
|
const byFolder = /* @__PURE__ */ new Map();
|
|
448
576
|
for (const note of orphanNotes) {
|
|
@@ -454,28 +582,44 @@ var TerminalReporter = class {
|
|
|
454
582
|
}
|
|
455
583
|
const sorted = [...byFolder.entries()].sort((a, b) => b[1] - a[1]);
|
|
456
584
|
if (sorted.length === 0) {
|
|
457
|
-
return chalk.green(" \u2713 No orphan notes (all in ignored folders)\n\n");
|
|
585
|
+
return this.chalk.green(" \u2713 No orphan notes (all in ignored folders)\n\n");
|
|
458
586
|
}
|
|
459
587
|
const maxCount = sorted[0][1];
|
|
460
588
|
const barWidth = 10;
|
|
461
|
-
let out = chalk.bold.yellow(`
|
|
589
|
+
let out = this.chalk.bold.yellow(`
|
|
462
590
|
\u26A0 Orphan notes (${orphanNotes.length} total)
|
|
463
591
|
|
|
464
592
|
`);
|
|
465
593
|
for (const [folder, count] of sorted) {
|
|
466
594
|
const filled = Math.round(count / maxCount * barWidth);
|
|
467
595
|
const empty = barWidth - filled;
|
|
468
|
-
const bar2 = chalk.gray("\u2588".repeat(filled) + "\u2591".repeat(empty));
|
|
469
|
-
out += ` ${chalk.gray(folder.padEnd(40))} ${bar2} ${chalk.white.bold(String(count))}
|
|
596
|
+
const bar2 = this.chalk.gray("\u2588".repeat(filled) + "\u2591".repeat(empty));
|
|
597
|
+
out += ` ${this.chalk.gray(folder.padEnd(40))} ${bar2} ${this.chalk.white.bold(String(count))}
|
|
470
598
|
`;
|
|
471
599
|
}
|
|
472
600
|
out += "\n";
|
|
473
601
|
return out;
|
|
474
602
|
}
|
|
475
603
|
printDuration(ms) {
|
|
476
|
-
return chalk.dim(` Completed in ${ms}ms
|
|
604
|
+
return this.chalk.dim(` Completed in ${ms}ms
|
|
477
605
|
`);
|
|
478
606
|
}
|
|
607
|
+
printSuggestions(suggestions) {
|
|
608
|
+
let out = this.chalk.bold.cyan(`
|
|
609
|
+
\u{1F4A1} Suggestions (${suggestions.length})
|
|
610
|
+
|
|
611
|
+
`);
|
|
612
|
+
for (const suggestion of suggestions.slice(0, 5)) {
|
|
613
|
+
const confidence = Math.round(suggestion.similarity * 100);
|
|
614
|
+
const bar2 = confidence >= 80 ? this.chalk.green("\u2588\u2588\u2588\u2588\u2588") : confidence >= 60 ? this.chalk.yellow("\u2588\u2588\u2588\u2588\u2588") : this.chalk.gray("\u2588\u2588\u2588\u2588\u2588");
|
|
615
|
+
out += ` ${this.chalk.gray(suggestion.broken)} \u2192 ${this.chalk.cyan(suggestion.suggested)}
|
|
616
|
+
`;
|
|
617
|
+
out += ` ${bar2} ${confidence}%
|
|
618
|
+
|
|
619
|
+
`;
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
479
623
|
};
|
|
480
624
|
|
|
481
625
|
// src/reporters/json-reporter.ts
|
|
@@ -501,6 +645,510 @@ var JsonReporter = class {
|
|
|
501
645
|
}
|
|
502
646
|
};
|
|
503
647
|
|
|
648
|
+
// src/reporters/csv-reporter.ts
|
|
649
|
+
var CsvReporter = class {
|
|
650
|
+
report(result) {
|
|
651
|
+
const lines = [];
|
|
652
|
+
lines.push("# BROKEN LINKS");
|
|
653
|
+
lines.push("Source,Target,Type,Line,Reason");
|
|
654
|
+
for (const broken of result.brokenLinks) {
|
|
655
|
+
const source = this.escapeCsv(broken.source);
|
|
656
|
+
const target = this.escapeCsv(broken.link.target);
|
|
657
|
+
const type = broken.link.type;
|
|
658
|
+
const line = broken.link.line;
|
|
659
|
+
const reason = this.escapeCsv(broken.reason);
|
|
660
|
+
lines.push(`${source},${target},${type},${line},"${reason}"`);
|
|
661
|
+
}
|
|
662
|
+
lines.push("\n# ORPHAN NOTES");
|
|
663
|
+
lines.push("Note,Path");
|
|
664
|
+
for (const orphan of result.orphanNotes) {
|
|
665
|
+
const name = this.escapeCsv(orphan.relativePath);
|
|
666
|
+
lines.push(`${name},"${name}"`);
|
|
667
|
+
}
|
|
668
|
+
lines.push("\n# GRAPH STATISTICS");
|
|
669
|
+
lines.push("Metric,Value");
|
|
670
|
+
lines.push(`Total Notes,${result.stats.totalNotes}`);
|
|
671
|
+
lines.push(`Total Links,${result.stats.totalLinks}`);
|
|
672
|
+
lines.push(`Broken Links,${result.stats.brokenCount}`);
|
|
673
|
+
lines.push(`Orphan Notes,${result.stats.orphanCount}`);
|
|
674
|
+
lines.push(`Connected Components,${result.stats.connectedComponents}`);
|
|
675
|
+
lines.push(`Duration (ms),${result.duration}`);
|
|
676
|
+
return lines.join("\n");
|
|
677
|
+
}
|
|
678
|
+
escapeCsv(value) {
|
|
679
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
680
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
681
|
+
}
|
|
682
|
+
return value;
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// src/reporters/html-reporter.ts
|
|
687
|
+
var HtmlReporter = class {
|
|
688
|
+
report(result) {
|
|
689
|
+
const brokenLinksHtml = this.renderBrokenLinks(result.brokenLinks);
|
|
690
|
+
const orphanNotesHtml = this.renderOrphanNotes(result.orphanNotes);
|
|
691
|
+
const graphHtml = this.renderGraph(result.graph);
|
|
692
|
+
const statsHtml = this.renderStats(result.stats, result.duration);
|
|
693
|
+
return `<!DOCTYPE html>
|
|
694
|
+
<html lang="en">
|
|
695
|
+
<head>
|
|
696
|
+
<meta charset="UTF-8">
|
|
697
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
698
|
+
<title>Dead Links Report</title>
|
|
699
|
+
<style>
|
|
700
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
701
|
+
body {
|
|
702
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
703
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
704
|
+
min-height: 100vh;
|
|
705
|
+
padding: 20px;
|
|
706
|
+
}
|
|
707
|
+
.container {
|
|
708
|
+
max-width: 1400px;
|
|
709
|
+
margin: 0 auto;
|
|
710
|
+
background: white;
|
|
711
|
+
border-radius: 12px;
|
|
712
|
+
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
713
|
+
overflow: hidden;
|
|
714
|
+
}
|
|
715
|
+
header {
|
|
716
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
717
|
+
color: white;
|
|
718
|
+
padding: 40px;
|
|
719
|
+
text-align: center;
|
|
720
|
+
}
|
|
721
|
+
header h1 { font-size: 2.5em; margin-bottom: 10px; }
|
|
722
|
+
header p { font-size: 1.1em; opacity: 0.9; }
|
|
723
|
+
.content { padding: 40px; }
|
|
724
|
+
.section {
|
|
725
|
+
margin-bottom: 40px;
|
|
726
|
+
border-left: 4px solid #667eea;
|
|
727
|
+
padding-left: 20px;
|
|
728
|
+
}
|
|
729
|
+
.section h2 {
|
|
730
|
+
font-size: 1.8em;
|
|
731
|
+
margin-bottom: 20px;
|
|
732
|
+
color: #333;
|
|
733
|
+
}
|
|
734
|
+
.stats-grid {
|
|
735
|
+
display: grid;
|
|
736
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
737
|
+
gap: 20px;
|
|
738
|
+
margin-bottom: 40px;
|
|
739
|
+
}
|
|
740
|
+
.stat-card {
|
|
741
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
742
|
+
color: white;
|
|
743
|
+
padding: 20px;
|
|
744
|
+
border-radius: 8px;
|
|
745
|
+
text-align: center;
|
|
746
|
+
}
|
|
747
|
+
.stat-card .value { font-size: 2.5em; font-weight: bold; }
|
|
748
|
+
.stat-card .label { font-size: 0.9em; opacity: 0.9; margin-top: 10px; }
|
|
749
|
+
table {
|
|
750
|
+
width: 100%;
|
|
751
|
+
border-collapse: collapse;
|
|
752
|
+
margin-top: 20px;
|
|
753
|
+
}
|
|
754
|
+
th {
|
|
755
|
+
background: #f5f5f5;
|
|
756
|
+
padding: 12px;
|
|
757
|
+
text-align: left;
|
|
758
|
+
border-bottom: 2px solid #667eea;
|
|
759
|
+
font-weight: 600;
|
|
760
|
+
}
|
|
761
|
+
td {
|
|
762
|
+
padding: 12px;
|
|
763
|
+
border-bottom: 1px solid #eee;
|
|
764
|
+
}
|
|
765
|
+
tr:hover { background: #f9f9f9; }
|
|
766
|
+
.badge {
|
|
767
|
+
display: inline-block;
|
|
768
|
+
padding: 4px 8px;
|
|
769
|
+
border-radius: 4px;
|
|
770
|
+
font-size: 0.85em;
|
|
771
|
+
font-weight: 500;
|
|
772
|
+
}
|
|
773
|
+
.badge-wiki { background: #dbeafe; color: #1e40af; }
|
|
774
|
+
.badge-embed { background: #fed7aa; color: #92400e; }
|
|
775
|
+
.badge-markdown { background: #d1fae5; color: #065f46; }
|
|
776
|
+
.error { color: #dc2626; }
|
|
777
|
+
.success { color: #16a34a; }
|
|
778
|
+
.tabs {
|
|
779
|
+
display: flex;
|
|
780
|
+
gap: 0;
|
|
781
|
+
border-bottom: 2px solid #eee;
|
|
782
|
+
margin-bottom: 20px;
|
|
783
|
+
}
|
|
784
|
+
.tab-button {
|
|
785
|
+
padding: 12px 20px;
|
|
786
|
+
border: none;
|
|
787
|
+
background: none;
|
|
788
|
+
cursor: pointer;
|
|
789
|
+
font-size: 1em;
|
|
790
|
+
border-bottom: 3px solid transparent;
|
|
791
|
+
color: #666;
|
|
792
|
+
transition: all 0.3s;
|
|
793
|
+
}
|
|
794
|
+
.tab-button.active {
|
|
795
|
+
color: #667eea;
|
|
796
|
+
border-bottom-color: #667eea;
|
|
797
|
+
}
|
|
798
|
+
.tab-content { display: none; }
|
|
799
|
+
.tab-content.active { display: block; }
|
|
800
|
+
.filter-box {
|
|
801
|
+
margin: 20px 0;
|
|
802
|
+
padding: 15px;
|
|
803
|
+
background: #f9f9f9;
|
|
804
|
+
border-radius: 8px;
|
|
805
|
+
}
|
|
806
|
+
.filter-box input {
|
|
807
|
+
width: 100%;
|
|
808
|
+
padding: 8px;
|
|
809
|
+
border: 1px solid #ddd;
|
|
810
|
+
border-radius: 4px;
|
|
811
|
+
font-size: 1em;
|
|
812
|
+
}
|
|
813
|
+
footer {
|
|
814
|
+
background: #f5f5f5;
|
|
815
|
+
padding: 20px;
|
|
816
|
+
text-align: center;
|
|
817
|
+
color: #666;
|
|
818
|
+
font-size: 0.9em;
|
|
819
|
+
}
|
|
820
|
+
</style>
|
|
821
|
+
</head>
|
|
822
|
+
<body>
|
|
823
|
+
<div class="container">
|
|
824
|
+
<header>
|
|
825
|
+
<h1>\u{1F50D} Dead Links Report</h1>
|
|
826
|
+
<p>Obsidian Vault Analysis</p>
|
|
827
|
+
</header>
|
|
828
|
+
|
|
829
|
+
<div class="content">
|
|
830
|
+
${statsHtml}
|
|
831
|
+
${brokenLinksHtml}
|
|
832
|
+
${orphanNotesHtml}
|
|
833
|
+
${graphHtml}
|
|
834
|
+
</div>
|
|
835
|
+
|
|
836
|
+
<footer>
|
|
837
|
+
Generated on ${(/* @__PURE__ */ new Date()).toLocaleString()}
|
|
838
|
+
</footer>
|
|
839
|
+
</div>
|
|
840
|
+
|
|
841
|
+
<script>
|
|
842
|
+
function setupTabs() {
|
|
843
|
+
const buttons = document.querySelectorAll('.tab-button');
|
|
844
|
+
const contents = document.querySelectorAll('.tab-content');
|
|
845
|
+
|
|
846
|
+
buttons.forEach((button, index) => {
|
|
847
|
+
button.addEventListener('click', () => {
|
|
848
|
+
buttons.forEach(b => b.classList.remove('active'));
|
|
849
|
+
contents.forEach(c => c.classList.remove('active'));
|
|
850
|
+
button.classList.add('active');
|
|
851
|
+
contents[index].classList.add('active');
|
|
852
|
+
});
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// Activate first tab
|
|
856
|
+
if (buttons.length > 0) buttons[0].classList.add('active');
|
|
857
|
+
if (contents.length > 0) contents[0].classList.add('active');
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function setupFilter(tableId, inputId) {
|
|
861
|
+
const input = document.getElementById(inputId);
|
|
862
|
+
const table = document.getElementById(tableId);
|
|
863
|
+
if (!input || !table) return;
|
|
864
|
+
|
|
865
|
+
input.addEventListener('keyup', (e) => {
|
|
866
|
+
const filter = e.target.value.toLowerCase();
|
|
867
|
+
const rows = table.querySelectorAll('tbody tr');
|
|
868
|
+
rows.forEach(row => {
|
|
869
|
+
const text = row.textContent.toLowerCase();
|
|
870
|
+
row.style.display = text.includes(filter) ? '' : 'none';
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
876
|
+
setupTabs();
|
|
877
|
+
setupFilter('broken-table', 'broken-filter');
|
|
878
|
+
setupFilter('orphan-table', 'orphan-filter');
|
|
879
|
+
});
|
|
880
|
+
</script>
|
|
881
|
+
</body>
|
|
882
|
+
</html>`;
|
|
883
|
+
}
|
|
884
|
+
renderStats(stats, duration) {
|
|
885
|
+
const healthColor = stats.brokenCount === 0 && stats.orphanCount === 0 ? "#16a34a" : "#dc2626";
|
|
886
|
+
return `
|
|
887
|
+
<div class="section">
|
|
888
|
+
<h2>\u{1F4CA} Overview</h2>
|
|
889
|
+
<div class="stats-grid">
|
|
890
|
+
<div class="stat-card">
|
|
891
|
+
<div class="value">${stats.totalNotes}</div>
|
|
892
|
+
<div class="label">Total Notes</div>
|
|
893
|
+
</div>
|
|
894
|
+
<div class="stat-card">
|
|
895
|
+
<div class="value">${stats.totalLinks}</div>
|
|
896
|
+
<div class="label">Total Links</div>
|
|
897
|
+
</div>
|
|
898
|
+
<div class="stat-card" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
|
|
899
|
+
<div class="value">${stats.brokenCount}</div>
|
|
900
|
+
<div class="label">Broken Links</div>
|
|
901
|
+
</div>
|
|
902
|
+
<div class="stat-card" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
|
|
903
|
+
<div class="value">${stats.orphanCount}</div>
|
|
904
|
+
<div class="label">Orphan Notes</div>
|
|
905
|
+
</div>
|
|
906
|
+
<div class="stat-card" style="background: linear-gradient(135deg, ${healthColor} 0%, ${healthColor === "#16a34a" ? "#15803d" : "#991b1b"} 100%);">
|
|
907
|
+
<div class="value">${stats.connectedComponents}</div>
|
|
908
|
+
<div class="label">Connected Components</div>
|
|
909
|
+
</div>
|
|
910
|
+
<div class="stat-card" style="background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);">
|
|
911
|
+
<div class="value">${duration}ms</div>
|
|
912
|
+
<div class="label">Scan Duration</div>
|
|
913
|
+
</div>
|
|
914
|
+
</div>
|
|
915
|
+
</div>
|
|
916
|
+
`;
|
|
917
|
+
}
|
|
918
|
+
renderBrokenLinks(brokenLinks) {
|
|
919
|
+
if (brokenLinks.length === 0) {
|
|
920
|
+
return `
|
|
921
|
+
<div class="section">
|
|
922
|
+
<h2>\u2705 Broken Links</h2>
|
|
923
|
+
<p class="success">No broken links found!</p>
|
|
924
|
+
</div>
|
|
925
|
+
`;
|
|
926
|
+
}
|
|
927
|
+
const rows = brokenLinks.map(
|
|
928
|
+
(link) => `
|
|
929
|
+
<tr>
|
|
930
|
+
<td>${this.escapeHtml(link.source)}</td>
|
|
931
|
+
<td>${this.escapeHtml(link.link.target)}</td>
|
|
932
|
+
<td><span class="badge badge-${link.link.type}">${link.link.type}</span></td>
|
|
933
|
+
<td>${link.link.line}</td>
|
|
934
|
+
<td>${this.escapeHtml(link.reason)}</td>
|
|
935
|
+
</tr>
|
|
936
|
+
`
|
|
937
|
+
).join("");
|
|
938
|
+
return `
|
|
939
|
+
<div class="section">
|
|
940
|
+
<h2>\u{1F534} Broken Links (${brokenLinks.length})</h2>
|
|
941
|
+
<div class="filter-box">
|
|
942
|
+
<input type="text" id="broken-filter" placeholder="Filter by source or target..." />
|
|
943
|
+
</div>
|
|
944
|
+
<table id="broken-table">
|
|
945
|
+
<thead>
|
|
946
|
+
<tr>
|
|
947
|
+
<th>Source</th>
|
|
948
|
+
<th>Target</th>
|
|
949
|
+
<th>Type</th>
|
|
950
|
+
<th>Line</th>
|
|
951
|
+
<th>Reason</th>
|
|
952
|
+
</tr>
|
|
953
|
+
</thead>
|
|
954
|
+
<tbody>
|
|
955
|
+
${rows}
|
|
956
|
+
</tbody>
|
|
957
|
+
</table>
|
|
958
|
+
</div>
|
|
959
|
+
`;
|
|
960
|
+
}
|
|
961
|
+
renderOrphanNotes(orphanNotes) {
|
|
962
|
+
if (orphanNotes.length === 0) {
|
|
963
|
+
return `
|
|
964
|
+
<div class="section">
|
|
965
|
+
<h2>\u2705 Orphan Notes</h2>
|
|
966
|
+
<p class="success">No orphan notes found!</p>
|
|
967
|
+
</div>
|
|
968
|
+
`;
|
|
969
|
+
}
|
|
970
|
+
const rows = orphanNotes.map(
|
|
971
|
+
(note) => `
|
|
972
|
+
<tr>
|
|
973
|
+
<td>${this.escapeHtml(note.relativePath)}</td>
|
|
974
|
+
</tr>
|
|
975
|
+
`
|
|
976
|
+
).join("");
|
|
977
|
+
return `
|
|
978
|
+
<div class="section">
|
|
979
|
+
<h2>\u{1F7E1} Orphan Notes (${orphanNotes.length})</h2>
|
|
980
|
+
<div class="filter-box">
|
|
981
|
+
<input type="text" id="orphan-filter" placeholder="Filter by path..." />
|
|
982
|
+
</div>
|
|
983
|
+
<table id="orphan-table">
|
|
984
|
+
<thead>
|
|
985
|
+
<tr>
|
|
986
|
+
<th>Path</th>
|
|
987
|
+
</tr>
|
|
988
|
+
</thead>
|
|
989
|
+
<tbody>
|
|
990
|
+
${rows}
|
|
991
|
+
</tbody>
|
|
992
|
+
</table>
|
|
993
|
+
</div>
|
|
994
|
+
`;
|
|
995
|
+
}
|
|
996
|
+
renderGraph(graph) {
|
|
997
|
+
const mostConnected = [...graph].sort((a, b) => b.incoming.length + b.outgoing.length - (a.incoming.length + a.outgoing.length)).slice(0, 5);
|
|
998
|
+
const rows = mostConnected.map(
|
|
999
|
+
(node) => `
|
|
1000
|
+
<tr>
|
|
1001
|
+
<td>${this.escapeHtml(node.note.relativePath)}</td>
|
|
1002
|
+
<td>${node.incoming.length}</td>
|
|
1003
|
+
<td>${node.outgoing.length}</td>
|
|
1004
|
+
<td>${node.incoming.length + node.outgoing.length}</td>
|
|
1005
|
+
</tr>
|
|
1006
|
+
`
|
|
1007
|
+
).join("");
|
|
1008
|
+
return `
|
|
1009
|
+
<div class="section">
|
|
1010
|
+
<h2>\u{1F310} Most Connected Notes</h2>
|
|
1011
|
+
<table>
|
|
1012
|
+
<thead>
|
|
1013
|
+
<tr>
|
|
1014
|
+
<th>Note</th>
|
|
1015
|
+
<th>Incoming</th>
|
|
1016
|
+
<th>Outgoing</th>
|
|
1017
|
+
<th>Total</th>
|
|
1018
|
+
</tr>
|
|
1019
|
+
</thead>
|
|
1020
|
+
<tbody>
|
|
1021
|
+
${rows}
|
|
1022
|
+
</tbody>
|
|
1023
|
+
</table>
|
|
1024
|
+
</div>
|
|
1025
|
+
`;
|
|
1026
|
+
}
|
|
1027
|
+
escapeHtml(text) {
|
|
1028
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
// src/reporters/mermaid-reporter.ts
|
|
1033
|
+
var MermaidReporter = class {
|
|
1034
|
+
report(result, maxNodes = 50) {
|
|
1035
|
+
let output = "graph LR\n";
|
|
1036
|
+
const nodesToShow = result.graph.slice(0, maxNodes);
|
|
1037
|
+
const nodeMap = new Map(nodesToShow.map((n) => [n.note.path, n.note.relativePath]));
|
|
1038
|
+
for (const node of nodesToShow) {
|
|
1039
|
+
const id = this.sanitizeId(node.note.relativePath);
|
|
1040
|
+
const label = this.escapeLabel(node.note.relativePath);
|
|
1041
|
+
if (result.orphanNotes.some((o) => o.path === node.note.path)) {
|
|
1042
|
+
output += ` ${id}["<b>\u{1F534} ${label}</b>"]:::orphan
|
|
1043
|
+
`;
|
|
1044
|
+
} else if (node.incoming.length === 0) {
|
|
1045
|
+
output += ` ${id}["\u{1F4C4} ${label}"]:::source
|
|
1046
|
+
`;
|
|
1047
|
+
} else if (node.outgoing.length === 0) {
|
|
1048
|
+
output += ` ${id}["\u{1F4CC} ${label}"]:::sink
|
|
1049
|
+
`;
|
|
1050
|
+
} else {
|
|
1051
|
+
output += ` ${id}["${label}"]
|
|
1052
|
+
`;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
const addedEdges = /* @__PURE__ */ new Set();
|
|
1056
|
+
for (const node of nodesToShow) {
|
|
1057
|
+
const sourceId = this.sanitizeId(node.note.relativePath);
|
|
1058
|
+
for (const targetPath of node.outgoing) {
|
|
1059
|
+
const targetName = nodeMap.get(targetPath);
|
|
1060
|
+
if (!targetName) continue;
|
|
1061
|
+
const targetId = this.sanitizeId(targetName);
|
|
1062
|
+
const edgeKey = `${sourceId}-${targetId}`;
|
|
1063
|
+
if (!addedEdges.has(edgeKey)) {
|
|
1064
|
+
output += ` ${sourceId} --> ${targetId}
|
|
1065
|
+
`;
|
|
1066
|
+
addedEdges.add(edgeKey);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
output += "\n classDef orphan fill:#ff6b6b,stroke:#c92a2a,color:#fff\n";
|
|
1071
|
+
output += " classDef source fill:#4dabf7,stroke:#1971c2,color:#fff\n";
|
|
1072
|
+
output += " classDef sink fill:#51cf66,stroke:#2f9e44,color:#fff\n";
|
|
1073
|
+
if (result.graph.length > maxNodes) {
|
|
1074
|
+
output += `
|
|
1075
|
+
note["Graph limited to ${maxNodes} of ${result.graph.length} nodes for readability"]
|
|
1076
|
+
`;
|
|
1077
|
+
}
|
|
1078
|
+
return output;
|
|
1079
|
+
}
|
|
1080
|
+
sanitizeId(name) {
|
|
1081
|
+
return name.replace(/\.(md|canvas)$/, "").replace(/[^a-zA-Z0-9_]/g, "_").substring(0, 30);
|
|
1082
|
+
}
|
|
1083
|
+
escapeLabel(name) {
|
|
1084
|
+
return name.replace(/"/g, '\\"').substring(0, 50);
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
// src/reporters/graphviz-reporter.ts
|
|
1089
|
+
var GraphVizReporter = class {
|
|
1090
|
+
report(result, maxNodes = 100) {
|
|
1091
|
+
let output = "digraph VaultGraph {\n";
|
|
1092
|
+
output += " rankdir=LR;\n";
|
|
1093
|
+
output += " node [shape=box, style=rounded];\n";
|
|
1094
|
+
output += " edge [arrowsize=0.5];\n\n";
|
|
1095
|
+
const nodesToShow = result.graph.slice(0, maxNodes);
|
|
1096
|
+
const nodeMap = new Map(nodesToShow.map((n) => [n.note.path, n.note.relativePath]));
|
|
1097
|
+
for (const node of nodesToShow) {
|
|
1098
|
+
const id = this.sanitizeId(node.note.relativePath);
|
|
1099
|
+
const label = this.escapeLabel(node.note.relativePath);
|
|
1100
|
+
let style = "filled";
|
|
1101
|
+
let color = "#ffffff";
|
|
1102
|
+
let fontColor = "#000000";
|
|
1103
|
+
let shape = "box";
|
|
1104
|
+
if (result.orphanNotes.some((o) => o.path === node.note.path)) {
|
|
1105
|
+
color = "#ff6b6b";
|
|
1106
|
+
fontColor = "#ffffff";
|
|
1107
|
+
shape = "box";
|
|
1108
|
+
} else if (node.incoming.length === 0) {
|
|
1109
|
+
color = "#4dabf7";
|
|
1110
|
+
fontColor = "#ffffff";
|
|
1111
|
+
} else if (node.outgoing.length === 0) {
|
|
1112
|
+
color = "#51cf66";
|
|
1113
|
+
fontColor = "#ffffff";
|
|
1114
|
+
}
|
|
1115
|
+
output += ` "${id}" [label="${label}", fillcolor="${color}", fontcolor="${fontColor}", shape=${shape}];
|
|
1116
|
+
`;
|
|
1117
|
+
}
|
|
1118
|
+
output += "\n";
|
|
1119
|
+
const addedEdges = /* @__PURE__ */ new Set();
|
|
1120
|
+
for (const node of nodesToShow) {
|
|
1121
|
+
const sourceId = this.sanitizeId(node.note.relativePath);
|
|
1122
|
+
for (const targetPath of node.outgoing) {
|
|
1123
|
+
const targetName = nodeMap.get(targetPath);
|
|
1124
|
+
if (!targetName) continue;
|
|
1125
|
+
const targetId = this.sanitizeId(targetName);
|
|
1126
|
+
const edgeKey = `${sourceId}->${targetId}`;
|
|
1127
|
+
if (!addedEdges.has(edgeKey)) {
|
|
1128
|
+
output += ` "${sourceId}" -> "${targetId}";
|
|
1129
|
+
`;
|
|
1130
|
+
addedEdges.add(edgeKey);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
output += "\n // Legend\n";
|
|
1135
|
+
output += " subgraph legend {\n";
|
|
1136
|
+
output += " rank=sink;\n";
|
|
1137
|
+
output += ' orphan [label="\u{1F534} Orphan", fillcolor="#ff6b6b", fontcolor="white"];\n';
|
|
1138
|
+
output += ' source [label="\u{1F4C4} Source (no incoming)", fillcolor="#4dabf7", fontcolor="white"];\n';
|
|
1139
|
+
output += ' sink [label="\u{1F4CC} Sink (no outgoing)", fillcolor="#51cf66", fontcolor="white"];\n';
|
|
1140
|
+
output += " }\n";
|
|
1141
|
+
output += "}\n";
|
|
1142
|
+
return output;
|
|
1143
|
+
}
|
|
1144
|
+
sanitizeId(name) {
|
|
1145
|
+
return name.replace(/\.(md|canvas)$/, "").replace(/[^a-zA-Z0-9_]/g, "_");
|
|
1146
|
+
}
|
|
1147
|
+
escapeLabel(label) {
|
|
1148
|
+
return label.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
|
|
504
1152
|
// src/utils/markdown-report.ts
|
|
505
1153
|
function pill(target, isMultiple) {
|
|
506
1154
|
return isMultiple ? `[${target}]` : `[${target}]`;
|
|
@@ -569,9 +1217,109 @@ function generateMarkdownReport(report) {
|
|
|
569
1217
|
return lines.join("\n");
|
|
570
1218
|
}
|
|
571
1219
|
|
|
1220
|
+
// src/utils/auto-fixer.ts
|
|
1221
|
+
import { readFile as readFile3, writeFile } from "fs/promises";
|
|
1222
|
+
import { resolve as resolve4 } from "path";
|
|
1223
|
+
function escapeRegExp(string) {
|
|
1224
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1225
|
+
}
|
|
1226
|
+
async function autoFixBrokenLinks(fixMap, filePath) {
|
|
1227
|
+
const results = [];
|
|
1228
|
+
try {
|
|
1229
|
+
const content = await readFile3(filePath, "utf-8");
|
|
1230
|
+
let newContent = content;
|
|
1231
|
+
for (const [oldTarget, newTarget] of fixMap.entries()) {
|
|
1232
|
+
const wikiRegex = new RegExp(
|
|
1233
|
+
`(!?\\[\\[${escapeRegExp(oldTarget)}(?:\\|[^\\]]*)?\\]\\])`,
|
|
1234
|
+
"g"
|
|
1235
|
+
);
|
|
1236
|
+
const wikiMatches = content.match(wikiRegex);
|
|
1237
|
+
if (wikiMatches) {
|
|
1238
|
+
for (const match of wikiMatches) {
|
|
1239
|
+
const isEmbed = match.startsWith("!");
|
|
1240
|
+
const hasAlias = match.includes("|");
|
|
1241
|
+
let replacement;
|
|
1242
|
+
if (hasAlias) {
|
|
1243
|
+
const parts = match.split("|");
|
|
1244
|
+
replacement = `${isEmbed ? "!" : ""}[[${newTarget}|${parts[1]}`;
|
|
1245
|
+
} else {
|
|
1246
|
+
replacement = `${isEmbed ? "!" : ""}[[${newTarget}]]`;
|
|
1247
|
+
}
|
|
1248
|
+
newContent = newContent.replace(match, replacement);
|
|
1249
|
+
results.push({
|
|
1250
|
+
file: filePath,
|
|
1251
|
+
originalLink: match,
|
|
1252
|
+
newLink: replacement,
|
|
1253
|
+
success: true
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const markdownRegex = new RegExp(
|
|
1258
|
+
`(\\[[^\\]]*\\]\\(${escapeRegExp(oldTarget)}(?:#[^)]*)?\\))`,
|
|
1259
|
+
"g"
|
|
1260
|
+
);
|
|
1261
|
+
const markdownMatches = content.match(markdownRegex);
|
|
1262
|
+
if (markdownMatches) {
|
|
1263
|
+
for (const match of markdownMatches) {
|
|
1264
|
+
const linkPart = match.match(/\(([^)]+)\)/)?.[1] || "";
|
|
1265
|
+
const heading = linkPart.includes("#") ? linkPart.split("#")[1] : "";
|
|
1266
|
+
const newLink = heading ? `${newTarget}#${heading}` : newTarget;
|
|
1267
|
+
const textPart = match.match(/\[([^\]]*)\]/)?.[0] || "[]";
|
|
1268
|
+
const replacement = `${textPart}(${newLink})`;
|
|
1269
|
+
newContent = newContent.replace(match, replacement);
|
|
1270
|
+
results.push({
|
|
1271
|
+
file: filePath,
|
|
1272
|
+
originalLink: match,
|
|
1273
|
+
newLink: replacement,
|
|
1274
|
+
success: true
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
if (newContent !== content) {
|
|
1280
|
+
await writeFile(filePath, newContent, "utf-8");
|
|
1281
|
+
}
|
|
1282
|
+
return results;
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
return [
|
|
1285
|
+
{
|
|
1286
|
+
file: filePath,
|
|
1287
|
+
originalLink: "",
|
|
1288
|
+
newLink: "",
|
|
1289
|
+
success: false,
|
|
1290
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
1291
|
+
}
|
|
1292
|
+
];
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
async function fixBrokenLinksBatch(brokenLinks, suggestionMap, vaultPath) {
|
|
1296
|
+
const fileFixMap = /* @__PURE__ */ new Map();
|
|
1297
|
+
for (const broken of brokenLinks) {
|
|
1298
|
+
const suggestion = suggestionMap.get(broken.link.target);
|
|
1299
|
+
if (!suggestion) continue;
|
|
1300
|
+
if (!fileFixMap.has(broken.source)) {
|
|
1301
|
+
fileFixMap.set(broken.source, /* @__PURE__ */ new Map());
|
|
1302
|
+
}
|
|
1303
|
+
fileFixMap.get(broken.source).set(broken.link.target, suggestion);
|
|
1304
|
+
}
|
|
1305
|
+
const allResults = /* @__PURE__ */ new Map();
|
|
1306
|
+
const promises = Array.from(fileFixMap.entries()).map(
|
|
1307
|
+
async ([source, fixMap]) => {
|
|
1308
|
+
const filePath = resolve4(vaultPath, source);
|
|
1309
|
+
const results = await autoFixBrokenLinks(fixMap, filePath);
|
|
1310
|
+
allResults.set(source, results);
|
|
1311
|
+
}
|
|
1312
|
+
);
|
|
1313
|
+
await Promise.all(promises);
|
|
1314
|
+
return allResults;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
572
1317
|
// src/index.ts
|
|
1318
|
+
var packageMetadata = JSON.parse(
|
|
1319
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
1320
|
+
);
|
|
573
1321
|
var program = new Command();
|
|
574
|
-
program.name("dead-links").description("Scan Obsidian vaults for broken links, orphan notes, and connection graphs").version(
|
|
1322
|
+
program.name("dead-links").description("Scan Obsidian vaults for broken links, orphan notes, and connection graphs").version(packageMetadata.version);
|
|
575
1323
|
async function loadConfigAndAnalyze(vaultPath, cliOptions) {
|
|
576
1324
|
const configLoader = new ConfigLoader();
|
|
577
1325
|
const fileConfig = await configLoader.loadConfig(vaultPath);
|
|
@@ -597,31 +1345,61 @@ function printOrphansVerbose(orphanNotes) {
|
|
|
597
1345
|
console.log(` ${note.relativePath}`);
|
|
598
1346
|
}
|
|
599
1347
|
}
|
|
600
|
-
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", "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").action(async (options) => {
|
|
1348
|
+
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) => {
|
|
601
1349
|
const spinner = ora("Scanning vault...").start();
|
|
602
1350
|
try {
|
|
603
1351
|
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
604
1352
|
spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
|
|
1353
|
+
let fixSummary = "";
|
|
1354
|
+
if (options.fix) {
|
|
1355
|
+
const suggestionMap = new Map(
|
|
1356
|
+
(report.suggestions || []).map((suggestion) => [suggestion.broken, suggestion.suggested])
|
|
1357
|
+
);
|
|
1358
|
+
const fixResults = await fixBrokenLinksBatch(report.brokenLinks, suggestionMap, options.vault);
|
|
1359
|
+
const changed = [...fixResults.values()].flat().filter((result) => result.success).length;
|
|
1360
|
+
const failed = [...fixResults.values()].flat().filter((result) => !result.success).length;
|
|
1361
|
+
const fixSummary2 = `Auto-fix: ${changed} link(s) changed${failed > 0 ? `, ${failed} failed` : ""}.`;
|
|
1362
|
+
console.error(fixSummary2);
|
|
1363
|
+
}
|
|
1364
|
+
if (options.debug) {
|
|
1365
|
+
console.log(`[DEBUG] Vault: ${options.vault}`);
|
|
1366
|
+
console.log(`[DEBUG] Format: ${options.format}`);
|
|
1367
|
+
console.log(`[DEBUG] Broken links: ${report.stats.brokenCount}`);
|
|
1368
|
+
console.log(`[DEBUG] Orphan notes: ${report.stats.orphanCount}`);
|
|
1369
|
+
}
|
|
1370
|
+
let output = "";
|
|
605
1371
|
if (options.format === "json") {
|
|
606
1372
|
const reporter = new JsonReporter();
|
|
607
|
-
|
|
608
|
-
if (options.output) {
|
|
609
|
-
await writeFile(options.output, output);
|
|
610
|
-
console.log(`JSON report written to ${options.output}`);
|
|
611
|
-
} else {
|
|
612
|
-
console.log(output);
|
|
613
|
-
}
|
|
1373
|
+
output = reporter.report(report);
|
|
614
1374
|
} else if (options.format === "markdown") {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1375
|
+
output = generateMarkdownReport(report);
|
|
1376
|
+
} else if (options.format === "csv") {
|
|
1377
|
+
const reporter = new CsvReporter();
|
|
1378
|
+
output = reporter.report(report);
|
|
1379
|
+
} else if (options.format === "html") {
|
|
1380
|
+
const reporter = new HtmlReporter();
|
|
1381
|
+
output = reporter.report(report);
|
|
1382
|
+
} else if (options.format === "mermaid") {
|
|
1383
|
+
const reporter = new MermaidReporter();
|
|
1384
|
+
output = reporter.report(report);
|
|
1385
|
+
} else if (options.format === "dot") {
|
|
1386
|
+
const reporter = new GraphVizReporter();
|
|
1387
|
+
output = reporter.report(report);
|
|
622
1388
|
} else {
|
|
623
|
-
const reporter = new TerminalReporter(
|
|
624
|
-
|
|
1389
|
+
const reporter = new TerminalReporter(
|
|
1390
|
+
options.ignoreFolders || [],
|
|
1391
|
+
options.quiet || false,
|
|
1392
|
+
options.color === false
|
|
1393
|
+
);
|
|
1394
|
+
output = reporter.report(report);
|
|
1395
|
+
}
|
|
1396
|
+
if (options.output) {
|
|
1397
|
+
await writeFile2(options.output, output);
|
|
1398
|
+
console.log(`Report written to ${options.output}`);
|
|
1399
|
+
} else if (options.format === "text") {
|
|
1400
|
+
console.log(output);
|
|
1401
|
+
} else {
|
|
1402
|
+
console.log(output);
|
|
625
1403
|
}
|
|
626
1404
|
process.exit(report.stats.brokenCount > 0 || report.stats.orphanCount > 0 ? 1 : 0);
|
|
627
1405
|
} catch (error) {
|
|
@@ -630,7 +1408,7 @@ program.command("scan").description("Scan the vault for all issues").option("--v
|
|
|
630
1408
|
process.exit(1);
|
|
631
1409
|
}
|
|
632
1410
|
});
|
|
633
|
-
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").action(async (options) => {
|
|
1411
|
+
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) => {
|
|
634
1412
|
const spinner = ora("Scanning vault...").start();
|
|
635
1413
|
try {
|
|
636
1414
|
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
@@ -656,6 +1434,14 @@ Broken links (${report.brokenLinks.length})
|
|
|
656
1434
|
}
|
|
657
1435
|
}
|
|
658
1436
|
console.log();
|
|
1437
|
+
if (report.suggestions && report.suggestions.length > 0) {
|
|
1438
|
+
console.log("\u{1F4A1} Suggestions:");
|
|
1439
|
+
for (const suggestion of report.suggestions.slice(0, 5)) {
|
|
1440
|
+
const confidence = Math.round(suggestion.similarity * 100);
|
|
1441
|
+
console.log(` ${suggestion.broken} \u2192 ${suggestion.suggested} (${confidence}%)`);
|
|
1442
|
+
}
|
|
1443
|
+
console.log();
|
|
1444
|
+
}
|
|
659
1445
|
}
|
|
660
1446
|
process.exit(report.stats.brokenCount > 0 ? 1 : 0);
|
|
661
1447
|
} catch (error) {
|
|
@@ -734,7 +1520,7 @@ program.command("report").description("Generate a markdown report").option("--va
|
|
|
734
1520
|
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
735
1521
|
spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
|
|
736
1522
|
const markdown = generateMarkdownReport(report);
|
|
737
|
-
await
|
|
1523
|
+
await writeFile2(options.output, markdown);
|
|
738
1524
|
console.log(`Report written to ${options.output}`);
|
|
739
1525
|
} catch (error) {
|
|
740
1526
|
spinner.fail("Scan failed");
|
|
@@ -742,5 +1528,40 @@ program.command("report").description("Generate a markdown report").option("--va
|
|
|
742
1528
|
process.exit(1);
|
|
743
1529
|
}
|
|
744
1530
|
});
|
|
1531
|
+
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) => {
|
|
1532
|
+
const spinner = ora("Analyzing vault...").start();
|
|
1533
|
+
try {
|
|
1534
|
+
const report = await loadConfigAndAnalyze(options.vault, options);
|
|
1535
|
+
spinner.succeed(`Analysis complete in ${report.duration}ms`);
|
|
1536
|
+
console.log("\n\u{1F4CA} Vault Statistics\n");
|
|
1537
|
+
console.log(` Total Notes: ${report.stats.totalNotes}`);
|
|
1538
|
+
console.log(` Total Links: ${report.stats.totalLinks}`);
|
|
1539
|
+
const averageLinks = report.stats.totalNotes === 0 ? 0 : report.stats.totalLinks / report.stats.totalNotes;
|
|
1540
|
+
const healthScore = report.stats.totalLinks === 0 ? 100 : 100 - report.stats.brokenCount / report.stats.totalLinks * 100;
|
|
1541
|
+
console.log(` Avg Links per Note: ${averageLinks.toFixed(2)}`);
|
|
1542
|
+
console.log(` Broken Links: ${report.stats.brokenCount}`);
|
|
1543
|
+
console.log(` Orphan Notes: ${report.stats.orphanCount}`);
|
|
1544
|
+
console.log(` Connected Components: ${report.stats.connectedComponents}`);
|
|
1545
|
+
console.log(` Health Score: ${healthScore.toFixed(1)}%`);
|
|
1546
|
+
const mostConnected = [...report.graph].sort((a, b) => b.incoming.length + b.outgoing.length - (a.incoming.length + a.outgoing.length)).slice(0, 5);
|
|
1547
|
+
console.log("\n\u{1F310} Most Connected Notes:");
|
|
1548
|
+
for (const node of mostConnected) {
|
|
1549
|
+
const total = node.incoming.length + node.outgoing.length;
|
|
1550
|
+
console.log(` ${node.note.relativePath} (${total} connections: ${node.incoming.length} in, ${node.outgoing.length} out)`);
|
|
1551
|
+
}
|
|
1552
|
+
const lonelyNotes = report.graph.filter((n) => n.incoming.length === 0 && n.outgoing.length === 0).slice(0, 5);
|
|
1553
|
+
if (lonelyNotes.length > 0) {
|
|
1554
|
+
console.log("\n\u{1F534} Orphan Notes:");
|
|
1555
|
+
for (const node of lonelyNotes) {
|
|
1556
|
+
console.log(` ${node.note.relativePath}`);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
console.log();
|
|
1560
|
+
} catch (error) {
|
|
1561
|
+
spinner.fail("Analysis failed");
|
|
1562
|
+
console.error(error instanceof Error ? error.message : error);
|
|
1563
|
+
process.exit(1);
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
745
1566
|
program.parse();
|
|
746
1567
|
//# sourceMappingURL=index.js.map
|