Fork Notesium source and restructure into Go package layout

Initial fork of github.com/alonswartz/notesium into librenotes:
- Source moved to internal/notesium/ (package notesium)
- Thin entry point at cmd/librenotes/main.go
- Module renamed to git.librete.ch/public/librenotes
- main() exposed as notesium.Run()
- LICENSE preserved (MIT), NOTICE added with attribution
- Web assets and completion.bash co-located with embedding code
  to satisfy go:embed path constraints

Closes #3, #34, #35.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:52:25 +02:00
co-authored by Claude Opus 4.7
parent b816914b9c
commit df9407dd8d
63 changed files with 7391 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
tailwind.css
d3.min.js
+100
View File
@@ -0,0 +1,100 @@
As of version [0.5.3](https://github.com/alonswartz/notesium/blob/master/CHANGELOG.md#053), the `web/graph` has been rewritten and implemented
in `web/app`, with all of same features (except cluster settings and
darkmode), has tighter integration, and additional improvements.
This graph implemention is backwards compatible, and is especially
useful for Vim users who don't need the additional features, but instead
just want a way to view the graph via a single `keybind` and have
optionally configured the `notesium://` [URI protocol](#custom-uri-protocol) handler for the
edit links available in the note preview side pane.
## Table of contents
- [Features](#features)
- [Screenshots](#screenshots)
- [Download](#download)
- [CLI](#cli)
- [Vim](#vim)
## Features
- Visual overview of notes structure with a force graph view.
- Cluster nodes based on links, inferred from titles or creation date.
- Adjust node size dynamically based on bi-directional link count.
- Emphasize nodes and their links using search filter or node click.
- Preview notes in a side panel. Open for editing via `notesium://` link.
- Tweak forces such as repel force, collide radius, and strength.
- Drag, pan, or zoom the graph for a better view or focus.
- Customize label visibility or automatically scale per zoom level.
## Screenshots
*Graph: display all notes and their links in a force graph view*
![image: force graph cluster links](https://www.notesium.com/images/screenshot-1688650369.png)
<br/>
*Graph: cluster notes based on their titles instead of links*
![image: force graph cluster titles](https://www.notesium.com/images/screenshot-1687865971.png)
<br/>
*Graph: filter notes with emphasized matches. preview note content (dark mode)*
![image: force graph note preview](https://www.notesium.com/images/screenshot-1690971723.png)
<br/>
*Graph: zoomed out large note collection (dark mode)*
![image: force graph zoom](https://www.notesium.com/images/screenshot-1682941869.png)
<br/>
## Download
As of version 0.5.3, the `web/graph` is no longer embedded in the
release binary, so it needs to be downloaded separately and vendor/css
files *handled* (depending on your preference).
**Offline usage**
Download vendor files and compile CSS (assumes Linux and [tailwindcss standalone-cli](https://tailwindcss.com/blog/standalone-cli)).
```bash
git clone https://github.com/alonswartz/notesium.git
cd notesium
./web/graph/make.sh all
```
**CDN usage**
```bash
git clone https://github.com/alonswartz/notesium.git
cd notesium
$EDITOR web/graph/index.html
```
```diff
<title>Notesium Graph</title>
- <!--
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = { darkMode: 'class' }</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
- -->
- <link href="tailwind.css" rel="stylesheet">
- <script src="d3.min.js"></script>
<script src="forcegraph.js"></script>
```
## CLI
```bash
notesium --webroot=/path/to/notesium/web/graph --stop-on-idle --open-browser
```
## Vim
```vim
command! -bang NotesiumGraph
\ let webroot = "/path/to/notesium/web/graph" |
\ let options = "--webroot=".webroot." --stop-on-idle --open-browser" |
\ execute ":silent !nohup notesium web ".options." > /dev/null 2>&1 &"
nnoremap <silent> <Leader>ng :NotesiumGraph<CR>
```
+221
View File
@@ -0,0 +1,221 @@
function initialize_forcegraph(data, graphdiv) {
const links = data.links.map(d => Object.create(d));
const nodes = data.nodes.map(d => Object.create(d));
const svg = d3.select(graphdiv).append("svg")
.style("height", "inherit")
.style("width", "inherit")
.attr("viewBox", [-160, -180, 320, 360]);
const simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody())
.force("collide", d3.forceCollide())
.force("center", d3.forceCenter())
.force("x", d3.forceX())
.force("y", d3.forceY());
const link = svg.append("g")
.classed("link", true)
.attr("stroke", "currentColor")
.selectAll("line")
.data(links)
.join("line");
const node = svg.append("g")
.selectAll("circle")
.data(nodes)
.join("circle")
.attr("r", 2)
.attr("nid", d => d.id)
.classed("node", true)
.classed("node-oneword", d => (d.title.split(" ").length == 1))
.classed("node-dangling", d => (d.title === 'dangling link'))
.classed("node-ghost", d => (d.type === 'ghost'))
.call(drag(simulation));
const title = node.append("title")
.text(d => (d.type === "ghost" ? "ghost: " + d.title : d.id + ": " + d.title));
const label = svg.append("g")
.selectAll("circle")
.data(nodes)
.enter()
.append('a')
.append("text")
.classed("label", true)
.attr("alignment-baseline", "middle")
.attr("href", node => (node.type === 'ghost' ? null : node.id))
.text(node => (node.title == 'dangling link') ? '' : node.title);
const zoom = d3.zoom().scaleExtent([0.3, 3]).on('zoom', function(event) {
svg.selectAll('g').attr('transform', event.transform);
if (d3.select("#forcegraph-scale-labels").property("checked")) {
scaleLabels(event);
}
});
svg.call(zoom);
simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
label
.attr('x', d => d.x + 4).attr('y', d => d.y);
});
// emphasize or de-emphasize nodes and their links and labels
var emphasizeNodesArr = [];
function emphasizeNodes() {
if (emphasizeNodesArr.length > 0) {
const _linkedIds = data.links.filter(l => emphasizeNodesArr.includes(l.source) || emphasizeNodesArr.includes(l.target));
const linkedIds = Array.from(new Set(_linkedIds.flatMap(l => [l.source, l.target])));
node.attr("fill-opacity", 0.1);
node.filter(n => linkedIds.includes(n.id)).attr("fill-opacity", 0.3)
node.filter(n => emphasizeNodesArr.includes(n.id)).attr("fill-opacity", 1)
label.attr("fill-opacity", 0.3).attr("font-weight", "normal");
label.filter(l => linkedIds.includes(l.id)).attr("fill-opacity", 1)
label.filter(l => emphasizeNodesArr.includes(l.id)).attr("fill-opacity", 1).attr("font-weight", "bold")
link.attr("stroke", "currentColor").attr("stroke-opacity", 0.3);
link.filter(l => emphasizeNodesArr.includes(l.source.id) || emphasizeNodesArr.includes(l.target.id)).attr("stroke-opacity", 1);
} else {
node.attr("fill-opacity", 1)
label.attr("fill-opacity", 1).attr("font-weight", "normal");
link.attr("stroke", "currentColor").attr("stroke-opacity", 1);
}
};
// emphasize or de-emphasize when nodes are clicked
node.on("click", function(event) {
const nid = event.target.attributes.nid.value;
emphasizeNodesArr.includes(nid) ?
emphasizeNodesArr.splice(emphasizeNodesArr.indexOf(nid), 1) :
emphasizeNodesArr.push(nid);
emphasizeNodes();
});
// filter: filtered list
function filteredList(results) {
const resultsDom = d3.select("#forcegraph-filter-results");
const resultsSorted = results.sort((a, b) => a.title.localeCompare(b.title));
const searchWords = d3.select("#forcegraph-filter").node().value.toLowerCase().split(" ").filter(n => n.replace(/\W/g, ''));
const searchExp = new RegExp(`(${searchWords.join('|')})`, 'ig');
resultsDom.html("");
resultsSorted.forEach(n => {
var href = n.type === 'ghost' ? null : n.id;
var title = n.title.replace(searchExp, '<b><a href="' + href + '">$1</a></b>');
resultsDom.append("li").append("a").attr("href", href).html(title)
});
}
// filter: search node titles word-wise
function searchNodes(searchStr) {
const searchWords = searchStr.toLowerCase().split(" ");
return data.nodes.filter(n => {
return searchWords.every(searchWord =>
n.title.toLowerCase().includes(searchWord)
);
});
}
// filter: list and graph emphasis when node titles match
d3.select('#forcegraph-filter').on('input', function() {
if (this.value) {
const results = searchNodes(this.value);
filteredList(results);
emphasizeNodesArr = [...results.map(n => n.id)];
emphasizeNodes();
} else {
filteredList([]);
emphasizeNodesArr.splice(0);
emphasizeNodes();
}
});
// dynamic circle radius based on links count
d3.select("#forcegraph-dynamic-radius").on("change", function() {
if (this.checked) {
node.attr("r", (d) => data.links.reduce((i, l) => (l.source === d.id || l.target === d.id) ? i + 0.1 : i, 1));
} else {
node.attr("r", 2);
}
});
// toggle labels
d3.select("#forcegraph-labels").on("change", function() {
svg.selectAll('.label').classed("hidden", !this.checked);
});
// scale labels
d3.select("#forcegraph-scale-labels").on("change", scaleLabels);
function scaleLabels(event) {
let k = 1;
let labelSize = 5;
switch (event.type) {
case "zoom":
k = event.transform.k;
break;
case "change":
if (event.target.checked) k = d3.zoomTransform(svg.node()).k;
break;
}
labelSize = k > 0.9 ? labelSize - k : 0;
svg.selectAll('.label').transition().style("font-size", labelSize + "px");
}
// repel force strength
d3.select("#forcegraph-force-strength").on("change", function() {
d3.select("#forcegraph-force-strength-value").text(this.value);
simulation.force("charge", d3.forceManyBody().strength(this.value));
simulation.alpha(1).restart();
});
// collide radius
d3.select("#forcegraph-collide-radius").on("change", function() {
d3.select("#forcegraph-collide-radius-value").text(this.value);
simulation.force("collide").radius(this.value);
simulation.alpha(1).restart();
});
// collide strength
d3.select("#forcegraph-collide-strength").on("change", function() {
d3.select("#forcegraph-collide-strength-value").text(this.value);
simulation.force("collide").strength(this.value);
simulation.alpha(1).restart();
});
// trigger settings consistency upon initialization
d3.select('#forcegraph-filter').dispatch("change");
d3.select("#forcegraph-dynamic-radius").dispatch("change");
d3.select("#forcegraph-labels").dispatch("change");
d3.select("#forcegraph-scale-labels").dispatch("change");
d3.select("#forcegraph-force-strength").dispatch("change");
d3.select("#forcegraph-collide-radius").dispatch("change");
d3.select("#forcegraph-collide-strength").dispatch("change");
function drag(simulation) {
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended);
}
}
+393
View File
@@ -0,0 +1,393 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notesium Graph</title>
<!--
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = { darkMode: 'class' }</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
-->
<link href="tailwind.css" rel="stylesheet">
<script src="d3.min.js"></script>
<script src="forcegraph.js"></script>
<style>
#forcegraph { margin: auto; width: 97vw; height: 97vh; }
#forcegraph .node { fill: #ff7f0e; cursor: pointer; }
#forcegraph .node-oneword { fill: #1f77b4; }
#forcegraph .node-ghost { fill: #a855f7; }
#forcegraph .node-dangling { fill-opacity: 0.3; }
#forcegraph .link { stroke-width: 0.5; }
#forcegraph .label { fill: #475569; font-size: 4px; cursor: pointer; }
#forcegraph .label:hover { text-decoration: underline; }
.dark #forcegraph .label { fill: #94a3b8; }
#forcegraph-filter-results li { margin-bottom: 3px; }
#forcegraph-filter-results a:hover { text-decoration: underline; }
#preview-panel-content a { color: #2563eb; }
#preview-panel-content a:hover { text-decoration: underline; }
#preview-panel-content .heading { color: #ff7f0e; font-weight: bold;}
#preview-panel-content-body pre { background-color: #f8fafc; padding: 5px;}
.dark #preview-panel-content a { color: #60a5fa; }
.dark #preview-panel-content hr { border-top: 1px solid #475569; }
.dark #preview-panel-content-body pre { background-color: #0f172a; padding: 5px;}
</style>
</head>
<body>
<div class="h-screen bg-slate-50 dark:bg-slate-800 dark:text-white">
<div id="preview-panel" hidden class="relative z-10" role="dialog" aria-modal="true">
<div class="absolute inset-0 overflow-hidden">
<div class="fixed inset-y-0 right-0 flex max-w-full pl-10">
<div class="w-screen max-w-xl">
<div class="flex h-full flex-col overflow-y-scroll bg-white py-3 shadow-xl dark:text-slate-300 dark:shadow-slate-700 dark:bg-slate-800">
<div class="absolute top-0 right-2 px-4 py-2 flex">
<a id="preview-panel-editlink" href="#" class="p-1 cursor-pointer text-gray-200 hover:text-gray-400" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</a>
<div onclick="toggleDiv('#preview-panel')" class="p-1 cursor-pointer text-gray-200 hover:text-gray-400">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div id="preview-panel-content" class="relative flex-1 mt-4 px-4 sm:px-6 text-xs leading-normal">
<pre id="preview-panel-content-body"></pre>
</div>
</div>
</div>
</div>
</div>
</div>
<ul id="forcegraph-filter-results" class="absolute top-0 left-44 max-h-screen overflow-y-hidden pt-6 p-4 w-max text-gray-600 text-xs backdrop-blur-sm bg-slate-50/10 dark:text-slate-400 dark:bg-slate-800/10"></ul>
<div class="absolute w-44 space-y-2 text-xs text-gray-600 border border-gray-200 rounded-lg px-2 py-1.5 m-2 backdrop-blur-sm bg-white/30
dark:text-slate-400 dark:border-slate-700 dark:bg-slate-900/50">
<div class="flex items-center justify-between pt-1 pb-3">
<input id="forcegraph-filter" placeholder="filter..." type="text" autocomplete="off" spellcheck="false"
class="w-full px-2 py-1 border focus:outline-none rounded-md dark:bg-slate-900 dark:border-slate-700">
<div class="flex h-5 items-center">
<input id="forcegraph-filter-results-toggle" type="checkbox" checked class="h-4 w-4 ml-2 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</div>
<div onclick="toggleDiv('#settings-display')" class="flex items-center justify-between py-1 cursor-pointer">
<p class="text-sm text-gray-900 dark:text-slate-200">display</p>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
<ul id="settings-display" hidden="true" role="list" class="my-4 divide-y divide-gray-200 dark:divide-slate-600">
<li class="flex items-center justify-between py-3">
<span>dark mode</span>
<div class="flex h-5 items-center">
<input id="darkmode" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>show labels</span>
<div class="flex h-5 items-center">
<input id="forcegraph-labels" type="checkbox" checked class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>auto-scale labels</span>
<div class="flex h-5 items-center">
<input id="forcegraph-scale-labels" type="checkbox" checked class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>size nodes per links</span>
<div class="flex h-5 items-center">
<input id="forcegraph-dynamic-radius" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
</ul>
<div onclick="toggleDiv('#settings-cluster')" class="flex items-center justify-between py-1 cursor-pointer">
<p class="text-sm text-gray-900 dark:text-slate-200">cluster</p>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
<ul id="settings-cluster" hidden="true" role="list" class="my-4 divide-y divide-gray-200 dark:divide-slate-600">
<li class="flex items-center justify-between py-3">
<span>default</span>
<div class="flex h-5 items-center">
<input name="cluster" type="radio" onchange="clusterDefault()" checked class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>infer from titles</span>
<div class="flex h-5 items-center">
<input name="cluster" type="radio" onchange="clusterTitles()" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>creation day</span>
<div class="flex h-5 items-center">
<input name="cluster" type="radio" onchange="clusterDates(format='YYYY-MM-DD')" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>creation week</span>
<div class="flex h-5 items-center">
<input name="cluster" type="radio" onchange="clusterDates(format='YYYY/WeekXX')" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
<li class="flex items-center justify-between py-3">
<span>creation month</span>
<div class="flex h-5 items-center">
<input name="cluster" type="radio" onchange="clusterDates(format='YYYY-MM')" class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500">
</div>
</li>
</ul>
<div onclick="toggleDiv('#settings-forces')" class="flex items-center justify-between py-1 cursor-pointer">
<p class="text-sm text-gray-900 dark:text-slate-200">forces</p>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
<ul id="settings-forces" hidden="true" role="list" class="my-4 divide-y divide-gray-200 dark:divide-slate-600">
<li class="py-3 space-y-1">
<div class="flex items-center justify-between">
<span>repel force</span>
<span id="forcegraph-force-strength-value">-30</span>
</div>
<input class="w-full" id="forcegraph-force-strength" type="range" value="-30" min="-100" max="0" step="1">
</li>
<li class="py-3 space-y-1">
<div class="flex items-center justify-between">
<span>collide radius</span>
<span id="forcegraph-collide-radius-value">1</span>
</div>
<input class="w-full" id="forcegraph-collide-radius" type="range" value="1" min="1" max="50" step="1">
</li>
<li class="py-3 space-y-1">
<div class="flex items-center justify-between">
<span>collide strength</span>
<span id="forcegraph-collide-strength-value">0.5</span>
</div>
<input class="w-full" id="forcegraph-collide-strength" type="range" value="0.5" min="0" max="1" step="0.05">
</li>
</ul>
</div>
<div id="forcegraph" class="text-gray-300/70 dark:text-slate-700"></div>
</div>
<script type="text/javascript">
function sendHeartbeat() {
fetch("/api/heartbeat")
.then(response => {
if (!response.ok) {
throw new Error("HTTP status " + response.status);
}
})
.catch(error => {
console.log("Failed to send heartbeat: " + error.message);
clearInterval(heartbeatInterval);
});
}
var heartbeatInterval = setInterval(sendHeartbeat, 5000);
var originalData = null;
function clusterDefault() {
document.getElementById("forcegraph").innerHTML = "";
initialize_forcegraph(originalData, '#forcegraph');
}
function clusterTitles() {
const data = {
"href": originalData.href,
"nodes": [ ...originalData.nodes ],
"links": []
};
const clusters = new Set();
data.nodes.forEach(node => {
const match = node.title.match(/^(\w+):/);
if (match) {
clusters.add(match[1]);
data.links.push({'source': node.id, 'target': match[1]});
}
});
clusters.forEach(c => data.nodes.push({'id': c, 'title': c, 'type': 'ghost'}));
document.getElementById("forcegraph").innerHTML = "";
initialize_forcegraph(data, '#forcegraph');
}
function clusterDates(format="YYYY-MM-DD") {
const data = {
"href": originalData.href,
"nodes": [ ...originalData.nodes ],
"links": []
};
const clusters = new Set();
data.nodes.forEach(node => {
let epoch = parseInt(node.id.slice(0, -3), 16);
let dateTime = new Date(epoch * 1000);
let year = dateTime.getFullYear();
switch (format) {
case 'YYYY-MM-DD':
month = String(dateTime.getMonth() + 1).padStart(2, '0');
day = String(dateTime.getDate()).padStart(2, '0');
formattedDate = `${year}-${month}-${day}`;
break;
case 'YYYY-MM':
month = String(dateTime.getMonth() + 1).padStart(2, '0');
formattedDate = `${year}-${month}`;
break;
case 'YYYY/WeekXX':
onejan = new Date(year, 0, 1);
week = Math.ceil(((dateTime - onejan) / 86400000 + onejan.getDay() + 1) / 7);
formattedDate = `${year}/Week${week}`;
}
clusters.add(formattedDate);
data.links.push({'source': node.id, 'target': formattedDate});
})
clusters.forEach(c => data.nodes.push({'id': c, 'title': c, 'type': 'ghost'}));
document.getElementById("forcegraph").innerHTML = "";
initialize_forcegraph(data, '#forcegraph');
}
// toggle settings section
function toggleDiv(settingsDiv) {
d3.select(settingsDiv).attr("hidden", function() {
return this.hasAttribute("hidden") ? null : true;
});
}
// toggle darkmode
d3.select("#darkmode").on("change", function() {
d3.select("body").classed("dark", this.checked);
});
// toggle filtered results list
d3.select("#forcegraph-filter-results-toggle").on("change", function() {
d3.select("#forcegraph-filter-results").classed("hidden", !this.checked);
});
// capture keypress events
d3.select("body").on('keyup', function() {
if (event.code === 'Slash' && document.activeElement.id != 'forcegraph-filter') {
d3.select("#forcegraph-filter").node().focus();
event.preventDefault();
return;
}
if (event.code === 'Escape' && document.activeElement.id === 'forcegraph-filter') {
d3.select("#forcegraph-filter").node().value = '';
d3.select("#forcegraph-filter").node().blur();
d3.select("#forcegraph-filter").dispatch("input");
event.preventDefault();
return;
}
});
// capture click events
d3.select("body").on('click', function() {
if (! event.target.attributes.href ) return;
if (! event.target.attributes.href.value ) return;
if (! event.target.attributes.href.value.endsWith('.md') ) return;
if (! graphData.nodes.map(n => n.id).includes(event.target.attributes.href.value) ) return;
event.preventDefault();
getNote(event.target.attributes.href.value);
});
// markdownify
function markdownify(s) {
const blocks = [];
s = s.replace(/(```)([^`]*?)(```)/gs, function(_, start, content, end) {
var div = document.createElement("div");
div.textContent = content;
blocks.push('<pre>' + div.innerHTML + '</pre>');
return '%%BLOCK' + (blocks.length - 1) + '%%';
});
s = s
.replace(/(^|\s)\[(.*?)\]\((http[s]?:\/\/.*?)\)/g, '$1<a href="$3" target="_blank">$2➜</a>')
.replace(/(^|\s)\[(.*?)\]\((?!http[s]?:\/\/)(?!.*\.md$)(.*?)\)/g, '$1<a href="$3">$2</a>')
.replace(/(^|\n)(#+\s.*?)(?=\n|$)/g, '$1<span class="heading">$2</span>')
.replace(/\*\*(.*?)\*\*/gs, '<b>$1</b>')
.replace(/`([^`]*)`/g, '<b><i>`$1`</i></b>')
.trimEnd();
blocks.forEach((block, index) => {
s = s.replace('%%BLOCK' + index + '%%', block);
});
return s;
}
// preview panel
function getNote(id) {
fetch("/api/notes/" + id)
.then(response => response.json())
.then(data => {
document.getElementById("preview-panel").hidden = false;
document.getElementById("preview-panel-editlink").href = 'notesium://' + data.Path;
document.getElementById("preview-panel-editlink").hidden = hideEditLink;
document.getElementById("preview-panel-content-body").innerHTML = markdownify(data.Content);
var backlinks = ""
if (data.IncomingLinks) {
backlinks += "\n\n<hr/>\n\n## backlinks\n\n";
var linksWithTitles = data.IncomingLinks.map(link => {
var node = graphData.nodes.find(node => node.id === link.Filename);
var title = node ? node.title : link.Filename;
return { link: link, title: title };
});
linksWithTitles.sort((a, b) => a.title.localeCompare(b.title));
linksWithTitles.forEach(item => {
backlinks += `[${item.title}](${item.link.Filename})\n`;
});
document.getElementById("preview-panel-content-body").innerHTML += markdownify(backlinks);
}
});
}
const hideEditLink = new URLSearchParams(window.location.search).has('noxdg');
const graphData = {
"nodes": [],
"links": [],
}
fetch("/api/notes")
.then(response => response.json())
.then(data => {
for (const key in data) {
const note = data[key];
graphData.nodes.push({'id': note.Filename, 'title': note.Title});
if (note.OutgoingLinks) {
note.OutgoingLinks.forEach(link => {
graphData.links.push({'source': note.Filename, 'target': link.Filename});
});
}
}
// dangling links
const nodes = graphData.nodes.map(n => n.id);
const targets = graphData.links.map(l => l.target);
const dangling = targets.filter(id => !nodes.includes(id));
dangling.forEach(id => graphData.nodes.push({'id': id, 'title': 'dangling link', 'type': 'ghost'}));
originalData = graphData;
clusterDefault();
});
</script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash -e
fatal() { echo "Fatal: $*" 1>&2; exit 1; }
usage() {
cat<<EOF
Usage: $0 COMMAND [OPTIONS]
Commands:
all handle vendor, tailwind
vendor download and verify vendor files
tailwind [--watch] build tailwind.css
EOF
exit 1
}
D3JS_URL="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"
D3JS_HASH="d6b03aefc9f6c44c7bc78713679c78c295028fa914319119e5cc4b4954855b1c"
__vendor() {
URL="$1"
OUT="$(basename "$URL")"
HASH="$2"
if [ -e "$OUT" ]; then
echo -n "$HASH $OUT" | sha256sum --strict --check -
return 0
fi
curl -qs $URL -o $OUT.tmp
echo -n "$HASH $OUT.tmp" | sha256sum --strict --check -
mv $OUT.tmp $OUT
}
_vendor() {
command -v curl >/dev/null || fatal "curl not found"
command -v sha256sum >/dev/null || fatal "sha256sum not found"
__vendor "$D3JS_URL" "$D3JS_HASH"
}
_tailwind() {
# tailwindcss v3.1.6
OPTS="$@"
command -v tailwindcss >/dev/null || fatal "tailwindcss not found"
[ -e "tailwind.input.css" ] || fatal "tailwind.input.css not found"
[ -e "tailwind.config.js" ] || fatal "tailwind.config.js not found"
tailwindcss $OPTS --minify -i tailwind.input.css -o tailwind.css
}
main() {
cd $(dirname $(realpath $0))
case $1 in
""|-h|--help|help) usage;;
all) _vendor; _tailwind;;
vendor) _vendor;;
tailwind) shift; _tailwind $@;;
*) fatal "unrecognized command: $1";;
esac
}
main "$@"
@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["*.html"],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;