From 29b1f63e60f473e2c24ef609b870efc23e29b1d1 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Mon, 28 Sep 2026 18:35:13 +0200 Subject: [PATCH] feat(magazine): click a flagged word to type your own correction Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ routes/magazine.py | 9 ++++-- static/js/magazine.js | 53 +++++++++++++++++++++++++++++++---- static/style.css | 5 ++++ tests/magazine.spec.js | 32 +++++++++++++++++++++ tests/test_magazine_routes.py | 17 ++++++++--- 6 files changed, 106 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb806f7..ef96c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **The Contents button is the first item of the sticky review bar**, so it stays in reach while scrolling a long page. - **The page thumbnail is the Review button.** In the thumbnail grid, clicking the (big) page image opens the page in review; the separate *Review* / *Review this page* buttons are gone. A page shared by two or more articles keeps one labelled *Review* button per article, so it stays clear which is meant. +- **Click a flagged word to correct it yourself.** In the spelling list, clicking the underlined word (or *Edit…*) turns the row into a small editor prefilled with the word: type the correction — one word or several, e.g. `dasich` → `das ich` — and press Enter (Escape cancels). Until now only the listed suggestions could be applied. The server accepts any short single-line text without markup for a correction you type; model answers still go through the strict check. + ### Fixed - **The new-box popup is no longer cut off.** Drawing a box near the bottom of a page put the *Region type / Add region* panel below the window edge. It is now placed in the window: below the box when it fits, above it otherwise, always fully inside, and it follows the box when the page scrolls. - **Changed scripts and stylesheets now reach the browser immediately.** They were requested under a version that only moved at release time (`…js?v=1.53.4`) and cached as immutable for a year, so a browser or proxy could keep running old code after an update (a button added to a script simply did not appear). The app shell now requests every asset under `-`, where the build id follows the newest script/stylesheet/manifest change. diff --git a/routes/magazine.py b/routes/magazine.py index 4df2870..ca319a4 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -2505,8 +2505,13 @@ async def magazine_spelling_fix(doc_id: str, request: Request): token, replacement = str(body["token"]), str(body["replacement"]).strip() except (KeyError, TypeError, ValueError): raise HTTPException(400, "block_id, start, end, token and replacement are required") - if not re.fullmatch(r"[A-Za-zÄÖÜäöüß-]{1,60}(?:- (?:und|oder))?", replacement): - raise HTTPException(400, "A replacement is a single word") + # Typed by the reviewer (a suggestion, or their own correction such as "das ich" for "dasich"): + # any short single-line text, but no markup or control characters. The agent's answers are held + # to the much stricter spelling.accept_agent_fix instead. + if not replacement or len(replacement) > 120 or re.search(r"[\x00-\x1f<>]", replacement): + raise HTTPException(400, "A correction is 1-120 characters on one line, without markup") + if replacement == token: + raise HTTPException(400, "That is what is already there") layout = mag.load_layout(d) block = next((b for b in layout.get("blocks", []) if b["id"] == block_id), None) if block is None: diff --git a/static/js/magazine.js b/static/js/magazine.js index 5bfc7ab..7a63c8c 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -3640,11 +3640,13 @@ function magSpellPanelHtml(issues, scopeLabel) { ' that no German or English dictionary knows. Names and technical terms are normal — choose Keep for those. ' + agent + '

' + '
    ' + issues.map((i, n) => { const at = i.context.indexOf(i.token); - const ctx = at < 0 ? esc(i.context) : esc(i.context.slice(0, at)) + '' + - esc(i.token) + '' + esc(i.context.slice(at + i.token.length)); - return '
  • ' + (i.page ? 'p.' + Number(i.page) + ' · ' : '') + '…' + ctx + '… ' + + const word = '' + esc(i.token) + ''; + const ctx = at < 0 ? esc(i.context) : esc(i.context.slice(0, at)) + word + esc(i.context.slice(at + i.token.length)); + return '
  • ' + (i.page ? 'p.' + Number(i.page) + ' · ' : '') + '…' + ctx + '… ' + i.suggestions.map((sug, k) => '').join(' ') + + ' ' + '
  • '; }).join('') + '
