├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── Readme.md ├── index.ts ├── lib ├── Note.d.ts ├── WikiLinkNode.d.ts ├── createLinkMap.ts ├── getBacklinksBlock.ts ├── getNoteLinks.ts ├── processor.ts ├── readAllNotes.ts └── updateBacklinks.ts ├── package.json ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 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 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "ts-node", 9 | "type": "node", 10 | "runtimeVersion": "12.6.0", 11 | "request": "launch", 12 | "args": ["${relativeFile}"], 13 | "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], 14 | "cwd": "${workspaceRoot}", 15 | "protocol": "inspector", 16 | "internalConsoleOptions": "openOnSessionStart", 17 | "env": { 18 | "TS_NODE_PROJECT": "${workspaceFolder}/tsconfig.json", 19 | "TS_NODE_TRANSPILE_ONLY": "true" 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "dist": true 4 | }, 5 | "editor.formatOnSave": true, 6 | "typescript.tsdk": "node_modules/typescript/lib" 7 | } 8 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # note-link-janitor 2 | 3 | **This is a fork of Andy's orginal script, with a quick fix to try to make it to work with Roam backups - I use the page names as titles, and also add processing of #tags and #[[tag words]]. It's quick and dirty, and can probably be improved.** 4 | 5 | **[Quick video](https://www.youtube.com/watch?v=DJtCoV4lF-A)** 6 | 7 | 8 | This script reads in a folder of Markdown files, notes all the [[wiki-style links]] between them, then adds a special "backlinks" section which lists passages which reference a given file. 9 | 10 | For example, this text might get added to `Sample note.md`: 11 | 12 | ``` 13 | ## Backlinks 14 | * [[Something that links here]] 15 | * The block of text in the referencing note which contains the link to [[Sample note]]. 16 | * Another block in that same note which links to [[Sample note]]. 17 | * [[A different note that links here]] 18 | * This is a paragraph from another note which links to [[Sample note]]. 19 | ``` 20 | 21 | The script is idempotent; on subsequent runs, _it will update that backlinks section in-place_. 22 | 23 | The backlinks section will be initially inserted at the end of the file. If there happens to be a HTML-style `` block at the end of your note, the backlinks will be inserted before that block. 24 | 25 | ## Assumptions/warnings 26 | 27 | 1. Links are formatted `[[like this]]`. 28 | 2. Note titles are inferred from filenames. That is: if a file named `Note A.md` contains the text `[[Note B]]`, then a backlink to `Note A` will be added to the file named `Note B.md`. No special handling is yet present for special characters in filenames. 29 | 3. All `.md` files are siblings; the script does not currently recursively traverse subtrees (though that would be a simple modification if you need it; see `lib/readAllNotes.ts`) 30 | 4. The backlinks "section" is defined as the AST span between `## Backlinks` and the next heading tag (or `` tag). Any text you might add to this section will be clobbered. Don't append text after the backlinks list without a heading in between! (I like to leave my backlinks list at the end of the file) 31 | 32 | ### This is FYI-style open source 33 | 34 | This is FYI-style open source. I'm sharing it for interested parties, but without any stewardship commitment. Assume that my default response to issues and pull requests will by to ignore or close them without comment. If you do something interesting with this, though, [please let me know](mailto:andy@andymatuschak.org). 35 | 36 | ## Usage 37 | 38 | To install a published release, run: 39 | 40 | ``` 41 | yarn global add @andymatuschak/note-link-janitor 42 | ``` 43 | 44 | Then to run it (note that it will modify your `.md` files _in-place_; you may want to make a backup!): 45 | 46 | ``` 47 | note-link-janitor path/to/folder/containing/md/files 48 | ``` 49 | 50 | That will run it once; you'll need to create a cron job or a launch daemon to run it regularly. 51 | 52 | It's built to run against Node >=12, so you may need to upgrade or swap your runtime version. 53 | 54 | ## Building a local copy 55 | 56 | ``` 57 | yarn install 58 | yarn run build 59 | ``` 60 | 61 | ## Future work 62 | 63 | In the future, I intend to expand this project to monitor for broken links, orphans, and other interesting hypertext-y predicates. 64 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as fs from "fs"; 4 | import * as graph from "pagerank.js"; 5 | import * as path from "path"; 6 | 7 | import createLinkMap from "./lib/createLinkMap"; 8 | import readAllNotes from "./lib/readAllNotes"; 9 | import updateBacklinks from "./lib/updateBacklinks"; 10 | 11 | (async () => { 12 | const baseNotePath = process.argv[2]; 13 | if (!baseNotePath || baseNotePath === "--help") { 14 | console.log("Usage: note-link-janitor [NOTE_DIRECTORY]"); 15 | return; 16 | } 17 | 18 | const notes = await readAllNotes(baseNotePath); 19 | const linkMap = createLinkMap(Object.values(notes)); 20 | 21 | // Sort by PageRank 22 | for (const note of linkMap.keys()) { 23 | const entry = linkMap.get(note)!; 24 | for (const linkingNote of entry.keys()) { 25 | graph.link(linkingNote, note, 1.0); 26 | } 27 | } 28 | const noteRankings: { [key: string]: number } = {}; 29 | graph.rank(0.85, 0.000001, function(node, rank) { 30 | noteRankings[node] = rank; 31 | }); 32 | 33 | await Promise.all( 34 | Object.keys(notes).map(async notePath => { 35 | const backlinks = linkMap.get(notes[notePath].title); 36 | const newContents = updateBacklinks( 37 | notes[notePath].parseTree, 38 | notes[notePath].noteContents, 39 | backlinks 40 | ? [...backlinks.keys()] 41 | .map(sourceTitle => ({ 42 | sourceTitle, 43 | context: backlinks.get(sourceTitle)! 44 | })) 45 | .sort( 46 | ( 47 | { sourceTitle: sourceTitleA }, 48 | { sourceTitle: sourceTitleB } 49 | ) => 50 | (noteRankings[sourceTitleB] || 0) - 51 | (noteRankings[sourceTitleA] || 0) 52 | ) 53 | : [] 54 | ); 55 | if (newContents !== notes[notePath].noteContents) { 56 | await fs.promises.writeFile( 57 | path.join(baseNotePath, path.basename(notePath)), 58 | newContents, 59 | { encoding: "utf-8" } 60 | ); 61 | } 62 | }) 63 | ); 64 | })(); 65 | -------------------------------------------------------------------------------- /lib/Note.d.ts: -------------------------------------------------------------------------------- 1 | import * as MDAST from "mdast"; 2 | 3 | import { NoteLinkEntry } from "./getNoteLinks"; 4 | 5 | export interface Note { 6 | title: string; 7 | links: NoteLinkEntry[]; 8 | parseTree: MDAST.Root; 9 | } 10 | -------------------------------------------------------------------------------- /lib/WikiLinkNode.d.ts: -------------------------------------------------------------------------------- 1 | import * as UNIST from "unist"; 2 | 3 | export interface WikiLinkNode extends UNIST.Node { 4 | value: string; 5 | data: { 6 | alias: string; 7 | permalink: string; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /lib/createLinkMap.ts: -------------------------------------------------------------------------------- 1 | import * as MDAST from "mdast"; 2 | 3 | import { Note } from "./Note"; 4 | 5 | export default function createLinkMap(notes: Note[]) { 6 | const linkMap: Map> = new Map(); 7 | for (const note of notes) { 8 | for (const link of note.links) { 9 | const targetTitle = link.targetTitle; 10 | let backlinkEntryMap = linkMap.get(targetTitle); 11 | if (!backlinkEntryMap) { 12 | backlinkEntryMap = new Map(); 13 | linkMap.set(targetTitle, backlinkEntryMap); 14 | } 15 | let contextList = backlinkEntryMap.get(note.title); 16 | if (!contextList) { 17 | contextList = []; 18 | backlinkEntryMap.set(note.title, contextList); 19 | } 20 | if (link.context) { 21 | contextList.push(link.context); 22 | } 23 | } 24 | } 25 | 26 | return linkMap; 27 | } 28 | -------------------------------------------------------------------------------- /lib/getBacklinksBlock.ts: -------------------------------------------------------------------------------- 1 | import * as MDAST from "mdast"; 2 | import * as UNIST from "unist"; 3 | import * as is from "unist-util-is"; 4 | 5 | // Hacky type predicate here. 6 | function isClosingMatterNode(node: UNIST.Node): node is UNIST.Node { 7 | return "value" in node && (node as MDAST.HTML).value.startsWith("