dead-links 0.1.3 → 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/README.md +120 -10
- package/dist/index.js +676 -22
- package/dist/index.js.map +1 -1
- package/package.json +12 -11
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
|
-
|
|
321
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
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
|