@cad0p/napkin 0.8.1
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/LICENSE +21 -0
- package/README.md +342 -0
- package/dist/commands/aliases.d.ts +7 -0
- package/dist/commands/aliases.js +25 -0
- package/dist/commands/bases.d.ts +23 -0
- package/dist/commands/bases.js +139 -0
- package/dist/commands/bookmarks.d.ts +15 -0
- package/dist/commands/bookmarks.js +51 -0
- package/dist/commands/canvas.d.ts +49 -0
- package/dist/commands/canvas.js +186 -0
- package/dist/commands/config.d.ts +13 -0
- package/dist/commands/config.js +48 -0
- package/dist/commands/crud.d.ts +40 -0
- package/dist/commands/crud.js +195 -0
- package/dist/commands/daily.d.ts +20 -0
- package/dist/commands/daily.js +58 -0
- package/dist/commands/files.d.ts +23 -0
- package/dist/commands/files.js +132 -0
- package/dist/commands/graph.d.ts +4 -0
- package/dist/commands/graph.js +461 -0
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.js +52 -0
- package/dist/commands/links.d.ts +26 -0
- package/dist/commands/links.js +119 -0
- package/dist/commands/outline.d.ts +7 -0
- package/dist/commands/outline.js +48 -0
- package/dist/commands/overview.d.ts +6 -0
- package/dist/commands/overview.js +40 -0
- package/dist/commands/properties.d.ts +24 -0
- package/dist/commands/properties.js +115 -0
- package/dist/commands/search.d.ts +13 -0
- package/dist/commands/search.js +48 -0
- package/dist/commands/tags.d.ts +13 -0
- package/dist/commands/tags.js +51 -0
- package/dist/commands/tasks.d.ts +22 -0
- package/dist/commands/tasks.js +106 -0
- package/dist/commands/templates.d.ts +16 -0
- package/dist/commands/templates.js +70 -0
- package/dist/commands/vault.d.ts +4 -0
- package/dist/commands/vault.js +17 -0
- package/dist/commands/wordcount.d.ts +7 -0
- package/dist/commands/wordcount.js +43 -0
- package/dist/core/aliases.d.ts +5 -0
- package/dist/core/aliases.js +26 -0
- package/dist/core/bases.d.ts +29 -0
- package/dist/core/bases.js +67 -0
- package/dist/core/bookmarks.d.ts +14 -0
- package/dist/core/bookmarks.js +34 -0
- package/dist/core/canvas.d.ts +74 -0
- package/dist/core/canvas.js +125 -0
- package/dist/core/config.d.ts +7 -0
- package/dist/core/config.js +35 -0
- package/dist/core/crud.d.ts +32 -0
- package/dist/core/crud.js +119 -0
- package/dist/core/daily.d.ts +12 -0
- package/dist/core/daily.js +102 -0
- package/dist/core/files.d.ts +15 -0
- package/dist/core/files.js +30 -0
- package/dist/core/init.d.ts +31 -0
- package/dist/core/init.js +119 -0
- package/dist/core/links.d.ts +11 -0
- package/dist/core/links.js +66 -0
- package/dist/core/outline.d.ts +3 -0
- package/dist/core/outline.js +12 -0
- package/dist/core/overview.d.ts +15 -0
- package/dist/core/overview.js +384 -0
- package/dist/core/properties.d.ts +14 -0
- package/dist/core/properties.js +60 -0
- package/dist/core/search.d.ts +17 -0
- package/dist/core/search.js +153 -0
- package/dist/core/tags.d.ts +11 -0
- package/dist/core/tags.js +40 -0
- package/dist/core/tasks.d.ts +35 -0
- package/dist/core/tasks.js +97 -0
- package/dist/core/templates.d.ts +14 -0
- package/dist/core/templates.js +55 -0
- package/dist/core/vault.d.ts +10 -0
- package/dist/core/vault.js +37 -0
- package/dist/core/wordcount.d.ts +5 -0
- package/dist/core/wordcount.js +16 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +715 -0
- package/dist/sdk.d.ts +179 -0
- package/dist/sdk.js +232 -0
- package/dist/templates/coding.d.ts +2 -0
- package/dist/templates/coding.js +104 -0
- package/dist/templates/company.d.ts +2 -0
- package/dist/templates/company.js +121 -0
- package/dist/templates/index.d.ts +4 -0
- package/dist/templates/index.js +15 -0
- package/dist/templates/personal.d.ts +2 -0
- package/dist/templates/personal.js +91 -0
- package/dist/templates/product.d.ts +2 -0
- package/dist/templates/product.js +123 -0
- package/dist/templates/research.d.ts +2 -0
- package/dist/templates/research.js +114 -0
- package/dist/templates/types.d.ts +7 -0
- package/dist/templates/types.js +1 -0
- package/dist/utils/bases.d.ts +61 -0
- package/dist/utils/bases.js +661 -0
- package/dist/utils/config.d.ts +42 -0
- package/dist/utils/config.js +112 -0
- package/dist/utils/exit-codes.d.ts +5 -0
- package/dist/utils/exit-codes.js +5 -0
- package/dist/utils/files.d.ts +135 -0
- package/dist/utils/files.js +299 -0
- package/dist/utils/formula.d.ts +28 -0
- package/dist/utils/formula.js +462 -0
- package/dist/utils/frontmatter.d.ts +17 -0
- package/dist/utils/frontmatter.js +34 -0
- package/dist/utils/markdown.d.ts +31 -0
- package/dist/utils/markdown.js +80 -0
- package/dist/utils/output.d.ts +28 -0
- package/dist/utils/output.js +48 -0
- package/dist/utils/search-cache.d.ts +29 -0
- package/dist/utils/search-cache.js +41 -0
- package/dist/utils/test-helpers.d.ts +13 -0
- package/dist/utils/test-helpers.js +40 -0
- package/dist/utils/vault.d.ts +21 -0
- package/dist/utils/vault.js +144 -0
- package/package.json +76 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import { Napkin } from "../sdk.js";
|
|
3
|
+
import { EXIT_NOT_FOUND } from "../utils/exit-codes.js";
|
|
4
|
+
import { resolveFile, suggestFile } from "../utils/files.js";
|
|
5
|
+
import { bold, dim, error, fileNotFound, output, } from "../utils/output.js";
|
|
6
|
+
export async function file(fileRef, opts) {
|
|
7
|
+
const n = new Napkin(opts.vault || process.cwd());
|
|
8
|
+
if (!fileRef) {
|
|
9
|
+
error("No file specified. Usage: obsidian-cli file <name>");
|
|
10
|
+
process.exit(EXIT_NOT_FOUND);
|
|
11
|
+
}
|
|
12
|
+
let info;
|
|
13
|
+
try {
|
|
14
|
+
info = n.fileInfo(fileRef);
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
const msg = e.message;
|
|
18
|
+
if (msg.startsWith("File not found:")) {
|
|
19
|
+
fileNotFound(fileRef, suggestFile(n.vault.contentPath, fileRef));
|
|
20
|
+
process.exit(EXIT_NOT_FOUND);
|
|
21
|
+
}
|
|
22
|
+
throw e;
|
|
23
|
+
}
|
|
24
|
+
output(opts, {
|
|
25
|
+
json: () => info,
|
|
26
|
+
human: () => {
|
|
27
|
+
console.log(`${dim("path")} ${info.path}`);
|
|
28
|
+
console.log(`${dim("name")} ${bold(info.name)}`);
|
|
29
|
+
console.log(`${dim("extension")} ${info.extension}`);
|
|
30
|
+
console.log(`${dim("size")} ${info.size}`);
|
|
31
|
+
console.log(`${dim("created")} ${Math.floor(info.created)}`);
|
|
32
|
+
console.log(`${dim("modified")} ${Math.floor(info.modified)}`);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export async function files(opts) {
|
|
37
|
+
const n = new Napkin(opts.vault || process.cwd());
|
|
38
|
+
const result = n.fileList({
|
|
39
|
+
folder: opts.folder,
|
|
40
|
+
ext: opts.ext,
|
|
41
|
+
});
|
|
42
|
+
output(opts, {
|
|
43
|
+
json: () => (opts.total ? { total: result.length } : { files: result }),
|
|
44
|
+
human: () => {
|
|
45
|
+
if (opts.total) {
|
|
46
|
+
console.log(result.length);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
for (const f of result)
|
|
50
|
+
console.log(f);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export async function folders(opts) {
|
|
56
|
+
const n = new Napkin(opts.vault || process.cwd());
|
|
57
|
+
const result = n.folders(opts.folder);
|
|
58
|
+
output(opts, {
|
|
59
|
+
json: () => (opts.total ? { total: result.length } : { folders: result }),
|
|
60
|
+
human: () => {
|
|
61
|
+
if (opts.total) {
|
|
62
|
+
console.log(result.length);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
for (const f of result)
|
|
66
|
+
console.log(f);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export async function folder(folderPath, opts) {
|
|
72
|
+
const n = new Napkin(opts.vault || process.cwd());
|
|
73
|
+
if (!folderPath) {
|
|
74
|
+
error("No folder specified. Usage: obsidian-cli folder <path>");
|
|
75
|
+
process.exit(EXIT_NOT_FOUND);
|
|
76
|
+
}
|
|
77
|
+
let fi;
|
|
78
|
+
try {
|
|
79
|
+
fi = n.folderInfo(folderPath);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
const msg = e.message;
|
|
83
|
+
if (msg.startsWith("Folder not found:")) {
|
|
84
|
+
error(msg);
|
|
85
|
+
process.exit(EXIT_NOT_FOUND);
|
|
86
|
+
}
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
if (opts.info) {
|
|
90
|
+
const val = opts.info === "files"
|
|
91
|
+
? fi.files
|
|
92
|
+
: opts.info === "folders"
|
|
93
|
+
? fi.folders
|
|
94
|
+
: fi.size;
|
|
95
|
+
output(opts, {
|
|
96
|
+
json: () => ({ [opts.info]: val }),
|
|
97
|
+
human: () => console.log(val),
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
output(opts, {
|
|
102
|
+
json: () => fi,
|
|
103
|
+
human: () => {
|
|
104
|
+
console.log(`${dim("path")} ${fi.path}`);
|
|
105
|
+
console.log(`${dim("files")} ${fi.files}`);
|
|
106
|
+
console.log(`${dim("folders")} ${fi.folders}`);
|
|
107
|
+
console.log(`${dim("size")} ${fi.size}`);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export async function open(fileRef, opts) {
|
|
112
|
+
const n = new Napkin(opts.vault || process.cwd());
|
|
113
|
+
const vaultName = encodeURIComponent(n.vault.name);
|
|
114
|
+
let uri;
|
|
115
|
+
if (fileRef) {
|
|
116
|
+
const resolved = resolveFile(n.vault.contentPath, fileRef);
|
|
117
|
+
if (!resolved) {
|
|
118
|
+
fileNotFound(fileRef, suggestFile(n.vault.contentPath, fileRef));
|
|
119
|
+
process.exit(EXIT_NOT_FOUND);
|
|
120
|
+
}
|
|
121
|
+
const encodedFile = encodeURIComponent(resolved.replace(/\.md$/, ""));
|
|
122
|
+
uri = `obsidian://open?vault=${vaultName}&file=${encodedFile}`;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
uri = `obsidian://open?vault=${vaultName}`;
|
|
126
|
+
}
|
|
127
|
+
exec(`open "${uri}"`);
|
|
128
|
+
output(opts, {
|
|
129
|
+
json: () => ({ uri }),
|
|
130
|
+
human: () => console.log(uri),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { basename, extname, relative } from "node:path";
|
|
4
|
+
import { platform } from "node:process";
|
|
5
|
+
import { walkDir } from "../utils/files.js";
|
|
6
|
+
import { error, info } from "../utils/output.js";
|
|
7
|
+
function walkMd(root) {
|
|
8
|
+
const files = [];
|
|
9
|
+
walkDir(root, {
|
|
10
|
+
onEntry: (fullPath, entry, kind) => {
|
|
11
|
+
if (kind !== "file")
|
|
12
|
+
return;
|
|
13
|
+
// Exclude dotfiles (dotdirs are already pruned via shouldEnter).
|
|
14
|
+
if (entry.name.startsWith("."))
|
|
15
|
+
return;
|
|
16
|
+
if (extname(fullPath) === ".md")
|
|
17
|
+
files.push(fullPath);
|
|
18
|
+
},
|
|
19
|
+
// walkMd is stricter than listFiles: it excludes every dotdir,
|
|
20
|
+
// matching its pre-consolidation behavior. Pruning at descent
|
|
21
|
+
// time prevents dotdir descendants (e.g. .drafts/secret.md) from
|
|
22
|
+
// appearing in the graph.
|
|
23
|
+
shouldEnter: (_fullPath, entry) => !entry.name.startsWith("."),
|
|
24
|
+
});
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
function buildGraphData(vaultPath) {
|
|
28
|
+
const mdFiles = walkMd(vaultPath).filter((f) => {
|
|
29
|
+
const rel = relative(vaultPath, f);
|
|
30
|
+
return !rel.startsWith("Templates/") && basename(f) !== "index.md";
|
|
31
|
+
});
|
|
32
|
+
const nodes = [];
|
|
33
|
+
const links = [];
|
|
34
|
+
const nodeSet = new Set();
|
|
35
|
+
// Build nodes
|
|
36
|
+
for (const file of mdFiles) {
|
|
37
|
+
const rel = relative(vaultPath, file);
|
|
38
|
+
const slug = rel.replace(/\.md$/, "");
|
|
39
|
+
const content = readFileSync(file, "utf-8");
|
|
40
|
+
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
41
|
+
const title = titleMatch ? titleMatch[1] : basename(file, ".md");
|
|
42
|
+
nodeSet.add(slug);
|
|
43
|
+
nodes.push({ id: slug, text: title, content, filePath: rel });
|
|
44
|
+
}
|
|
45
|
+
// Build name -> [slugs] for disambiguation
|
|
46
|
+
const nameToSlugs = new Map();
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
const name = basename(node.filePath, ".md");
|
|
49
|
+
if (!nameToSlugs.has(name))
|
|
50
|
+
nameToSlugs.set(name, []);
|
|
51
|
+
nameToSlugs.get(name)?.push(node.id);
|
|
52
|
+
}
|
|
53
|
+
// Extract wikilinks and build edges
|
|
54
|
+
for (const node of nodes) {
|
|
55
|
+
const wikilinks = [
|
|
56
|
+
...node.content.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g),
|
|
57
|
+
].map((m) => m[1]);
|
|
58
|
+
const nodeFolder = node.id.includes("/") ? node.id.split("/")[0] : "";
|
|
59
|
+
for (const link of wikilinks) {
|
|
60
|
+
const candidates = nameToSlugs.get(link);
|
|
61
|
+
if (!candidates)
|
|
62
|
+
continue;
|
|
63
|
+
// Prefer same-folder match, otherwise first
|
|
64
|
+
const target = candidates.find((s) => s.startsWith(`${nodeFolder}/`)) || candidates[0];
|
|
65
|
+
if (target && target !== node.id) {
|
|
66
|
+
// Avoid duplicate edges
|
|
67
|
+
if (!links.some((l) => l.source === node.id && l.target === target)) {
|
|
68
|
+
links.push({ source: node.id, target: target });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { nodes, links };
|
|
74
|
+
}
|
|
75
|
+
function buildHTML(graphDataB64) {
|
|
76
|
+
return `
|
|
77
|
+
<meta charset="utf-8">
|
|
78
|
+
<style>
|
|
79
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
80
|
+
body { background: #1e1e2e; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, sans-serif; display: flex; width: 100vw; height: 100vh; }
|
|
81
|
+
#graph-wrap { flex: 1; min-width: 0; position: relative; overflow: hidden; }
|
|
82
|
+
canvas { display: block; }
|
|
83
|
+
#tooltip {
|
|
84
|
+
position: absolute; display: none; padding: 6px 10px;
|
|
85
|
+
background: rgba(30,30,46,0.95); color: #cdd6f4; border: 1px solid #45475a;
|
|
86
|
+
border-radius: 6px; font-size: 13px; pointer-events: none; z-index: 10;
|
|
87
|
+
}
|
|
88
|
+
#sidebar {
|
|
89
|
+
flex: 0 0 0px; height: 100vh; background: #181825; border-left: 1px solid #313244;
|
|
90
|
+
color: #cdd6f4; overflow: hidden; display: flex; flex-direction: column;
|
|
91
|
+
transition: flex-basis 0.2s ease;
|
|
92
|
+
}
|
|
93
|
+
#sidebar.open { flex: 0 0 50%; padding: 16px; overflow-y: auto; }
|
|
94
|
+
#sidebar .path { font-size: 11px; color: #6c7086; margin-bottom: 4px; font-family: monospace; }
|
|
95
|
+
#sidebar .title { font-size: 16px; font-weight: 600; color: #89b4fa; margin-bottom: 12px; }
|
|
96
|
+
#sidebar .content { font-size: 13px; line-height: 1.6; color: #cdd6f4; word-wrap: break-word; flex: 1; }
|
|
97
|
+
#sidebar .content * { color: inherit; }
|
|
98
|
+
#sidebar .content h1 { font-size: 18px; margin: 16px 0 8px; font-weight: 600; }
|
|
99
|
+
#sidebar .content h2 { font-size: 15px; margin: 14px 0 6px; font-weight: 600; }
|
|
100
|
+
#sidebar .content h3 { font-size: 14px; margin: 12px 0 4px; font-weight: 600; }
|
|
101
|
+
#sidebar .content h4, #sidebar .content h5, #sidebar .content h6 { font-size: 13px; margin: 10px 0 4px; font-weight: 600; }
|
|
102
|
+
#sidebar .content p { margin: 0 0 8px; color: #a6adc8; }
|
|
103
|
+
#sidebar .content ul, #sidebar .content ol { margin: 0 0 8px; padding-left: 20px; color: #a6adc8; }
|
|
104
|
+
#sidebar .content li { margin: 2px 0; }
|
|
105
|
+
#sidebar .content code { background: #313244; padding: 1px 5px; border-radius: 3px; font-size: 12px; font-family: 'SF Mono', Menlo, monospace; color: #f38ba8; }
|
|
106
|
+
#sidebar .content pre { background: #313244; padding: 10px 12px; border-radius: 6px; overflow-x: auto; margin: 0 0 8px; }
|
|
107
|
+
#sidebar .content pre code { background: none; padding: 0; color: #a6adc8; }
|
|
108
|
+
#sidebar .content blockquote { border-left: 3px solid #45475a; padding-left: 12px; margin: 0 0 8px; color: #7f849c; }
|
|
109
|
+
#sidebar .content a { color: #89b4fa; text-decoration: none; }
|
|
110
|
+
#sidebar .content strong { color: #cdd6f4; }
|
|
111
|
+
#sidebar .content em { color: #bac2de; }
|
|
112
|
+
#sidebar .content hr { border: none; border-top: 1px solid #313244; margin: 12px 0; }
|
|
113
|
+
#sidebar .content table { border-collapse: collapse; margin: 0 0 8px; width: 100%; }
|
|
114
|
+
#sidebar .content th, #sidebar .content td { border: 1px solid #45475a; padding: 4px 8px; font-size: 12px; color: #a6adc8; }
|
|
115
|
+
#sidebar .content th { background: #313244; color: #cdd6f4; }
|
|
116
|
+
#sidebar .content img { max-width: 100%; border-radius: 4px; }
|
|
117
|
+
#sidebar .links { margin-top: 12px; border-top: 1px solid #313244; padding-top: 10px; }
|
|
118
|
+
#sidebar .links-label { font-size: 11px; color: #6c7086; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
119
|
+
#sidebar .link-item { font-size: 12px; color: #89b4fa; cursor: pointer; padding: 2px 0; }
|
|
120
|
+
#sidebar .link-item:hover { color: #f5c2e7; }
|
|
121
|
+
</style>
|
|
122
|
+
<div id="graph-wrap">
|
|
123
|
+
<div id="tooltip"></div>
|
|
124
|
+
<canvas id="graph"></canvas>
|
|
125
|
+
</div>
|
|
126
|
+
<div id="sidebar"></div>
|
|
127
|
+
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
|
128
|
+
<script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
|
|
129
|
+
<script>
|
|
130
|
+
function dbg(msg) { if (window.glimpse) window.glimpse.send({ dbg: msg }); else console.log('[graph]', msg); }
|
|
131
|
+
let data;
|
|
132
|
+
try {
|
|
133
|
+
data = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob("${graphDataB64}"), c => c.charCodeAt(0))));
|
|
134
|
+
dbg('parsed ' + data.nodes.length + ' nodes, ' + data.links.length + ' links');
|
|
135
|
+
dbg('nodes: ' + data.nodes.map(n => n.id).join(', '));
|
|
136
|
+
} catch(e) {
|
|
137
|
+
dbg('PARSE ERROR: ' + e.message);
|
|
138
|
+
}
|
|
139
|
+
const canvas = document.getElementById('graph');
|
|
140
|
+
const ctx = canvas.getContext('2d');
|
|
141
|
+
const tooltip = document.getElementById('tooltip');
|
|
142
|
+
const sidebar = document.getElementById('sidebar');
|
|
143
|
+
const graphWrap = document.getElementById('graph-wrap');
|
|
144
|
+
|
|
145
|
+
const dpr = window.devicePixelRatio || 1;
|
|
146
|
+
let width = graphWrap.offsetWidth;
|
|
147
|
+
let height = window.innerHeight;
|
|
148
|
+
canvas.width = width * dpr;
|
|
149
|
+
canvas.height = height * dpr;
|
|
150
|
+
canvas.style.width = width + 'px';
|
|
151
|
+
canvas.style.height = height + 'px';
|
|
152
|
+
ctx.scale(dpr, dpr);
|
|
153
|
+
|
|
154
|
+
const colors = {
|
|
155
|
+
nodeHover: '#f5c2e7',
|
|
156
|
+
link: '#45475a', linkActive: '#6c7086',
|
|
157
|
+
text: '#cdd6f4', bg: '#1e1e2e',
|
|
158
|
+
};
|
|
159
|
+
const folderColors = {
|
|
160
|
+
'architecture': '#89b4fa',
|
|
161
|
+
'decisions': '#a6e3a1',
|
|
162
|
+
'changelog': '#f9e2af',
|
|
163
|
+
'guides': '#fab387',
|
|
164
|
+
'': '#cba6f7',
|
|
165
|
+
};
|
|
166
|
+
function nodeColor(id) {
|
|
167
|
+
const folder = id.includes('/') ? id.split('/')[0] : '';
|
|
168
|
+
return folderColors[folder] || '#89b4fa';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const linkCount = {};
|
|
172
|
+
data.nodes.forEach(n => linkCount[n.id] = 0);
|
|
173
|
+
data.links.forEach(l => {
|
|
174
|
+
linkCount[l.source] = (linkCount[l.source] || 0) + 1;
|
|
175
|
+
linkCount[l.target] = (linkCount[l.target] || 0) + 1;
|
|
176
|
+
});
|
|
177
|
+
function nodeRadius(id) { return 4 + Math.sqrt(linkCount[id] || 0) * 2; }
|
|
178
|
+
|
|
179
|
+
const simulation = d3.forceSimulation(data.nodes)
|
|
180
|
+
.force('charge', d3.forceManyBody().strength(-120))
|
|
181
|
+
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.05))
|
|
182
|
+
.force('link', d3.forceLink(data.links).id(d => d.id).distance(60))
|
|
183
|
+
.force('collide', d3.forceCollide(d => nodeRadius(d.id) + 2))
|
|
184
|
+
.alphaDecay(0.02);
|
|
185
|
+
|
|
186
|
+
let transform = d3.zoomIdentity;
|
|
187
|
+
let hoveredNode = null;
|
|
188
|
+
let hoveredNeighbours = new Set();
|
|
189
|
+
|
|
190
|
+
function getNeighbours(nodeId) {
|
|
191
|
+
const s = new Set();
|
|
192
|
+
data.links.forEach(l => {
|
|
193
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
194
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
195
|
+
if (sid === nodeId) s.add(tid);
|
|
196
|
+
if (tid === nodeId) s.add(sid);
|
|
197
|
+
});
|
|
198
|
+
return s;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function findNode(screenX, screenY) {
|
|
202
|
+
const [px, py] = transform.invert([screenX, screenY]);
|
|
203
|
+
for (const n of data.nodes) {
|
|
204
|
+
const dx = n.x - px, dy = n.y - py;
|
|
205
|
+
if (dx * dx + dy * dy < (nodeRadius(n.id) + 5) ** 2) return n;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function draw() {
|
|
211
|
+
ctx.save();
|
|
212
|
+
ctx.clearRect(0, 0, width, height);
|
|
213
|
+
ctx.fillStyle = colors.bg;
|
|
214
|
+
ctx.fillRect(0, 0, width, height);
|
|
215
|
+
ctx.translate(transform.x, transform.y);
|
|
216
|
+
ctx.scale(transform.k, transform.k);
|
|
217
|
+
|
|
218
|
+
for (const l of data.links) {
|
|
219
|
+
const sid = typeof l.source === 'object' ? l.source.id : l.source;
|
|
220
|
+
const tid = typeof l.target === 'object' ? l.target.id : l.target;
|
|
221
|
+
const active = hoveredNode && (hoveredNeighbours.has(sid) && hoveredNeighbours.has(tid)) &&
|
|
222
|
+
(sid === hoveredNode.id || tid === hoveredNode.id);
|
|
223
|
+
ctx.beginPath();
|
|
224
|
+
ctx.moveTo(l.source.x, l.source.y);
|
|
225
|
+
ctx.lineTo(l.target.x, l.target.y);
|
|
226
|
+
ctx.strokeStyle = active ? colors.linkActive : colors.link;
|
|
227
|
+
ctx.globalAlpha = hoveredNode ? (active ? 0.8 : 0.15) : 0.4;
|
|
228
|
+
ctx.lineWidth = active ? 1.5 : 0.8;
|
|
229
|
+
ctx.stroke();
|
|
230
|
+
}
|
|
231
|
+
ctx.globalAlpha = 1;
|
|
232
|
+
|
|
233
|
+
for (const n of data.nodes) {
|
|
234
|
+
const r = nodeRadius(n.id);
|
|
235
|
+
const isHovered = hoveredNode && hoveredNode.id === n.id;
|
|
236
|
+
const isNeighbour = hoveredNode && hoveredNeighbours.has(n.id);
|
|
237
|
+
const dimmed = hoveredNode && !isHovered && !isNeighbour;
|
|
238
|
+
ctx.beginPath();
|
|
239
|
+
ctx.arc(n.x, n.y, r, 0, 2 * Math.PI);
|
|
240
|
+
ctx.fillStyle = isHovered ? colors.nodeHover : nodeColor(n.id);
|
|
241
|
+
ctx.globalAlpha = dimmed ? 0.15 : 1;
|
|
242
|
+
ctx.fill();
|
|
243
|
+
ctx.globalAlpha = 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const labelThreshold = 0.8;
|
|
247
|
+
for (const n of data.nodes) {
|
|
248
|
+
const isHovered = hoveredNode && hoveredNode.id === n.id;
|
|
249
|
+
const isNeighbour = hoveredNode && hoveredNeighbours.has(n.id);
|
|
250
|
+
const dimmed = hoveredNode && !isHovered && !isNeighbour;
|
|
251
|
+
if (transform.k > labelThreshold || isHovered || isNeighbour) {
|
|
252
|
+
ctx.font = (isHovered ? 'bold ' : '') + '11px -apple-system, sans-serif';
|
|
253
|
+
ctx.textAlign = 'center';
|
|
254
|
+
ctx.fillStyle = colors.text;
|
|
255
|
+
ctx.globalAlpha = dimmed ? 0.1 : (isHovered || isNeighbour ? 1 : Math.min((transform.k - labelThreshold) * 3, 0.7));
|
|
256
|
+
const label = n.text.length > 25 ? n.text.slice(0, 23) + '...' : n.text;
|
|
257
|
+
ctx.fillText(label, n.x, n.y - nodeRadius(n.id) - 4);
|
|
258
|
+
ctx.globalAlpha = 1;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
ctx.restore();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
simulation.on('tick', draw);
|
|
265
|
+
|
|
266
|
+
// Sidebar state
|
|
267
|
+
let selectedNode = null;
|
|
268
|
+
|
|
269
|
+
function resizeCanvas() {
|
|
270
|
+
width = graphWrap.offsetWidth;
|
|
271
|
+
height = window.innerHeight;
|
|
272
|
+
canvas.width = width * dpr;
|
|
273
|
+
canvas.height = height * dpr;
|
|
274
|
+
canvas.style.width = width + 'px';
|
|
275
|
+
canvas.style.height = height + 'px';
|
|
276
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
277
|
+
simulation.force('center', d3.forceCenter(width / 2, height / 2));
|
|
278
|
+
draw();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function esc(s) {
|
|
282
|
+
const d = document.createElement('div');
|
|
283
|
+
d.textContent = s;
|
|
284
|
+
return d.innerHTML;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function showSidebar(node) {
|
|
288
|
+
dbg('showSidebar: ' + node.id + ' content-len: ' + (node.content||'').length);
|
|
289
|
+
selectedNode = node;
|
|
290
|
+
sidebar.classList.remove('open');
|
|
291
|
+
const neighbours = getNeighbours(node.id);
|
|
292
|
+
sidebar.innerHTML = '<div class="path">' + esc(node.filePath || node.id) + '</div>'
|
|
293
|
+
+ '<div class="title">' + esc(node.text) + '</div>'
|
|
294
|
+
+ '<div class="content">' + marked.parse(node.content || '(empty)') + '</div>'
|
|
295
|
+
+ (neighbours.size > 0 ? '<div class="links"><div class="links-label">Links (' + neighbours.size + ')</div>'
|
|
296
|
+
+ [...neighbours].map(id => {
|
|
297
|
+
const n = data.nodes.find(n => n.id === id);
|
|
298
|
+
return '<div class="link-item" data-id="' + id + '">' + (n ? n.text : id) + '</div>';
|
|
299
|
+
}).join('') + '</div>' : '');
|
|
300
|
+
sidebar.querySelectorAll('.link-item').forEach(el => {
|
|
301
|
+
el.addEventListener('click', () => {
|
|
302
|
+
const target = data.nodes.find(n => n.id === el.dataset.id);
|
|
303
|
+
if (target) showSidebar(target);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
requestAnimationFrame(() => {
|
|
307
|
+
sidebar.classList.add('open');
|
|
308
|
+
setTimeout(resizeCanvas, 250);
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function hideSidebar() {
|
|
313
|
+
selectedNode = null;
|
|
314
|
+
sidebar.className = '';
|
|
315
|
+
sidebar.innerHTML = '';
|
|
316
|
+
setTimeout(resizeCanvas, 250);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Hover
|
|
320
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
321
|
+
const node = findNode(e.offsetX, e.offsetY);
|
|
322
|
+
hoveredNode = node;
|
|
323
|
+
hoveredNeighbours = node ? getNeighbours(node.id) : new Set();
|
|
324
|
+
if (node) hoveredNeighbours.add(node.id);
|
|
325
|
+
canvas.style.cursor = node ? 'pointer' : 'grab';
|
|
326
|
+
if (node) {
|
|
327
|
+
tooltip.style.display = 'block';
|
|
328
|
+
tooltip.textContent = node.text;
|
|
329
|
+
tooltip.style.left = (e.clientX + 12) + 'px';
|
|
330
|
+
tooltip.style.top = (e.clientY - 8) + 'px';
|
|
331
|
+
} else {
|
|
332
|
+
tooltip.style.display = 'none';
|
|
333
|
+
}
|
|
334
|
+
draw();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Zoom + pan + click all via d3.zoom
|
|
338
|
+
// d3.zoom handles mousedown/mousemove/mouseup internally.
|
|
339
|
+
// We detect clicks by checking if the mouse moved between start and end.
|
|
340
|
+
let zoomStartX = 0, zoomStartY = 0;
|
|
341
|
+
|
|
342
|
+
const zoomBehavior = d3.zoom()
|
|
343
|
+
.scaleExtent([0.2, 5])
|
|
344
|
+
.on('start', (event) => {
|
|
345
|
+
if (event.sourceEvent) {
|
|
346
|
+
zoomStartX = event.sourceEvent.clientX || 0;
|
|
347
|
+
zoomStartY = event.sourceEvent.clientY || 0;
|
|
348
|
+
dbg('zoom-start at ' + zoomStartX + ',' + zoomStartY);
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
.on('zoom', (event) => {
|
|
352
|
+
transform = event.transform;
|
|
353
|
+
draw();
|
|
354
|
+
})
|
|
355
|
+
.on('end', (event) => {
|
|
356
|
+
if (event.sourceEvent) {
|
|
357
|
+
const ex = event.sourceEvent.clientX || 0;
|
|
358
|
+
const ey = event.sourceEvent.clientY || 0;
|
|
359
|
+
const dx = ex - zoomStartX;
|
|
360
|
+
const dy = ey - zoomStartY;
|
|
361
|
+
const dist = dx * dx + dy * dy;
|
|
362
|
+
dbg('zoom-end dist=' + dist + ' at ' + ex + ',' + ey);
|
|
363
|
+
if (dist < 25) {
|
|
364
|
+
const rect = canvas.getBoundingClientRect();
|
|
365
|
+
const ox = ex - rect.left;
|
|
366
|
+
const oy = ey - rect.top;
|
|
367
|
+
dbg('click at canvas ' + ox + ',' + oy);
|
|
368
|
+
const node = findNode(ox, oy);
|
|
369
|
+
dbg('findNode=' + (node ? node.id : 'null'));
|
|
370
|
+
if (node) {
|
|
371
|
+
showSidebar(node);
|
|
372
|
+
} else if (selectedNode) {
|
|
373
|
+
hideSidebar();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
d3.select(canvas).call(zoomBehavior)
|
|
380
|
+
.on('dblclick.zoom', null);
|
|
381
|
+
|
|
382
|
+
window.addEventListener('resize', resizeCanvas);
|
|
383
|
+
document.addEventListener('keydown', (e) => {
|
|
384
|
+
if (e.key === 'Escape' && selectedNode) hideSidebar();
|
|
385
|
+
});
|
|
386
|
+
</script>
|
|
387
|
+
`;
|
|
388
|
+
}
|
|
389
|
+
async function openWithGlimpse(html) {
|
|
390
|
+
const { open } = await import("glimpseui");
|
|
391
|
+
const win = open(html, {
|
|
392
|
+
width: 1200,
|
|
393
|
+
height: 800,
|
|
394
|
+
title: "napkin graph",
|
|
395
|
+
});
|
|
396
|
+
win.on("message", (data) => {
|
|
397
|
+
if (data.dbg) {
|
|
398
|
+
console.log("[graph]", data.dbg);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
await new Promise((resolve) => {
|
|
402
|
+
win.on("closed", () => resolve());
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
async function openInBrowser(html) {
|
|
406
|
+
// Wrap fragment in a full HTML document for the browser
|
|
407
|
+
const fullHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>napkin graph</title></head><body>${html}</body></html>`;
|
|
408
|
+
const server = createServer((_req, res) => {
|
|
409
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
410
|
+
res.end(fullHtml);
|
|
411
|
+
});
|
|
412
|
+
await new Promise((resolve) => {
|
|
413
|
+
server.listen(0, "127.0.0.1", () => {
|
|
414
|
+
const addr = server.address();
|
|
415
|
+
if (!addr || typeof addr === "string")
|
|
416
|
+
return;
|
|
417
|
+
const url = `http://127.0.0.1:${addr.port}`;
|
|
418
|
+
info(`Graph running at ${url} — press Ctrl+C to stop`);
|
|
419
|
+
// Open browser
|
|
420
|
+
const { exec } = require("node:child_process");
|
|
421
|
+
const cmd = platform === "win32"
|
|
422
|
+
? `start ${url}`
|
|
423
|
+
: platform === "linux"
|
|
424
|
+
? `xdg-open ${url}`
|
|
425
|
+
: `open ${url}`;
|
|
426
|
+
exec(cmd);
|
|
427
|
+
// Keep running until interrupted
|
|
428
|
+
process.on("SIGINT", () => {
|
|
429
|
+
server.close();
|
|
430
|
+
resolve();
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
export async function graph(_args, options) {
|
|
436
|
+
const { findVault } = await import("../utils/vault.js");
|
|
437
|
+
const vaultInfo = findVault(options.vault || process.cwd());
|
|
438
|
+
const vault = vaultInfo?.contentPath;
|
|
439
|
+
if (!vault) {
|
|
440
|
+
error("No vault found. Run napkin init or use --vault <path>");
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
const { loadConfig } = await import("../utils/config.js");
|
|
444
|
+
const config = loadConfig(vaultInfo.configPath);
|
|
445
|
+
const renderer = config.graph?.renderer ?? "auto";
|
|
446
|
+
const { nodes, links } = buildGraphData(vault);
|
|
447
|
+
const graphDataB64 = Buffer.from(JSON.stringify({ nodes, links })).toString("base64");
|
|
448
|
+
const html = buildHTML(graphDataB64);
|
|
449
|
+
const useGlimpse = renderer === "glimpse" || (renderer === "auto" && platform === "darwin");
|
|
450
|
+
if (useGlimpse) {
|
|
451
|
+
try {
|
|
452
|
+
await openWithGlimpse(html);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
await openInBrowser(html);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
await openInBrowser(html);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type OutputOptions } from "../utils/output.js";
|
|
2
|
+
export interface InitOptions extends OutputOptions {
|
|
3
|
+
path?: string;
|
|
4
|
+
template?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function init(opts: InitOptions): Promise<void>;
|
|
7
|
+
export declare function initTemplates(opts: OutputOptions): Promise<void>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Napkin } from "../sdk.js";
|
|
2
|
+
import { EXIT_ERROR } from "../utils/exit-codes.js";
|
|
3
|
+
import { bold, dim, error, output, success, } from "../utils/output.js";
|
|
4
|
+
export async function init(opts) {
|
|
5
|
+
let result;
|
|
6
|
+
try {
|
|
7
|
+
result = Napkin.scaffold(opts.path || process.cwd(), {
|
|
8
|
+
template: opts.template,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
error(e.message);
|
|
13
|
+
process.exit(EXIT_ERROR);
|
|
14
|
+
}
|
|
15
|
+
if (!result.created && !result.template) {
|
|
16
|
+
output(opts, {
|
|
17
|
+
json: () => result,
|
|
18
|
+
human: () => {
|
|
19
|
+
console.log(`${dim("Vault already initialized at")} ${bold(result.path)}`);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
output(opts, {
|
|
25
|
+
json: () => result,
|
|
26
|
+
human: () => {
|
|
27
|
+
if (result.created) {
|
|
28
|
+
console.log(`${dim("Initialized vault at")} ${bold(result.path)}`);
|
|
29
|
+
}
|
|
30
|
+
if (result.template) {
|
|
31
|
+
console.log(` ${dim("template")} ${bold(result.template)}`);
|
|
32
|
+
for (const f of result.files) {
|
|
33
|
+
console.log(` ${dim("created")} ${f}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
console.log("");
|
|
37
|
+
success("Edit .napkin/NAPKIN.md to set your context.");
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function initTemplates(opts) {
|
|
42
|
+
const templates = Napkin.vaultTemplates();
|
|
43
|
+
output(opts, {
|
|
44
|
+
json: () => ({ templates }),
|
|
45
|
+
human: () => {
|
|
46
|
+
for (const t of templates) {
|
|
47
|
+
console.log(`${bold(t.name)} - ${t.description}`);
|
|
48
|
+
console.log(` ${dim("folders:")} ${t.dirs.join(", ")}`);
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type OutputOptions } from "../utils/output.js";
|
|
2
|
+
export declare function backlinks(opts: OutputOptions & {
|
|
3
|
+
vault?: string;
|
|
4
|
+
file?: string;
|
|
5
|
+
counts?: boolean;
|
|
6
|
+
total?: boolean;
|
|
7
|
+
}): Promise<void>;
|
|
8
|
+
export declare function links(opts: OutputOptions & {
|
|
9
|
+
vault?: string;
|
|
10
|
+
file?: string;
|
|
11
|
+
total?: boolean;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
export declare function unresolvedLinks(opts: OutputOptions & {
|
|
14
|
+
vault?: string;
|
|
15
|
+
total?: boolean;
|
|
16
|
+
counts?: boolean;
|
|
17
|
+
verbose?: boolean;
|
|
18
|
+
}): Promise<void>;
|
|
19
|
+
export declare function orphans(opts: OutputOptions & {
|
|
20
|
+
vault?: string;
|
|
21
|
+
total?: boolean;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
export declare function deadends(opts: OutputOptions & {
|
|
24
|
+
vault?: string;
|
|
25
|
+
total?: boolean;
|
|
26
|
+
}): Promise<void>;
|