@webship.co/workspace-graph 0.0.0-stage → 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/README.md +24 -2
- package/commands/cmd-tools-graphify.sh +27 -0
- package/commands/cmd-tools-graphs.sh +18 -0
- package/commands/cmd-tools-obsidian.sh +31 -0
- package/config/workspace.graphs.settings.yml +25 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +309 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lib/graphs.d.ts +1 -0
- package/dist/src/lib/graphs.js +141 -0
- package/dist/src/lib/graphs.js.map +1 -0
- package/dist/src/lib/obsidian.d.ts +1 -0
- package/dist/src/lib/obsidian.js +414 -0
- package/dist/src/lib/obsidian.js.map +1 -0
- package/docs/AGENTS.md +29 -0
- package/docs/CLAUDE.md +7 -0
- package/docs/README.md +11 -0
- package/features/graph.feature +13 -0
- package/package.json +80 -4
- package/scripts/args/arg-graphify.sh +36 -0
- package/scripts/args/arg-graphs.sh +12 -0
- package/scripts/args/arg-obsidian.sh +17 -0
- package/scripts/functions/fun-graphify.sh +587 -0
- package/scripts/functions/fun-graphs.sh +205 -0
- package/scripts/functions/fun-obsidian.sh +330 -0
|
@@ -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
|
+
}
|