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>
394 lines
18 KiB
HTML
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>
|
|
|