├── .gitignore
├── CHANGELOG.md
├── LICENCE.md
├── README.md
├── assets
└── screenshot.png
├── keyboard-maestro
└── Generate network visualization of notes selected in The Archive using zkviz.kmmacros
├── setup.py
├── tests
├── __init__.py
└── test_zkviz.py
└── zkviz
├── __init__.py
├── graphviz.py
├── plotly.py
└── zkviz.py
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.gitignore.io/api/osx
3 | # Edit at https://www.gitignore.io/?templates=osx
4 |
5 | ### OSX ###
6 | # General
7 | .DS_Store
8 | .AppleDouble
9 | .LSOverride
10 |
11 | # Icon must end with two \r
12 | Icon
13 |
14 | # Thumbnails
15 | ._*
16 |
17 | # Files that might appear in the root of a volume
18 | .DocumentRevisions-V100
19 | .fseventsd
20 | .Spotlight-V100
21 | .TemporaryItems
22 | .Trashes
23 | .VolumeIcon.icns
24 | .com.apple.timemachine.donotpresent
25 |
26 | # Directories potentially created on remote AFP share
27 | .AppleDB
28 | .AppleDesktop
29 | Network Trash Folder
30 | Temporary Items
31 | .apdisk
32 |
33 | # End of https://www.gitignore.io/api/osx
34 |
35 | # Created by https://www.gitignore.io/api/python
36 | # Edit at https://www.gitignore.io/?templates=python
37 |
38 | ### Python ###
39 | # Byte-compiled / optimized / DLL files
40 | __pycache__/
41 | *.py[cod]
42 | *$py.class
43 |
44 | # C extensions
45 | *.so
46 |
47 | # Distribution / packaging
48 | .Python
49 | build/
50 | develop-eggs/
51 | dist/
52 | downloads/
53 | eggs/
54 | .eggs/
55 | lib/
56 | lib64/
57 | parts/
58 | sdist/
59 | var/
60 | wheels/
61 | pip-wheel-metadata/
62 | share/python-wheels/
63 | *.egg-info/
64 | .installed.cfg
65 | *.egg
66 | MANIFEST
67 |
68 | # PyInstaller
69 | # Usually these files are written by a python script from a template
70 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
71 | *.manifest
72 | *.spec
73 |
74 | # Installer logs
75 | pip-log.txt
76 | pip-delete-this-directory.txt
77 |
78 | # Unit test / coverage reports
79 | htmlcov/
80 | .tox/
81 | .nox/
82 | .coverage
83 | .coverage.*
84 | .cache
85 | nosetests.xml
86 | coverage.xml
87 | *.cover
88 | .hypothesis/
89 | .pytest_cache/
90 |
91 | # Translations
92 | *.mo
93 | *.pot
94 |
95 | # Django stuff:
96 | *.log
97 | local_settings.py
98 | db.sqlite3
99 |
100 | # Flask stuff:
101 | instance/
102 | .webassets-cache
103 |
104 | # Scrapy stuff:
105 | .scrapy
106 |
107 | # Sphinx documentation
108 | docs/_build/
109 |
110 | # PyBuilder
111 | target/
112 |
113 | # Jupyter Notebook
114 | .ipynb_checkpoints
115 |
116 | # IPython
117 | profile_default/
118 | ipython_config.py
119 |
120 | # pyenv
121 | .python-version
122 |
123 | # pipenv
124 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
125 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
126 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not
127 | # install all needed dependencies.
128 | #Pipfile.lock
129 |
130 | # celery beat schedule file
131 | celerybeat-schedule
132 |
133 | # SageMath parsed files
134 | *.sage.py
135 |
136 | # Environments
137 | .env
138 | .venv
139 | env/
140 | venv/
141 | ENV/
142 | env.bak/
143 | venv.bak/
144 |
145 | # Spyder project settings
146 | .spyderproject
147 | .spyproject
148 |
149 | # Rope project settings
150 | .ropeproject
151 |
152 | # mkdocs documentation
153 | /site
154 |
155 | # mypy
156 | .mypy_cache/
157 | .dmypy.json
158 | dmypy.json
159 |
160 | # Pyre type checker
161 | .pyre/
162 |
163 | # End of https://www.gitignore.io/api/python
164 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [1.3.2] - 2020-11-05
9 |
10 | ### Fixed
11 | FIX: Fix typo in graph attribute (#20)
12 |
13 | ## [1.3.0] - 2019-08-10
14 |
15 | ### New
16 | * Add options to not include external links, and self references (#16)
17 | * Add screenshot and tags to README (#15)
18 |
19 | ### Changed
20 | * Use the Black formatter on the whole codebase
21 |
22 | ## [1.2.0] - 2019-08-06
23 |
24 | ### New
25 |
26 | * ENH: Add the `--use-graphviz` option to use Graphviz to draw the network (#13)
27 |
28 | ## [1.1.0] - 2019-07-01
29 |
30 | ### Changed
31 | FIX: Use faster layout algorithm for large networks (#8)
32 |
33 | ### Fixed
34 | ENH: Allow different, and multiple file patterns (#6)
35 | DOC: Fix twine instructions to upload to PyPI
36 |
37 | ## [1.0.0] - 2019-06-17
38 |
39 | First version of zkviz
40 |
41 |
42 | [Unreleased]: https://github.com/Zettelkasten-Method/zkviz/compare/v1.0.0...HEAD
43 | [1.0.0]: https://github.com/Zettelkasten-Method/zkviz/compare/04d473f...v1.0.0
44 | [1.1.0]: https://github.com/Zettelkasten-Method/zkviz/compare/v1.0.0...v1.1.0
45 | [1.2.0]: https://github.com/Zettelkasten-Method/zkviz/compare/v1.1.0...v1.2.0
46 | [1.3.0]: https://github.com/Zettelkasten-Method/zkviz/compare/v1.2.0...v1.3.0
47 |
--------------------------------------------------------------------------------
/LICENCE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Alexandre Chabot-Leclerc
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # zkviz: Visualize Link Network Between Zettels (Notes)
2 |
3 | 
4 | 
5 |
6 | Produce an interactive overview of all your notes and their connections.
7 |
8 | 
9 |
10 | ## Installing
11 |
12 | I recommend using Python 3 and an environment specifically for zkviz.
13 |
14 | Assuming that you're using macOS or Linux, to create the environment, open
15 | a Terminal window and type the following to create the standalone environment
16 | and activate it.
17 |
18 | ```sh
19 | python3 -m venv ~/envs/zkviz
20 | source ~/envs/zkviz/bin/activate
21 | ```
22 |
23 | Then install zkviz with:
24 |
25 | ```sh
26 | pip install zkviz
27 | ```
28 |
29 | If [Graphviz](https://graphviz.org/download/) is installed on your computer,
30 | zkviz can use it to draw the network. It is not a Python package so it needs to
31 | be installed independently. If you're on a Mac and have
32 | [Homebrew](https://brew.sh) installed, you can install Graphviz from a Terminal
33 | with:
34 |
35 | ```sh
36 | brew install graphviz
37 | ```
38 |
39 | ## Usage
40 |
41 | To execute zkviz from the Terminal, you either need to add the zkviz
42 | environment path to your `PATH` environment variable or specify the path to the
43 | zkviz executable directly. Below, I use the explicit path.
44 |
45 | Executing zkviz without any argument will build the visualization based on all
46 | the `*.md` files found in the current directory.
47 |
48 |
49 | ```sh
50 | ~/envs/zkviz/bin/zkviz
51 | ```
52 |
53 | You can also point zkviz to the folder containing your notes. For example:
54 |
55 | ```sh
56 | ~/envs/zkviz/bin/zkviz --notes-dir ~/Notes
57 | ```
58 |
59 | By default zkviz will look for files with the `.md` extension, but you can override
60 | the default with the `--pattern` option:
61 |
62 | ```sh
63 | ~/envs/zkviz/bin/zkviz --pattern '*.mkdown'
64 | ```
65 |
66 | You can also specify multiple patterns separately. With the following, zkviz
67 | will find all txt and md files. I recommend wrapping the pattern in quotes.
68 |
69 | ```sh
70 | ~/envs/zkviz/bin/zkviz --pattern '*.md' --pattern '*.txt'
71 | ```
72 | You can also pass a list of files to zkviz:
73 |
74 | ```sh
75 | ~/envs/zkviz/bin/zkviz "~/Notes/201906021303 the state of affairs.md" "~/Notes/201901021232 Journey to the center of the earth.md"
76 | ```
77 |
78 | To use Graphviz to generate the visualization, add the `--use-graphviz` option:
79 |
80 | ```sh
81 | ~/envs/zkviz/bin/zkviz --notes-dir ~/Notes --use-graphviz
82 | ```
83 |
84 | By default, zkviz will draw a node for every reference found in the files
85 | provided, even if the referenced zettel does not exist, and even if a zettel
86 | refers to itself. You can change that behavior in two ways. The `--only-list`
87 | option tells zkviz to draw links only to zettels that have been provided to it.
88 | In the example below, only links between the two zettels will be shown:
89 |
90 | ```sh
91 | ~/envs/zkviz/bin/zkviz --only-list "20190810190224 Note 1.md" "20190810190230 Note 2.md"
92 | ```
93 |
94 | The other way to change the behavior is to disable self-reference links using
95 | the `--no-self-ref` option.
96 |
97 | ## Using zkviz with Keyboard Maestro
98 |
99 | The `keyboard-maestro` folder includes a [Keyboard Maestro](https://www.keyboardmaestro.com)
100 | macro to automatically create a visualization based on the list of files
101 | currently selected in [The Archive](https://zettelkasten.de/the-archive/). To
102 | use this macro, download it and import it into Keyboard Maestro. The follow the
103 | README comment within the macro to set the necessary variables.
104 |
105 | ## Making a Release
106 |
107 | 1. Bump the version in `zkviz/__init__.py`
108 | 2. Update the changelog, link the versions.
109 | 3. Commit and tag with version number
110 | 4. Build a source dist with `python setup.py clean && rm dist/* && python setup.py sdist`
111 | 5. Test upload to PyPI test with `twine upload --repository-url https://test.pypi.org/legacy/ dist/*`
112 | 6. Create a temporary environment `mktmpenv` and test install with `pip install --index-url https://test.pypi.org/simple/ zkviz`
113 | 7. If everything looks good, upload for real with `twine upload dist/*`
114 |
115 |
--------------------------------------------------------------------------------
/assets/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Zettelkasten-Method/zkviz/d92d40ac20ff50963c80cc4fb77abd097f42d8e5/assets/screenshot.png
--------------------------------------------------------------------------------
/keyboard-maestro/Generate network visualization of notes selected in The Archive using zkviz.kmmacros:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Activate
7 | Normal
8 | CreationDate
9 | 564694355.70418799
10 | Macros
11 |
12 |
13 | Actions
14 |
15 |
16 | MacroActionType
17 | Comment
18 | StyledText
19 |
20 | cnRmZAAAAAADAAAAAgAAAAcAAABU
21 | WFQucnRmAQAAAC4kBAAAKwAAAAEA
22 | AAAcBAAAe1xydGYxXGFuc2lcYW5z
23 | aWNwZzEyNTJcY29jb2FydGYxNjcx
24 | XGNvY29hc3VicnRmNjAwCntcZm9u
25 | dHRibFxmMFxmbmlsXGZjaGFyc2V0
26 | MCBIZWx2ZXRpY2FOZXVlO30Ke1xj
27 | b2xvcnRibDtccmVkMjU1XGdyZWVu
28 | MjU1XGJsdWUyNTU7XHJlZDBcZ3Jl
29 | ZW4wXGJsdWUwO30Ke1wqXGV4cGFu
30 | ZGVkY29sb3J0Ymw7O1xjc3NyZ2Jc
31 | YzBcYzBcYzBcY25hbWUgdGV4dENv
32 | bG9yO30KXHBhcmRcdHg1NjBcdHgx
33 | MTIwXHR4MTY4MFx0eDIyNDBcdHgy
34 | ODAwXHR4MzM2MFx0eDM5MjBcdHg0
35 | NDgwXHR4NTA0MFx0eDU2MDBcdHg2
36 | MTYwXHR4NjcyMFxwYXJkaXJuYXR1
37 | cmFsXHBhcnRpZ2h0ZW5mYWN0b3Iw
38 | CgpcZjBcZnMyNiBcY2YyIFRoaXMg
39 | bWFjcm8gdXNlcyB6a3ZpeiBbMV0g
40 | dG8gY3JlYXRlIGEgbmV0d29yayB2
41 | aXN1YWxpemF0aW9uIG9mIHRoZSB0
42 | aGUgbm90ZXMgY3VycmVudGx5IHNl
43 | bGVjdGUgaW4gVGhlIEFyY2hpdmUg
44 | YXBwIFsyXS5cClwKRm9yIHRoaXMg
45 | bWFjcm8gdG8gd29yayBwcm9wZXJs
46 | eSwgcGxlYXNlIHNldCB0aGVzZSB2
47 | YXJpYWJsZXM6XApcCi0gWktWSVpf
48 | QVJDSElWRV9QQVRIIGlzIHRoZSBw
49 | YXRoIHRvIHdoZXJlIHlvdXIgYXJj
50 | aGl2ZSBub3RlcyBhcmUgc3RvcmUu
51 | IEZvciBleGFtcGxlLCB+L0Ryb3Bi
52 | b3gvTm90ZXNcCi0gWktWSVpfUEFU
53 | SCBpcyB0aGUgYWJzb2x1dGUgcGF0
54 | aCB0byB0aGUgemt2aXogZXhlY3V0
55 | YWJsZS4gUGxlYXNlIHNlZSBbMV0g
56 | Zm9yIGluc3RydWN0aW9ucyBvZiBo
57 | b3cgdG8gaW5zdGFsbCBpdC4gVGhl
58 | IHBhdGggYmVsb3cgdXNlcyB0aGUg
59 | b25lIHlvdSdkIGhhdmUgaWYgeW91
60 | IGZvbGxvd2VkIHRoZSBpbnN0cnVj
61 | dGlvbnMgZGlyZWN0bHkuXAotIFpL
62 | VklaX1VTRV9HUkFQSFZJWiBzZXQg
63 | dG8gMSBpZiB5b3Ugd2FudCB0byB1
64 | c2UgR3JhcGh2aXogdG8gZ2VuZXJh
65 | dGUgdGhlIHZpc3VhbGl6YXRpb24u
66 | IE9yIHVzZSAwIGlmIHlvdSB3YW50
67 | IHRvIHVzZSBQbG90bHlcClwKXApb
68 | MV06IGh0dHBzOi8vZ2l0aHViLmNv
69 | bS9aZXR0ZWxrYXN0ZW4tTWV0aG9k
70 | L3prdml6XApbMl06IGh0dHBzOi8v
71 | emV0dGVsa2FzdGVuLmRlL3RoZS1h
72 | cmNoaXZlL30BAAAAIwAAAAEAAAAH
73 | AAAAVFhULnJ0ZhAAAAA/NkpdtgEA
74 | AAAAAAAAAAAA
75 |
76 | Title
77 | README
78 |
79 |
80 | ActionName
81 | Set ARCHIVE_PATH to the folder containing your archive
82 | MacroActionType
83 | SetVariableToText
84 | Text
85 | ~/projects/zkviz/data/notes
86 | Variable
87 | ZKVIZ_ARCHIVE_PATH
88 |
89 |
90 | ActionName
91 | Set path to the zkviz executable
92 | MacroActionType
93 | SetVariableToText
94 | Text
95 | ~/envs/zkviz/bin/zkviz
96 | Variable
97 | ZKVIZ_PATH
98 |
99 |
100 | ActionName
101 | Choose whether to use Graphviz or not to render the visualization [0 or 1]
102 | MacroActionType
103 | SetVariableToText
104 | Text
105 | 0
106 | Variable
107 | ZKVIZ_USE_GRAPHVIZ
108 |
109 |
110 | ActionName
111 | Notify that listing all notes might take a little while
112 | IsDisclosed
113 |
114 | MacroActionType
115 | Notification
116 | SoundName
117 |
118 | Subtitle
119 | Listing notes selected in The Archive.
120 | Text
121 | This might take a few seconds.
122 | Title
123 | zkviz
124 |
125 |
126 | ActionName
127 | Get the name of all the files selected in The Archive
128 | DisplayKind
129 | Variable
130 | IncludeStdErr
131 |
132 | IsDisclosed
133 |
134 | MacroActionType
135 | ExecuteAppleScript
136 | NotifyOnFailure
137 |
138 | Path
139 |
140 | StopOnFailure
141 |
142 | Text
143 | use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions
tell application "System Events"
tell its application process "The Archive"
tell its window "The Archive"
tell its splitter group 1
tell its splitter group 1
tell its scroll area 2
tell its table 1
set theRows to rows
set theNames to {}
repeat with theRow in theRows
set theName to the name of item 1 of UI element of theRow
set theName to theName & ".md"
copy theName to the end of theNames
end repeat
set AppleScript's text item delimiters to linefeed
set theFileNames to theNames as string
set AppleScript's text item delimiters to ""
return theFileNames
end tell
end tell
end tell
end tell
end tell
end tell
end tell
144 | TimeOutAbortsMacro
145 |
146 | TrimResults
147 |
148 | TrimResultsNew
149 |
150 | UseText
151 |
152 | Variable
153 | ZKVIZ_ZETTEL_FILENAMES
154 |
155 |
156 | Action
157 | ExpandTildeInPath
158 | ActionName
159 | Expand Tilde in ZKVIZ_ARCHIVE_PATH
160 | IsDisclosed
161 |
162 | MacroActionType
163 | Filter
164 | Source
165 | Variable
166 | Variable
167 | ZKVIZ_ARCHIVE_PATH
168 |
169 |
170 | Action
171 | ExpandTildeInPath
172 | ActionName
173 | Expand Tilde in ZKVIZ_ARCHIVE_PATH
174 | IsDisclosed
175 |
176 | MacroActionType
177 | Filter
178 | Source
179 | Variable
180 | Variable
181 | ZKVIZ_PATH
182 |
183 |
184 | Action
185 | IgnoreCaseRegEx
186 | ActionName
187 | Prepend the path to the archive to each filename
188 | IsDisclosed
189 |
190 | MacroActionType
191 | SearchReplace
192 | Replace
193 | %Variable%ZKVIZ_ARCHIVE_PATH%/
194 | Search
195 | (?m)^
196 | Source
197 | Variable
198 | Variable
199 | ZKVIZ_ZETTEL_FILENAMES
200 |
201 |
202 | ActionName
203 | Execute zkviz with the files selected.
204 | DisplayKind
205 | Window
206 | IncludeStdErr
207 |
208 | MacroActionType
209 | ExecuteShellScript
210 | Path
211 | /Users/chabot/projects/zkviz/env/bin/zkviz
212 | Source
213 | Nothing
214 | StopOnFailure
215 |
216 | Text
217 | #!/usr/bin/env python
218 | import os
219 | import subprocess
220 |
221 | # Get path to the zkviz executable
222 | zkviz = os.environ['KMVAR_ZKVIZ_PATH']
223 |
224 | # Check if `/usr/local/bin` is in the PATH, which is needed for Graphviz.
225 | # If not, append it.
226 | if '/usr/loca/bin/' not in os.environ['PATH']:
227 | os.environ['PATH'] += ':/usr/local/bin'
228 |
229 | # Use graphviz or not
230 | use_graphviz = int(os.environ['KMVAR_ZKVIZ_USE_GRAPHVIZ'])
231 |
232 | # Get the list of paths using environment variables.
233 | filenames = os.environ['KMVAR_ZKVIZ_ZETTEL_FILENAMES'].strip().splitlines()
234 |
235 | # Create the output file next to your zettels. Otherwise, we run into a permission issue.
236 | archive_path = os.path.expanduser(os.environ['KMVAR_ZKVIZ_ARCHIVE_PATH'])
237 | output_filename = os.path.join(archive_path, 'zettel-network.html')
238 |
239 | # Build the arguments to execute
240 | args = [zkviz, '--output', output_filename] + filenames
241 | if use_graphviz:
242 | args.insert(1, '--use-graphviz')
243 |
244 | # Capture error messages and display them if something happens. Otherwise,
245 | # this should print any output.
246 | try:
247 | output = subprocess.check_output(args, stderr=subprocess.STDOUT)
248 | except subprocess.CalledProcessError as exc:
249 | print("Status : FAIL", exc.returncode, exc.output)
250 | TimeOutAbortsMacro
251 |
252 | TrimResults
253 |
254 | TrimResultsNew
255 |
256 | UseText
257 |
258 |
259 |
260 | CreationDate
261 | 582430104.35251606
262 | ModificationDate
263 | 586837783.08286202
264 | Name
265 | Generate network visualization of notes selected in The Archive using zkviz
266 | Triggers
267 |
268 |
269 | FireType
270 | Pressed
271 | KeyCode
272 | 45
273 | MacroTriggerType
274 | HotKey
275 | Modifiers
276 | 4352
277 |
278 |
279 | UID
280 | 4C49BA73-EEC5-4AE2-A29B-178F17D9C3B4
281 |
282 |
283 | Name
284 | App - The Archive
285 | Targeting
286 |
287 | Targeting
288 | Included
289 | TargetingApps
290 |
291 |
292 | BundleIdentifier
293 | de.zettelkasten.TheArchive
294 | Name
295 | The Archive
296 | NewFile
297 | /Applications/The Archive.app
298 |
299 |
300 |
301 | ToggleMacroUID
302 | CDBB7F8C-8114-42A6-B9EE-4B5A8169A575
303 | UID
304 | 048F20DA-8E8A-4EDF-94E7-2F34B783DD5B
305 |
306 |
307 |
308 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os.path
2 | import re
3 | import setuptools
4 |
5 |
6 | # Find the version number
7 | here = os.path.abspath(os.path.dirname(__file__))
8 |
9 |
10 | def read(*parts):
11 | with open(os.path.join(here, *parts), 'r', encoding='utf-8') as fp:
12 | return fp.read()
13 |
14 |
15 | def find_version(*file_paths):
16 | version_file = read(*file_paths)
17 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]",
18 | version_file, re.M)
19 | if version_match:
20 | return version_match.group(1)
21 | raise RuntimeError("Unable to find version string.")
22 |
23 |
24 | # Load README
25 | with open("README.md", "r") as fh:
26 | long_description = fh.read()
27 |
28 | setuptools.setup(
29 | name="zkviz",
30 | version=find_version("zkviz", "__init__.py"),
31 | author="Alexandre Chabot-Leclerc",
32 | author_email="github@alexchabot.net",
33 | description="Zettel Network Visualizer",
34 | long_description=long_description,
35 | long_description_content_type="text/markdown",
36 | url="https://github.com/Zettelkasten-Method/zkviz",
37 | packages=setuptools.find_packages(),
38 | install_requires=[
39 | 'graphviz',
40 | 'plotly',
41 | 'numpy',
42 | 'scipy',
43 | 'networkx',
44 | ],
45 | classifiers=[
46 | "Programming Language :: Python :: 3",
47 | "License :: OSI Approved :: MIT License",
48 | "Operating System :: OS Independent",
49 | ],
50 | entry_points={
51 | 'console_scripts': [
52 | 'zkviz = zkviz.zkviz:main'
53 | ]
54 | }
55 | )
56 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Zettelkasten-Method/zkviz/d92d40ac20ff50963c80cc4fb77abd097f42d8e5/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_zkviz.py:
--------------------------------------------------------------------------------
1 | from pathlib import Path
2 | from tempfile import TemporaryDirectory
3 | from unittest import TestCase
4 |
5 | from zkviz import zkviz
6 |
7 |
8 | class TestListZettels(TestCase):
9 | def test_list_zettels_with_md_extension(self):
10 | # Create a temporary folder and write files in it
11 | with TemporaryDirectory() as tmpdirname:
12 | ext = ".md"
13 | basename = "201906242157"
14 | filepaths = []
15 | for i in range(3):
16 | path = Path(tmpdirname, basename + str(i) + ext)
17 | path.touch()
18 | filepaths.append(str(path))
19 |
20 | files_found = zkviz.list_zettels(tmpdirname)
21 | self.assertEqual(filepaths, files_found)
22 |
23 | def test_list_zettels_with_txt_extension(self):
24 | # Create a temporary folder and write files in it
25 | with TemporaryDirectory() as tmpdirname:
26 | ext = ".txt"
27 | basename = "201906242157"
28 | filepaths = []
29 | for i in range(3):
30 | path = Path(tmpdirname, basename + str(i) + ext)
31 | path.touch()
32 | filepaths.append(str(path))
33 |
34 | files_found = zkviz.list_zettels(tmpdirname, "*.txt")
35 | self.assertEqual(filepaths, files_found)
36 |
37 | def test_list_zettels_with_mixed_extensions(self):
38 | # Create a temporary folder and write files in it
39 | with TemporaryDirectory() as tmpdirname:
40 | filepaths = []
41 | basename = "201906242157"
42 |
43 | ext = ".txt"
44 | for i in range(5):
45 | path = Path(tmpdirname, basename + str(i) + ext)
46 | path.touch()
47 | filepaths.append(str(path))
48 |
49 | ext = ".md"
50 | for i in range(5, 10):
51 | path = Path(tmpdirname, basename + str(i) + ext)
52 | path.touch()
53 | filepaths.append(str(path))
54 |
55 | files_found = zkviz.list_zettels(tmpdirname, "*.txt|*.md")
56 | self.assertEqual(filepaths, files_found)
57 |
58 |
59 | class TestParseArgs(TestCase):
60 | def test_default_extension(self):
61 | args = zkviz.parse_args("")
62 | self.assertEqual(["*.md"], args.pattern)
63 |
64 | def test_overwrite_extension(self):
65 | args = zkviz.parse_args(["--pattern", "*.txt"])
66 | self.assertEqual(["*.txt"], args.pattern)
67 |
68 | def test_multiple_extensions(self):
69 | args = zkviz.parse_args(["--pattern", "*.txt", "--pattern", "*.md"])
70 | self.assertEqual(["*.txt", "*.md"], args.pattern)
71 |
--------------------------------------------------------------------------------
/zkviz/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "1.3.2"
2 |
--------------------------------------------------------------------------------
/zkviz/graphviz.py:
--------------------------------------------------------------------------------
1 | from textwrap import fill
2 |
3 | from graphviz import Digraph
4 |
5 |
6 | class NetworkGraphviz:
7 | def __init__(self, name="Zettelkasten", engine="sfdp", shape="record"):
8 | """
9 | Build network to visualize with Graphviz.
10 |
11 | Parameters
12 | ----------
13 | name : str (optional)
14 | Name of the network. Default is "Zettelkasten".
15 | engine : str
16 | Layout engine used by Graphviz. The default is 'sfdp', which is a
17 | good default. See the graphviz documentation for alternatives.
18 | shape : str {record, plaintext}
19 | The shape, or style, of each node. The default is "record".
20 | """
21 | self.name = name
22 | self.engine = engine
23 | self.graph = Digraph(comment=self.name, engine=self.engine)
24 | self.shape = shape
25 |
26 | def wrap_title(self, text, width=30):
27 | """
28 | Wrap the title to be a certain width.
29 |
30 | Parameters
31 | ----------
32 | text : str
33 | The text to wrap.
34 | width : int
35 | The text width, in characters.
36 |
37 | """
38 | return fill(text, width)
39 |
40 | def add_node(self, node_id, title):
41 | """
42 | Add a node to the network.
43 |
44 | Parameters
45 | ----------
46 | node_id : str, or int
47 | A unique identifier for the node, typically the zettel ID.
48 | title : str
49 | The text label for each node, typically the zettel title.
50 |
51 | """
52 | if self.shape == "plaintext":
53 | label = self.wrap_title("{} {}".format(node_id, title)).strip()
54 | elif self.shape == "record":
55 | # Wrap in {} so the elements are stacked vertically
56 | label = "{" + "|".join([node_id, self.wrap_title(title)]) + "}"
57 |
58 | self.graph.node(node_id, label, shape=self.shape)
59 |
60 | def add_edge(self, source, target):
61 | """
62 | Add a node (a zettel) to the network.
63 |
64 | Parameters
65 | ----------
66 | source : str or int
67 | The ID of the source zettel.
68 | target : str or int
69 | The ID of the target (cited) zettel.
70 |
71 | """
72 | self.graph.edge(source, target)
73 |
74 | def render(self, output, view=True):
75 | """
76 | Render the network to disk.
77 |
78 | Parameters
79 | ----------
80 | output : str
81 | Name of the output file.
82 | view : bool
83 | Show the network using the default PDF viewer. Default is True.
84 |
85 | """
86 | self.graph.render(output, view=view)
87 |
--------------------------------------------------------------------------------
/zkviz/plotly.py:
--------------------------------------------------------------------------------
1 | import networkx as nx
2 | import plotly.graph_objs as go
3 |
4 |
5 | class NetworkPlotly:
6 | def __init__(self, name="Zettelkasten"):
7 | """
8 | Build network to visualize with Plotly
9 |
10 | Parameters
11 | ----------
12 | name : str
13 | The network name.
14 | """
15 | self.graph = nx.Graph()
16 |
17 | def add_node(self, node_id, title):
18 | """
19 | Add a node to the network.
20 |
21 | Parameters
22 | ----------
23 | node_id : str, or int
24 | A unique identifier for the node, typically the zettel ID.
25 | title : str
26 | The text label for each node, typically the zettel title.
27 |
28 | """
29 | self.graph.add_node(node_id, title=title)
30 |
31 | def add_edge(self, source, target):
32 | """
33 | Add a node (a zettel) to the network.
34 |
35 | Parameters
36 | ----------
37 | source : str or int
38 | The ID of the source zettel.
39 | target : str or int
40 | The ID of the target (cited) zettel.
41 |
42 | """
43 | self.graph.add_edge(source, target)
44 |
45 | def build_plotly_figure(self, pos=None):
46 | """
47 | Creates a Plot.ly Figure that can be view online or offline.
48 |
49 | Parameters
50 | ----------
51 | graph : nx.Graph
52 | The network of zettels to visualize
53 | pos : dict
54 | Dictionay of zettel_id : (x, y) coordinates where to draw nodes. If
55 | None, the Kamada Kawai layout will be used.
56 |
57 | Returns
58 | -------
59 | fig : plotly Figure
60 |
61 | """
62 |
63 | if pos is None:
64 | # The kamada kawai layout produces a really nice graph but it's
65 | # a O(N^2) algorithm. It seems only reasonable to draw the graph
66 | # with fewer than ~1000 nodes.
67 | if len(self.graph) < 1000:
68 | pos = nx.layout.kamada_kawai_layout(self.graph)
69 | else:
70 | pos = nx.layout.random_layout(self.graph)
71 |
72 | # Create scatter plot of the position of all notes
73 | node_trace = go.Scatter(
74 | x=[],
75 | y=[],
76 | text=[],
77 | mode="markers",
78 | hoverinfo="text",
79 | marker=dict(
80 | showscale=True,
81 | # colorscale options
82 | #'Greys' | 'YlGnBu' | 'Greens' | 'YlOrRd' | 'Bluered' | 'RdBu' |
83 | #'Reds' | 'Blues' | 'Picnic' | 'Rainbow' | 'Portland' | 'Jet' |
84 | #'Hot' | 'Blackbody' | 'Earth' | 'Electric' | 'Viridis' |
85 | colorscale="YlGnBu",
86 | reversescale=True,
87 | color=[],
88 | size=10,
89 | colorbar=dict(
90 | thickness=15, title="Centrality", xanchor="left", titleside="right"
91 | ),
92 | line=dict(width=2),
93 | ),
94 | )
95 |
96 | for node in self.graph.nodes():
97 | x, y = pos[node]
98 | text = "
".join([node, self.graph.nodes[node].get("title", "")])
99 | node_trace["x"] += tuple([x])
100 | node_trace["y"] += tuple([y])
101 | node_trace["text"] += tuple([text])
102 |
103 | # Color nodes based on the centrality
104 | for node, centrality in nx.degree_centrality(self.graph).items():
105 | node_trace["marker"]["color"] += tuple([centrality])
106 |
107 | # Draw the edges as annotations because it's only sane way to draw arrows.
108 | edges = []
109 | for from_node, to_node in self.graph.edges():
110 | edges.append(
111 | dict(
112 | # Tail coordinates
113 | ax=pos[from_node][0],
114 | ay=pos[from_node][1],
115 | axref="x",
116 | ayref="y",
117 | # Head coordinates
118 | x=pos[to_node][0],
119 | y=pos[to_node][1],
120 | xref="x",
121 | yref="y",
122 | # Aesthetics
123 | arrowwidth=2,
124 | arrowcolor="#666",
125 | arrowhead=2,
126 | # Have the head stop short 5 px for the center point,
127 | # i.e., depends on the node marker size.
128 | standoff=5,
129 | )
130 | )
131 |
132 | fig = go.Figure(
133 | data=[node_trace],
134 | layout=go.Layout(
135 | showlegend=False,
136 | hovermode="closest",
137 | margin=dict(b=20, l=5, r=5, t=40),
138 | annotations=edges,
139 | xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
140 | yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
141 | ),
142 | )
143 | return fig
144 |
145 | def render(self, output, view=True):
146 | """
147 | Render the network to disk.
148 |
149 | Parameters
150 | ----------
151 | output : str
152 | Name of the output file.
153 | view : bool
154 | Open the rendered network using the default browser. Default is
155 | True.
156 |
157 | """
158 | fig = self.build_plotly_figure()
159 | if not output.endswith(".html"):
160 | output += ".html"
161 | fig.write_html(output, auto_open=view)
162 |
--------------------------------------------------------------------------------
/zkviz/zkviz.py:
--------------------------------------------------------------------------------
1 | """
2 |
3 | Visualize the notes network of a Zettelkasten.
4 |
5 | Each arrow represents a link from one zettel to another. The script assumes
6 | that zettels have filenames of the form "YYYYMMDDHHMM This is a title" and that
7 | links have the form [[YYYYMMDDHHMM]]
8 |
9 | """
10 | import glob
11 | import os.path
12 | import re
13 | from textwrap import fill
14 |
15 |
16 | PAT_ZK_ID = re.compile(r"^(?P\d+)\s(.*)")
17 | PAT_LINK = re.compile(r"\[\[(\d+)\]\]")
18 |
19 |
20 | def parse_zettels(filepaths):
21 | """ Parse the ID and title from the filename.
22 |
23 | Assumes that the filename has the format "YYYYMMDDHHMMSS This is title"
24 |
25 | """
26 | documents = []
27 | for filepath in filepaths:
28 | basename = os.path.basename(filepath)
29 | filename, ext = os.path.splitext(basename)
30 | r = PAT_ZK_ID.match(filename)
31 | if not r:
32 | continue
33 |
34 | with open(filepath, encoding="utf-8") as f:
35 | links = PAT_LINK.findall(f.read())
36 |
37 | document = dict(id=r.group(1), title=r.group(2), links=links)
38 | documents.append(document)
39 | return documents
40 |
41 |
42 | def create_graph(zettels, graph, include_self_references=True, only_listed=False):
43 | """
44 | Create of graph of the zettels linking to each other.
45 |
46 | Parameters
47 | ----------
48 | zettels : list of dictionaries
49 | include_self_references : bool, optional
50 | Include links to the source document. Defaults to True.
51 | only_listed : bool, optional
52 | Only include nodes in the graph it's actually one of the zettels.
53 | Default is False.
54 |
55 | Returns
56 | -------
57 | graph : nx.Graph
58 |
59 | """
60 |
61 | # Collect IDs from source zettels and from zettels linked
62 | zettel_ids = set()
63 | link_ids = set()
64 | for zettel in zettels:
65 | zettel_ids.add(zettel["id"])
66 | link_ids.update(zettel["links"])
67 |
68 | if only_listed:
69 | ids_to_include = zettel_ids
70 | else:
71 | ids_to_include = zettel_ids | link_ids
72 |
73 | for zettel in zettels:
74 | graph.add_node(zettel["id"], title=zettel["title"])
75 | for link in zettel["links"]:
76 | if link not in ids_to_include:
77 | continue
78 | if include_self_references or (zettel["id"] != link):
79 | # Truth table for the inclusion conditional
80 | # IS_SAME IS_DIFF (Is different ID)
81 | # INCLUDE T T
82 | # DON'T INCLUDE F T
83 | graph.add_edge(zettel["id"], link)
84 | return graph
85 |
86 |
87 | def list_zettels(notes_dir, pattern="*.md"):
88 | """
89 | List zettels in a directory.
90 |
91 | Parameters
92 | ----------
93 | notes_dir : str
94 | Path to the directory containing the zettels.
95 | pattern : str (optional)
96 | Pattern matching zettels. The default is '*.md'. If there are multiple
97 | patterns, separate them with a |, such as in '*.md|*.txt'
98 |
99 | """
100 |
101 | filepaths = []
102 |
103 | for patt in pattern.split("|"):
104 | filepaths.extend(glob.glob(os.path.join(notes_dir, patt)))
105 | return sorted(filepaths)
106 |
107 |
108 | def parse_args(args=None):
109 | from argparse import ArgumentParser
110 |
111 | parser = ArgumentParser(description=__doc__)
112 | parser.add_argument(
113 | "--notes-dir", default=".", help="path to folder containin notes. [.]"
114 | )
115 | parser.add_argument(
116 | "--output",
117 | default="zettel-network",
118 | help="name of output file. [zettel-network]",
119 | )
120 | parser.add_argument(
121 | "--pattern",
122 | action="append",
123 | help=(
124 | "pattern to match notes. You can repeat this argument to"
125 | " match multiple file types. [*.md]"
126 | ),
127 | )
128 | parser.add_argument(
129 | "--use-graphviz",
130 | action="store_true",
131 | default=False,
132 | help="Use Graphviz instead of plotly to render the network.",
133 | )
134 | parser.add_argument(
135 | "--no-self-ref",
136 | action="store_false",
137 | default=True,
138 | dest="include_self_references",
139 | help="Do not include self-references in a zettel.",
140 | )
141 | parser.add_argument(
142 | "--only-listed",
143 | action="store_true",
144 | default=False,
145 | help="Only include links to documents that are in the file list",
146 | )
147 | parser.add_argument("zettel_paths", nargs="*", help="zettel file paths.")
148 | args = parser.parse_args(args=args)
149 |
150 | # Use the list of files the user specify, otherwise, fall back to
151 | # listing a directory.
152 | if not args.zettel_paths:
153 | if args.pattern is None:
154 | args.pattern = ["*.md"]
155 | patterns = "|".join(args.pattern)
156 |
157 | args.zettel_paths = list_zettels(args.notes_dir, pattern=patterns)
158 | return args
159 |
160 |
161 | def main(args=None):
162 | args = parse_args(args)
163 |
164 | zettels = parse_zettels(args.zettel_paths)
165 |
166 | # Fail in case we didn't find a zettel
167 | if not zettels:
168 | raise FileNotFoundError("I'm sorry, I couldn't find any files.")
169 |
170 | if args.use_graphviz:
171 | from zkviz.graphviz import NetworkGraphviz
172 | import graphviz
173 |
174 | try:
175 | graphviz.version()
176 | except graphviz.ExecutableNotFound:
177 | raise FileNotFoundError(
178 | fill(
179 | "The Graphviz application must be installed for the"
180 | " --use-graphviz option to work. Please see"
181 | " https://graphviz.org/download/ for installation"
182 | " instructions."
183 | )
184 | )
185 | graph = NetworkGraphviz()
186 | else:
187 | from zkviz.plotly import NetworkPlotly
188 |
189 | graph = NetworkPlotly()
190 |
191 | graph = create_graph(
192 | zettels,
193 | graph,
194 | include_self_references=args.include_self_references,
195 | only_listed=args.only_listed,
196 | )
197 | graph.render(args.output)
198 |
199 |
200 | if __name__ == "__main__":
201 | import sys
202 |
203 | try:
204 | sys.exit(main())
205 | except FileNotFoundError as e:
206 | # Failed either because it didn't find any files or because Graphviz
207 | # wasn't installed
208 | sys.exit(e)
209 |
--------------------------------------------------------------------------------