Add companion application

This commit is contained in:
2026-05-08 20:47:02 +02:00
parent 5fcca77d58
commit 7209aebc62
15 changed files with 802 additions and 3 deletions
+176
View File
@@ -0,0 +1,176 @@
// Detects the local datascape-companion via a /status probe and wires up
// the footer status icon, file-row click interception, and the "reveal in
// file manager" page action. All companion calls are best-effort: if the
// fetch fails the page falls back to default browser behavior.
(function () {
var COMPANION_PORT = 17680;
var COMPANION_BASE = 'http://127.0.0.1:' + COMPANION_PORT;
var STATUS_TIMEOUT_MS = 1500;
var state = { available: false, info: null };
function wikiPathFromHref(href) {
// href is the URL the wiki rendered for the listing item (e.g.
// "/photos/2024/img.jpg"). Strip leading slash and decode so the
// companion sees a relative wiki path matching its on-disk layout.
try {
var u = new URL(href, window.location.href);
if (u.origin !== window.location.origin) return null;
var p = u.pathname.replace(/^\/+/, '');
return decodeURIComponent(p);
} catch (_) {
return null;
}
}
function companionGET(path, params) {
var qs = '';
if (params) {
var parts = [];
for (var k in params) {
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(params[k]));
}
qs = '?' + parts.join('&');
}
var ctrl = new AbortController();
var timer = setTimeout(function () { ctrl.abort(); }, STATUS_TIMEOUT_MS);
return fetch(COMPANION_BASE + path + qs, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
signal: ctrl.signal
}).finally(function () { clearTimeout(timer); });
}
function renderFlyout(menu) {
menu.innerHTML = '';
if (state.available) {
var info = state.info || {};
var name = info.name || 'datascape-companion';
var ver = info.version ? ' v' + info.version : '';
var head = document.createElement('div');
head.className = 'panel-header';
head.textContent = 'Companion';
menu.appendChild(head);
var label = document.createElement('div');
label.className = 'companion-line';
label.textContent = name + ver;
menu.appendChild(label);
var link = document.createElement('a');
link.className = 'btn dropdown-item';
link.href = COMPANION_BASE + '/config';
link.target = '_blank';
link.rel = 'noopener';
link.textContent = 'Settings';
menu.appendChild(link);
} else {
var head2 = document.createElement('div');
head2.className = 'panel-header';
head2.textContent = 'Companion not detected';
menu.appendChild(head2);
var msg = document.createElement('div');
msg.className = 'companion-line muted';
msg.textContent = 'Install the companion to open files locally.';
menu.appendChild(msg);
var win = document.createElement('a');
win.className = 'btn dropdown-item';
win.href = '/companion/download/windows';
win.textContent = 'Download — Windows';
menu.appendChild(win);
var lin = document.createElement('a');
lin.className = 'btn dropdown-item';
lin.href = '/companion/download/linux';
lin.textContent = 'Download — Linux';
menu.appendChild(lin);
}
}
function updateFooterIcon() {
var wrap = document.querySelector('[data-companion-status]');
if (!wrap) return;
wrap.hidden = false;
var btn = wrap.querySelector('.companion-icon');
if (state.available) {
btn.textContent = '●';
btn.classList.add('companion-on');
btn.classList.remove('companion-off');
btn.title = 'Companion detected';
} else {
btn.textContent = '○';
btn.classList.add('companion-off');
btn.classList.remove('companion-on');
btn.title = 'Companion not detected';
}
var menu = wrap.querySelector('.companion-flyout');
renderFlyout(menu);
if (typeof wireDropdown === 'function') wireDropdown(btn);
}
function wireFileLinks() {
if (!state.available) return;
document.addEventListener('click', function (e) {
var item = e.target.closest && e.target.closest('.listing-item');
if (!item) return;
var anchor = e.target.closest('a');
if (!anchor) return;
// Only intercept the primary file link, and only for files (not folders).
// Folders end with "/" — let the browser navigate normally.
var path = item.dataset.path || anchor.getAttribute('href');
if (!path || path.endsWith('/')) return;
// Allow modified clicks (open in new tab, etc.) to pass through.
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var rel = wikiPathFromHref(path);
if (rel === null) return;
e.preventDefault();
companionGET('/open-file', { path: rel }).catch(function () {
// Fallback: navigate to the file (download / inline view).
window.location.href = anchor.href;
});
});
}
function wireRevealButton() {
var btn = document.querySelector('[data-companion-reveal]');
if (!btn) return;
if (!state.available) return;
btn.hidden = false;
btn.addEventListener('click', function () {
var rel = wikiPathFromHref(window.location.pathname);
if (rel === null) rel = '';
companionGET('/open-folder', { path: rel }).catch(function () { });
});
}
function probeStatus() {
return companionGET('/status').then(function (r) {
if (!r.ok) throw new Error('status ' + r.status);
return r.json();
}).then(function (info) {
state.available = true;
state.info = info;
}).catch(function () {
state.available = false;
state.info = null;
});
}
function init() {
probeStatus().then(function () {
updateFooterIcon();
wireFileLinks();
wireRevealButton();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();