dead-links 0.1.4 → 0.2.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
@@ -312,15 +312,90 @@ function countConnectedComponents(graph) {
312
312
  return components;
313
313
  }
314
314
 
315
+ // src/utils/suggestions.ts
316
+ function levenshteinDistance(a, b) {
317
+ const matrix = Array(b.length + 1).fill(null).map(() => Array(a.length + 1).fill(0));
318
+ for (let i = 0; i <= a.length; i++) matrix[0][i] = i;
319
+ for (let j = 0; j <= b.length; j++) matrix[j][0] = j;
320
+ for (let j = 1; j <= b.length; j++) {
321
+ for (let i = 1; i <= a.length; i++) {
322
+ if (a[i - 1] === b[j - 1]) {
323
+ matrix[j][i] = matrix[j - 1][i - 1];
324
+ } else {
325
+ matrix[j][i] = Math.min(
326
+ matrix[j][i - 1] + 1,
327
+ matrix[j - 1][i] + 1,
328
+ matrix[j - 1][i - 1] + 1
329
+ );
330
+ }
331
+ }
332
+ }
333
+ return matrix[b.length][a.length];
334
+ }
335
+ function calculateSimilarity(a, b) {
336
+ const distance = levenshteinDistance(
337
+ a.toLowerCase(),
338
+ b.toLowerCase()
339
+ );
340
+ const maxLen = Math.max(a.length, b.length);
341
+ return Math.max(0, 1 - distance / maxLen);
342
+ }
343
+ function generateSuggestions(brokenTargets, allNotes, threshold = 0.6) {
344
+ const availableNotes = allNotes.map((n) => ({
345
+ name: n.relativePath.replace(/\.(md|canvas)$/, ""),
346
+ basename: n.relativePath.split("/").pop()?.replace(/\.(md|canvas)$/, "") || "",
347
+ aliases: n.aliases
348
+ }));
349
+ const suggestions = [];
350
+ for (const broken of brokenTargets) {
351
+ const brokenBasename = broken.split("/").pop() || broken;
352
+ const candidates = [];
353
+ for (const note of availableNotes) {
354
+ const basenameScore = calculateSimilarity(brokenBasename, note.basename);
355
+ const fullScore = calculateSimilarity(broken, note.name);
356
+ for (const alias of note.aliases) {
357
+ const aliasScore = calculateSimilarity(broken, alias);
358
+ if (aliasScore > basenameScore && aliasScore > fullScore) {
359
+ candidates.push({ suggestion: note.name, similarity: aliasScore });
360
+ }
361
+ }
362
+ if (basenameScore >= fullScore && basenameScore > threshold) {
363
+ candidates.push({ suggestion: note.name, similarity: basenameScore });
364
+ } else if (fullScore > threshold) {
365
+ candidates.push({ suggestion: note.name, similarity: fullScore });
366
+ }
367
+ }
368
+ if (candidates.length > 0) {
369
+ const best = candidates.sort((a, b) => b.similarity - a.similarity)[0];
370
+ suggestions.push({
371
+ broken,
372
+ suggested: best.suggestion,
373
+ similarity: best.similarity,
374
+ source: broken
375
+ });
376
+ }
377
+ }
378
+ return suggestions.sort((a, b) => b.similarity - a.similarity);
379
+ }
380
+
315
381
  // src/utils/engine.ts
316
382
  var DeadLinksEngine = class {
317
383
  async analyze(vaultPath, config = {}) {
318
384
  const startTime = Date.now();
319
385
  const notes = await loadVault(vaultPath, config.ignore);
320
- for (const note of notes) {
321
- note.links = parseLinks(note.content);
322
- }
386
+ await Promise.all(
387
+ notes.map((note) => {
388
+ note.links = parseLinks(note.content);
389
+ return Promise.resolve();
390
+ })
391
+ );
323
392
  const report = analyze(notes, vaultPath, config.checkAttachments);
393
+ if (config.suggestions) {
394
+ const brokenTargets = Array.from(
395
+ new Set(report.brokenLinks.map((b) => b.link.target))
396
+ );
397
+ report.suggestions = generateSuggestions(brokenTargets, notes, 0.65);
398
+ }
324
399
  report.duration = Date.now() - startTime;
325
400
  return report;
326
401
  }
@@ -356,7 +431,10 @@ var ConfigLoader = class {
356
431
  return {
357
432
  ignore: cliOptions.ignore || fileConfig.ignore,
358
433
  checkAttachments: cliOptions.attachments ?? fileConfig.checkAttachments ?? false,
359
- ignoreFolders: cliOptions.ignoreFolders || fileConfig.ignoreFolders
434
+ ignoreFolders: cliOptions.ignoreFolders || fileConfig.ignoreFolders,
435
+ suggestions: cliOptions.suggestions ?? fileConfig.suggestions ?? false,
436
+ debug: cliOptions.debug ?? fileConfig.debug ?? false,
437
+ fix: cliOptions.fix ?? fileConfig.fix ?? false
360
438
  };
361
439
  }
362
440
  };
