Files

269 lines
8.4 KiB
JavaScript

import { CONFIG } from "./config.js";
const HOST = "si.komelt.local_history_archive";
const LEGACY_QUEUE_KEY = "pendingVisits";
const LOCAL_QUEUE_KEY = "pendingLocalVisits";
const REMOTE_QUEUE_KEY = "pendingRemoteVisits";
const MAX_QUEUE = 2000;
const MAX_REMOTE_URL_LENGTH = 2083;
let operationTail = Promise.resolve();
let runtimeSettings = CONFIG;
let flushInProgress = false;
let flushRequested = false;
let activeFlush = null;
async function refreshRuntimeSettings() {
try {
const managed = await chrome.storage.managed.get(["collectorUrl", "apiToken", "deviceId"]);
runtimeSettings = {
collectorUrl: typeof managed.collectorUrl === "string" ? managed.collectorUrl : CONFIG.collectorUrl,
apiToken: typeof managed.apiToken === "string" ? managed.apiToken : CONFIG.apiToken,
deviceId: typeof managed.deviceId === "string" ? managed.deviceId : CONFIG.deviceId
};
} catch (error) {
console.warn("Managed extension settings unavailable", error?.message || error);
}
}
function runSerialized(operation) {
const next = operationTail.then(operation, operation);
operationTail = next.catch((error) => {
console.warn("Queued operation failed", error?.message || error);
});
return next;
}
function capped(visits) {
return visits.slice(-MAX_QUEUE);
}
async function readQueues() {
const values = await chrome.storage.local.get([
LEGACY_QUEUE_KEY,
LOCAL_QUEUE_KEY,
REMOTE_QUEUE_KEY
]);
return {
legacy: Array.isArray(values[LEGACY_QUEUE_KEY]) ? values[LEGACY_QUEUE_KEY] : [],
local: Array.isArray(values[LOCAL_QUEUE_KEY]) ? values[LOCAL_QUEUE_KEY] : [],
remote: Array.isArray(values[REMOTE_QUEUE_KEY]) ? values[REMOTE_QUEUE_KEY] : []
};
}
async function snapshotQueues() {
return runSerialized(async () => {
const { legacy, local, remote } = await readQueues();
const localVisits = capped(legacy.length ? [...legacy, ...local] : local);
const remoteVisits = capped(
legacy.length && remoteEnabled()
? [...legacy.filter(isRemoteVisit), ...remote]
: remote
);
if (
legacy.length
|| localVisits.length !== local.length
|| remoteVisits.length !== remote.length
) {
const update = {
[LOCAL_QUEUE_KEY]: localVisits,
[REMOTE_QUEUE_KEY]: remoteVisits
};
if (legacy.length) update[LEGACY_QUEUE_KEY] = [];
await chrome.storage.local.set(update);
}
return { localVisits, remoteVisits };
});
}
function sameQueueItem(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
function deliveredItemsStillQueued(current, snapshot, offset, deliveredCount) {
const confirmed = snapshot.slice(offset, offset + deliveredCount);
if (confirmed.length !== deliveredCount || current.length < deliveredCount) return 0;
for (let index = 0; index < deliveredCount; index += 1) {
if (!sameQueueItem(current[index], confirmed[index])) return 0;
}
return deliveredCount;
}
async function acknowledgeQueuePrefix(key, snapshot, offset, deliveredCount) {
if (!deliveredCount) return;
try {
await runSerialized(async () => {
const { [key]: current = [] } = await chrome.storage.local.get(key);
const visits = Array.isArray(current) ? current : [];
const removeCount = deliveredItemsStillQueued(visits, snapshot, offset, deliveredCount);
if (removeCount) {
await chrome.storage.local.set({ [key]: capped(visits.slice(removeCount)) });
}
});
} catch (error) {
console.warn("Could not acknowledge queued visits", error?.message || error);
}
}
function remoteEnabled() {
return Boolean(runtimeSettings.collectorUrl && runtimeSettings.apiToken);
}
function isRemoteVisit(visit) {
return isHttpUrl(visit?.url) && visit.url.length <= MAX_REMOTE_URL_LENGTH;
}
async function enqueueVisit(visit) {
await refreshRuntimeSettings();
try {
await runSerialized(async () => {
const { legacy, local, remote } = await readQueues();
const localVisits = [...(legacy.length ? legacy : []), ...local, visit];
const remoteVisits = [
...(legacy.length && remoteEnabled() ? legacy.filter(isRemoteVisit) : []),
...remote
];
if (remoteEnabled() && isRemoteVisit(visit)) remoteVisits.push(visit);
const update = {
[LOCAL_QUEUE_KEY]: capped(localVisits),
[REMOTE_QUEUE_KEY]: capped(remoteVisits)
};
if (legacy.length) update[LEGACY_QUEUE_KEY] = [];
await chrome.storage.local.set(update);
});
requestFlush();
} catch (error) {
console.warn("Could not queue visit", error?.message || error);
}
}
async function archiveLocally(visits) {
if (!visits.length) return true;
try {
const reply = await chrome.runtime.sendNativeMessage(HOST, { type: "visits", visits });
return reply?.ok === true;
} catch (error) {
console.warn("Local archive unavailable", error.message);
return false;
}
}
function isHttpUrl(url) {
if (typeof url !== "string") return false;
try {
const protocol = new URL(url).protocol;
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}
async function sendToCollector(visits, onChunkDelivered) {
// Empty settings deliberately disable sending; the local SQLite archive still works.
if (!remoteEnabled()) return false;
const remoteVisits = visits
.filter(isRemoteVisit)
.map((visit) => ({
...visit,
title: String(visit.title ?? "").slice(0, 1000)
}));
try {
for (let offset = 0; offset < remoteVisits.length; offset += 100) {
const chunk = remoteVisits.slice(offset, offset + 100);
const response = await fetch(runtimeSettings.collectorUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${runtimeSettings.apiToken}`
},
body: JSON.stringify({
device_id: runtimeSettings.deviceId,
visits: chunk
})
});
if (!response.ok) {
const detail = (await response.text()).trim().slice(0, 1000);
throw new Error(`HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
}
if (onChunkDelivered) await onChunkDelivered(offset, chunk.length);
}
return true;
} catch (error) {
console.warn("Collector unavailable", error.message);
return false;
}
}
async function flushOnce() {
await refreshRuntimeSettings();
const { localVisits, remoteVisits } = await snapshotQueues();
if (!localVisits.length && !remoteVisits.length) return;
const [localOk, remoteOk] = await Promise.all([
archiveLocally(localVisits),
sendToCollector(
remoteVisits,
(offset, deliveredCount) => acknowledgeQueuePrefix(
REMOTE_QUEUE_KEY,
remoteVisits,
offset,
deliveredCount
)
)
]);
if (localOk && localVisits.length) {
await acknowledgeQueuePrefix(LOCAL_QUEUE_KEY, localVisits, 0, localVisits.length);
}
if (remoteOk && remoteVisits.length) {
await acknowledgeQueuePrefix(REMOTE_QUEUE_KEY, remoteVisits, 0, remoteVisits.length);
}
}
async function flushLoop() {
while (flushRequested) {
flushRequested = false;
try {
await flushOnce();
} catch (error) {
console.warn("Queue flush failed", error?.message || error);
}
}
}
function requestFlush() {
flushRequested = true;
if (flushInProgress) return activeFlush;
flushInProgress = true;
activeFlush = flushLoop().catch((error) => {
console.warn("Queue flush loop failed", error?.message || error);
}).then(() => {
flushInProgress = false;
if (flushRequested) return requestFlush();
return undefined;
});
return activeFlush;
}
chrome.history.onVisited.addListener((item) => {
return enqueueVisit({
url: item.url, title: item.title || "",
visit_time: new Date(item.lastVisitTime || Date.now()).toISOString(),
transition: "observed", source: "chrome-extension"
});
});
chrome.downloads.onCreated.addListener((item) => {
if (!isHttpUrl(item?.url)) return;
const startTime = new Date(item.startTime || Date.now());
return enqueueVisit({
url: item.url,
title: "",
visit_time: Number.isNaN(startTime.getTime()) ? new Date().toISOString() : startTime.toISOString(),
transition: "download",
source: "chrome-extension"
});
});
chrome.runtime.onStartup.addListener(() => requestFlush());
chrome.runtime.onInstalled.addListener(() => requestFlush());
chrome.alarms.create("local-history-archive-flush", { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener(() => requestFlush());