'; @@ -3691,21 +3693,59 @@ async function magSpellAfterChange() { async function magSpellFix(n, k) { const issue = state.spell?.issues[n]; - if (!issue || !issue.suggestions[k]) return; + if (issue && issue.suggestions[k]) await magSpellApply(n, issue.suggestions[k]); +} + +// Correct one finding with the given text (a suggestion, or whatever the reviewer typed). +async function magSpellApply(n, replacement) { + const issue = state.spell?.issues[n]; + if (!issue) return; try { const r = await api('/api/magazine/' + state.doc.id + '/spelling/fix', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ block_id: issue.block_id, start: issue.start, end: issue.end, - token: issue.raw || issue.token, replacement: issue.suggestions[k] }), + token: issue.raw || issue.token, replacement }), }); if (state.view === 'review') magOfferUndo(r.undo, 'Corrected ' + issue.token); - say('Corrected ' + issue.token + ' → ' + issue.suggestions[k], 'ok'); + say('Corrected ' + issue.token + ' → ' + replacement, 'ok'); } catch (e) { return say('Could not correct: ' + e.message, 'err'); } await magSpellAfterChange(); } +// Click the flagged word (or Edit…): the row turns into a small editor prefilled with the word. +// Enter applies, Escape cancels. +function magSpellEdit(n) { + const issue = state.spell?.issues[n]; + const row = document.querySelector('.mag-spell-list li[data-spell="' + n + '"]'); + if (!issue || !row || row.querySelector('.mag-spell-editor')) return; + const start = issue.raw || issue.token; + const editor = document.createElement('span'); + editor.className = 'mag-spell-editor'; + editor.innerHTML = ' ' + + ' '; + row.innerHTML = ''; + row.appendChild(editor); + const input = editor.querySelector('input'); + const submit = () => { + const value = input.value.replace(/\s+/g, ' ').trim(); + if (!value) return say('Type the corrected text first', 'err'); + if (value === start) return say('That is what is already there', ''); + magSpellApply(n, value); + }; + const cancel = () => { if (state.view === 'review') magSpellLoadPage(); else magChecksLoad(); }; + editor.querySelector('[data-apply]').addEventListener('click', submit); + editor.querySelector('[data-cancel]').addEventListener('click', cancel); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); submit(); } + else if (e.key === 'Escape') { e.preventDefault(); cancel(); } + }); + input.focus(); + input.select(); +} + async function magSpellKeep(n) { const issue = state.spell?.issues[n]; if (!issue) return; @@ -5141,6 +5181,7 @@ root.magAutofixPage = magAutofixPage; root.magSpellOpenAndFix = magSpellOpenAndFix; root.magSpellToggle = magSpellToggle; root.magSpellFix = magSpellFix; +root.magSpellEdit = magSpellEdit; root.magSpellKeep = magSpellKeep; root.magSpellAgent = magSpellAgent; root.magChecksToggled = magChecksToggled; diff --git a/static/style.css b/static/style.css index 7d939ce..f93515b 100644 --- a/static/style.css +++ b/static/style.css @@ -5538,3 +5538,8 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; } .mag-card-thumb-btn:focus-visible { outline: none; } .mag-pagenav-break { flex-basis: 100%; height: 0; margin: 0; padding: 0; } + +.mag-spell-edit { cursor: text; border-radius: 3px; } +.mag-spell-edit:hover, .mag-spell-edit:focus-visible { background: rgba(217,48,37,.14); outline: 2px solid rgba(217,48,37,.5); outline-offset: 1px; } +.mag-spell-editor { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; flex: 1 1 320px; } +.mag-spell-input { flex: 1 1 200px; min-width: 140px; font: inherit; padding: 4px 8px; border: 1px solid var(--accent); border-radius: 4px; background: var(--surface); color: var(--text); } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index 230d620..efa09be 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -458,6 +458,38 @@ test.describe('Magazine pronunciation and spelling checks', () => { await expect(page.locator('.mag-spell-list')).toHaveCount(0); }); + test('click a flagged word, type your own correction and press Enter; Escape cancels', async ({ page }) => { + await mockBackend(page, { articleStatus: 'approved' }); + const issue = { token: 'dasich', raw: 'dasich', block_id: 'b3', page: 3, start: 0, end: 6, language: 'de', + suggestions: [], context: 'dasich mitentwickelt habe und heraus- gebe' }; + let fixed = null; + await page.route(`**/api/magazine/${DOC_ID}/spelling**`, (route) => { + if (route.request().method() === 'POST') { fixed = route.request().postDataJSON(); return route.fulfill({ json: { ok: true, undo: 'tok', text: 'x' } }); } + return route.fulfill({ json: { issues: fixed ? [] : [issue], dictionary: {} } }); + }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await navTo(page, 's-magazine'); + await page.locator('#mag-library-list .mag-card').first().click(); + await page.locator('#mag-contents-view-toggle').click(); + await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click(); + await page.getByRole('button', { name: /^Spelling/ }).click(); + const panel = page.locator('#mag-spell-panel'); + + await panel.locator('mark.mag-spell-edit').click(); // Escape first: nothing is sent + const input = panel.locator('.mag-spell-input'); + await expect(input).toHaveValue('dasich'); + await expect(input).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(panel.locator('.mag-spell-input')).toHaveCount(0); + expect(fixed).toBeNull(); + + await panel.getByRole('button', { name: 'Edit…' }).click(); // the button opens the same editor + await panel.locator('.mag-spell-input').fill('das ich'); + await page.keyboard.press('Enter'); + await expect.poll(() => fixed).toMatchObject({ block_id: 'b3', token: 'dasich', start: 0, end: 6, replacement: 'das ich' }); + }); + test('misspelled words are marked in the review list and can be kept in the dictionary', async ({ page }) => { await mockBackend(page, { articleStatus: 'approved' }); let kept = null; diff --git a/tests/test_magazine_routes.py b/tests/test_magazine_routes.py index fcb505a..5be4360 100644 --- a/tests/test_magazine_routes.py +++ b/tests/test_magazine_routes.py @@ -1708,16 +1708,25 @@ class SpellingRouteTests(unittest.TestCase): self.call(magazine_undo, body={"token": out["undo"]}) self.assertIn("Der Tesst ist gut", mag.load_layout(self.dir)["blocks"][0]["text"]) - def test_a_stale_position_or_a_phrase_replacement_is_refused(self): + def test_a_stale_position_is_refused_and_markup_or_no_change_is_rejected(self): from routes.magazine import magazine_spelling, magazine_spelling_fix issue = self.call(magazine_spelling)["issues"][0] base = {k: issue[k] for k in ("block_id", "start", "end", "token")} with self.assertRaises(HTTPException) as ctx: self.call(magazine_spelling_fix, body={**base, "start": issue["start"] + 1, "end": issue["end"] + 1, "replacement": "Test"}) self.assertEqual(ctx.exception.status_code, 409) - with self.assertRaises(HTTPException) as ctx: - self.call(magazine_spelling_fix, body={**base, "replacement": "Der Test"}) - self.assertEqual(ctx.exception.status_code, 400) + for bad in ("Test", "Zeile\nzwei", "x" * 121, " ", issue["token"]): + with self.assertRaises(HTTPException) as ctx: + self.call(magazine_spelling_fix, body={**base, "replacement": bad}) + self.assertEqual(ctx.exception.status_code, 400, repr(bad)) + + def test_the_reviewer_can_type_their_own_correction_even_several_words(self): + from core import magazine as mag + from routes.magazine import magazine_spelling, magazine_spelling_fix + issue = self.call(magazine_spelling)["issues"][0] + base = {k: issue[k] for k in ("block_id", "start", "end", "token")} + self.call(magazine_spelling_fix, body={**base, "replacement": "der Test"}) + self.assertIn("Der der Test ist gut", mag.load_layout(self.dir)["blocks"][0]["text"]) def test_dictionary_entries_silence_a_finding_and_can_be_removed(self): from routes.magazine import magazine_spelling, magazine_spelling_dictionary