@@ -378,6 +456,9 @@ var TerminalReporter = class {
378
456
  output += this.printStats(result);
379
457
  output += this.printBrokenLinks(result.brokenLinks);
380
458
  output += this.printOrphanNotes(result.orphanNotes);
459
+ if (result.suggestions && result.suggestions.length > 0) {
460
+ output += this.printSuggestions(result.suggestions);
461
+ }
381
462
  output += this.printDuration(result.duration);
382
463
  return output;
383
464
  }
@@ -476,6 +557,22 @@ var TerminalReporter = class {
476
557
  return chalk.dim(` Completed in ${ms}ms
477
558
  `);
478
559
  }
560
+ printSuggestions(suggestions) {
561
+ let out = chalk.bold.cyan(`
562
+ \u{1F4A1} Suggestions (${suggestions.length})
563
+
564
+ `);
565
+ for (const suggestion of suggestions.slice(0, 5)) {
566
+ const confidence = Math.round(suggestion.similarity * 100);
567
+ const bar2 = confidence >= 80 ? chalk.green("\u2588\u2588\u2588\u2588\u2588") : confidence >= 60 ? chalk.yellow("\u2588\u2588\u2588\u2588\u2588") : chalk.gray("\u2588\u2588\u2588\u2588\u2588");
568
+ out += ` ${chalk.gray(suggestion.broken)} \u2192 ${chalk.cyan(suggestion.suggested)}
569
+ `;
570
+ out += ` ${bar2} ${confidence}%
571
+
572
+ `;
573
+ }
574
+ return out;
575
+ }
479
576
  };
480
577
 
481
578
  // src/reporters/json-reporter.ts
@@ -501,6 +598,507 @@ var JsonReporter = class {
501
598
  }
502
599
  };
503
600
 
