├── .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 | ![Version](https://img.shields.io/github/tag/Zettelkasten-Method/zkviz.svg?style=flat) 4 | ![License](https://img.shields.io/github/license/Zettelkasten-Method/zkviz.svg?style=flat) 5 | 6 | Produce an interactive overview of all your notes and their connections. 7 | 8 | ![](assets/screenshot.png) 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 | --------------------------------------------------------------------------------