Skip to content

Check first-round imports before importing; save a first round with its files (#510, #509, #447) - #656

Merged
mahmoud merged 22 commits into
masterfrom
feat/import-check-510
Oct 10, 2026
Merged

mahmoud merged 22 commits into
masterfrom
feat/import-check-510

Conversation

@lgelauff

@lgelauff lgelauff commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #510, closes #509, closes #447, closes #517, closes #146. Fixes the link part of #208. Prevents #645 within one import, and against files already in Montage where entries.name has a unique index. Related: #513, #635, #645, #650, #654.

What changes

First-round imports become two steps: check, then save the round together with exactly what was checked.

Check (POST /v1/admin/campaign/<id>/import/check, campaign coordinators)

  • Montage fetches the source: a CSV, gist or Google Sheet link, a File List, or a category.
  • The File List box takes file names, file IDs or both, one per line. A line of digits only is a file ID (a Commons file name always has an extension). If the first line is a header with filename, img_name or file_id, the box is read as a CSV, so the check's own download can be pasted back in.
  • It looks every row up on Commons, by file_id if the row has one, otherwise by name. The lookup by file_id is a new wikireplica query, file.file_id IN (...), 500 per query, with the same joins as the name lookup.
  • Only the name column (filename, or else img_name; header names are case-insensitive and a BOM is ignored) and an optional file_id column are read. A CSV may have only a file_id column. Commons supplies all metadata (Entry metadata should be identical regardless of import method #509).
  • Names are made canonical the way MediaWiki does for File: titles:
    • a File:/Image: prefix is removed, in any case;
    • runs of spaces and underscores become one underscore;
    • the first letter is upper-cased.

Row results:

Row Status Result
file_id known, same name ok imported
file_id known, other name renamed imported under Commons' current name if the file is new to Montage; shown as information
a bare number that is a file_id by_file_id imported; shown as "Found by file ID" with the file's name, so a wrong number is seen before saving
same Commons file twice duplicate imported once (#146)
name known (no file_id) ok imported
name unknown (no file_id) unknown_name left out, warning
file_id unknown, or not a plain whole number unknown_file_id, malformed_file_id blocks
a bare number that is no file_id but the page ID or a revision ID of a file page page_id, revision_id blocks; the message names the file and says to use its file ID or name
two different files in this import whose names the database treats as equal same_name blocks CSV, gist, Sheet and File List imports (the names shown side by side); in a category, the first file is kept and the others are left out with a warning
a new file whose name the database treats as equal to a different file already in Montage same_name_existing the same as same_name. Only where entries.name has a unique index (beta, installs built from the model); production has none (#650), so the lookup is skipped there

A file ID is the number of the file itself (the file_id column), not the page ID shown under Page information. Almost every file_id is also the page ID of some other file page, so a bare number is always taken as a file_id. Page and revision IDs are looked up only for numbers that are no file_id, to explain the error.

The same-name rule is the database's own:

  • On MariaDB, the check asks the server for WEIGHT_STRING(CONVERT(name USING utf8mb4) COLLATE utf8mb4_unicode_ci), batched and without reading any table.
  • In tests and local development (SQLite), a clearly marked approximation is used. Anywhere else the check refuses rather than approximate.
  • One switch: import_check.SAME_NAME_CHECK.

Translated reasons: every checked row and every source error carries reason_code and reason_params. The form shows them through i18n keys (montage-round-check-reason-*, montage-round-check-error-*) and falls back to the English server text (marked lang="en") only when a key is missing. The dialog after saving is built from the check's rows, translated.

Check file (MONTAGE_IMPORT_CHECK_PATH)

  • The check result is saved as a JSON file under a random token, mode 0600.
  • It holds the campaign id, the source, each row as written, and Commons' data per file, including Commons uploader names. It holds no Montage users.
  • A check can be used to save a round for 1 hour (files are renamed and deleted on Commons all the time). The file itself is deleted after at most 7 days: every check, import, download and app start deletes older files, and at most 5,000 are kept. The downloads work until then.
  • The folder must be Montage's own. Montage refuses to start if the variable is missing outside local development, or if the path is relative, not writable, or holds other files.

Downloads

  • The upload file has filename (Commons' current name) and file_id, one row per file the import would take. Uploading it again, or pasting it into File List, passes the check unchanged. When the check blocks, the link says the file holds only the rows without problems, and the steps follow as text.
  • A separate issues report lists every row that is not ok.
  • Cells that a spreadsheet would run as a formula get a leading ', which Montage removes again when reading.

Saving a first round (POST /v1/admin/campaign/<id>/add_round with import: {import_method, check_token}, #447)

  • The round is created and its checked files imported in one transaction. The import reads the check file only: no fetch, no Commons lookup.
  • If the check blocks, is older than 1 hour, belongs to another campaign or source, or the import brings no files (import_empty), nothing is saved (no round). The coordinator fixes the source (or checks it again) and saves again.
  • POST /v1/admin/round/<id>/import also needs check_token (for imports into an existing round). Without one it returns import_check_required.
  • Advancing a round (import_method: round) is unchanged.

Matching existing entries: by file_id in every campaign. #654's 2026-06-01 cutoff is removed, because every checked file has a file_id. Entry.file_id is declared indexed (production already has ix_entries_file_id).

Round form

  • A Check button and a result panel:
    • counts, and a verdict: blocked, or "You can save the round" (also when only warnings were found);
    • rows with problems, marked in text, not by colour only, with their row numbers in the text box;
    • same-name groups;
    • the upload-file and issues-report downloads.
  • Save is enabled only after a check that does not block and finds at least one file. It is disabled while a check runs. Changing the source clears the check.
  • While saving, the source fields and Cancel are disabled.
  • Errors are shown next to the check or next to Save, by type. An expired check says that nothing was saved and why. A save that got no answer reloads the campaign and says the round may exist.
  • Screen readers: the result panel is a named region; check results and errors are announced through a live region outside the busy container; focus moves to the result or the save error; Save is described by the reason it is disabled.
  • Phones: buttons wrap instead of cutting labels off; the problems table scrolls sideways; file names are in <bdi> for right-to-left names.
  • Texts follow one glossary (problems, file ID, "check the source again", Commons, group). Labels match the help: "File names or file IDs (one per line)", "Link to the sheet or file list".

Source links

Round sources store a fingerprint of the check (check_id), not the token; a new check of the same source reuses the round source.

Deploy: deploy.sh stops before building if a required envvar is missing. deployment.md has the folder rules, troubleshooting and rollback notes, and docs/api.md the new endpoints.

Behaviour changes

  • Coordinators press Check before Save on a first round.
  • A first round is saved together with its files. A failed import leaves no empty round.
  • Every CSV, gist and Google Sheet import needs the wikireplica; if it is unavailable, the check fails (no round created).
  • Metadata columns in a CSV have no effect. Files no longer on Commons are left out, or block if their row has a file_id.
  • A bare number in a File List is a file ID. A page ID or revision ID blocks, with a message naming the file.
  • Sheets that show file_id in a number format (1.49673E+08, 149,673,020) block until the column holds plain whole numbers.
  • Source links must be https.
  • Two files whose names differ only in an emoji count as the same name (the database cannot tell them apart either): blocked in a list, first kept in a category.

Choices made in this PR (easy to change)

Open questions (not changed here)

  1. Refuse checked imports into rounds other than the first?
  2. add_round without import still creates an empty round, for API and script use only (the form always sends import). Require import when the campaign has no rounds yet?
  3. A first round whose files are all disqualified by its settings is saved: refuse it?
  4. Read the collation of entries.name from the database instead of assuming utf8mb4_unicode_ci?
  5. A kill switch (envvar) for the same-name check?

Known limits

Tests

  • Backend: pytest montage/tests/ gives 158 passed, 3 xfailed (master: 56 passed, 1 xfailed). New or extended: test_import_check.py, test_loaders.py, test_import_entries.py. A pr-check panel review (2026-10-06) led to fixes, each with a failing test first: 24 of the 28 tests added for them fail without the fix; the other 4 guard behaviour that was already correct. Later tests cover reason codes per row and per source error, the clash with existing names (app level, the database comparison, and the skip without a unique index), bare file IDs, and a live-replica test of the page/revision ID query (xfail off Toolforge).
  • Frontend: prettier --check src/, eslint src/ and npm run build pass. The Cypress spec asserts the save request, a double click saving once, and a refused import keeping the form open. It has not been run (not in CI).
  • Same-name rule on MariaDB, confirmed on ToolsDB (10.6.22-MariaDB, 2026-10-06, read-only query): under utf8mb4_unicode_ci, е/ё, upper/lower case, accents and ß/ss each compare equal, and so do names that differ only in an emoji (this collation gives every emoji the same weight). Different names stay different.
  • On montage-dev (2026-10-07, scripts in a buildservice shell, rolled back): a public https CSV (5 rows, 0.2 s); http and 127.0.0.1 refused; the WLM 2026 Russia category (15,678 files) checked in 6.5–7.4 s; renamed, unknown and malformed file IDs, unknown names and duplicates classified as above; a blocked check refused at save before anything was written; a check older than an hour, or from another campaign, refused. A 307-line File List imported without delay.
  • On montage-beta (2026-10-07..10): a save of the WLM 2026 Russia category hit IntegrityError 1062 on a name equal to an existing row's (…71А vs …71а); that led to the same_name_existing check. A File List of file IDs was blocked as "ambiguous" before dcc90b1; after it, a quick test worked.
  • Not verified: the form in a browser by script (the Cypress spec needs Xvfb, which is not installed on the test machine); it is tested by hand on staging (checks below).

After deploy: checks on montage-beta

  1. A Google Sheet with filename,file_id, one id changed: Check shows the row as blocking and Save is disabled. Fix it, check again, Save: the round exists with its files, and the entries carry the ids.
  2. A renamed file (old name + its file_id) shows "Renamed on Commons"; a file new to Montage is stored under its current name.
  3. Two different files whose names differ only in accents or case break category imports (unicode_ci unique index on entries.name) #645: a File List with Церковь_Богоявления,_Вышний_Волочек.jpg and Церковь_Богоявления,_Вышний_Волочёк.jpg → both rows block, side by side. Then a category containing both: the first is imported, the others left out with a warning, no 500.
  4. Existing name: a File List with a file whose name differs only in case from a different file already in the campaign's database → blocks with "Same name as a file already in Montage"; in a category it is left out with a warning.
  5. File IDs: a File List of bare file IDs → each row "Found by file ID" with its name. A number that is no file ID but the page ID of a file page → blocks and names the file.
  6. Pasted CSV: download the upload file, paste it into File List, check → all rows OK. The issues report lists the rest.
  7. Montage File List URL image load interface is broken #208: a plain https text or CSV link that is not a gist loads; an http link is refused.
  8. Inconsistency: load_partial_csv fallback permanently fails during Google Sheet imports #517: a Google Sheet with a filename column and other columns imports by name.
  9. First round creation without images succeeds with zero entries, blocking subsequent round creation #447: a source whose files are all missing from Commons → Save disabled with a message. A save refused by the server leaves no round in the campaign.
  10. A category of a few thousand files: time the check and the save (each must stay under 30 s).
  11. With a screen reader: press Check and hear the summary; with Save disabled, hear why.
  12. Double-click Save on a clean check: one round is created, with its files, and the form closes.
  13. Check a source, wait more than an hour, then Save: the form says the check has expired and nothing was saved, no round is created, and Check plus Save then works.
  14. Close the import-warning dialog with the X: the form closes and the new round appears in the campaign.
  15. In a non-English interface language, the reasons in the result panel are translated (or marked English where TranslateWiki has no translation yet).

Deploying to production

MONTAGE_IMPORT_CHECK_PATH must exist on the montage tool before this runs there: Montage refuses to start without it (production went down on 2026-10-09 when this branch was deployed by mistake without it). Steps are in deployment.md.

Written with Claude (Claude Code) on behalf of @lgelauff.

lgelauff and others added 20 commits October 5, 2026 19:21
- utils: ImportSourceInvalid/CheckBlocked/CheckExpired/CheckRequired (400 with
  error_type); config key import_check_path (MONTAGE_IMPORT_CHECK_PATH)
- labs: one query text for lookups by name and by file_id; get_files_info_by_ids
- loaders: fetch_source_text, parse_source_rows (filename > img_name, optional
  file_id, BOM/case-insensitive headers), parse_name_list, lookups; old
  full-CSV/name-list loaders removed (#509, #517); only gist links are
  rewritten (#208)

Refs #510, #509, #208

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #510, #645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- POST /admin/campaign/<id>/import/check, GET .../import/check/<token>/download
- import_entries requires check_token for csv/gistcsv/category/selected
  and imports exactly the checked list; blocked check -> 400 before writes
- add_checked_entries replaces the three DAO loaders; file_id lookup in
  every campaign (FILE_ID_LOOKUP_CUTOFF removed)
- app refuses to start on beta/prod/devlabs without MONTAGE_IMPORT_CHECK_PATH
- no 'all disqualified' warning for an empty import (#208)
- tests import through check + token

Refs #510, #208

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Check button + ImportCheckResult panel (text only), download link
- Save only after a non-blocking check; source change clears the check;
  stale check responses ignored; source locked while checking
- the round is created once; a failed import is retried into it, with the
  round settings locked
- i18n (en + qqq) for the new texts and the import warning dialog
- Cypress: check before Add Round (not run)

Refs #510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by pr-check's delete-the-fix step: reverting the guard left every
test green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll (#447)

A first round was created by one request and filled by a second one; when
the import brought no files (an empty category, a CSV with no valid
names) or failed, an empty round stayed behind and blocked creating
another one.

- add_round takes an optional import {import_method, check_token}: the
  round and its files are saved in one transaction; an import that
  fails, is blocked or brings no files (import_empty) rolls the round
  back
- the round form sends the checked import with the round and keeps Save
  disabled when the check found no importable file; on an error nothing
  was saved and the form stays as entered (the locked-settings retry of
  #510 is no longer needed and is removed)

Based on feat/import-check-510.

Fixes #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- check folder: cleanup deletes only Montage's own check files (token-
  named, .tmp-), at most MAX_CHECK_FILES kept; an existing folder keeps
  its mode; startup refuses a relative, unwritable or shared folder and
  any unconfigured folder outside dev/devtest; warns outside /data/project
- source links: https only, every address of the host (and of each of at
  most 3 redirects) must be public; 20 MB and 100,000-row caps; source
  fields must be text
- same-name keys: database keys on mysql/mariadb, the test-only stand-in
  only for SQLite in dev/tests, otherwise an error; NULL keys refused
- file names canonical like MediaWiki File: titles (prefix in any case,
  runs of spaces/underscores, first letter upper case)
- round sources store a check fingerprint (check_id), not the token, and
  a new check of the same source reuses the round source
- issues report download (every row that is not ok)
- add_round accepts only checked import methods
- checked imports skip add_entries' lower-case dedupe (matched by
  file_id); Entry.file_id declared indexed
- tools/admin.py create_round: source and advance params
- unused imports and cutoff names removed; _ImportError renamed

Refs #510, #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- deploy.sh stops before building when a required envvar is missing
- deployment.md: $HOME-based commands, folder of its own, cleanup rules,
  troubleshooting (missing variable, timeout, old files) and rollback
- docs/api.md: import/check, download, issues, add_round's import, the
  new error types

Refs #510, #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- while a round is saved: source fields and Cancel disabled, Save says
  what it is doing; Cancel reloads the campaign
- save errors handled by type: blocked -> check again, no files ->
  nothing to import, expired / stale page -> check again or reload,
  other errors -> a message next to Save, no answer -> reload the
  campaign and say the round may exist
- a check older than 7 days is refused before saving
- the warnings dialog closed with X/Escape closes the form too
  (dialogService onClose); dialog text keeps its line breaks
- screen readers: a live region announces check results, errors and a
  cleared check; focus moves to the result; Save is described by the
  reason it is disabled; the issues table is a named, focusable region
  with a caption; blocking rows are marked in text; server English is
  marked lang=en; the pair separator is hidden
- category checks: own summary, no 'row' labels, same-name rows not
  shown as blocking; a typed but unpicked category gets a hint
- issues report link; download links use the download attribute
- i18n: no concatenated strings; no plurals (none in the project)
- Cypress: the save is intercepted, its body asserted, a double click
  saves once, a refused import keeps the form (not run)

Refs #510, #447

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… days

Files are renamed and deleted on Commons all the time, and the check and
the save happen in one sitting, so a check can be imported for 1 hour
(was 7 days). The check file and its downloads (upload file, issues
report) stay at most 7 days: every check, import, download and the app
start delete older files, so they also go after a quiet spell.

Refs #510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…help texts

The File List box read every line as a file name, so a pasted CSV (e.g.
the check's download, meant to be the next source) came back as "not on
Commons" line by line. Now:
- a first line that is a header with filename, img_name or file_id makes
  the box read as a CSV, exactly like a File List URL source;
- a line of digits only is a file_id (a Commons name always has an
  extension), so a plain list of file_ids works, mixed with names;
- a CSV may have only a file_id column (names come from Commons).
The same-name rule and the file_id lookup apply as for every list.

Help texts: file list explains names, file_ids and pasted CSVs; File
List URL names the columns and the sharing setting; the download says it
can be pasted into File List; category example is 2026.

Found while testing on montage-dev (2026-10-07). #510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…esult

- Status "Same name as another file" -> "... inside this import": the
  check compares names within one import, not with files already in
  Montage.
- The visually hidden "and" between two same-name files had no spaces
  (Vue drops whitespace between elements across a line break), so a
  screen reader read "andName.jpg" as one word.

Found while testing on montage-dev (2026-10-07). #510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… clearer download

- "(row N)" is its own message and never breaks across lines; the
  combined "{0} (row {1})" message is replaced by montage-round-check-row-ref.
- Commons names (no spaces) may wrap anywhere; on narrow screens each file
  of a same-name pair gets its own line.
- When the check blocks, the download link says it holds only the files
  without problems and can be pasted into File List; otherwise "Download
  the files that will be imported (CSV)".

Found while testing on montage-dev (2026-10-07). #510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h a reason

Commons shows page IDs (Page information) and revision IDs (oldid=...),
not file_ids. A bare number in a list (no name) was taken as a file_id:
a revision ID came back as "not on Commons" without saying why, and a
page ID that happened to be another file's file_id imported that other
file silently. Bare numbers are now also looked up as the page ID and
revision IDs of File: pages (main replica, same batches):
- page_id / revision_id: blocks, "N is a revision ID (one edit of the
  page File:X), not a file_id; ... write the file name instead: X";
- ambiguous_id: the file_id of one file and the page/revision ID of
  another: blocks, asks for the file name;
- a page ID of the same file, or a row with a name, is unaffected.
Live-replica test (xfail off Toolforge) for the new query.

#510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… focus, phones

From a three-viewpoint UI review (coordinator, consistency, screen reader
+ phone), 2026-10-08.

Translated reasons: every checked row and every source error now carries
reason_code + reason_params; the form shows montage-round-check-reason-*
/ montage-round-check-error-* and falls back to the English server text
(marked lang="en") only without a translation. The dialog after saving
is built from the check's rows, translated, instead of server English.

Texts (one glossary: problems, file ID, "check the source again",
"can be imported", Commons, group):
- a check with only warnings now says the round can be saved and that
  rows are left out or changed;
- the hints next to Save no longer point to rows "below" (they are
  above); Save after a blocking check no longer says "check first";
- same-name: "names Montage cannot tell apart", explains why (case,
  accents, е/ё), "groups" instead of "pairs", no advice to rename;
- labels match the help text (file names or file IDs; link to the sheet
  or file list); expiry says nothing was saved and why;
- the partial-download link is short, the steps follow as text;
- blocked-save detail: no raw status codes, singular "1 row".

Screen reader and phone:
- focus moves to the check result or the save error after a check, an
  expired check or a refused save (Save loses focus while disabled);
  the result panel is a named region instead of being announced twice;
  the live region is outside the aria-busy container;
- Save is disabled while a check runs;
- buttons and the button row wrap instead of cutting labels off; the
  problems table scrolls sideways on phones (no inner vertical scroll);
  row numbers are row headers; file names in <bdi> for right-to-left
  names; dialog text wraps long names.

Tests: reason codes per row and on source errors; wording updates.

#510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heck

Where entries.name has a unique index (beta, installs built from the
model), a new file whose name the database treats as equal to that of a
different file already in Montage cannot be stored: saving a round with
the WLM 2026 Russia category on beta failed with IntegrityError 1062 on
"Челябинск_2026,_пр._Ленина_71А.jpg" (beta already had "...71а.jpg"),
2026-10-10. The check compared names only within one import.

The check now also looks the names up in entries (the database's own
comparison, through that unique index) and marks the clashes as
same_name_existing: left out of a category with a warning, blocking a
list. The same file (same file_id) and an old row of exactly this name
without a file_id are reused by the import, as before, and are not
clashes. Production has no index on entries.name (#650): there both rows
are stored, so the lookup (a full table scan there) is skipped.

New status label and reasons (translated); tests for the app-level case,
the database comparison and the skip without a unique index.

#510, #645

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Testing on beta (2026-10-10) showed the "ambiguous id" check blocking an
ordinary file ID: almost every file_id is also the page ID of some other
file page (Commons API: 4 of 4 file_ids from our tests), so a bare
number cannot be told apart and the check blocked nearly every list of
file IDs.

Now a bare number that is a file_id is taken as one, and shown as
"Found by file ID" with the file's name (status by_file_id, imported),
so a wrong number is seen before saving. Only a number that is no
file_id is looked up as a page ID / revision ID of a file page, as a
courtesy in the error message ("... is the page ID of File:X, not its
file ID"). The ambiguous_id status is gone.

The help texts and errors now say plainly that a file ID is the number
of the file itself (the file_id column), not the page ID shown under
Page information.

#510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lgelauff and others added 2 commits October 10, 2026 17:06
…file ID

The yellow message after a check with only warnings said rows were "left
out or changed". Rows found by a bare file ID are also listed there and
are neither: they are imported, and shown so a wrong number is seen. The
message now reads "You can save the round. Check the rows listed below:
some are left out, changed, or found by file ID." The qqq entry explains
the three kinds.

#510

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mahmoud
mahmoud merged commit 33c0c00 into master Oct 10, 2026
4 checks passed
mahmoud added a commit that referenced this pull request Oct 10, 2026
October 2026 integration: import check (#656), quorum reduction (#617), final-review PRs, sentry/timeout/export/import-lock fixes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment