269 lines
8.4 KiB
JavaScript
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());
|