-
+
${escapeHtml(t('review.table.col_edit'))}
-
+
@@ -461,9 +463,9 @@ function buildWordModal(): string {
@@ -821,6 +823,9 @@ function registerTableReviewComponent(): void {
async init() {
this.loadColumnSettings();
this.loadContextAnnotationSettings();
+ document.addEventListener('lwt-term-saved', () => {
+ void this.loadWords();
+ });
await this.loadWords();
},
@@ -853,6 +858,18 @@ function registerTableReviewComponent(): void {
}
},
+ /**
+ * Open the term editor for a row.
+ *
+ * A saved term fires lwt-term-saved, which the component listens for to
+ * pull the row's fresh values back in.
+ *
+ * @param wordId Term ID
+ */
+ editWord(wordId: number) {
+ void openTermEditModal(0, 0, wordId);
+ },
+
revealTerm(wordId: number) {
if (this.hideTermContent) this.revealedTerms[wordId] = true;
},
diff --git a/src/frontend/js/modules/review/stores/review_store.ts b/src/frontend/js/modules/review/stores/review_store.ts
index 678e8e749..a05707993 100644
--- a/src/frontend/js/modules/review/stores/review_store.ts
+++ b/src/frontend/js/modules/review/stores/review_store.ts
@@ -10,6 +10,8 @@
import Alpine from 'alpinejs';
import { ReviewApi } from '@modules/review/api/review_api';
+import { successSound, failureSound } from '@shared/utils/audio_feedback';
+import { openTermEditModal } from '@modules/vocabulary/components/term_edit_modal';
/**
* Language settings for the review.
@@ -130,7 +132,8 @@ export interface ReviewStoreState {
formatElapsed(seconds: number): string;
getDictUrl(which: 'dict1' | 'dict2' | 'translator'): string;
hasDictUrl(which: 'dict1' | 'dict2' | 'translator'): boolean;
- getEditUrl(): string;
+ editWord(wordId: number): void;
+ editCurrentWord(): void;
openModal(): void;
closeModal(): void;
playSound(correct: boolean): void;
@@ -473,11 +476,23 @@ function createReviewStore(initialValues?: ReviewStoreInitialValues): ReviewStor
},
/**
- * Get edit URL for the current word.
+ * Open the term editor for a word in the review table.
+ *
+ * The term already knows its language, so no reading context is needed.
+ *
+ * @param wordId Term ID
*/
- getEditUrl(): string {
- if (!this.currentWord) return '#';
- return `/word/edit-term?wid=${this.currentWord.wordId}`;
+ editWord(wordId: number): void {
+ void openTermEditModal(0, 0, wordId);
+ },
+
+ /**
+ * Open the term editor for the word currently under review.
+ */
+ editCurrentWord(): void {
+ if (!this.currentWord) return;
+ this.closeModal();
+ void openTermEditModal(0, 0, this.currentWord.wordId);
},
/**
@@ -498,14 +513,10 @@ function createReviewStore(initialValues?: ReviewStoreInitialValues): ReviewStor
* Play success or failure sound.
*/
playSound(correct: boolean): void {
- const soundId = correct ? 'success_sound' : 'failure_sound';
- const audio = document.getElementById(soundId) as HTMLAudioElement | null;
- if (audio) {
- audio.currentTime = 0;
- audio.play().catch(() => {
- // Ignore autoplay errors
- });
- }
+ const play = correct ? successSound : failureSound;
+ play().catch(() => {
+ // Ignore autoplay errors
+ });
},
/**
diff --git a/src/frontend/js/modules/text/pages/reading/frame_management.ts b/src/frontend/js/modules/text/pages/reading/frame_management.ts
deleted file mode 100644
index e390109d8..000000000
--- a/src/frontend/js/modules/text/pages/reading/frame_management.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * Frame Management - Right frames show/hide/cleanup operations
- *
- * The iframe system is only used for external dictionary lookups.
- * Internal LWT operations now use the API-based word_store and word_modal
- * components with Bulma + Alpine.js.
- *
- * Sound functions (successSound, failureSound) are still used.
- * Consider using audio_feedback.ts for new code.
- *
- * @license unlicense
- * @author andreask7
- * @since 1.6.16-fork
- */
-
-import { onDomReady } from '@shared/utils/dom_ready';
-import { closeParentPopup } from '@modules/vocabulary/components/word_popup';
-
-/**
- * Animate an element's CSS property using requestAnimationFrame.
- *
- * @param el Element to animate
- * @param property CSS property to animate
- * @param targetValue Target value (e.g., '5px')
- * @param duration Animation duration in ms
- */
-function animateStyle(
- el: HTMLElement,
- property: 'right' | 'left' | 'top' | 'bottom',
- targetValue: string,
- duration: number = 300
-): void {
- const startValue = parseFloat(getComputedStyle(el)[property]) || 0;
- const endValue = parseFloat(targetValue);
- const startTime = performance.now();
-
- function step(currentTime: number): void {
- const elapsed = currentTime - startTime;
- const progress = Math.min(elapsed / duration, 1);
- // Ease out cubic
- const easeProgress = 1 - Math.pow(1 - progress, 3);
- const currentValue = startValue + (endValue - startValue) * easeProgress;
- el.style[property] = currentValue + 'px';
-
- if (progress < 1) {
- requestAnimationFrame(step);
- }
- }
-
- requestAnimationFrame(step);
-}
-
-/**
- * Show the right frames panel if found.
- *
- * This function only reveals the panel (animates it into view),
- * without loading any content into the frames.
- *
- * @returns true if frames panel was found and shown, false otherwise
- */
-export function showRightFramesPanel(): boolean {
- const framesR = document.getElementById('frames-r');
- if (framesR) {
- animateStyle(framesR, 'right', '5px');
- return true;
- }
- return false;
-}
-
-
-/**
- * Hide the right frames if found.
- *
- * @returns true if frames were found, false otherwise
- */
-export function hideRightFrames(): boolean {
- const framesR = document.getElementById('frames-r');
- if (framesR) {
- // Get the parent width to calculate -100%
- const parentWidth = framesR.parentElement?.offsetWidth || window.innerWidth;
- animateStyle(framesR, 'right', `-${parentWidth}px`);
- return true;
- }
- return false;
-}
-
-/**
- * Hide the right frame and any popups.
- *
- * Called from several places: insert_word_ignore.php,
- * set_word_status.php, delete_word.php, etc.
- */
-export function cleanupRightFrames(): void {
- const mytimeout = function () {
- const rf = window.parent.document.getElementById('frames-r');
- rf?.click();
- };
- window.parent.setTimeout(mytimeout, 800);
-
- window.parent.document.getElementById('frame-l')?.focus();
- // Close popup in parent frame via custom event
- setTimeout(() => closeParentPopup(), 100);
-}
-
-/**
- * Play the success sound.
- *
- * @returns Promise on the status of sound
- */
-export function successSound(): Promise {
- (document.getElementById('success_sound') as HTMLAudioElement)?.pause();
- (document.getElementById('failure_sound') as HTMLAudioElement)?.pause();
- return (document.getElementById('success_sound') as HTMLAudioElement)?.play();
-}
-
-/**
- * Play the failure sound.
- *
- * @returns Promise on the status of sound
- */
-export function failureSound(): Promise {
- (document.getElementById('success_sound') as HTMLAudioElement)?.pause();
- (document.getElementById('failure_sound') as HTMLAudioElement)?.pause();
- return (document.getElementById('failure_sound') as HTMLAudioElement)?.play();
-}
-
-/**
- * Initialize event delegation for hide-right-frames action.
- * Handles clicks on elements with data-action="hide-right-frames".
- */
-export function initHideRightFramesHandler(): void {
- document.addEventListener('click', function (e) {
- const target = e.target as HTMLElement;
- // Check if click is on an element with the data-action attribute
- if (target.matches('[data-action="hide-right-frames"]')) {
- hideRightFrames();
- }
- });
-}
-
-// Auto-initialize when DOM is ready
-onDomReady(initHideRightFramesHandler);
diff --git a/src/frontend/js/modules/text/pages/text_suggestions.ts b/src/frontend/js/modules/text/pages/text_suggestions.ts
index e793c05a9..ea5e2cfc8 100644
--- a/src/frontend/js/modules/text/pages/text_suggestions.ts
+++ b/src/frontend/js/modules/text/pages/text_suggestions.ts
@@ -11,6 +11,7 @@
import Alpine from 'alpinejs';
import { initIcons } from '@shared/icons/lucide_icons';
import { getCsrfToken } from '@shared/api/client';
+import { importEpubForm } from '@modules/book/api/books_api';
// ── Gutenberg browser ───────────────────────────────────────────────
@@ -786,6 +787,9 @@ interface TextNewFormData {
isEpub(): boolean;
formAction(): string;
submitOp(): string;
+ epubError: string;
+ hasEpubError(): boolean;
+ handleSubmit(event: Event): void;
}
export function textNewFormData(): TextNewFormData {
@@ -796,6 +800,7 @@ export function textNewFormData(): TextNewFormData {
autoImporting: false,
fileTab: 'computer',
fileType: '',
+ epubError: '',
init() {
// When arriving via import_url (Gutenberg/Feed) or import_epub_url
@@ -867,7 +872,43 @@ export function textNewFormData(): TextNewFormData {
},
formAction(): string {
- return this.isEpub() ? '/book/import' : '/texts/new';
+ return this.isEpub() ? '/api/v1/books' : '/texts/new';
+ },
+
+ /**
+ * Whether the last EPUB import reported a failure.
+ *
+ * @returns True when an error message is pending
+ */
+ hasEpubError(): boolean {
+ return this.epubError !== '';
+ },
+
+ /**
+ * Send an EPUB to the books API instead of letting the form post.
+ *
+ * Every other source keeps its native POST to /texts/new.
+ *
+ * @param event Submit event
+ */
+ handleSubmit(event: Event) {
+ if (!this.isEpub()) return;
+
+ event.preventDefault();
+ const form = event.target as HTMLFormElement | null;
+ if (!form || this.autoImporting) return;
+
+ this.epubError = '';
+ this.autoImporting = true;
+
+ void importEpubForm(form).then((result) => {
+ if (result.bookId !== null) {
+ window.location.href = `/book/${result.bookId}`;
+ return;
+ }
+ this.epubError = result.error;
+ this.autoImporting = false;
+ });
},
submitOp(): string {
diff --git a/src/frontend/js/modules/vocabulary/api/terms_api.ts b/src/frontend/js/modules/vocabulary/api/terms_api.ts
index 0dc1fcc02..2742845cb 100644
--- a/src/frontend/js/modules/vocabulary/api/terms_api.ts
+++ b/src/frontend/js/modules/vocabulary/api/terms_api.ts
@@ -163,6 +163,8 @@ export interface TermCreateFullRequest {
* Request body for updating a term with full data.
*/
export interface TermUpdateFullRequest {
+ /** Term text. Only a change of capitalization is accepted by the server. */
+ text?: string;
translation: string;
romanization?: string;
sentence?: string;
@@ -496,6 +498,7 @@ export const TermsApi = {
position: number,
wordId?: number
): Promise> {
+ // An existing term carries its own language, so textId/position may be 0.
const params: Record = {
term_id: String(textId),
ord: String(position)
diff --git a/src/frontend/js/modules/vocabulary/components/term_edit_modal.ts b/src/frontend/js/modules/vocabulary/components/term_edit_modal.ts
index a4b9cf259..e438649fd 100644
--- a/src/frontend/js/modules/vocabulary/components/term_edit_modal.ts
+++ b/src/frontend/js/modules/vocabulary/components/term_edit_modal.ts
@@ -1,8 +1,10 @@
/**
* Term Edit Modal - Standalone modal for editing terms via API.
*
- * Provides a simple modal form for editing terms from the annotation page,
- * using the generic modal component and TermsApi for data loading/saving.
+ * Replaces the server-rendered /word/edit and /word/edit-term forms: the
+ * /terms/for-edit endpoint already returns every field those pages rendered
+ * (lemma, notes, tags, similar terms, dictionary URI), so the modal renders
+ * from data instead of from PHP-emitted HTML.
*
* @license Unlicense
* @since 3.0.0
@@ -12,11 +14,30 @@ import { openModal, closeModal } from '@shared/components/modal';
import {
TermsApi,
type TermForEditResponse,
+ type SimilarTermForEdit,
type TermCreateFullRequest,
type TermUpdateFullRequest
} from '@modules/vocabulary/api/terms_api';
import { escapeHtml } from '@shared/utils/html_utils';
import { getStatusDefinitions } from '@shared/stores/statuses';
+import { createTheDictUrl } from '@modules/vocabulary/services/dictionary';
+import { t } from '@shared/i18n/translator';
+
+/**
+ * What the host does once the editor finishes.
+ *
+ * The modal closes itself; a full-page editor navigates away instead. Keeping
+ * these as callbacks is what lets one renderer serve both.
+ */
+interface EditorHost {
+ onSaved(): void;
+ onCancel(): void;
+}
+
+let host: EditorHost = {
+ onSaved: () => closeModal(),
+ onCancel: () => closeModal()
+};
/** Current form context */
let currentContext: {
@@ -25,10 +46,113 @@ let currentContext: {
wordId: number | null;
isNew: boolean;
hex: string;
+ /** Lowercase form the term text must keep — only recasing is allowed. */
+ textLc: string;
} | null = null;
+/** Field IDs, kept in one place so the render and read paths cannot drift. */
+const FIELD = {
+ form: 'term-edit-form',
+ text: 'term-edit-text',
+ translation: 'term-edit-translation',
+ romanization: 'term-edit-romanization',
+ lemma: 'term-edit-lemma',
+ sentence: 'term-edit-sentence',
+ notes: 'term-edit-notes',
+ tags: 'term-edit-tags',
+ status: 'term-edit-status',
+ save: 'term-edit-save',
+ cancel: 'term-edit-cancel',
+ error: 'term-edit-error'
+} as const;
+
+/**
+ * Read a form field's value, or '' when the field was not rendered.
+ *
+ * @param id Element ID
+ *
+ * @returns Trimmed-as-typed value
+ */
+function fieldValue(id: string): string {
+ const el = document.getElementById(id);
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
+ || el instanceof HTMLSelectElement) {
+ return el.value;
+ }
+ return '';
+}
+
+/**
+ * Split a comma-separated tag input into a clean list.
+ *
+ * @param raw Raw input value
+ *
+ * @returns Non-empty, de-duplicated tags in entry order
+ */
+function parseTags(raw: string): string[] {
+ const seen = new Set();
+ for (const part of raw.split(',')) {
+ const tag = part.trim();
+ if (tag !== '') {
+ seen.add(tag);
+ }
+ }
+ return [...seen];
+}
+
+/**
+ * Render the similar-terms block, or '' when there are none.
+ *
+ * @param similar Similar terms from the API
+ *
+ * @returns HTML fragment
+ */
+function renderSimilarTerms(similar: SimilarTermForEdit[] | undefined): string {
+ if (!similar || similar.length === 0) {
+ return '';
+ }
+ const items = similar.map(term => {
+ const translation = term.translation === '*' ? '' : term.translation;
+ const suffix = translation === '' ? '' : ` — ${escapeHtml(translation)}`;
+ return `${escapeHtml(term.text)} ${suffix} `;
+ }).join('');
+
+ return `
+
+
${escapeHtml(t('vocabulary.form.similar_terms'))}
+
+
+ `;
+}
+
+/**
+ * Render the dictionary lookup link, or '' when the language has no URI.
+ *
+ * @param translateUri Language translation URI template
+ * @param term Term text to look up
+ *
+ * @returns HTML fragment
+ */
+function renderDictLink(translateUri: string | undefined, term: string): string {
+ if (!translateUri || translateUri.trim() === '') {
+ return '';
+ }
+ const url = createTheDictUrl(translateUri, term);
+ return `
+
+
+ ${escapeHtml(t('vocabulary.form.dictionary_lookup'))}
+
+
+ `;
+}
+
/**
* Render the edit form HTML.
+ *
+ * @param data Term edit payload
+ *
+ * @returns HTML for the modal body
*/
function renderForm(data: TermForEditResponse): string {
const term = data.term;
@@ -41,27 +165,37 @@ function renderForm(data: TermForEditResponse): string {
const romanizationField = lang.showRomanization ? `
-
Romanization
+
${escapeHtml(t('vocabulary.common.romanization'))}
-
` : '';
+ const tagOptions = (data.allTags ?? [])
+ .map(tag => ``)
+ .join('');
+
return `
-
`;
}
+/**
+ * Show an error message inside the open form.
+ *
+ * @param message Message to display
+ */
+function showError(message: string): void {
+ const errorEl = document.getElementById(FIELD.error);
+ if (errorEl) {
+ errorEl.textContent = message;
+ errorEl.style.display = 'block';
+ }
+}
+
/**
* Handle form submission.
+ *
+ * @param e Submit event
*/
async function handleSave(e: Event): Promise {
e.preventDefault();
if (!currentContext) return;
- const saveBtn = document.getElementById('term-edit-save') as HTMLButtonElement;
- const errorEl = document.getElementById('term-edit-error');
+ const saveBtn = document.getElementById(FIELD.save) as HTMLButtonElement | null;
+ const errorEl = document.getElementById(FIELD.error);
if (saveBtn) {
saveBtn.disabled = true;
@@ -121,10 +303,24 @@ async function handleSave(e: Event): Promise {
errorEl.style.display = 'none';
}
- const translation = (document.getElementById('term-edit-translation') as HTMLTextAreaElement)?.value || '';
- const romanization = (document.getElementById('term-edit-romanization') as HTMLInputElement)?.value || '';
- const sentence = (document.getElementById('term-edit-sentence') as HTMLTextAreaElement)?.value || '';
- const status = parseInt((document.getElementById('term-edit-status') as HTMLSelectElement)?.value || '1', 10);
+ const text = fieldValue(FIELD.text).trim();
+ const translation = fieldValue(FIELD.translation);
+ const romanization = fieldValue(FIELD.romanization);
+ const lemma = fieldValue(FIELD.lemma).trim();
+ const sentence = fieldValue(FIELD.sentence);
+ const notes = fieldValue(FIELD.notes);
+ const tags = parseTags(fieldValue(FIELD.tags));
+ const status = parseInt(fieldValue(FIELD.status) || '1', 10);
+
+ // Mirror the server rule so a recase typo is caught before the round trip.
+ if (!currentContext.isNew && text !== '' && text.toLowerCase() !== currentContext.textLc) {
+ showError(t('vocabulary.form.uppercase_only_hint'));
+ if (saveBtn) {
+ saveBtn.disabled = false;
+ saveBtn.classList.remove('is-loading');
+ }
+ return;
+ }
try {
let response;
@@ -136,8 +332,10 @@ async function handleSave(e: Event): Promise {
translation,
romanization,
sentence,
+ notes,
+ lemma,
status,
- tags: []
+ tags
};
response = await TermsApi.createFull(createData);
} else {
@@ -145,11 +343,14 @@ async function handleSave(e: Event): Promise {
throw new Error('Word ID is missing');
}
const updateData: TermUpdateFullRequest = {
+ text,
translation,
romanization,
sentence,
+ notes,
+ lemma,
status,
- tags: []
+ tags
};
response = await TermsApi.updateFull(currentContext.wordId, updateData);
}
@@ -158,10 +359,10 @@ async function handleSave(e: Event): Promise {
throw new Error(response.error || response.data?.error || 'Failed to save');
}
- // Success - close modal and dispatch event for parent page to refresh
- closeModal();
+ // Success - let the host decide what "done" looks like
+ host.onSaved();
- // Dispatch event to notify annotation page to refresh
+ // Dispatch event to notify the host page to refresh
if (response.data?.term) {
document.dispatchEvent(new CustomEvent('lwt-term-saved', {
detail: {
@@ -172,10 +373,7 @@ async function handleSave(e: Event): Promise {
}));
}
} catch (error) {
- if (errorEl) {
- errorEl.textContent = error instanceof Error ? error.message : 'Failed to save term';
- errorEl.style.display = 'block';
- }
+ showError(error instanceof Error ? error.message : 'Failed to save term');
}
if (saveBtn) {
@@ -184,76 +382,116 @@ async function handleSave(e: Event): Promise {
}
}
+/** A loaded editor, ready for the host to insert. */
+export interface LoadedTermEditor {
+ html: string;
+ title: string;
+}
+
/**
- * Open a modal to edit a term.
+ * Fetch a term and render its editor, without deciding where it goes.
*
- * @param textId Text ID
+ * @param textId Text ID (may be 0 when wordId is given)
* @param position Word position in text
* @param wordId Word ID (optional, for existing terms)
+ *
+ * @returns The rendered editor, or the error to show instead
*/
-export async function openTermEditModal(
+export async function loadTermEditor(
textId: number,
position: number,
wordId?: number
-): Promise {
- // Show loading modal
- openModal('', {
- title: 'Edit Term',
- closeOnEscape: true,
- closeOnOverlayClick: false
- });
-
+): Promise<{ ok: true; editor: LoadedTermEditor } | { ok: false; error: string }> {
try {
const response = await TermsApi.getForEdit(textId, position, wordId);
if (response.error || !response.data) {
- openModal(`${escapeHtml(response.error || 'Failed to load term data')}
`, {
- title: 'Error'
- });
- return;
+ return { ok: false, error: response.error || 'Failed to load term data' };
}
-
if (response.data.error) {
- openModal(`${escapeHtml(response.data.error)}
`, {
- title: 'Error'
- });
- return;
+ return { ok: false, error: response.data.error };
}
- // Store context for save handler
currentContext = {
textId,
position,
wordId: response.data.term.id,
isNew: response.data.isNew,
- hex: response.data.term.hex
+ hex: response.data.term.hex,
+ textLc: response.data.term.textLc ?? ''
};
- // Render form
- const title = response.data.isNew ? 'Add Term' : 'Edit Term';
- openModal(renderForm(response.data), {
- title,
- closeOnEscape: true,
- closeOnOverlayClick: false
- });
+ return {
+ ok: true,
+ editor: {
+ html: renderForm(response.data),
+ title: response.data.isNew
+ ? t('vocabulary.form.new_term')
+ : t('vocabulary.form.edit_term')
+ }
+ };
+ } catch {
+ return { ok: false, error: 'Failed to load term data' };
+ }
+}
- // Attach event listeners
- const form = document.getElementById('term-edit-form');
- const cancelBtn = document.getElementById('term-edit-cancel');
+/**
+ * Attach the editor's handlers once its markup is in the document.
+ *
+ * @param hostHandlers What to do after a save, and on cancel
+ */
+export function wireTermEditor(hostHandlers: EditorHost): void {
+ host = hostHandlers;
- if (form) {
- form.addEventListener('submit', handleSave);
- }
- if (cancelBtn) {
- cancelBtn.addEventListener('click', () => closeModal());
- }
- } catch {
- openModal('Failed to load term data
', {
- title: 'Error'
- });
+ const form = document.getElementById(FIELD.form);
+ const cancelBtn = document.getElementById(FIELD.cancel);
+
+ if (form) {
+ form.addEventListener('submit', handleSave);
+ }
+ if (cancelBtn) {
+ cancelBtn.addEventListener('click', () => host.onCancel());
}
}
+/**
+ * Open a modal to edit a term.
+ *
+ * @param textId Text ID
+ * @param position Word position in text
+ * @param wordId Word ID (optional, for existing terms)
+ */
+export async function openTermEditModal(
+ textId: number,
+ position: number,
+ wordId?: number
+): Promise {
+ // Show loading modal
+ openModal(`${escapeHtml(t('vocabulary.common.loading'))}
`, {
+ title: t('vocabulary.form.edit_term'),
+ closeOnEscape: true,
+ closeOnOverlayClick: false
+ });
+
+ const result = await loadTermEditor(textId, position, wordId);
+
+ if (!result.ok) {
+ openModal(`${escapeHtml(result.error)}
`, { title: 'Error' });
+ return;
+ }
+
+ openModal(result.editor.html, {
+ title: result.editor.title,
+ closeOnEscape: true,
+ closeOnOverlayClick: false
+ });
+
+ wireTermEditor({
+ onSaved: () => closeModal(),
+ onCancel: () => closeModal()
+ });
+}
+
// Expose for global access (needed for inline onclick handlers)
declare global {
interface Window {
diff --git a/src/frontend/js/modules/vocabulary/components/word_modal.ts b/src/frontend/js/modules/vocabulary/components/word_modal.ts
index 05b876919..9f8411a91 100644
--- a/src/frontend/js/modules/vocabulary/components/word_modal.ts
+++ b/src/frontend/js/modules/vocabulary/components/word_modal.ts
@@ -106,7 +106,6 @@ export interface WordModalData {
markWellKnown(): Promise;
markIgnored(): Promise;
deleteWord(): Promise;
- getEditUrl(): string;
getDictUrl(which: 'dict1' | 'dict2' | 'translator'): string;
hasDictUrl(which: 'dict1' | 'dict2' | 'translator'): boolean;
isCurrentStatus(status: number): boolean;
@@ -328,22 +327,6 @@ export function wordModalData(): WordModalData {
}
},
- getEditUrl(): string {
- const word = this.word;
- if (!word) return '#';
-
- const params = new URLSearchParams({
- tid: String(this.store.textId),
- ord: String(word.position)
- });
-
- if (word.wordId) {
- params.set('wid', String(word.wordId));
- }
-
- return `/word/edit?${params.toString()}`;
- },
-
getDictUrl(which: 'dict1' | 'dict2' | 'translator'): string {
return this.store.getDictUrl(which);
},
diff --git a/src/frontend/js/modules/vocabulary/components/word_popup.ts b/src/frontend/js/modules/vocabulary/components/word_popup.ts
index 52ff4a096..6aa8e4204 100644
--- a/src/frontend/js/modules/vocabulary/components/word_popup.ts
+++ b/src/frontend/js/modules/vocabulary/components/word_popup.ts
@@ -295,24 +295,4 @@ if (typeof document !== 'undefined') {
const styleEl = document.createElement('style');
styleEl.textContent = styles;
document.head.appendChild(styleEl);
-
- // Listen for cross-frame popup close events
- document.addEventListener('lwt-close-popup', () => {
- closePopup();
- });
-}
-
-
-/**
- * Close popup in parent frame via custom event.
- * Use this from child frames instead of accessing window.parent.closePopup directly.
- */
-export function closeParentPopup(): void {
- try {
- if (window.parent && window.parent !== window) {
- window.parent.document.dispatchEvent(new CustomEvent('lwt-close-popup'));
- }
- } catch {
- // Parent access may be blocked by same-origin policy, ignore
- }
}
diff --git a/src/frontend/js/modules/vocabulary/index.ts b/src/frontend/js/modules/vocabulary/index.ts
index ce889d3e2..7e8425eac 100644
--- a/src/frontend/js/modules/vocabulary/index.ts
+++ b/src/frontend/js/modules/vocabulary/index.ts
@@ -40,13 +40,12 @@ export * from './services/word_dom_updates';
// Shared utilities needed by vocabulary pages
import '@shared/forms/bulk_actions';
-import '@shared/forms/word_form_auto';
// Side-effect imports (pages)
import './pages/word_list_app';
import './pages/bulk_translate';
import './pages/word_upload';
import './pages/expression_interactable';
-import './pages/word_result_init';
import './pages/translation_page';
import './pages/starter_vocab';
+import './pages/term_edit_page';
diff --git a/src/frontend/js/modules/vocabulary/pages/bulk_translate.ts b/src/frontend/js/modules/vocabulary/pages/bulk_translate.ts
index b340229b1..93ae56ce7 100644
--- a/src/frontend/js/modules/vocabulary/pages/bulk_translate.ts
+++ b/src/frontend/js/modules/vocabulary/pages/bulk_translate.ts
@@ -13,6 +13,8 @@ import Alpine from 'alpinejs';
import { createTheDictUrl, openDictionaryPopup } from '@modules/vocabulary/services/dictionary';
import { selectToggle } from '@shared/forms/bulk_actions';
import { setDictionaryLinks } from '@modules/language/stores/language_config';
+import { apiPost } from '@shared/api/client';
+import { t } from '@shared/i18n/translator';
declare global {
interface Window {
@@ -37,6 +39,93 @@ declare global {
};
}
+/** Response from POST /terms/bulk. */
+interface BulkSaveResponse {
+ success?: boolean;
+ saved?: number;
+ error?: string;
+}
+
+/** One term row as the bulk endpoint expects it. */
+interface BulkTerm {
+ lg: number;
+ text: string;
+ status: number;
+ trans: string;
+}
+
+/**
+ * Gather the `term[N][field]` inputs into a list.
+ *
+ * The translation inputs are injected client-side once Google Translate has
+ * populated the cells, so reading the live FormData is what picks them up.
+ *
+ * @param data Submitted form data
+ *
+ * @returns Terms with a text and a language, in row order
+ */
+function collectTerms(data: FormData): BulkTerm[] {
+ const rows = new Map>();
+
+ for (const [key, value] of data.entries()) {
+ const match = /^term\[(\d+)\]\[(\w+)\]$/.exec(key);
+ if (!match) continue;
+ const [, index, field] = match;
+ const row = rows.get(index) ?? {};
+ row[field] = String(value);
+ rows.set(index, row);
+ }
+
+ const terms: BulkTerm[] = [];
+ for (const row of rows.values()) {
+ const text = (row.text ?? '').trim();
+ const lg = parseInt(row.lg ?? '0', 10);
+ if (text === '' || !Number.isFinite(lg) || lg <= 0) continue;
+ terms.push({
+ lg,
+ text,
+ status: parseInt(row.status ?? '1', 10),
+ trans: (row.trans ?? '').trim()
+ });
+ }
+ return terms;
+}
+
+/**
+ * URL of the next batch, or null when this was the last one.
+ *
+ * Saved terms leave the unknown-word set, so the next offset moves back by
+ * however many were just saved — the same arithmetic the controller did.
+ *
+ * @param form The submitted form
+ * @param saved Number of terms saved
+ *
+ * @returns Next batch URL, or null when the form carried no offset
+ */
+function nextBatchUrl(form: HTMLFormElement, saved: number): string | null {
+ const data = new FormData(form);
+ const rawOffset = data.get('offset');
+ if (rawOffset === null) {
+ return null;
+ }
+
+ const offset = parseInt(String(rawOffset), 10);
+ if (!Number.isFinite(offset)) {
+ return null;
+ }
+
+ const params = new URLSearchParams({
+ tid: String(data.get('tid') ?? ''),
+ offset: String(Math.max(0, offset - saved))
+ });
+ const sl = data.get('sl');
+ const tl = data.get('tl');
+ if (sl !== null) params.set('sl', String(sl));
+ if (tl !== null) params.set('tl', String(tl));
+
+ return `/word/bulk-translate?${params.toString()}`;
+}
+
/**
* Configuration for bulk translate component.
*/
@@ -67,6 +156,14 @@ export interface BulkTranslateData {
// State
isGoogleTranslateReady: boolean;
submitButtonText: string;
+ isSaving: boolean;
+ savedCount: number;
+ saveError: string;
+ isDone(): boolean;
+ hasSaveError(): boolean;
+ savedMessage(): string;
+ saveButtonClass(): string;
+ submitTerms(event: Event): Promise;
hasOffset: boolean;
// Methods
@@ -105,6 +202,9 @@ export function bulkTranslateApp(config: BulkTranslateConfig = {
// State
isGoogleTranslateReady: false,
submitButtonText: 'Save',
+ isSaving: false,
+ savedCount: -1,
+ saveError: '',
hasOffset: false,
/**
@@ -150,20 +250,99 @@ export function bulkTranslateApp(config: BulkTranslateConfig = {
},
+ /**
+ * Whether a save has completed and there is no further page of terms.
+ *
+ * @returns True once the final batch has been saved
+ */
+ isDone(): boolean {
+ return this.savedCount >= 0;
+ },
+
+ /**
+ * Whether the last save failed.
+ *
+ * @returns True when an error should be shown
+ */
+ hasSaveError(): boolean {
+ return this.saveError !== '';
+ },
+
+ /**
+ * Confirmation text for the final batch.
+ *
+ * @returns Localised "saved N terms" message
+ */
+ savedMessage(): string {
+ return t('vocabulary.result.bulk_saved', { count: this.savedCount });
+ },
+
+ /**
+ * Loading modifier for the save button.
+ *
+ * @returns Bulma class list
+ */
+ saveButtonClass(): string {
+ return this.isSaving ? 'is-loading' : '';
+ },
+
+ /**
+ * Save the batch through the API instead of posting the form.
+ *
+ * The server used to save, echo a confirmation, and render the next batch
+ * in the same response. Now the save is an API call and the next batch is
+ * a plain GET, so no HTML comes back from the write.
+ *
+ * @param event Submit event
+ */
+ async submitTerms(event: Event): Promise {
+ event.preventDefault();
+
+ const form = event.target as HTMLFormElement | null;
+ if (!form || this.isSaving) return;
+
+ // A term row being edited holds its real name in data_name.
+ const currentTranslation = document.querySelector('[name="WoTranslation"]');
+ if (currentTranslation) {
+ currentTranslation.setAttribute('name', currentTranslation.getAttribute('data_name') ?? '');
+ }
+
+ const terms = collectTerms(new FormData(form));
+ if (terms.length === 0) {
+ this.saveError = 'No terms to save';
+ return;
+ }
+
+ this.isSaving = true;
+ this.saveError = '';
+
+ const response = await apiPost('/terms/bulk', { terms });
+ const payload = response.data;
+
+ if (response.error || !payload || payload.success !== true) {
+ this.saveError = response.error || payload?.error || 'Failed to save terms';
+ this.isSaving = false;
+ return;
+ }
+
+ const saved = payload.saved ?? terms.length;
+ const nextUrl = nextBatchUrl(form, saved);
+
+ if (nextUrl !== null) {
+ window.location.href = nextUrl;
+ return;
+ }
+
+ // Last batch: report it here rather than on a server-rendered page.
+ this.savedCount = saved;
+ this.isSaving = false;
+ },
+
/**
* Setup form submission handler.
*/
setupFormSubmission(): void {
- const form1 = document.querySelector('[name="form1"]');
- if (form1) {
- form1.addEventListener('submit', () => {
- const currentTranslation = document.querySelector('[name="WoTranslation"]');
- if (currentTranslation) {
- currentTranslation.setAttribute('name', currentTranslation.getAttribute('data_name') ?? '');
- }
- return true;
- });
- }
+ // Submission is bound in the template via @submit; nothing to do here.
},
/**
@@ -182,9 +361,21 @@ export function bulkTranslateApp(config: BulkTranslateConfig = {
const cnt = (trans.id || '').replace('Trans_', '');
trans.classList.add('notranslate');
- trans.innerHTML =
- ` ` +
- '
';
+
+ // Built through the DOM, not a markup string: the translation is
+ // third-party text, and a quote in it would otherwise close the
+ // value attribute and let the rest inject attributes.
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.name = `term[${cnt}][trans]`;
+ input.value = txt;
+ input.maxLength = 100;
+ input.className = 'respinput';
+
+ const delTrans = document.createElement('div');
+ delTrans.className = 'del_trans';
+
+ trans.replaceChildren(input, delTrans);
});
// Add dictionary links after each term
diff --git a/src/frontend/js/modules/vocabulary/pages/expression_interactable.ts b/src/frontend/js/modules/vocabulary/pages/expression_interactable.ts
index a95f723b3..6891c1efe 100644
--- a/src/frontend/js/modules/vocabulary/pages/expression_interactable.ts
+++ b/src/frontend/js/modules/vocabulary/pages/expression_interactable.ts
@@ -71,14 +71,9 @@ function initMultiWordInteractable(config: MultiWordConfig): void {
parseInt(term.data_status, 10)
);
- let attrs = '';
- Object.entries(term).forEach(([k, v]) => {
- attrs += ' ' + k + '="' + v + '"';
- });
-
newExpressionInteractable(
config.multiWords[textId],
- attrs,
+ { ...term },
term.data_code,
config.hex,
config.showAll
@@ -100,14 +95,9 @@ function initExpressionInteractable2(config: ExpressionConfig): void {
parseInt(term.data_status, 10)
);
- let attrs = '';
- Object.entries(term).forEach(([k, v]) => {
- attrs += ' ' + k + '="' + v + '"';
- });
-
newExpressionInteractable(
config.appendText,
- attrs,
+ { ...term },
config.len,
config.hex,
config.showAll
diff --git a/src/frontend/js/modules/vocabulary/pages/term_edit_page.ts b/src/frontend/js/modules/vocabulary/pages/term_edit_page.ts
new file mode 100644
index 000000000..30538c8ff
--- /dev/null
+++ b/src/frontend/js/modules/vocabulary/pages/term_edit_page.ts
@@ -0,0 +1,140 @@
+/**
+ * Standalone term editor page.
+ *
+ * /word/edit, /word/edit-term and /words/{id}/edit used to render a PHP form
+ * that posted back and returned a confirmation page. They now mount the same
+ * editor the reading view opens in a modal, so there is one implementation and
+ * the outcome is rendered from the API response.
+ *
+ * @license Unlicense
+ * @since 3.3.0
+ */
+
+import Alpine from 'alpinejs';
+import { loadTermEditor, wireTermEditor } from '@modules/vocabulary/components/term_edit_modal';
+import { t } from '@shared/i18n/translator';
+
+interface TermEditPageConfig {
+ textId: number;
+ position: number;
+ wordId: number | null;
+ returnUrl: string;
+}
+
+interface TermEditPageState {
+ title: string;
+ errorMessage: string;
+ isLoading: boolean;
+ returnUrl: string;
+ hasError(): boolean;
+ init(): void;
+ leave(): void;
+}
+
+/**
+ * Read the server-emitted config blob.
+ *
+ * @returns Parsed config, or null when the blob is missing or malformed
+ */
+function readConfig(): TermEditPageConfig | null {
+ const el = document.getElementById('term-edit-page-config');
+ if (!el?.textContent) {
+ return null;
+ }
+ try {
+ return JSON.parse(el.textContent) as TermEditPageConfig;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Where to send the user once they are done editing.
+ *
+ * Prefers the page they arrived from so the reading view and the term list
+ * both feel like they were never left, and falls back to the server's default.
+ *
+ * @param fallback URL to use when there is no usable referrer
+ *
+ * @returns Destination URL
+ */
+function resolveReturnUrl(fallback: string): string {
+ const referrer = document.referrer;
+ if (referrer !== '') {
+ try {
+ const url = new URL(referrer, window.location.href);
+ // Same-origin only, and never bounce back to this editor.
+ if (url.origin === window.location.origin && !url.pathname.includes('/edit')) {
+ return url.href;
+ }
+ } catch {
+ // Malformed referrer — fall through to the default.
+ }
+ }
+ return fallback;
+}
+
+/**
+ * Alpine component backing the standalone editor page.
+ *
+ * @returns Component state and methods
+ */
+export function termEditPageData(): TermEditPageState {
+ return {
+ title: '',
+ errorMessage: '',
+ isLoading: true,
+ returnUrl: '/words',
+
+ /**
+ * Whether loading the term failed.
+ *
+ * @returns True when an error should be shown instead of the form
+ */
+ hasError(): boolean {
+ return this.errorMessage !== '';
+ },
+
+ /** Leave the editor. */
+ leave(): void {
+ window.location.href = this.returnUrl;
+ },
+
+ init(): void {
+ const config = readConfig();
+ if (!config) {
+ this.isLoading = false;
+ this.errorMessage = t('vocabulary.form.edit_term');
+ return;
+ }
+
+ this.returnUrl = resolveReturnUrl(config.returnUrl);
+
+ void loadTermEditor(
+ config.textId,
+ config.position,
+ config.wordId ?? undefined
+ ).then((result) => {
+ this.isLoading = false;
+
+ const container = document.getElementById('term-edit-page-form');
+ if (!container) return;
+
+ if (!result.ok) {
+ this.errorMessage = result.error;
+ return;
+ }
+
+ this.title = result.editor.title;
+ container.innerHTML = result.editor.html;
+
+ wireTermEditor({
+ onSaved: () => this.leave(),
+ onCancel: () => this.leave()
+ });
+ });
+ }
+ };
+}
+
+Alpine.data('termEditPage', termEditPageData);
diff --git a/src/frontend/js/modules/vocabulary/pages/word_result_init.ts b/src/frontend/js/modules/vocabulary/pages/word_result_init.ts
deleted file mode 100644
index 1ab053932..000000000
--- a/src/frontend/js/modules/vocabulary/pages/word_result_init.ts
+++ /dev/null
@@ -1,416 +0,0 @@
-/**
- * Word Result Initialization - Auto-initializes word result views.
- *
- * Handles initialization of result views after word operations. Each view emits a
- * `` inside any
+ * user-controlled field closes a `');
+ if ($scriptClose !== false && $scriptClose > $scriptOpen) {
+ continue;
+ }
+
+ $line = substr_count($preceding, "\n") + 1;
+ $offenders[] = str_replace(dirname(__DIR__, 3) . '/', '', $path) . ':' . $line;
+ }
+ }
+
+ $this->assertSame(
+ [],
+ $offenders,
+ "json_encode into a
-
-
-
-
-
- `;
-
- // Since langShort is provided, getLangFromDict won't be called
- // The function should complete without errors
- expect(() => initWordFormAuto()).not.toThrow();
- });
-
- it('uses getLangFromDict when langShort not in config', async () => {
- document.body.innerHTML = `
-
-
-
-
-
-
- `;
-
- (getLangFromDict as any).mockReturnValue('fr');
-
- initWordFormAuto();
-
- expect(getLangFromDict).toHaveBeenCalledWith('http://example.com');
- });
-
- it('handles invalid JSON config gracefully', () => {
- document.body.innerHTML = `
-
- `;
-
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- expect(() => initWordFormAuto()).not.toThrow();
- expect(consoleSpy).toHaveBeenCalled();
- });
-
- it('handles empty config element', () => {
- document.body.innerHTML = `
-
- `;
-
- expect(() => initWordFormAuto()).not.toThrow();
- });
- });
-});
diff --git a/tests/frontend/languages/language_list.test.ts b/tests/frontend/languages/language_list.test.ts
index d58f22f80..6a94547a7 100644
--- a/tests/frontend/languages/language_list.test.ts
+++ b/tests/frontend/languages/language_list.test.ts
@@ -303,6 +303,59 @@ describe('languages/language_list.ts', () => {
});
});
+ describe('hostile language names', () => {
+ beforeEach(async () => {
+ // A language name is user-supplied and used to be interpolated straight
+ // into both a text node and a data- attribute.
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ vi.resetModules();
+ await import('../../../src/frontend/js/modules/language/pages/language_list');
+ document.dispatchEvent(new Event('DOMContentLoaded'));
+ });
+
+ it('does not execute markup from a language name', async () => {
+ mockSave.mockResolvedValueOnce({ error: null });
+
+ const card2 = document.querySelector('[data-lang-id="2"]')!;
+ const button = card2.querySelector('.set-current-language-btn') as HTMLElement;
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+
+ await vi.waitFor(() => {
+ expect(mockSave).toHaveBeenCalled();
+ });
+
+ // Card 1 lost "current" and gets its button rebuilt from its own name.
+ const card1 = document.querySelector('[data-lang-id="1"]')!;
+ const rebuilt = card1.querySelector('.set-current-language-btn') as HTMLElement;
+ expect(rebuilt).not.toBeNull();
+ expect(rebuilt.dataset.langName).toBe(' ');
+ expect(rebuilt.getAttribute('onerror')).toBeNull();
+ expect(document.querySelector('img')).toBeNull();
+
+ // Card 2 became current: its title now carries the indicator icon plus
+ // the name, which must stay text.
+ const title2 = card2.querySelector('.card-header-title')!;
+ expect(title2.textContent).toContain('" onmouseover="alert(2)');
+ expect(title2.querySelector('[onmouseover]')).toBeNull();
+ });
+ });
+
describe('handleSetCurrentLanguage', () => {
beforeEach(async () => {
document.body.innerHTML = `
diff --git a/tests/frontend/reading/frame_management.test.ts b/tests/frontend/reading/frame_management.test.ts
deleted file mode 100644
index db3c52884..000000000
--- a/tests/frontend/reading/frame_management.test.ts
+++ /dev/null
@@ -1,411 +0,0 @@
-/**
- * Tests for frame_management.ts - Right frames show/hide/cleanup operations
- */
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import {
- showRightFramesPanel,
- hideRightFrames,
- cleanupRightFrames,
- successSound,
- failureSound
-} from '../../../src/frontend/js/modules/text/pages/reading/frame_management';
-
-// Mock word_popup module
-vi.mock('../../../src/frontend/js/modules/vocabulary/components/word_popup', () => ({
- closePopup: vi.fn(),
- closeParentPopup: vi.fn()
-}));
-
-import { closeParentPopup } from '../../../src/frontend/js/modules/vocabulary/components/word_popup';
-
-describe('frame_management.ts', () => {
- // Store originals
- const originalTop = global.top;
- const originalParent = global.parent;
-
- beforeEach(() => {
- document.body.innerHTML = '';
- vi.clearAllMocks();
- vi.useFakeTimers();
-
- // Setup mock frames for top.frames access
- const mockFrames: any = {};
- (global as any).top = {
- frames: mockFrames
- };
- (global as any).parent = {
- document: document,
- setTimeout: vi.fn((fn: () => void, delay: number) => setTimeout(fn, delay))
- };
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- vi.useRealTimers();
- document.body.innerHTML = '';
- (global as any).top = originalTop;
- (global as any).parent = originalParent;
- });
-
- // ===========================================================================
- // showRightFramesPanel Tests
- // ===========================================================================
-
- describe('showRightFramesPanel', () => {
- it('returns true when #frames-r exists', () => {
- document.body.innerHTML = '
';
-
- const result = showRightFramesPanel();
-
- expect(result).toBe(true);
- });
-
- it('returns false when #frames-r does not exist', () => {
- document.body.innerHTML = 'No frames
';
-
- const result = showRightFramesPanel();
-
- expect(result).toBe(false);
- });
-
- it('animates #frames-r to visible position', () => {
- document.body.innerHTML = '
';
- const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
- // Simulate animation completing immediately
- cb(performance.now() + 1000);
- return 1;
- });
-
- showRightFramesPanel();
-
- // Verify that animation was initiated via requestAnimationFrame
- expect(rafSpy).toHaveBeenCalled();
- rafSpy.mockRestore();
- });
-
- it('does not modify frame contents', () => {
- const mockRoFrame = { location: { href: 'original.php' } };
- (global as any).top = {
- frames: {
- ro: mockRoFrame
- }
- };
- document.body.innerHTML = '
';
-
- showRightFramesPanel();
-
- expect(mockRoFrame.location.href).toBe('original.php');
- });
- });
-
- // ===========================================================================
- // hideRightFrames Tests
- // ===========================================================================
-
- describe('hideRightFrames', () => {
- it('returns true when #frames-r exists', () => {
- document.body.innerHTML = '
';
-
- const result = hideRightFrames();
-
- expect(result).toBe(true);
- });
-
- it('returns false when #frames-r does not exist', () => {
- document.body.innerHTML = 'No frames
';
-
- const result = hideRightFrames();
-
- expect(result).toBe(false);
- });
-
- it('animates #frames-r to hidden position', () => {
- document.body.innerHTML = '
';
- const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
- // Simulate animation completing immediately
- cb(performance.now() + 1000);
- return 1;
- });
-
- hideRightFrames();
-
- // Verify that animation was initiated via requestAnimationFrame
- expect(rafSpy).toHaveBeenCalled();
- rafSpy.mockRestore();
- });
- });
-
- // ===========================================================================
- // cleanupRightFrames Tests
- // ===========================================================================
-
- describe('cleanupRightFrames', () => {
- it('sets timeout to click on frames-r after 800ms', () => {
- const mockFramesR = {
- click: vi.fn()
- };
- (global as any).parent = {
- document: {
- getElementById: vi.fn((id: string) => {
- if (id === 'frames-r') return mockFramesR;
- if (id === 'frame-l') return { focus: vi.fn() };
- return null;
- })
- },
- setTimeout: vi.fn((fn: () => void, delay: number) => setTimeout(fn, delay))
- };
-
- cleanupRightFrames();
-
- expect((global as any).parent.setTimeout).toHaveBeenCalledWith(
- expect.any(Function),
- 800
- );
-
- vi.advanceTimersByTime(800);
- expect(mockFramesR.click).toHaveBeenCalled();
- });
-
- it('focuses frame-l element', () => {
- const mockFrameL = { focus: vi.fn() };
- (global as any).parent = {
- document: {
- getElementById: vi.fn((id: string) => {
- if (id === 'frame-l') return mockFrameL;
- return null;
- })
- },
- setTimeout: vi.fn()
- };
-
- cleanupRightFrames();
-
- expect(mockFrameL.focus).toHaveBeenCalled();
- });
-
- it('calls closeParentPopup after 100ms', () => {
- (global as any).parent = {
- document: {
- getElementById: vi.fn(() => null)
- },
- setTimeout: vi.fn((fn: () => void, delay: number) => setTimeout(fn, delay))
- };
-
- cleanupRightFrames();
-
- // closeParentPopup is called via window.setTimeout (not parent.setTimeout)
- vi.advanceTimersByTime(100);
- expect(closeParentPopup).toHaveBeenCalled();
- });
-
- it('handles missing frames-r gracefully', () => {
- (global as any).parent = {
- document: {
- getElementById: vi.fn(() => null)
- },
- setTimeout: vi.fn((fn: () => void, delay: number) => setTimeout(fn, delay))
- };
-
- expect(() => cleanupRightFrames()).not.toThrow();
-
- vi.advanceTimersByTime(800);
- // Should not throw even when frames-r is null
- });
-
- it('handles missing frame-l gracefully', () => {
- (global as any).parent = {
- document: {
- getElementById: vi.fn(() => null)
- },
- setTimeout: vi.fn()
- };
-
- expect(() => cleanupRightFrames()).not.toThrow();
- });
- });
-
- // ===========================================================================
- // successSound Tests
- // ===========================================================================
-
- describe('successSound', () => {
- it('pauses both sounds before playing success', () => {
- const mockSuccessSound = {
- pause: vi.fn(),
- play: vi.fn().mockResolvedValue(undefined)
- };
- const mockFailureSound = {
- pause: vi.fn()
- };
-
- document.body.innerHTML = `
-
-
- `;
-
- // Replace elements with mocks
- vi.spyOn(document, 'getElementById').mockImplementation((id: string) => {
- if (id === 'success_sound') return mockSuccessSound as unknown as HTMLElement;
- if (id === 'failure_sound') return mockFailureSound as unknown as HTMLElement;
- return null;
- });
-
- successSound();
-
- expect(mockSuccessSound.pause).toHaveBeenCalled();
- expect(mockFailureSound.pause).toHaveBeenCalled();
- });
-
- it('plays success sound and returns promise', () => {
- const mockSuccessSound = {
- pause: vi.fn(),
- play: vi.fn().mockResolvedValue(undefined)
- };
- const mockFailureSound = {
- pause: vi.fn()
- };
-
- vi.spyOn(document, 'getElementById').mockImplementation((id: string) => {
- if (id === 'success_sound') return mockSuccessSound as unknown as HTMLElement;
- if (id === 'failure_sound') return mockFailureSound as unknown as HTMLElement;
- return null;
- });
-
- const result = successSound();
-
- expect(mockSuccessSound.play).toHaveBeenCalled();
- expect(result).toBeInstanceOf(Promise);
- });
-
- it('handles missing audio elements gracefully', () => {
- vi.spyOn(document, 'getElementById').mockReturnValue(null);
-
- expect(() => successSound()).not.toThrow();
- });
- });
-
- // ===========================================================================
- // failureSound Tests
- // ===========================================================================
-
- describe('failureSound', () => {
- it('pauses both sounds before playing failure', () => {
- const mockSuccessSound = {
- pause: vi.fn()
- };
- const mockFailureSound = {
- pause: vi.fn(),
- play: vi.fn().mockResolvedValue(undefined)
- };
-
- vi.spyOn(document, 'getElementById').mockImplementation((id: string) => {
- if (id === 'success_sound') return mockSuccessSound as unknown as HTMLElement;
- if (id === 'failure_sound') return mockFailureSound as unknown as HTMLElement;
- return null;
- });
-
- failureSound();
-
- expect(mockSuccessSound.pause).toHaveBeenCalled();
- expect(mockFailureSound.pause).toHaveBeenCalled();
- });
-
- it('plays failure sound and returns promise', () => {
- const mockSuccessSound = {
- pause: vi.fn()
- };
- const mockFailureSound = {
- pause: vi.fn(),
- play: vi.fn().mockResolvedValue(undefined)
- };
-
- vi.spyOn(document, 'getElementById').mockImplementation((id: string) => {
- if (id === 'success_sound') return mockSuccessSound as unknown as HTMLElement;
- if (id === 'failure_sound') return mockFailureSound as unknown as HTMLElement;
- return null;
- });
-
- const result = failureSound();
-
- expect(mockFailureSound.play).toHaveBeenCalled();
- expect(result).toBeInstanceOf(Promise);
- });
-
- it('handles missing audio elements gracefully', () => {
- vi.spyOn(document, 'getElementById').mockReturnValue(null);
-
- expect(() => failureSound()).not.toThrow();
- });
- });
-
- // ===========================================================================
- // Integration Tests
- // ===========================================================================
-
- describe('Integration', () => {
- it('show and hide frames work together', () => {
- document.body.innerHTML = '
';
-
- const showResult = showRightFramesPanel();
- expect(showResult).toBe(true);
-
- const hideResult = hideRightFrames();
- expect(hideResult).toBe(true);
- });
-
- it('frame operations work without errors when frames container missing', () => {
- document.body.innerHTML = 'Empty page
';
-
- expect(showRightFramesPanel()).toBe(false);
- expect(hideRightFrames()).toBe(false);
- });
- });
-
- // ===========================================================================
- // Edge Cases
- // ===========================================================================
-
- describe('Edge Cases', () => {
- it('sound functions handle play() rejection', async () => {
- const mockSuccessSound = {
- pause: vi.fn(),
- play: vi.fn().mockRejectedValue(new Error('Autoplay blocked'))
- };
- const mockFailureSound = {
- pause: vi.fn()
- };
-
- vi.spyOn(document, 'getElementById').mockImplementation((id: string) => {
- if (id === 'success_sound') return mockSuccessSound as unknown as HTMLElement;
- if (id === 'failure_sound') return mockFailureSound as unknown as HTMLElement;
- return null;
- });
-
- // Should not throw, just reject the promise
- const result = successSound();
-
- await expect(result).rejects.toThrow('Autoplay blocked');
- });
-
- it('cleanupRightFrames uses correct timing sequence', () => {
- const calls: { fn: string; delay: number }[] = [];
- (global as any).parent = {
- document: {
- getElementById: vi.fn(() => ({ focus: vi.fn(), click: vi.fn() }))
- },
- setTimeout: vi.fn((fn: () => void, delay: number) => {
- if (delay === 800) calls.push({ fn: 'frames-r click', delay });
- return setTimeout(fn, delay);
- })
- };
-
- cleanupRightFrames();
-
- // frames-r click is scheduled via parent.setTimeout at 800ms
- expect(calls).toContainEqual({ fn: 'frames-r click', delay: 800 });
- // closeParentPopup is called via window.setTimeout at 100ms, which we verify separately
- vi.advanceTimersByTime(100);
- expect(closeParentPopup).toHaveBeenCalled();
- });
- });
-});
diff --git a/tests/frontend/reading/text_multiword_selection.test.ts b/tests/frontend/reading/text_multiword_selection.test.ts
index 6c37e1ab4..aaf7dc3cc 100644
--- a/tests/frontend/reading/text_multiword_selection.test.ts
+++ b/tests/frontend/reading/text_multiword_selection.test.ts
@@ -18,10 +18,6 @@ vi.mock('alpinejs', () => ({
}));
// Mock frame management
-vi.mock('../../../src/frontend/js/modules/text/pages/reading/frame_management', () => ({
- loadModalFrame: vi.fn()
-}));
-
import {
handleTextSelection,
setupMultiWordSelection,
diff --git a/tests/frontend/terms/dictionary.test.ts b/tests/frontend/terms/dictionary.test.ts
index 94f952799..f174b0506 100644
--- a/tests/frontend/terms/dictionary.test.ts
+++ b/tests/frontend/terms/dictionary.test.ts
@@ -16,10 +16,6 @@ import {
} from '../../../src/frontend/js/modules/vocabulary/services/dictionary';
// Mock dependencies
-vi.mock('../../../src/frontend/js/modules/text/pages/reading/frame_management', () => ({
- showRightFramesPanel: vi.fn(),
-}));
-
describe('dictionary.ts', () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/tests/frontend/testing/review_view.test.ts b/tests/frontend/testing/review_view.test.ts
index 6753216d3..1e63cc7ae 100644
--- a/tests/frontend/testing/review_view.test.ts
+++ b/tests/frontend/testing/review_view.test.ts
@@ -42,7 +42,8 @@ const mockReviewStore = {
openModal: vi.fn(),
closeModal: vi.fn(),
getDictUrl: vi.fn(() => 'http://dict.test'),
- getEditUrl: vi.fn(() => '/word/edit')
+ editWord: vi.fn(),
+ editCurrentWord: vi.fn()
};
// Mock review_store
diff --git a/tests/frontend/testing/stores/review_store.test.ts b/tests/frontend/testing/stores/review_store.test.ts
index c429f92b8..ccdb5dec2 100644
--- a/tests/frontend/testing/stores/review_store.test.ts
+++ b/tests/frontend/testing/stores/review_store.test.ts
@@ -787,26 +787,6 @@ describe('review/stores/review_store.ts', () => {
});
});
- // ===========================================================================
- // getEditUrl Tests
- // ===========================================================================
-
- describe('getEditUrl', () => {
- it('returns # without current word', () => {
- const store = getReviewStore();
- store.currentWord = null;
-
- expect(store.getEditUrl()).toBe('#');
- });
-
- it('returns correct edit URL', () => {
- const store = getReviewStore();
- store.currentWord = { wordId: 123 } as never;
-
- expect(store.getEditUrl()).toBe('/word/edit-term?wid=123');
- });
- });
-
// ===========================================================================
// Modal Tests
// ===========================================================================
@@ -841,6 +821,7 @@ describe('review/stores/review_store.ts', () => {
const store = getReviewStore();
const mockAudio = {
currentTime: 10,
+ pause: vi.fn(),
play: vi.fn().mockResolvedValue(undefined)
};
vi.spyOn(document, 'getElementById').mockImplementation((id) => {
@@ -858,6 +839,7 @@ describe('review/stores/review_store.ts', () => {
const store = getReviewStore();
const mockAudio = {
currentTime: 10,
+ pause: vi.fn(),
play: vi.fn().mockResolvedValue(undefined)
};
vi.spyOn(document, 'getElementById').mockImplementation((id) => {
@@ -883,6 +865,7 @@ describe('review/stores/review_store.ts', () => {
const store = getReviewStore();
const mockAudio = {
currentTime: 0,
+ pause: vi.fn(),
play: vi.fn().mockRejectedValue(new Error('Autoplay blocked'))
};
vi.spyOn(document, 'getElementById').mockReturnValue(
diff --git a/tests/frontend/vocabulary/term_edit_modal.test.ts b/tests/frontend/vocabulary/term_edit_modal.test.ts
index 585495db7..eba1f5555 100644
--- a/tests/frontend/vocabulary/term_edit_modal.test.ts
+++ b/tests/frontend/vocabulary/term_edit_modal.test.ts
@@ -158,7 +158,7 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
expect(lastCall[0]).toContain('term-edit-sentence');
});
- it('uses "Add Term" title for new terms', async () => {
+ it('uses the new-term title for new terms', async () => {
vi.mocked(TermsApi.getForEdit).mockResolvedValue({
data: {
...mockTermResponse.data!,
@@ -170,7 +170,7 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
expect(openModal).toHaveBeenLastCalledWith(
expect.any(String),
- expect.objectContaining({ title: 'Add Term' })
+ expect.objectContaining({ title: 'New Term' })
);
});
@@ -296,7 +296,11 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
// Set up DOM with form
document.body.innerHTML = `
+
test translation
+
+
+
test sentence
Learning (2)
@@ -317,7 +321,11 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
// Set up DOM
document.body.innerHTML = `
+
updated
+
+
+
test
3
@@ -444,7 +452,7 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
const lastCall = vi.mocked(openModal).mock.calls.slice(-1)[0];
const formHtml = lastCall[0];
- expect(formHtml).toContain('Use {curly braces} around the term');
+ expect(formHtml).toContain('Wrap the term in {curly braces}');
});
});
@@ -493,17 +501,179 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
// ===========================================================================
describe('Term Display', () => {
- it('displays term text as readonly', async () => {
+ it('leaves an existing term text editable for recasing', async () => {
await openTermEditModal(1, 5, 123);
const lastCall = vi.mocked(openModal).mock.calls.slice(-1)[0];
const formHtml = lastCall[0];
+ expect(formHtml).toContain('id="term-edit-text"');
+ expect(formHtml).not.toContain('readonly');
+ expect(formHtml).toContain('Only change uppercase/lowercase!');
+ });
+
+ it('locks the term text for a new term', async () => {
+ vi.mocked(TermsApi.getForEdit).mockResolvedValue({
+ data: {
+ ...mockTermResponse.data!,
+ isNew: true,
+ term: { ...mockTermResponse.data!.term, id: null },
+ },
+ });
+
+ await openTermEditModal(1, 5);
+
+ const lastCall = vi.mocked(openModal).mock.calls.slice(-1)[0];
+ const formHtml = lastCall[0];
+
expect(formHtml).toContain('readonly');
expect(formHtml).toContain('disabled');
});
});
+ // ===========================================================================
+ // Data-driven Field Tests
+ // ===========================================================================
+
+ describe('fields rendered from API data', () => {
+ it('renders lemma, notes and tags from the payload', async () => {
+ vi.mocked(TermsApi.getForEdit).mockResolvedValue({
+ data: {
+ ...mockTermResponse.data!,
+ allTags: ['noun', 'verb'],
+ term: {
+ ...mockTermResponse.data!.term,
+ lemma: 'hallo',
+ notes: 'a greeting',
+ tags: ['noun', 'common'],
+ },
+ },
+ });
+
+ await openTermEditModal(1, 5, 123);
+
+ const formHtml = vi.mocked(openModal).mock.calls.slice(-1)[0][0];
+
+ expect(formHtml).toContain('id="term-edit-lemma"');
+ expect(formHtml).toContain('value="hallo"');
+ expect(formHtml).toContain('a greeting');
+ expect(formHtml).toContain('value="noun, common"');
+ // allTags feeds the datalist, not the value.
+ expect(formHtml).toContain('');
+ });
+
+ it('renders similar terms as links', async () => {
+ vi.mocked(TermsApi.getForEdit).mockResolvedValue({
+ data: {
+ ...mockTermResponse.data!,
+ similarTerms: [
+ { id: 7, text: 'hallo', translation: 'hi', status: 2 },
+ { id: 8, text: 'helo', translation: '*', status: 1 },
+ ],
+ },
+ });
+
+ await openTermEditModal(1, 5, 123);
+
+ const formHtml = vi.mocked(openModal).mock.calls.slice(-1)[0][0];
+
+ expect(formHtml).toContain('/words/7/edit');
+ expect(formHtml).toContain('hi');
+ // A '*' translation is a placeholder and must not be shown.
+ expect(formHtml).toContain('/words/8/edit');
+ expect(formHtml).not.toContain('— *');
+ });
+
+ it('omits the similar terms block when there are none', async () => {
+ await openTermEditModal(1, 5, 123);
+
+ const formHtml = vi.mocked(openModal).mock.calls.slice(-1)[0][0];
+
+ expect(formHtml).not.toContain('Similar Terms');
+ });
+
+ it('renders a dictionary link built from the language URI', async () => {
+ vi.mocked(TermsApi.getForEdit).mockResolvedValue({
+ data: {
+ ...mockTermResponse.data!,
+ language: {
+ ...mockTermResponse.data!.language,
+ translateUri: 'https://dict.test/?q=lwt_term',
+ },
+ },
+ });
+
+ await openTermEditModal(1, 5, 123);
+
+ const formHtml = vi.mocked(openModal).mock.calls.slice(-1)[0][0];
+
+ expect(formHtml).toContain('https://dict.test/?q=hello');
+ });
+
+ it('omits the dictionary link when the language has no URI', async () => {
+ await openTermEditModal(1, 5, 123);
+
+ const formHtml = vi.mocked(openModal).mock.calls.slice(-1)[0][0];
+
+ expect(formHtml).not.toContain('Dictionary Lookup');
+ });
+ });
+
+ // ===========================================================================
+ // Term Recasing Guard Tests
+ // ===========================================================================
+
+ describe('term recasing guard', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+ t
+
+
+
+
+ 1
+ Save
+
+
+ `;
+ });
+
+ it('accepts a pure change of capitalization', async () => {
+ await openTermEditModal(1, 5, 123);
+ (document.getElementById('term-edit-text') as HTMLInputElement).value = 'Hello';
+
+ document.getElementById('term-edit-form')
+ ?.dispatchEvent(new Event('submit', { cancelable: true }));
+
+ await vi.waitFor(() => {
+ return vi.mocked(TermsApi.updateFull).mock.calls.length > 0;
+ });
+
+ expect(TermsApi.updateFull).toHaveBeenCalledWith(
+ 123,
+ expect.objectContaining({ text: 'Hello' })
+ );
+ });
+
+ it('rejects a different term before calling the API', async () => {
+ await openTermEditModal(1, 5, 123);
+ (document.getElementById('term-edit-text') as HTMLInputElement).value = 'goodbye';
+
+ document.getElementById('term-edit-form')
+ ?.dispatchEvent(new Event('submit', { cancelable: true }));
+
+ await vi.waitFor(() => {
+ return document.getElementById('term-edit-error')!.style.display === 'block';
+ });
+
+ expect(TermsApi.updateFull).not.toHaveBeenCalled();
+ const saveBtn = document.getElementById('term-edit-save') as HTMLButtonElement;
+ expect(saveBtn.disabled).toBe(false);
+ });
+ });
+
// ===========================================================================
// Form Submission Handler Tests
// ===========================================================================
@@ -513,7 +683,11 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
// Set up DOM with form before each test
document.body.innerHTML = `
+
test translation
+
+
+
test {sentence}
@@ -584,9 +758,12 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
});
expect(TermsApi.updateFull).toHaveBeenCalledWith(123, {
+ text: 'hello',
translation: 'test translation',
romanization: 'romaji',
sentence: 'test {sentence}',
+ notes: '',
+ lemma: '',
status: 2,
tags: []
});
@@ -616,6 +793,8 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
translation: 'test translation',
romanization: 'romaji',
sentence: 'test {sentence}',
+ notes: '',
+ lemma: '',
status: 2,
tags: []
});
@@ -779,9 +958,12 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
});
expect(TermsApi.updateFull).toHaveBeenCalledWith(123, {
+ text: 'hello',
translation: '',
romanization: '',
sentence: '',
+ notes: '',
+ lemma: '',
status: 2,
tags: []
});
@@ -796,7 +978,11 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
beforeEach(async () => {
document.body.innerHTML = `
+
test
+
+
+
test
1
@@ -850,7 +1036,11 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
document.body.innerHTML = `
+
test
+
+
+
test
1
@@ -893,11 +1083,15 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
return vi.mocked(TermsApi.updateFull).mock.calls.length > 0;
});
- // Should use empty/default values for missing fields
+ // Should use empty/default values for missing fields. An absent term
+ // input sends text: '', which the server reads as "leave WoText alone".
expect(TermsApi.updateFull).toHaveBeenCalledWith(123, {
+ text: '',
translation: '',
romanization: '',
sentence: '',
+ notes: '',
+ lemma: '',
status: 1, // Default when parsing empty string
tags: []
});
@@ -906,7 +1100,11 @@ describe('modules/vocabulary/components/term_edit_modal.ts', () => {
it('handles status selection without romanization field', async () => {
document.body.innerHTML = `
+
translation
+
+
+
sentence
Well Known
diff --git a/tests/frontend/vocabulary/word_modal.test.ts b/tests/frontend/vocabulary/word_modal.test.ts
index bdde93b1f..3ac329231 100644
--- a/tests/frontend/vocabulary/word_modal.test.ts
+++ b/tests/frontend/vocabulary/word_modal.test.ts
@@ -437,38 +437,6 @@ describe('word_modal.ts', () => {
});
});
- describe('getEditUrl', () => {
- it('returns correct edit URL with word parameters', () => {
- const component = wordModalData();
-
- const url = component.getEditUrl();
-
- expect(url).toBe('/word/edit?tid=1&ord=10&wid=100');
- });
-
- it('returns URL without wid when wordId is not set', () => {
- mockWordStore.getSelectedWord = vi.fn().mockReturnValue({
- hex: 'test',
- text: 'test',
- position: 5,
- wordId: null
- });
- const component = wordModalData();
-
- const url = component.getEditUrl();
-
- expect(url).toBe('/word/edit?tid=1&ord=5');
- });
-
- it('returns # when word is null', () => {
- mockWordStore.getSelectedWord = vi.fn().mockReturnValue(null);
- const component = wordModalData();
-
- const url = component.getEditUrl();
-
- expect(url).toBe('#');
- });
- });
describe('getDictUrl', () => {
it('delegates to store.getDictUrl', () => {
diff --git a/tests/frontend/words/bulk_translate.test.ts b/tests/frontend/words/bulk_translate.test.ts
index d80fe45fb..af043efa5 100644
--- a/tests/frontend/words/bulk_translate.test.ts
+++ b/tests/frontend/words/bulk_translate.test.ts
@@ -522,6 +522,31 @@ describe('bulk_translate.ts', () => {
expect(typeof window.googleTranslateElementInit).toBe('function');
});
});
+
+ describe('setupInteractions', () => {
+ it('keeps a hostile translation inside the input value', () => {
+ // The translation is third-party text: it used to be interpolated
+ // into value="…", so a quote in it could inject attributes.
+ const hostile = '" onfocus="alert(1)" autofocus x="';
+ document.body.innerHTML = `
+ ${hostile
+ .replace(/&/g, '&')
+ .replace(/
+ `;
+
+ const component = bulkTranslateApp();
+ component.setupInteractions();
+ vi.advanceTimersByTime(500);
+
+ const input = document.querySelector('.trans input') as HTMLInputElement;
+ expect(input).not.toBeNull();
+ expect(input.value).toBe(hostile);
+ expect(input.getAttribute('onfocus')).toBeNull();
+ expect(input.hasAttribute('autofocus')).toBe(false);
+ expect(input.name).toBe('term[0][trans]');
+ });
+ });
});
// ===========================================================================
diff --git a/tests/frontend/words/expression_interactable.test.ts b/tests/frontend/words/expression_interactable.test.ts
index 62d1442d7..33ac9cf88 100644
--- a/tests/frontend/words/expression_interactable.test.ts
+++ b/tests/frontend/words/expression_interactable.test.ts
@@ -183,7 +183,7 @@ describe('expression_interactable.ts', () => {
expect(newExpressionInteractable).toHaveBeenCalledWith(
{ "0": "append text" },
- expect.stringContaining('class="status3"'),
+ expect.objectContaining({ class: 'status3' }),
3,
"def456",
true
@@ -333,7 +333,7 @@ describe('expression_interactable.ts', () => {
expect(newExpressionInteractable).toHaveBeenCalledWith(
expect.anything(),
- expect.stringContaining('data_wid="999"'),
+ expect.objectContaining({ data_wid: 999 }),
expect.anything(),
expect.anything(),
expect.anything()
diff --git a/tests/frontend/words/word_dom_updates.test.ts b/tests/frontend/words/word_dom_updates.test.ts
index affc1fbf1..3f3014d53 100644
--- a/tests/frontend/words/word_dom_updates.test.ts
+++ b/tests/frontend/words/word_dom_updates.test.ts
@@ -5,19 +5,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
getParentContext,
getFrameElement,
- updateLearnStatus,
generateTooltip,
- updateNewWordInDOM,
updateExistingWordInDOM,
updateWordStatusInDOM,
markWordWellKnownInDOM,
markWordIgnoredInDOM,
- updateMultiWordInDOM,
- updateBulkWordInDOM,
- updateTestWordInDOM,
- completeWordOperation,
- type WordUpdateParams,
- type BulkWordUpdateParams
+ type WordUpdateParams
} from '../../../src/frontend/js/modules/vocabulary/services/word_dom_updates';
// Mock dependencies
@@ -25,10 +18,6 @@ vi.mock('../../../src/frontend/js/modules/vocabulary/services/word_status', () =
createWordTooltip: vi.fn((word, trans, rom, status) => `${word}|${trans}|${rom}|${status}`)
}));
-vi.mock('../../../src/frontend/js/modules/text/pages/reading/frame_management', () => ({
- cleanupRightFrames: vi.fn()
-}));
-
import { resetSettingsConfig } from '../../../src/frontend/js/shared/utils/settings_config';
describe('word_dom_updates.ts', () => {
@@ -108,26 +97,6 @@ describe('word_dom_updates.ts', () => {
});
});
- // ===========================================================================
- // updateLearnStatus Tests
- // ===========================================================================
-
- describe('updateLearnStatus', () => {
- it('updates #learnstatus element with content', () => {
- document.body.innerHTML = `Old content
`;
-
- updateLearnStatus('New content ');
-
- expect(document.querySelector('#learnstatus')!.innerHTML).toBe('New content ');
- });
-
- it('does nothing when element does not exist', () => {
- document.body.innerHTML = '';
-
- expect(() => updateLearnStatus('content')).not.toThrow();
- });
- });
-
// ===========================================================================
// generateTooltip Tests
// ===========================================================================
@@ -147,77 +116,6 @@ describe('word_dom_updates.ts', () => {
});
});
- // ===========================================================================
- // updateNewWordInDOM Tests
- // ===========================================================================
-
- describe('updateNewWordInDOM', () => {
- it('updates elements with matching hex class', () => {
- document.body.innerHTML = `
- hello
- hello
- `;
-
- const params: WordUpdateParams = {
- wid: 123,
- status: 2,
- translation: 'bonjour',
- romanization: '',
- text: 'hello',
- hex: '48454c4c4f'
- };
-
- updateNewWordInDOM(params);
-
- const elements = document.querySelectorAll('[data_hex="48454c4c4f"]');
- elements.forEach(el => {
- expect(el.classList.contains('status0')).toBe(false);
- expect(el.classList.contains('status2')).toBe(true);
- expect(el.classList.contains('word123')).toBe(true);
- expect(el.getAttribute('data_trans')).toBe('bonjour');
- expect(el.getAttribute('data_wid')).toBe('123');
- });
- });
-
- it('does nothing when hex is not provided', () => {
- document.body.innerHTML = `
- hello
- `;
-
- const params: WordUpdateParams = {
- wid: 123,
- status: 2,
- translation: 'bonjour',
- romanization: '',
- text: 'hello'
- };
-
- updateNewWordInDOM(params);
-
- expect(document.querySelector('[data_hex="48454c4c4f"]')!.classList.contains('status0')).toBe(true);
- });
-
- it('sets title attribute with generated tooltip', () => {
- document.body.innerHTML = `
- hello
- `;
-
- const params: WordUpdateParams = {
- wid: 123,
- status: 2,
- translation: 'bonjour',
- romanization: 'bɔ̃ʒuʁ',
- text: 'hello',
- hex: '48454c4c4f'
- };
-
- updateNewWordInDOM(params);
-
- // generateTooltip returns formatted tooltip string
- expect(document.querySelector('[data_hex="48454c4c4f"]')!.getAttribute('title')).toBe('hello|bonjour|bɔ̃ʒuʁ|2');
- });
- });
-
// ===========================================================================
// updateExistingWordInDOM Tests
// ===========================================================================
@@ -352,121 +250,6 @@ describe('word_dom_updates.ts', () => {
});
});
- // ===========================================================================
- // updateMultiWordInDOM Tests
- // ===========================================================================
-
- describe('updateMultiWordInDOM', () => {
- it('updates multi-word expression attributes', () => {
- document.body.innerHTML = `
- hello world
- `;
-
- updateMultiWordInDOM(333, 'hello world', 'bonjour monde', 'rom', 4, 2);
-
- const element = document.querySelector('.word333')!;
- expect(element.classList.contains('status2')).toBe(false);
- expect(element.classList.contains('status4')).toBe(true);
- expect(element.getAttribute('data_trans')).toBe('bonjour monde');
- expect(element.getAttribute('data_rom')).toBe('rom');
- expect(element.getAttribute('data_status')).toBe('4');
- });
- });
-
- // ===========================================================================
- // updateBulkWordInDOM Tests
- // ===========================================================================
-
- describe('updateBulkWordInDOM', () => {
- it('updates word from bulk translate', () => {
- document.body.innerHTML = `
- hello
- `;
-
- const term: BulkWordUpdateParams = {
- WoID: 555,
- WoTextLC: 'hello',
- WoStatus: 3,
- translation: 'bonjour',
- hex: '48454c4c4f'
- };
-
- updateBulkWordInDOM(term, true);
-
- const element = document.querySelector('[data_hex="48454c4c4f"]')!;
- expect(element.classList.contains('status0')).toBe(false);
- expect(element.classList.contains('status3')).toBe(true);
- expect(element.classList.contains('word555')).toBe(true);
- expect(element.getAttribute('data_wid')).toBe('555');
- expect(element.getAttribute('data_trans')).toBe('bonjour');
- });
-
- it('sets empty title when useTooltip is false', () => {
- document.body.innerHTML = `
- hello
- `;
-
- const term: BulkWordUpdateParams = {
- WoID: 555,
- WoTextLC: 'hello',
- WoStatus: 3,
- translation: 'bonjour',
- hex: '48454c4c4f'
- };
-
- updateBulkWordInDOM(term, false);
-
- expect(document.querySelector('[data_hex="48454c4c4f"]')!.getAttribute('title')).toBe('');
- });
- });
-
- // ===========================================================================
- // updateTestWordInDOM Tests
- // ===========================================================================
-
- describe('updateTestWordInDOM', () => {
- it('updates word data attributes for test results', () => {
- document.body.innerHTML = `
- test word
- `;
-
- updateTestWordInDOM(777, 'test word', 'new trans', 'new rom', 5);
-
- const element = document.querySelector('.word777')!;
- expect(element.getAttribute('data_text')).toBe('test word');
- expect(element.getAttribute('data_trans')).toBe('new trans');
- expect(element.getAttribute('data_rom')).toBe('new rom');
- expect(element.getAttribute('data_status')).toBe('5');
- });
- });
-
- // ===========================================================================
- // completeWordOperation Tests
- // ===========================================================================
-
- describe('completeWordOperation', () => {
- it('updates learn status and cleans up frames', async () => {
- const { cleanupRightFrames } = await import('../../../src/frontend/js/modules/text/pages/reading/frame_management');
-
- document.body.innerHTML = `old
`;
-
- completeWordOperation('5 words to learn ');
-
- expect(document.querySelector('#learnstatus')!.innerHTML).toBe('5 words to learn ');
- expect(cleanupRightFrames).toHaveBeenCalled();
- });
-
- it('skips cleanup when shouldCleanup is false', async () => {
- const { cleanupRightFrames } = await import('../../../src/frontend/js/modules/text/pages/reading/frame_management');
-
- document.body.innerHTML = `old
`;
-
- completeWordOperation('content', false);
-
- expect(cleanupRightFrames).not.toHaveBeenCalled();
- });
- });
-
// ===========================================================================
// Edge Cases
// ===========================================================================
diff --git a/tests/frontend/words/word_result_init.test.ts b/tests/frontend/words/word_result_init.test.ts
deleted file mode 100644
index 5644c9bb7..000000000
--- a/tests/frontend/words/word_result_init.test.ts
+++ /dev/null
@@ -1,501 +0,0 @@
-/**
- * Tests for word_result_init.ts - Auto-initializes word result views
- */
-import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { autoInitWordResults } from '../../../src/frontend/js/modules/vocabulary/pages/word_result_init';
-
-// Mock dependencies
-vi.mock('../../../src/frontend/js/modules/vocabulary/services/word_dom_updates', () => ({
- updateNewWordInDOM: vi.fn(),
- updateExistingWordInDOM: vi.fn(),
- completeWordOperation: vi.fn(),
- getParentContext: vi.fn(() => document),
- updateLearnStatus: vi.fn(),
- updateTestWordInDOM: vi.fn(),
- updateMultiWordInDOM: vi.fn(),
- updateBulkWordInDOM: vi.fn()
-}));
-
-vi.mock('../../../src/frontend/js/modules/vocabulary/services/word_status', () => ({
- createWordTooltip: vi.fn(() => 'tooltip text')
-}));
-
-vi.mock('../../../src/frontend/js/modules/text/pages/reading/frame_management', () => ({
- cleanupRightFrames: vi.fn()
-}));
-
-vi.mock('../../../src/frontend/js/modules/vocabulary/services/term_operations', () => ({
- loadTermTranslations: vi.fn()
-}));
-
-vi.mock('../../../src/frontend/js/shared/utils/html_utils', () => ({
- escapeHtml: vi.fn((s) => s)
-}));
-
-import {
- updateNewWordInDOM,
- completeWordOperation,
- updateMultiWordInDOM,
- updateBulkWordInDOM,
- updateLearnStatus,
- updateExistingWordInDOM
-} from '../../../src/frontend/js/modules/vocabulary/services/word_dom_updates';
-import { cleanupRightFrames } from '../../../src/frontend/js/modules/text/pages/reading/frame_management';
-
-describe('word_result_init.ts', () => {
- beforeEach(() => {
- document.body.innerHTML = '';
- vi.clearAllMocks();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- document.body.innerHTML = '';
- });
-
- // ===========================================================================
- // Cleanup Frames Tests
- // ===========================================================================
-
- describe('cleanup frames', () => {
- it('calls cleanupRightFrames when data attribute is present', () => {
- document.body.innerHTML = `
-
- `;
-
- autoInitWordResults();
-
- expect(cleanupRightFrames).toHaveBeenCalled();
- });
-
- it('does not call cleanupRightFrames when data attribute is missing', () => {
- document.body.innerHTML = 'No cleanup
';
-
- autoInitWordResults();
-
- expect(cleanupRightFrames).not.toHaveBeenCalled();
- });
- });
-
- // ===========================================================================
- // Save Result Config Tests
- // ===========================================================================
-
- describe('save result config', () => {
- it('initializes from save result config', () => {
- document.body.innerHTML = `
-
- `;
-
- autoInitWordResults();
-
- expect(updateNewWordInDOM).toHaveBeenCalledWith({
- wid: 123,
- status: 2,
- translation: 'translated',
- romanization: 'roman',
- text: 'word',
- hex: 'abc123'
- });
- expect(completeWordOperation).toHaveBeenCalledWith('5 words');
- });
-
- it('handles invalid JSON gracefully', () => {
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- document.body.innerHTML = `
-
- `;
-
- autoInitWordResults();
-
- expect(errorSpy).toHaveBeenCalledWith(
- 'Failed to parse save result config:',
- expect.any(Error)
- );
- });
- });
-
- // ===========================================================================
- // Edit Result Config Tests
- // ===========================================================================
-
- describe('edit result config', () => {
- it('calls updateNewWordInDOM for new words', () => {
- document.body.innerHTML = `
-
- `;
-
- autoInitWordResults();
-
- expect(updateNewWordInDOM).toHaveBeenCalled();
- expect(completeWordOperation).toHaveBeenCalledWith('10 words');
- });
-
- it('calls updateExistingWordInDOM for existing words', () => {
- document.body.innerHTML = `
-
- `;
-
- autoInitWordResults();
-
- expect(updateExistingWordInDOM).toHaveBeenCalled();
- expect(completeWordOperation).toHaveBeenCalledWith('15 words');
- });
- });
-
- // ===========================================================================
- // Edit Multi Update Result Config Tests
- // ===========================================================================
-
- describe('edit multi update result config', () => {
- it('initializes from edit multi update result config', () => {
- document.body.innerHTML = `
-
- `;
-
- autoInitWordResults();
-
- expect(updateMultiWordInDOM).toHaveBeenCalledWith(
- 444, 'multi word', 'phrase', 'rom', 4, 3
- );
- });
- });
-
- // ===========================================================================
- // Bulk Save Result Config Tests
- // ===========================================================================
-
- describe('bulk save result config', () => {
- it('initializes from bulk save result config', () => {
- document.body.innerHTML = `
- Updating...
-
- `;
-
- autoInitWordResults();
-
- expect(updateBulkWordInDOM).toHaveBeenCalledTimes(2);
- expect(updateLearnStatus).toHaveBeenCalledWith('20 words');
- });
-
- it('removes displ_message element', () => {
- document.body.innerHTML = `
- Updating...
-
- `;
-
- autoInitWordResults();
-
- expect(document.getElementById('displ_message')).toBeNull();
- });
-
- it('calls cleanupRightFrames when cleanUp is true', () => {
- document.body.innerHTML = `
-
- `;
-
- autoInitWordResults();
-
- expect(cleanupRightFrames).toHaveBeenCalled();
- });
- });
-
- // ===========================================================================
- // All Well-Known Result Config Tests
- // ===========================================================================
-
- describe('all wellknown result config', () => {
- beforeEach(() => {
- // Mock parent for closePopup
- Object.defineProperty(window, 'parent', {
- writable: true,
- value: {
- closePopup: vi.fn(),
- setTimeout: vi.fn((fn) => fn())
- }
- });
- });
-
- it('initializes from all wellknown result config', () => {
- document.body.innerHTML = `
- word1
- word2
-
- `;
-
- autoInitWordResults();
-
- expect(updateLearnStatus).toHaveBeenCalledWith('0 words');
- });
-
- it('updates word elements with new status', () => {
- document.body.innerHTML = `
- word
-
- `;
-
- autoInitWordResults();
-
- const wordEl = document.querySelector('[data_hex="abc"]');
- expect(wordEl?.classList.contains('status99')).toBe(true);
- expect(wordEl?.classList.contains('word1')).toBe(true);
- expect(wordEl?.getAttribute('data_status')).toBe('99');
- expect(wordEl?.getAttribute('data_wid')).toBe('1');
- });
- });
-
- // ===========================================================================
- // Hover Save Result Config Tests
- // ===========================================================================
-
- describe('hover save result config', () => {
- it('initializes from hover save result config', () => {
- document.body.innerHTML = `
- word
-
- `;
-
- autoInitWordResults();
-
- const wordEl = document.querySelector('[data_hex="abc"]');
- expect(wordEl?.classList.contains('status1')).toBe(true);
- expect(wordEl?.classList.contains('word123')).toBe(true);
- expect(updateLearnStatus).toHaveBeenCalledWith('5 words');
- expect(cleanupRightFrames).toHaveBeenCalled();
- });
- });
-
- // ===========================================================================
- // Edit Term Result Config Tests
- // ===========================================================================
-
- describe('edit term result config', () => {
- it('initializes from edit term result config for table test', () => {
- // Mock parent location for table test detection
- Object.defineProperty(window, 'parent', {
- writable: true,
- value: {
- location: { href: 'test.php?type=table' }
- }
- });
-
- document.body.innerHTML = `
- Old status
- Old term
- Old trans
- Old roman
- Old sent
-
- `;
-
- autoInitWordResults();
-
- expect(document.querySelector('#TERM123')!.innerHTML).toBe('new term');
- expect(document.querySelector('#TRAN123')!.innerHTML).toBe('new trans');
- expect(document.querySelector('#ROMA123')!.innerHTML).toBe('new roman');
- expect(document.querySelector('#SENT123')!.innerHTML).toBe('new sentence');
- expect(cleanupRightFrames).toHaveBeenCalled();
- });
- });
-
- // ===========================================================================
- // Multiple Config Elements Tests
- // ===========================================================================
-
- describe('multiple config elements', () => {
- it('handles no config elements', () => {
- document.body.innerHTML = 'No configs
';
-
- expect(() => autoInitWordResults()).not.toThrow();
- });
-
- it('processes multiple different config types', () => {
- document.body.innerHTML = `
-
-
-
- `;
-
- autoInitWordResults();
-
- expect(cleanupRightFrames).toHaveBeenCalled();
- expect(updateNewWordInDOM).toHaveBeenCalled();
- expect(updateMultiWordInDOM).toHaveBeenCalled();
- });
- });
-
- // ===========================================================================
- // Error Handling Tests
- // ===========================================================================
-
- describe('error handling', () => {
- it('handles invalid all wellknown config', () => {
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- document.body.innerHTML = `
-
- `;
-
- autoInitWordResults();
-
- expect(errorSpy).toHaveBeenCalledWith(
- 'Failed to parse all wellknown result config:',
- expect.any(Error)
- );
- });
-
- it('handles invalid edit term result config', () => {
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- document.body.innerHTML = `
-
- `;
-
- autoInitWordResults();
-
- expect(errorSpy).toHaveBeenCalledWith(
- 'Failed to parse edit term result config:',
- expect.any(Error)
- );
- });
-
- it('handles invalid bulk save result config', () => {
- const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- document.body.innerHTML = `
-
- `;
-
- autoInitWordResults();
-
- expect(errorSpy).toHaveBeenCalledWith(
- 'Failed to parse bulk save result config:',
- expect.any(Error)
- );
- });
- });
-});