@webship.co/workspace 1.0.0-alpha1 → 1.0.0-beta1
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/PLUGINS.md +1 -1
- package/package.json +1 -1
- package/plugin-loader.js +19 -1
- package/row-layout.js +0 -1
- package/server.js +8 -225
- package/workspace-types.js +2 -2
- package/workspaces.js +1 -5
- package/actions/graphs.js +0 -107
- package/actions/obsidian.js +0 -59
- package/graphs.js +0 -104
- package/obsidian.js +0 -405
package/PLUGINS.md
CHANGED
|
@@ -239,7 +239,7 @@ plugin itself (no manifest) and the dashboard never sees it.
|
|
|
239
239
|
| Forges (`github`, `gitlab`, `bitbucket`) | `@webship.co/workspace-forge` — `registerForge(api, forge)` | the credential the dashboard uses for the host (`api.addForge`, which `github-auth.js` asks before the legacy `git.github` block), the row menu on every project whose git remote is at the host (repository, open issues and pull or merge requests, open one from the branch in a terminal, clone URL), the repository chip, the New-folder clone option | a `ForgeProvider`: the host, the CLI, how a remote URL reads, the pages, the issue and request lists, the clone URL |
|
|
240
240
|
| Container runtimes (`docker`, `lando`, `docksal`, `podman`, `kubernetes`) | `@webship.co/workspace-container` — `registerContainer(api, runtime)` | the row menu on every project the runtime recognises (its verbs as terminal sessions in the project, grouped, with the site link), the up/down caption answered after the row is drawn, the New-folder init option | a `ContainerRuntime`: how a folder is detected as a project, the CLI, how to tell whether it is up, the verbs and their commands |
|
|
241
241
|
|
|
242
|
-
`@webship.co/workspace-upsun`
|
|
242
|
+
`@webship.co/workspace-upsun` is the reference provider: `register(api)` is one line, the facts are
|
|
243
243
|
in `src/lib/provider.ts`, and the bash tool, compose file, host install, settings template and theme
|
|
244
244
|
are what the installer places.
|
|
245
245
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webship.co/workspace",
|
|
3
|
-
"version": "1.0.0-
|
|
3
|
+
"version": "1.0.0-beta1",
|
|
4
4
|
"description": "The Webship Workspace dashboard: a lean core (workspaces, builders, settings, the assistant shell, the plugin system) that every feature joins as a @webship.co/workspace-<name> plugin \u2014 required with npm, then activated, installed and configured.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"repository": {
|
package/plugin-loader.js
CHANGED
|
@@ -68,6 +68,11 @@ const lib = {
|
|
|
68
68
|
LINGER_MARK: require('./linger').LINGER_MARK,
|
|
69
69
|
tildePath: (p) => require('./format').tildePath(p),
|
|
70
70
|
hostRunnerRequest: (...a) => require('./jobs').hostRunnerRequest(...a),
|
|
71
|
+
// The list helpers a plugin's own list page draws with (paging, sorting, the controls), the
|
|
72
|
+
// stylesheet version a standalone page links, and the script finder (cmd-tools-<x>.sh in a folder).
|
|
73
|
+
lists: require('./lists'),
|
|
74
|
+
cssVersionNow: () => require('./themes').cssVersion(require('./workspaces').styleSettings().theme),
|
|
75
|
+
findScript: (dir, patterns) => require('./workspaces').findScript(dir, patterns),
|
|
71
76
|
workspaceType: (key) => (loadWorkspaces()[key] || {}).type || null,
|
|
72
77
|
typeAllows: (key, action) => {
|
|
73
78
|
const meta = loadWorkspaces()[key];
|
|
@@ -94,6 +99,7 @@ const sessionLogFns = []; // (session, dirs) -> file | ''
|
|
|
94
99
|
const assistantContextFns = []; // () -> a paragraph for the assistant's system context, or ''
|
|
95
100
|
const forges = {}; // '<host>' -> { name, label, env(), auth() }: who answers for a git forge host
|
|
96
101
|
const openWithFns = []; // (abs, { label, caption, git }) -> html | null: the Open-with menu for a folder (the IDE plugin)
|
|
102
|
+
const workspaceViews = {}; // '<key>' -> { name, listHtml(state), count(), noFilesActions, noNewForm }: a plugin that owns a workspace folder's page
|
|
97
103
|
const folderLinkFns = []; // (abs) -> { href, label, icon?, title? } | null: a link to open a folder elsewhere (the Web IDE)
|
|
98
104
|
|
|
99
105
|
let _screens = { at: 0, rows: [], running: false };
|
|
@@ -232,6 +238,13 @@ function makeApi(name, config) {
|
|
|
232
238
|
addFolderLink(fn) {
|
|
233
239
|
folderLinkFns.push(fn);
|
|
234
240
|
},
|
|
241
|
+
// A plugin that owns a workspace folder's page (the graphs workspace): its list section
|
|
242
|
+
// (`listHtml(state)`), the count on the home card (`count()`), and whether the files-page
|
|
243
|
+
// actions and the New-folder form stay away from it.
|
|
244
|
+
addWorkspaceView(key, view) {
|
|
245
|
+
if (!/^[a-z0-9_-]+$/.test(String(key))) throw new Error(`${name}: invalid workspace key '${key}'`);
|
|
246
|
+
workspaceViews[key] = { name, ...view };
|
|
247
|
+
},
|
|
235
248
|
};
|
|
236
249
|
}
|
|
237
250
|
|
|
@@ -404,6 +417,11 @@ function pluginWorkPrompt(ctx) {
|
|
|
404
417
|
for (const fn of workPromptFns) { try { const p = fn(ctx); if (p) return p; } catch (_) { /* next */ } }
|
|
405
418
|
return null;
|
|
406
419
|
}
|
|
420
|
+
/** The plugin view of a workspace folder's page, or null. */
|
|
421
|
+
function pluginWorkspaceView(key) {
|
|
422
|
+
return workspaceViews[String(key || '')] || null;
|
|
423
|
+
}
|
|
424
|
+
|
|
407
425
|
/** The Open-with menu for a folder, from the first plugin that draws one; '' without an IDE plugin. */
|
|
408
426
|
function pluginOpenWith(abs, opts = {}) {
|
|
409
427
|
for (const fn of openWithFns) {
|
|
@@ -443,6 +461,6 @@ loadPlugins();
|
|
|
443
461
|
module.exports = {
|
|
444
462
|
pluginActions: actions, pluginFragment, pluginPage, pluginPageActionGroups, pluginProjectActionsHtml,
|
|
445
463
|
pluginRowMenus, pluginProjectChipsHtml, pluginFolderSetups, pluginCssFiles, pluginLabels,
|
|
446
|
-
pluginProjectBadgesHtml, pluginSettingsOptions, pluginScreenStarted, pluginWorkKeyLink, pluginWorkPrompt, pluginSessionLog, pluginAssistantContext, pluginForge, pluginForges, pluginOpenWith, pluginFolderLinks,
|
|
464
|
+
pluginProjectBadgesHtml, pluginSettingsOptions, pluginScreenStarted, pluginWorkKeyLink, pluginWorkPrompt, pluginSessionLog, pluginAssistantContext, pluginForge, pluginForges, pluginOpenWith, pluginFolderLinks, pluginWorkspaceView,
|
|
447
465
|
loadedPlugins: loaded, pluginConfigs,
|
|
448
466
|
};
|
package/row-layout.js
CHANGED
|
@@ -58,7 +58,6 @@ const BUILTIN_BUTTONS = [
|
|
|
58
58
|
// Single buttons, with no menu inside to arrange or to add actions to.
|
|
59
59
|
{ id: 'backup', label: 'Backup', row: 1, menu: false },
|
|
60
60
|
{ id: 'clone', label: 'Clone', row: 1, menu: false },
|
|
61
|
-
{ id: 'graph', label: 'Graph', row: 1 },
|
|
62
61
|
{ id: 'rag', label: 'RAG', row: 1 },
|
|
63
62
|
{ id: 'more', label: 'More', row: 1 },
|
|
64
63
|
];
|
package/server.js
CHANGED
|
@@ -26,7 +26,6 @@ const {
|
|
|
26
26
|
workspaceDir,
|
|
27
27
|
findBackupScript,
|
|
28
28
|
findRemoveScript,
|
|
29
|
-
findGraphifyScript,
|
|
30
29
|
findRagifyScript,
|
|
31
30
|
findBuilderScripts,
|
|
32
31
|
listBackups,
|
|
@@ -85,7 +84,7 @@ const {
|
|
|
85
84
|
// Optional features workspaces turn on from settings.yml `plugins:` — see PLUGINS.md.
|
|
86
85
|
const {
|
|
87
86
|
pluginActions, pluginFragment, pluginPage, pluginPageActionGroups, pluginProjectActionsHtml,
|
|
88
|
-
pluginRowMenus, pluginOpenWith, pluginFolderLinks, pluginProjectChipsHtml, pluginLabels,
|
|
87
|
+
pluginRowMenus, pluginWorkspaceView, pluginOpenWith, pluginFolderLinks, pluginProjectChipsHtml, pluginLabels,
|
|
89
88
|
pluginProjectBadgesHtml, pluginSettingsOptions, pluginWorkKeyLink, pluginWorkPrompt,
|
|
90
89
|
} = require('./plugin-loader');
|
|
91
90
|
// How a project row is arranged — the order of its buttons and of what is inside their menus (Commands → Buttons).
|
|
@@ -133,7 +132,6 @@ const {
|
|
|
133
132
|
const { commandAuthorPrompt, commandEditPrompt } = require('./command-author');
|
|
134
133
|
const { checksCaption, checksDeclaredOf, checksRowsOf } = require('./checks');
|
|
135
134
|
const { testReportPdfHtml } = require('./report-pdf');
|
|
136
|
-
const { graphStoreDir, hasGraph, graphCounts, listGraphs } = require('./graphs');
|
|
137
135
|
const {
|
|
138
136
|
milvusProjectName, milvusRestBase, ragInstances, ragBindings, ragInstanceFor, ragWriteBinding,
|
|
139
137
|
ragCollectionName, milvusPost, ragCollections, ragInvalidate,
|
|
@@ -143,7 +141,6 @@ const {
|
|
|
143
141
|
ddevProjectName, listProjects, listProjectsDetailed,
|
|
144
142
|
startInScreen, screenStartedHtml, screenAction, screenSessionName,
|
|
145
143
|
} = require('./jobs');
|
|
146
|
-
const { vaultDir, indexNote, vaultView, vaultCanvas, vaultFrame, obsidianMenuItems } = require('./obsidian');
|
|
147
144
|
const { assembleCss, cssVersion, listThemes, themeLabel, createTheme, themePreview, isYamlOnlyTheme } = require('./themes');
|
|
148
145
|
const NAME_RE = /^[a-zA-Z0-9_\-]+$/;
|
|
149
146
|
|
|
@@ -2887,7 +2884,8 @@ function homeCardsHtml(message) {
|
|
|
2887
2884
|
const workspaces = loadWorkspaces();
|
|
2888
2885
|
const keys = Object.keys(workspaces);
|
|
2889
2886
|
const cards = Object.values(workspaces).map((w) => {
|
|
2890
|
-
const
|
|
2887
|
+
const view = pluginWorkspaceView(w.key);
|
|
2888
|
+
const projectCount = view && view.count ? view.count()
|
|
2891
2889
|
: w.kind === 'files' ? listItems(w.key).length : listProjects(w.dir).length;
|
|
2892
2890
|
const hasBuilders = w.kind === 'files' || !w.typeActions.has('build') ? false : findBuilderScripts(w.dir).length > 0;
|
|
2893
2891
|
const backupCount = listBackups(w.key).length;
|
|
@@ -3144,7 +3142,6 @@ async function projectRowsHtml(key, dir, state) {
|
|
|
3144
3142
|
const canBackup = allowed.has('backup') && !!findBackupScript(dir);
|
|
3145
3143
|
const canClone = allowed.has('clone') && fs.existsSync(path.join(dir, 'cmd-tools-clone.sh'));
|
|
3146
3144
|
const canRemove = allowed.has('remove') && !!findRemoveScript(dir);
|
|
3147
|
-
const canGraphify = allowed.has('graphify') && !!findGraphifyScript(dir);
|
|
3148
3145
|
const canRagify = allowed.has('ragify') && !!findRagifyScript(dir);
|
|
3149
3146
|
const canTest = allowed.has('tests') && fs.existsSync(path.join(dir, 'cmd-tools-testing.sh'));
|
|
3150
3147
|
const canChecks = allowed.has('checks') && fs.existsSync(path.join(dir, 'cmd-tools-checks.sh'));
|
|
@@ -3459,25 +3456,6 @@ async function projectRowsHtml(key, dir, state) {
|
|
|
3459
3456
|
hx-get="/fragments/projects/clone-form/${encodeURIComponent(key)}/${encodeURIComponent(p)}" hx-target="#editor-modal-body" hx-swap="innerHTML"
|
|
3460
3457
|
hx-on::after:request="UIkit.modal('#editor-modal').show()"
|
|
3461
3458
|
title="Clone this ${esc(meta.noun)} — a copy, or a git worktree sharing its history"><span uk-icon="icon: copy; ratio: .7"></span> Clone</button>` : '';
|
|
3462
|
-
const graphMenu = canGraphify ? (() => {
|
|
3463
|
-
const built = hasGraph(key, p, 'graph.json') || hasGraph(key, p, 'graph.html');
|
|
3464
|
-
const busy = !!runningJobKey(`graphify:${key}/${p}`);
|
|
3465
|
-
return `<span class="act-with-status"><div class="uk-inline act-menu act-menu-graph">
|
|
3466
|
-
<button class="uk-button uk-button-default uk-button-small" type="button" title="Knowledge graph"><span uk-icon="icon: git-fork; ratio: .7"></span> Graph <span uk-icon="icon: chevron-down; ratio: .6"></span></button>
|
|
3467
|
-
|
|
3468
|
-
<div uk-dropdown="mode: click; pos: bottom-right"><ul class="uk-nav uk-dropdown-nav">
|
|
3469
|
-
<li data-entry="build the graph"><a href hx-post="/actions/graphify" ${vals()} class="${busy ? 'uk-disabled' : ''}"><span uk-icon="icon: play; ratio: .7"></span> ${built ? 'Rebuild the graph' : 'Build the graph'}${busy ? ' (running…)' : ''}</a></li>
|
|
3470
|
-
${built ? `<li class="uk-nav-divider"></li>
|
|
3471
|
-
${hasGraph(key, p, 'graph.html') ? `<li><a href="/graph/${esc(key)}/${esc(p)}" target="_blank" title="Loads vis-network from unpkg.com, so it needs internet"><span uk-icon="icon: image; ratio: .7"></span> Open the graph</a></li>` : ''}
|
|
3472
|
-
${hasGraph(key, p, 'GRAPH_REPORT.md') ? `<li><a href="/graph/${esc(key)}/${esc(p)}/report" target="_blank"><span uk-icon="icon: file-text; ratio: .7"></span> Read the report</a></li>` : ''}
|
|
3473
|
-
<li><a href="/graph/${esc(key)}/${esc(p)}/download" title="The graph as a .tar.gz, to hand to another workspace"><span uk-icon="icon: download; ratio: .7"></span> Download to share</a></li>
|
|
3474
|
-
<li><a href hx-post="/actions/graph-mcp-command" ${vals()}><span uk-icon="icon: link; ratio: .7"></span> Connect over MCP…</a></li>
|
|
3475
|
-
${obsidianMenuItems(key, p, vals, busy)}
|
|
3476
|
-
<li class="uk-nav-divider"></li>
|
|
3477
|
-
<li><a href class="uk-text-danger arm-step" hx-post="/actions/graph-remove" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"><span uk-icon="icon: trash; ratio: .7"></span> Delete the graph</a></li>` : ''}
|
|
3478
|
-
</ul></div>
|
|
3479
|
-
</div><span class="act-status uk-text-meta">${built ? 'built' : 'none'}</span></span>`;
|
|
3480
|
-
})() : '';
|
|
3481
3459
|
const ragMenu = canRagify ? (() => {
|
|
3482
3460
|
// The RAG index: the same shape as the Graph menu next to it, because it answers the
|
|
3483
3461
|
// half of the tree the graph does not — prose, templates and configuration.
|
|
@@ -3559,7 +3537,7 @@ async function projectRowsHtml(key, dir, state) {
|
|
|
3559
3537
|
// The menus activated plugins bring, in the slots they registered (UPSUN, …).
|
|
3560
3538
|
...pluginRowMenus({ key, project: p, dir, meta, allowed, vals }),
|
|
3561
3539
|
screens: screensOnTools, composer: composerMenu, node: nodeMenu, backup: backupButton, clone: cloneButton,
|
|
3562
|
-
|
|
3540
|
+
rag: ragMenu, more: moreMenu,
|
|
3563
3541
|
}, rowCtx, { preArranged: new Set(['ddev', 'more']) });
|
|
3564
3542
|
// What is written down about this folder, read once: the name it is called by is the heading, the
|
|
3565
3543
|
// paragraph is the row's tooltip, and the pen that edits both sits in the card's own corner.
|
|
@@ -3840,7 +3818,7 @@ function pageActionGroups(context) {
|
|
|
3840
3818
|
groups.push({ id: 'go-to', label: 'Go to', items: [
|
|
3841
3819
|
link('default', 'arrow-left', `Back to ${meta.label}`, wsUrl(key)),
|
|
3842
3820
|
] });
|
|
3843
|
-
} else if (meta && meta.kind === 'files' && key
|
|
3821
|
+
} else if (meta && meta.kind === 'files' && !(pluginWorkspaceView(key) || {}).noFilesActions) {
|
|
3844
3822
|
// Audio makes things rather than writes them, so each kind of thing is its own action and its own modal.
|
|
3845
3823
|
// Each is a modal, not an inline form: the result of generating stays in the window that asked for it.
|
|
3846
3824
|
const modal = 'hx-target="#editor-modal-body" hx-swap="innerHTML"';
|
|
@@ -4324,57 +4302,6 @@ function commandsShell(title, subtitle, active, body, context = 'commands') {
|
|
|
4324
4302
|
</main>`, context);
|
|
4325
4303
|
}
|
|
4326
4304
|
|
|
4327
|
-
function graphRowsHtml(state) {
|
|
4328
|
-
const all = listGraphs();
|
|
4329
|
-
const st = state;
|
|
4330
|
-
const filtered = st.q
|
|
4331
|
-
? all.filter((g) => g.name.toLowerCase().includes(st.q.toLowerCase()))
|
|
4332
|
-
: all;
|
|
4333
|
-
const sorted = filtered.slice().sort((SORTS[st.sort] || SORTS[defaultSort()] || SORTS.newest).cmp);
|
|
4334
|
-
const p = paginate(sorted, st);
|
|
4335
|
-
|
|
4336
|
-
const rows = p.slice.map((g) => `
|
|
4337
|
-
<div class="uk-card uk-card-default uk-card-small uk-card-body uk-margin-small project-row">
|
|
4338
|
-
<div class="item-row-body">
|
|
4339
|
-
<span class="uk-text-bold">${iconHtml('tabler:hierarchy-2', 0.8)} ${esc(g.project)}
|
|
4340
|
-
<span class="uk-text-meta item-machine" title="The workspace the project lives in">${esc(g.workspace)}</span></span>
|
|
4341
|
-
<div class="item-meta-line">
|
|
4342
|
-
<span class="uk-text-meta item-time">${esc(humanTime(g.mtime))}</span>
|
|
4343
|
-
<span class="uk-text-meta">· ${esc(humanSize(g.size))}</span>
|
|
4344
|
-
${g.nodes != null ? `<span class="uk-label" title="Nodes and edges, from graphify's own build file">${g.nodes} nodes${g.links != null ? ` · ${g.links} edges` : ''}</span>` : ''}
|
|
4345
|
-
${g.projectExists ? '' : '<span class="uk-label" title="The project is gone — the graph outlived it, and is safe to delete">project removed</span>'}
|
|
4346
|
-
</div>
|
|
4347
|
-
<div class="project-actions">
|
|
4348
|
-
${g.hasHtml ? `<a class="uk-button uk-button-default uk-button-small" href="/graph/${esc(g.workspace)}/${esc(g.project)}" target="_blank" title="Loads vis-network from unpkg.com, so it needs internet"><span uk-icon="icon: image; ratio: .7"></span> Open</a>` : ''}
|
|
4349
|
-
${g.hasReport ? `<a class="uk-button uk-button-default uk-button-small" href="/graph/${esc(g.workspace)}/${esc(g.project)}/report" target="_blank"><span uk-icon="icon: file-text; ratio: .7"></span> Report</a>` : ''}
|
|
4350
|
-
<a class="uk-button uk-button-default uk-button-small" href="/graph/${esc(g.workspace)}/${esc(g.project)}/download"><span uk-icon="icon: download; ratio: .7"></span> Download</a>
|
|
4351
|
-
${g.projectExists ? `<button class="uk-button uk-button-secondary uk-button-small" hx-post="/actions/graphify" hx-vals='{"workspace":"${esc(g.workspace)}","projectName":"${esc(g.project)}"}' hx-target="#webship-workspace-output" hx-swap="innerHTML" title="Re-extract and re-cluster from the project"><span uk-icon="icon: refresh; ratio: .7"></span> Rebuild</button>` : ''}
|
|
4352
|
-
<button class="uk-button uk-button-danger uk-button-small arm-step" hx-post="/actions/graph-remove" hx-vals='{"workspace":"${esc(g.workspace)}","projectName":"${esc(g.project)}","confirm":"yes"}' hx-target="#webship-workspace-output" hx-swap="innerHTML" hx-trigger="confirmed-remove"><span uk-icon="icon: trash; ratio: .7"></span> Delete</button>
|
|
4353
|
-
</div>
|
|
4354
|
-
</div>
|
|
4355
|
-
</div>`).join('');
|
|
4356
|
-
|
|
4357
|
-
// The controls get the BARE URL: htmx appends the included inputs without deduping, and
|
|
4358
|
-
// readPageState reads the FIRST q/sort — a URL already carrying ?q= made the search box a no-op.
|
|
4359
|
-
// The pager keeps the qs form so page links preserve the current search.
|
|
4360
|
-
const bareUrl = '/fragments/graphs/list';
|
|
4361
|
-
const qs = new URLSearchParams();
|
|
4362
|
-
if (st.q) qs.set('q', st.q);
|
|
4363
|
-
if (st.sort !== defaultSort()) qs.set('sort', st.sort);
|
|
4364
|
-
const url = `${bareUrl}${qs.toString() ? `?${qs}` : ''}`;
|
|
4365
|
-
const pager = listPagerHtml(url, '#webship-workspace-projects', p, st, 'graphs');
|
|
4366
|
-
|
|
4367
|
-
return `
|
|
4368
|
-
<h3 class="uk-margin-small-bottom">Graphs <span class="uk-badge">${all.length}</span></h3>
|
|
4369
|
-
<p class="uk-text-meta uk-margin-small-bottom">One directory per project in <code>${esc(path.join(ROOT, 'graphs'))}/<workspace>/<project>/</code>,
|
|
4370
|
-
built by <code>cmd-tools-graphify.sh</code> from the project's workspace folder. Query one with
|
|
4371
|
-
<code>graphify query "…" --graph ${esc(path.join(ROOT, 'graphs'))}/<workspace>/<project>/graph.json</code>.</p>
|
|
4372
|
-
${listControlsHtml(bareUrl, '#webship-workspace-projects', st, 'graphs')}
|
|
4373
|
-
${pager}
|
|
4374
|
-
${rows || '<p class="uk-text-meta">No graphs yet — build one from the Graph menu on any project row.</p>'}
|
|
4375
|
-
${pager}`;
|
|
4376
|
-
}
|
|
4377
|
-
|
|
4378
4305
|
async function workspacePage(key, state) {
|
|
4379
4306
|
await refreshLiveSessions();
|
|
4380
4307
|
const workspaces = loadWorkspaces();
|
|
@@ -4384,12 +4311,8 @@ async function workspacePage(key, state) {
|
|
|
4384
4311
|
const builders = isFiles || !meta.typeActions.has('build') ? [] : findBuilderScripts(dir);
|
|
4385
4312
|
const builderOptions = builders.map((s) => `<option value="${esc(s)}">${esc(builderLabel(dir, s))}</option>`).join('');
|
|
4386
4313
|
|
|
4387
|
-
const
|
|
4388
|
-
|
|
4389
|
-
${graphRowsHtml(state)}
|
|
4390
|
-
</div>
|
|
4391
|
-
|
|
4392
|
-
` : meta.kind === 'commands' ? `
|
|
4314
|
+
const view = pluginWorkspaceView(key);
|
|
4315
|
+
const listSection = view && view.listHtml ? view.listHtml(state) : meta.kind === 'commands' ? `
|
|
4393
4316
|
<div id="webship-workspace-projects" hx-get="/fragments/cc/list" hx-trigger="refresh-projects from:body">
|
|
4394
4317
|
${await commandCatalogHtml(state)}
|
|
4395
4318
|
</div>
|
|
@@ -4517,8 +4440,6 @@ const ACTIONS = {
|
|
|
4517
4440
|
...require('./actions/ddev'),
|
|
4518
4441
|
...require('./actions/ddev-mail').make({ mailpitFor: ddevMail.mailpitFor(ddevStatusMap) }),
|
|
4519
4442
|
...require('./actions/ddev-addons'),
|
|
4520
|
-
...require('./actions/graphs'),
|
|
4521
|
-
...require('./actions/obsidian'),
|
|
4522
4443
|
...require('./actions/rag'),
|
|
4523
4444
|
...require('./actions/testing'),
|
|
4524
4445
|
...require('./actions/checks'),
|
|
@@ -5660,80 +5581,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
5660
5581
|
const out = path.join(tmp, 'report.pdf');
|
|
5661
5582
|
const cleanup = () => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) { /* gone */ } };
|
|
5662
5583
|
|
|
5663
|
-
// The vault: its index note, and the whole thing as an archive. Same symlink discipline
|
|
5664
|
-
// as the graph routes below — a project directory that is a link would otherwise read
|
|
5665
|
-
// outside the workspace, and this container bind-mounts ~/.claude as well as ~/workspace.
|
|
5666
|
-
const vaultMatch = pathname.match(/^\/obsidian\/([a-z0-9_-]+)\/([a-zA-Z0-9_.-]+)\/(index|download|view|canvas|frame)$/);
|
|
5667
|
-
if (vaultMatch && isValidWorkspace(vaultMatch[1])) {
|
|
5668
|
-
const [, wsKey, project, what] = vaultMatch;
|
|
5669
|
-
if (project.includes('..')) { res.writeHead(400); return res.end('bad name'); }
|
|
5670
|
-
let real;
|
|
5671
|
-
try {
|
|
5672
|
-
real = fs.realpathSync(vaultDir(wsKey, project));
|
|
5673
|
-
const base = fs.realpathSync(path.join(ROOT, 'graphs'));
|
|
5674
|
-
if (!real.startsWith(base + path.sep) || !fs.statSync(real).isDirectory()) throw new Error('outside');
|
|
5675
|
-
} catch (_) {
|
|
5676
|
-
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
5677
|
-
return res.end('No vault yet — build one from the Graph menu on the project row.');
|
|
5678
|
-
}
|
|
5679
|
-
|
|
5680
|
-
if (what === 'frame') {
|
|
5681
|
-
const wanted = String(new URL(req.url, 'http://localhost').searchParams.get('what') || 'view');
|
|
5682
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
5683
|
-
return res.end(vaultFrame(wsKey, project, wanted));
|
|
5684
|
-
}
|
|
5685
|
-
|
|
5686
|
-
if (what === 'view' || what === 'canvas') {
|
|
5687
|
-
// Built before the head is written: a throw after writeHead becomes a 200 with an
|
|
5688
|
-
// empty body, which reads as "the vault is empty" rather than "this broke".
|
|
5689
|
-
let page;
|
|
5690
|
-
try {
|
|
5691
|
-
const themed = cssVersion(styleSettings().theme);
|
|
5692
|
-
page = what === 'view'
|
|
5693
|
-
? vaultView(wsKey, project, String(new URL(req.url, 'http://localhost').searchParams.get('note') || ''), themed)
|
|
5694
|
-
: vaultCanvas(wsKey, project, themed);
|
|
5695
|
-
} catch (e) {
|
|
5696
|
-
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
5697
|
-
return res.end(`Could not read the vault: ${e.message}`);
|
|
5698
|
-
}
|
|
5699
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
5700
|
-
return res.end(page);
|
|
5701
|
-
}
|
|
5702
|
-
|
|
5703
|
-
if (what === 'index') {
|
|
5704
|
-
// Read before the head is written: computing after writeHead turns a throw into a
|
|
5705
|
-
// 200 with an empty body, which reads as "it worked and there is nothing there".
|
|
5706
|
-
let md;
|
|
5707
|
-
try {
|
|
5708
|
-
md = fs.readFileSync(path.join(real, indexNote(project)), 'utf8');
|
|
5709
|
-
} catch (_) {
|
|
5710
|
-
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
5711
|
-
return res.end('<div class="msg error">The vault has no index note. Rebuild it.</div>');
|
|
5712
|
-
}
|
|
5713
|
-
const vaultUri = `obsidian://open?path=${encodeURIComponent(real)}`;
|
|
5714
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
5715
|
-
return res.end(`<h3>${esc(project)} <span class="uk-text-meta">Obsidian vault</span></h3>
|
|
5716
|
-
<p class="uk-text-meta">${esc(real)}</p>
|
|
5717
|
-
<p><a class="uk-button uk-button-primary uk-button-small" href="${esc(vaultUri)}">Open in Obsidian</a>
|
|
5718
|
-
<a class="uk-button uk-button-default uk-button-small" href="/obsidian/${esc(wsKey)}/${esc(project)}/download">Download</a></p>
|
|
5719
|
-
<pre class="uk-background-muted uk-padding-small" style="white-space:pre-wrap">${esc(md)}</pre>`);
|
|
5720
|
-
}
|
|
5721
|
-
|
|
5722
|
-
const vaultArchive = `${wsKey}--${project}--obsidian.tar.gz`;
|
|
5723
|
-
res.writeHead(200, {
|
|
5724
|
-
'Content-Type': 'application/gzip',
|
|
5725
|
-
'Content-Disposition': `attachment; filename="${vaultArchive.replace(/[^A-Za-z0-9._-]/g, '_')}"`,
|
|
5726
|
-
'X-Content-Type-Options': 'nosniff',
|
|
5727
|
-
});
|
|
5728
|
-
const vtar = spawn('tar', ['-czf', '-', '-C', path.dirname(real), path.basename(real)],
|
|
5729
|
-
{ stdio: ['ignore', 'pipe', 'pipe'] });
|
|
5730
|
-
vtar.stdout.pipe(res);
|
|
5731
|
-
vtar.stderr.resume();
|
|
5732
|
-
vtar.on('error', () => { try { res.destroy(); } catch (_) { /* client gone */ } });
|
|
5733
|
-
res.on('close', () => { try { vtar.kill(); } catch (_) { /* already exited */ } });
|
|
5734
|
-
return;
|
|
5735
|
-
}
|
|
5736
|
-
|
|
5737
5584
|
// Rendered from the run's JSON through our own print stylesheet, not from
|
|
5738
5585
|
// cucumber-html-reporter's page: that page's feature and scenario bodies are Bootstrap
|
|
5739
5586
|
// collapsibles which print CLOSED — a PDF of nothing but headers — and its CSS comes from
|
|
@@ -5779,66 +5626,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
5779
5626
|
});
|
|
5780
5627
|
return;
|
|
5781
5628
|
}
|
|
5782
|
-
const graphDlMatch = pathname.match(/^\/graph\/([a-z0-9_-]+)\/([a-zA-Z0-9_.\-]+)\/download$/);
|
|
5783
|
-
if (graphDlMatch && isValidWorkspace(graphDlMatch[1])) {
|
|
5784
|
-
const [, wsKey, project] = graphDlMatch;
|
|
5785
|
-
if (project.includes('..')) { res.writeHead(400); return res.end('bad name'); }
|
|
5786
|
-
const out = graphStoreDir(wsKey, project);
|
|
5787
|
-
let real;
|
|
5788
|
-
try {
|
|
5789
|
-
real = fs.realpathSync(out);
|
|
5790
|
-
const base = fs.realpathSync(path.join(ROOT, 'graphs'));
|
|
5791
|
-
if (!real.startsWith(base + path.sep) || !fs.statSync(real).isDirectory()) throw new Error('nope');
|
|
5792
|
-
} catch (_) {
|
|
5793
|
-
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
5794
|
-
return res.end('No graph yet — build one from the Graph menu on the project row.');
|
|
5795
|
-
}
|
|
5796
|
-
// Streamed straight from tar: a graph runs to tens of MB and buffering it would hold
|
|
5797
|
-
// the whole thing in the app's memory.
|
|
5798
|
-
const name = `${wsKey}--${project}--graph.tar.gz`;
|
|
5799
|
-
res.writeHead(200, {
|
|
5800
|
-
'Content-Type': 'application/gzip',
|
|
5801
|
-
'Content-Disposition': `attachment; filename="${name.replace(/[^A-Za-z0-9._-]/g, '_')}"`,
|
|
5802
|
-
'X-Content-Type-Options': 'nosniff',
|
|
5803
|
-
});
|
|
5804
|
-
const tar = spawn('tar', ['-czf', '-', '-C', path.dirname(real), path.basename(real)],
|
|
5805
|
-
{ stdio: ['ignore', 'pipe', 'pipe'] });
|
|
5806
|
-
tar.stdout.pipe(res);
|
|
5807
|
-
tar.stderr.resume();
|
|
5808
|
-
tar.on('error', () => { try { res.destroy(); } catch (_) { /* client gone */ } });
|
|
5809
|
-
res.on('close', () => { try { tar.kill(); } catch (_) { /* already exited */ } });
|
|
5810
|
-
return;
|
|
5811
|
-
}
|
|
5812
|
-
const graphMatch = pathname.match(/^\/graph\/([a-z0-9_-]+)\/([a-zA-Z0-9_\-]+)(\/report)?$/);
|
|
5813
|
-
if (graphMatch && isValidWorkspace(graphMatch[1])) {
|
|
5814
|
-
const wantsReport = !!graphMatch[3];
|
|
5815
|
-
const dir = path.join(ROOT, 'graphs');
|
|
5816
|
-
const file = path.join(graphStoreDir(graphMatch[1], graphMatch[2]),
|
|
5817
|
-
wantsReport ? 'GRAPH_REPORT.md' : 'graph.html');
|
|
5818
|
-
// Resolve symlinks before trusting the path: a project directory that
|
|
5819
|
-
// is a symlink would otherwise read outside the workspace, and this
|
|
5820
|
-
// container bind-mounts ~/.claude as well as ~/workspace.
|
|
5821
|
-
let real = null;
|
|
5822
|
-
try {
|
|
5823
|
-
const base = fs.realpathSync(dir);
|
|
5824
|
-
real = fs.realpathSync(file);
|
|
5825
|
-
if (real !== base && !real.startsWith(base + path.sep)) real = null;
|
|
5826
|
-
if (real && !fs.statSync(real).isFile()) real = null;
|
|
5827
|
-
} catch (_) { real = null; }
|
|
5828
|
-
if (!real) {
|
|
5829
|
-
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
5830
|
-
return res.end('No graph yet — press Graph on the project row to build one.');
|
|
5831
|
-
}
|
|
5832
|
-
// graph.html is generated from the project's own code: serve it with
|
|
5833
|
-
// scripts sandboxed away from this origin, and stream it (it runs to
|
|
5834
|
-
// tens of MB on a large codebase).
|
|
5835
|
-
res.writeHead(200, {
|
|
5836
|
-
'Content-Type': wantsReport ? 'text/plain; charset=utf-8' : 'text/html; charset=utf-8',
|
|
5837
|
-
'X-Content-Type-Options': 'nosniff',
|
|
5838
|
-
'Content-Security-Policy': 'sandbox allow-scripts allow-popups;',
|
|
5839
|
-
});
|
|
5840
|
-
return fs.createReadStream(real).pipe(res);
|
|
5841
|
-
}
|
|
5842
5629
|
const jobMatch = pathname.match(/^\/fragments\/job\/([a-z0-9-]+)$/);
|
|
5843
5630
|
if (jobMatch) {
|
|
5844
5631
|
const frag = jobFragment(jobMatch[1]);
|
|
@@ -6092,7 +5879,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
6092
5879
|
return res.end(made.html);
|
|
6093
5880
|
}
|
|
6094
5881
|
const newMatch = pathname.match(/^\/fragments\/([a-z0-9_-]+)\/new$/);
|
|
6095
|
-
if (newMatch && newMatch[1]
|
|
5882
|
+
if (newMatch && !(pluginWorkspaceView(newMatch[1]) || {}).noNewForm && isValidWorkspace(newMatch[1]) && loadWorkspaces()[newMatch[1]].kind === 'files') {
|
|
6096
5883
|
const key = newMatch[1];
|
|
6097
5884
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
6098
5885
|
return res.end(editorFormHtml(key, '', (ITEM_TEMPLATES[key] || ITEM_TEMPLATES.docs)('my-' + loadWorkspaces()[key].noun), true));
|
|
@@ -6534,10 +6321,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
6534
6321
|
}
|
|
6535
6322
|
// The images public/ actually holds, as tiles. A settings value has to match a file
|
|
6536
6323
|
// exactly, so this shows what is there rather than asking you to remember a filename.
|
|
6537
|
-
if (pathname === '/fragments/graphs/list') {
|
|
6538
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
6539
|
-
return res.end(graphRowsHtml(readPageState(req)));
|
|
6540
|
-
}
|
|
6541
6324
|
if (pathname === '/fragments/jobs/inline') {
|
|
6542
6325
|
const rows = [...jobs.entries()].filter(([, j]) => !j.done && j.inline).map(([id, j]) => `
|
|
6543
6326
|
<div class="uk-flex uk-flex-middle uk-flex-between uk-margin-small-bottom">
|
package/workspace-types.js
CHANGED
|
@@ -23,12 +23,12 @@ const TYPES = {
|
|
|
23
23
|
sites: {
|
|
24
24
|
label: 'Sites — DDEV projects',
|
|
25
25
|
description: 'Built Drupal sites: DDEV controls, tests, users, the full toolbelt.',
|
|
26
|
-
actions: ['build', 'folder', 'ddev', 'backup', 'clone', 'remove', '
|
|
26
|
+
actions: ['build', 'folder', 'ddev', 'backup', 'clone', 'remove', 'ragify', 'tests', 'checks', 'users'],
|
|
27
27
|
},
|
|
28
28
|
code: {
|
|
29
29
|
label: 'Code — packages and repositories',
|
|
30
30
|
description: 'Modules, themes, libraries, recipes: quality checks and knowledge tools, no site controls.',
|
|
31
|
-
actions: ['build', 'folder', 'backup', 'remove', '
|
|
31
|
+
actions: ['build', 'folder', 'backup', 'remove', 'ragify', 'checks'],
|
|
32
32
|
},
|
|
33
33
|
files: {
|
|
34
34
|
label: 'Files — documents and drafts',
|
package/workspaces.js
CHANGED
|
@@ -256,10 +256,6 @@ function findRemoveScript(dir) {
|
|
|
256
256
|
return findScript(dir, [/^cmd-tools-remove\.sh$/]);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
function findGraphifyScript(dir) {
|
|
260
|
-
return findScript(dir, [/^cmd-tools?-graphify\.sh$/]);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
259
|
function findRagifyScript(dir) {
|
|
264
260
|
return findScript(dir, [/^cmd-tools?-ragify\.sh$/]);
|
|
265
261
|
}
|
|
@@ -549,7 +545,7 @@ module.exports = {
|
|
|
549
545
|
workspaceDir,
|
|
550
546
|
findBackupScript,
|
|
551
547
|
findRemoveScript,
|
|
552
|
-
|
|
548
|
+
findScript,
|
|
553
549
|
findRagifyScript,
|
|
554
550
|
findBuilderScripts,
|
|
555
551
|
builderLabel,
|
package/actions/graphs.js
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Building a project's knowledge graph, and what you do with it afterwards.
|
|
3
|
-
*
|
|
4
|
-
* One module per area of the dashboard, each exporting the handlers it owns keyed by the path that
|
|
5
|
-
* reaches them. server.js merges the maps and dispatches; nothing here knows about routing, and
|
|
6
|
-
* adding an action is adding an entry rather than another branch in a 1,250-line function.
|
|
7
|
-
*
|
|
8
|
-
* Every handler takes the same context: the parsed form, the response, a `send` that writes an HTML
|
|
9
|
-
* fragment, and the workspace the request named.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
const fs = require('fs');
|
|
13
|
-
const path = require('path');
|
|
14
|
-
|
|
15
|
-
const { esc } = require('../html');
|
|
16
|
-
const { humanSize, humanTime } = require('../format');
|
|
17
|
-
const { ROOT, loadWorkspaces, workspaceDir } = require('../workspaces');
|
|
18
|
-
const {
|
|
19
|
-
run, jobFragment, runningJobKey, startJob, ddevProjectName, listProjects,
|
|
20
|
-
startInScreen, screenStartedHtml,
|
|
21
|
-
} = require('../jobs');
|
|
22
|
-
const { graphStoreDir } = require('../graphs');
|
|
23
|
-
const {
|
|
24
|
-
milvusProjectName, ragInstances, ragBindings, ragInstanceFor, ragWriteBinding,
|
|
25
|
-
ragCollectionName, milvusPost, ragCollections, ragInvalidate,
|
|
26
|
-
} = require('../rag');
|
|
27
|
-
const {
|
|
28
|
-
findTestReports, testReportFor, invalidateTestReports, testMediaHtml, testingStackOf,
|
|
29
|
-
} = require('../test-reports');
|
|
30
|
-
const { NAME_RE, SCRIPT_RE } = require('../validate');
|
|
31
|
-
const { commandBlock } = require('../command-block');
|
|
32
|
-
const { wsUrl } = require('../urls');
|
|
33
|
-
const { findGraphifyScript, findRagifyScript } = require('../workspaces');
|
|
34
|
-
const { DDEV_ACTIONS } = require('../ddev');
|
|
35
|
-
|
|
36
|
-
async function graphify({ form, res, send, workspace, pathname }) {
|
|
37
|
-
const dir = workspaceDir(workspace);
|
|
38
|
-
const script = findGraphifyScript(dir);
|
|
39
|
-
const projectName = String(form.projectName || '');
|
|
40
|
-
if (!script) return send('<div class="msg error">No graphify script in this workspace.</div>', 400);
|
|
41
|
-
if (!NAME_RE.test(projectName) || !listProjects(dir).includes(projectName)) {
|
|
42
|
-
return send('<div class="msg error">Unknown project.</div>', 400);
|
|
43
|
-
}
|
|
44
|
-
// graphify has no cross-process lock: two runs against one graphify-out/
|
|
45
|
-
// race on manifest.json, graph.json and cache/ast, and the loser's atomic
|
|
46
|
-
// replace silently wins, corrupting the incremental gate.
|
|
47
|
-
const jobKey = `graphify:${workspace}/${projectName}`;
|
|
48
|
-
if (runningJobKey(jobKey)) {
|
|
49
|
-
return send('<div class="msg error">A graph is already being built for this project.</div>', 409);
|
|
50
|
-
}
|
|
51
|
-
// Local AST extraction only: no API key, nothing leaves the machine. Big
|
|
52
|
-
// codebases take a while, so it gets the long build timeout.
|
|
53
|
-
const started = await startInScreen({ workspace, project: projectName, projectDir: dir, ownerDir: path.join(dir, projectName), verb: 'graphify',
|
|
54
|
-
cmdLine: `bash ${JSON.stringify(script)} ${JSON.stringify(projectName)}`, label: 'graphify' });
|
|
55
|
-
return send(screenStartedHtml(started, 'graphify', projectName), started.ok ? 200 : (started.already ? 409 : 500));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Commands the dashboard SHOWS rather than runs, with a copy button each.
|
|
59
|
-
//
|
|
60
|
-
// Two kinds end up here. `claude mcp add` writes ~/.claude.json — the CLI configuration of the
|
|
61
|
-
// person at the keyboard, not of this web request. The Upsun pulls need cloud credentials this
|
|
62
|
-
// container does not have and replace local data. Both are worth reading before they happen.
|
|
63
|
-
async function graph_mcp_command({ form, res, send, workspace, pathname }) {
|
|
64
|
-
const projectName = String(form.projectName || '');
|
|
65
|
-
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Unknown project.</div>', 400);
|
|
66
|
-
const graph = path.join(graphStoreDir(workspace, projectName), 'graph.json');
|
|
67
|
-
if (!fs.existsSync(graph)) return send('<div class="msg error">No graph yet — build it first.</div>', 409);
|
|
68
|
-
return send(commandBlock(
|
|
69
|
-
`Serve the ${workspace}/${projectName} graph to the Claude Code CLI`,
|
|
70
|
-
[`cd ${ROOT}/graphs && bash cmd-tools-graphs.sh ${workspace}/${projectName} -a mcp-register`,
|
|
71
|
-
`claude mcp add --scope user graph-${workspace}-${projectName} -- graphify-mcp ${graph.replace(ROOT, '~/workspace')}`],
|
|
72
|
-
'Run either one <strong>on the host</strong>, in a terminal. The first is the workspace command; '
|
|
73
|
-
+ 'the second is what it runs. stdio, so there is no port and no daemon — the CLI starts the server '
|
|
74
|
-
+ 'when a session needs it. Open a NEW session to see the tools; rebuilding the graph needs no '
|
|
75
|
-
+ 're-registration.'));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Delete a project's graph. Bounded to graphs/<workspace>/<project> — the whole point is that a
|
|
79
|
-
// graph is regenerable, so this is the one cheap thing to throw away. The project itself is
|
|
80
|
-
// never touched: nothing of the graph lives in it.
|
|
81
|
-
async function graph_remove({ form, res, send, workspace, pathname }) {
|
|
82
|
-
if (form.confirm !== 'yes') return send('<div class="msg error">Deleting requires confirmation.</div>', 400);
|
|
83
|
-
const projectName = String(form.projectName || '');
|
|
84
|
-
// The project may be gone — a graph outlives it, and that is exactly a graph worth deleting.
|
|
85
|
-
if (!NAME_RE.test(projectName)) {
|
|
86
|
-
return send('<div class="msg error">Unknown project.</div>', 400);
|
|
87
|
-
}
|
|
88
|
-
if (runningJobKey(`graphify:${workspace}/${projectName}`)) {
|
|
89
|
-
return send('<div class="msg error">A graph run is in progress for this project — wait for it to finish.</div>', 409);
|
|
90
|
-
}
|
|
91
|
-
const out = graphStoreDir(workspace, projectName);
|
|
92
|
-
let real;
|
|
93
|
-
try {
|
|
94
|
-
real = fs.realpathSync(out);
|
|
95
|
-
const base = fs.realpathSync(path.join(ROOT, 'graphs'));
|
|
96
|
-
if (!real.startsWith(base + path.sep)) throw new Error('outside');
|
|
97
|
-
} catch (_) { return send('<div class="msg assistant">No graph to delete.</div>'); }
|
|
98
|
-
fs.rmSync(real, { recursive: true, force: true });
|
|
99
|
-
res.setHeader('HX-Trigger', 'refresh-projects');
|
|
100
|
-
return send(`<div class="msg assistant">🗑️ Deleted the graph for <strong>${esc(projectName)}</strong>. Rebuild it any time from the Graph menu.</div>`);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
module.exports = {
|
|
104
|
-
'/actions/graphify': graphify,
|
|
105
|
-
'/actions/graph-mcp-command': graph_mcp_command,
|
|
106
|
-
'/actions/graph-remove': graph_remove,
|
|
107
|
-
};
|
package/actions/obsidian.js
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Building and removing a project's Obsidian vault.
|
|
3
|
-
*
|
|
4
|
-
* Both shell out to `cmd-tools-obsidian.sh` in the project's own workspace rather than
|
|
5
|
-
* reimplementing the conversion, for the same reason every other action here does: the button and
|
|
6
|
-
* the command line cannot then drift apart.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const fs = require('fs');
|
|
10
|
-
const path = require('path');
|
|
11
|
-
|
|
12
|
-
const { listProjects, workspaceDir } = require('../workspaces');
|
|
13
|
-
const { esc } = require('../html');
|
|
14
|
-
const { jobFragment, runningJobKey, startJob } = require('../jobs');
|
|
15
|
-
const { NAME_RE } = require('../validate');
|
|
16
|
-
const { graphStoreDir } = require('../graphs');
|
|
17
|
-
const { vaultDir } = require('../obsidian');
|
|
18
|
-
|
|
19
|
-
module.exports = {
|
|
20
|
-
'/actions/obsidian': async ({ form, res, send, workspace, req }) => {
|
|
21
|
-
const dir = workspaceDir(workspace);
|
|
22
|
-
const script = 'cmd-tools-obsidian.sh';
|
|
23
|
-
const projectName = String(form.projectName || '');
|
|
24
|
-
if (!fs.existsSync(path.join(dir, script))) return send('<div class="msg error">This workspace has no Obsidian script.</div>', 400);
|
|
25
|
-
if (!NAME_RE.test(projectName) || !listProjects(dir).includes(projectName)) {
|
|
26
|
-
return send('<div class="msg error">Unknown project.</div>', 400);
|
|
27
|
-
}
|
|
28
|
-
// Said here rather than left to the script, because the menu is reached from a row that may
|
|
29
|
-
// have had its graph deleted since the page was drawn.
|
|
30
|
-
if (!fs.existsSync(path.join(graphStoreDir(workspace, projectName), 'graph.json'))) {
|
|
31
|
-
return send('<div class="msg error">No graph to convert yet — build the graph first, from the same menu.</div>', 409);
|
|
32
|
-
}
|
|
33
|
-
// One writer per vault: the build writes to a sibling directory and swaps it in, so two runs
|
|
34
|
-
// would race on that swap and the loser's vault would vanish under it.
|
|
35
|
-
const jobKey = `obsidian:${workspace}/${projectName}`;
|
|
36
|
-
if (runningJobKey(jobKey)) {
|
|
37
|
-
return send('<div class="msg error">A vault is already being built for this project.</div>', 409);
|
|
38
|
-
}
|
|
39
|
-
const id = startJob(`🗂️ Obsidian <strong>${esc(projectName)}</strong>`, 'bash', [script, projectName], dir,
|
|
40
|
-
{ timeoutMs: 10 * 60 * 1000, echoLine: `bash ${script} ${projectName}`, key: jobKey });
|
|
41
|
-
return send(jobFragment(id).html);
|
|
42
|
-
},
|
|
43
|
-
|
|
44
|
-
'/actions/obsidian-remove': async ({ form, res, send, workspace }) => {
|
|
45
|
-
if (form.confirm !== 'yes') return send('<div class="msg error">Deleting requires confirmation.</div>', 400);
|
|
46
|
-
const projectName = String(form.projectName || '');
|
|
47
|
-
// The project may be long gone — a vault outlives it, and that is exactly a vault worth
|
|
48
|
-
// throwing away. So the name is validated but not looked up.
|
|
49
|
-
if (!NAME_RE.test(projectName)) return send('<div class="msg error">Unknown project.</div>', 400);
|
|
50
|
-
const dir = vaultDir(workspace, projectName);
|
|
51
|
-
if (!fs.existsSync(dir)) return send('<div class="msg">There was no vault to delete.</div>');
|
|
52
|
-
try {
|
|
53
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
54
|
-
} catch (e) {
|
|
55
|
-
return send(`<div class="msg error">Could not delete the vault: ${esc(e.message)}</div>`, 500);
|
|
56
|
-
}
|
|
57
|
-
return send('<div class="msg">Vault deleted. The graph it was made from is untouched.</div>');
|
|
58
|
-
},
|
|
59
|
-
};
|
package/graphs.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where a project's knowledge graph lives, and what is in it.
|
|
3
|
-
*
|
|
4
|
-
* The graph is kept in the graphs workspace rather than inside the project — it is tens of MB, it
|
|
5
|
-
* re-diffs entirely on every rebuild, and half the projects worth mapping are repositories we ship.
|
|
6
|
-
* Everything here reads that store; building one is `cmd-tools-graphify.sh`.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const fs = require('fs');
|
|
10
|
-
const path = require('path');
|
|
11
|
-
|
|
12
|
-
const { ROOT } = require('./workspaces');
|
|
13
|
-
|
|
14
|
-
// Where a project's graph lives: the graphs workspace, never inside the project. graphify only
|
|
15
|
-
// writes <project>/graphify-out, so cmd-tools-graphify.sh moves the result here — a graph is tens
|
|
16
|
-
// of MB, it re-diffs entirely on every rebuild, and half the projects worth mapping are
|
|
17
|
-
// repositories we ship.
|
|
18
|
-
function graphStoreDir(wsKey, project) {
|
|
19
|
-
return path.join(ROOT, 'graphs', wsKey, project);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Graph lookups per project row, memoised for 2s: projectRowsHtml is
|
|
23
|
-
// re-rendered on every job poll.
|
|
24
|
-
let _graphCache = new Map();
|
|
25
|
-
let _graphCacheAt = 0;
|
|
26
|
-
function hasGraph(wsKey, project, file) {
|
|
27
|
-
const now = Date.now();
|
|
28
|
-
if (now - _graphCacheAt > 2000) { _graphCache = new Map(); _graphCacheAt = now; }
|
|
29
|
-
const k = `${wsKey}/${project}/${file}`;
|
|
30
|
-
if (!_graphCache.has(k)) _graphCache.set(k, fs.existsSync(path.join(graphStoreDir(wsKey, project), file)));
|
|
31
|
-
return _graphCache.get(k);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Node and edge counts, memoised on the graph file's identity so paging a long list re-reads
|
|
35
|
-
// nothing. Keyed on mtime+size: a rebuild changes both.
|
|
36
|
-
const _graphCounts = new Map();
|
|
37
|
-
function graphCounts(file, stat) {
|
|
38
|
-
if (!stat) return { nodes: null, links: null };
|
|
39
|
-
const key = `${file}:${stat.mtimeMs}:${stat.size}`;
|
|
40
|
-
if (_graphCounts.has(key)) return _graphCounts.get(key);
|
|
41
|
-
let counts = { nodes: null, links: null };
|
|
42
|
-
try {
|
|
43
|
-
const d = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
44
|
-
counts = { nodes: (d.nodes || []).length, links: (d.links || d.edges || []).length };
|
|
45
|
-
} catch (_) { /* unreadable or half-written */ }
|
|
46
|
-
_graphCounts.set(key, counts);
|
|
47
|
-
return counts;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Every graph in the store, as <workspace>/<project> rows. Reads the two levels the build writes
|
|
51
|
-
// and nothing else, so a stray file in graphs/ cannot become a row.
|
|
52
|
-
function listGraphs() {
|
|
53
|
-
const base = path.join(ROOT, 'graphs');
|
|
54
|
-
const rows = [];
|
|
55
|
-
let workspaces = [];
|
|
56
|
-
try {
|
|
57
|
-
workspaces = fs.readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
58
|
-
} catch (_) {
|
|
59
|
-
return rows;
|
|
60
|
-
}
|
|
61
|
-
for (const ws of workspaces) {
|
|
62
|
-
let projects = [];
|
|
63
|
-
try {
|
|
64
|
-
projects = fs.readdirSync(path.join(base, ws.name), { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
65
|
-
} catch (_) { continue; }
|
|
66
|
-
for (const pr of projects) {
|
|
67
|
-
const dir = path.join(base, ws.name, pr.name);
|
|
68
|
-
const stat = (f) => { try { return fs.statSync(path.join(dir, f)); } catch (_) { return null; } };
|
|
69
|
-
const json = stat('graph.json');
|
|
70
|
-
// The counts come from graph.json itself — .graphify_build.json carries only the excludes,
|
|
71
|
-
// so the numbers were rendering empty. Parsing is cheap enough (20ms for a 2,430-node graph)
|
|
72
|
-
// and the result is memoised on the file's mtime and size, so a re-render costs nothing.
|
|
73
|
-
const nodes_links = graphCounts(path.join(dir, 'graph.json'), json);
|
|
74
|
-
const nodes = nodes_links.nodes;
|
|
75
|
-
const links = nodes_links.links;
|
|
76
|
-
let size = 0;
|
|
77
|
-
const walk = (d) => {
|
|
78
|
-
let entries = [];
|
|
79
|
-
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
|
|
80
|
-
for (const e of entries) {
|
|
81
|
-
const full = path.join(d, e.name);
|
|
82
|
-
if (e.isDirectory()) walk(full);
|
|
83
|
-
else { try { size += fs.statSync(full).size; } catch (_) { /* gone */ } }
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
walk(dir);
|
|
87
|
-
rows.push({
|
|
88
|
-
workspace: ws.name,
|
|
89
|
-
project: pr.name,
|
|
90
|
-
name: `${ws.name}/${pr.name}`,
|
|
91
|
-
mtime: json ? json.mtime : new Date(0),
|
|
92
|
-
size,
|
|
93
|
-
nodes,
|
|
94
|
-
links,
|
|
95
|
-
hasHtml: !!stat('graph.html'),
|
|
96
|
-
hasReport: !!stat('GRAPH_REPORT.md'),
|
|
97
|
-
projectExists: fs.existsSync(path.join(ROOT, ws.name, pr.name)),
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return rows;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
module.exports = { graphStoreDir, hasGraph, graphCounts, listGraphs };
|
package/obsidian.js
DELETED
|
@@ -1,405 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The Obsidian vault a graphed project can have, and the menu entries that make and read it.
|
|
3
|
-
*
|
|
4
|
-
* A vault is made FROM the graph — `cmd-tools-obsidian.sh` reads `graph.json` and writes notes,
|
|
5
|
-
* wikilinks and a JSON Canvas — so it lives inside the graph store at
|
|
6
|
-
* `graphs/<workspace>/<project>/obsidian/` and is thrown away with it. It also means the entries
|
|
7
|
-
* belong in the Graph menu rather than a menu of their own: without a graph there is nothing to
|
|
8
|
-
* convert, and a second button that is disabled most of the time only adds noise to the row.
|
|
9
|
-
*
|
|
10
|
-
* `views.js` builds the row's menu from this and `server.js` serves what the menu points at, so
|
|
11
|
-
* the menu cannot offer a file the server will not find.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
const fs = require('fs');
|
|
15
|
-
const path = require('path');
|
|
16
|
-
|
|
17
|
-
const { ROOT } = require('./workspaces');
|
|
18
|
-
const { esc } = require('./html');
|
|
19
|
-
const { graphStoreDir } = require('./graphs');
|
|
20
|
-
|
|
21
|
-
// Where a project's vault lives. Inside its graph, never inside the project.
|
|
22
|
-
function vaultDir(wsKey, project) {
|
|
23
|
-
return path.join(graphStoreDir(wsKey, project), 'obsidian');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// The index note is named after the project, with the same sanitising the builder applies, so the
|
|
27
|
-
// server and the builder agree on one filename.
|
|
28
|
-
function indexNote(project) {
|
|
29
|
-
return `${String(project).replace(/[\\/:*?"<>|[\]#^]+/g, '-').replace(/^[ .-]+|[ .-]+$/g, '').slice(0, 120) || 'unnamed'}.md`;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Same memo window as the graph's: a row re-renders once a second while a job runs, and asking the
|
|
33
|
-
// filesystem per render turns a list of twenty projects into a stream of stat calls.
|
|
34
|
-
let _cache = new Map();
|
|
35
|
-
let _cacheAt = 0;
|
|
36
|
-
function hasVault(wsKey, project) {
|
|
37
|
-
const now = Date.now();
|
|
38
|
-
if (now - _cacheAt > 2000) { _cache = new Map(); _cacheAt = now; }
|
|
39
|
-
const k = `${wsKey}/${project}`;
|
|
40
|
-
if (!_cache.has(k)) _cache.set(k, fs.existsSync(path.join(vaultDir(wsKey, project), '.obsidian')));
|
|
41
|
-
return _cache.get(k);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* The Obsidian entries of the Graph menu.
|
|
46
|
-
*
|
|
47
|
-
* Returned as a list of `<li>` strings rather than a menu, because they hang off the Graph button:
|
|
48
|
-
* `graphMenuHtml` decides where they sit and only asks for them once a graph exists.
|
|
49
|
-
*/
|
|
50
|
-
function obsidianMenuItems(key, project, vals, busy) {
|
|
51
|
-
const built = hasVault(key, project);
|
|
52
|
-
const items = ['<li class="uk-nav-divider"></li>', '<li class="uk-nav-header">Obsidian</li>'];
|
|
53
|
-
|
|
54
|
-
items.push(`<li><a href hx-post="/actions/obsidian" ${vals()} class="${busy ? 'uk-disabled' : ''}"><span uk-icon="icon: file-edit; ratio: .7"></span> ${built ? 'Rebuild the vault' : 'Build the vault'}${busy ? ' (running…)' : ''}</a></li>`);
|
|
55
|
-
|
|
56
|
-
if (built) {
|
|
57
|
-
// A desktop protocol link, not an HTTP one: it hands the absolute path to the Obsidian app on
|
|
58
|
-
// the machine running the browser. Every reserved character has to be encoded — an unescaped
|
|
59
|
-
// slash makes Obsidian read the path as a vault name and open the wrong thing, or nothing.
|
|
60
|
-
const uri = `obsidian://open?path=${encodeURIComponent(vaultDir(key, project))}`;
|
|
61
|
-
items.push(`<li><a href="${esc(uri)}" title="Opens the Obsidian app on this machine"><span uk-icon="icon: link-external; ratio: .7"></span> Open in Obsidian</a></li>`);
|
|
62
|
-
// The reader first: the vault lives on the machine running the dashboard, which is not
|
|
63
|
-
// necessarily the machine looking at it, and this needs nothing installed.
|
|
64
|
-
items.push(`<li><a href hx-get="/obsidian/${esc(key)}/${esc(project)}/frame?what=view" hx-target="#editor-modal-body" hx-swap="innerHTML"><span uk-icon="icon: file-text; ratio: .7"></span> Read the notes</a></li>`);
|
|
65
|
-
items.push(`<li><a href hx-get="/obsidian/${esc(key)}/${esc(project)}/frame?what=canvas" hx-target="#editor-modal-body" hx-swap="innerHTML"><span uk-icon="icon: git-fork; ratio: .7"></span> Open the map</a></li>`);
|
|
66
|
-
items.push(`<li><a href="/obsidian/${esc(key)}/${esc(project)}/download" title="The vault as a .tar.gz, to open on another machine"><span uk-icon="icon: download; ratio: .7"></span> Download the vault</a></li>`);
|
|
67
|
-
items.push(`<li><a href class="uk-text-danger arm-step" data-armed="0" hx-post="/actions/obsidian-remove" ${vals(',"confirm":"yes"')} hx-trigger="confirmed-remove"><span uk-icon="icon: trash; ratio: .7"></span> Delete the vault</a></li>`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return items.join('');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
module.exports = { vaultDir, hasVault, obsidianMenuItems, indexNote };
|
|
74
|
-
|
|
75
|
-
/* ---------------------------------------------------------------------------
|
|
76
|
-
* The web reader.
|
|
77
|
-
*
|
|
78
|
-
* Obsidian is a desktop app, and the vault lives on the machine running the
|
|
79
|
-
* dashboard — which is not necessarily the machine looking at it. So the vault is
|
|
80
|
-
* also readable in the browser: the notes with their wikilinks working, and the
|
|
81
|
-
* canvas drawn as the map it describes.
|
|
82
|
-
*
|
|
83
|
-
* Everything is served from this app. No CDN, because the graph viewer already
|
|
84
|
-
* needs the internet for vis-network and having one thing in the dashboard that
|
|
85
|
-
* stops working on a train is enough.
|
|
86
|
-
* ------------------------------------------------------------------------- */
|
|
87
|
-
|
|
88
|
-
// The notes, by name. One pass over the vault, because every view needs the full
|
|
89
|
-
// set: the list, and the link resolver that decides which [[targets]] are real.
|
|
90
|
-
function vaultNotes(wsKey, project) {
|
|
91
|
-
const dir = vaultDir(wsKey, project);
|
|
92
|
-
const found = new Map();
|
|
93
|
-
const walk = (d, rel) => {
|
|
94
|
-
let entries;
|
|
95
|
-
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch (_) { return; }
|
|
96
|
-
for (const e of entries) {
|
|
97
|
-
if (e.name === '.obsidian') continue;
|
|
98
|
-
const full = path.join(d, e.name);
|
|
99
|
-
if (e.isDirectory()) walk(full, path.join(rel, e.name));
|
|
100
|
-
else if (e.name.endsWith('.md')) found.set(e.name.slice(0, -3), path.join(rel, e.name));
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
walk(dir, '');
|
|
104
|
-
return found;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The note shape this workspace writes, rendered.
|
|
109
|
-
*
|
|
110
|
-
* Not a general markdown parser — a parser that handles every construct would be a
|
|
111
|
-
* dependency and a much larger surface, and these notes are written by
|
|
112
|
-
* fun-obsidian.sh, so their shape is known: frontmatter, a heading, a source line,
|
|
113
|
-
* two link lists, a rule and a footer.
|
|
114
|
-
*
|
|
115
|
-
* Everything is escaped first and marked up second, so a note whose content happens
|
|
116
|
-
* to contain markup is displayed rather than obeyed.
|
|
117
|
-
*/
|
|
118
|
-
function renderNote(md, linkHref, exists) {
|
|
119
|
-
let meta = {};
|
|
120
|
-
let body = md;
|
|
121
|
-
const fm = md.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
122
|
-
if (fm) {
|
|
123
|
-
body = md.slice(fm[0].length);
|
|
124
|
-
for (const line of fm[1].split('\n')) {
|
|
125
|
-
const m = line.match(/^([a-z_]+):\s*(.*)$/i);
|
|
126
|
-
if (m) meta[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const inline = (t) => esc(t)
|
|
131
|
-
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
132
|
-
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
133
|
-
.replace(/\[\[([^\]|#]+)(\|[^\]]+)?\]\]/g, (_, name) => {
|
|
134
|
-
const n = name.trim();
|
|
135
|
-
return exists(n)
|
|
136
|
-
? `<a class="wl" href="${linkHref(n)}">${esc(n)}</a>`
|
|
137
|
-
: `<span class="wl wl-missing" title="No note of that name in this vault">${esc(n)}</span>`;
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
const out = [];
|
|
141
|
-
let inList = false;
|
|
142
|
-
for (const raw of body.split('\n')) {
|
|
143
|
-
const line = raw.trimEnd();
|
|
144
|
-
if (/^-{3,}$/.test(line)) { if (inList) { out.push('</ul>'); inList = false; } out.push('<hr>'); continue; }
|
|
145
|
-
const li = line.match(/^-\s+(.*)$/);
|
|
146
|
-
if (li) {
|
|
147
|
-
if (!inList) { out.push('<ul class="links">'); inList = true; }
|
|
148
|
-
out.push(`<li>${inline(li[1])}</li>`);
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (inList) { out.push('</ul>'); inList = false; }
|
|
152
|
-
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
153
|
-
if (h) { out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); continue; }
|
|
154
|
-
if (!line) continue;
|
|
155
|
-
out.push(`<p>${inline(line)}</p>`);
|
|
156
|
-
}
|
|
157
|
-
if (inList) out.push('</ul>');
|
|
158
|
-
return { meta, html: out.join('\n') };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// One stylesheet for both views, using the dashboard's own theme tokens so the reader
|
|
162
|
-
// follows whatever theme is set instead of picking its own colours.
|
|
163
|
-
const VAULT_CSS = `
|
|
164
|
-
body { background: var(--surface); color: var(--ink); margin: 0; }
|
|
165
|
-
.vault { display: grid; grid-template-columns: 16rem 1fr; gap: 0; height: calc(100vh - 3.5rem); }
|
|
166
|
-
.vault-list { border-right: 1px solid var(--line); overflow-y: auto; padding: .5rem; }
|
|
167
|
-
.vault-list input { width: 100%; margin-bottom: .5rem; padding: .35rem .5rem;
|
|
168
|
-
border: 1px solid var(--line); border-radius: 4px; background: var(--surface);
|
|
169
|
-
color: var(--ink); }
|
|
170
|
-
.vault-list a { display: block; padding: .25rem .4rem; border-radius: 4px;
|
|
171
|
-
color: var(--ink); text-decoration: none; font-size: .82rem; overflow: hidden;
|
|
172
|
-
text-overflow: ellipsis; white-space: nowrap; }
|
|
173
|
-
.vault-list a:hover { background: var(--surface-sunken); }
|
|
174
|
-
.vault-list a.on { background: var(--link); color: var(--danger-ink); }
|
|
175
|
-
.vault-body { overflow-y: auto; padding: 1.25rem 1.75rem; }
|
|
176
|
-
.vault-body h1 { font-size: 1.5rem; margin: 0 0 .75rem; }
|
|
177
|
-
.vault-body h2 { font-size: 1rem; margin: 1.25rem 0 .4rem; color: var(--text-subtle); }
|
|
178
|
-
.vault-body ul.links { list-style: none; padding-left: 0; }
|
|
179
|
-
.vault-body ul.links li { padding: .15rem 0; }
|
|
180
|
-
.wl { color: var(--link); text-decoration: none; border-bottom: 1px solid transparent; }
|
|
181
|
-
.wl:hover { border-bottom-color: var(--link); }
|
|
182
|
-
.wl-missing { color: var(--text-subtle); border-bottom: 1px dotted var(--text-subtle); }
|
|
183
|
-
.vault-meta { font-size: .78rem; color: var(--text-subtle); margin-bottom: 1rem; }
|
|
184
|
-
.vault-meta code { background: var(--surface-sunken); padding: .05rem .3rem; border-radius: 3px; }
|
|
185
|
-
.vault-bar { display: flex; gap: .5rem; align-items: center; padding: .5rem .75rem;
|
|
186
|
-
border-bottom: 1px solid var(--line); background: var(--surface-raised); }
|
|
187
|
-
.vault-bar a, .vault-bar span { font-size: .82rem; }
|
|
188
|
-
#cv { width: 100%; height: calc(100vh - 7rem); background: var(--surface); cursor: grab; }
|
|
189
|
-
#cv:active { cursor: grabbing; }
|
|
190
|
-
#cv text { font: 15px system-ui, sans-serif; fill: var(--ink); pointer-events: none; }
|
|
191
|
-
#cv .n rect { fill: var(--surface-raised); stroke: var(--line); stroke-width: 1.5; }
|
|
192
|
-
#cv .n:hover rect { stroke: var(--link); stroke-width: 3; }
|
|
193
|
-
#cv .e { stroke: var(--text-subtle); stroke-width: 1.5; fill: none; opacity: .55; }
|
|
194
|
-
.vault-bar button { font-size: .8rem; padding: .15rem .55rem; border: 1px solid var(--line);
|
|
195
|
-
border-radius: 4px; background: var(--surface-raised); color: var(--ink); cursor: pointer; }
|
|
196
|
-
.vault-bar button:hover { border-color: var(--link); }
|
|
197
|
-
`;
|
|
198
|
-
|
|
199
|
-
// A page frame that inherits the dashboard's assembled theme sheet.
|
|
200
|
-
function vaultPage(title, cssVersion, bodyHtml) {
|
|
201
|
-
return `<!doctype html><html><head><meta charset="utf-8">
|
|
202
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
203
|
-
<title>${esc(title)}</title>
|
|
204
|
-
<link rel="icon" href="/favicon.ico">
|
|
205
|
-
<link rel="stylesheet" href="/style.css?v=${esc(String(cssVersion))}">
|
|
206
|
-
<style>${VAULT_CSS}</style>
|
|
207
|
-
</head><body>${bodyHtml}</body></html>`;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** The note browser: the list, and one note with its links live. */
|
|
211
|
-
function vaultView(wsKey, project, note, cssVersion) {
|
|
212
|
-
const notes = vaultNotes(wsKey, project);
|
|
213
|
-
const names = [...notes.keys()].sort((a, b) => a.localeCompare(b));
|
|
214
|
-
const current = notes.has(note) ? note : (names.includes(project) ? project : names[0]);
|
|
215
|
-
const base = `/obsidian/${encodeURIComponent(wsKey)}/${encodeURIComponent(project)}/view`;
|
|
216
|
-
const href = (n) => `${base}?note=${encodeURIComponent(n)}`;
|
|
217
|
-
|
|
218
|
-
let rendered = { meta: {}, html: '<p class="uk-text-meta">This vault has no notes.</p>' };
|
|
219
|
-
if (current) {
|
|
220
|
-
try {
|
|
221
|
-
rendered = renderNote(fs.readFileSync(path.join(vaultDir(wsKey, project), notes.get(current)), 'utf8'),
|
|
222
|
-
href, (n) => notes.has(n));
|
|
223
|
-
} catch (_) {
|
|
224
|
-
rendered = { meta: {}, html: '<p class="msg error">That note could not be read.</p>' };
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const meta = rendered.meta;
|
|
229
|
-
const metaLine = [
|
|
230
|
-
meta.source_file ? `<code>${esc(meta.source_file)}</code>` : '',
|
|
231
|
-
meta.source_location ? esc(meta.source_location) : '',
|
|
232
|
-
meta.cluster !== undefined ? `cluster ${esc(meta.cluster)}` : '',
|
|
233
|
-
meta.tags ? esc(meta.tags) : '',
|
|
234
|
-
].filter(Boolean).join(' · ');
|
|
235
|
-
|
|
236
|
-
return vaultPage(`${current || project} · ${project}`, cssVersion, `
|
|
237
|
-
<div class="vault-bar">
|
|
238
|
-
<strong>${esc(project)}</strong>
|
|
239
|
-
<span class="uk-text-meta">${names.length} notes</span>
|
|
240
|
-
<a href="/obsidian/${esc(wsKey)}/${esc(project)}/canvas">Map</a>
|
|
241
|
-
<a href="/obsidian/${esc(wsKey)}/${esc(project)}/download">Download</a>
|
|
242
|
-
<a href="obsidian://open?path=${encodeURIComponent(vaultDir(wsKey, project))}">Open in the Obsidian app</a>
|
|
243
|
-
<span style="margin-left:auto"><a href="/">${esc(wsKey)} workspace</a></span>
|
|
244
|
-
</div>
|
|
245
|
-
<div class="vault">
|
|
246
|
-
<nav class="vault-list">
|
|
247
|
-
<input id="q" type="search" placeholder="Filter ${names.length} notes…" autocomplete="off">
|
|
248
|
-
<div id="ls">
|
|
249
|
-
${names.map((n) => `<a href="${href(n)}" class="${n === current ? 'on' : ''}" data-n="${esc(n.toLowerCase())}">${esc(n)}</a>`).join('')}
|
|
250
|
-
</div>
|
|
251
|
-
</nav>
|
|
252
|
-
<article class="vault-body">
|
|
253
|
-
${metaLine ? `<div class="vault-meta">${metaLine}</div>` : ''}
|
|
254
|
-
${rendered.html}
|
|
255
|
-
</article>
|
|
256
|
-
</div>
|
|
257
|
-
<script>
|
|
258
|
-
// Filtering is the one thing worth scripting here: every link is a real URL, so
|
|
259
|
-
// the reader works with scripting off apart from this.
|
|
260
|
-
var q = document.getElementById('q'), ls = document.getElementById('ls');
|
|
261
|
-
q.addEventListener('input', function () {
|
|
262
|
-
var v = q.value.toLowerCase();
|
|
263
|
-
for (var i = 0; i < ls.children.length; i++) {
|
|
264
|
-
var a = ls.children[i];
|
|
265
|
-
a.style.display = a.dataset.n.indexOf(v) === -1 ? 'none' : '';
|
|
266
|
-
}
|
|
267
|
-
});
|
|
268
|
-
</script>`);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* The canvas, drawn.
|
|
273
|
-
*
|
|
274
|
-
* The .canvas file already carries a position for every node — the builder laid them
|
|
275
|
-
* out cluster by cluster — so this is a straight projection into SVG rather than a
|
|
276
|
-
* layout algorithm. That also means the web map and the map Obsidian's Canvas shows
|
|
277
|
-
* are the same picture, which is the point of storing coordinates at all.
|
|
278
|
-
*/
|
|
279
|
-
function vaultCanvas(wsKey, project, cssVersion) {
|
|
280
|
-
const file = path.join(vaultDir(wsKey, project), `${indexNote(project).slice(0, -3)}.canvas`);
|
|
281
|
-
let canvas;
|
|
282
|
-
try {
|
|
283
|
-
canvas = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
284
|
-
} catch (_) {
|
|
285
|
-
return vaultPage(`${project} map`, cssVersion,
|
|
286
|
-
'<div class="vault-bar"><span class="msg error">This vault has no canvas. Rebuild it.</span></div>');
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
const nodes = canvas.nodes || [];
|
|
290
|
-
const edges = canvas.edges || [];
|
|
291
|
-
const at = new Map(nodes.map((n) => [n.id, n]));
|
|
292
|
-
const pad = 60;
|
|
293
|
-
const xs = nodes.map((n) => n.x); const ys = nodes.map((n) => n.y);
|
|
294
|
-
const minX = Math.min(...xs, 0) - pad; const minY = Math.min(...ys, 0) - pad;
|
|
295
|
-
const w = Math.max(...xs.map((x, i) => x + nodes[i].width), 100) - minX + pad;
|
|
296
|
-
const h = Math.max(...ys.map((y, i) => y + nodes[i].height), 100) - minY + pad;
|
|
297
|
-
|
|
298
|
-
// The colour a node carries is a JSON Canvas preset number, not a CSS colour.
|
|
299
|
-
const PRESET = { 1: '#e05252', 2: '#e08c52', 3: '#d9c04a', 4: '#4fa373', 5: '#4f9fa3', 6: '#8b6fc4' };
|
|
300
|
-
|
|
301
|
-
const view = `/obsidian/${encodeURIComponent(wsKey)}/${encodeURIComponent(project)}/view`;
|
|
302
|
-
const noteOf = (n) => (n.file || '').replace(/^notes\//, '').replace(/\.md$/, '');
|
|
303
|
-
|
|
304
|
-
const edgeSvg = edges.map((e) => {
|
|
305
|
-
const a = at.get(e.fromNode); const b = at.get(e.toNode);
|
|
306
|
-
if (!a || !b) return '';
|
|
307
|
-
const x1 = a.x + a.width; const y1 = a.y + a.height / 2;
|
|
308
|
-
const x2 = b.x; const y2 = b.y + b.height / 2;
|
|
309
|
-
const mid = (x1 + x2) / 2;
|
|
310
|
-
return `<path class="e" d="M${x1},${y1} C${mid},${y1} ${mid},${y2} ${x2},${y2}"/>`;
|
|
311
|
-
}).join('');
|
|
312
|
-
|
|
313
|
-
const nodeSvg = nodes.map((n) => {
|
|
314
|
-
const name = noteOf(n);
|
|
315
|
-
const label = name.length > 30 ? `${name.slice(0, 29)}…` : name;
|
|
316
|
-
const stroke = PRESET[n.color] || '';
|
|
317
|
-
return `<a class="n" href="${view}?note=${encodeURIComponent(name)}"><title>${esc(name)}</title>
|
|
318
|
-
<rect x="${n.x}" y="${n.y}" width="${n.width}" height="${n.height}" rx="6"${stroke ? ` style="stroke:${stroke}"` : ''}/>
|
|
319
|
-
<text x="${n.x + 12}" y="${n.y + n.height / 2 + 4}">${esc(label)}</text></a>`;
|
|
320
|
-
}).join('');
|
|
321
|
-
|
|
322
|
-
return vaultPage(`${project} map`, cssVersion, `
|
|
323
|
-
<div class="vault-bar">
|
|
324
|
-
<strong>${esc(project)}</strong>
|
|
325
|
-
<span class="uk-text-meta">${nodes.length} on the map · ${edges.length} links</span>
|
|
326
|
-
<a href="${view}">Notes</a>
|
|
327
|
-
<a href="obsidian://open?path=${encodeURIComponent(vaultDir(wsKey, project))}">Open in the Obsidian app</a>
|
|
328
|
-
<button id="fit" type="button">Fit</button>
|
|
329
|
-
<button id="one" type="button">100%</button>
|
|
330
|
-
<span class="uk-text-meta" style="margin-left:auto">drag to pan · scroll to zoom · click a node to read it</span>
|
|
331
|
-
</div>
|
|
332
|
-
<svg id="cv" viewBox="${minX} ${minY} ${w} ${h}">
|
|
333
|
-
<g id="vp">${edgeSvg}${nodeSvg}</g>
|
|
334
|
-
</svg>
|
|
335
|
-
<script>
|
|
336
|
-
// Pan and zoom by moving the viewBox, so the drawing stays vector-sharp and the
|
|
337
|
-
// links keep working — a CSS transform would break hit testing on the anchors.
|
|
338
|
-
(function () {
|
|
339
|
-
var svg = document.getElementById('cv');
|
|
340
|
-
var full = { x: ${minX}, y: ${minY}, w: ${w}, h: ${h} };
|
|
341
|
-
var vb = { x: full.x, y: full.y, w: full.w, h: full.h };
|
|
342
|
-
function apply() { svg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h); }
|
|
343
|
-
function set(o) { vb.x = o.x; vb.y = o.y; vb.w = o.w; vb.h = o.h;
|
|
344
|
-
svg.setAttribute('viewBox', vb.x + ' ' + vb.y + ' ' + vb.w + ' ' + vb.h); }
|
|
345
|
-
function fit() { set(full); }
|
|
346
|
-
// 100%: one user unit per CSS pixel, anchored at the top-left of the map.
|
|
347
|
-
function oneToOne() {
|
|
348
|
-
set({ x: full.x, y: full.y, w: svg.clientWidth, h: svg.clientHeight });
|
|
349
|
-
}
|
|
350
|
-
document.getElementById('fit').addEventListener('click', fit);
|
|
351
|
-
document.getElementById('one').addEventListener('click', oneToOne);
|
|
352
|
-
// Fit only when the whole map already fits comfortably; otherwise open readable.
|
|
353
|
-
if (full.w <= svg.clientWidth * 1.4 && full.h <= svg.clientHeight * 1.4) { fit(); } else { oneToOne(); }
|
|
354
|
-
var drag = null, moved = false;
|
|
355
|
-
svg.addEventListener('pointerdown', function (e) { drag = { x: e.clientX, y: e.clientY }; moved = false; });
|
|
356
|
-
window.addEventListener('pointerup', function () { drag = null; });
|
|
357
|
-
window.addEventListener('pointermove', function (e) {
|
|
358
|
-
if (!drag) return;
|
|
359
|
-
var k = vb.w / svg.clientWidth;
|
|
360
|
-
vb.x -= (e.clientX - drag.x) * k; vb.y -= (e.clientY - drag.y) * k;
|
|
361
|
-
drag = { x: e.clientX, y: e.clientY };
|
|
362
|
-
if (Math.abs(e.movementX) + Math.abs(e.movementY) > 2) moved = true;
|
|
363
|
-
apply();
|
|
364
|
-
});
|
|
365
|
-
// A drag that ends over a node must not also open it.
|
|
366
|
-
svg.addEventListener('click', function (e) { if (moved) { e.preventDefault(); moved = false; } }, true);
|
|
367
|
-
svg.addEventListener('wheel', function (e) {
|
|
368
|
-
e.preventDefault();
|
|
369
|
-
var f = e.deltaY > 0 ? 1.12 : 0.89;
|
|
370
|
-
var r = svg.getBoundingClientRect();
|
|
371
|
-
var mx = vb.x + (e.clientX - r.left) / r.width * vb.w;
|
|
372
|
-
var my = vb.y + (e.clientY - r.top) / r.height * vb.h;
|
|
373
|
-
vb.x = mx - (mx - vb.x) * f; vb.y = my - (my - vb.y) * f;
|
|
374
|
-
vb.w *= f; vb.h *= f;
|
|
375
|
-
apply();
|
|
376
|
-
}, { passive: false });
|
|
377
|
-
})();
|
|
378
|
-
</script>`);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* The modal wrapper: one of the two views in an iframe, filling the dialog.
|
|
383
|
-
*
|
|
384
|
-
* An iframe rather than the page's markup inlined, because both views are whole documents — own
|
|
385
|
-
* stylesheet link, own script, own pan/zoom state — and splicing that into the dashboard would
|
|
386
|
-
* put two competing sets of handlers on one document.
|
|
387
|
-
*/
|
|
388
|
-
function vaultFrame(wsKey, project, what) {
|
|
389
|
-
const src = `/obsidian/${encodeURIComponent(wsKey)}/${encodeURIComponent(project)}/${what === 'canvas' ? 'canvas' : 'view'}`;
|
|
390
|
-
const other = what === 'canvas' ? 'view' : 'canvas';
|
|
391
|
-
return `<div class="uk-flex uk-flex-between uk-flex-middle uk-margin-small-bottom">
|
|
392
|
-
<h3 class="uk-margin-remove">${esc(project)} <span class="uk-text-meta">${what === 'canvas' ? 'map' : 'notes'}</span></h3>
|
|
393
|
-
<div>
|
|
394
|
-
<a class="uk-button uk-button-default uk-button-small" href hx-get="/obsidian/${esc(wsKey)}/${esc(project)}/frame?what=${other}" hx-target="#editor-modal-body" hx-swap="innerHTML">${other === 'canvas' ? 'Map' : 'Notes'}</a>
|
|
395
|
-
<a class="uk-button uk-button-default uk-button-small" href="${esc(src)}" target="_blank">Open full screen</a>
|
|
396
|
-
</div>
|
|
397
|
-
</div>
|
|
398
|
-
<iframe src="${esc(src)}" title="${esc(project)}" style="width:100%;height:74vh;border:1px solid var(--line);border-radius:4px;background:var(--surface)"></iframe>`;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
module.exports.vaultFrame = vaultFrame;
|
|
402
|
-
module.exports.vaultNotes = vaultNotes;
|
|
403
|
-
module.exports.renderNote = renderNote;
|
|
404
|
-
module.exports.vaultView = vaultView;
|
|
405
|
-
module.exports.vaultCanvas = vaultCanvas;
|