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:
parent
63cb129d24
commit
29b1f63e60
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -3640,11 +3640,13 @@ function magSpellPanelHtml(issues, scopeLabel) {
|
||||
' that no German or English dictionary knows. Names and technical terms are normal — 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;
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 ("<b>Test</b>", "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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user