diff --git a/picker/scripts/dcx/dcx-hooks.js b/picker/scripts/dcx/dcx-hooks.js index c5441bb68..6ce27cfe2 100644 --- a/picker/scripts/dcx/dcx-hooks.js +++ b/picker/scripts/dcx/dcx-hooks.js @@ -30,49 +30,105 @@ import RULES from '../../data/hook-rules.json'; "Copy", "Quality", ]; + const EXCEPTION_KINDS = { + value: { label: "Silence one value", hint: "One rule stops flagging one exact value, everywhere." }, + "rule-in-files": { label: "Silence a rule in files", hint: "One rule goes quiet in matching files and stays live everywhere else." }, + file: { label: "Skip a file entirely", hint: "Every rule skips matching files. The widest exception; prefer the two above." }, + }; - const initialState = () => ({ - enabled: true, - activeFamily: "fingerprints", - disabled: ["em-dash-overuse"], - custom: [], - }); - - const loadState = () => { - const fallback = initialState(); + /* Only the view preference persists in the browser. The state that matters + lives in the project's .impeccable/config.json and arrives from the doc + session, so the page shows what the hook will actually do, not a preview. */ + const loadView = () => { try { const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "null"); - if (!parsed || typeof parsed !== "object") return fallback; - return { - enabled: parsed.enabled !== false, - activeFamily: FAMILY_META[parsed.activeFamily] ? parsed.activeFamily : fallback.activeFamily, - disabled: Array.isArray(parsed.disabled) - ? parsed.disabled.filter((id) => typeof id === "string") - : fallback.disabled, - custom: Array.isArray(parsed.custom) - ? parsed.custom.filter((rule) => rule && typeof rule.id === "string" && typeof rule.name === "string") - : [], - }; + return { activeFamily: parsed && FAMILY_META[parsed.activeFamily] ? parsed.activeFamily : "fingerprints" }; } catch { - return fallback; + return { activeFamily: "fingerprints" }; } }; - const state = loadState(); - const disabledRules = new Set(state.disabled); - const disciplineAnimations = new WeakMap(); - const customFormAnimations = new WeakMap(); - let syncFrame = 0; + const view = loadView(); - const persist = () => { - state.disabled = [...disabledRules]; + const persistView = () => { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ activeFamily: view.activeFamily })); } catch { // The file:// preview may deny storage; the in-memory controls still work. } }; + /* live is the last state the project confirmed; draft is what the controls + show. Apply sends the whole draft and the server's echo becomes the new + live, so the page and .impeccable/config.json can only disagree while the + Apply bar is visible and says so. */ + let live = null; + let draft = null; + let liveError = ""; + let applying = false; + let appliedFlash = false; + let appliedFlashTimer = 0; + let fetchStarted = false; + + const docSession = () => window.dcxDocSession || null; + const hooksUrl = () => { + const session = docSession(); + if (!session?.base || !session?.token) return ""; + return `${session.base}/doc/hooks?token=${encodeURIComponent(session.token)}`; + }; + + const cloneState = (state) => JSON.parse(JSON.stringify(state)); + + const entryKey = (entry) => { + const files = Array.isArray(entry.files) && entry.files.length > 0 ? [...entry.files].sort().join("\u001f") : ""; + return `${entry.rule}\u0000${entry.value}\u0000${files}`; + }; + + const changeCount = () => { + if (!live || !draft) return 0; + let count = draft.enabled !== live.enabled ? 1 : 0; + const liveRules = new Set(live.ignoreRules); + const draftRules = new Set(draft.ignoreRules); + for (const id of draftRules) if (!liveRules.has(id)) count += 1; + for (const id of liveRules) if (!draftRules.has(id)) count += 1; + const liveFiles = new Set(live.ignoreFiles); + const draftFiles = new Set(draft.ignoreFiles); + for (const glob of draftFiles) if (!liveFiles.has(glob)) count += 1; + for (const glob of liveFiles) if (!draftFiles.has(glob)) count += 1; + const liveValues = new Map(live.ignoreValues.map((entry) => [entryKey(entry), entry])); + const draftValues = new Map(draft.ignoreValues.map((entry) => [entryKey(entry), entry])); + for (const key of draftValues.keys()) if (!liveValues.has(key)) count += 1; + for (const key of liveValues.keys()) if (!draftValues.has(key)) count += 1; + return count; + }; + + /* Three-way rebase for an apply that lost the race: keep every edit the + visitor made (draft against the state the page read) and land it on what + the project now holds, so nothing another writer added is dropped. */ + const rebaseDraft = (oldLive, oldDraft, newLive) => { + const next = cloneState(newLive); + if (oldDraft.enabled !== oldLive.enabled) next.enabled = oldDraft.enabled; + for (const key of ["ignoreRules", "ignoreFiles"]) { + const removed = new Set(oldLive[key].filter((item) => !oldDraft[key].includes(item))); + const added = oldDraft[key].filter((item) => !oldLive[key].includes(item)); + next[key] = next[key].filter((item) => !removed.has(item)); + for (const item of added) if (!next[key].includes(item)) next[key].push(item); + } + const oldKeys = new Set(oldLive.ignoreValues.map(entryKey)); + const draftKeys = new Set(oldDraft.ignoreValues.map(entryKey)); + const removedKeys = new Set([...oldKeys].filter((key) => !draftKeys.has(key))); + next.ignoreValues = next.ignoreValues.filter((entry) => !removedKeys.has(entryKey(entry))); + const presentKeys = new Set(next.ignoreValues.map(entryKey)); + for (const entry of oldDraft.ignoreValues) { + const key = entryKey(entry); + if (!oldKeys.has(key) && !presentKeys.has(key)) { + next.ignoreValues.push(cloneState(entry)); + presentKeys.add(key); + } + } + return next; + }; + const escapeHtml = (value) => String(value) .replaceAll("&", "&") .replaceAll("<", "<") @@ -90,6 +146,8 @@ import RULES from '../../data/hook-rules.json'; return firstSentence || text; }; + const ruleName = (id) => RULES.find((rule) => rule.id === id)?.name || id; + const templateMarkup = () => `
@@ -103,7 +161,7 @@ import RULES from '../../data/hook-rules.json';
Enable hooks -

Preview only — project settings are unchanged.

+

Reading this project’s settings…

On @@ -140,43 +198,55 @@ import RULES from '../../data/hook-rules.json';
-
- Custom rules +
+ Exceptions
-

No custom rules.

- +

No exceptions.

+
+
`; @@ -210,7 +280,8 @@ import RULES from '../../data/hook-rules.json'; }; const familyRules = (family) => RULES.filter((rule) => rule.group === family); - const isEnabled = (id) => !disabledRules.has(id); + const isEnabled = (id) => !(draft ? draft.ignoreRules.includes(id) : false); + const interactive = () => Boolean(live && draft && !applying); const revealSelectedFamily = (target) => { if (!MOBILE_FAMILIES.matches) return; @@ -229,6 +300,10 @@ import RULES from '../../data/hook-rules.json'; }); }; + const disciplineAnimations = new WeakMap(); + const customFormAnimations = new WeakMap(); + let syncFrame = 0; + const setDisciplineOpen = (details, expanded) => { const panel = details.querySelector(":scope > .dcx-hooks-disclosure"); const inner = panel?.querySelector(":scope > .dcx-hooks-disclosure-inner"); @@ -333,7 +408,7 @@ import RULES from '../../data/hook-rules.json'; target.innerHTML = Object.entries(FAMILY_META).map(([id, meta]) => { const rules = familyRules(id); const enabled = rules.filter((rule) => isEnabled(rule.id)).length; - const selected = state.activeFamily === id; + const selected = view.activeFamily === id; return `