feat(magazine): click a flagged word to type your own correction

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 18:35:13 +02:00
parent 63cb129d24
commit 29b1f63e60
6 changed files with 106 additions and 12 deletions

View File

@ -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 `<version>-<build id>`, where the build id follows the newest script/stylesheet/manifest change.

View File

@ -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:

View File

@ -3640,11 +3640,13 @@ function magSpellPanelHtml(issues, scopeLabel) {
' that no German or English dictionary knows. Names and technical terms are normal &mdash; choose <em>Keep</em> for those. ' + agent + '</p>' +
'<ul class="mag-spell-list">' + issues.map((i, n) => {
const at = i.context.indexOf(i.token);
const ctx = at < 0 ? esc(i.context) : esc(i.context.slice(0, at)) + '<mark class="mag-spell">' +
esc(i.token) + '</mark>' + esc(i.context.slice(at + i.token.length));
return '<li><span class="mag-spell-ctx">' + (i.page ? 'p.' + Number(i.page) + ' · ' : '') + '…' + ctx + '…</span> ' +
const word = '<mark class="mag-spell mag-spell-edit" role="button" tabindex="0" title="Click to correct this word yourself" ' +
'onclick="magSpellEdit(' + n + ')" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();magSpellEdit(' + n + ');}">' + esc(i.token) + '</mark>';
const ctx = at < 0 ? esc(i.context) : esc(i.context.slice(0, at)) + word + esc(i.context.slice(at + i.token.length));
return '<li data-spell="' + n + '"><span class="mag-spell-ctx">' + (i.page ? 'p.' + Number(i.page) + ' · ' : '') + '…' + ctx + '…</span> ' +
i.suggestions.map((sug, k) => '<button type="button" class="btn-sm" onclick="magSpellFix(' + n + ',' + k + ')" ' +
'aria-label="Replace ' + esc(i.token) + ' with ' + esc(sug) + '">' + esc(sug) + '</button>').join(' ') +
' <button type="button" class="btn-sm" onclick="magSpellEdit(' + n + ')" title="Type your own correction">Edit…</button>' +
' <button type="button" class="btn-sm" onclick="magSpellKeep(' + n + ')" ' +
'title="Add ' + esc(i.token) + ' to this issue\'s dictionary">Keep</button></li>';
}).join('') + '</ul>';
@ -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 = '<input type="text" class="mag-spell-input" maxlength="120" autocomplete="off" spellcheck="false" ' +
'aria-label="Correct ' + esc(issue.token) + '" value="' + esc(start) + '" size="' + Math.max(12, start.length + 4) + '"> ' +
'<button type="button" class="btn-sm primary" data-apply>Apply</button> <button type="button" class="btn-sm" data-cancel>Cancel</button>';
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;

View File

@ -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); }

View File

@ -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;

View File

@ -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)
for bad in ("<b>Test</b>", "Zeile\nzwei", "x" * 121, " ", issue["token"]):
with self.assertRaises(HTTPException) as ctx:
self.call(magazine_spelling_fix, body={**base, "replacement": "Der Test"})
self.assertEqual(ctx.exception.status_code, 400)
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