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/dist/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { writeFile } from "fs/promises";
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: file,
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.type === "markdown" && !link.target.endsWith(".md")) {
172
- continue;
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
- orphanNotes.push(node.note);
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.type === "markdown" && !link.target.endsWith(".md")) {
282
+ if (isExternalTarget(link.target)) {
247
283
  continue;
248
284
  }
249
- if (!checkAttachments && isAttachment(link.target)) {
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 = resolver.resolve(link.target);
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
- for (const note of notes) {
321
- note.links = parseLinks(note.content);
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 resolve2, join } from "path";
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 = resolve2(vaultPath);
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 chalk from "chalk";
487
+ import { Chalk } from "chalk";
366
488
  var TerminalReporter = class {
367
489
  ignoreFolders;
368
490
  quiet;
369
- constructor(ignoreFolders = [], quiet = false) {
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
- return ` ${chalk.gray(label)} ${color === "red" ? chalk.red.bold(value) : color === "yellow" ? chalk.yellow.bold(value) : chalk.white.bold(value)}
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
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("0.1.3");
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
- const output = reporter.report(report);
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
- const output = generateMarkdownReport(report);
616
- if (options.output) {
617
- await writeFile(options.output, output);
618
- console.log(`Markdown report written to ${options.output}`);
619
- } else {
620
- console.log(output);
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(options.ignoreFolders || [], options.quiet || false);
624
- console.log(reporter.report(report));
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 writeFile(options.output, markdown);
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