├── .eslintrc.json ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── demo.gif ├── docs ├── id-based-links.md ├── local-links.md └── roadmap.md ├── examples ├── functionality │ ├── README.md │ ├── With spaces - should work too.md │ ├── chain.md │ ├── frontmatter.md │ ├── longer.md │ ├── much.md │ ├── nested │ │ ├── 3.md │ │ ├── another.md │ │ ├── links.md │ │ ├── more.md │ │ ├── nested.md │ │ └── other.md │ ├── next.md │ ├── such space │ │ └── With spaces - nested.md │ └── wiki-links.md ├── miserables │ └── miserablelise.js └── wiki-links │ ├── id-below-link.md │ ├── id-on-top.md │ ├── leaf-note.md │ └── various links.md ├── package.json ├── src ├── extension.ts ├── parsing.ts ├── test │ ├── runTest.ts │ └── suite │ │ ├── extension.test.ts │ │ └── index.ts ├── types.ts └── utils.ts ├── static ├── d3.min.js ├── graphs │ ├── default │ │ ├── graph.css │ │ └── graph.js │ └── obsidian │ │ ├── graph.css │ │ └── graph.js ├── main.css └── webview.html ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": ["@typescript-eslint"], 9 | "ignorePatterns": ["**/*.min.js"], 10 | "rules": { 11 | "@typescript-eslint/class-name-casing": "warn", 12 | "@typescript-eslint/semi": "warn", 13 | "curly": "warn", 14 | "eqeqeq": "warn", 15 | "no-throw-literal": "warn", 16 | "semi": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | examples/miserables/*.md 7 | package-lock.json 8 | .DS_Store -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "runtimeExecutable": "${execPath}", 13 | "args": ["--extensionDevelopmentPath=${workspaceFolder}"], 14 | "outFiles": ["${workspaceFolder}/dist/**/*.js"], 15 | "preLaunchTask": "${defaultBuildTask}" 16 | }, 17 | { 18 | "name": "Extension Tests", 19 | "type": "extensionHost", 20 | "request": "launch", 21 | "runtimeExecutable": "${execPath}", 22 | "args": [ 23 | "--extensionDevelopmentPath=${workspaceFolder}", 24 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 25 | ], 26 | "outFiles": ["${workspaceFolder}/out/test/**/*.js"], 27 | "preLaunchTask": "${defaultBuildTask}" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": "build", 15 | "label": "npm: watch", 16 | "detail": "tsc -watch -p ./" 17 | }, 18 | { 19 | "type": "npm", 20 | "script": "webpack", 21 | "problemMatcher": [], 22 | "label": "npm: webpack", 23 | "detail": "webpack --mode development", 24 | "group": { 25 | "kind": "build", 26 | "isDefault": true 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | node_modules/** 4 | out/test/** 5 | src/** 6 | examples/** 7 | .gitignore 8 | **/tsconfig.json 9 | **/.eslintrc.json 10 | **/*.map 11 | **/*.ts 12 | **/*.gif 13 | webpack.config.js 14 | -------------------------------------------------------------------------------- /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 | ## [0.8.0] – 2020-08-22 9 | 10 | ### Added 11 | 12 | - Custom rendering modes! Starting with two choices: `default` and `obsidian` (inspired by the Obsidian editor) available under `Markdown-links: Graph Type` ([#51](https://github.com/tchayen/markdown-links/pull/51)). 13 | 14 | ### Changed 15 | 16 | - Ignored files specified in workspace are now respected ([#35](https://github.com/tchayen/markdown-links/pull/35)). 17 | ### Fixed 18 | 19 | - Fixed the problem with deleting and or renaming files on Windows ([#53](https://github.com/tchayen/markdown-links/pull/53)). 20 | - Non-existing edges are now re-filtered when deleting files ([#54](https://github.com/tchayen/markdown-links/pull/54)). 21 | 22 | ## [0.7.0] – 2020-07-25 23 | 24 | ### Added 25 | 26 | - Extensions to look for can now be specified in settings ([#31](https://github.com/tchayen/markdown-links/pull/31)). 27 | - Configurable autostart ([#14](https://github.com/tchayen/markdown-links/pull/14)). 28 | 29 | ### Fixed 30 | 31 | - Graph should now properly update on adding a new file ([#34](https://github.com/tchayen/markdown-links/pull/34)). 32 | - Wrong active node size when zooming ([#20](https://github.com/tchayen/markdown-links/pull/20)). 33 | 34 | ## [0.6.0] – 2020-06-26 35 | 36 | ### Added 37 | 38 | - Support for [file-name] ids. A format where id of a file is its name (works either with or without `.md` extension) ([#13](https://github.com/tchayen/markdown-links/pull/13)). 39 | 40 | ### Fixed 41 | 42 | - Active node highlight should work better on Windows ([#10](https://github.com/tchayen/markdown-links/pull/10)). 43 | 44 | ### Changed 45 | 46 | - Extension will now pick colors of your theme ([#10](https://github.com/tchayen/markdown-links/pull/10)). 47 | 48 | ## [0.5.0] – 2020-05-23 49 | 50 | ### Changed 51 | 52 | - When active file in the editor changes, it will be highlighted on the graph if it matches one of the nodes. 53 | 54 | ## [0.4.0] - 2020-05-23 55 | 56 | ### Added 57 | 58 | - Support for wiki-style links using `[[link]]` format and corresponding `fileIdRegexp` setting for specifying regular expression for resolving file IDs – ([#3](https://github.com/tchayen/markdown-links/pull/3)). 59 | 60 | ## [0.3.0] - 2020-05-22 61 | 62 | ### Changed 63 | 64 | - `column` setting is now divided into `openColumn` and `showColumn`. 65 | 66 | ## [0.2.3] - 2020-05-22 67 | 68 | ### Fixed 69 | 70 | - Graph reloads on title change. 71 | - Parsing of files in a directory now happens asynchronously. 72 | 73 | ## [0.2.2] - 2020-05-22 74 | 75 | ### Fixed 76 | 77 | - Fix bug with missing `d3`. 78 | 79 | ## [0.2.1] - 2020-05-21 80 | 81 | ### Fixed 82 | 83 | - Fix bug with missing `webview.html`. 84 | 85 | ## [0.2.0] - 2020-05-21 86 | 87 | ### Changed 88 | 89 | - Extension is now configured to use bundle. 90 | 91 | ## [0.1.0] - 2020-05-21 92 | 93 | ### Added 94 | 95 | - Initial version of the `Show Graph` command. 96 | - Setting for controlling the `column` used to open files. 97 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2020 Tomasz Czajęcki 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdown Links 2 | 3 | Adds command `Show Graph` that displays a graph of local links between Markdown files in the current working directory. 4 | 5 | Gives better insights into structure of your files if you are writinga wiki, a Zettelkasten style notebook or documentation. 6 | 7 | ![Demo GIF](demo.gif) 8 | 9 | ## Workflow 10 | 11 | Recommended workflow is either keeping the graph open and using it as an alternative to the explorer sidebar or checking the it from time to time. 12 | 13 | The graph refreshes automatically every time you: 14 | 15 | - Update a Markdown title of the file. 16 | - Change links to other files. 17 | - Create a new file and add give it a title. 18 | - Remove a file. 19 | 20 | When active file in the editor changes and it matches one of the files in the graph – it will be highlighted. 21 | 22 | ## Concepts 23 | 24 | - Title is always the first Markdown heading of depth 1, i.e. `# Title`. 25 | - Files which do not have a title do not appear in the graph. 26 | - Files can link to other files using [local Markdown links](docs/local-links.md), [ID-based links](docs/id-based-links.md), or `[file-name]` links. 27 | - The graph is not directed. It doesn't show which file has the link and which one is linked. 28 | - Directory structure is not relevant for the graph. All that matters is the mutual links between files. 29 | 30 | ## Example 31 | 32 | ```md 33 | # Title 34 | 35 | Link can be present in [text](first.md) or on a special list. 36 | 37 | ## Linked 38 | 39 | - [Second](./2.md) 40 | 41 | Named reference can also be used, like this: [Reference]. 42 | 43 | [reference]: ref.md 44 | ``` 45 | 46 | ## Settings 47 | 48 | This extension contributes the following settings: 49 | 50 | ### `markdown-links.showColumn` 51 | 52 | Controls in which column should the graph appear. Refer to [Column values](####column-values). Defaults to `beside`. 53 | 54 | ### `markdown-links.openColumn` 55 | 56 | Controls in which column should clicked files open. Refer to [Column values](###c#olumn-values). Defaults to `one`. 57 | 58 | #### Column values 59 | 60 | - `active` – in the currently focused column. 61 | - `beside` – other than the current. 62 | - `one` (**default**), `two`, `three`, `four`, `five`, `six`, `seven`, `eight`, `nine` – respective editor columns. 63 | 64 | ### `markdown-links.fileIdRegexp` 65 | 66 | A [regular expression](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) used to find the file ID for use in wiki-style links. 67 | 68 | ### `markdown-links.graphType` 69 | 70 | - `default` (**default**) 71 | - `obsidian` - obsidian like graph 72 | 73 | ### `markdown-links.titleMaxLength` 74 | 75 | The maximum title length before being abbreviated. Set to 0 or less to disable. 76 | 77 | #### Example 78 | 79 | The sentence: 80 | 81 | ``` 82 | Type checking a multithreaded functional language with session types 83 | ``` 84 | 85 | When abbreviated for a maximum length of 24, becomes: 86 | 87 | ``` 88 | Type checking a multithr... 89 | ``` 90 | 91 | ## Roadmap 92 | 93 | Plans for development are roughly summarized in the [Roadmap](docs/roadmap.md). 94 | 95 | ## Changelog 96 | 97 | Refer to the [CHANGELOG.md](CHANGELOG.md) file. 98 | 99 | ## Contributing 100 | 101 | You are very welcome to open an issue or a pull request with changes. 102 | 103 | If it is your first time with vscode extension, make sure to checkout [Official Guides](https://code.visualstudio.com/api/get-started/your-first-extension). 104 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tchayen/markdown-links/ca5b5e82d783a58794f9f1621b9453faa22b2326/demo.gif -------------------------------------------------------------------------------- /docs/id-based-links.md: -------------------------------------------------------------------------------- 1 | # ID-based links 2 | 3 | A file can be given an ID that can be used to link it from other files. The ID is a first string matching a configured pattern (see [Settings](#settings)) found in the file. 4 | 5 | File having an ID can be linked using double-bracketed 'wiki-style' links. For example: 6 | 7 | ```md 8 | 9 | 10 | # This is a file having an id 11 | 12 | This is its id: 20200522225822 13 | ``` 14 | 15 | ```md 16 | 17 | 18 | # This is a file linking to another 19 | 20 | See the other file: [[20200522225822]] 21 | ``` 22 | 23 | This feature is heavily inspired by [Zettlr](https://github.com/Zettlr/Zettlr), therefore its [documentation](https://docs.zettlr.com/en/reference/settings/#the-id-regex) may give useful background. 24 | 25 | #### Using title as the ID 26 | 27 | By setting the ID regexp setting to `(?<=^# ).+$` all titles (level 1 # headings) will be detected as IDs. This allows you to do the following: 28 | 29 | ```md 30 | 31 | 32 | # This file just has a title 33 | 34 | And some content. 35 | ``` 36 | 37 | ```md 38 | 39 | 40 | # This is a file linking to another 41 | 42 | See the other file: [[This file just has a title]] 43 | ``` 44 | 45 | You'll have to **restart VS Code** for the changed setting to take effect. 46 | -------------------------------------------------------------------------------- /docs/local-links.md: -------------------------------------------------------------------------------- 1 | # Local Markdown links 2 | 3 | The usual Markdown links syntax with a relative or absolute file. 4 | 5 | ```md 6 | 7 | 8 | # First file 9 | ``` 10 | 11 | ```md 12 | 13 | 14 | # This is a file linking to another 15 | 16 | See the other file: [First file](file1.md) 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/roadmap.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | This is early development version. I am currently considering: 4 | 5 | - [x] Main `Show Graph` command. 6 | - [x] Setting for choosing column for opening files. 7 | - [x] Support for Zettlr-style ID-based links. 8 | - [x] Highlighting of focused file. 9 | - [ ] Automated tests. _WIP by [@tchayen](https://github.com/tchayen)_ 10 | - [ ] ~~Dark theme support (+ auto detecting system's dark/light mode).~~ Support for detecting theme's colors was added. 11 | - [ ] Zoom button controls (`+` / `-` / `reset`). 12 | - [ ] Optional displaying of external URLs in the graph. 13 | - [ ] Ignoring files or directories. 14 | - [ ] Some directory controls within the graph view (adding, removing files). 15 | - [ ] Configurable parameters of graph simulation. 16 | - [ ] Optional display of edge directions. 17 | - [ ] Better examples. 18 | - [x] Optional autostart. 19 | -------------------------------------------------------------------------------- /examples/functionality/README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | This example is supposed to test as many features of the library as it makes sense. Ideally, if some important feature breaks, it should be noticeable on this directory's graph. 4 | 5 | [Doesnt matter here](next.md) 6 | 7 | [With spaces - should work too](With spaces - should work too.md) 8 | -------------------------------------------------------------------------------- /examples/functionality/With spaces - should work too.md: -------------------------------------------------------------------------------- 1 | # With spaces - should work too 2 | 3 | [Next](next.md) 4 | [Another space](such%20space/With%20spaces%20-%20nested.md) 5 | -------------------------------------------------------------------------------- /examples/functionality/chain.md: -------------------------------------------------------------------------------- 1 | # Chain 2 | 3 | [More](nested/more.md) 4 | -------------------------------------------------------------------------------- /examples/functionality/frontmatter.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Test 3 | id: 123 4 | --- 5 | 6 | # Frontmatter 7 | 8 | [README](README.md) 9 | -------------------------------------------------------------------------------- /examples/functionality/longer.md: -------------------------------------------------------------------------------- 1 | # Longer 2 | 3 | [Chain](chain.md) 4 | -------------------------------------------------------------------------------- /examples/functionality/much.md: -------------------------------------------------------------------------------- 1 | # Much 2 | 3 | [Longer](longer.md) 4 | -------------------------------------------------------------------------------- /examples/functionality/nested/3.md: -------------------------------------------------------------------------------- 1 | # Even longer 2 | 3 | [Much](../much.md) 4 | -------------------------------------------------------------------------------- /examples/functionality/nested/another.md: -------------------------------------------------------------------------------- 1 | # Another 2 | 3 | [Home](../README.md) 4 | -------------------------------------------------------------------------------- /examples/functionality/nested/links.md: -------------------------------------------------------------------------------- 1 | # Links 2 | 3 | [Another](another.md) 4 | [Other](other.md) 5 | [Nested](nested.md) 6 | [Error](error.md) 7 | 8 | [More] 9 | 10 | [more]: more.md 11 | 12 | [nowhere]: <> 13 | -------------------------------------------------------------------------------- /examples/functionality/nested/more.md: -------------------------------------------------------------------------------- 1 | # More 2 | 3 | [Another](another.md) 4 | -------------------------------------------------------------------------------- /examples/functionality/nested/nested.md: -------------------------------------------------------------------------------- 1 | # Nested 2 | 3 | [Back to main](../README.md) 4 | -------------------------------------------------------------------------------- /examples/functionality/nested/other.md: -------------------------------------------------------------------------------- 1 | # Other 2 | 3 | [Another](another.md) 4 | -------------------------------------------------------------------------------- /examples/functionality/next.md: -------------------------------------------------------------------------------- 1 | # Next file here 2 | 3 | [Nested](nested/nested.md) 4 | -------------------------------------------------------------------------------- /examples/functionality/such space/With spaces - nested.md: -------------------------------------------------------------------------------- 1 | # With spaces - nested 2 | 3 | [Next](../nested/nested.md) 4 | -------------------------------------------------------------------------------- /examples/functionality/wiki-links.md: -------------------------------------------------------------------------------- 1 | # Wiki 2 | 3 | [[Wiki style link]]. 4 | -------------------------------------------------------------------------------- /examples/miserables/miserablelise.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | 3 | const graph = { 4 | nodes: [ 5 | { id: "Myriel", group: 1 }, 6 | { id: "Napoleon", group: 1 }, 7 | { id: "Mlle.Baptistine", group: 1 }, 8 | { id: "Mme.Magloire", group: 1 }, 9 | { id: "CountessdeLo", group: 1 }, 10 | { id: "Geborand", group: 1 }, 11 | { id: "Champtercier", group: 1 }, 12 | { id: "Cravatte", group: 1 }, 13 | { id: "Count", group: 1 }, 14 | { id: "OldMan", group: 1 }, 15 | { id: "Labarre", group: 2 }, 16 | { id: "Valjean", group: 2 }, 17 | { id: "Marguerite", group: 3 }, 18 | { id: "Mme.deR", group: 2 }, 19 | { id: "Isabeau", group: 2 }, 20 | { id: "Gervais", group: 2 }, 21 | { id: "Tholomyes", group: 3 }, 22 | { id: "Listolier", group: 3 }, 23 | { id: "Fameuil", group: 3 }, 24 | { id: "Blacheville", group: 3 }, 25 | { id: "Favourite", group: 3 }, 26 | { id: "Dahlia", group: 3 }, 27 | { id: "Zephine", group: 3 }, 28 | { id: "Fantine", group: 3 }, 29 | { id: "Mme.Thenardier", group: 4 }, 30 | { id: "Thenardier", group: 4 }, 31 | { id: "Cosette", group: 5 }, 32 | { id: "Javert", group: 4 }, 33 | { id: "Fauchelevent", group: 0 }, 34 | { id: "Bamatabois", group: 2 }, 35 | { id: "Perpetue", group: 3 }, 36 | { id: "Simplice", group: 2 }, 37 | { id: "Scaufflaire", group: 2 }, 38 | { id: "Woman1", group: 2 }, 39 | { id: "Judge", group: 2 }, 40 | { id: "Champmathieu", group: 2 }, 41 | { id: "Brevet", group: 2 }, 42 | { id: "Chenildieu", group: 2 }, 43 | { id: "Cochepaille", group: 2 }, 44 | { id: "Pontmercy", group: 4 }, 45 | { id: "Boulatruelle", group: 6 }, 46 | { id: "Eponine", group: 4 }, 47 | { id: "Anzelma", group: 4 }, 48 | { id: "Woman2", group: 5 }, 49 | { id: "MotherInnocent", group: 0 }, 50 | { id: "Gribier", group: 0 }, 51 | { id: "Jondrette", group: 7 }, 52 | { id: "Mme.Burgon", group: 7 }, 53 | { id: "Gavroche", group: 8 }, 54 | { id: "Gillenormand", group: 5 }, 55 | { id: "Magnon", group: 5 }, 56 | { id: "Mlle.Gillenormand", group: 5 }, 57 | { id: "Mme.Pontmercy", group: 5 }, 58 | { id: "Mlle.Vaubois", group: 5 }, 59 | { id: "Lt.Gillenormand", group: 5 }, 60 | { id: "Marius", group: 8 }, 61 | { id: "BaronessT", group: 5 }, 62 | { id: "Mabeuf", group: 8 }, 63 | { id: "Enjolras", group: 8 }, 64 | { id: "Combeferre", group: 8 }, 65 | { id: "Prouvaire", group: 8 }, 66 | { id: "Feuilly", group: 8 }, 67 | { id: "Courfeyrac", group: 8 }, 68 | { id: "Bahorel", group: 8 }, 69 | { id: "Bossuet", group: 8 }, 70 | { id: "Joly", group: 8 }, 71 | { id: "Grantaire", group: 8 }, 72 | { id: "MotherPlutarch", group: 9 }, 73 | { id: "Gueulemer", group: 4 }, 74 | { id: "Babet", group: 4 }, 75 | { id: "Claquesous", group: 4 }, 76 | { id: "Montparnasse", group: 4 }, 77 | { id: "Toussaint", group: 5 }, 78 | { id: "Child1", group: 10 }, 79 | { id: "Child2", group: 10 }, 80 | { id: "Brujon", group: 4 }, 81 | { id: "Mme.Hucheloup", group: 8 }, 82 | ], 83 | links: [ 84 | { source: "Napoleon", target: "Myriel", value: 1 }, 85 | { source: "Mlle.Baptistine", target: "Myriel", value: 8 }, 86 | { source: "Mme.Magloire", target: "Myriel", value: 10 }, 87 | { source: "Mme.Magloire", target: "Mlle.Baptistine", value: 6 }, 88 | { source: "CountessdeLo", target: "Myriel", value: 1 }, 89 | { source: "Geborand", target: "Myriel", value: 1 }, 90 | { source: "Champtercier", target: "Myriel", value: 1 }, 91 | { source: "Cravatte", target: "Myriel", value: 1 }, 92 | { source: "Count", target: "Myriel", value: 2 }, 93 | { source: "OldMan", target: "Myriel", value: 1 }, 94 | { source: "Valjean", target: "Labarre", value: 1 }, 95 | { source: "Valjean", target: "Mme.Magloire", value: 3 }, 96 | { source: "Valjean", target: "Mlle.Baptistine", value: 3 }, 97 | { source: "Valjean", target: "Myriel", value: 5 }, 98 | { source: "Marguerite", target: "Valjean", value: 1 }, 99 | { source: "Mme.deR", target: "Valjean", value: 1 }, 100 | { source: "Isabeau", target: "Valjean", value: 1 }, 101 | { source: "Gervais", target: "Valjean", value: 1 }, 102 | { source: "Listolier", target: "Tholomyes", value: 4 }, 103 | { source: "Fameuil", target: "Tholomyes", value: 4 }, 104 | { source: "Fameuil", target: "Listolier", value: 4 }, 105 | { source: "Blacheville", target: "Tholomyes", value: 4 }, 106 | { source: "Blacheville", target: "Listolier", value: 4 }, 107 | { source: "Blacheville", target: "Fameuil", value: 4 }, 108 | { source: "Favourite", target: "Tholomyes", value: 3 }, 109 | { source: "Favourite", target: "Listolier", value: 3 }, 110 | { source: "Favourite", target: "Fameuil", value: 3 }, 111 | { source: "Favourite", target: "Blacheville", value: 4 }, 112 | { source: "Dahlia", target: "Tholomyes", value: 3 }, 113 | { source: "Dahlia", target: "Listolier", value: 3 }, 114 | { source: "Dahlia", target: "Fameuil", value: 3 }, 115 | { source: "Dahlia", target: "Blacheville", value: 3 }, 116 | { source: "Dahlia", target: "Favourite", value: 5 }, 117 | { source: "Zephine", target: "Tholomyes", value: 3 }, 118 | { source: "Zephine", target: "Listolier", value: 3 }, 119 | { source: "Zephine", target: "Fameuil", value: 3 }, 120 | { source: "Zephine", target: "Blacheville", value: 3 }, 121 | { source: "Zephine", target: "Favourite", value: 4 }, 122 | { source: "Zephine", target: "Dahlia", value: 4 }, 123 | { source: "Fantine", target: "Tholomyes", value: 3 }, 124 | { source: "Fantine", target: "Listolier", value: 3 }, 125 | { source: "Fantine", target: "Fameuil", value: 3 }, 126 | { source: "Fantine", target: "Blacheville", value: 3 }, 127 | { source: "Fantine", target: "Favourite", value: 4 }, 128 | { source: "Fantine", target: "Dahlia", value: 4 }, 129 | { source: "Fantine", target: "Zephine", value: 4 }, 130 | { source: "Fantine", target: "Marguerite", value: 2 }, 131 | { source: "Fantine", target: "Valjean", value: 9 }, 132 | { source: "Mme.Thenardier", target: "Fantine", value: 2 }, 133 | { source: "Mme.Thenardier", target: "Valjean", value: 7 }, 134 | { source: "Thenardier", target: "Mme.Thenardier", value: 13 }, 135 | { source: "Thenardier", target: "Fantine", value: 1 }, 136 | { source: "Thenardier", target: "Valjean", value: 12 }, 137 | { source: "Cosette", target: "Mme.Thenardier", value: 4 }, 138 | { source: "Cosette", target: "Valjean", value: 31 }, 139 | { source: "Cosette", target: "Tholomyes", value: 1 }, 140 | { source: "Cosette", target: "Thenardier", value: 1 }, 141 | { source: "Javert", target: "Valjean", value: 17 }, 142 | { source: "Javert", target: "Fantine", value: 5 }, 143 | { source: "Javert", target: "Thenardier", value: 5 }, 144 | { source: "Javert", target: "Mme.Thenardier", value: 1 }, 145 | { source: "Javert", target: "Cosette", value: 1 }, 146 | { source: "Fauchelevent", target: "Valjean", value: 8 }, 147 | { source: "Fauchelevent", target: "Javert", value: 1 }, 148 | { source: "Bamatabois", target: "Fantine", value: 1 }, 149 | { source: "Bamatabois", target: "Javert", value: 1 }, 150 | { source: "Bamatabois", target: "Valjean", value: 2 }, 151 | { source: "Perpetue", target: "Fantine", value: 1 }, 152 | { source: "Simplice", target: "Perpetue", value: 2 }, 153 | { source: "Simplice", target: "Valjean", value: 3 }, 154 | { source: "Simplice", target: "Fantine", value: 2 }, 155 | { source: "Simplice", target: "Javert", value: 1 }, 156 | { source: "Scaufflaire", target: "Valjean", value: 1 }, 157 | { source: "Woman1", target: "Valjean", value: 2 }, 158 | { source: "Woman1", target: "Javert", value: 1 }, 159 | { source: "Judge", target: "Valjean", value: 3 }, 160 | { source: "Judge", target: "Bamatabois", value: 2 }, 161 | { source: "Champmathieu", target: "Valjean", value: 3 }, 162 | { source: "Champmathieu", target: "Judge", value: 3 }, 163 | { source: "Champmathieu", target: "Bamatabois", value: 2 }, 164 | { source: "Brevet", target: "Judge", value: 2 }, 165 | { source: "Brevet", target: "Champmathieu", value: 2 }, 166 | { source: "Brevet", target: "Valjean", value: 2 }, 167 | { source: "Brevet", target: "Bamatabois", value: 1 }, 168 | { source: "Chenildieu", target: "Judge", value: 2 }, 169 | { source: "Chenildieu", target: "Champmathieu", value: 2 }, 170 | { source: "Chenildieu", target: "Brevet", value: 2 }, 171 | { source: "Chenildieu", target: "Valjean", value: 2 }, 172 | { source: "Chenildieu", target: "Bamatabois", value: 1 }, 173 | { source: "Cochepaille", target: "Judge", value: 2 }, 174 | { source: "Cochepaille", target: "Champmathieu", value: 2 }, 175 | { source: "Cochepaille", target: "Brevet", value: 2 }, 176 | { source: "Cochepaille", target: "Chenildieu", value: 2 }, 177 | { source: "Cochepaille", target: "Valjean", value: 2 }, 178 | { source: "Cochepaille", target: "Bamatabois", value: 1 }, 179 | { source: "Pontmercy", target: "Thenardier", value: 1 }, 180 | { source: "Boulatruelle", target: "Thenardier", value: 1 }, 181 | { source: "Eponine", target: "Mme.Thenardier", value: 2 }, 182 | { source: "Eponine", target: "Thenardier", value: 3 }, 183 | { source: "Anzelma", target: "Eponine", value: 2 }, 184 | { source: "Anzelma", target: "Thenardier", value: 2 }, 185 | { source: "Anzelma", target: "Mme.Thenardier", value: 1 }, 186 | { source: "Woman2", target: "Valjean", value: 3 }, 187 | { source: "Woman2", target: "Cosette", value: 1 }, 188 | { source: "Woman2", target: "Javert", value: 1 }, 189 | { source: "MotherInnocent", target: "Fauchelevent", value: 3 }, 190 | { source: "MotherInnocent", target: "Valjean", value: 1 }, 191 | { source: "Gribier", target: "Fauchelevent", value: 2 }, 192 | { source: "Mme.Burgon", target: "Jondrette", value: 1 }, 193 | { source: "Gavroche", target: "Mme.Burgon", value: 2 }, 194 | { source: "Gavroche", target: "Thenardier", value: 1 }, 195 | { source: "Gavroche", target: "Javert", value: 1 }, 196 | { source: "Gavroche", target: "Valjean", value: 1 }, 197 | { source: "Gillenormand", target: "Cosette", value: 3 }, 198 | { source: "Gillenormand", target: "Valjean", value: 2 }, 199 | { source: "Magnon", target: "Gillenormand", value: 1 }, 200 | { source: "Magnon", target: "Mme.Thenardier", value: 1 }, 201 | { source: "Mlle.Gillenormand", target: "Gillenormand", value: 9 }, 202 | { source: "Mlle.Gillenormand", target: "Cosette", value: 2 }, 203 | { source: "Mlle.Gillenormand", target: "Valjean", value: 2 }, 204 | { source: "Mme.Pontmercy", target: "Mlle.Gillenormand", value: 1 }, 205 | { source: "Mme.Pontmercy", target: "Pontmercy", value: 1 }, 206 | { source: "Mlle.Vaubois", target: "Mlle.Gillenormand", value: 1 }, 207 | { source: "Lt.Gillenormand", target: "Mlle.Gillenormand", value: 2 }, 208 | { source: "Lt.Gillenormand", target: "Gillenormand", value: 1 }, 209 | { source: "Lt.Gillenormand", target: "Cosette", value: 1 }, 210 | { source: "Marius", target: "Mlle.Gillenormand", value: 6 }, 211 | { source: "Marius", target: "Gillenormand", value: 12 }, 212 | { source: "Marius", target: "Pontmercy", value: 1 }, 213 | { source: "Marius", target: "Lt.Gillenormand", value: 1 }, 214 | { source: "Marius", target: "Cosette", value: 21 }, 215 | { source: "Marius", target: "Valjean", value: 19 }, 216 | { source: "Marius", target: "Tholomyes", value: 1 }, 217 | { source: "Marius", target: "Thenardier", value: 2 }, 218 | { source: "Marius", target: "Eponine", value: 5 }, 219 | { source: "Marius", target: "Gavroche", value: 4 }, 220 | { source: "BaronessT", target: "Gillenormand", value: 1 }, 221 | { source: "BaronessT", target: "Marius", value: 1 }, 222 | { source: "Mabeuf", target: "Marius", value: 1 }, 223 | { source: "Mabeuf", target: "Eponine", value: 1 }, 224 | { source: "Mabeuf", target: "Gavroche", value: 1 }, 225 | { source: "Enjolras", target: "Marius", value: 7 }, 226 | { source: "Enjolras", target: "Gavroche", value: 7 }, 227 | { source: "Enjolras", target: "Javert", value: 6 }, 228 | { source: "Enjolras", target: "Mabeuf", value: 1 }, 229 | { source: "Enjolras", target: "Valjean", value: 4 }, 230 | { source: "Combeferre", target: "Enjolras", value: 15 }, 231 | { source: "Combeferre", target: "Marius", value: 5 }, 232 | { source: "Combeferre", target: "Gavroche", value: 6 }, 233 | { source: "Combeferre", target: "Mabeuf", value: 2 }, 234 | { source: "Prouvaire", target: "Gavroche", value: 1 }, 235 | { source: "Prouvaire", target: "Enjolras", value: 4 }, 236 | { source: "Prouvaire", target: "Combeferre", value: 2 }, 237 | { source: "Feuilly", target: "Gavroche", value: 2 }, 238 | { source: "Feuilly", target: "Enjolras", value: 6 }, 239 | { source: "Feuilly", target: "Prouvaire", value: 2 }, 240 | { source: "Feuilly", target: "Combeferre", value: 5 }, 241 | { source: "Feuilly", target: "Mabeuf", value: 1 }, 242 | { source: "Feuilly", target: "Marius", value: 1 }, 243 | { source: "Courfeyrac", target: "Marius", value: 9 }, 244 | { source: "Courfeyrac", target: "Enjolras", value: 17 }, 245 | { source: "Courfeyrac", target: "Combeferre", value: 13 }, 246 | { source: "Courfeyrac", target: "Gavroche", value: 7 }, 247 | { source: "Courfeyrac", target: "Mabeuf", value: 2 }, 248 | { source: "Courfeyrac", target: "Eponine", value: 1 }, 249 | { source: "Courfeyrac", target: "Feuilly", value: 6 }, 250 | { source: "Courfeyrac", target: "Prouvaire", value: 3 }, 251 | { source: "Bahorel", target: "Combeferre", value: 5 }, 252 | { source: "Bahorel", target: "Gavroche", value: 5 }, 253 | { source: "Bahorel", target: "Courfeyrac", value: 6 }, 254 | { source: "Bahorel", target: "Mabeuf", value: 2 }, 255 | { source: "Bahorel", target: "Enjolras", value: 4 }, 256 | { source: "Bahorel", target: "Feuilly", value: 3 }, 257 | { source: "Bahorel", target: "Prouvaire", value: 2 }, 258 | { source: "Bahorel", target: "Marius", value: 1 }, 259 | { source: "Bossuet", target: "Marius", value: 5 }, 260 | { source: "Bossuet", target: "Courfeyrac", value: 12 }, 261 | { source: "Bossuet", target: "Gavroche", value: 5 }, 262 | { source: "Bossuet", target: "Bahorel", value: 4 }, 263 | { source: "Bossuet", target: "Enjolras", value: 10 }, 264 | { source: "Bossuet", target: "Feuilly", value: 6 }, 265 | { source: "Bossuet", target: "Prouvaire", value: 2 }, 266 | { source: "Bossuet", target: "Combeferre", value: 9 }, 267 | { source: "Bossuet", target: "Mabeuf", value: 1 }, 268 | { source: "Bossuet", target: "Valjean", value: 1 }, 269 | { source: "Joly", target: "Bahorel", value: 5 }, 270 | { source: "Joly", target: "Bossuet", value: 7 }, 271 | { source: "Joly", target: "Gavroche", value: 3 }, 272 | { source: "Joly", target: "Courfeyrac", value: 5 }, 273 | { source: "Joly", target: "Enjolras", value: 5 }, 274 | { source: "Joly", target: "Feuilly", value: 5 }, 275 | { source: "Joly", target: "Prouvaire", value: 2 }, 276 | { source: "Joly", target: "Combeferre", value: 5 }, 277 | { source: "Joly", target: "Mabeuf", value: 1 }, 278 | { source: "Joly", target: "Marius", value: 2 }, 279 | { source: "Grantaire", target: "Bossuet", value: 3 }, 280 | { source: "Grantaire", target: "Enjolras", value: 3 }, 281 | { source: "Grantaire", target: "Combeferre", value: 1 }, 282 | { source: "Grantaire", target: "Courfeyrac", value: 2 }, 283 | { source: "Grantaire", target: "Joly", value: 2 }, 284 | { source: "Grantaire", target: "Gavroche", value: 1 }, 285 | { source: "Grantaire", target: "Bahorel", value: 1 }, 286 | { source: "Grantaire", target: "Feuilly", value: 1 }, 287 | { source: "Grantaire", target: "Prouvaire", value: 1 }, 288 | { source: "MotherPlutarch", target: "Mabeuf", value: 3 }, 289 | { source: "Gueulemer", target: "Thenardier", value: 5 }, 290 | { source: "Gueulemer", target: "Valjean", value: 1 }, 291 | { source: "Gueulemer", target: "Mme.Thenardier", value: 1 }, 292 | { source: "Gueulemer", target: "Javert", value: 1 }, 293 | { source: "Gueulemer", target: "Gavroche", value: 1 }, 294 | { source: "Gueulemer", target: "Eponine", value: 1 }, 295 | { source: "Babet", target: "Thenardier", value: 6 }, 296 | { source: "Babet", target: "Gueulemer", value: 6 }, 297 | { source: "Babet", target: "Valjean", value: 1 }, 298 | { source: "Babet", target: "Mme.Thenardier", value: 1 }, 299 | { source: "Babet", target: "Javert", value: 2 }, 300 | { source: "Babet", target: "Gavroche", value: 1 }, 301 | { source: "Babet", target: "Eponine", value: 1 }, 302 | { source: "Claquesous", target: "Thenardier", value: 4 }, 303 | { source: "Claquesous", target: "Babet", value: 4 }, 304 | { source: "Claquesous", target: "Gueulemer", value: 4 }, 305 | { source: "Claquesous", target: "Valjean", value: 1 }, 306 | { source: "Claquesous", target: "Mme.Thenardier", value: 1 }, 307 | { source: "Claquesous", target: "Javert", value: 1 }, 308 | { source: "Claquesous", target: "Eponine", value: 1 }, 309 | { source: "Claquesous", target: "Enjolras", value: 1 }, 310 | { source: "Montparnasse", target: "Javert", value: 1 }, 311 | { source: "Montparnasse", target: "Babet", value: 2 }, 312 | { source: "Montparnasse", target: "Gueulemer", value: 2 }, 313 | { source: "Montparnasse", target: "Claquesous", value: 2 }, 314 | { source: "Montparnasse", target: "Valjean", value: 1 }, 315 | { source: "Montparnasse", target: "Gavroche", value: 1 }, 316 | { source: "Montparnasse", target: "Eponine", value: 1 }, 317 | { source: "Montparnasse", target: "Thenardier", value: 1 }, 318 | { source: "Toussaint", target: "Cosette", value: 2 }, 319 | { source: "Toussaint", target: "Javert", value: 1 }, 320 | { source: "Toussaint", target: "Valjean", value: 1 }, 321 | { source: "Child1", target: "Gavroche", value: 2 }, 322 | { source: "Child2", target: "Gavroche", value: 2 }, 323 | { source: "Child2", target: "Child1", value: 3 }, 324 | { source: "Brujon", target: "Babet", value: 3 }, 325 | { source: "Brujon", target: "Gueulemer", value: 3 }, 326 | { source: "Brujon", target: "Thenardier", value: 3 }, 327 | { source: "Brujon", target: "Gavroche", value: 1 }, 328 | { source: "Brujon", target: "Eponine", value: 1 }, 329 | { source: "Brujon", target: "Claquesous", value: 1 }, 330 | { source: "Brujon", target: "Montparnasse", value: 1 }, 331 | { source: "Mme.Hucheloup", target: "Bossuet", value: 1 }, 332 | { source: "Mme.Hucheloup", target: "Joly", value: 1 }, 333 | { source: "Mme.Hucheloup", target: "Grantaire", value: 1 }, 334 | { source: "Mme.Hucheloup", target: "Bahorel", value: 1 }, 335 | { source: "Mme.Hucheloup", target: "Courfeyrac", value: 1 }, 336 | { source: "Mme.Hucheloup", target: "Gavroche", value: 1 }, 337 | { source: "Mme.Hucheloup", target: "Enjolras", value: 1 }, 338 | ], 339 | }; 340 | 341 | const files = {}; 342 | 343 | const toFileName = (id) => `${id.toLocaleLowerCase().replace(".", "")}.md`; 344 | 345 | graph.nodes.forEach((node) => { 346 | files[node.id] = { 347 | path: toFileName(node.id), 348 | content: `# ${node.id}\n\n## Connected to\n`, 349 | }; 350 | }); 351 | 352 | graph.links.forEach((link) => { 353 | files[link.source].content += `\n- [${link.target}](${toFileName( 354 | link.target 355 | )})`; 356 | }); 357 | 358 | const result = Object.values(files).map((file) => ({ 359 | path: file.path, 360 | content: file.content + "\n", 361 | })); 362 | 363 | result.forEach((file) => { 364 | fs.writeFile(`./${file.path}`, file.content, () => {}); 365 | }); 366 | -------------------------------------------------------------------------------- /examples/wiki-links/id-below-link.md: -------------------------------------------------------------------------------- 1 | # ID below link 2 | 3 | This notes links to another note [[20200522215538]] (that is id-on-top.md) and has its own ID below: 4 | 5 | 20200522215604 6 | -------------------------------------------------------------------------------- /examples/wiki-links/id-on-top.md: -------------------------------------------------------------------------------- 1 | 20200522215538 2 | 3 | # Note with ID and title 4 | 5 | This note has an ID as the first element. -------------------------------------------------------------------------------- /examples/wiki-links/leaf-note.md: -------------------------------------------------------------------------------- 1 | 20200522215638 2 | 3 | # Leaf note 4 | 5 | This note has an ID, but no links. 6 | -------------------------------------------------------------------------------- /examples/wiki-links/various links.md: -------------------------------------------------------------------------------- 1 | # A note with both wiki-style and Markdown links 2 | 3 | It has an ID: 20200522233226 4 | 5 | It links to other files by id [[20200522215604]] and [by path](./leaf-note.md)gd 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-links", 3 | "displayName": "Markdown Links", 4 | "description": "Adds command - Show Graph - that displays a graph of local links between markdown files in the current working directory.", 5 | "version": "0.8.0", 6 | "publisher": "tchayen", 7 | "engines": { 8 | "vscode": "^1.45.0" 9 | }, 10 | "categories": [ 11 | "Other" 12 | ], 13 | "keywords": [ 14 | "markdown" 15 | ], 16 | "bugs": { 17 | "url": "https://github.com/tchayen/markdown-links/issues" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/tchayen/markdown-links" 22 | }, 23 | "license": "MIT", 24 | "activationEvents": [ 25 | "onLanguage:markdown", 26 | "onCommand:markdown-links.showGraph" 27 | ], 28 | "main": "./dist/extension.js", 29 | "contributes": { 30 | "commands": [ 31 | { 32 | "command": "markdown-links.showGraph", 33 | "title": "Markdown Links: Show Graph" 34 | } 35 | ], 36 | "configuration": { 37 | "title": "Markdown Links", 38 | "properties": { 39 | "markdown-links.showColumn": { 40 | "type": "string", 41 | "default": "beside", 42 | "description": "- active – in the currently focused column.\n- beside – (default) other than the current.\n- one, two, three, four, five, six, seven, eight, nine – respective editor columns." 43 | }, 44 | "markdown-links.openColumn": { 45 | "type": "string", 46 | "default": "one", 47 | "description": "- active – in the currently focused column.\n- beside – other than the current.\n- one (default), two, three, four, five, six, seven, eight, nine – respective editor columns." 48 | }, 49 | "markdown-links.fileIdRegexp": { 50 | "type": "string", 51 | "default": "\\d{14}", 52 | "description": "Regular extension used to find file IDs. First match of this regex in file contents, excluding [[links]], will be used as the file ID. This file ID can be used for wiki-style links." 53 | }, 54 | "markdown-links.autoStart": { 55 | "type": "boolean", 56 | "default": false, 57 | "description": "Should Markdown Links automatically start when a markdown file is opened." 58 | }, 59 | "markdown-links.graphType": { 60 | "type": "string", 61 | "enum": [ 62 | "default", 63 | "obsidian" 64 | ], 65 | "default": "default", 66 | "description": "Choose type of graph appearance." 67 | }, 68 | "markdown-links.titleMaxLength": { 69 | "type": "number", 70 | "default": 24, 71 | "description": "The maximum title length before being abbreviated. Set to 0 or less to disable." 72 | } 73 | } 74 | } 75 | }, 76 | "scripts": { 77 | "vscode:prepublish": "webpack --mode production", 78 | "webpack": "webpack --mode development", 79 | "webpack-dev": "webpack --mode development --watch", 80 | "test-compile": "tsc -p ./", 81 | "compile": "webpack --mode production", 82 | "lint": "eslint src --ext ts", 83 | "watch": "tsc -watch -p ./", 84 | "pretest": "yarn run compile && yarn run lint", 85 | "test": "node ./out/test/runTest.js" 86 | }, 87 | "devDependencies": { 88 | "@types/glob": "^7.1.1", 89 | "@types/mocha": "^7.0.2", 90 | "@types/node": "^13.11.0", 91 | "@types/vscode": "^1.45.0", 92 | "@typescript-eslint/eslint-plugin": "^2.33.0", 93 | "@typescript-eslint/parser": "^2.33.0", 94 | "eslint": "^6.8.0", 95 | "glob": "^7.1.6", 96 | "mocha": "^7.1.2", 97 | "ts-loader": "^7.0.4", 98 | "typescript": "^3.8.3", 99 | "vscode-test": "^1.3.0", 100 | "webpack": "^4.43.0", 101 | "webpack-cli": "^3.3.11" 102 | }, 103 | "dependencies": { 104 | "@types/md5": "^2.2.0", 105 | "md5": "^2.2.1", 106 | "remark-frontmatter": "^2.0.0", 107 | "remark-parse": "^8.0.2", 108 | "remark-wiki-link": "^0.0.4", 109 | "unified": "^9.0.0" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { TextDecoder } from "util"; 3 | import * as path from "path"; 4 | import { parseFile, parseDirectory, learnFileId } from "./parsing"; 5 | import { 6 | filterNonExistingEdges, 7 | getColumnSetting, 8 | getConfiguration, 9 | getFileTypesSetting, 10 | } from "./utils"; 11 | import { Graph } from "./types"; 12 | 13 | const watch = ( 14 | context: vscode.ExtensionContext, 15 | panel: vscode.WebviewPanel, 16 | graph: Graph 17 | ) => { 18 | if (vscode.workspace.rootPath === undefined) { 19 | return; 20 | } 21 | 22 | const watcher = vscode.workspace.createFileSystemWatcher( 23 | new vscode.RelativePattern( 24 | vscode.workspace.rootPath, 25 | `**/*{${getFileTypesSetting().join(",")}}` 26 | ), 27 | false, 28 | false, 29 | false 30 | ); 31 | 32 | const sendGraph = () => { 33 | panel.webview.postMessage({ 34 | type: "refresh", 35 | payload: graph, 36 | }); 37 | }; 38 | 39 | // Watch file changes in case user adds a link. 40 | watcher.onDidChange(async (event) => { 41 | await parseFile(graph, event.path); 42 | filterNonExistingEdges(graph); 43 | sendGraph(); 44 | }); 45 | 46 | // Watch file creation in case user adds a new file. 47 | watcher.onDidCreate(async (event) => { 48 | await parseFile(graph, event.path); 49 | filterNonExistingEdges(graph); 50 | sendGraph(); 51 | }); 52 | 53 | watcher.onDidDelete(async (event) => { 54 | const filePath = path.normalize(event.path); 55 | const index = graph.nodes.findIndex((node) => node.path === filePath); 56 | if (index === -1) { 57 | return; 58 | } 59 | 60 | graph.nodes.splice(index, 1); 61 | graph.edges = graph.edges.filter( 62 | (edge) => edge.source !== filePath && edge.target !== filePath 63 | ); 64 | 65 | filterNonExistingEdges(graph); 66 | sendGraph(); 67 | }); 68 | 69 | vscode.window.onDidChangeActiveTextEditor(async (event) => { 70 | if (!event) { 71 | return; 72 | } 73 | panel.webview.postMessage({ 74 | type: "fileOpen", 75 | payload: { path: event!.document.fileName }, 76 | }); 77 | }); 78 | 79 | vscode.workspace.onDidRenameFiles(async (event) => { 80 | for (const file of event.files) { 81 | const previous = path.normalize(file.oldUri.path); 82 | const next = path.normalize(file.newUri.path); 83 | 84 | for (const edge of graph.edges) { 85 | if (edge.source === previous) { 86 | edge.source = next; 87 | } 88 | 89 | if (edge.target === previous) { 90 | edge.target = next; 91 | } 92 | } 93 | 94 | for (const node of graph.nodes) { 95 | if (node.path === previous) { 96 | node.path = next; 97 | } 98 | } 99 | 100 | sendGraph(); 101 | } 102 | }); 103 | 104 | panel.webview.onDidReceiveMessage( 105 | (message) => { 106 | if (message.type === "ready") { 107 | sendGraph(); 108 | } 109 | if (message.type === "click") { 110 | const openPath = vscode.Uri.file(message.payload.path); 111 | const column = getColumnSetting("openColumn"); 112 | 113 | vscode.workspace.openTextDocument(openPath).then((doc) => { 114 | vscode.window.showTextDocument(doc, column); 115 | }); 116 | } 117 | }, 118 | undefined, 119 | context.subscriptions 120 | ); 121 | 122 | panel.onDidDispose(() => { 123 | watcher.dispose(); 124 | }); 125 | }; 126 | 127 | export function activate(context: vscode.ExtensionContext) { 128 | context.subscriptions.push( 129 | vscode.commands.registerCommand("markdown-links.showGraph", async () => { 130 | const column = getColumnSetting("showColumn"); 131 | 132 | const panel = vscode.window.createWebviewPanel( 133 | "markdownLinks", 134 | "Markdown Links", 135 | column, 136 | { 137 | enableScripts: true, 138 | retainContextWhenHidden: true, 139 | } 140 | ); 141 | 142 | if (vscode.workspace.rootPath === undefined) { 143 | vscode.window.showErrorMessage( 144 | "This command can only be activated in open directory" 145 | ); 146 | return; 147 | } 148 | 149 | const graph: Graph = { 150 | nodes: [], 151 | edges: [], 152 | }; 153 | 154 | await parseDirectory(graph, learnFileId); 155 | await parseDirectory(graph, parseFile); 156 | filterNonExistingEdges(graph); 157 | 158 | panel.webview.html = await getWebviewContent(context, panel); 159 | 160 | watch(context, panel, graph); 161 | }) 162 | ); 163 | 164 | const shouldAutoStart = getConfiguration("autoStart"); 165 | 166 | if (shouldAutoStart) { 167 | vscode.commands.executeCommand("markdown-links.showGraph"); 168 | } 169 | } 170 | 171 | async function getWebviewContent( 172 | context: vscode.ExtensionContext, 173 | panel: vscode.WebviewPanel 174 | ) { 175 | const webviewPath = vscode.Uri.file( 176 | path.join(context.extensionPath, "static", "webview.html") 177 | ); 178 | const file = await vscode.workspace.fs.readFile(webviewPath); 179 | 180 | const text = new TextDecoder("utf-8").decode(file); 181 | 182 | const webviewUri = (fileName: string) => 183 | panel.webview 184 | .asWebviewUri( 185 | vscode.Uri.file(path.join(context.extensionPath, "static", fileName)) 186 | ) 187 | .toString(); 188 | 189 | const graphDirectory = path.join("graphs", getConfiguration("graphType")); 190 | const textWithVariables = text 191 | .replace( 192 | "${graphPath}", 193 | "{{" + path.join(graphDirectory, "graph.js") + "}}" 194 | ) 195 | .replace( 196 | "${graphStylesPath}", 197 | "{{" + path.join(graphDirectory, "graph.css") + "}}" 198 | ); 199 | 200 | // Basic templating. Will replace {{someScript.js}} with the 201 | // appropriate webview URI. 202 | const filled = textWithVariables.replace(/\{\{.*\}\}/g, (match) => { 203 | const fileName = match.slice(2, -2).trim(); 204 | return webviewUri(fileName); 205 | }); 206 | 207 | return filled; 208 | } 209 | -------------------------------------------------------------------------------- /src/parsing.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as path from "path"; 3 | import * as unified from "unified"; 4 | import * as markdown from "remark-parse"; 5 | import * as wikiLinkPlugin from "remark-wiki-link"; 6 | import * as frontmatter from "remark-frontmatter"; 7 | import { MarkdownNode, Graph } from "./types"; 8 | import { TextDecoder } from "util"; 9 | import { 10 | findTitle, 11 | findLinks, 12 | id, 13 | FILE_ID_REGEXP, 14 | getFileTypesSetting, 15 | getConfiguration, 16 | getTitleMaxLength, 17 | } from "./utils"; 18 | import { basename } from "path"; 19 | 20 | let idToPath: Record = {}; 21 | 22 | export const idResolver = (id: string) => { 23 | const filePath = idToPath[id]; 24 | if (filePath === undefined) { 25 | return [id]; 26 | } else { 27 | return [filePath]; 28 | } 29 | }; 30 | 31 | const parser = unified() 32 | .use(markdown) 33 | .use(wikiLinkPlugin, { pageResolver: idResolver }) 34 | .use(frontmatter); 35 | 36 | export const parseFile = async (graph: Graph, filePath: string) => { 37 | filePath = path.normalize(filePath); 38 | const buffer = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)); 39 | const content = new TextDecoder("utf-8").decode(buffer); 40 | const ast: MarkdownNode = parser.parse(content); 41 | 42 | let title: string | null = findTitle(ast); 43 | 44 | const index = graph.nodes.findIndex((node) => node.path === filePath); 45 | 46 | if (!title) { 47 | if (index !== -1) { 48 | graph.nodes.splice(index, 1); 49 | } 50 | 51 | return; 52 | } 53 | 54 | if (index !== -1) { 55 | graph.nodes[index].label = title; 56 | } else { 57 | graph.nodes.push({ id: id(filePath), path: filePath, label: title }); 58 | } 59 | 60 | // Remove edges based on an old version of this file. 61 | graph.edges = graph.edges.filter((edge) => edge.source !== id(filePath)); 62 | 63 | // Returns a list of decoded links (by default markdown only supports encoded URI) 64 | const links = findLinks(ast).map(uri => decodeURI(uri)); 65 | const parentDirectory = filePath.split(path.sep).slice(0, -1).join(path.sep); 66 | 67 | for (const link of links) { 68 | let target = path.normalize(link); 69 | if (!path.isAbsolute(link)) { 70 | target = path.normalize(`${parentDirectory}/${link}`); 71 | } 72 | 73 | graph.edges.push({ source: id(filePath), target: id(target) }); 74 | } 75 | }; 76 | 77 | export const findFileId = async (filePath: string): Promise => { 78 | const buffer = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)); 79 | const content = new TextDecoder("utf-8").decode(buffer); 80 | 81 | const match = content.match(FILE_ID_REGEXP); 82 | return match ? match[1] : null; 83 | }; 84 | 85 | export const learnFileId = async (_graph: Graph, filePath: string) => { 86 | const id = await findFileId(filePath); 87 | if (id !== null) { 88 | idToPath[id] = filePath; 89 | } 90 | 91 | const fileName = basename(filePath); 92 | idToPath[fileName] = filePath; 93 | 94 | const fileNameWithoutExt = fileName.split(".").slice(0, -1).join("."); 95 | idToPath[fileNameWithoutExt] = filePath; 96 | }; 97 | 98 | export const parseDirectory = async ( 99 | graph: Graph, 100 | fileCallback: (graph: Graph, path: string) => Promise 101 | ) => { 102 | // `findFiles` is used here since it respects files excluded by either the 103 | // global or workspace level files.exclude config option. 104 | const files = await vscode.workspace.findFiles( 105 | `**/*{${(getFileTypesSetting() as string[]).map((f) => `.${f}`).join(",")}}` 106 | ); 107 | 108 | const promises: Promise[] = []; 109 | 110 | for (const file of files) { 111 | const hiddenFile = path.basename(file.path).startsWith("."); 112 | if (!hiddenFile) { 113 | promises.push(fileCallback(graph, file.path)); 114 | } 115 | } 116 | 117 | await Promise.all(promises); 118 | }; 119 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { runTests } from 'vscode-test'; 4 | 5 | async function main() { 6 | try { 7 | // The folder containing the Extension Manifest package.json 8 | // Passed to `--extensionDevelopmentPath` 9 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 10 | 11 | // The path to test runner 12 | // Passed to --extensionTestsPath 13 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 14 | 15 | // Download VS Code, unzip it and run the integration test 16 | await runTests({ extensionDevelopmentPath, extensionTestsPath }); 17 | } catch (err) { 18 | console.error('Failed to run tests'); 19 | process.exit(1); 20 | } 21 | } 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /src/test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | // You can import and use all API from the 'vscode' module 4 | // as well as import your extension to test it 5 | import * as vscode from 'vscode'; 6 | // import * as myExtension from '../../extension'; 7 | 8 | suite('Extension Test Suite', () => { 9 | vscode.window.showInformationMessage('Start all tests.'); 10 | 11 | test('Sample test', () => { 12 | assert.equal(-1, [1, 2, 3].indexOf(5)); 13 | assert.equal(-1, [1, 2, 3].indexOf(0)); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as Mocha from 'mocha'; 3 | import * as glob from 'glob'; 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | color: true 10 | }); 11 | 12 | const testsRoot = path.resolve(__dirname, '..'); 13 | 14 | return new Promise((c, e) => { 15 | glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { 16 | if (err) { 17 | return e(err); 18 | } 19 | 20 | // Add files to the test suite 21 | files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); 22 | 23 | try { 24 | // Run the mocha test 25 | mocha.run(failures => { 26 | if (failures > 0) { 27 | e(new Error(`${failures} tests failed.`)); 28 | } else { 29 | c(); 30 | } 31 | }); 32 | } catch (err) { 33 | console.error(err); 34 | e(err); 35 | } 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Edge = { 2 | source: string; 3 | target: string; 4 | }; 5 | 6 | export type Node = { 7 | id: string; 8 | path: string; 9 | label: string; 10 | }; 11 | 12 | export type Graph = { 13 | nodes: Node[]; 14 | edges: Edge[]; 15 | }; 16 | 17 | export type MarkdownNode = { 18 | type: string; 19 | children?: MarkdownNode[]; 20 | url?: string; 21 | value?: string; 22 | depth?: number; 23 | data?: { 24 | permalink?: string; 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import * as md5 from "md5"; 3 | import { extname } from "path"; 4 | import { MarkdownNode, Graph } from "./types"; 5 | 6 | export const findLinks = (ast: MarkdownNode): string[] => { 7 | if (ast.type === "link" || ast.type === "definition") { 8 | // Ignore empty, anchor and web links. 9 | if ( 10 | !ast.url || 11 | ast.url.startsWith("#") || 12 | vscode.Uri.parse(ast.url).scheme.startsWith("http") 13 | ) { 14 | return []; 15 | } 16 | 17 | return [ast.url]; 18 | } 19 | 20 | if (ast.type === "wikiLink") { 21 | return [ast.data!.permalink!]; 22 | } 23 | 24 | const links: string[] = []; 25 | 26 | if (!ast.children) { 27 | return links; 28 | } 29 | 30 | for (const node of ast.children) { 31 | links.push(...findLinks(node)); 32 | } 33 | 34 | return links; 35 | }; 36 | 37 | export const findTitle = (ast: MarkdownNode): string | null => { 38 | if (!ast.children) { 39 | return null; 40 | } 41 | 42 | for (const child of ast.children) { 43 | if ( 44 | child.type === "heading" && 45 | child.depth === 1 && 46 | child.children && 47 | child.children.length > 0 48 | ) { 49 | let title = child.children[0].value! 50 | 51 | const titleMaxLength = getTitleMaxLength(); 52 | if (titleMaxLength > 0 && title.length > titleMaxLength) { 53 | title = title.substr(0, titleMaxLength).concat("..."); 54 | } 55 | 56 | return title; 57 | } 58 | } 59 | return null; 60 | }; 61 | 62 | export const id = (path: string): string => { 63 | // Extracting file name without extension. 64 | return md5(path.substring(0, path.length - extname(path).length)); 65 | }; 66 | 67 | export const getConfiguration = (key: string) => 68 | vscode.workspace.getConfiguration("markdown-links")[key]; 69 | 70 | const settingToValue: { [key: string]: vscode.ViewColumn | undefined } = { 71 | active: -1, 72 | beside: -2, 73 | one: 1, 74 | two: 2, 75 | three: 3, 76 | four: 4, 77 | five: 5, 78 | six: 6, 79 | seven: 7, 80 | eight: 8, 81 | nine: 9, 82 | }; 83 | 84 | export const getTitleMaxLength = () => { 85 | return getConfiguration("titleMaxLength"); 86 | } 87 | 88 | export const getColumnSetting = (key: string) => { 89 | const column = getConfiguration(key); 90 | return settingToValue[column] || vscode.ViewColumn.One; 91 | }; 92 | 93 | export const getFileIdRegexp = () => { 94 | const DEFAULT_VALUE = "\\d{14}"; 95 | const userValue = getConfiguration("fileIdRegexp") || DEFAULT_VALUE; 96 | 97 | // Ensure the id is not preceeded by [[, which would make it a part of 98 | // wiki-style link, and put the user-supplied regex in a capturing group to 99 | // retrieve matching string. 100 | return new RegExp(`(? { 106 | const DEFAULT_VALUE = ["md"]; 107 | return getConfiguration("fileTypes") || DEFAULT_VALUE; 108 | }; 109 | 110 | export const getDot = (graph: Graph) => `digraph g { 111 | ${graph.nodes 112 | .map((node) => ` ${node.id} [label="${node.label}"];`) 113 | .join("\n")} 114 | ${graph.edges.map((edge) => ` ${edge.source} -> ${edge.target}`).join("\n")} 115 | }`; 116 | 117 | export const exists = (graph: Graph, id: string) => 118 | !!graph.nodes.find((node) => node.id === id); 119 | 120 | export const filterNonExistingEdges = (graph: Graph) => { 121 | graph.edges = graph.edges.filter( 122 | (edge) => exists(graph, edge.source) && exists(graph, edge.target) 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /static/graphs/default/graph.css: -------------------------------------------------------------------------------- 1 | .links line { 2 | stroke: var(--vscode-editor-foreground); 3 | opacity: 0.5; 4 | } 5 | 6 | .nodes circle { 7 | cursor: pointer; 8 | fill: var(--vscode-editor-foreground); 9 | transition: all 0.15s ease-out; 10 | } 11 | 12 | .text text { 13 | cursor: pointer; 14 | fill: var(--vscode-editor-foreground); 15 | } 16 | .nodes [active], 17 | .text [active] { 18 | cursor: pointer; 19 | fill: var(--vscode-textLink-foreground); 20 | } 21 | -------------------------------------------------------------------------------- /static/graphs/default/graph.js: -------------------------------------------------------------------------------- 1 | const RADIUS = 4; 2 | const ACTIVE_RADIUS = 6; 3 | const STROKE = 1; 4 | const FONT_SIZE = 14; 5 | const TICKS = 5000; 6 | const FONT_BASELINE = 15; 7 | 8 | let nodesData = []; 9 | let linksData = []; 10 | 11 | const vscode = acquireVsCodeApi(); 12 | 13 | const onClick = (d) => { 14 | vscode.postMessage({ type: "click", payload: d }); 15 | }; 16 | 17 | const sameNodes = (previous, next) => { 18 | if (next.length !== previous.length) { 19 | return false; 20 | } 21 | 22 | const map = new Map(); 23 | for (const node of previous) { 24 | map.set(node.id, node.label); 25 | } 26 | 27 | for (const node of next) { 28 | const found = map.get(node.id); 29 | if (!found || found !== node.title) { 30 | return false; 31 | } 32 | } 33 | 34 | return true; 35 | }; 36 | 37 | const sameEdges = (previous, next) => { 38 | if (next.length !== previous.length) { 39 | return false; 40 | } 41 | 42 | const set = new Set(); 43 | for (const edge of previous) { 44 | set.add(`${edge.source.id}-${edge.target.id}`); 45 | } 46 | 47 | for (const edge of next) { 48 | if (!set.has(`${edge.source}-${edge.target}`)) { 49 | return false; 50 | } 51 | } 52 | 53 | return true; 54 | }; 55 | 56 | const element = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 57 | element.setAttribute("width", window.innerWidth); 58 | element.setAttribute("height", window.innerHeight); 59 | document.body.appendChild(element); 60 | 61 | const reportWindowSize = () => { 62 | element.setAttribute("width", window.innerWidth); 63 | element.setAttribute("height", window.innerHeight); 64 | }; 65 | 66 | window.onresize = reportWindowSize; 67 | 68 | const svg = d3.select("svg"); 69 | const width = Number(svg.attr("width")); 70 | const height = Number(svg.attr("height")); 71 | let zoomLevel = 1; 72 | 73 | console.log(JSON.stringify({ nodesData, linksData }, null, 2)); 74 | 75 | const simulation = d3 76 | .forceSimulation(nodesData) 77 | .force("charge", d3.forceManyBody().strength(-300)) 78 | .force( 79 | "link", 80 | d3 81 | .forceLink(linksData) 82 | .id((d) => d.id) 83 | .distance(70) 84 | ) 85 | .force("center", d3.forceCenter(width / 2, height / 2)) 86 | .stop(); 87 | 88 | const g = svg.append("g"); 89 | let link = g.append("g").attr("class", "links").selectAll(".link"); 90 | let node = g.append("g").attr("class", "nodes").selectAll(".node"); 91 | let text = g.append("g").attr("class", "text").selectAll(".text"); 92 | 93 | const resize = () => { 94 | if (d3.event) { 95 | const scale = d3.event.transform; 96 | zoomLevel = scale.k; 97 | g.attr("transform", scale); 98 | } 99 | 100 | const zoomOrKeep = (value) => (zoomLevel >= 1 ? value / zoomLevel : value); 101 | 102 | const font = Math.max(Math.round(zoomOrKeep(FONT_SIZE)), 1); 103 | 104 | text.attr("font-size", `${font}px`); 105 | text.attr("y", (d) => d.y - zoomOrKeep(FONT_BASELINE)); 106 | link.attr("stroke-width", zoomOrKeep(STROKE)); 107 | node.attr("r", zoomOrKeep(RADIUS)); 108 | svg 109 | .selectAll("circle") 110 | .filter((_d, i, nodes) => d3.select(nodes[i]).attr("active")) 111 | .attr("r", zoomOrKeep(ACTIVE_RADIUS)); 112 | 113 | document.getElementById("zoom").innerHTML = zoomLevel.toFixed(2); 114 | }; 115 | 116 | window.addEventListener("message", (event) => { 117 | const message = event.data; 118 | 119 | switch (message.type) { 120 | case "refresh": 121 | const { nodes, edges } = message.payload; 122 | 123 | if (sameNodes(nodesData, nodes) && sameEdges(linksData, edges)) { 124 | return; 125 | } 126 | 127 | nodesData = nodes; 128 | linksData = edges; 129 | restart(); 130 | break; 131 | case "fileOpen": 132 | let path = message.payload.path; 133 | if (path.endsWith(".git")) { 134 | path = path.slice(0, -4); 135 | } 136 | 137 | const fixSlashes = (input) => { 138 | const onLocalWindowsFilesystem = 139 | navigator.platform == "Win32" && /^\w:\\/.test(input); 140 | return onLocalWindowsFilesystem ? input.replace(/\//g, "\\") : input; 141 | }; 142 | 143 | node.attr("active", (d) => (fixSlashes(d.path) === path ? true : null)); 144 | text.attr("active", (d) => (fixSlashes(d.path) === path ? true : null)); 145 | break; 146 | } 147 | 148 | // Resize to update size of active node. 149 | resize(); 150 | }); 151 | 152 | const ticked = () => { 153 | document.getElementById("connections").innerHTML = linksData.length; 154 | document.getElementById("files").innerHTML = nodesData.length; 155 | 156 | node.attr("cx", (d) => d.x).attr("cy", (d) => d.y); 157 | text.attr("x", (d) => d.x).attr("y", (d) => d.y - FONT_BASELINE / zoomLevel); 158 | link 159 | .attr("x1", (d) => d.source.x) 160 | .attr("y1", (d) => d.source.y) 161 | .attr("x2", (d) => d.target.x) 162 | .attr("y2", (d) => d.target.y); 163 | }; 164 | 165 | const restart = () => { 166 | node = node.data(nodesData, (d) => d.id); 167 | node.exit().remove(); 168 | node = node 169 | .enter() 170 | .append("circle") 171 | .attr("r", RADIUS) 172 | .on("click", onClick) 173 | .merge(node); 174 | 175 | link = link.data(linksData, (d) => `${d.source.id}-${d.target.id}`); 176 | link.exit().remove(); 177 | link = link.enter().append("line").attr("stroke-width", STROKE).merge(link); 178 | 179 | text = text.data(nodesData, (d) => d.label); 180 | text.exit().remove(); 181 | text = text 182 | .enter() 183 | .append("text") 184 | .text((d) => d.label.replace(/_*/g, "")) 185 | .attr("font-size", `${FONT_SIZE}px`) 186 | .attr("text-anchor", "middle") 187 | .attr("alignment-baseline", "central") 188 | .on("click", onClick) 189 | .merge(text); 190 | 191 | simulation.nodes(nodesData); 192 | simulation.force("link").links(linksData); 193 | simulation.alpha(1).restart(); 194 | simulation.stop(); 195 | 196 | for (let i = 0; i < TICKS; i++) { 197 | simulation.tick(); 198 | } 199 | 200 | ticked(); 201 | }; 202 | 203 | const zoomHandler = d3.zoom().scaleExtent([0.2, 3]).on("zoom", resize); 204 | 205 | zoomHandler(svg); 206 | restart(); 207 | 208 | vscode.postMessage({ type: "ready" }); 209 | -------------------------------------------------------------------------------- /static/graphs/obsidian/graph.css: -------------------------------------------------------------------------------- 1 | .links line { 2 | stroke: var(--vscode-editor-foreground); 3 | opacity: 0.5; 4 | } 5 | 6 | .nodes circle { 7 | cursor: pointer; 8 | fill: var(--vscode-editor-foreground); 9 | transition: all 0.15s ease-out; 10 | } 11 | 12 | .text text { 13 | cursor: pointer; 14 | fill: var(--vscode-editor-foreground); 15 | } 16 | .nodes [active], 17 | .text [active] { 18 | cursor: pointer; 19 | fill: var(--vscode-textLink-foreground); 20 | } 21 | 22 | .inactive { 23 | opacity: 0.1; 24 | transition: all 0.15s ease-out; 25 | } 26 | -------------------------------------------------------------------------------- /static/graphs/obsidian/graph.js: -------------------------------------------------------------------------------- 1 | const MINIMAL_NODE_SIZE = 8; 2 | const MAX_NODE_SIZE = 24; 3 | const ACTIVE_RADIUS_FACTOR = 1.5; 4 | const STROKE = 1; 5 | const FONT_SIZE = 20; 6 | const TICKS = 5000; 7 | const FONT_BASELINE = 15; 8 | 9 | let nodesData = []; 10 | let linksData = []; 11 | const nodeSize = {}; 12 | 13 | const vscode = acquireVsCodeApi(); 14 | 15 | const updateNodeSize = () => { 16 | nodesData.forEach((el) => { 17 | let weight = 18 | 3 * 19 | Math.sqrt( 20 | linksData.filter((l) => l.source === el.id || l.target === el.id) 21 | .length + 1 22 | ); 23 | if (weight < MINIMAL_NODE_SIZE) { 24 | weight = MINIMAL_NODE_SIZE; 25 | } else if (weight > MAX_NODE_SIZE) { 26 | weight = MAX_NODE_SIZE; 27 | } 28 | nodeSize[el.id] = weight; 29 | }); 30 | }; 31 | 32 | const onClick = (d) => { 33 | vscode.postMessage({ type: "click", payload: d }); 34 | }; 35 | 36 | const onMouseover = function (d) { 37 | const relatedNodesSet = new Set(); 38 | linksData 39 | .filter((n) => n.target.id == d.id || n.source.id == d.id) 40 | .forEach((n) => { 41 | relatedNodesSet.add(n.target.id); 42 | relatedNodesSet.add(n.source.id); 43 | }); 44 | 45 | node.attr("class", (node_d) => { 46 | if (node_d.id !== d.id && !relatedNodesSet.has(node_d.id)) { 47 | return "inactive"; 48 | } 49 | return ""; 50 | }); 51 | 52 | link.attr("class", (link_d) => { 53 | if (link_d.source.id !== d.id && link_d.target.id !== d.id) { 54 | return "inactive"; 55 | } 56 | return ""; 57 | }); 58 | 59 | link.attr("stroke-width", (link_d) => { 60 | if (link_d.source.id === d.id || link_d.target.id === d.id) { 61 | return STROKE * 4; 62 | } 63 | return STROKE; 64 | }); 65 | text.attr("class", (text_d) => { 66 | if (text_d.id !== d.id && !relatedNodesSet.has(text_d.id)) { 67 | return "inactive"; 68 | } 69 | return ""; 70 | }); 71 | }; 72 | 73 | const onMouseout = function (d) { 74 | node.attr("class", ""); 75 | link.attr("class", ""); 76 | text.attr("class", ""); 77 | link.attr("stroke-width", STROKE); 78 | }; 79 | 80 | const sameNodes = (previous, next) => { 81 | if (next.length !== previous.length) { 82 | return false; 83 | } 84 | 85 | const map = new Map(); 86 | for (const node of previous) { 87 | map.set(node.id, node.label); 88 | } 89 | 90 | for (const node of next) { 91 | const found = map.get(node.id); 92 | if (!found || found !== node.title) { 93 | return false; 94 | } 95 | } 96 | 97 | return true; 98 | }; 99 | 100 | const sameEdges = (previous, next) => { 101 | if (next.length !== previous.length) { 102 | return false; 103 | } 104 | 105 | const set = new Set(); 106 | for (const edge of previous) { 107 | set.add(`${edge.source.id}-${edge.target.id}`); 108 | } 109 | 110 | for (const edge of next) { 111 | if (!set.has(`${edge.source}-${edge.target}`)) { 112 | return false; 113 | } 114 | } 115 | 116 | return true; 117 | }; 118 | 119 | const element = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 120 | element.setAttribute("width", window.innerWidth); 121 | element.setAttribute("height", window.innerHeight); 122 | document.body.appendChild(element); 123 | 124 | const reportWindowSize = () => { 125 | element.setAttribute("width", window.innerWidth); 126 | element.setAttribute("height", window.innerHeight); 127 | }; 128 | 129 | window.onresize = reportWindowSize; 130 | 131 | const svg = d3.select("svg"); 132 | const width = Number(svg.attr("width")); 133 | const height = Number(svg.attr("height")); 134 | let zoomLevel = 1; 135 | 136 | const simulation = d3 137 | .forceSimulation(nodesData) 138 | .force("forceX", d3.forceX().x(width / 2)) 139 | .force("forceY", d3.forceY().y(height / 2)) 140 | .force("charge", d3.forceManyBody()) 141 | .force( 142 | "link", 143 | d3 144 | .forceLink(linksData) 145 | .id((d) => d.id) 146 | .distance(70) 147 | ) 148 | .force("center", d3.forceCenter(width / 2, height / 2)) 149 | .force("collision", d3.forceCollide().radius(80)) 150 | .stop(); 151 | 152 | const g = svg.append("g"); 153 | let link = g.append("g").attr("class", "links").selectAll(".link"); 154 | let node = g.append("g").attr("class", "nodes").selectAll(".node"); 155 | let text = g.append("g").attr("class", "text").selectAll(".text"); 156 | 157 | const resize = () => { 158 | if (d3.event) { 159 | const scale = d3.event.transform; 160 | zoomLevel = scale.k; 161 | g.attr("transform", scale); 162 | } 163 | 164 | const zoomOrKeep = (value) => (zoomLevel >= 1 ? value / zoomLevel : value); 165 | 166 | const font = Math.max(Math.round(zoomOrKeep(FONT_SIZE)), 1); 167 | 168 | text.attr("font-size", (d) => nodeSize[d.id]); 169 | text.attr("y", (d) => d.y - zoomOrKeep(FONT_BASELINE + nodeSize[d.id])); 170 | link.attr("stroke-width", zoomOrKeep(STROKE)); 171 | node.attr("r", (d) => { 172 | return zoomOrKeep(nodeSize[d.id]); 173 | }); 174 | svg 175 | .selectAll("circle") 176 | .filter((_d, i, nodes) => d3.select(nodes[i]).attr("active")) 177 | .attr("r", (d) => zoomOrKeep(ACTIVE_RADIUS_FACTOR * nodeSize[d.id])); 178 | 179 | document.getElementById("zoom").innerHTML = zoomLevel.toFixed(2); 180 | }; 181 | 182 | window.addEventListener("message", (event) => { 183 | const message = event.data; 184 | 185 | switch (message.type) { 186 | case "refresh": 187 | const { nodes, edges } = message.payload; 188 | 189 | if (sameNodes(nodesData, nodes) && sameEdges(linksData, edges)) { 190 | return; 191 | } 192 | 193 | nodesData = nodes; 194 | linksData = edges; 195 | restart(); 196 | break; 197 | case "fileOpen": 198 | let path = message.payload.path; 199 | if (path.endsWith(".git")) { 200 | path = path.slice(0, -4); 201 | } 202 | 203 | const fixSlashes = (input) => { 204 | const onLocalWindowsFilesystem = 205 | navigator.platform == "Win32" && /^\w:\\/.test(input); 206 | return onLocalWindowsFilesystem ? input.replace(/\//g, "\\") : input; 207 | }; 208 | 209 | node.attr("active", (d) => (fixSlashes(d.path) === path ? true : null)); 210 | text.attr("active", (d) => (fixSlashes(d.path) === path ? true : null)); 211 | break; 212 | } 213 | 214 | // Resize to update size of active node. 215 | resize(); 216 | }); 217 | 218 | const ticked = () => { 219 | document.getElementById("connections").innerHTML = linksData.length; 220 | document.getElementById("files").innerHTML = nodesData.length; 221 | 222 | node.attr("cx", (d) => d.x).attr("cy", (d) => d.y); 223 | text 224 | .attr("x", (d) => d.x) 225 | .attr("y", (d) => d.y - (FONT_BASELINE - nodeSize[d.id]) / zoomLevel); 226 | link 227 | .attr("x1", (d) => d.source.x) 228 | .attr("y1", (d) => d.source.y) 229 | .attr("x2", (d) => d.target.x) 230 | .attr("y2", (d) => d.target.y); 231 | }; 232 | 233 | const restart = () => { 234 | updateNodeSize(); 235 | node = node.data(nodesData, (d) => d.id); 236 | node.exit().remove(); 237 | node = node 238 | .enter() 239 | .append("circle") 240 | .attr("r", (d) => { 241 | return nodeSize[d.id]; 242 | }) 243 | .on("click", onClick) 244 | .on("mouseover", onMouseover) 245 | .on("mouseout", onMouseout) 246 | .merge(node); 247 | 248 | link = link.data(linksData, (d) => `${d.source.id}-${d.target.id}`); 249 | link.exit().remove(); 250 | link = link.enter().append("line").attr("stroke-width", STROKE).merge(link); 251 | 252 | text = text.data(nodesData, (d) => d.label); 253 | text.exit().remove(); 254 | text = text 255 | .enter() 256 | .append("text") 257 | .text((d) => d.label.replace(/_*/g, "")) 258 | .attr("font-size", `${FONT_SIZE}px`) 259 | .attr("text-anchor", "middle") 260 | .attr("alignment-baseline", "central") 261 | .on("click", onClick) 262 | .on("mouseover", onMouseover) 263 | .on("mouseout", onMouseout) 264 | .merge(text); 265 | 266 | simulation.nodes(nodesData); 267 | simulation.force("link").links(linksData); 268 | simulation.alpha(1).restart(); 269 | simulation.stop(); 270 | 271 | for (let i = 0; i < TICKS; i++) { 272 | simulation.tick(); 273 | } 274 | 275 | ticked(); 276 | }; 277 | 278 | const zoomHandler = d3.zoom().scaleExtent([0.2, 3]).on("zoom", resize); 279 | 280 | zoomHandler(svg); 281 | restart(); 282 | 283 | vscode.postMessage({ type: "ready" }); 284 | -------------------------------------------------------------------------------- /static/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | overflow: hidden; 5 | background-color: var(--vscode-editor-background); 6 | } 7 | 8 | .buttons { 9 | position: absolute; 10 | bottom: 0; 11 | right: 0; 12 | display: flex; 13 | flex-direction: column; 14 | margin: 16px; 15 | cursor: default; 16 | } 17 | 18 | .buttons span > span { 19 | color: var(--vscode-foreground); 20 | } 21 | -------------------------------------------------------------------------------- /static/webview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 0 files 12 | 0 links 13 | 1.00x 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": ["es6"], 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "noImplicitAny": false, 10 | "strict": true /* enable all strict type-checking options */ 11 | /* Additional Checks */ 12 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 13 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 14 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 15 | }, 16 | "exclude": ["node_modules", ".vscode-test"] 17 | } 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | "use strict"; 4 | 5 | const path = require("path"); 6 | 7 | /**@type {import('webpack').Configuration}*/ 8 | const config = { 9 | target: "node", // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/ 10 | 11 | entry: "./src/extension.ts", // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ 12 | output: { 13 | // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ 14 | path: path.resolve(__dirname, "dist"), 15 | filename: "extension.js", 16 | libraryTarget: "commonjs2", 17 | devtoolModuleFilenameTemplate: "../[resource-path]", 18 | }, 19 | devtool: "source-map", 20 | externals: { 21 | vscode: "commonjs vscode", // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ 22 | }, 23 | resolve: { 24 | // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader 25 | extensions: [".ts", ".js"], 26 | }, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.ts$/, 31 | exclude: /node_modules/, 32 | use: [ 33 | { 34 | loader: "ts-loader", 35 | }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | }; 41 | module.exports = config; 42 | --------------------------------------------------------------------------------