diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6e876dde0..7bb833de0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,22 @@ ones are marked like "v1.0.0-fork".
## [Unreleased]
+### Fixed in Unreleased
+
+* **New languages lost their settings.** The language wizard filled the form
+ on screen but not the form's state, so Save reported a missing name, and
+ retyping the name stored the language without its dictionary, language
+ codes, parsing rules or parser. The wizard now also fills in the translator
+ URL, which it never did. Languages already saved this way keep their
+ settings: repair them from the language's edit page.
+
+* **Term status changes and term edits failed on multi-user installs.** Both
+ returned an HTTP 500, and a failed status change showed the user nothing.
+
+* **Starter vocabulary stayed invisible to the reader.** Imported words were
+ shown as unknown in every text, and they are now linked to existing texts
+ and to new ones. A migration repairs earlier imports.
+
## [3.8.0-fork] - 2026-11-03
### Added in 3.8.0-fork
diff --git a/cypress/e2e/04-languages.cy.ts b/cypress/e2e/04-languages.cy.ts
index 705861ac0..8e0964119 100644
--- a/cypress/e2e/04-languages.cy.ts
+++ b/cypress/e2e/04-languages.cy.ts
@@ -187,28 +187,35 @@ describe('Languages Management', () => {
});
it('should persist wizard-derived settings after saving', () => {
+ // The language keeps the name the wizard gave it, so clear what an
+ // interrupted run left behind.
+ const deleteDanish = () =>
+ cy.request('/api/v1/languages').then((response) => {
+ const languages = response.body.languages as Array<{ id: number; name: string }>;
+ languages.filter((l) => l.name === 'Danish').forEach((l) => {
+ cy.apiRequest({ method: 'DELETE', url: `/api/v1/languages/${l.id}` });
+ });
+ });
+ deleteDanish();
+
cy.visit('/languages/new');
cy.wait(500);
+ cy.get('input#l1').closest('.searchable-select').within(() => {
+ cy.get('.searchable-select__trigger').click();
+ cy.get('.searchable-select__dropdown input[type="text"]').type('English');
+ cy.get('.searchable-select__options li').contains('English').click();
+ });
+
cy.get('input#l2').closest('.searchable-select').within(() => {
cy.get('.searchable-select__trigger').click();
cy.get('.searchable-select__dropdown input[type="text"]').type('Danish');
+ cy.get('.searchable-select__options li').contains('Danish').click();
});
- cy.get('.searchable-select__options li').contains('Danish').click();
cy.wait(300);
- const uniqueLangName = `Danish Test ${Date.now()}`;
- cy.get('input[name="LgName"]').clear().type(uniqueLangName);
-
- // The embedded wizard fills the parsing settings but not the dictionary
- // URI, which is required — supply it the way a user would before saving.
- cy.contains('Advanced Settings').click();
- cy.get('input[name="LgDict1URI"]').then(($input) => {
- if (!$input.val()) {
- cy.wrap($input).type('https://example.com/###');
- }
- });
-
+ // Saved exactly as the wizard filled it in: retyping any field here
+ // would hide a wizard write that never reached the form's state.
cy.get('form[name="lg_form"] button[type="submit"]').click();
// Creating a language now hands off to the starter-vocabulary step.
@@ -218,15 +225,21 @@ describe('Languages Management', () => {
// Alpine root and then for the card itself rather than a fixed delay.
cy.visit('/languages');
cy.get('[x-data="languageList"]').should('exist');
- cy.contains(LANG_ROW, uniqueLangName, { timeout: 10000 })
+ cy.contains(LANG_ROW, 'Danish', { timeout: 10000 })
.within(() => {
cy.get('a[href*="/edit"]').click();
});
- cy.get('input[name="LgName"]').should('have.value', uniqueLangName);
+ cy.get('input[name="LgName"]').should('have.value', 'Danish');
cy.contains('Advanced Settings').click();
+ cy.get('input[name="LgDict1URI"]').should('have.value', 'https://glosbe.com/da/en/lwt_term');
+ cy.get('input[name="LgGoogleTranslateURI"]').invoke('val').should('contain', 'translate.google.com');
+ cy.get('input[name="LgSourceLang"]').should('have.value', 'da');
+ cy.get('input[name="LgTargetLang"]').should('have.value', 'en');
cy.get('input[name="LgRightToLeft"]').should('not.be.checked');
cy.get('input[name="LgRegexpWordCharacters"]').invoke('val').should('not.be.empty');
+
+ deleteDanish();
});
});
diff --git a/db/migrations/20261104_120000_link_frequency_imported_terms.sql b/db/migrations/20261104_120000_link_frequency_imported_terms.sql
new file mode 100644
index 000000000..ae04cae52
--- /dev/null
+++ b/db/migrations/20261104_120000_link_frequency_imported_terms.sql
@@ -0,0 +1,28 @@
+-- Link the terms a starter-vocabulary import created to the occurrences they
+-- match.
+--
+-- FrequencyImportService inserted its words with WoWordCount at 0, "not
+-- counted yet", and never touched word_occurrences. Text parsing only links
+-- terms counted as 1, so the words stayed unknown in every text, those parsed
+-- after the import included. This is the same failure as dictionary imports
+-- (see 20260830_120000_link_dictionary_imported_terms.sql).
+--
+-- The import fixes its own rows now. This repairs the installs that already
+-- ran it. The counts are filled in by Migrations::computeMissingWordCounts(),
+-- which runs after this file, because they come from each language's parsing
+-- rules. Linking does not need them, so the order does not matter here.
+--
+-- Narrowed to languages holding a term that was never counted. The join cannot
+-- use an index on word_occurrences, and on an install with nothing to repair
+-- this touches no occurrence at all.
+--
+-- Single-word occurrences only, and no user filter, for the reasons given in
+-- the dictionary migration.
+
+UPDATE word_occurrences o
+JOIN words w
+ ON LOWER(o.Ti2Text) = w.WoTextLC AND o.Ti2LgID = w.WoLgID
+SET o.Ti2WoID = w.WoID
+WHERE o.Ti2WoID IS NULL
+ AND o.Ti2WordCount = 1
+ AND o.Ti2LgID IN (SELECT DISTINCT WoLgID FROM words WHERE WoWordCount = 0);
diff --git a/src/Modules/Dictionary/Application/Services/LocalDictionaryService.php b/src/Modules/Dictionary/Application/Services/LocalDictionaryService.php
index 827df2032..6a4b44804 100644
--- a/src/Modules/Dictionary/Application/Services/LocalDictionaryService.php
+++ b/src/Modules/Dictionary/Application/Services/LocalDictionaryService.php
@@ -580,42 +580,11 @@ public function createVocabularyFromEntries(int $dictId, int $languageId): int
// to the routine that owns them; it only visits rows still at 0.
Maintenance::initWordCount();
- $this->linkOccurrencesForLanguage($languageId);
+ Maintenance::linkOccurrencesToTerms($languageId);
return $created;
}
- /**
- * Point this language's unlinked occurrences at the terms matching them.
- *
- * Single-word occurrences only: a multi-word occurrence is an expression,
- * matched by its own mechanism rather than through Ti2WoID.
- *
- * Nothing here filters by user, and it does not need to — the join pins
- * occurrence and term to the same LgID, and a `languages` row has exactly
- * one owner, so the statement cannot reach across users. This is the same
- * confinement {@see \Lwt\Modules\Vocabulary\Application\Services\WordLinkingService}
- * relies on, and the caller has already passed assertOwnsDictionary().
- *
- * @param int $languageId Language ID
- *
- * @return void
- */
- private function linkOccurrencesForLanguage(int $languageId): void
- {
- $occurrencesTable = 'word_occurrences';
- $wordsTable = 'words';
-
- Connection::preparedExecute(
- "UPDATE {$occurrencesTable} o
- JOIN {$wordsTable} w
- ON LOWER(o.Ti2Text) = w.WoTextLC AND o.Ti2LgID = w.WoLgID
- SET o.Ti2WoID = w.WoID
- WHERE o.Ti2WoID IS NULL AND o.Ti2WordCount = 1 AND o.Ti2LgID = ?",
- [$languageId]
- );
- }
-
/**
* Auto-enable local dictionary mode if currently set to online-only.
*
diff --git a/src/Modules/Vocabulary/Application/Services/FrequencyImportService.php b/src/Modules/Vocabulary/Application/Services/FrequencyImportService.php
index 95cdc7b36..9107472d0 100644
--- a/src/Modules/Vocabulary/Application/Services/FrequencyImportService.php
+++ b/src/Modules/Vocabulary/Application/Services/FrequencyImportService.php
@@ -6,6 +6,7 @@
use Lwt\Shared\Infrastructure\Database\Connection;
use Lwt\Shared\Infrastructure\Database\DB;
+use Lwt\Shared\Infrastructure\Database\Maintenance;
use Lwt\Shared\Infrastructure\Database\UserScopedQuery;
/**
@@ -102,11 +103,40 @@ public function importWords(int $langId, string $languageName, int $count): arra
throw $e;
}
+ if ($inserted > 0) {
+ $this->makeVisibleToReader($langId);
+ }
+
$skipped = $total - $inserted;
return ['imported' => $inserted, 'skipped' => $skipped, 'total' => $total];
}
+ /**
+ * Make freshly imported terms visible to the reader.
+ *
+ * The rows go in with WoWordCount = 0, "not counted yet": text parsing
+ * only links terms counted as 1, so without this an imported word stays
+ * unknown in every text. The count comes from the language's own parsing
+ * rules, MeCab included, so it cannot be a constant in the INSERT.
+ *
+ * The words are already committed, so a failure here, such as a missing
+ * tokenizer, is logged rather than reported as a failed import.
+ *
+ * @param int $langId Language ID
+ *
+ * @return void
+ */
+ private function makeVisibleToReader(int $langId): void
+ {
+ try {
+ Maintenance::initWordCount();
+ Maintenance::linkOccurrencesToTerms($langId);
+ } catch (\Throwable $e) {
+ error_log('Could not link imported starter words: ' . $e->getMessage());
+ }
+ }
+
/**
* Parse the FrequencyWords text format.
*
diff --git a/src/Modules/Vocabulary/Http/TermCrudApiHandler.php b/src/Modules/Vocabulary/Http/TermCrudApiHandler.php
index 86c3a7fb1..f5c002ea9 100644
--- a/src/Modules/Vocabulary/Http/TermCrudApiHandler.php
+++ b/src/Modules/Vocabulary/Http/TermCrudApiHandler.php
@@ -930,7 +930,7 @@ public function updateTermFull(int $termId, array $data): array
WoStatusChanged = NOW()
WHERE WoID = ?"
. UserScopedQuery::forTablePrepared('words', $bindings),
- [$text, $translation, $romanization, $sentence, $notes, $lemma, $lemmaLc, $status, $termId]
+ $bindings
);
// A term whose occurrences were never linked stays invisible to the
diff --git a/src/Modules/Vocabulary/Http/TermStatusApiHandler.php b/src/Modules/Vocabulary/Http/TermStatusApiHandler.php
index 1dd6d2678..38e8d9f91 100644
--- a/src/Modules/Vocabulary/Http/TermStatusApiHandler.php
+++ b/src/Modules/Vocabulary/Http/TermStatusApiHandler.php
@@ -217,7 +217,7 @@ public function setWordStatus(int $wid, int $status): int
SET WoStatus = ?, WoStatusChanged = NOW()
WHERE WoID = ?"
. UserScopedQuery::forTablePrepared('words', $bindings),
- [$status, $wid]
+ $bindings
);
}
diff --git a/src/Shared/Infrastructure/Database/Maintenance.php b/src/Shared/Infrastructure/Database/Maintenance.php
index 7e5958124..237c465ce 100644
--- a/src/Shared/Infrastructure/Database/Maintenance.php
+++ b/src/Shared/Infrastructure/Database/Maintenance.php
@@ -180,6 +180,33 @@ public static function updateJapaneseWordCount(int $japid): void
}
}
+ /**
+ * Point a language's unlinked occurrences at the terms matching them.
+ *
+ * Single-word occurrences only: a multi-word occurrence is an expression,
+ * matched by its own mechanism rather than through Ti2WoID.
+ *
+ * Nothing here filters by user, and it does not need to: the join pins
+ * occurrence and term to the same LgID, and a `languages` row has exactly
+ * one owner, so the statement cannot reach across users. Callers must have
+ * established that the language is the current user's.
+ *
+ * @param int $languageId Language ID
+ *
+ * @return void
+ */
+ public static function linkOccurrencesToTerms(int $languageId): void
+ {
+ Connection::preparedExecute(
+ "UPDATE word_occurrences o
+ JOIN words w
+ ON LOWER(o.Ti2Text) = w.WoTextLC AND o.Ti2LgID = w.WoLgID
+ SET o.Ti2WoID = w.WoID
+ WHERE o.Ti2WoID IS NULL AND o.Ti2WordCount = 1 AND o.Ti2LgID = ?",
+ [$languageId]
+ );
+ }
+
/**
* Initiate the number of words in terms for all languages.
*
diff --git a/src/frontend/js/modules/language/pages/bound_fields.ts b/src/frontend/js/modules/language/pages/bound_fields.ts
new file mode 100644
index 000000000..8ad46c256
--- /dev/null
+++ b/src/frontend/js/modules/language/pages/bound_fields.ts
@@ -0,0 +1,39 @@
+/**
+ * Write to language form fields that are bound with Alpine's `x-model`.
+ *
+ * `x-model` reads from the DOM only when an input or change event fires.
+ * Assigning `.value` alone updates what the user sees but leaves the form's
+ * state holding the old value, so the write is lost the moment the form saves.
+ *
+ * @license Unlicense
+ * @since 3.8.1
+ */
+
+/**
+ * Set a bound field's value and tell Alpine about it.
+ *
+ * @param input Field to write to, if it exists
+ * @param value Value to set
+ */
+export function setBoundValue(
+ input: HTMLInputElement | HTMLSelectElement | null,
+ value: string
+): void {
+ if (!input) return;
+ input.value = value;
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ // Alpine binds a