Skip to content

Give each unlisted language its own ISO 639-3 code (qaa..qtz) - #72

Draft
hatton wants to merge 6 commits into
masterfrom
V3
Draft

Give each unlisted language its own ISO 639-3 code (qaa..qtz)#72
hatton wants to merge 6 commits into
masterfrom
V3

Conversation

@hatton

@hatton hatton commented Aug 27, 2026

Copy link
Copy Markdown
Member

Problem

An archive (ELAR) cannot ingest IMDI files that lameta 3.0.x writes. For a language that ISO 639-3 does not list, lameta wrote the whole tag into the IMDI language id, for example <Id>ISO639-3:qaa-x-Kürbinian</Id>, where only a 3-letter code belongs. It also gave every such language the same code, qaa, so an archive could not tell two invented languages apart. lameta V2 let the user type a code and a name, and it exported ISO639-3:qab, which is what the archive expects.

Cause

In V2 the Subject Language field had type: "language", and the editor was a dialog with a Language Code box and a Name box. V3 changed the field to type: "languageChoices", and no field uses type: "language" any more, so that dialog became unreachable. The new type-ahead editor built qaa-x-<name> unconditionally, and the two IMDI emitters for a session language and an actor language passed the tag through without reducing it to a code.

What this PR changes

  • A new module, src/languageFinder/privateUseLanguages.ts, allocates the next unused code from qaa..qtz for each new language, and resolves a collision in an old project deterministically at export time. No project file is rewritten.
  • A new dialog, Add an Unlisted Language, takes a name and a code, validates the code against the qaa..qtz range and against the codes the project already uses, and refuses a name that holds a semicolon or a colon. It replaces a window.alert that only announced what had already happened.
  • An unlisted language can be created only in Subject Languages on the Project tab, the one field whose definition carries canCreateNew. Two end-to-end tests hold that gate shut, because losing it would let two sessions each be given qaa.
  • Typing a free code such as qax now opens that dialog, instead of silently adding a nameless language. Typing a name that no language matches opens it too.
  • No field offers a code from the range that has no name of its own, so typing qa or unlisted no longer produces an anonymous qaa.
  • IMDI now writes the primary subtag at every language id, and a real name beside it: <Id>ISO639-3:qab</Id><Name>Tolo</Name>. Four metadata-language sites that bypassed the conversion now use it.
  • The language picker's menu renders in document.body. Several ancestor panes have overflow: hidden, and on the short Session and Person fields they cut off the whole dropdown, so those fields appeared to have no type-ahead at all.
  • The code beside a language name is now legible. The highlight colour is the lameta dark blue, and the code reads at 4.9:1 contrast, which passes WCAG AA.
  • Serialization keeps a name the file already carried, so dragging a pill no longer turns qab-x-tolo:Tolo into qab-x-tolo.
  • The PO catalog generator now escapes quotes. A field description holding a quote used to truncate its entry and leave stray tokens behind.
  • New unit tests for the allocator, the parser, the serializer, the catalogs and the IMDI output, and 12 end-to-end tests for the picker and the dialog. The localization catalogs hold the new strings.

Devin review

hatton and others added 4 commits August 26, 2026 16:34
An archive reported that lameta 3.0.x wrote "qaa-x-languagename" where IMDI
needs a 3-letter ISO 639-3 code, and that every language a user invented got
the same code, qaa.

Now each such language gets the next free code from the qaa..qtz range that
ISO 639-3 sets aside, and IMDI carries only the primary subtag:
<Id>ISO639-3:qab</Id><Name>Tolo</Name>.

Behaviour
- The user types a name. A new dialog, Add an Unlisted Language, asks for the
  name and the code, pre-filled with the next free code, and validates both.
  It replaces a window.alert.
- The user may instead type a free code, such as "qax". The row then reads
  "Add an unlisted language using qax" and the dialog asks for the name.
- No field offers a code from the range with no name. Such a language is of no
  use to an archive.
- Typing the code of a language the project already has finds that language,
  with the project's own name, and offers no duplicate row.
- The language finder knows all three of the project's language fields, so a
  session or a person can find a language the project invented, by code or by
  name.

Export
- resolvePrivateUseCodes gives each colliding tag of a 3.0.x project its own
  code, sorted alphabetically, so no file has to be rewritten.
- The map and its lookups are lowercase, because 3.0.x built the tag from the
  typed name. No Id or Name ever carries "-x-", and a language element with no
  tag no longer throws.
- The metadata-language sites use the same map, so one file cannot say qab in
  Language/Id and qaa in a LanguageId attribute.

Fixes found while testing this
- The dropdown was clipped by an ancestor with overflow hidden, so on the
  Session and Person forms the user saw nothing at all. The menu now renders in
  a portal. The e2e helper clicks the row, because reading its text does not
  prove the user can reach it.