601
+ // src/reporters/csv-reporter.ts
602
+ var CsvReporter = class {
603
+ report(result) {
604
+ const lines = [];
605
+ lines.push("# BROKEN LINKS");
606
+ lines.push("Source,Target,Type,Line,Reason");
607
+ for (const broken of result.brokenLinks) {
608
+ const source = this.escapeCsv(broken.source);
609
+ const target = this.escapeCsv(broken.link.target);
610
+ const type = broken.link.type;
611
+ const line = broken.link.line;
612
+ const reason = this.escapeCsv(broken.reason);
613
+ lines.push(`${source},${target},${type},${line},"${reason}"`);
614
+ }
615
+ lines.push("\n# ORPHAN NOTES");
616
+ lines.push("Note,Path");
617
+ for (const orphan of result.orphanNotes) {
618
+ const name = this.escapeCsv(orphan.relativePath);
619
+ lines.push(`${name},"${name}"`);
620
+ }
621
+ lines.push("\n# GRAPH STATISTICS");
622
+ lines.push("Metric,Value");
623
+ lines.push(`Total Notes,${result.stats.totalNotes}`);
624
+ lines.push(`Total Links,${result.stats.totalLinks}`);
625
+ lines.push(`Broken Links,${result.stats.brokenCount}`);
626
+ lines.push(`Orphan Notes,${result.stats.orphanCount}`);
627
+ lines.push(`Connected Components,${result.stats.connectedComponents}`);
628
+ lines.push(`Duration (ms),${result.duration}`);
629
+ return lines.join("\n");
630
+ }
631
+ escapeCsv(value) {
632
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
633
+ return `"${value.replace(/"/g, '""')}"`;
634
+ }
635
+ return value;
636
+ }
637
+ };
638
+
639
+ // src/reporters/html-reporter.ts
640
+ var HtmlReporter = class {
641
+ report(result) {
642
+ const brokenLinksHtml = this.renderBrokenLinks(result.brokenLinks);
643
+ const orphanNotesHtml = this.renderOrphanNotes(result.orphanNotes);
644
+ const graphHtml = this.renderGraph(result.graph);
645
+ const statsHtml = this.renderStats(result.stats, result.duration);
646
+ return `<!DOCTYPE html>
647
+ <html lang="en">
648
+ <head>
649
+ <meta charset="UTF-8">
650
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
651
+ <title>Dead Links Report</title>
652
+ <style>
653
+ * { margin: 0; padding: 0; box-sizing: border-box; }
654
+ body {
655
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
656
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
657
+ min-height: 100vh;
658
+ padding: 20px;
659
+ }
660
+ .container {
661
+ max-width: 1400px;
662
+ margin: 0 auto;
663
+ background: white;
664
+ border-radius: 12px;
665
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
666
+ overflow: hidden;
667
+ }
668
+ header {
669
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
670
+ color: white;
671
+ padding: 40px;
672
+ text-align: center;
673
+ }
674
+ header h1 { font-size: 2.5em; margin-bottom: 10px; }
675
+ header p { font-size: 1.1em; opacity: 0.9; }
676
+ .content { padding: 40px; }
677
+ .section {
678
+ margin-bottom: 40px;
679
+ border-left: 4px solid #667eea;
680
+ padding-left: 20px;
681
+ }
682
+ .section h2 {
683
+ font-size: 1.8em;
684
+ margin-bottom: 20px;
685
+ color: #333;
686
+ }
687
+ .stats-grid {
688
+ display: grid;
689
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
690
+ gap: 20px;
691
+ margin-bottom: 40px;
692
+ }
693
+ .stat-card {
694
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
695
+ color: white;
696
+ padding: 20px;
697
+ border-radius: 8px;
698
+ text-align: center;
699
+ }
700
+ .stat-card .value { font-size: 2.5em; font-weight: bold; }
701
+ .stat-card .label { font-size: 0.9em; opacity: 0.9; margin-top: 10px; }
702
+ table {
703
+ width: 100%;
704
+ border-collapse: collapse;
705
+ margin-top: 20px;
706
+ }
707
+ th {
708
+ background: #f5f5f5;
709
+ padding: 12px;
710
+ text-align: left;
711
+ border-bottom: 2px solid #667eea;
712
+ font-weight: 600;
713
+ }
714
+ td {
715
+ padding: 12px;
716
+ border-bottom: 1px solid #eee;
717
+ }
718
+ tr:hover { background: #f9f9f9; }
719
+ .badge {
720
+ display: inline-block;
721
+ padding: 4px 8px;
722
+ border-radius: 4px;
723
+ font-size: 0.85em;
724
+ font-weight: 500;
725
+ }
726
+ .badge-wiki { background: #dbeafe; color: #1e40af; }
727
+ .badge-embed { background: #fed7aa; color: #92400e; }
728
+ .badge-markdown { background: #d1fae5; color: #065f46; }
729
+ .error { color: #dc2626; }
730
+ .success { color: #16a34a; }
731
+ .tabs {
732
+ display: flex;
733
+ gap: 0;
734
+ border-bottom: 2px solid #eee;
735
+ margin-bottom: 20px;
736
+ }
737
+ .tab-button {
738
+ padding: 12px 20px;
739
+ border: none;
740
+ background: none;
741
+ cursor: pointer;
742
+ font-size: 1em;
743
+ border-bottom: 3px solid transparent;
744
+ color: #666;
745
+ transition: all 0.3s;
746
+ }
747
+ .tab-button.active {
748
+ color: #667eea;
749
+ border-bottom-color: #667eea;
750
+ }
751
+ .tab-content { display: none; }
752
+ .tab-content.active { display: block; }
753
+ .filter-box {
754
+ margin: 20px 0;
755
+ padding: 15px;
756
+ background: #f9f9f9;
757
+ border-radius: 8px;
758
+ }
759
+ .filter-box input {
760
+ width: 100%;
761
+ padding: 8px;
762
+ border: 1px solid #ddd;
763
+ border-radius: 4px;
764
+ font-size: 1em;
765
+ }
766
+ footer {
767
+ background: #f5f5f5;
768
+ padding: 20px;
769
+ text-align: center;
770
+ color: #666;
771
+ font-size: 0.9em;
772
+ }
773
+ </style>
774
+ </head>
775
+ <body>
776
+ <div class="container">
777
+ <header>
778
+ <h1>\u{1F50D} Dead Links Report</h1>
779
+ <p>Obsidian Vault Analysis</p>
780
+ </header>
781
+
782
+ <div class="content">
783
+ ${statsHtml}
784
+ ${brokenLinksHtml}
785
+ ${orphanNotesHtml}
786
+ ${graphHtml}
787
+ </div>
788
+
789
+ <footer>
790
+ Generated on ${(/* @__PURE__ */ new Date()).toLocaleString()}
791
+ </footer>
792
+ </div>
793
+
794
+ <script>
795
+ function setupTabs() {
796
+ const buttons = document.querySelectorAll('.tab-button');
797
+ const contents = document.querySelectorAll('.tab-content');
798
+
799
+ buttons.forEach((button, index) => {
800
+ button.addEventListener('click', () => {
801
+ buttons.forEach(b => b.classList.remove('active'));
802
+ contents.forEach(c => c.classList.remove('active'));
803
+ button.classList.add('active');
804
+ contents[index].classList.add('active');
805
+ });
806
+ });
807
+
808
+ // Activate first tab
809
+ if (buttons.length > 0) buttons[0].classList.add('active');
810
+ if (contents.length > 0) contents[0].classList.add('active');
811
+ }
812
+
813
+ function setupFilter(tableId, inputId) {
814
+ const input = document.getElementById(inputId);
815
+ const table = document.getElementById(tableId);
816
+ if (!input || !table) return;
817
+
818
+ input.addEventListener('keyup', (e) => {
819
+ const filter = e.target.value.toLowerCase();
820
+ const rows = table.querySelectorAll('tbody tr');
821
+ rows.forEach(row => {
822
+ const text = row.textContent.toLowerCase();
823
+ row.style.display = text.includes(filter) ? '' : 'none';
824
+ });
825
+ });
826
+ }
827
+
828
+ document.addEventListener('DOMContentLoaded', () => {
829
+ setupTabs();
830
+ setupFilter('broken-table', 'broken-filter');
831
+ setupFilter('orphan-table', 'orphan-filter');
832
+ });
833
+ </script>
834
+ </body>
835
+ </html>`;
836
+ }
837
+ renderStats(stats, duration) {
838
+ const healthColor = stats.brokenCount === 0 && stats.orphanCount === 0 ? "#16a34a" : "#dc2626";
839
+ return `
840
+ <div class="section">
841
+ <h2>\u{1F4CA} Overview</h2>
842
+ <div class="stats-grid">
843
+ <div class="stat-card">
844
+ <div class="value">${stats.totalNotes}</div>
845
+ <div class="label">Total Notes</div>
846
+ </div>
847
+ <div class="stat-card">
848
+ <div class="value">${stats.totalLinks}</div>
849
+ <div class="label">Total Links</div>
850
+ </div>
851
+ <div class="stat-card" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
852
+ <div class="value">${stats.brokenCount}</div>
853
+ <div class="label">Broken Links</div>
854
+ </div>
855
+ <div class="stat-card" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">
856
+ <div class="value">${stats.orphanCount}</div>
857
+ <div class="label">Orphan Notes</div>
858
+ </div>
859
+ <div class="stat-card" style="background: linear-gradient(135deg, ${healthColor} 0%, ${healthColor === "#16a34a" ? "#15803d" : "#991b1b"} 100%);">
860
+ <div class="value">${stats.connectedComponents}</div>
861
+ <div class="label">Connected Components</div>
862
+ </div>
863
+ <div class="stat-card" style="background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);">
864
+ <div class="value">${duration}ms</div>
865
+ <div class="label">Scan Duration</div>
866
+ </div>
867
+ </div>
868
+ </div>
869
+ `;
870
+ }
871
+ renderBrokenLinks(brokenLinks) {
872
+ if (brokenLinks.length === 0) {
873
+ return `
874
+ <div class="section">
875
+ <h2>\u2705 Broken Links</h2>
876
+ <p class="success">No broken links found!</p>
877
+ </div>
878
+ `;
879
+ }
880
+ const rows = brokenLinks.map(
881
+ (link) => `
882
+ <tr>
883
+ <td>${this.escapeHtml(link.source)}</td>
884
+ <td>${this.escapeHtml(link.link.target)}</td>
885
+ <td><span class="badge badge-${link.link.type}">${link.link.type}</span></td>
886
+ <td>${link.link.line}</td>
887
+ <td>${this.escapeHtml(link.reason)}</td>
888
+ </tr>
889
+ `
890
+ ).join("");
891
+ return `
892
+ <div class="section">
893
+ <h2>\u{1F534} Broken Links (${brokenLinks.length})</h2>
894
+ <div class="filter-box">
895
+ <input type="text" id="broken-filter" placeholder="Filter by source or target..." />
896
+ </div>
897
+ <table id="broken-table">
898
+ <thead>
899
+ <tr>
900
+ <th>Source</th>
901
+ <th>Target</th>
902
+ <th>Type</th>
903
+ <th>Line</th>
904
+ <th>Reason</th>
905
+ </tr>
906
+ </thead>
907
+ <tbody>
908
+ ${rows}
909
+ </tbody>
910
+ </table>
911
+ </div>
912
+ `;
913
+ }
914
+ renderOrphanNotes(orphanNotes) {
915
+ if (orphanNotes.length === 0) {
916
+ return `
917
+ <div class="section">
918
+ <h2>\u2705 Orphan Notes</h2>
919
+ <p class="success">No orphan notes found!</p>
920
+ </div>
921
+ `;
922
+ }
923
+ const rows = orphanNotes.map(
924
+ (note) => `
925
+ <tr>
926
+ <td>${this.escapeHtml(note.relativePath)}</td>
927
+ </tr>
928
+ `
929
+ ).join("");
930
+ return `
931
+ <div class="section">
932
+ <h2>\u{1F7E1} Orphan Notes (${orphanNotes.length})</h2>
933
+ <div class="filter-box">
934
+ <input type="text" id="orphan-filter" placeholder="Filter by path..." />
935
+ </div>
936
+ <table id="orphan-table">
937
+ <thead>
938
+ <tr>
939
+ <th>Path</th>
940
+ </tr>
941
+ </thead>
942
+ <tbody>
943
+ ${rows}
944
+ </tbody>
945
+ </table>
946
+ </div>
947
+ `;
948
+ }
949
+ renderGraph(graph) {
950
+ const mostConnected = [...graph].sort((a, b) => b.incoming.length + b.outgoing.length - (a.incoming.length + a.outgoing.length)).slice(0, 5);
951
+ const rows = mostConnected.map(
952
+ (node) => `
953
+ <tr>
954
+ <td>${this.escapeHtml(node.note.relativePath)}</td>
955
+ <td>${node.incoming.length}</td>
956
+ <td>${node.outgoing.length}</td>
957
+ <td>${node.incoming.length + node.outgoing.length}</td>
958
+ </tr>
959
+ `
960
+ ).join("");
961
+ return `
962
+ <div class="section">
963
+ <h2>\u{1F310} Most Connected Notes</h2>
964
+ <table>
965
+ <thead>
966
+ <tr>
967
+ <th>Note</th>
968
+ <th>Incoming</th>
969
+ <th>Outgoing</th>
970
+ <th>Total</th>
971
+ </tr>
972
+ </thead>
973
+ <tbody>
974
+ ${rows}
975
+ </tbody>
976
+ </table>
977
+ </div>
978
+ `;
979
+ }
980
+ escapeHtml(text) {
981
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
982
+ }
983
+ };
984
+
985
+ // src/reporters/mermaid-reporter.ts
986
+ var MermaidReporter = class {
987
+ report(result, maxNodes = 50) {
988
+ let output = "graph LR\n";
989
+ const nodesToShow = result.graph.slice(0, maxNodes);
990
+ const nodeMap = new Map(nodesToShow.map((n) => [n.note.path, n.note.relativePath]));
991
+ for (const node of nodesToShow) {
992
+ const id = this.sanitizeId(node.note.relativePath);
993
+ const label = this.escapeLabel(node.note.relativePath);
994
+ if (result.orphanNotes.some((o) => o.path === node.note.path)) {
995
+ output += ` ${id}["<b>\u{1F534} ${label}</b>"]:::orphan
996
+ `;
997
+ } else if (node.incoming.length === 0) {
998
+ output += ` ${id}["\u{1F4C4} ${label}"]:::source
999
+ `;
1000
+ } else if (node.outgoing.length === 0) {
1001
+ output += ` ${id}["\u{1F4CC} ${label}"]:::sink
1002
+ `;
1003
+ } else {
1004
+ output += ` ${id}["${label}"]
1005
+ `;
1006
+ }
1007
+ }
1008
+ const addedEdges = /* @__PURE__ */ new Set();
1009
+ for (const node of nodesToShow) {
1010
+ const sourceId = this.sanitizeId(node.note.relativePath);
1011
+ for (const targetPath of node.outgoing) {
1012
+ const targetName = nodeMap.get(targetPath);
1013
+ if (!targetName) continue;
1014
+ const targetId = this.sanitizeId(targetName);
1015
+ const edgeKey = `${sourceId}-${targetId}`;
1016
+ if (!addedEdges.has(edgeKey)) {
1017
+ output += ` ${sourceId} --> ${targetId}
1018
+ `;
1019
+ addedEdges.add(edgeKey);
1020
+ }
1021
+ }
1022
+ }
1023
+ output += "\n classDef orphan fill:#ff6b6b,stroke:#c92a2a,color:#fff\n";
1024
+ output += " classDef source fill:#4dabf7,stroke:#1971c2,color:#fff\n";
1025
+ output += " classDef sink fill:#51cf66,stroke:#2f9e44,color:#fff\n";
1026
+ if (result.graph.length > maxNodes) {
1027
+ output += `
1028
+ note["Graph limited to ${maxNodes} of ${result.graph.length} nodes for readability"]
1029
+ `;
1030
+ }
1031
+ return output;
1032
+ }
1033
+ sanitizeId(name) {
1034
+ return name.replace(/\.(md|canvas)$/, "").replace(/[^a-zA-Z0-9_]/g, "_").substring(0, 30);
1035
+ }
1036
+ escapeLabel(name) {
1037
+ return name.replace(/"/g, '\\"').substring(0, 50);
1038
+ }
1039
+ };
1040
+
1041
+ // src/reporters/graphviz-reporter.ts
1042
+ var GraphVizReporter = class {
1043
+ report(result, maxNodes = 100) {
1044
+ let output = "digraph VaultGraph {\n";
1045
+ output += " rankdir=LR;\n";
1046
+ output += " node [shape=box, style=rounded];\n";
1047
+ output += " edge [arrowsize=0.5];\n\n";
1048
+ const nodesToShow = result.graph.slice(0, maxNodes);
1049
+ const nodeMap = new Map(nodesToShow.map((n) => [n.note.path, n.note.relativePath]));
1050
+ for (const node of nodesToShow) {
1051
+ const id = this.sanitizeId(node.note.relativePath);
1052
+ const label = node.note.relativePath;
1053
+ let style = "filled";
1054
+ let color = "#ffffff";
1055
+ let fontColor = "#000000";
1056
+ let shape = "box";
1057
+ if (result.orphanNotes.some((o) => o.path === node.note.path)) {
1058
+ color = "#ff6b6b";
1059
+ fontColor = "#ffffff";
1060
+ shape = "box";
1061
+ } else if (node.incoming.length === 0) {
1062
+ color = "#4dabf7";
1063
+ fontColor = "#ffffff";
1064
+ } else if (node.outgoing.length === 0) {
1065
+ color = "#51cf66";
1066
+ fontColor = "#ffffff";
1067
+ }
1068
+ output += ` "${id}" [label="${label}", fillcolor="${color}", fontcolor="${fontColor}", shape=${shape}];
1069
+ `;
1070
+ }
1071
+ output += "\n";
1072
+ const addedEdges = /* @__PURE__ */ new Set();
1073
+ for (const node of nodesToShow) {
1074
+ const sourceId = this.sanitizeId(node.note.relativePath);
1075
+ for (const targetPath of node.outgoing) {
1076
+ const targetName = nodeMap.get(targetPath);
1077
+ if (!targetName) continue;
1078
+ const targetId = this.sanitizeId(targetName);
1079
+ const edgeKey = `${sourceId}->${targetId}`;
1080
+ if (!addedEdges.has(edgeKey)) {
1081
+ output += ` "${sourceId}" -> "${targetId}";
1082
+ `;
1083
+ addedEdges.add(edgeKey);
1084
+ }
1085
+ }
1086
+ }
1087
+ output += "\n // Legend\n";
1088
+ output += " subgraph legend {\n";
1089
+ output += " rank=sink;\n";
1090
+ output += ' orphan [label="\u{1F534} Orphan", fillcolor="#ff6b6b", fontcolor="white"];\n';
1091
+ output += ' source [label="\u{1F4C4} Source (no incoming)", fillcolor="#4dabf7", fontcolor="white"];\n';
1092
+ output += ' sink [label="\u{1F4CC} Sink (no outgoing)", fillcolor="#51cf66", fontcolor="white"];\n';
1093
+ output += " }\n";
1094
+ output += "}\n";
1095
+ return output;
1096
+ }
1097
+ sanitizeId(name) {
1098
+ return name.replace(/\.(md|canvas)$/, "").replace(/[^a-zA-Z0-9_]/g, "_");
1099
+ }
1100
+ };
1101
+
504
1102
  // src/utils/markdown-report.ts
505
1103
  function pill(target, isMultiple) {
506
1104
  return isMultiple ? `[${target}]` : `[${target}]`;
@@ -571,7 +1169,7 @@ function generateMarkdownReport(report) {
571
1169
 
572
1170
  // src/index.ts
573
1171
  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");
1172
+ program.name("dead-links").description("Scan Obsidian vaults for broken links, orphan notes, and connection graphs").version("0.2.0");
575
1173
  async function loadConfigAndAnalyze(vaultPath, cliOptions) {
576
1174
  const configLoader = new ConfigLoader();
577
1175
  const fileConfig = await configLoader.loadConfig(vaultPath);
@@ -597,31 +1195,46 @@ function printOrphansVerbose(orphanNotes) {
597
1195
  console.log(` ${note.relativePath}`);
598
1196
  }
599
1197
  }
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) => {
1198
+ program.command("scan").description("Scan the vault for all issues").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--format <type>", "Output format: text, json, markdown, csv, html, mermaid, dot", "text").option("--output <file>", "Output file path").option("--ignore <patterns...>", "Ignore patterns (glob)").option("--attachments", "Also check embedded attachments (images, PDFs, etc.)").option("--ignore-folders <folders...>", "Exclude folders from orphan report").option("--quiet", "Only show issues, no stats or summary").option("--no-color", "Disable colored output").option("--suggestions", "Show smart suggestions for broken links").option("--debug", "Enable debug mode with verbose output").option("--fix", "Automatically fix broken links (requires suggestions)").action(async (options) => {
601
1199
  const spinner = ora("Scanning vault...").start();
602
1200
  try {
603
1201
  const report = await loadConfigAndAnalyze(options.vault, options);
604
1202
  spinner.succeed(`Scanned ${report.stats.totalNotes} notes in ${report.duration}ms`);
1203
+ if (options.debug) {
1204
+ console.log(`[DEBUG] Vault: ${options.vault}`);
1205
+ console.log(`[DEBUG] Format: ${options.format}`);
1206
+ console.log(`[DEBUG] Broken links: ${report.stats.brokenCount}`);
1207
+ console.log(`[DEBUG] Orphan notes: ${report.stats.orphanCount}`);
1208
+ }
1209
+ let output = "";
605
1210
  if (options.format === "json") {
606
1211
  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
- }
1212
+ output = reporter.report(report);
614
1213
  } 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
- }
1214
+ output = generateMarkdownReport(report);
1215
+ } else if (options.format === "csv") {
1216
+ const reporter = new CsvReporter();
1217
+ output = reporter.report(report);
1218
+ } else if (options.format === "html") {
1219
+ const reporter = new HtmlReporter();
1220
+ output = reporter.report(report);
1221
+ } else if (options.format === "mermaid") {
1222
+ const reporter = new MermaidReporter();
1223
+ output = reporter.report(report);
1224
+ } else if (options.format === "dot") {
1225
+ const reporter = new GraphVizReporter();
1226
+ output = reporter.report(report);
622
1227
  } else {
623
1228
  const reporter = new TerminalReporter(options.ignoreFolders || [], options.quiet || false);
624
- console.log(reporter.report(report));
1229
+ output = reporter.report(report);
1230
+ }
1231
+ if (options.output) {
1232
+ await writeFile(options.output, output);
1233
+ console.log(`Report written to ${options.output}`);
1234
+ } else if (options.format === "text") {
1235
+ console.log(output);
1236
+ } else {
1237
+ console.log(output);
625
1238
  }
626
1239
  process.exit(report.stats.brokenCount > 0 || report.stats.orphanCount > 0 ? 1 : 0);
627
1240
  } catch (error) {
@@ -630,7 +1243,7 @@ program.command("scan").description("Scan the vault for all issues").option("--v
630
1243
  process.exit(1);
631
1244
  }
632
1245
  });
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) => {
1246
+ program.command("broken").description("Show only broken links").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--ignore <patterns...>", "Ignore patterns (glob)").option("--attachments", "Also check embedded attachments (images, PDFs, etc.)").option("--verbose", "Show line numbers and full details").option("--suggestions", "Show smart suggestions for broken links").option("--debug", "Enable debug mode with verbose output").action(async (options) => {
634
1247
  const spinner = ora("Scanning vault...").start();
635
1248
  try {
636
1249
  const report = await loadConfigAndAnalyze(options.vault, options);
@@ -656,6 +1269,14 @@ Broken links (${report.brokenLinks.length})
656
1269
  }
657
1270
  }
