-
Notifications
You must be signed in to change notification settings - Fork 0
/
graph.py
134 lines (117 loc) · 4.75 KB
/
graph.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# Script to create an SVG graph of parts of your emanote Zettelkasten.
#
# Copyright (C) 2024 David Pätzel <david.paetzel@posteo.de>
#
# This file is part of ematools.
#
# ematools is free software: you can redistribute it and/or modify it under the
# terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
#
# ematools is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
# PARTICULAR PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# ematools. If not, see <https://www.gnu.org/licenses/>.
# TODO Consider using https://click.palletsprojects.com/en/8.1.x/setuptools/
import re
import urllib
import click
import networkx as nx
import toolz
from tqdm import tqdm
from ematools import fetch_zettels
@click.command()
@click.option(
"--path", "-p", default="", help="Only Zettels with this prefix are included"
)
@click.option(
"--exclude",
"-e",
default=None,
help="Regular expression, Zettels matching this are excluded",
)
@click.option("--engine", default="fdp", help="Graphviz layout engine to use")
@click.option(
"--include-edge-zettels/--exclude-edge-zettels",
default=False,
help=(
"Whether to include excluded Zettels that are linked by included "
"Zettels (if this is on, they are marked in blue)"
),
)
def cli(path, exclude, engine, include_edge_zettels):
if exclude is None:
exclude = f"^{r'.*/' * (path.count('/') + 1 + (not path.endswith('/')))}.*$"
print(f"Using exclusion rule {exclude} …")
print("Fetching Zettels …")
zettels = fetch_zettels()
pred = (
lambda key: key.startswith(path)
and not re.match(exclude, key)
and
# Always throw away archive.
not re.match("/?Archive/", key)
)
zettels_filtered = {key: zettels[key] for key in zettels if pred(key)}
graph = nx.DiGraph()
for zettel in tqdm(zettels_filtered, desc="Building graph"):
graph.add_node(zettel, label=zettels[zettel]["title"])
for link in zettels[zettel]["links"]:
try:
zettel2 = (
link["resolvedRelTarget"]["contents"].removesuffix(".html") + ".md"
)
# Since resolvedRelTargets are URL encoded, we have to decode
# them (e.g. spaces are %20).
zettel2 = urllib.parse.unquote(zettel2)
except KeyError:
pass
except AttributeError:
print(f"{zettel} is broken (probably a broken link?), ignoring it …")
continue
# But consider Zettels not returned by `fetch_zettels` (e.g. static
# files or internal API pseudo-Zettels like
# `"-/tags/something.md"`).
if zettel2 in zettels:
attr_unvisited = (
dict(fillcolor="skyblue", style="filled")
if "unvisited" in zettels[zettel2]["meta"]["tags"]
else {}
)
# If `zettel2` is not a discarded Zettel, always add an edge.
if zettel2 in zettels_filtered:
graph.add_node(
zettel2, label=zettels[zettel2]["title"], **attr_unvisited
)
graph.add_edge(zettel, zettel2)
# … otherwise, only add an edge if we opted to include edges to
# discarded Zettels.
elif include_edge_zettels:
# Color edge nodes' lines.
graph.add_node(
zettel2,
color="blue",
label=zettels[zettel2]["title"],
**attr_unvisited,
)
graph.add_edge(zettel, zettel2)
else:
print(f'Excluding "{zettel2}".')
print("Marking unreachable notes …")
degs = dict(graph.in_degree())
unreachable_zettels = toolz.valfilter(lambda deg: deg == 0, degs).keys()
graph.add_nodes_from(unreachable_zettels, fillcolor="orange", style="filled")
print("Generating SVG …")
agraph = nx.nx_agraph.to_agraph(graph)
if engine == "all":
for engine in ["dot", "neato", "fdp", "sfdp", "circo", "twopi", "nop", "osage"]:
# These are the best ones for this purpose I think.
# for engine in ["fdp", "sfdp", "circo"]:
agraph.draw(f"graph-{engine}.svg", prog=engine)
else:
agraph.draw(f"graph-{engine}.svg", prog=engine)
if __name__ == "__main__":
cli()