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:
@@ -0,0 +1,2 @@
|
||||
tailwind.css
|
||||
d3.min.js
|
||||
@@ -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*
|
||||

|
||||
<br/>
|
||||
|
||||
*Graph: cluster notes based on their titles instead of links*
|
||||

|
||||
<br/>
|
||||
|
||||
*Graph: filter notes with emphasized matches. preview note content (dark mode)*
|
||||

|
||||
<br/>
|
||||
|
||||
*Graph: zoomed out large note collection (dark mode)*
|
||||

|
||||
<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>
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Executable
+60
@@ -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;
|
||||
Reference in New Issue
Block a user