diff --git a/README.md b/README.md index e2dba84..48e80c4 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Open `about:addons`, select this extension, and choose the **Options** tab guarantee. - **Open in** — a new tab, a new background tab, or a new window. - **Prompt template** — wraps your text, for example `Explain simply: {query}`. + Up to 1,000 characters. Restore defaults resets extension preferences to Simple mode with translation hidden; it does not change Firefox's search engine or shortcut settings. diff --git a/lib/defaults.js b/lib/defaults.js index fcf5e57..fc8e59c 100644 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -3,6 +3,12 @@ export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"]; +// In UTF-16 units, as maxlength counts. Keeps the template far inside +// storage.sync's 8192-byte item quota even when every character is a pasted +// control character (six bytes once JSON-escaped), and far beyond any template +// the 8,000-character URL budget could send. +export const MAX_TEMPLATE_CHARS = 1000; + export const DEFAULT_SETTINGS = { contextMenuMode: "simple", showTranslate: false, diff --git a/lib/settings.js b/lib/settings.js index 978b926..068a1ba 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -1,4 +1,4 @@ -import { DEFAULT_SETTINGS, OPEN_IN_VALUES } from "./defaults.js"; +import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS, OPEN_IN_VALUES } from "./defaults.js"; export function normalizeLanguage(value) { if (typeof value !== "string") return ""; @@ -28,7 +28,7 @@ export function mergeSettings(stored) { if (OPEN_IN_VALUES.includes(stored.openIn)) { merged.openIn = stored.openIn; } - if (typeof stored.promptTemplate === "string") { + if (typeof stored.promptTemplate === "string" && stored.promptTemplate.length <= MAX_TEMPLATE_CHARS) { merged.promptTemplate = stored.promptTemplate; } return merged; diff --git a/options/options.html b/options/options.html index 127b569..0b852e5 100644 --- a/options/options.html +++ b/options/options.html @@ -80,8 +80,8 @@
- -

{query} is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts.

+ +

{query} is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts. Up to 1,000 characters.

diff --git a/test/settings.test.js b/test/settings.test.js index 58b6e13..0f7fb34 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { readFileSync } from "node:fs"; + +import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS } from "../lib/defaults.js"; import { mergeSettings } from "../lib/settings.js"; test("empty object yields the defaults", () => { @@ -67,6 +69,30 @@ test("wrong types fall back to the default", () => { assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); }); +test("a prompt template over the length limit falls back to the default", () => { + assert.equal(typeof MAX_TEMPLATE_CHARS, "number"); + const atLimit = "x".repeat(MAX_TEMPLATE_CHARS); + assert.equal(mergeSettings({ promptTemplate: atLimit }).promptTemplate, atLimit); + assert.equal(mergeSettings({ promptTemplate: `${atLimit}x` }).promptTemplate, + DEFAULT_SETTINGS.promptTemplate); +}); + +// storage.sync rejects any item over 8192 bytes (key plus JSON value), and the +// options page writes every field in one set(), so one oversized template would +// make every later save fail. The worst character is a pasted control +// character, which JSON escapes to six bytes. +test("a template at the limit fits storage.sync's per-item quota in the worst case", () => { + const worst = "\u0001".repeat(MAX_TEMPLATE_CHARS); + const bytes = new TextEncoder().encode("promptTemplate" + JSON.stringify(worst)).length; + assert.ok(bytes <= 8192, `${bytes} bytes`); +}); + +test("the options page enforces the same template limit", () => { + const html = readFileSync(new URL("../options/options.html", import.meta.url), "utf8"); + const input = html.match(/]*id="promptTemplate"[^>]*>/)?.[0] ?? ""; + assert.equal(input.match(/maxlength="(\d+)"/)?.[1], String(MAX_TEMPLATE_CHARS)); +}); + test("an out-of-range openIn falls back to the default", () => { assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn);