feat(dictionary): add Hachidori backend support

- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
This commit is contained in:
2026-09-22 00:21:19 -07:00
parent 1508863dbb
commit d9fdc7ef6d
446 changed files with 109060 additions and 244 deletions
+155
View File
@@ -0,0 +1,155 @@
// File-URL access is controlled by the user in browser-owned extension details.
// SPDX-License-Identifier: GPL-3.0-or-later
export function createLocalFileAccessController({
document,
container,
chromeApi = globalThis.browser ?? globalThis.chrome,
onDismiss = null,
}) {
const window = document.defaultView;
const firefox = (() => {
try {
return new URL(chromeApi.runtime.getURL("")).protocol === "moz-extension:";
} catch {
return false;
}
})();
// Setup shows this section with a dismiss action; Settings shows it without.
const surface = onDismiss ? "setup" : "Settings";
// Firefox refuses tabs.create for about:addons and has no API that opens the
// Add-ons Manager for a full-tab options page, so it gets the exact manual
// path instead of an Open button.
const COPY = {
firefox: {
browserName: "Firefox",
description: "allow Hachidori to access local files in Firefoxs Add-ons Manager.",
instruction: "Open about:addons, choose Hachidori, open the Permissions and data tab,"
+ " turn on “Access local files on your computer”, then return to this tab.",
reopen: null,
settingsUrl: null,
},
chrome: {
browserName: "Chrome",
description: "enable “Allow access to file URLs” in Hachidoris extension settings.",
instruction: "Turn on “Allow access to file URLs”, then return to this tab.",
reopen: "On the extension details page, open Extension options",
settingsUrl: () => `chrome://extensions/?id=${chromeApi.runtime.id}`,
},
}[firefox ? "firefox" : "chrome"];
const { browserName } = COPY;
const canOpenSettings = COPY.settingsUrl !== null;
const make = (tag, id, text, className = "") => {
const node = document.createElement(tag);
node.id = id;
node.textContent = text;
node.className = className;
return node;
};
const heading = make("h2", "local-file-heading", "Read saved pages too");
const description = make("p", "local-file-description",
`To look up Japanese in HTML files opened from your computer, ${COPY.description}`);
const instruction = make("p", "local-file-instruction", COPY.instruction);
let recovery = null;
if (canOpenSettings) {
const returnPath = onDismiss ? ", then choose Resume setup." : " to return.";
recovery = make("p", "local-file-recovery",
`${browserName} may close ${surface} when it reloads Hachidori. ${COPY.reopen}${returnPath}`);
}
const status = make("output", "local-file-status", "");
status.setAttribute("role", "status");
status.tabIndex = -1;
const actions = make("div", "local-file-actions", "", "local-file-actions");
let open;
if (canOpenSettings) {
open = make("button", "local-file-open", "Open extension settings", "ghost");
open.type = "button";
actions.append(open);
}
let skip;
if (onDismiss) {
skip = make("button", "local-file-skip", "Not now", "ghost");
skip.type = "button";
actions.append(skip);
}
container.classList.add("local-file-access");
container.setAttribute("aria-labelledby", heading.id);
container.replaceChildren(...[heading, description, actions, instruction, recovery, status].filter(Boolean));
container.hidden = true;
let allowed = null, opened = false, error = "", dismissed = false, disposed = false;
let sequence = 0, opening = false;
function render() {
const enabled = allowed === true;
const hideActions = (enabled && Boolean(onDismiss)) || actions.childElementCount === 0;
const focusedAction = actions.contains(document.activeElement);
container.hidden = dismissed;
heading.hidden = enabled;
description.hidden = enabled;
// Without an Open button the manual path is the only path, so it shows at once.
instruction.hidden = (canOpenSettings && !opened) || enabled;
if (recovery) recovery.hidden = instruction.hidden;
actions.hidden = hideActions;
container.classList.toggle("is-enabled", enabled);
const message = enabled ? "Local-file lookups enabled" : error;
if (status.textContent !== message) status.textContent = message;
if (hideActions && focusedAction) status.focus();
}
async function refresh() {
if (disposed || dismissed) return;
const request = ++sequence;
let nextAllowed = null, nextError = "";
try { nextAllowed = await chromeApi.extension.isAllowedFileSchemeAccess(); }
catch { nextError = `Could not check local-file access. You can check it in ${browserName}s extension settings.`; }
if (disposed || dismissed || request !== sequence) return;
allowed = nextAllowed;
error = nextError;
render();
}
async function openSettings() {
if (opening) return;
opening = true;
opened = true;
error = "";
render();
try {
await chromeApi.tabs.create({
url: COPY.settingsUrl(),
});
}
catch {
if (!disposed && !dismissed) {
error = "Could not open extension settings. Try again.";
render();
}
} finally { opening = false; }
}
function dismiss() {
dismissed = true;
container.hidden = true;
onDismiss();
}
function visible() {
if (document.visibilityState === "visible") void refresh();
}
open?.addEventListener("click", openSettings);
skip?.addEventListener("click", dismiss);
document.addEventListener("visibilitychange", visible);
window.addEventListener("pageshow", visible);
void refresh();
return {
refresh,
destroy() {
disposed = true;
open?.removeEventListener("click", openSettings);
skip?.removeEventListener("click", dismiss);
document.removeEventListener("visibilitychange", visible);
window.removeEventListener("pageshow", visible);
},
};
}