Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 28 additions & 15 deletions cypress/e2e/04-languages.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
});
});

Expand Down
28 changes: 28 additions & 0 deletions db/migrations/20261104_120000_link_frequency_imported_terms.sql
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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.
*
Expand Down
2 changes: 1 addition & 1 deletion src/Modules/Vocabulary/Http/TermCrudApiHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Modules/Vocabulary/Http/TermStatusApiHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down
27 changes: 27 additions & 0 deletions src/Shared/Infrastructure/Database/Maintenance.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
39 changes: 39 additions & 0 deletions src/frontend/js/modules/language/pages/bound_fields.ts
Original file line number Diff line number Diff line change
@@ -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 <http://unlicense.org/>
* @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 <select> on change, not input
input.dispatchEvent(new Event('change', { bubbles: true }));
}

/**
* Check or uncheck a bound checkbox and tell Alpine about it.
*
* @param input Checkbox to write to, if it exists
* @param checked Desired state
*/
export function setBoundChecked(input: HTMLInputElement | null, checked: boolean): void {
if (!input) return;
input.checked = checked;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
2 changes: 1 addition & 1 deletion src/frontend/js/modules/language/pages/language_editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function languageEditorData(): LanguageEditorData {

async save(): Promise<void> {
if (this.lang.name.trim() === '') {
this.error = t('language.form.name_required');
this.error = t('language.errors.name_required');
return;
}

Expand Down
39 changes: 2 additions & 37 deletions src/frontend/js/modules/language/pages/language_form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* @since 3.0.0
*/

import { setBoundChecked, setBoundValue } from './bound_fields';
import { onDomReady } from '@shared/utils/dom_ready';
import { getLibreTranslateTranslation } from '@modules/vocabulary/services/translation_api';
import { deepFindValue, readTextWithExternal } from '@shared/utils/user_interactions';
Expand Down Expand Up @@ -121,9 +122,7 @@ export const languageForm = {
const lgForm = document.forms.namedItem('lg_form') as HTMLFormElement | null;
if (lgForm) {
const translatorUri = lgForm.elements.namedItem('LgGoogleTranslateURI') as HTMLInputElement | null;
if (translatorUri) {
translatorUri.value = result;
}
setBoundValue(translatorUri, result);
}
}

Expand Down Expand Up @@ -389,40 +388,6 @@ export function checkDuplicateLanguage(
return true;
}

/**
* Set an input's value and tell Alpine about it.
*
* The language fields are bound with `x-model`, which reads from the DOM only
* when an input/change event fires. Assigning `.value` alone updates what the
* user sees but leaves `lang` holding the old value, so the preset would be
* discarded the moment the form is saved.
*
* @param input Field to write to, if it exists
* @param value Value to set
*/
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 <select> on change, not input
input.dispatchEvent(new Event('change', { bubbles: true }));
}

/**
* Check or uncheck a bound checkbox and tell Alpine about it.
*
* @param input Checkbox to write to, if it exists
* @param checked Desired state
*/
function setBoundChecked(input: HTMLInputElement | null, checked: boolean): void {
if (!input) return;
input.checked = checked;
input.dispatchEvent(new Event('change', { bubbles: true }));
}

/**
* Apply wizard preset data to the form.
*/
Expand Down
Loading
Loading