Files
libretechandClaude Opus 4.7 df9407dd8d 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>
2026-04-28 21:52:25 +02:00

394 lines
18 KiB
HTML

<!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>