- Dragging the pills lost a name the file carried, such as "pta : Guarani".
- The highlight was a teal that no palette entry holds, and the code beside the
  name did not meet WCAG AA on either ground.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md records that this repo uses no card tracker, so preflight and the
other review skills stop asking. CLAUDE.md imports it, because Claude Code
reads CLAUDE.md and not AGENTS.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…session languages

Devin found that locale/en/fields.po held a msgid with unescaped quotes. A PO string ends
at the first unescaped quote, so the entry truncated and left stray tokens behind. The cause
is extractFromJSON.ts, which interpolated a description into a PO string with no escaping,
so it broke a second entry as well. Escape at the generator and regenerate. A new test reads
the generated catalogs and refuses any malformed string.

The two session-language sites in ImdiGenerator took the name from the language finder
alone. For a tag that nothing knows, the finder returns the tag itself, which would write a
raw tag into a Name element. Use nameForExport, as the actor path already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/export/ImdiGenerator.ts
@hatton

hatton commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

[Claude Opus 5 from Hatton's machine during preflight]

Consulted Devin on 2026-08-27 up to commit 6f9e746f400538215199f8ebdd83ba5cd0a62e62.

Two rounds. Round 1 ran against 29256f01 and found 1 non-severe bug and 4 flags, 1 of them an Investigate flag. Both items that were actionable are fixed in 6f9e746f, and Devin's round 2 marks each of them resolved:

  • Bug, Unescaped quotes in a catalog string (locale/en/fields.po:242) - fixed. The cause was archive-configurations/extractFromJSON.ts, which interpolated a field description into a PO string with no escaping, so it had broken a second entry as well. The generator now escapes, the catalogs are regenerated, and archive-configurations/poEscaping.spec.ts refuses any malformed string. Devin: "The generated catalog now escapes the embedded quotation marks in this entry."
  • Flag, Session language Name bypasses nameForExport - fixed. Both session language paths now call nameForExport. Devin: "unknown private-use tags no longer reach IMDI Name elements unchanged."

One Investigate flag stands, mirrored as a review thread above: Session-only invented languages fall outside the collision map. Confirmed real, and on the preflight decision report, because it reopens an allocation-scope decision that the plan settled.

Three informational flags, assessed, no action:

  • getIso639_3Code now returns only the primary subtag. Intended, and the point of the change. Only two callers exist, Imdi-static-fns.ts:134 and ImdiGenerator.ts:174, both IMDI. No RO-Crate or PARADISEC path calls it, so no BCP 47 output loses its script or region subtag.
  • Picker filter depends on two accessors agreeing. The case needs a finder built with a private-use default and an empty project-language accessor, which only a test constructs.
  • Debounced loader captures first-render languageFinder. Devin agrees the change is correct. The finder reference does not change without a remount.

An unlisted language may be created only in Subject Languages on the Project tab, which is
the one field that carries canCreateNew. The gate already held, but nothing tested it, and
losing it would bring back the defect this branch set out to fix: the allocator reads only
the project's own language fields, so two sessions would each be given qaa, and both
languages would export as ISO639-3:qaa.

Two end-to-end tests type a name that matches no language into a session field and into a
person field, and refuse a create row in the menu. Adding canCreateNew to the session field
makes the first test fail, so the guard works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hatton

hatton commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

[Claude Opus 5 from Hatton's machine during preflight]

Consulted Devin on 2026-08-27 up to commit 27f3c5db1f935c77b2f46ef839408a9084a73ad2.

Round three. No new actionable finding. Both earlier actionable items stay marked resolved by Devin: the unescaped quotes in the catalog, and the session Name that bypassed nameForExport.

Devin repeats the Investigate flag Session-only invented languages fall outside the collision map. Its thread above now carries the answer and is resolved: a user cannot create an unlisted language outside the Project tab, so every invented language is in a project language field and therefore in the collision map. Commit 27f3c5db adds two end-to-end tests that hold that gate shut.

One new informational flag, Names preserved for languages the file already carried. It notes that serializeLanguageFieldValue now writes eng:English where it once wrote eng, for an entry whose file already carried the name. That is intended, and languageTagFieldValue.spec.ts covers it. No action.

The test saved it to the repository root, which .gitignore does not cover,
so every end-to-end run left an untracked PNG behind. playwright.config.ts
already sets outputDir to tmp/playwright-output, and .gitignore covers tmp/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HughP

HughP commented Aug 28, 2026

Copy link
Copy Markdown

Hi John, @hatton

Semantically, the iso 639 identifier “und” is the appropriate identifier here not “qaa” or any other identifier in the reserved/“special use” block. The use of the ‘-x-‘ construct is a BCP-47 convention and not an iso 639 thing so <Id>ISO639-3:qaa-x-Kürbinian</Id> is not accurate. This is a constructed BCP-47 tag not an ISO 639 identifier. Additionally, the component that follows the -x- needs to conform to the BCP-47 convention, which it doesn’t. There are length and character set restrictions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants