@webship.co/workspace-graph 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.
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # The graphs workspace: every project's knowledge graph, one directory per project at
4
+ # graphs/<workspace>/<project>/. cmd-tools-graphify.sh builds them and moves them here;
5
+ # this command is what you do with them afterwards.
6
+ #
7
+ # The graph is NOT kept inside the project on purpose — it is tens of MB, Leiden clustering is not
8
+ # bit-stable so it re-diffs entirely on every rebuild, and half the projects worth mapping are
9
+ # repositories we ship.
10
+
11
+ # Resolve a name to one or more <workspace>/<project> directories INSIDE the store.
12
+ #
13
+ # Every path this returns is checked, because the caller deletes what it is handed: a name is
14
+ # refused if it contains `..`, if it does not resolve to exactly two segments under the store, or if
15
+ # the directory holds no graph.json. Without that, `-a remove '../modules/toc_api'` resolved to a
16
+ # real project checkout and deleted it — verified, then fixed.
17
+ function graphs_resolve() {
18
+ local wanted="$1" base dir real;
19
+ base="$(cd "${WORKSPACE_ROOT}/graphs" 2>/dev/null && pwd -P)" || return 0 ;
20
+
21
+ # A candidate is emitted only if it survives containment, shape and content checks.
22
+ _graphs_emit() {
23
+ local candidate="$1" resolved;
24
+ resolved="$(realpath -m "${candidate}" 2>/dev/null)" || return 0 ;
25
+ case "${resolved}" in
26
+ "${base}/"*) ;;
27
+ *) return 0 ;;
28
+ esac
29
+ # Exactly <workspace>/<project> under the store, and it must actually hold a graph.
30
+ [ "$(printf '%s' "${resolved#${base}/}" | tr -cd '/' | wc -c)" -eq 1 ] || return 0 ;
31
+ [ -f "${resolved}/graph.json" ] || return 0 ;
32
+ printf '%s\n' "${resolved}";
33
+ }
34
+
35
+ case "${wanted}" in
36
+ *..*) return 0 ;;
37
+ esac
38
+
39
+ if [ "${wanted}" == '_all_' ] ; then
40
+ while read -r dir ; do
41
+ [ -n "${dir}" ] && _graphs_emit "${dir}" ;
42
+ done <<< "$(find "${base}" -mindepth 2 -maxdepth 2 -type d 2>/dev/null | sort)"
43
+ return 0 ;
44
+ fi
45
+
46
+ case "${wanted}" in
47
+ */*) _graphs_emit "${base}/${wanted}" ;;
48
+ *) # A bare project name can match in two workspaces; the caller decides what to do with that.
49
+ while read -r dir ; do
50
+ [ -n "${dir}" ] && _graphs_emit "${dir}" ;
51
+ done <<< "$(find "${base}" -mindepth 2 -maxdepth 2 -type d -name "${wanted}" 2>/dev/null | sort)" ;;
52
+ esac
53
+ }
54
+
55
+ function graphs_list() {
56
+ local base="${WORKSPACE_ROOT}/graphs" dir ws project nodes links size;
57
+
58
+ if [ -z "$(graphs_resolve "${GRAPH_NAME}")" ] ; then
59
+ echo "No graphs yet. Build one from the project's workspace folder:";
60
+ echo " cd ${WORKSPACE_ROOT}/<workspace> && bash cmd-tools-graphify.sh <project>";
61
+ return 0 ;
62
+ fi
63
+
64
+ printf '%-46s %8s %8s %9s %s\n' "GRAPH" "NODES" "EDGES" "SIZE" "PROJECT";
65
+ while read -r dir ; do
66
+ [ -n "${dir}" ] || continue ;
67
+ ws="$(basename "$(dirname "${dir}")")";
68
+ project="$(basename "${dir}")";
69
+ read -r nodes links <<< "$(python3 -c "
70
+ import json
71
+ try:
72
+ d = json.load(open('${dir}/graph.json'))
73
+ print(len(d.get('nodes', [])), len(d.get('links', d.get('edges', []))))
74
+ except Exception:
75
+ print('?', '?')
76
+ " 2>/dev/null)";
77
+ size="$(du -sh "${dir}" 2>/dev/null | cut -f1)";
78
+ printf '%-46s %8s %8s %9s %s\n' "${ws}/${project}" "${nodes}" "${links}" "${size}" \
79
+ "$([ -d "${WORKSPACE_ROOT}/${ws}/${project}" ] && echo present || echo "gone — safe to remove")";
80
+ done <<< "$(graphs_resolve "${GRAPH_NAME}")"
81
+ }
82
+
83
+ # Sweep up graphs left inside projects by an older build, or by graphify run by hand.
84
+ function graphs_collect() {
85
+ local out ws project dest moved=0;
86
+
87
+ while read -r out ; do
88
+ [ -n "${out}" ] || continue ;
89
+ ws="$(basename "$(dirname "$(dirname "${out}")")")";
90
+ project="$(basename "$(dirname "${out}")")";
91
+ dest="${WORKSPACE_ROOT}/graphs/${ws}/${project}";
92
+ if [ -d "${dest}" ] ; then
93
+ echo " ${ws}/${project}: already in the graphs workspace — left where it is, delete the copy in the project yourself if it is stale.";
94
+ continue ;
95
+ fi
96
+ mkdir -p "$(dirname "${dest}")" ;
97
+ if mv "${out}" "${dest}" ; then
98
+ echo " moved ${ws}/${project}";
99
+ moved=$((moved + 1));
100
+ # That project's CLAUDE.md still tells a session the graph is in graphify-out/ and gives
101
+ # commands with no --graph, which now error. Rewrite the note against the new location.
102
+ PROJECT_NAME="${project}" doc_name="${ws}" \
103
+ graphify_write_project_note "$(dirname "${out}")" "${dest}" > /dev/null 2>&1 ;
104
+ fi
105
+ done <<< "$(find "${WORKSPACE_ROOT}" -mindepth 3 -maxdepth 3 -name graphify-out -type d 2>/dev/null | sort)"
106
+
107
+ # The YAML mirror is a build input, never a result.
108
+ find "${WORKSPACE_ROOT}" -mindepth 3 -maxdepth 3 -name graphify-yaml -type d -exec rm -rf {} + 2>/dev/null ;
109
+
110
+ echo "";
111
+ echo "Collected ${moved} graph(s) into ${WORKSPACE_ROOT}/graphs/.";
112
+ }
113
+
114
+ function graphs_remove() {
115
+ local matches count dir;
116
+
117
+ # Deleting everything must be asked for by name, never be the default. GRAPH_NAME defaults to
118
+ # _all_, so without this a bare `-a remove` deleted whichever graph sorted first.
119
+ if [ "${GRAPH_NAME}" == '_all_' ] ; then
120
+ echo "Name the graph to remove: bash cmd-tools-graphs.sh <workspace>/<project> -a remove";
121
+ echo " (--graphs-action list shows them)";
122
+ return 1 ;
123
+ fi
124
+
125
+ matches="$(graphs_resolve "${GRAPH_NAME}")";
126
+ count="$(printf '%s' "${matches}" | grep -c . )";
127
+
128
+ if [ "${count}" -eq 0 ] ; then
129
+ echo "No graph matches '${GRAPH_NAME}' inside ${WORKSPACE_ROOT}/graphs.";
130
+ echo " Run with --graphs-action list to see them.";
131
+ return 1 ;
132
+ fi
133
+ # Ambiguity is refused rather than resolved by sort order: the same project name can exist in two
134
+ # workspaces, and this is the one action that cannot be undone.
135
+ if [ "${count}" -gt 1 ] ; then
136
+ echo "'${GRAPH_NAME}' matches ${count} graphs — name the workspace too:";
137
+ while read -r dir ; do printf ' %s\n' "${dir#${WORKSPACE_ROOT}/graphs/}"; done <<< "${matches}"
138
+ return 1 ;
139
+ fi
140
+
141
+ dir="${matches}";
142
+ rm -rf "${dir}" ;
143
+ echo "Deleted ${dir#${WORKSPACE_ROOT}/graphs/}. Rebuild it with cmd-tools-graphify.sh in its workspace folder.";
144
+ }
145
+
146
+ # Serve one graph to the Claude Code CLI over MCP.
147
+ #
148
+ # graphify ships its own MCP server (`graphify-mcp`, i.e. python -m graphify.serve) with ten tools:
149
+ # query_graph, get_node, get_neighbors, shortest_path, get_community, god_nodes, graph_stats,
150
+ # list_prs, get_pr_impact, triage_prs. Registered on **stdio**, so there is no port to keep open
151
+ # and no daemon to remember: the CLI starts it when a session needs it and stops it after.
152
+ function graphs_mcp() {
153
+ local dir ws project name graph;
154
+ dir="$(graphs_resolve "${GRAPH_NAME}" | head -1)";
155
+
156
+ if [ "${GRAPH_NAME}" == '_all_' ] ; then
157
+ echo "Name the graph: bash cmd-tools-graphs.sh <workspace>/<project> --graphs-action ${GRAPHS_ACTION}";
158
+ return 1 ;
159
+ fi
160
+ if [ -z "${dir}" ] || [ ! -f "${dir}/graph.json" ] ; then
161
+ echo "No graph for '${GRAPH_NAME}'. Build it first from its workspace folder.";
162
+ return 1 ;
163
+ fi
164
+
165
+ ws="$(basename "$(dirname "${dir}")")";
166
+ project="$(basename "${dir}")";
167
+ name="graph-${ws}-${project}";
168
+ graph="${dir}/graph.json";
169
+
170
+ case "${GRAPHS_ACTION}" in
171
+ mcp-register)
172
+ # `command -v` is not enough: the entry point is installed whether or not the mcp dependency
173
+ # is, and without it the server dies at import time — which the CLI reports only as
174
+ # "Connection closed". Prove the import instead.
175
+ if ! graphify-mcp --help > /dev/null 2>&1 ; then
176
+ echo "graphify-mcp cannot run — its mcp dependency is missing or too new (graphify needs mcp<2).";
177
+ echo " Fix it with: uv tool install --force \"graphifyy[sql]==${GRAPHIFY_VERSION:-0.9.28}\" --with \"mcp<2\"";
178
+ return 1 ;
179
+ fi
180
+ claude mcp add --scope user "${name}" -- graphify-mcp "${graph}" || return 1 ;
181
+ echo "";
182
+ echo "Registered ${name} with the Claude Code CLI (stdio, no port).";
183
+ echo " Open a NEW session to see its tools: query_graph, get_node, get_neighbors,";
184
+ echo " shortest_path, get_community, god_nodes, graph_stats, list_prs, get_pr_impact, triage_prs.";
185
+ echo " Rebuilding the graph needs no re-registration — the file path does not change.";
186
+ ;;
187
+ mcp-unregister)
188
+ claude mcp remove --scope user "${name}" && echo "Removed ${name} from the Claude Code CLI." ;
189
+ ;;
190
+ serve)
191
+ echo "Serving ${ws}/${project} over MCP on stdio — Ctrl+C to stop.";
192
+ exec graphify-mcp "${graph}" ;
193
+ ;;
194
+ esac
195
+ }
196
+
197
+ function graphs_command() {
198
+ case "${GRAPHS_ACTION}" in
199
+ list) graphs_list ;;
200
+ collect) graphs_collect ;;
201
+ remove) graphs_remove ;;
202
+ mcp-register|mcp-unregister|serve) graphs_mcp ;;
203
+ *) echo "Unknown action '${GRAPHS_ACTION}'." ; return 1 ;;
204
+ esac
205
+ }
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Turn a project's knowledge graph into an Obsidian vault.
4
+ #
5
+ # The graph is already the hard part: `graphify` parses the code and writes
6
+ # graphs/<workspace>/<project>/graph.json — the entities and the relations between
7
+ # them. Obsidian is a reader for exactly that shape, so this converts rather than
8
+ # re-parses. Parsing the tree a second time would cost the same minutes again and
9
+ # could disagree with the graph the rest of the menu shows.
10
+ #
11
+ # What it writes, next to the graph in graphs/<workspace>/<project>/obsidian/:
12
+ #
13
+ # <project>.md the index — every entity, grouped by the cluster graphify found
14
+ # notes/<entity>.md one note per node: frontmatter, then a [[wikilink]] per relation
15
+ # <project>.canvas JSON Canvas 1.0, so Obsidian's Canvas opens the actual map
16
+ # .obsidian/ enough config that Obsidian opens the vault without asking
17
+ #
18
+ # The wikilinks are the point. Obsidian builds its own graph view, backlinks and
19
+ # unlinked-mentions from them, so a graph that was a static picture becomes something
20
+ # you can walk — and the notes are plain markdown, readable without Obsidian at all.
21
+ #
22
+ # JSON Canvas 1.0: https://jsoncanvas.org/spec/1.0/
23
+
24
+ # Where a project's vault lives: inside its graph store, because it is made from the
25
+ # graph and is thrown away with it.
26
+ function obsidian_vault_dir() {
27
+ printf '%s' "${WORKSPACE_ROOT}/graphs/${1}/${2}/obsidian";
28
+ }
29
+
30
+ # Build the vault from the graph.
31
+ function obsidian_project() {
32
+ local ws="${doc_name}" project="${PROJECT_NAME}";
33
+ local store="${WORKSPACE_ROOT}/graphs/${ws}/${project}";
34
+ local graph="${store}/graph.json";
35
+ local vault; vault=$(obsidian_vault_dir "${ws}" "${project}");
36
+
37
+ if [ ! -f "${graph}" ] ; then
38
+ echo "No graph for ${ws}/${project} yet." ;
39
+ echo "Build one first: bash cmd-tools-graphify.sh ${project}" ;
40
+ return 1 ;
41
+ fi
42
+
43
+ if ! command -v python3 > /dev/null 2>&1 ; then
44
+ echo "python3 is needed to read the graph — not found." ;
45
+ return 1 ;
46
+ fi
47
+
48
+ echo "*---------------------------------------------------------------------*";
49
+ echo " Obsidian vault for ${ws}/${project}";
50
+ echo "*---------------------------------------------------------------------*";
51
+ echo " graph ${graph}";
52
+ echo " vault ${vault}";
53
+ echo "";
54
+
55
+ # Written to a sibling directory and swapped in, so an interrupted run never leaves
56
+ # a half-written vault where Obsidian would open it.
57
+ local tmp="${vault}.building";
58
+ rm -rf "${tmp}" ;
59
+ mkdir -p "${tmp}" ;
60
+
61
+ if ! MAX_CANVAS_NODES="${MAX_CANVAS_NODES:-300}" python3 - "${graph}" "${tmp}" "${project}" "${ws}" <<'PYEOF'
62
+ import json, os, re, sys, unicodedata
63
+ from collections import defaultdict
64
+
65
+ graph_path, out, project, workspace = sys.argv[1:5]
66
+ max_canvas = int(os.environ.get('MAX_CANVAS_NODES', '300'))
67
+
68
+ with open(graph_path, encoding='utf-8') as fh:
69
+ graph = json.load(fh)
70
+
71
+ nodes = graph.get('nodes') or []
72
+ links = graph.get('links') or []
73
+
74
+ if not nodes:
75
+ print(' The graph has no nodes — nothing to write.')
76
+ sys.exit(2)
77
+
78
+ # ---------------------------------------------------------------- note names
79
+ # A node id is whatever the parser found: it can carry /, :, #, characters Windows
80
+ # refuses and characters Obsidian reads as link syntax. Sanitise, then de-duplicate,
81
+ # because two different ids can flatten to the same name and a collision would make
82
+ # one note silently overwrite the other.
83
+ ILLEGAL = re.compile(r'[\\/:*?"<>|\[\]#^]+')
84
+
85
+ def note_name(raw):
86
+ name = unicodedata.normalize('NFC', str(raw)).strip()
87
+ name = ILLEGAL.sub('-', name).strip(' .-')
88
+ return name[:120] or 'unnamed'
89
+
90
+ names, taken = {}, {}
91
+ for n in nodes:
92
+ nid = n.get('id')
93
+ if nid is None:
94
+ continue
95
+ base = note_name(n.get('label') or nid)
96
+ key = base.lower()
97
+ if key in taken:
98
+ taken[key] += 1
99
+ base = f'{base} ({taken[key]})'
100
+ else:
101
+ taken[key] = 1
102
+ names[nid] = base
103
+
104
+ # --------------------------------------------------------------------- edges
105
+ out_edges = defaultdict(list)
106
+ in_edges = defaultdict(list)
107
+ for e in links:
108
+ s, t = e.get('source'), e.get('target')
109
+ if s in names and t in names:
110
+ rel = str(e.get('relation') or 'relates to')
111
+ out_edges[s].append((t, rel))
112
+ in_edges[t].append((s, rel))
113
+
114
+ by_id = {n['id']: n for n in nodes if n.get('id') in names}
115
+
116
+ def yaml_str(v):
117
+ s = str(v).replace('"', "'")
118
+ return f'"{s}"'
119
+
120
+ # --------------------------------------------------------------------- notes
121
+ notes_dir = os.path.join(out, 'notes')
122
+ os.makedirs(notes_dir, exist_ok=True)
123
+
124
+ for nid, name in names.items():
125
+ n = by_id[nid]
126
+ fm = ['---']
127
+ fm.append(f'title: {yaml_str(n.get("label") or nid)}')
128
+ tags = ['graphify']
129
+ ftype = n.get('file_type')
130
+ if ftype:
131
+ tags.append(re.sub(r'[^a-z0-9_-]+', '-', str(ftype).lower()))
132
+ fm.append('tags: [' + ', '.join(tags) + ']')
133
+ if n.get('source_file'):
134
+ fm.append(f'source_file: {yaml_str(n["source_file"])}')
135
+ if n.get('source_location'):
136
+ fm.append(f'source_location: {yaml_str(n["source_location"])}')
137
+ if n.get('community') is not None:
138
+ fm.append(f'cluster: {n["community"]}')
139
+ fm.append(f'project: {yaml_str(project)}')
140
+ fm.append(f'workspace: {yaml_str(workspace)}')
141
+ fm.append('---')
142
+
143
+ body = [f'# {n.get("label") or nid}', '']
144
+ where = n.get('source_file')
145
+ if where:
146
+ loc = n.get('source_location')
147
+ body.append(f'`{where}`' + (f' — {loc}' if loc else ''))
148
+ body.append('')
149
+
150
+ outs = sorted(out_edges.get(nid, []), key=lambda x: (x[1], names[x[0]]))
151
+ if outs:
152
+ body.append('## Points at')
153
+ body.append('')
154
+ for tgt, rel in outs:
155
+ body.append(f'- {rel} → [[{names[tgt]}]]')
156
+ body.append('')
157
+
158
+ ins = sorted(in_edges.get(nid, []), key=lambda x: (x[1], names[x[0]]))
159
+ if ins:
160
+ # Obsidian computes backlinks itself, but only from links that exist in the
161
+ # text. Writing the incoming side too means the relation is readable from
162
+ # either end, and it survives being read outside Obsidian.
163
+ body.append('## Pointed at by')
164
+ body.append('')
165
+ for src, rel in ins:
166
+ body.append(f'- [[{names[src]}]] → {rel}')
167
+ body.append('')
168
+
169
+ body.append(f'---')
170
+ body.append(f'Part of [[{project}]].')
171
+
172
+ with open(os.path.join(notes_dir, f'{name}.md'), 'w', encoding='utf-8') as fh:
173
+ fh.write('\n'.join(fm) + '\n\n' + '\n'.join(body) + '\n')
174
+
175
+ # --------------------------------------------------------------------- index
176
+ clusters = defaultdict(list)
177
+ for nid, name in names.items():
178
+ clusters[by_id[nid].get('community', 0)].append(name)
179
+
180
+ index = ['---', f'title: {yaml_str(project)}', 'tags: [graphify, index]', '---', '',
181
+ f'# {project}', '',
182
+ f'The knowledge graph of `{workspace}/{project}`, as notes: '
183
+ f'**{len(names)} entities**, **{len(links)} relations**, '
184
+ f'in **{len(clusters)} clusters**.', '',
185
+ f'Open `{project}.canvas` for the map. Every note links to what it points at, so '
186
+ 'Obsidian\'s own graph view, backlinks and unlinked mentions all work.', '']
187
+ for c in sorted(clusters, key=lambda k: (str(type(k)), k)):
188
+ index.append(f'## Cluster {c}')
189
+ index.append('')
190
+ for name in sorted(clusters[c]):
191
+ index.append(f'- [[{name}]]')
192
+ index.append('')
193
+ with open(os.path.join(out, f'{note_name(project)}.md'), 'w', encoding='utf-8') as fh:
194
+ fh.write('\n'.join(index))
195
+
196
+ # -------------------------------------------------------------------- canvas
197
+ # JSON Canvas 1.0. Laid out deterministically — grid within cluster, clusters in a
198
+ # grid — because the vault is stored and a random layout would diff in full on
199
+ # every rebuild.
200
+ NODE_W, NODE_H, GAP_X, GAP_Y = 260, 60, 320, 110
201
+ PRESET = ['1', '2', '3', '4', '5', '6']
202
+
203
+ ranked = sorted(names, key=lambda i: (-len(out_edges.get(i, [])) - len(in_edges.get(i, [])), names[i]))
204
+ shown = ranked[:max_canvas]
205
+ shown_set = set(shown)
206
+
207
+ canvas_nodes, canvas_edges, canvas_id = [], [], {}
208
+ grouped = defaultdict(list)
209
+ for nid in shown:
210
+ grouped[by_id[nid].get('community', 0)].append(nid)
211
+
212
+ # Clusters are packed into ROWS rather than laid end to end. Sixteen clusters in one line
213
+ # made a canvas 13,000 wide and 440 tall: fitted to a screen that is an eight-fold shrink,
214
+ # and every label goes sub-pixel. Wrapping at a target width keeps the map roughly the
215
+ # shape of the thing looking at it.
216
+ MARGIN = 160
217
+ blocks = []
218
+ for c in sorted(grouped, key=lambda k: str(k)):
219
+ members = sorted(grouped[c], key=lambda i: names[i])
220
+ cols = max(1, round(len(members) ** 0.5))
221
+ rows = (len(members) + cols - 1) // cols
222
+ blocks.append((c, members, cols, cols * GAP_X, rows * GAP_Y))
223
+
224
+ # Aim for a 16:10-ish sheet: wrap once a row is wider than the square root of the total
225
+ # area, stretched by that ratio.
226
+ area = sum(w * h for _, _, _, w, h in blocks) or 1
227
+ target_w = max(max((w for _, _, _, w, _ in blocks), default=GAP_X), int((area ** 0.5) * 1.6))
228
+
229
+ cx = cy = row_h = 0
230
+ for ci, (c, members, cols, bw, bh) in enumerate(blocks):
231
+ if cx > 0 and cx + bw > target_w:
232
+ cx = 0
233
+ cy += row_h + MARGIN
234
+ row_h = 0
235
+ for i, nid in enumerate(members):
236
+ cid = f'n{len(canvas_nodes)}-{re.sub(r"[^A-Za-z0-9]", "", str(nid))[:24] or "x"}'
237
+ canvas_id[nid] = cid
238
+ canvas_nodes.append({
239
+ 'id': cid,
240
+ 'type': 'file',
241
+ 'file': f'notes/{names[nid]}.md',
242
+ 'x': cx + (i % cols) * GAP_X,
243
+ 'y': cy + (i // cols) * GAP_Y,
244
+ 'width': NODE_W,
245
+ 'height': NODE_H,
246
+ 'color': PRESET[ci % len(PRESET)],
247
+ })
248
+ cx += bw + MARGIN
249
+ row_h = max(row_h, bh)
250
+
251
+ for e in links:
252
+ s, t = e.get('source'), e.get('target')
253
+ if s in shown_set and t in shown_set:
254
+ canvas_edges.append({
255
+ 'id': f'e{len(canvas_edges)}',
256
+ 'fromNode': canvas_id[s],
257
+ 'fromSide': 'right',
258
+ 'toNode': canvas_id[t],
259
+ 'toSide': 'left',
260
+ 'toEnd': 'arrow',
261
+ 'label': str(e.get('relation') or ''),
262
+ })
263
+
264
+ with open(os.path.join(out, f'{note_name(project)}.canvas'), 'w', encoding='utf-8') as fh:
265
+ json.dump({'nodes': canvas_nodes, 'edges': canvas_edges}, fh, indent=2)
266
+
267
+ # ------------------------------------------------------------ vault settings
268
+ conf = os.path.join(out, '.obsidian')
269
+ os.makedirs(conf, exist_ok=True)
270
+ with open(os.path.join(conf, 'app.json'), 'w', encoding='utf-8') as fh:
271
+ json.dump({'attachmentFolderPath': 'notes', 'newLinkFormat': 'shortest',
272
+ 'useMarkdownLinks': False, 'alwaysUpdateLinks': True}, fh, indent=2)
273
+ with open(os.path.join(conf, 'appearance.json'), 'w', encoding='utf-8') as fh:
274
+ json.dump({'accentColor': ''}, fh, indent=2)
275
+ with open(os.path.join(conf, 'core-plugins.json'), 'w', encoding='utf-8') as fh:
276
+ json.dump(['file-explorer', 'global-search', 'switcher', 'graph', 'backlink',
277
+ 'outgoing-link', 'tag-pane', 'page-preview', 'canvas', 'outline'], fh, indent=2)
278
+
279
+ print(f' {len(names)} notes, {len(links)} relations, {len(clusters)} clusters')
280
+ print(f' canvas: {len(canvas_nodes)} nodes, {len(canvas_edges)} edges')
281
+ if len(names) > len(shown):
282
+ # Never let a cap look like completeness: the notes hold everything, the canvas
283
+ # holds the most connected slice of it.
284
+ print(f' canvas capped at MAX_CANVAS_NODES={max_canvas}: showing the {len(shown)} '
285
+ f'most connected of {len(names)} entities. Every entity still has a note.')
286
+ PYEOF
287
+ then
288
+ echo "";
289
+ echo "Could not build the vault — the graph above was left untouched.";
290
+ rm -rf "${tmp}" ;
291
+ return 1 ;
292
+ fi
293
+
294
+ rm -rf "${vault}" ;
295
+ mv "${tmp}" "${vault}" ;
296
+
297
+ echo "";
298
+ echo "*---------------------------------------------------------------------*";
299
+ echo " Vault ready:";
300
+ echo " ${vault}";
301
+ echo "";
302
+ echo " Open it: obsidian://open?path=$(printf '%s' "${vault}" | sed 's|/|%2F|g')";
303
+ echo " or point Obsidian at the folder above (Open folder as vault).";
304
+ echo "*---------------------------------------------------------------------*";
305
+ }
306
+
307
+ # Throw the vault away. The graph it was made from is left alone.
308
+ function obsidian_remove() {
309
+ local ws="${doc_name}" project="${PROJECT_NAME}";
310
+ local vault; vault=$(obsidian_vault_dir "${ws}" "${project}");
311
+
312
+ if [ ! -d "${vault}" ] ; then
313
+ echo "No Obsidian vault for ${ws}/${project}." ;
314
+ return 0 ;
315
+ fi
316
+ rm -rf "${vault}" ;
317
+ echo "Removed the Obsidian vault for ${ws}/${project}. The graph is untouched." ;
318
+ }
319
+
320
+ # What is there, without building anything.
321
+ function obsidian_status() {
322
+ local ws="${doc_name}" project="${PROJECT_NAME}";
323
+ local vault; vault=$(obsidian_vault_dir "${ws}" "${project}");
324
+
325
+ if [ ! -d "${vault}" ] ; then
326
+ echo "${ws}/${project}: no vault yet." ;
327
+ return 0 ;
328
+ fi
329
+ echo "${ws}/${project}: $(find "${vault}/notes" -name '*.md' 2>/dev/null | wc -l) notes at ${vault}" ;
330
+ }