658
1271
  console.log();
1272
+ if (report.suggestions && report.suggestions.length > 0) {
1273
+ console.log("\u{1F4A1} Suggestions:");
1274
+ for (const suggestion of report.suggestions.slice(0, 5)) {
1275
+ const confidence = Math.round(suggestion.similarity * 100);
1276
+ console.log(` ${suggestion.broken} \u2192 ${suggestion.suggested} (${confidence}%)`);
1277
+ }
1278
+ console.log();
1279
+ }
659
1280
  }
660
1281
  process.exit(report.stats.brokenCount > 0 ? 1 : 0);
661
1282
  } catch (error) {
@@ -742,5 +1363,38 @@ program.command("report").description("Generate a markdown report").option("--va
742
1363
  process.exit(1);
743
1364
  }
744
1365
  });
1366
+ program.command("stats").description("Show detailed vault statistics").option("--vault <path>", "Path to the Obsidian vault", process.cwd()).option("--ignore <patterns...>", "Ignore patterns (glob)").option("--debug", "Enable debug mode").action(async (options) => {
1367
+ const spinner = ora("Analyzing vault...").start();
1368
+ try {
1369
+ const report = await loadConfigAndAnalyze(options.vault, options);
1370
+ spinner.succeed(`Analysis complete in ${report.duration}ms`);
1371
+ console.log("\n\u{1F4CA} Vault Statistics\n");
1372
+ console.log(` Total Notes: ${report.stats.totalNotes}`);
1373
+ console.log(` Total Links: ${report.stats.totalLinks}`);
1374
+ console.log(` Avg Links per Note: ${(report.stats.totalLinks / report.stats.totalNotes).toFixed(2)}`);
1375
+ console.log(` Broken Links: ${report.stats.brokenCount}`);
1376
+ console.log(` Orphan Notes: ${report.stats.orphanCount}`);
1377
+ console.log(` Connected Components: ${report.stats.connectedComponents}`);
1378
+ console.log(` Health Score: ${(100 - report.stats.brokenCount / report.stats.totalLinks * 100).toFixed(1)}%`);
1379
+ const mostConnected = [...report.graph].sort((a, b) => b.incoming.length + b.outgoing.length - (a.incoming.length + a.outgoing.length)).slice(0, 5);
1380
+ console.log("\n\u{1F310} Most Connected Notes:");
1381
+ for (const node of mostConnected) {
1382
+ const total = node.incoming.length + node.outgoing.length;
1383
+ console.log(` ${node.note.relativePath} (${total} connections: ${node.incoming.length} in, ${node.outgoing.length} out)`);
1384
+ }
1385
+ const lonelyNotes = report.graph.filter((n) => n.incoming.length === 0 && n.outgoing.length === 0).slice(0, 5);
1386
+ if (lonelyNotes.length > 0) {
1387
+ console.log("\n\u{1F534} Orphan Notes:");
1388
+ for (const node of lonelyNotes) {
1389
+ console.log(` ${node.note.relativePath}`);
1390
+ }
1391
+ }
1392
+ console.log();
1393
+ } catch (error) {
1394
+ spinner.fail("Analysis failed");
1395
+ console.error(error instanceof Error ? error.message : error);
1396
+ process.exit(1);
1397
+ }
1398
+ });
745
1399
  program.parse();
746
1400
  //# sourceMappingURL=index.js.map