From 2e3a5d86c6b891252c284453eabed56ae23dd121 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 22:42:45 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(db):=20line-centric=20model=20?= =?UTF-8?q?=E2=80=94=20lyric=5Flines,=20translations,=20song=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the lyric line as a first-class, ordered, multilingual, singer-attributed unit, layered additively over the existing songs row. Schema (rust-backend/src/db/sqlite.rs): - lyric_lines(id, song_id, position, kind, content, phonetic, singers) with kind = 'lyric' | 'silence' and a UNIQUE(song_id, position) index - lyric_line_translations(line_id, lang, text, phonetic) — one row per (line, language), so a song gains a language without a schema change - song_notes(id, song_id, position, content) — free-form notes attached to the whole song, outside the lyric sequence - songs.lines_migrated flag + idempotent migration Model (models/lyric_line.rs): LyricLine, LineKind, LineTranslation, SongNote, CreateLineData, UpdateLineData. Service (services/lyric_lines.rs): lazy backfill from the legacy parallel arrays on first read (guarded by lines_migrated so deleted lines are not resurrected), line CRUD with position re-packing, safe two-pass reorder, per-language translation upsert/remove, and song-note CRUD. 12 tests. Tauri commands + frontend TS bindings (types + invoke wrappers) added. The songs parallel arrays remain the current source of truth; migrating the practice UI to LyricLine and dropping the redundant columns is a later phase. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ReeGwy3JKhaXxkxCWYYQsG --- lyremember-app/src-tauri/src/commands.rs | 135 +++- lyremember-app/src-tauri/src/lib.rs | 13 + lyremember-app/src/lib/tauri-api.ts | 123 ++++ lyremember-app/src/types/index.ts | 4 + rust-backend/src/db/sqlite.rs | 80 ++- rust-backend/src/models/lyric_line.rs | 96 +++ rust-backend/src/models/mod.rs | 4 + rust-backend/src/services/lyric_lines.rs | 793 +++++++++++++++++++++++ rust-backend/src/services/mod.rs | 1 + 9 files changed, 1247 insertions(+), 2 deletions(-) create mode 100644 rust-backend/src/models/lyric_line.rs create mode 100644 rust-backend/src/services/lyric_lines.rs diff --git a/lyremember-app/src-tauri/src/commands.rs b/lyremember-app/src-tauri/src/commands.rs index 3e851d9..3377054 100644 --- a/lyremember-app/src-tauri/src/commands.rs +++ b/lyremember-app/src-tauri/src/commands.rs @@ -1,5 +1,5 @@ use lyremember_backend::models::*; -use lyremember_backend::services::{auth, songs, practice, translation, phonetic}; +use lyremember_backend::services::{auth, songs, lyric_lines, practice, translation, phonetic}; use lyremember_backend::services::practice::UserStats; use rusqlite::Connection; use serde::Serialize; @@ -177,6 +177,139 @@ pub fn cmd_delete_song( songs::delete_song(&conn, &song_id).map_err(|e| e.to_string()) } +// ==================== LYRIC LINE COMMANDS ==================== + +/// Get all lines of a song (ordered, each with its translations). +/// Backfills from the legacy parallel arrays on first read. +#[tauri::command] +pub fn cmd_get_song_lines( + song_id: String, + state: State<'_, DbState>, +) -> std::result::Result, String> { + let conn = lock_db(&state)?; + lyric_lines::get_song_lines(&conn, &song_id).map_err(|e| e.to_string()) +} + +/// Append a new line (kind = "lyric" | "silence") to a song. +#[tauri::command] +pub fn cmd_create_line( + song_id: String, + kind: LineKind, + content: Option, + phonetic: Option, + singers: Option>, + state: State<'_, DbState>, +) -> std::result::Result { + let conn = lock_db(&state)?; + let data = CreateLineData { kind, content, phonetic, singers }; + lyric_lines::create_line(&conn, &song_id, data).map_err(|e| e.to_string()) +} + +/// Partially update a line. Omitted fields are left untouched. +#[tauri::command] +pub fn cmd_update_line( + line_id: String, + kind: Option, + content: Option, + phonetic: Option, + singers: Option>, + state: State<'_, DbState>, +) -> std::result::Result { + let conn = lock_db(&state)?; + let data = UpdateLineData { kind, content, phonetic, singers }; + lyric_lines::update_line(&conn, &line_id, data).map_err(|e| e.to_string()) +} + +/// Delete a line; remaining positions are re-packed. +#[tauri::command] +pub fn cmd_delete_line( + line_id: String, + state: State<'_, DbState>, +) -> std::result::Result<(), String> { + let conn = lock_db(&state)?; + lyric_lines::delete_line(&conn, &line_id).map_err(|e| e.to_string()) +} + +/// Reorder a song's lines. `ordered_ids` must be exactly the song's line ids. +#[tauri::command] +pub fn cmd_reorder_lines( + song_id: String, + ordered_ids: Vec, + state: State<'_, DbState>, +) -> std::result::Result, String> { + let conn = lock_db(&state)?; + lyric_lines::reorder_lines(&conn, &song_id, &ordered_ids).map_err(|e| e.to_string()) +} + +/// Set (insert or replace) a line's translation for one language. +#[tauri::command] +pub fn cmd_set_line_translation( + line_id: String, + lang: String, + text: String, + phonetic: Option, + state: State<'_, DbState>, +) -> std::result::Result<(), String> { + let conn = lock_db(&state)?; + lyric_lines::set_line_translation(&conn, &line_id, &lang, &text, phonetic.as_deref()) + .map_err(|e| e.to_string()) +} + +/// Remove a line's translation for one language. +#[tauri::command] +pub fn cmd_remove_line_translation( + line_id: String, + lang: String, + state: State<'_, DbState>, +) -> std::result::Result<(), String> { + let conn = lock_db(&state)?; + lyric_lines::remove_line_translation(&conn, &line_id, &lang).map_err(|e| e.to_string()) +} + +// ==================== SONG NOTE COMMANDS ==================== + +/// Get a song's notes, ordered. +#[tauri::command] +pub fn cmd_get_song_notes( + song_id: String, + state: State<'_, DbState>, +) -> std::result::Result, String> { + let conn = lock_db(&state)?; + lyric_lines::get_song_notes(&conn, &song_id).map_err(|e| e.to_string()) +} + +/// Append a note to a song. +#[tauri::command] +pub fn cmd_create_note( + song_id: String, + content: String, + state: State<'_, DbState>, +) -> std::result::Result { + let conn = lock_db(&state)?; + lyric_lines::create_note(&conn, &song_id, &content).map_err(|e| e.to_string()) +} + +/// Update a note's content. +#[tauri::command] +pub fn cmd_update_note( + note_id: String, + content: String, + state: State<'_, DbState>, +) -> std::result::Result { + let conn = lock_db(&state)?; + lyric_lines::update_note(&conn, ¬e_id, &content).map_err(|e| e.to_string()) +} + +/// Delete a note. +#[tauri::command] +pub fn cmd_delete_note( + note_id: String, + state: State<'_, DbState>, +) -> std::result::Result<(), String> { + let conn = lock_db(&state)?; + lyric_lines::delete_note(&conn, ¬e_id).map_err(|e| e.to_string()) +} + // ==================== PRACTICE COMMANDS ==================== #[tauri::command] diff --git a/lyremember-app/src-tauri/src/lib.rs b/lyremember-app/src-tauri/src/lib.rs index 0b7196b..fab9b79 100644 --- a/lyremember-app/src-tauri/src/lib.rs +++ b/lyremember-app/src-tauri/src/lib.rs @@ -49,6 +49,19 @@ pub fn run() { cmd_add_to_repertoire, cmd_update_song, cmd_delete_song, + // Lyric line commands + cmd_get_song_lines, + cmd_create_line, + cmd_update_line, + cmd_delete_line, + cmd_reorder_lines, + cmd_set_line_translation, + cmd_remove_line_translation, + // Song note commands + cmd_get_song_notes, + cmd_create_note, + cmd_update_note, + cmd_delete_note, // Practice commands cmd_create_practice_session, cmd_get_user_sessions, diff --git a/lyremember-app/src/lib/tauri-api.ts b/lyremember-app/src/lib/tauri-api.ts index 8568ea2..1468fd0 100644 --- a/lyremember-app/src/lib/tauri-api.ts +++ b/lyremember-app/src/lib/tauri-api.ts @@ -52,6 +52,35 @@ export interface SongMastery { mastery_level: string; } +export type LineKind = 'lyric' | 'silence'; + +export interface LineTranslation { + text: string; + phonetic: string | null; +} + +export interface LyricLine { + id: string; + song_id: string; + position: number; + kind: LineKind; + content: string | null; + phonetic: string | null; + singers: string[] | null; + translations: Record; + created_at: string; + updated_at: string; +} + +export interface SongNote { + id: string; + song_id: string; + position: number; + content: string; + created_at: string; + updated_at: string; +} + // ==================== AUTH API ==================== export async function register( @@ -160,6 +189,100 @@ export async function getRecommendations(userId: string, limit = 5): Promise { + return await invoke('cmd_get_song_lines', { songId }); +} + +export async function createLine( + songId: string, + kind: LineKind, + options?: { + content?: string | null; + phonetic?: string | null; + singers?: string[] | null; + } +): Promise { + return await invoke('cmd_create_line', { + songId, + kind, + content: options?.content ?? null, + phonetic: options?.phonetic ?? null, + singers: options?.singers ?? null, + }); +} + +export async function updateLine( + lineId: string, + updates: { + kind?: LineKind; + content?: string; + phonetic?: string; + singers?: string[]; + } +): Promise { + return await invoke('cmd_update_line', { + lineId, + kind: updates.kind ?? null, + content: updates.content ?? null, + phonetic: updates.phonetic ?? null, + singers: updates.singers ?? null, + }); +} + +export async function deleteLine(lineId: string): Promise { + return await invoke('cmd_delete_line', { lineId }); +} + +/** Reorder a song's lines. `orderedIds` must be exactly the song's line ids. */ +export async function reorderLines( + songId: string, + orderedIds: string[] +): Promise { + return await invoke('cmd_reorder_lines', { songId, orderedIds }); +} + +export async function setLineTranslation( + lineId: string, + lang: string, + text: string, + phonetic?: string | null +): Promise { + return await invoke('cmd_set_line_translation', { + lineId, + lang, + text, + phonetic: phonetic ?? null, + }); +} + +export async function removeLineTranslation(lineId: string, lang: string): Promise { + return await invoke('cmd_remove_line_translation', { lineId, lang }); +} + +// ==================== SONG NOTE API ==================== + +export async function getSongNotes(songId: string): Promise { + return await invoke('cmd_get_song_notes', { songId }); +} + +export async function createNote(songId: string, content: string): Promise { + return await invoke('cmd_create_note', { songId, content }); +} + +export async function updateNote(noteId: string, content: string): Promise { + return await invoke('cmd_update_note', { noteId, content }); +} + +export async function deleteNote(noteId: string): Promise { + return await invoke('cmd_delete_note', { noteId }); +} + // ==================== PRACTICE API ==================== export async function createPracticeSession( diff --git a/lyremember-app/src/types/index.ts b/lyremember-app/src/types/index.ts index 047e0e6..e472f92 100644 --- a/lyremember-app/src/types/index.ts +++ b/lyremember-app/src/types/index.ts @@ -6,6 +6,10 @@ export type { UserStats, SongMastery, LrclibHit, + LineKind, + LineTranslation, + LyricLine, + SongNote, } from '../lib/tauri-api'; // Additional UI-specific types diff --git a/rust-backend/src/db/sqlite.rs b/rust-backend/src/db/sqlite.rs index 228ea02..2bb2829 100644 --- a/rust-backend/src/db/sqlite.rs +++ b/rust-backend/src/db/sqlite.rs @@ -52,6 +52,7 @@ fn create_tables(conn: &Connection) -> Result<()> { synced_lyrics TEXT, singers TEXT, line_singers TEXT, + lines_migrated INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )", @@ -89,6 +90,57 @@ fn create_tables(conn: &Connection) -> Result<()> { [], )?; + // Lyric lines — the atomic, ordered, multilingual unit of a song. + // `kind` distinguishes a sung line ('lyric') from a separator ('silence'). + // `content`/`phonetic` are the original-language text; `singers` is a JSON + // array of singer names for this line. Translations live in the child table + // `lyric_line_translations` so a song can gain a new language without a + // schema change. + conn.execute( + "CREATE TABLE IF NOT EXISTS lyric_lines ( + id TEXT PRIMARY KEY, + song_id TEXT NOT NULL, + position INTEGER NOT NULL, + kind TEXT NOT NULL, + content TEXT, + phonetic TEXT, + singers TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE + )", + [], + )?; + + // One translation per (line, language). Original phonetic stays on the line; + // `phonetic` here is the phonetic of the *translated* text (optional). + conn.execute( + "CREATE TABLE IF NOT EXISTS lyric_line_translations ( + line_id TEXT NOT NULL, + lang TEXT NOT NULL, + text TEXT NOT NULL, + phonetic TEXT, + PRIMARY KEY (line_id, lang), + FOREIGN KEY (line_id) REFERENCES lyric_lines(id) ON DELETE CASCADE + )", + [], + )?; + + // Free-form notes attached to a whole song (anecdotes, meaning summaries…). + // Not part of the lyric sequence — ordered independently by `position`. + conn.execute( + "CREATE TABLE IF NOT EXISTS song_notes ( + id TEXT PRIMARY KEY, + song_id TEXT NOT NULL, + position INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE + )", + [], + )?; + // Indexes for performance conn.execute( "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)", @@ -110,6 +162,22 @@ fn create_tables(conn: &Connection) -> Result<()> { [], )?; + // A song's line positions are contiguous and unique. + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_lyric_lines_song_pos ON lyric_lines(song_id, position)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_lyric_lines_song ON lyric_lines(song_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_song_notes_song ON song_notes(song_id)", + [], + )?; + Ok(()) } @@ -125,6 +193,10 @@ fn apply_migrations(conn: &Connection) -> Result<()> { "ALTER TABLE songs ADD COLUMN synced_lyrics TEXT", "ALTER TABLE songs ADD COLUMN singers TEXT", "ALTER TABLE songs ADD COLUMN line_singers TEXT", + // v1 → v2 : line-centric model. Flag marking whether a song's legacy + // parallel arrays have been exploded into lyric_lines yet. 0 = not yet; + // the lyric-line service backfills lazily on first read and sets it to 1. + "ALTER TABLE songs ADD COLUMN lines_migrated INTEGER NOT NULL DEFAULT 0", ]; for sql in migrations { @@ -180,6 +252,9 @@ mod tests { assert!(tables.contains(&"songs".to_string()), "missing songs table"); assert!(tables.contains(&"user_songs".to_string()), "missing user_songs table"); assert!(tables.contains(&"practice_sessions".to_string()), "missing practice_sessions table"); + assert!(tables.contains(&"lyric_lines".to_string()), "missing lyric_lines table"); + assert!(tables.contains(&"lyric_line_translations".to_string()), "missing lyric_line_translations table"); + assert!(tables.contains(&"song_notes".to_string()), "missing song_notes table"); } #[test] @@ -191,6 +266,9 @@ mod tests { assert!(indexes.contains(&"idx_songs_language".to_string()), "missing idx_songs_language"); assert!(indexes.contains(&"idx_user_songs_user".to_string()), "missing idx_user_songs_user"); assert!(indexes.contains(&"idx_sessions_user".to_string()), "missing idx_sessions_user"); + assert!(indexes.contains(&"idx_lyric_lines_song_pos".to_string()), "missing idx_lyric_lines_song_pos"); + assert!(indexes.contains(&"idx_lyric_lines_song".to_string()), "missing idx_lyric_lines_song"); + assert!(indexes.contains(&"idx_song_notes_song".to_string()), "missing idx_song_notes_song"); } #[test] @@ -203,7 +281,7 @@ mod tests { let conn2 = init_database(temp_file.path()).unwrap(); let tables = get_table_names(&conn2); - assert_eq!(tables.len(), 4); + assert_eq!(tables.len(), 7); } #[test] diff --git a/rust-backend/src/models/lyric_line.rs b/rust-backend/src/models/lyric_line.rs new file mode 100644 index 0000000..af580ba --- /dev/null +++ b/rust-backend/src/models/lyric_line.rs @@ -0,0 +1,96 @@ +//! Lyric-line model — the atomic unit of a song. +//! +//! A [`LyricLine`] is the multilingual, ordered, singer-attributed unit the app +//! is built around: it carries the original text, its phonetic, the singers who +//! perform it, and a translation per language. A [`SongNote`] is a free-form +//! note attached to the whole song, outside the lyric sequence. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Kind of a block in a song's sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LineKind { + /// A sung line of lyrics (carries `content`). + Lyric, + /// A silence separating verses/choruses (no `content`). + Silence, +} + +impl LineKind { + /// The canonical string stored in the `lyric_lines.kind` column. + pub fn as_str(&self) -> &'static str { + match self { + LineKind::Lyric => "lyric", + LineKind::Silence => "silence", + } + } + + /// Parse the value stored in the `lyric_lines.kind` column. + pub fn parse(s: &str) -> Option { + match s { + "lyric" => Some(LineKind::Lyric), + "silence" => Some(LineKind::Silence), + _ => None, + } + } +} + +/// A translation of one line into one language. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LineTranslation { + pub text: String, + /// Phonetic of the translated text (optional). + pub phonetic: Option, +} + +/// A lyric line: multilingual, ordered, and singer-attributed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LyricLine { + pub id: String, + pub song_id: String, + /// 0-based order within the song. Contiguous and unique per song. + pub position: i64, + pub kind: LineKind, + /// Original-language text. `None` for a silence. + pub content: Option, + /// Phonetic of the original-language text. + pub phonetic: Option, + /// Singers performing this line. `None` = unassigned / all. + pub singers: Option>, + /// Translations keyed by language code (e.g. "en", "fr"). + pub translations: HashMap, + pub created_at: String, + pub updated_at: String, +} + +/// Data to create a new lyric line. New lines are appended to the song. +#[derive(Debug, Deserialize)] +pub struct CreateLineData { + pub kind: LineKind, + pub content: Option, + pub phonetic: Option, + pub singers: Option>, +} + +/// Partial update for a lyric line. A `None` field is left untouched. +#[derive(Debug, Default, Deserialize)] +pub struct UpdateLineData { + pub kind: Option, + pub content: Option, + pub phonetic: Option, + pub singers: Option>, +} + +/// A free-form note attached to a whole song (anecdote, meaning summary…). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SongNote { + pub id: String, + pub song_id: String, + /// 0-based display order among the song's notes. + pub position: i64, + pub content: String, + pub created_at: String, + pub updated_at: String, +} diff --git a/rust-backend/src/models/mod.rs b/rust-backend/src/models/mod.rs index 2f86651..63c3236 100644 --- a/rust-backend/src/models/mod.rs +++ b/rust-backend/src/models/mod.rs @@ -2,8 +2,12 @@ pub mod user; pub mod song; +pub mod lyric_line; pub mod session; pub use user::{User, LoginCredentials, RegisterData}; pub use song::{Song, CreateSongData, UpdateSongData}; +pub use lyric_line::{ + LyricLine, LineKind, LineTranslation, CreateLineData, UpdateLineData, SongNote, +}; pub use session::{PracticeSession, CreateSessionData}; diff --git a/rust-backend/src/services/lyric_lines.rs b/rust-backend/src/services/lyric_lines.rs new file mode 100644 index 0000000..85bdb85 --- /dev/null +++ b/rust-backend/src/services/lyric_lines.rs @@ -0,0 +1,793 @@ +//! Lyric-line service — the line-centric model layered over the `songs` table. +//! +//! The `songs` row still stores the legacy parallel arrays (`lyrics`, +//! `phonetic_lyrics`, `translations`, `line_singers`). This service exposes the +//! normalized [`LyricLine`] model backed by the `lyric_lines` / +//! `lyric_line_translations` tables, and backfills a song's lines lazily from +//! those arrays the first time they are read (guarded by `songs.lines_migrated` +//! so lines are never resurrected after the caller edits or deletes them). +//! +//! Notes ([`SongNote`]) are attached to the whole song, outside the sequence. + +pub use crate::models::{ + CreateLineData, LineKind, LineTranslation, LyricLine, SongNote, UpdateLineData, +}; + +use crate::{Error, Result}; +use rusqlite::Connection; +use std::collections::HashMap; + +fn now() -> String { + chrono::Utc::now().to_rfc3339() +} + +fn new_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +// ─── column indices (must mirror LINE_SELECT order exactly) ────────────────── + +const COL_ID: usize = 0; +const COL_SONG_ID: usize = 1; +const COL_POSITION: usize = 2; +const COL_KIND: usize = 3; +const COL_CONTENT: usize = 4; +const COL_PHONETIC: usize = 5; +const COL_SINGERS: usize = 6; +const COL_CREATED_AT: usize = 7; +const COL_UPDATED_AT: usize = 8; + +const LINE_SELECT: &str = "SELECT id, song_id, position, kind, content, phonetic, \ + singers, created_at, updated_at FROM lyric_lines"; + +/// Deserialize a `lyric_lines` row. `translations` is left empty and filled by +/// the caller from a separate query. +fn row_to_line(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let kind_str: String = row.get(COL_KIND)?; + let singers_json: Option = row.get(COL_SINGERS)?; + + let kind = LineKind::parse(&kind_str).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + COL_KIND, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid line kind '{}'", kind_str), + )), + ) + })?; + + Ok(LyricLine { + id: row.get(COL_ID)?, + song_id: row.get(COL_SONG_ID)?, + position: row.get(COL_POSITION)?, + kind, + content: row.get(COL_CONTENT)?, + phonetic: row.get(COL_PHONETIC)?, + singers: singers_json.and_then(|j| serde_json::from_str(&j).ok()), + translations: HashMap::new(), + created_at: row.get(COL_CREATED_AT)?, + updated_at: row.get(COL_UPDATED_AT)?, + }) +} + +/// Load the `lang -> LineTranslation` map for every line of a song in one query. +fn load_translations( + conn: &Connection, + song_id: &str, +) -> Result>> { + let mut stmt = conn.prepare( + "SELECT tr.line_id, tr.lang, tr.text, tr.phonetic + FROM lyric_line_translations tr + JOIN lyric_lines ll ON ll.id = tr.line_id + WHERE ll.song_id = ?1", + )?; + + let rows = stmt.query_map([song_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + })?; + + let mut by_line: HashMap> = HashMap::new(); + for r in rows { + let (line_id, lang, text, phonetic) = r?; + by_line + .entry(line_id) + .or_default() + .insert(lang, LineTranslation { text, phonetic }); + } + Ok(by_line) +} + +/// Explode a song's legacy parallel arrays into `lyric_lines` rows the first +/// time it is read. No-op once `songs.lines_migrated = 1`. Errors if the song +/// does not exist. +fn ensure_backfilled(conn: &Connection, song_id: &str) -> Result<()> { + const COL_MIGRATED: usize = 0; + const COL_LYRICS: usize = 1; + const COL_PHON: usize = 2; + const COL_TRANS: usize = 3; + const COL_LINE_SINGERS: usize = 4; + + let row = conn + .query_row( + "SELECT lines_migrated, lyrics, phonetic_lyrics, translations, line_singers + FROM songs WHERE id = ?1", + [song_id], + |row| { + Ok(( + row.get::<_, i64>(COL_MIGRATED)?, + row.get::<_, String>(COL_LYRICS)?, + row.get::<_, Option>(COL_PHON)?, + row.get::<_, Option>(COL_TRANS)?, + row.get::<_, Option>(COL_LINE_SINGERS)?, + )) + }, + ) + .map_err(|_| Error::NotFound("Song not found".to_string()))?; + + let (migrated, lyrics_json, phon_json, trans_json, line_singers_json) = row; + if migrated != 0 { + return Ok(()); + } + + let lyrics: Vec = serde_json::from_str(&lyrics_json).unwrap_or_default(); + let phonetic: Option> = phon_json.and_then(|j| serde_json::from_str(&j).ok()); + let translations: Option>> = + trans_json.and_then(|j| serde_json::from_str(&j).ok()); + let line_singers: Option>> = + line_singers_json.and_then(|j| serde_json::from_str(&j).ok()); + + // The parallel-array model is only correct when every array has the same + // length as `lyrics`; a mismatch is the latent bug this migration retires. + if let Some(p) = &phonetic { + if p.len() != lyrics.len() { + log::warn!( + "song {}: phonetic_lyrics length {} != lyrics length {}; extra/missing entries dropped", + song_id, p.len(), lyrics.len() + ); + } + } + + let tx = conn.unchecked_transaction()?; + let ts = now(); + + for (i, content) in lyrics.iter().enumerate() { + let line_id = new_id(); + let phon = phonetic.as_ref().and_then(|p| p.get(i)).cloned(); + let singers = line_singers + .as_ref() + .and_then(|ls| ls.get(i)) + .cloned() + .filter(|v| !v.is_empty()); + let singers_json = singers.as_ref().map(serde_json::to_string).transpose()?; + + tx.execute( + "INSERT INTO lyric_lines + (id, song_id, position, kind, content, phonetic, singers, created_at, updated_at) + VALUES (?1, ?2, ?3, 'lyric', ?4, ?5, ?6, ?7, ?7)", + rusqlite::params![line_id, song_id, i as i64, content, phon, singers_json, ts], + )?; + + if let Some(trans) = &translations { + for (lang, texts) in trans { + if let Some(t) = texts.get(i) { + tx.execute( + "INSERT INTO lyric_line_translations (line_id, lang, text, phonetic) + VALUES (?1, ?2, ?3, NULL)", + rusqlite::params![line_id, lang, t], + )?; + } + } + } + } + + tx.execute( + "UPDATE songs SET lines_migrated = 1 WHERE id = ?1", + [song_id], + )?; + tx.commit()?; + Ok(()) +} + +/// Rewrite the positions of a song's lines to `0..n` in their current order, +/// using a two-pass negative offset so the `UNIQUE(song_id, position)` index is +/// never transiently violated. Must run inside a transaction. +fn reindex(conn: &Connection, song_id: &str) -> Result<()> { + let ids: Vec = { + let mut stmt = + conn.prepare("SELECT id FROM lyric_lines WHERE song_id = ?1 ORDER BY position")?; + let ids = stmt + .query_map([song_id], |row| row.get(0))? + .collect::>()?; + ids + }; + + // Pass 1: park every row at a unique negative position. + for (i, id) in ids.iter().enumerate() { + conn.execute( + "UPDATE lyric_lines SET position = ?1 WHERE id = ?2", + rusqlite::params![-(i as i64) - 1, id], + )?; + } + // Pass 2: assign the final contiguous positions. + for (i, id) in ids.iter().enumerate() { + conn.execute( + "UPDATE lyric_lines SET position = ?1 WHERE id = ?2", + rusqlite::params![i as i64, id], + )?; + } + Ok(()) +} + +// ─── lines: read ───────────────────────────────────────────────────────────── + +/// Get all lines of a song, ordered by position, each with its translations. +/// Backfills from the legacy arrays on first read. +pub fn get_song_lines(conn: &Connection, song_id: &str) -> Result> { + ensure_backfilled(conn, song_id)?; + + let query = format!("{} WHERE song_id = ?1 ORDER BY position", LINE_SELECT); + let mut stmt = conn.prepare(&query)?; + let mut lines: Vec = stmt + .query_map([song_id], row_to_line)? + .collect::>()?; + + let mut by_line = load_translations(conn, song_id)?; + for line in &mut lines { + if let Some(m) = by_line.remove(&line.id) { + line.translations = m; + } + } + Ok(lines) +} + +/// Get a single line by id, with its translations. +pub fn get_line(conn: &Connection, line_id: &str) -> Result { + let query = format!("{} WHERE id = ?1", LINE_SELECT); + let mut line = conn + .prepare(&query)? + .query_row([line_id], row_to_line) + .map_err(|_| Error::NotFound("Line not found".to_string()))?; + + let mut stmt = conn.prepare( + "SELECT lang, text, phonetic FROM lyric_line_translations WHERE line_id = ?1", + )?; + let rows = stmt.query_map([line_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + for r in rows { + let (lang, text, phonetic) = r?; + line.translations.insert(lang, LineTranslation { text, phonetic }); + } + Ok(line) +} + +// ─── lines: write ──────────────────────────────────────────────────────────── + +/// Append a new line at the end of the song's sequence. +pub fn create_line(conn: &Connection, song_id: &str, data: CreateLineData) -> Result { + ensure_backfilled(conn, song_id)?; + + let position: i64 = { + let max: Option = conn.query_row( + "SELECT MAX(position) FROM lyric_lines WHERE song_id = ?1", + [song_id], + |row| row.get(0), + )?; + max.map(|m| m + 1).unwrap_or(0) + }; + + let id = new_id(); + let ts = now(); + let singers_json = data.singers.as_ref().map(serde_json::to_string).transpose()?; + + conn.execute( + "INSERT INTO lyric_lines + (id, song_id, position, kind, content, phonetic, singers, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)", + rusqlite::params![ + id, + song_id, + position, + data.kind.as_str(), + data.content, + data.phonetic, + singers_json, + ts, + ], + )?; + + get_line(conn, &id) +} + +/// Apply a partial update to a line. `None` fields are left untouched. +pub fn update_line(conn: &Connection, line_id: &str, data: UpdateLineData) -> Result { + let mut line = get_line(conn, line_id)?; + + if let Some(k) = data.kind { + line.kind = k; + } + if let Some(c) = data.content { + line.content = Some(c); + } + if let Some(p) = data.phonetic { + line.phonetic = Some(p); + } + if let Some(s) = data.singers { + line.singers = Some(s); + } + line.updated_at = now(); + + let singers_json = line.singers.as_ref().map(serde_json::to_string).transpose()?; + conn.execute( + "UPDATE lyric_lines + SET kind = ?1, content = ?2, phonetic = ?3, singers = ?4, updated_at = ?5 + WHERE id = ?6", + rusqlite::params![ + line.kind.as_str(), + line.content, + line.phonetic, + singers_json, + line.updated_at, + line_id, + ], + )?; + + Ok(line) +} + +/// Delete a line (its translations cascade) and re-pack remaining positions. +pub fn delete_line(conn: &Connection, line_id: &str) -> Result<()> { + let song_id: String = conn + .query_row( + "SELECT song_id FROM lyric_lines WHERE id = ?1", + [line_id], + |row| row.get(0), + ) + .map_err(|_| Error::NotFound("Line not found".to_string()))?; + + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM lyric_lines WHERE id = ?1", [line_id])?; + reindex(&tx, &song_id)?; + tx.commit()?; + Ok(()) +} + +/// Reorder a song's lines. `ordered_ids` must be exactly the song's current +/// line ids, in the desired order. +pub fn reorder_lines( + conn: &Connection, + song_id: &str, + ordered_ids: &[String], +) -> Result> { + ensure_backfilled(conn, song_id)?; + + let existing: Vec = { + let mut stmt = conn.prepare("SELECT id FROM lyric_lines WHERE song_id = ?1")?; + let existing = stmt + .query_map([song_id], |row| row.get(0))? + .collect::>()?; + existing + }; + + let same_set = existing.len() == ordered_ids.len() + && { + let set: std::collections::HashSet<&String> = existing.iter().collect(); + ordered_ids.iter().all(|id| set.contains(id)) + }; + if !same_set { + return Err(Error::InvalidInput( + "reorder_lines: ordered_ids must match the song's lines exactly".to_string(), + )); + } + + let tx = conn.unchecked_transaction()?; + // Pass 1: park at negatives. + for (i, id) in ordered_ids.iter().enumerate() { + tx.execute( + "UPDATE lyric_lines SET position = ?1 WHERE id = ?2 AND song_id = ?3", + rusqlite::params![-(i as i64) - 1, id, song_id], + )?; + } + // Pass 2: final positions. + let ts = now(); + for (i, id) in ordered_ids.iter().enumerate() { + tx.execute( + "UPDATE lyric_lines SET position = ?1, updated_at = ?2 WHERE id = ?3 AND song_id = ?4", + rusqlite::params![i as i64, ts, id, song_id], + )?; + } + tx.commit()?; + + get_song_lines(conn, song_id) +} + +// ─── translations ───────────────────────────────────────────────────────────── + +/// Set (insert or replace) the translation of a line into one language. +pub fn set_line_translation( + conn: &Connection, + line_id: &str, + lang: &str, + text: &str, + phonetic: Option<&str>, +) -> Result<()> { + // Ensure the line exists so we return NotFound rather than an FK failure. + get_line(conn, line_id)?; + + conn.execute( + "INSERT INTO lyric_line_translations (line_id, lang, text, phonetic) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(line_id, lang) DO UPDATE SET text = excluded.text, phonetic = excluded.phonetic", + rusqlite::params![line_id, lang, text, phonetic], + )?; + Ok(()) +} + +/// Remove a line's translation for one language. No error if absent. +pub fn remove_line_translation(conn: &Connection, line_id: &str, lang: &str) -> Result<()> { + conn.execute( + "DELETE FROM lyric_line_translations WHERE line_id = ?1 AND lang = ?2", + rusqlite::params![line_id, lang], + )?; + Ok(()) +} + +// ─── notes ──────────────────────────────────────────────────────────────────── + +fn row_to_note(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SongNote { + id: row.get(0)?, + song_id: row.get(1)?, + position: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) +} + +const NOTE_SELECT: &str = + "SELECT id, song_id, position, content, created_at, updated_at FROM song_notes"; + +/// Get a song's notes, ordered by position. +pub fn get_song_notes(conn: &Connection, song_id: &str) -> Result> { + let query = format!("{} WHERE song_id = ?1 ORDER BY position", NOTE_SELECT); + let mut stmt = conn.prepare(&query)?; + let notes = stmt + .query_map([song_id], row_to_note)? + .collect::>()?; + Ok(notes) +} + +/// Append a note to a song. +pub fn create_note(conn: &Connection, song_id: &str, content: &str) -> Result { + // Guard against a dangling song_id (FK is only enforced when PRAGMA is on). + let exists: bool = conn + .query_row("SELECT 1 FROM songs WHERE id = ?1", [song_id], |_| Ok(true)) + .unwrap_or(false); + if !exists { + return Err(Error::NotFound("Song not found".to_string())); + } + + let position: i64 = { + let max: Option = conn.query_row( + "SELECT MAX(position) FROM song_notes WHERE song_id = ?1", + [song_id], + |row| row.get(0), + )?; + max.map(|m| m + 1).unwrap_or(0) + }; + + let id = new_id(); + let ts = now(); + conn.execute( + "INSERT INTO song_notes (id, song_id, position, content, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5)", + rusqlite::params![id, song_id, position, content, ts], + )?; + + Ok(SongNote { + id, + song_id: song_id.to_string(), + position, + content: content.to_string(), + created_at: ts.clone(), + updated_at: ts, + }) +} + +/// Update a note's content. +pub fn update_note(conn: &Connection, note_id: &str, content: &str) -> Result { + let ts = now(); + let affected = conn.execute( + "UPDATE song_notes SET content = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![content, ts, note_id], + )?; + if affected == 0 { + return Err(Error::NotFound("Note not found".to_string())); + } + + let query = format!("{} WHERE id = ?1", NOTE_SELECT); + let note = conn + .prepare(&query)? + .query_row([note_id], row_to_note) + .map_err(|_| Error::NotFound("Note not found".to_string()))?; + Ok(note) +} + +/// Delete a note. +pub fn delete_note(conn: &Connection, note_id: &str) -> Result<()> { + let affected = conn.execute("DELETE FROM song_notes WHERE id = ?1", [note_id])?; + if affected == 0 { + return Err(Error::NotFound("Note not found".to_string())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::init_database; + use crate::models::CreateSongData; + use crate::services::songs; + use tempfile::NamedTempFile; + + fn setup() -> (NamedTempFile, Connection) { + let tmp = NamedTempFile::new().unwrap(); + let conn = init_database(tmp.path()).unwrap(); + (tmp, conn) + } + + /// Create a song with parallel-array data (bypassing translation/phonetic + /// network calls by using English + no translation). + fn make_song(conn: &Connection, lyrics: Vec<&str>) -> String { + let data = CreateSongData { + title: "T".into(), + artist: "A".into(), + language: "en".into(), + lyrics: lyrics.into_iter().map(String::from).collect(), + web_url: None, + synced_lyrics: None, + singers: None, + line_singers: None, + }; + songs::create_song(conn, data).unwrap().id + } + + #[test] + fn backfill_creates_one_line_per_lyric() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["one", "two", "three"]); + + let lines = get_song_lines(&conn, &song_id).unwrap(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].position, 0); + assert_eq!(lines[0].content.as_deref(), Some("one")); + assert_eq!(lines[2].content.as_deref(), Some("three")); + assert!(lines.iter().all(|l| l.kind == LineKind::Lyric)); + } + + #[test] + fn backfill_is_idempotent_and_sets_flag() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["a", "b"]); + + let first = get_song_lines(&conn, &song_id).unwrap(); + let second = get_song_lines(&conn, &song_id).unwrap(); + assert_eq!(first.len(), 2); + assert_eq!(second.len(), 2); + // Same ids across reads → not re-created. + assert_eq!(first[0].id, second[0].id); + + let migrated: i64 = conn + .query_row( + "SELECT lines_migrated FROM songs WHERE id = ?1", + [&song_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(migrated, 1); + } + + #[test] + fn deleted_lines_are_not_resurrected() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["x", "y"]); + + let lines = get_song_lines(&conn, &song_id).unwrap(); + delete_line(&conn, &lines[0].id).unwrap(); + + let after = get_song_lines(&conn, &song_id).unwrap(); + assert_eq!(after.len(), 1, "deleted line must not be re-backfilled"); + assert_eq!(after[0].content.as_deref(), Some("y")); + assert_eq!(after[0].position, 0, "positions must be re-packed after delete"); + } + + #[test] + fn create_line_appends() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["a"]); + + let line = create_line( + &conn, + &song_id, + CreateLineData { + kind: LineKind::Silence, + content: None, + phonetic: None, + singers: None, + }, + ) + .unwrap(); + + assert_eq!(line.position, 1); + assert_eq!(line.kind, LineKind::Silence); + assert!(line.content.is_none()); + + let lines = get_song_lines(&conn, &song_id).unwrap(); + assert_eq!(lines.len(), 2); + } + + #[test] + fn update_line_edits_fields() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["orig"]); + let id = get_song_lines(&conn, &song_id).unwrap()[0].id.clone(); + + let updated = update_line( + &conn, + &id, + UpdateLineData { + content: Some("edited".into()), + singers: Some(vec!["Lisa".into(), "Rosé".into()]), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(updated.content.as_deref(), Some("edited")); + assert_eq!(updated.singers, Some(vec!["Lisa".into(), "Rosé".into()])); + + // Persisted. + let reloaded = get_line(&conn, &id).unwrap(); + assert_eq!(reloaded.content.as_deref(), Some("edited")); + assert_eq!(reloaded.singers, Some(vec!["Lisa".into(), "Rosé".into()])); + } + + #[test] + fn reorder_reverses_positions() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["a", "b", "c"]); + let ids: Vec = get_song_lines(&conn, &song_id) + .unwrap() + .into_iter() + .map(|l| l.id) + .collect(); + + let reversed: Vec = ids.iter().rev().cloned().collect(); + let lines = reorder_lines(&conn, &song_id, &reversed).unwrap(); + + assert_eq!(lines[0].content.as_deref(), Some("c")); + assert_eq!(lines[1].content.as_deref(), Some("b")); + assert_eq!(lines[2].content.as_deref(), Some("a")); + assert_eq!(lines[0].position, 0); + assert_eq!(lines[2].position, 2); + } + + #[test] + fn reorder_rejects_wrong_id_set() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["a", "b"]); + let result = reorder_lines(&conn, &song_id, &["bogus".to_string()]); + assert!(result.is_err()); + } + + #[test] + fn translations_set_get_remove() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["hello"]); + let id = get_song_lines(&conn, &song_id).unwrap()[0].id.clone(); + + set_line_translation(&conn, &id, "fr", "bonjour", Some("bɔ̃ʒuʁ")).unwrap(); + set_line_translation(&conn, &id, "jp", "こんにちは", None).unwrap(); + + let line = get_line(&conn, &id).unwrap(); + assert_eq!(line.translations.len(), 2); + assert_eq!(line.translations["fr"].text, "bonjour"); + assert_eq!(line.translations["fr"].phonetic.as_deref(), Some("bɔ̃ʒuʁ")); + assert_eq!(line.translations["jp"].phonetic, None); + + // Upsert overwrites. + set_line_translation(&conn, &id, "fr", "salut", None).unwrap(); + let line = get_line(&conn, &id).unwrap(); + assert_eq!(line.translations["fr"].text, "salut"); + assert_eq!(line.translations["fr"].phonetic, None); + + remove_line_translation(&conn, &id, "fr").unwrap(); + let line = get_line(&conn, &id).unwrap(); + assert!(!line.translations.contains_key("fr")); + assert!(line.translations.contains_key("jp")); + } + + #[test] + fn backfill_carries_translations_and_singers() { + let (_t, conn) = setup(); + + // Build a song directly with parallel arrays including translations. + let data = CreateSongData { + title: "Duet".into(), + artist: "A".into(), + language: "en".into(), + lyrics: vec!["l1".into(), "l2".into()], + web_url: None, + synced_lyrics: None, + singers: Some(vec!["Lisa".into(), "JK".into()]), + line_singers: Some(vec![vec!["Lisa".into()], vec!["Lisa".into(), "JK".into()]]), + }; + let song = songs::create_song(&conn, data).unwrap(); + + // Attach translations via the legacy update path, then read as lines. + let mut translations = HashMap::new(); + translations.insert("fr".to_string(), vec!["ligne1".to_string(), "ligne2".to_string()]); + songs::update_song( + &conn, + &song.id, + crate::models::UpdateSongData { + translations: Some(translations), + ..Default::default() + }, + ) + .unwrap(); + + let lines = get_song_lines(&conn, &song.id).unwrap(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].singers, Some(vec!["Lisa".into()])); + assert_eq!(lines[1].singers, Some(vec!["Lisa".into(), "JK".into()])); + assert_eq!(lines[0].translations["fr"].text, "ligne1"); + assert_eq!(lines[1].translations["fr"].text, "ligne2"); + } + + #[test] + fn notes_crud() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["a"]); + + let n1 = create_note(&conn, &song_id, "First anecdote").unwrap(); + let n2 = create_note(&conn, &song_id, "Meaning summary").unwrap(); + assert_eq!(n1.position, 0); + assert_eq!(n2.position, 1); + + let notes = get_song_notes(&conn, &song_id).unwrap(); + assert_eq!(notes.len(), 2); + + let updated = update_note(&conn, &n1.id, "Edited anecdote").unwrap(); + assert_eq!(updated.content, "Edited anecdote"); + + delete_note(&conn, &n1.id).unwrap(); + let notes = get_song_notes(&conn, &song_id).unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].id, n2.id); + } + + #[test] + fn create_note_rejects_unknown_song() { + let (_t, conn) = setup(); + let result = create_note(&conn, "no-such-song", "x"); + assert!(result.is_err()); + } + + #[test] + fn get_lines_unknown_song_errors() { + let (_t, conn) = setup(); + assert!(get_song_lines(&conn, "nope").is_err()); + } +} diff --git a/rust-backend/src/services/mod.rs b/rust-backend/src/services/mod.rs index 25ded29..47564ca 100644 --- a/rust-backend/src/services/mod.rs +++ b/rust-backend/src/services/mod.rs @@ -4,4 +4,5 @@ pub mod phonetic; pub mod translation; pub mod auth; pub mod songs; +pub mod lyric_lines; pub mod practice; From 1e20a1cb2c34d01db567f3ad40de146dc252dc11 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 23:38:29 +0000 Subject: [PATCH 2/5] ci: fix frontend lockfile + gate stub-only phonetic tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend (CI Frontend job was failing at `npm ci`): - Regenerate package-lock.json — it predated vitest and most devDependencies, so `npm ci` aborted on missing lockfile entries. - Add the `test:unit` script the workflow invokes (alias of `vitest run`); it was referenced by ci-frontend.yml but absent from package.json. Backend (CI Rust `cargo test` step): - Gate `test_generate_phonetic_returns_original_in_stub` and `test_create_song_supported_language_populates_phonetic_lyrics` behind `#[cfg(not(feature = "python"))]`. Both assert the stub behaviour (original text echoed); under the default `python` feature the backend romanizes (or errors when the Python packages are absent), so they only hold for the no-python build. The python path stays covered by the `#[ignore]`d tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ReeGwy3JKhaXxkxCWYYQsG --- lyremember-app/package-lock.json | 2577 ++++++++++++++++++++++++- lyremember-app/package.json | 1 + rust-backend/src/services/phonetic.rs | 5 + rust-backend/src/services/songs.rs | 4 + 4 files changed, 2544 insertions(+), 43 deletions(-) diff --git a/lyremember-app/package-lock.json b/lyremember-app/package-lock.json index 5f29c88..0a7e664 100644 --- a/lyremember-app/package-lock.json +++ b/lyremember-app/package-lock.json @@ -19,16 +19,25 @@ "devDependencies": { "@crabnebula/tauri-driver": "^2.0.9", "@tauri-apps/cli": "^2", + "@typescript-eslint/eslint-plugin": "^8.60.0", + "@typescript-eslint/parser": "^8.60.0", "@vitejs/plugin-vue": "^5.2.1", + "@vitest/coverage-v8": "^4.1.7", + "@vue/test-utils": "^2.4.10", "@wdio/cli": "^9.27.0", "@wdio/local-runner": "^9.27.0", "@wdio/mocha-framework": "^9.27.0", "@wdio/spec-reporter": "^9.27.0", "autoprefixer": "^10.4.24", + "eslint": "^10.4.0", + "eslint-plugin-vue": "^10.9.1", + "happy-dom": "^20.9.0", + "jsdom": "^25.0.1", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", "typescript": "~5.6.2", "vite": "^6.0.3", + "vitest": "^4.1.7", "vue-tsc": "^2.2.12", "webdriverio": "^9.27.0" } @@ -46,6 +55,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -62,30 +85,30 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -95,18 +118,28 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@crabnebula/tauri-driver": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/@crabnebula/tauri-driver/-/tauri-driver-2.0.9.tgz", @@ -194,6 +227,121 @@ "node": ">= 10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -636,6 +784,205 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -1280,6 +1627,13 @@ "node": ">= 8" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1710,6 +2064,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", @@ -1953,6 +2314,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1987,6 +2373,13 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", @@ -2025,6 +2418,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -2070,33 +2470,526 @@ "@types/node": "*" } }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/runner/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/runner/node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@vitest/snapshot": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", @@ -2112,6 +3005,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/utils": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", @@ -2348,6 +3251,27 @@ "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, "node_modules/@wdio/cli": { "version": "9.27.0", "resolved": "https://registry.npmjs.org/@wdio/cli/-/cli-9.27.0.tgz", @@ -2688,6 +3612,16 @@ "node": ">=18.0.0" } }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2701,6 +3635,29 @@ "node": ">=6.5" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2711,6 +3668,30 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/alien-signals": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", @@ -2860,6 +3841,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -2873,6 +3864,35 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2890,6 +3910,13 @@ "node": ">=0.12.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.24", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", @@ -3215,6 +4242,33 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -3259,6 +4313,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -3512,6 +4576,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3559,6 +4636,17 @@ "dev": true, "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3724,6 +4812,27 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3740,6 +4849,20 @@ "node": ">= 14" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -3778,6 +4901,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3788,6 +4918,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -3827,6 +4964,16 @@ "node": ">= 14" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3936,6 +5083,21 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4014,13 +5176,42 @@ "dev": true, "license": "ISC", "dependencies": { - "isexe": "^4.0.0" + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" }, "bin": { - "node-which": "bin/which.js" + "editorconfig": "bin/editorconfig" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" } }, "node_modules/ejs": { @@ -4112,6 +5303,62 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4199,6 +5446,243 @@ "source-map": "~0.6.1" } }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.10.0.tgz", + "integrity": "sha512-dL9x9rBHqqNcByWiLOHK6L0SB97V82/NC0cZRn9cXPjM7pCuWlpQQP9bFH4vjBv80ej1ZpzAkuD8zWH1o9bZbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.4", + "semver": "^7.8.5", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -4213,6 +5697,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -4327,6 +5837,16 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expect-webdriverio": { "version": "5.6.5", "resolved": "https://registry.npmjs.org/expect-webdriverio/-/expect-webdriverio-5.6.5.tgz", @@ -4486,6 +6006,20 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-xml-builder": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", @@ -4577,6 +6111,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -4640,6 +6187,27 @@ "flat": "cli.js" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4657,6 +6225,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4735,17 +6320,56 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-port": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=16" + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 0.4" } }, "node_modules/get-stream": { @@ -4828,6 +6452,19 @@ "node": ">=10.13.0" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4842,6 +6479,35 @@ "dev": true, "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4852,10 +6518,39 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -4894,6 +6589,26 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlfy": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/htmlfy/-/htmlfy-0.8.1.tgz", @@ -4997,6 +6712,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -5015,6 +6740,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5034,6 +6769,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/inquirer": { "version": "12.11.1", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.11.1.tgz", @@ -5163,6 +6905,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -5215,6 +6964,58 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -5539,6 +7340,35 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5559,6 +7389,54 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", @@ -5569,6 +7447,20 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -5615,6 +7507,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -5661,6 +7563,20 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -5906,6 +7822,44 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5943,6 +7897,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -6219,6 +8196,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -6236,6 +8220,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-package-data": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.1.tgz", @@ -6304,6 +8304,13 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6337,6 +8344,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6347,6 +8368,24 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6845,6 +8884,16 @@ "dev": true, "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", @@ -6903,6 +8952,13 @@ "node": ">=0.4.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -6951,6 +9007,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/query-selector-shadow-dom": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", @@ -7392,6 +9458,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-async": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", @@ -7497,10 +9570,23 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -7566,6 +9652,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7745,6 +9838,20 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", @@ -7977,6 +10084,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -8086,6 +10200,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -8113,6 +10244,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8126,6 +10277,45 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -8644,6 +10834,19 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -8732,6 +10935,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/urlpattern-polyfill": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", @@ -8842,6 +11055,157 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -8870,6 +11234,52 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.8.tgz", + "integrity": "sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", + "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/vue-i18n": { "version": "9.14.5", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz", @@ -8935,6 +11345,19 @@ "typescript": ">=5.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wait-port": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", @@ -9088,6 +11511,16 @@ } } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -9125,6 +11558,20 @@ "node": ">=18" } }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9141,6 +11588,33 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/workerpool": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", @@ -9292,9 +11766,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { @@ -9313,6 +11787,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/lyremember-app/package.json b/lyremember-app/package.json index e090946..4c99f58 100644 --- a/lyremember-app/package.json +++ b/lyremember-app/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "tauri": "tauri", "test": "vitest run", + "test:unit": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "wdio run wdio.conf.ts", diff --git a/rust-backend/src/services/phonetic.rs b/rust-backend/src/services/phonetic.rs index 9ab5145..37df6e5 100644 --- a/rust-backend/src/services/phonetic.rs +++ b/rust-backend/src/services/phonetic.rs @@ -183,7 +183,12 @@ mod tests { } } + // Stub-only: asserts the no-python behaviour (original text returned). Under + // the `python` feature this path romanizes instead, so the test is gated to + // the stub build. The python-backed path is covered by the `#[ignore]`d + // tests below. #[test] + #[cfg(not(feature = "python"))] fn test_generate_phonetic_returns_original_in_stub() { // In the stub (no python feature), all languages return original text unchanged. let text = vec!["こんにちは".to_string(), "안녕하세요".to_string()]; diff --git a/rust-backend/src/services/songs.rs b/rust-backend/src/services/songs.rs index 1c41ba8..3d49f5b 100644 --- a/rust-backend/src/services/songs.rs +++ b/rust-backend/src/services/songs.rs @@ -893,7 +893,11 @@ mod tests { // ── create_song phonetic generation ────────────────────────────────────── + // Stub-only: with the `python` feature the phonetic backend romanizes (or + // errors when the Python packages are absent) rather than echoing the input, + // so this assertion only holds for the no-python stub build. #[test] + #[cfg(not(feature = "python"))] fn test_create_song_supported_language_populates_phonetic_lyrics() { let (_tmp, conn) = setup_db(); From 281c4b54bf4dfadfb00909ffff692a0aa48f77fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 23:47:05 +0000 Subject: [PATCH 3/5] feat(frontend): line-centric store + wire SongDetailView to lines/notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (partial): make the line model the source of truth on the read path, without changing behaviour for existing (backfilled) songs. - useLinesStore (stores/lines.ts): fetch (TTL cache) + full mutations for lines, translations and notes over the cmd_*_line / cmd_*_note commands. 12 vitest specs. - projectLinesToSong (lib/line-projection.ts): pure, tested bridge that renders a song's lines back into the legacy parallel-array Song shape the practice modes still expect — silences excluded, line edits reflected. 8 vitest specs. - SongDetailView: fetch lines + notes on mount; feed the practice modes (Karaoke/FillBlank/Mcq/Oral) the projected song so their data now flows from the line store; render a Notes section. The mode components are left untouched. Remaining tail (not in this commit): migrate the standalone practice views (PracticeOralView/QuizView/…, separate inline implementations) to the line store, then drop the redundant songs parallel columns once every consumer reads lines. Both need runtime QA of the desktop app. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ReeGwy3JKhaXxkxCWYYQsG --- .../src/lib/line-projection.spec.ts | 96 ++++++++ lyremember-app/src/lib/line-projection.ts | 37 +++ .../src/stores/__tests__/lines.spec.ts | 210 ++++++++++++++++++ lyremember-app/src/stores/lines.ts | 200 +++++++++++++++++ lyremember-app/src/views/SongDetailView.vue | 56 ++++- 5 files changed, 591 insertions(+), 8 deletions(-) create mode 100644 lyremember-app/src/lib/line-projection.spec.ts create mode 100644 lyremember-app/src/lib/line-projection.ts create mode 100644 lyremember-app/src/stores/__tests__/lines.spec.ts create mode 100644 lyremember-app/src/stores/lines.ts diff --git a/lyremember-app/src/lib/line-projection.spec.ts b/lyremember-app/src/lib/line-projection.spec.ts new file mode 100644 index 0000000..6b4ee2a --- /dev/null +++ b/lyremember-app/src/lib/line-projection.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { projectLinesToSong } from './line-projection'; +import type { LyricLine, Song } from '../types'; + +const baseSong: Song = { + id: 'song-1', + title: 'T', + artist: 'A', + language: 'fr', + lyrics: ['stale'], + phonetic_lyrics: null, + translations: null, + synced_lyrics: null, + singers: null, + line_singers: null, + web_url: null, + genius_url: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; + +function line(p: Partial & { id: string; position: number }): LyricLine { + return { + song_id: 'song-1', + kind: 'lyric', + content: `c${p.position}`, + phonetic: null, + singers: null, + translations: {}, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...p, + }; +} + +describe('projectLinesToSong', () => { + it('maps sung lines to the lyrics array in order', () => { + const lines = [line({ id: 'a', position: 0, content: 'un' }), line({ id: 'b', position: 1, content: 'deux' })]; + const s = projectLinesToSong(baseSong, lines); + expect(s.lyrics).toEqual(['un', 'deux']); + }); + + it('excludes silences from the sung arrays', () => { + const lines = [ + line({ id: 'a', position: 0, content: 'un' }), + line({ id: 's', position: 1, kind: 'silence', content: null }), + line({ id: 'b', position: 2, content: 'deux' }), + ]; + const s = projectLinesToSong(baseSong, lines); + expect(s.lyrics).toEqual(['un', 'deux']); + }); + + it('leaves phonetic_lyrics null when no line has phonetic', () => { + const lines = [line({ id: 'a', position: 0 })]; + expect(projectLinesToSong(baseSong, lines).phonetic_lyrics).toBeNull(); + }); + + it('builds phonetic_lyrics when at least one line has phonetic', () => { + const lines = [ + line({ id: 'a', position: 0, phonetic: 'œ̃' }), + line({ id: 'b', position: 1, phonetic: null }), + ]; + expect(projectLinesToSong(baseSong, lines).phonetic_lyrics).toEqual(['œ̃', '']); + }); + + it('gathers translations per language across lines', () => { + const lines = [ + line({ id: 'a', position: 0, translations: { en: { text: 'one', phonetic: null } } }), + line({ id: 'b', position: 1, translations: { en: { text: 'two', phonetic: null }, es: { text: 'dos', phonetic: null } } }), + ]; + const s = projectLinesToSong(baseSong, lines); + expect(s.translations).toEqual({ en: ['one', 'two'], es: ['', 'dos'] }); + }); + + it('leaves translations null when there are none', () => { + expect(projectLinesToSong(baseSong, [line({ id: 'a', position: 0 })]).translations).toBeNull(); + }); + + it('builds line_singers only when a line has singers', () => { + const none = projectLinesToSong(baseSong, [line({ id: 'a', position: 0 })]); + expect(none.line_singers).toBeNull(); + + const withSingers = projectLinesToSong(baseSong, [ + line({ id: 'a', position: 0, singers: ['Lisa'] }), + line({ id: 'b', position: 1, singers: null }), + ]); + expect(withSingers.line_singers).toEqual([['Lisa'], []]); + }); + + it('preserves song metadata (id, title, language)', () => { + const s = projectLinesToSong(baseSong, [line({ id: 'a', position: 0 })]); + expect(s.id).toBe('song-1'); + expect(s.title).toBe('T'); + expect(s.language).toBe('fr'); + }); +}); diff --git a/lyremember-app/src/lib/line-projection.ts b/lyremember-app/src/lib/line-projection.ts new file mode 100644 index 0000000..3919c11 --- /dev/null +++ b/lyremember-app/src/lib/line-projection.ts @@ -0,0 +1,37 @@ +import type { LyricLine, Song } from '../types'; + +/** + * Project a song's line-centric model back into the legacy parallel-array + * `Song` shape that the practice modes still consume. + * + * Silences are excluded — you don't drill a silence — so the arrays contain + * only sung lines, in order. This is the bridge that lets the line store be + * the source of truth while the practice components migrate incrementally. + * + * A field stays `null` when no line carries that kind of data, matching how + * the backend leaves absent metadata null (so "generate" affordances keep + * working off the same emptiness signal). + */ +export function projectLinesToSong(song: Song, lines: LyricLine[]): Song { + const sung = lines.filter((l) => l.kind === 'lyric'); + + const lyrics = sung.map((l) => l.content ?? ''); + + const hasPhonetic = sung.some((l) => l.phonetic != null && l.phonetic !== ''); + const phonetic_lyrics = hasPhonetic ? sung.map((l) => l.phonetic ?? '') : null; + + const langs = new Set(); + sung.forEach((l) => Object.keys(l.translations).forEach((lang) => langs.add(lang))); + let translations: Record | null = null; + if (langs.size > 0) { + translations = {}; + for (const lang of langs) { + translations[lang] = sung.map((l) => l.translations[lang]?.text ?? ''); + } + } + + const hasSingers = sung.some((l) => l.singers != null && l.singers.length > 0); + const line_singers = hasSingers ? sung.map((l) => l.singers ?? []) : null; + + return { ...song, lyrics, phonetic_lyrics, translations, line_singers }; +} diff --git a/lyremember-app/src/stores/__tests__/lines.spec.ts b/lyremember-app/src/stores/__tests__/lines.spec.ts new file mode 100644 index 0000000..2c60868 --- /dev/null +++ b/lyremember-app/src/stores/__tests__/lines.spec.ts @@ -0,0 +1,210 @@ +import { setActivePinia, createPinia } from 'pinia'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { useLinesStore } from '../lines'; +import type { LyricLine, SongNote } from '../../types'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +import { invoke } from '@tauri-apps/api/core'; +const mockInvoke = vi.mocked(invoke); + +function line(partial: Partial & { id: string; position: number }): LyricLine { + return { + song_id: 'song-1', + kind: 'lyric', + content: `line ${partial.position}`, + phonetic: null, + singers: null, + translations: {}, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...partial, + }; +} + +const L0 = line({ id: 'l0', position: 0, content: 'first' }); +const L1 = line({ id: 'l1', position: 1, kind: 'silence', content: null }); +const L2 = line({ id: 'l2', position: 2, content: 'second' }); + +describe('linesStore', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + describe('fetchLines + caching', () => { + it('fetches and caches lines', async () => { + mockInvoke.mockResolvedValueOnce([L0, L1, L2]); + const store = useLinesStore(); + + const lines = await store.fetchLines('song-1'); + expect(lines).toHaveLength(3); + expect(mockInvoke).toHaveBeenCalledWith('cmd_get_song_lines', { songId: 'song-1' }); + expect(store.linesFor('song-1')).toHaveLength(3); + }); + + it('serves from cache within TTL (no second IPC)', async () => { + mockInvoke.mockResolvedValueOnce([L0]); + const store = useLinesStore(); + + await store.fetchLines('song-1'); + await store.fetchLines('song-1'); + expect(mockInvoke).toHaveBeenCalledTimes(1); + }); + + it('force bypasses the cache', async () => { + mockInvoke.mockResolvedValue([L0]); + const store = useLinesStore(); + + await store.fetchLines('song-1'); + await store.fetchLines('song-1', true); + expect(mockInvoke).toHaveBeenCalledTimes(2); + }); + + it('records the error message on failure', async () => { + mockInvoke.mockRejectedValueOnce(new Error('boom')); + const store = useLinesStore(); + + await expect(store.fetchLines('song-1')).rejects.toThrow('boom'); + expect(store.error).toBe('boom'); + }); + }); + + describe('lyricLinesFor', () => { + it('excludes silences', async () => { + mockInvoke.mockResolvedValueOnce([L0, L1, L2]); + const store = useLinesStore(); + await store.fetchLines('song-1'); + + const sung = store.lyricLinesFor('song-1'); + expect(sung.map((l) => l.id)).toEqual(['l0', 'l2']); + }); + }); + + describe('createLine', () => { + it('appends the created line to the cache', async () => { + mockInvoke.mockResolvedValueOnce([L0]); // fetch + const store = useLinesStore(); + await store.fetchLines('song-1'); + + const created = line({ id: 'l9', position: 1, kind: 'silence', content: null }); + mockInvoke.mockResolvedValueOnce(created); // createLine + const result = await store.createLine('song-1', 'silence', { content: null }); + + expect(result.id).toBe('l9'); + expect(store.linesFor('song-1').map((l) => l.id)).toEqual(['l0', 'l9']); + expect(mockInvoke).toHaveBeenLastCalledWith('cmd_create_line', { + songId: 'song-1', + kind: 'silence', + content: null, + phonetic: null, + singers: null, + }); + }); + }); + + describe('updateLine', () => { + it('replaces the line in place', async () => { + mockInvoke.mockResolvedValueOnce([L0, L2]); + const store = useLinesStore(); + await store.fetchLines('song-1'); + + const edited = { ...L0, content: 'edited' }; + mockInvoke.mockResolvedValueOnce(edited); + await store.updateLine('song-1', 'l0', { content: 'edited' }); + + expect(store.linesFor('song-1').find((l) => l.id === 'l0')?.content).toBe('edited'); + }); + }); + + describe('deleteLine', () => { + it('refetches after delete (positions re-packed server-side)', async () => { + mockInvoke.mockResolvedValueOnce([L0, L2]); // initial fetch + const store = useLinesStore(); + await store.fetchLines('song-1'); + + mockInvoke.mockResolvedValueOnce(undefined); // deleteLine + mockInvoke.mockResolvedValueOnce([line({ id: 'l2', position: 0, content: 'second' })]); // refetch + await store.deleteLine('song-1', 'l0'); + + expect(store.linesFor('song-1').map((l) => l.id)).toEqual(['l2']); + expect(store.linesFor('song-1')[0].position).toBe(0); + }); + }); + + describe('reorderLines', () => { + it('replaces the cache with the server-returned order', async () => { + mockInvoke.mockResolvedValueOnce([L0, L2]); + const store = useLinesStore(); + await store.fetchLines('song-1'); + + const reordered = [ + line({ id: 'l2', position: 0, content: 'second' }), + line({ id: 'l0', position: 1, content: 'first' }), + ]; + mockInvoke.mockResolvedValueOnce(reordered); + await store.reorderLines('song-1', ['l2', 'l0']); + + expect(store.linesFor('song-1').map((l) => l.id)).toEqual(['l2', 'l0']); + }); + }); + + describe('translations', () => { + it('setTranslation updates the cached line', async () => { + mockInvoke.mockResolvedValueOnce([L0]); + const store = useLinesStore(); + await store.fetchLines('song-1'); + + mockInvoke.mockResolvedValueOnce(undefined); + await store.setTranslation('song-1', 'l0', 'en', 'FIRST', 'fɜːst'); + + const l = store.linesFor('song-1')[0]; + expect(l.translations.en).toEqual({ text: 'FIRST', phonetic: 'fɜːst' }); + }); + + it('removeTranslation drops the language', async () => { + const withTr = line({ id: 'l0', position: 0, translations: { en: { text: 'x', phonetic: null } } }); + mockInvoke.mockResolvedValueOnce([withTr]); + const store = useLinesStore(); + await store.fetchLines('song-1'); + + mockInvoke.mockResolvedValueOnce(undefined); + await store.removeTranslation('song-1', 'l0', 'en'); + + expect(store.linesFor('song-1')[0].translations.en).toBeUndefined(); + }); + }); + + describe('notes', () => { + const note = (id: string, position: number, content: string): SongNote => ({ + id, + song_id: 'song-1', + position, + content, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }); + + it('fetch, create, update, delete', async () => { + const store = useLinesStore(); + + mockInvoke.mockResolvedValueOnce([note('n0', 0, 'first note')]); + await store.fetchNotes('song-1'); + expect(store.notesFor('song-1')).toHaveLength(1); + + mockInvoke.mockResolvedValueOnce(note('n1', 1, 'second note')); + await store.createNote('song-1', 'second note'); + expect(store.notesFor('song-1').map((n) => n.id)).toEqual(['n0', 'n1']); + + mockInvoke.mockResolvedValueOnce(note('n0', 0, 'edited')); + await store.updateNote('song-1', 'n0', 'edited'); + expect(store.notesFor('song-1').find((n) => n.id === 'n0')?.content).toBe('edited'); + + mockInvoke.mockResolvedValueOnce(undefined); + await store.deleteNote('song-1', 'n0'); + expect(store.notesFor('song-1').map((n) => n.id)).toEqual(['n1']); + }); + }); +}); diff --git a/lyremember-app/src/stores/lines.ts b/lyremember-app/src/stores/lines.ts new file mode 100644 index 0000000..3569bbd --- /dev/null +++ b/lyremember-app/src/stores/lines.ts @@ -0,0 +1,200 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import * as api from '../lib/tauri-api'; +import type { LineKind, LyricLine, SongNote } from '../types'; + +/** + * Line-centric store: a song's ordered lyric lines (with per-language + * translations and singers) and its free-form notes. This is the source of + * truth for the sequence — the legacy `Song.lyrics`/`phonetic_lyrics`/ + * `translations` parallel arrays are derived/legacy and being phased out. + * + * Backed by the `cmd_*_line` / `cmd_*_note` Tauri commands; the backend + * backfills a song's lines from the legacy arrays on first read. + */ +export const useLinesStore = defineStore('lines', () => { + // Keyed by songId. + const linesBySong = ref>({}); + const notesBySong = ref>({}); + const fetchedAt = ref>({}); + const notesFetchedAt = ref>({}); + const loading = ref(false); + const error = ref(null); + + const TTL_MS = 5 * 60 * 1000; + + function linesFor(songId: string): LyricLine[] { + return linesBySong.value[songId] ?? []; + } + + /** Only the sung lines (silences excluded) — what practice modes drill. */ + function lyricLinesFor(songId: string): LyricLine[] { + return linesFor(songId).filter((l) => l.kind === 'lyric'); + } + + function notesFor(songId: string): SongNote[] { + return notesBySong.value[songId] ?? []; + } + + async function fetchLines(songId: string, force = false): Promise { + const cached = linesBySong.value[songId]; + const at = fetchedAt.value[songId]; + if (!force && cached && at && Date.now() - at < TTL_MS) { + return cached; + } + + loading.value = true; + error.value = null; + try { + const lines = await api.getSongLines(songId); + linesBySong.value[songId] = lines; + fetchedAt.value[songId] = Date.now(); + return lines; + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to fetch lines'; + throw err; + } finally { + loading.value = false; + } + } + + async function fetchNotes(songId: string, force = false): Promise { + const cached = notesBySong.value[songId]; + const at = notesFetchedAt.value[songId]; + if (!force && cached && at && Date.now() - at < TTL_MS) { + return cached; + } + + error.value = null; + try { + const notes = await api.getSongNotes(songId); + notesBySong.value[songId] = notes; + notesFetchedAt.value[songId] = Date.now(); + return notes; + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to fetch notes'; + throw err; + } + } + + async function createLine( + songId: string, + kind: LineKind, + options?: { content?: string | null; phonetic?: string | null; singers?: string[] | null } + ): Promise { + const line = await api.createLine(songId, kind, options); + const list = linesBySong.value[songId] ?? []; + linesBySong.value[songId] = [...list, line]; + return line; + } + + async function updateLine( + songId: string, + lineId: string, + updates: { kind?: LineKind; content?: string; phonetic?: string; singers?: string[] } + ): Promise { + const line = await api.updateLine(lineId, updates); + replaceLine(songId, line); + return line; + } + + async function deleteLine(songId: string, lineId: string): Promise { + await api.deleteLine(lineId); + // Positions are re-packed server-side; refetch to stay consistent. + await fetchLines(songId, true); + } + + async function reorderLines(songId: string, orderedIds: string[]): Promise { + const lines = await api.reorderLines(songId, orderedIds); + linesBySong.value[songId] = lines; + fetchedAt.value[songId] = Date.now(); + return lines; + } + + async function setTranslation( + songId: string, + lineId: string, + lang: string, + text: string, + phonetic?: string | null + ): Promise { + await api.setLineTranslation(lineId, lang, text, phonetic); + const line = linesBySong.value[songId]?.find((l) => l.id === lineId); + if (line) { + line.translations = { ...line.translations, [lang]: { text, phonetic: phonetic ?? null } }; + } + } + + async function removeTranslation(songId: string, lineId: string, lang: string): Promise { + await api.removeLineTranslation(lineId, lang); + const line = linesBySong.value[songId]?.find((l) => l.id === lineId); + if (line) { + const next = { ...line.translations }; + delete next[lang]; + line.translations = next; + } + } + + async function createNote(songId: string, content: string): Promise { + const note = await api.createNote(songId, content); + const list = notesBySong.value[songId] ?? []; + notesBySong.value[songId] = [...list, note]; + return note; + } + + async function updateNote(songId: string, noteId: string, content: string): Promise { + const note = await api.updateNote(noteId, content); + const list = notesBySong.value[songId] ?? []; + notesBySong.value[songId] = list.map((n) => (n.id === noteId ? note : n)); + return note; + } + + async function deleteNote(songId: string, noteId: string): Promise { + await api.deleteNote(noteId); + const list = notesBySong.value[songId] ?? []; + notesBySong.value[songId] = list.filter((n) => n.id !== noteId); + } + + function replaceLine(songId: string, line: LyricLine) { + const list = linesBySong.value[songId]; + if (!list) return; + linesBySong.value[songId] = list.map((l) => (l.id === line.id ? line : l)); + } + + function invalidate(songId: string) { + delete linesBySong.value[songId]; + delete fetchedAt.value[songId]; + delete notesBySong.value[songId]; + delete notesFetchedAt.value[songId]; + } + + function clearError() { + error.value = null; + } + + return { + // State + linesBySong, + notesBySong, + loading, + error, + // Getters + linesFor, + lyricLinesFor, + notesFor, + // Actions + fetchLines, + fetchNotes, + createLine, + updateLine, + deleteLine, + reorderLines, + setTranslation, + removeTranslation, + createNote, + updateNote, + deleteNote, + invalidate, + clearError, + }; +}); diff --git a/lyremember-app/src/views/SongDetailView.vue b/lyremember-app/src/views/SongDetailView.vue index cf221d3..9fdbe8b 100644 --- a/lyremember-app/src/views/SongDetailView.vue +++ b/lyremember-app/src/views/SongDetailView.vue @@ -67,23 +67,23 @@ @@ -210,6 +210,25 @@ + +
+

+ {{ $t('songDetail.notes', 'Notes') }} +

+
+

+ {{ note.content }} +

+
+
+
@@ -291,13 +310,16 @@ import FillBlankMode from '../components/practice/FillBlankMode.vue'; import McqMode from '../components/practice/McqMode.vue'; import OralMode from '../components/practice/OralMode.vue'; import { useSongsStore } from '../stores/songs'; +import { useLinesStore } from '../stores/lines'; import { useAuthStore } from '../stores/auth'; import { createPracticeSession, generatePhonetic, translateLines } from '../lib/tauri-api'; +import { projectLinesToSong } from '../lib/line-projection'; import type { Song, PracticeMode } from '../types'; const { t } = useI18n(); const route = useRoute(); const songsStore = useSongsStore(); +const linesStore = useLinesStore(); const authStore = useAuthStore(); // ── State ──────────────────────────────────────────────────────────────────── @@ -327,6 +349,18 @@ const availableTranslationLangs = computed(() => { return Object.keys(song.value.translations); }); +// Line-centric projection fed to the practice modes: the line store is the +// source of truth for the sequence (silences excluded, line edits reflected). +// Falls back to the raw song until its lines are loaded. +const practiceSong = computed(() => { + if (!song.value) return null; + const lines = linesStore.linesFor(song.value.id); + return lines.length > 0 ? projectLinesToSong(song.value, lines) : song.value; +}); + +// Free-form notes attached to the song (anecdotes, meaning summaries…). +const songNotes = computed(() => (song.value ? linesStore.notesFor(song.value.id) : [])); + const langFlagMap: Record = { fr: '\u{1F1EB}\u{1F1F7}', en: '\u{1F1EC}\u{1F1E7}', @@ -457,6 +491,12 @@ onMounted(async () => { const songId = String(route.params.id); song.value = await songsStore.fetchSong(songId); + // Load the line-centric sequence + notes (non-blocking for the rest). + await Promise.all([ + linesStore.fetchLines(songId).catch((err) => console.error('Failed to fetch lines:', err)), + linesStore.fetchNotes(songId).catch((err) => console.error('Failed to fetch notes:', err)), + ]); + // Auto-start mode from query param (e.g. from PracticeView) const mode = route.query.mode as PracticeMode | undefined; if (mode && mode in modeTitles.value) { From eebb84a7064523e1b24e8074a2bb15796e5236bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:02:44 +0000 Subject: [PATCH 4/5] feat(frontend): standalone practice views read from the line store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the four standalone practice views (Oral/Quiz/Karaoke/FillBlanks) off `song.lyrics` and onto the line store: each now fetches the song's lines on mount and derives its `lyrics` list from `linesStore.lyricLinesFor` (silences excluded). Behaviour is unchanged for existing songs — backfilled lines reproduce the original lyric lines exactly — and silences added via the line API are now correctly skipped in practice. With this, every practice surface (both the mode components inside SongDetailView and these standalone views) sources its sequence from the line model. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ReeGwy3JKhaXxkxCWYYQsG --- lyremember-app/src/views/PracticeFillBlanksView.vue | 8 +++++++- lyremember-app/src/views/PracticeKaraokeView.vue | 8 +++++++- lyremember-app/src/views/PracticeOralView.vue | 8 +++++++- lyremember-app/src/views/PracticeQuizView.vue | 8 +++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/lyremember-app/src/views/PracticeFillBlanksView.vue b/lyremember-app/src/views/PracticeFillBlanksView.vue index 753e29e..f8c23b5 100644 --- a/lyremember-app/src/views/PracticeFillBlanksView.vue +++ b/lyremember-app/src/views/PracticeFillBlanksView.vue @@ -199,11 +199,13 @@ import Button from '../components/ui/Button.vue'; import * as tauriApi from '../lib/tauri-api'; import type { Song } from '../types'; import { useSessionsStore } from '../stores/sessions'; +import { useLinesStore } from '../stores/lines'; // ── Router ──────────────────────────────────────────────────────────────────── const route = useRoute(); const router = useRouter(); const sessionsStore = useSessionsStore(); +const linesStore = useLinesStore(); // ── Helpers ────────────────────────────────────────────────────────────────── function normalizeText(text: string): string { @@ -230,7 +232,10 @@ const inputRef = ref(null); // ── Computed ───────────────────────────────────────────────────────────────── const lyrics = computed(() => { if (!song.value) return []; - return song.value.lyrics.filter((line) => line.trim().length > 0); + return linesStore + .lyricLinesFor(song.value.id) + .map((l) => l.content ?? '') + .filter((line) => line.trim().length > 0); }); const currentLine = computed(() => lyrics.value[currentIndex.value] ?? ''); @@ -320,6 +325,7 @@ onMounted(async () => { const songId = String(route.params.id); try { song.value = await tauriApi.getSong(songId); + await linesStore.fetchLines(songId); sessionsStore.startSession(songId, 'blanks'); await nextTick(); inputRef.value?.focus(); diff --git a/lyremember-app/src/views/PracticeKaraokeView.vue b/lyremember-app/src/views/PracticeKaraokeView.vue index 32a62de..7decb84 100644 --- a/lyremember-app/src/views/PracticeKaraokeView.vue +++ b/lyremember-app/src/views/PracticeKaraokeView.vue @@ -190,11 +190,13 @@ import Button from '../components/ui/Button.vue'; import * as tauriApi from '../lib/tauri-api'; import type { Song } from '../types'; import { useSessionsStore } from '../stores/sessions'; +import { useLinesStore } from '../stores/lines'; // ── Router ──────────────────────────────────────────────────────────────────── const route = useRoute(); const router = useRouter(); const sessionsStore = useSessionsStore(); +const linesStore = useLinesStore(); // ── State ──────────────────────────────────────────────────────────────────── const song = ref(null); @@ -208,7 +210,10 @@ let timer: ReturnType | null = null; // ── Computed ───────────────────────────────────────────────────────────────── const lyrics = computed(() => { if (!song.value) return []; - return song.value.lyrics.filter((line) => line.trim().length > 0); + return linesStore + .lyricLinesFor(song.value.id) + .map((l) => l.content ?? '') + .filter((line) => line.trim().length > 0); }); const totalNonEmpty = computed(() => lyrics.value.length); @@ -296,6 +301,7 @@ onMounted(async () => { const songId = String(route.params.id); try { song.value = await tauriApi.getSong(songId); + await linesStore.fetchLines(songId); sessionsStore.startSession(songId, 'karaoke'); } catch (err) { console.error('Failed to load song:', err); diff --git a/lyremember-app/src/views/PracticeOralView.vue b/lyremember-app/src/views/PracticeOralView.vue index 35d2fce..dec8866 100644 --- a/lyremember-app/src/views/PracticeOralView.vue +++ b/lyremember-app/src/views/PracticeOralView.vue @@ -221,6 +221,7 @@ import Button from '../components/ui/Button.vue' import * as tauriApi from '../lib/tauri-api' import type { Song } from '../types' import { useSessionsStore } from '../stores/sessions' +import { useLinesStore } from '../stores/lines' import { similarity } from '../lib/similarity' // ── Language map ───────────────────────────────────────────────────────────── @@ -250,6 +251,7 @@ const sensitivityLevels = [ const route = useRoute() const router = useRouter() const sessionsStore = useSessionsStore() +const linesStore = useLinesStore() // ── State ──────────────────────────────────────────────────────────────────── const song = ref(null) @@ -270,7 +272,10 @@ const speechSupported = typeof window !== 'undefined' && // ── Computed ───────────────────────────────────────────────────────────────── const lyrics = computed(() => { if (!song.value) return [] - return song.value.lyrics.filter((line) => line.trim().length > 0) + return linesStore + .lyricLinesFor(song.value.id) + .map((l) => l.content ?? '') + .filter((line) => line.trim().length > 0) }) const currentLine = computed(() => lyrics.value[currentIndex.value] ?? '') @@ -365,6 +370,7 @@ onMounted(async () => { const songId = String(route.params.id) try { song.value = await tauriApi.getSong(songId) + await linesStore.fetchLines(songId) sessionsStore.startSession(songId, 'oral') } catch (err) { console.error('Failed to load song:', err) diff --git a/lyremember-app/src/views/PracticeQuizView.vue b/lyremember-app/src/views/PracticeQuizView.vue index ef19e58..6ad8116 100644 --- a/lyremember-app/src/views/PracticeQuizView.vue +++ b/lyremember-app/src/views/PracticeQuizView.vue @@ -220,6 +220,7 @@ import Button from '../components/ui/Button.vue'; import * as tauriApi from '../lib/tauri-api'; import type { Song } from '../types'; import { useSessionsStore } from '../stores/sessions'; +import { useLinesStore } from '../stores/lines'; // ── Types ──────────────────────────────────────────────────────────────────── interface QuizQuestion { @@ -232,6 +233,7 @@ interface QuizQuestion { const route = useRoute(); const router = useRouter(); const sessionsStore = useSessionsStore(); +const linesStore = useLinesStore(); // ── Helpers ────────────────────────────────────────────────────────────────── function shuffle(arr: T[]): T[] { @@ -279,7 +281,10 @@ const showSummary = ref(false); // ── Computed ───────────────────────────────────────────────────────────────── const lyrics = computed(() => { if (!song.value) return []; - return song.value.lyrics.filter((line) => line.trim().length > 0); + return linesStore + .lyricLinesFor(song.value.id) + .map((l) => l.content ?? '') + .filter((line) => line.trim().length > 0); }); const currentQuestion = computed( @@ -363,6 +368,7 @@ onMounted(async () => { const songId = String(route.params.id); try { song.value = await tauriApi.getSong(songId); + await linesStore.fetchLines(songId); sessionsStore.startSession(songId, 'quiz'); if (lyrics.value.length >= 4) { questions.value = buildQuestions(lyrics.value); From 06e315afed8e7849197a19807cfcaca373c36b05 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:16:46 +0000 Subject: [PATCH 5/5] refactor(db): make lyric_lines the source of truth; drop parallel columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the line-centric migration: the lyric sequence now lives only in lyric_lines / lyric_line_translations. The redundant songs columns (lyrics, phonetic_lyrics, translations, line_singers) are dropped. - Migration (sqlite.rs): explode every un-migrated song's legacy arrays into lines, then ALTER TABLE ... DROP COLUMN the four parallel columns. Guarded by a column-existence check so it's a no-op once applied. `songs.singers` (the song's singer roster) is kept — it is not per-line data. - songs service: save_song / update_song persist the sequence via lyric_lines::write_song_lines; get_song(s) reconstruct the parallel arrays from lines via load_song_arrays + hydrate. The Song API shape is unchanged, so the Tauri commands and the whole frontend are unaffected. - lyric_lines: ensure_backfilled is now a plain existence check (no lazy column read); write_song_lines (Song → lines) and load_song_arrays (lines → Song arrays) are the round-trip bridge. The full backend suite (151 tests, incl. a new legacy-migration test that backfills then drops columns and checks idempotence) passes with the default `python` feature; clippy -D warnings is clean. Caveat: a legacy song update rebuilds the whole line set, so silences added via the line API are not preserved across a cmd_update_song call. No UI adds silences through that path today. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ReeGwy3JKhaXxkxCWYYQsG --- rust-backend/src/db/sqlite.rs | 143 +++++++++++++++++++++- rust-backend/src/services/lyric_lines.rs | 145 +++++++++++++++-------- rust-backend/src/services/songs.rs | 133 ++++++++++----------- 3 files changed, 295 insertions(+), 126 deletions(-) diff --git a/rust-backend/src/db/sqlite.rs b/rust-backend/src/db/sqlite.rs index 2bb2829..8321a31 100644 --- a/rust-backend/src/db/sqlite.rs +++ b/rust-backend/src/db/sqlite.rs @@ -207,6 +207,90 @@ fn apply_migrations(conn: &Connection) -> Result<()> { } } + // v2 → v3 : lines become the source of truth; retire the per-line parallel + // columns on `songs`. + migrate_lines_source_of_truth(conn)?; + + Ok(()) +} + +/// Whether `table` currently has a column named `column`. +fn column_exists(conn: &Connection, table: &str, column: &str) -> Result { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let name: String = row.get(1)?; // table_info col 1 = name + if name == column { + return Ok(true); + } + } + Ok(false) +} + +/// One-time migration: explode every song's legacy parallel arrays into +/// `lyric_lines`, then drop the now-redundant `songs` columns +/// (`lyrics`/`phonetic_lyrics`/`translations`/`line_singers`). +/// +/// Guarded on the presence of the `lyrics` column so it is a no-op on databases +/// already migrated (and on every subsequent startup). +#[allow(clippy::type_complexity)] +fn migrate_lines_source_of_truth(conn: &Connection) -> Result<()> { + if !column_exists(conn, "songs", "lyrics")? { + return Ok(()); + } + + // Backfill every song that has not been exploded into lines yet. + let pending: Vec<(String, String, Option, Option, Option)> = { + let mut stmt = conn.prepare( + "SELECT id, lyrics, phonetic_lyrics, translations, line_singers + FROM songs WHERE lines_migrated = 0", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + )) + })?; + rows.collect::>>()? + }; + + for (id, lyrics_json, phon_json, trans_json, ls_json) in pending { + let lyrics: Vec = serde_json::from_str(&lyrics_json).unwrap_or_default(); + let phonetic_lyrics: Option> = + phon_json.and_then(|j| serde_json::from_str(&j).ok()); + let translations: Option>> = + trans_json.and_then(|j| serde_json::from_str(&j).ok()); + let line_singers: Option>> = + ls_json.and_then(|j| serde_json::from_str(&j).ok()); + + // Reuse the canonical Song → lines writer (only the sequence fields and + // id are read from this partial Song). + let song = crate::models::Song { + id, + title: String::new(), + artist: String::new(), + language: String::new(), + lyrics, + phonetic_lyrics, + translations, + synced_lyrics: None, + singers: None, + line_singers, + web_url: None, + created_at: String::new(), + updated_at: String::new(), + }; + crate::services::lyric_lines::write_song_lines(conn, &song)?; + } + + // Drop the redundant columns now that lyric_lines is authoritative. + for col in ["lyrics", "phonetic_lyrics", "translations", "line_singers"] { + conn.execute(&format!("ALTER TABLE songs DROP COLUMN {}", col), [])?; + } + Ok(()) } @@ -355,8 +439,8 @@ mod tests { let (_tmp, conn) = setup_db(); conn.execute( - "INSERT INTO songs (id, title, artist, language, lyrics, phonetic_lyrics, translations, created_at, updated_at) - VALUES ('s1', 'Song', 'Artist', 'en', '[\"line1\"]', NULL, NULL, '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", + "INSERT INTO songs (id, title, artist, language, created_at, updated_at) + VALUES ('s1', 'Song', 'Artist', 'en', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", [], ).unwrap(); @@ -379,8 +463,8 @@ mod tests { [], ).unwrap(); conn.execute( - "INSERT INTO songs (id, title, artist, language, lyrics, created_at, updated_at) - VALUES ('s1', 'Song', 'Art', 'en', '[]', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", + "INSERT INTO songs (id, title, artist, language, created_at, updated_at) + VALUES ('s1', 'Song', 'Art', 'en', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", [], ).unwrap(); @@ -407,8 +491,8 @@ mod tests { [], ).unwrap(); conn.execute( - "INSERT INTO songs (id, title, artist, language, lyrics, created_at, updated_at) - VALUES ('s1', 'Song', 'Art', 'en', '[]', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", + "INSERT INTO songs (id, title, artist, language, created_at, updated_at) + VALUES ('s1', 'Song', 'Art', 'en', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", [], ).unwrap(); @@ -438,4 +522,51 @@ mod tests { init_database(temp_file.path()).unwrap(); init_database(temp_file.path()).unwrap(); } + + #[test] + fn test_migration_backfills_legacy_song_and_drops_columns() { + // Simulate a pre-v3 database: legacy `songs` row with the parallel + // columns still populated and not yet exploded into lines. + let conn = Connection::open_in_memory().unwrap(); + create_tables(&conn).unwrap(); + + conn.execute( + "INSERT INTO songs + (id, title, artist, language, lyrics, phonetic_lyrics, translations, line_singers, + lines_migrated, created_at, updated_at) + VALUES ('s1', 'T', 'A', 'fr', ?1, ?2, ?3, ?4, 0, 'now', 'now')", + rusqlite::params![ + "[\"un\",\"deux\"]", + "[\"œ̃\",\"dø\"]", + "{\"en\":[\"one\",\"two\"]}", + "[[\"Lisa\"],[]]", + ], + ).unwrap(); + + migrate_lines_source_of_truth(&conn).unwrap(); + + // Redundant columns are gone. + for col in ["lyrics", "phonetic_lyrics", "translations", "line_singers"] { + assert!(!column_exists(&conn, "songs", col).unwrap(), "{} should be dropped", col); + } + // `singers` (song roster) survives. + assert!(column_exists(&conn, "songs", "singers").unwrap()); + + // Lines + translations were backfilled. + let lines: i64 = conn.query_row( + "SELECT COUNT(*) FROM lyric_lines WHERE song_id = 's1'", [], |r| r.get(0), + ).unwrap(); + assert_eq!(lines, 2); + let trans: i64 = conn.query_row( + "SELECT COUNT(*) FROM lyric_line_translations", [], |r| r.get(0), + ).unwrap(); + assert_eq!(trans, 2); + + // Idempotent: a second run (columns already dropped) is a no-op. + migrate_lines_source_of_truth(&conn).unwrap(); + let lines_again: i64 = conn.query_row( + "SELECT COUNT(*) FROM lyric_lines WHERE song_id = 's1'", [], |r| r.get(0), + ).unwrap(); + assert_eq!(lines_again, 2, "second migration run must not duplicate lines"); + } } diff --git a/rust-backend/src/services/lyric_lines.rs b/rust-backend/src/services/lyric_lines.rs index 85bdb85..67afe68 100644 --- a/rust-backend/src/services/lyric_lines.rs +++ b/rust-backend/src/services/lyric_lines.rs @@ -103,63 +103,49 @@ fn load_translations( Ok(by_line) } -/// Explode a song's legacy parallel arrays into `lyric_lines` rows the first -/// time it is read. No-op once `songs.lines_migrated = 1`. Errors if the song -/// does not exist. +/// Guard that a song exists before reading/writing its lines. +/// +/// Lines are the source of truth: a song's rows are written by +/// [`write_song_lines`] at creation/update (and by the one-time migration for +/// pre-existing databases), so there is no lazy backfill here — only an +/// existence check that yields `NotFound` for an unknown id. fn ensure_backfilled(conn: &Connection, song_id: &str) -> Result<()> { - const COL_MIGRATED: usize = 0; - const COL_LYRICS: usize = 1; - const COL_PHON: usize = 2; - const COL_TRANS: usize = 3; - const COL_LINE_SINGERS: usize = 4; - - let row = conn - .query_row( - "SELECT lines_migrated, lyrics, phonetic_lyrics, translations, line_singers - FROM songs WHERE id = ?1", - [song_id], - |row| { - Ok(( - row.get::<_, i64>(COL_MIGRATED)?, - row.get::<_, String>(COL_LYRICS)?, - row.get::<_, Option>(COL_PHON)?, - row.get::<_, Option>(COL_TRANS)?, - row.get::<_, Option>(COL_LINE_SINGERS)?, - )) - }, - ) - .map_err(|_| Error::NotFound("Song not found".to_string()))?; - - let (migrated, lyrics_json, phon_json, trans_json, line_singers_json) = row; - if migrated != 0 { - return Ok(()); + let exists: bool = conn + .query_row("SELECT 1 FROM songs WHERE id = ?1", [song_id], |_| Ok(true)) + .unwrap_or(false); + if exists { + Ok(()) + } else { + Err(Error::NotFound("Song not found".to_string())) } +} - let lyrics: Vec = serde_json::from_str(&lyrics_json).unwrap_or_default(); - let phonetic: Option> = phon_json.and_then(|j| serde_json::from_str(&j).ok()); - let translations: Option>> = - trans_json.and_then(|j| serde_json::from_str(&j).ok()); - let line_singers: Option>> = - line_singers_json.and_then(|j| serde_json::from_str(&j).ok()); - - // The parallel-array model is only correct when every array has the same - // length as `lyrics`; a mismatch is the latent bug this migration retires. - if let Some(p) = &phonetic { - if p.len() != lyrics.len() { +/// Replace a song's entire line sequence from the legacy parallel-array `Song` +/// shape. Used by the songs service on create/update — the arrays on `Song` are +/// an in-memory transport, persisted here as `lyric_lines` + translations. +/// +/// Any existing lines (and their translations, via cascade) are deleted first, +/// so this is a wholesale replace — line-level identity added through the line +/// API is not preserved across a legacy song update. +pub fn write_song_lines(conn: &Connection, song: &crate::models::Song) -> Result<()> { + if let Some(p) = &song.phonetic_lyrics { + if p.len() != song.lyrics.len() { log::warn!( "song {}: phonetic_lyrics length {} != lyrics length {}; extra/missing entries dropped", - song_id, p.len(), lyrics.len() + song.id, p.len(), song.lyrics.len() ); } } let tx = conn.unchecked_transaction()?; - let ts = now(); + tx.execute("DELETE FROM lyric_lines WHERE song_id = ?1", [&song.id])?; - for (i, content) in lyrics.iter().enumerate() { + let ts = now(); + for (i, content) in song.lyrics.iter().enumerate() { let line_id = new_id(); - let phon = phonetic.as_ref().and_then(|p| p.get(i)).cloned(); - let singers = line_singers + let phon = song.phonetic_lyrics.as_ref().and_then(|p| p.get(i)).cloned(); + let singers = song + .line_singers .as_ref() .and_then(|ls| ls.get(i)) .cloned() @@ -170,10 +156,10 @@ fn ensure_backfilled(conn: &Connection, song_id: &str) -> Result<()> { "INSERT INTO lyric_lines (id, song_id, position, kind, content, phonetic, singers, created_at, updated_at) VALUES (?1, ?2, ?3, 'lyric', ?4, ?5, ?6, ?7, ?7)", - rusqlite::params![line_id, song_id, i as i64, content, phon, singers_json, ts], + rusqlite::params![line_id, song.id, i as i64, content, phon, singers_json, ts], )?; - if let Some(trans) = &translations { + if let Some(trans) = &song.translations { for (lang, texts) in trans { if let Some(t) = texts.get(i) { tx.execute( @@ -186,14 +172,71 @@ fn ensure_backfilled(conn: &Connection, song_id: &str) -> Result<()> { } } - tx.execute( - "UPDATE songs SET lines_migrated = 1 WHERE id = ?1", - [song_id], - )?; + tx.execute("UPDATE songs SET lines_migrated = 1 WHERE id = ?1", [&song.id])?; tx.commit()?; Ok(()) } +/// Reconstruct the legacy parallel arrays from a song's stored lines, in order. +/// Silences are excluded (they have no place in the flat lyric arrays). Inverse +/// of [`write_song_lines`]. +/// +/// Returns `(lyrics, phonetic_lyrics, translations, line_singers)` with each +/// optional field `None` when no line carries that kind of data. +#[allow(clippy::type_complexity)] +pub fn load_song_arrays( + conn: &Connection, + song_id: &str, +) -> Result<( + Vec, + Option>, + Option>>, + Option>>, +)> { + let lines: Vec = get_song_lines(conn, song_id)? + .into_iter() + .filter(|l| l.kind == LineKind::Lyric) + .collect(); + + let lyrics: Vec = lines.iter().map(|l| l.content.clone().unwrap_or_default()).collect(); + + let has_phon = lines.iter().any(|l| l.phonetic.is_some()); + let phonetic = if has_phon { + Some(lines.iter().map(|l| l.phonetic.clone().unwrap_or_default()).collect()) + } else { + None + }; + + let mut langs: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for l in &lines { + for lang in l.translations.keys() { + langs.insert(lang.clone()); + } + } + let translations = if langs.is_empty() { + None + } else { + let mut map = HashMap::new(); + for lang in &langs { + let col = lines + .iter() + .map(|l| l.translations.get(lang).map(|t| t.text.clone()).unwrap_or_default()) + .collect(); + map.insert(lang.clone(), col); + } + Some(map) + }; + + let has_singers = lines.iter().any(|l| l.singers.as_ref().is_some_and(|s| !s.is_empty())); + let line_singers = if has_singers { + Some(lines.iter().map(|l| l.singers.clone().unwrap_or_default()).collect()) + } else { + None + }; + + Ok((lyrics, phonetic, translations, line_singers)) +} + /// Rewrite the positions of a song's lines to `0..n` in their current order, /// using a two-pass negative offset so the `UNIQUE(song_id, position)` index is /// never transiently violated. Must run inside a transaction. diff --git a/rust-backend/src/services/songs.rs b/rust-backend/src/services/songs.rs index 3d49f5b..5b4403e 100644 --- a/rust-backend/src/services/songs.rs +++ b/rust-backend/src/services/songs.rs @@ -3,7 +3,7 @@ pub use crate::models::{Song, CreateSongData, UpdateSongData}; use crate::{Error, Result}; -use crate::services::{phonetic, translation}; +use crate::services::{lyric_lines, phonetic, translation}; use rusqlite::Connection; use std::collections::HashMap; @@ -51,44 +51,35 @@ pub fn create_song(conn: &Connection, data: CreateSongData) -> Result { Ok(song) } -/// Save song to database +/// Save song to database. +/// +/// Song-level metadata lives on the `songs` row; the lyric sequence +/// (`lyrics`/`phonetic_lyrics`/`translations`/`line_singers`) is persisted as +/// `lyric_lines` + `lyric_line_translations` via [`lyric_lines::write_song_lines`]. fn save_song(conn: &Connection, song: &Song) -> Result<()> { - let lyrics_json = serde_json::to_string(&song.lyrics)?; - let phonetic_json = song.phonetic_lyrics.as_ref() - .map(serde_json::to_string) - .transpose()?; - let translations_json = song.translations.as_ref() - .map(serde_json::to_string) - .transpose()?; let singers_json = song.singers.as_ref() .map(serde_json::to_string) .transpose()?; - let line_singers_json = song.line_singers.as_ref() - .map(serde_json::to_string) - .transpose()?; conn.execute( "INSERT INTO songs - (id, title, artist, language, lyrics, phonetic_lyrics, translations, - web_url, synced_lyrics, singers, line_singers, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + (id, title, artist, language, web_url, synced_lyrics, singers, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", rusqlite::params![ &song.id, &song.title, &song.artist, &song.language, - &lyrics_json, - &phonetic_json, - &translations_json, &song.web_url, &song.synced_lyrics, &singers_json, - &line_singers_json, &song.created_at, &song.updated_at, ], )?; + lyric_lines::write_song_lines(conn, song)?; + Ok(()) } @@ -96,58 +87,64 @@ const COL_ID: usize = 0; const COL_TITLE: usize = 1; const COL_ARTIST: usize = 2; const COL_LANGUAGE: usize = 3; -const COL_LYRICS: usize = 4; -const COL_PHONETIC: usize = 5; -const COL_TRANSLATIONS: usize = 6; -const COL_WEB_URL: usize = 7; -const COL_SYNCED_LYRICS: usize = 8; -const COL_SINGERS: usize = 9; -const COL_LINE_SINGERS: usize = 10; -const COL_CREATED_AT: usize = 11; -const COL_UPDATED_AT: usize = 12; +const COL_WEB_URL: usize = 4; +const COL_SYNCED_LYRICS: usize = 5; +const COL_SINGERS: usize = 6; +const COL_CREATED_AT: usize = 7; +const COL_UPDATED_AT: usize = 8; /// Deserialize a Song row from rusqlite (columns must be selected in canonical order). /// /// Canonical SELECT order: -/// id, title, artist, language, lyrics, phonetic_lyrics, translations, -/// web_url, synced_lyrics, singers, line_singers, created_at, updated_at +/// id, title, artist, language, web_url, synced_lyrics, singers, +/// created_at, updated_at +/// +/// The lyric-sequence fields (`lyrics`/`phonetic_lyrics`/`translations`/ +/// `line_singers`) are left empty here and filled by [`hydrate`] from the +/// song's `lyric_lines` — they are no longer stored on the `songs` row. fn row_to_song(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let lyrics_json: String = row.get(COL_LYRICS)?; - let phonetic_json: Option = row.get(COL_PHONETIC)?; - let translations_json: Option = row.get(COL_TRANSLATIONS)?; let singers_json: Option = row.get(COL_SINGERS)?; - let line_singers_json: Option = row.get(COL_LINE_SINGERS)?; Ok(Song { id: row.get(COL_ID)?, title: row.get(COL_TITLE)?, artist: row.get(COL_ARTIST)?, language: row.get(COL_LANGUAGE)?, - lyrics: serde_json::from_str(&lyrics_json).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure(COL_LYRICS, rusqlite::types::Type::Text, Box::new(e)) - })?, - phonetic_lyrics: phonetic_json.and_then(|j| serde_json::from_str(&j).ok()), - translations: translations_json.and_then(|j| serde_json::from_str(&j).ok()), + lyrics: Vec::new(), + phonetic_lyrics: None, + translations: None, web_url: row.get(COL_WEB_URL)?, synced_lyrics: row.get(COL_SYNCED_LYRICS)?, singers: singers_json.and_then(|j| serde_json::from_str(&j).ok()), - line_singers: line_singers_json.and_then(|j| serde_json::from_str(&j).ok()), + line_singers: None, created_at: row.get(COL_CREATED_AT)?, updated_at: row.get(COL_UPDATED_AT)?, }) } +/// Fill a song's lyric-sequence arrays from its stored lines. +fn hydrate(conn: &Connection, song: &mut Song) -> Result<()> { + let (lyrics, phonetic, translations, line_singers) = + lyric_lines::load_song_arrays(conn, &song.id)?; + song.lyrics = lyrics; + song.phonetic_lyrics = phonetic; + song.translations = translations; + song.line_singers = line_singers; + Ok(()) +} + const SONG_SELECT: &str = - "SELECT id, title, artist, language, lyrics, phonetic_lyrics, translations, - web_url, synced_lyrics, singers, line_singers, created_at, updated_at"; + "SELECT id, title, artist, language, web_url, synced_lyrics, singers, + created_at, updated_at"; /// Get song by ID pub fn get_song(conn: &Connection, song_id: &str) -> Result { let query = format!("{} FROM songs WHERE id = ?1", SONG_SELECT); let mut stmt = conn.prepare(&query)?; - let song = stmt.query_row([song_id], row_to_song) + let mut song = stmt.query_row([song_id], row_to_song) .map_err(|_| Error::NotFound("Song not found".to_string()))?; + hydrate(conn, &mut song)?; Ok(song) } @@ -155,10 +152,15 @@ pub fn get_song(conn: &Connection, song_id: &str) -> Result { /// Get all songs pub fn get_all_songs(conn: &Connection) -> Result> { let query = format!("{} FROM songs ORDER BY created_at DESC", SONG_SELECT); - let mut stmt = conn.prepare(&query)?; - - let songs = stmt.query_map([], row_to_song)? - .collect::>>()?; + let mut songs = { + let mut stmt = conn.prepare(&query)?; + let songs = stmt.query_map([], row_to_song)? + .collect::>>()?; + songs + }; + for song in &mut songs { + hydrate(conn, song)?; + } Ok(songs) } @@ -172,10 +174,15 @@ pub fn get_user_songs(conn: &Connection, user_id: &str) -> Result> { ORDER BY us.added_at DESC", SONG_SELECT ); - let mut stmt = conn.prepare(&query)?; - - let songs = stmt.query_map([user_id], row_to_song)? - .collect::>>()?; + let mut songs = { + let mut stmt = conn.prepare(&query)?; + let songs = stmt.query_map([user_id], row_to_song)? + .collect::>>()?; + songs + }; + for song in &mut songs { + hydrate(conn, song)?; + } Ok(songs) } @@ -210,42 +217,30 @@ pub fn update_song(conn: &Connection, song_id: &str, data: UpdateSongData) -> Re song.updated_at = chrono::Utc::now().to_rfc3339(); - let lyrics_json = serde_json::to_string(&song.lyrics)?; - let phonetic_json = song.phonetic_lyrics.as_ref() - .map(serde_json::to_string) - .transpose()?; - let translations_json = song.translations.as_ref() - .map(serde_json::to_string) - .transpose()?; let singers_json = song.singers.as_ref() .map(serde_json::to_string) .transpose()?; - let line_singers_json = song.line_singers.as_ref() - .map(serde_json::to_string) - .transpose()?; conn.execute( "UPDATE songs - SET title = ?1, artist = ?2, language = ?3, lyrics = ?4, - phonetic_lyrics = ?5, translations = ?6, synced_lyrics = ?7, - singers = ?8, line_singers = ?9, web_url = ?10, updated_at = ?11 - WHERE id = ?12", + SET title = ?1, artist = ?2, language = ?3, synced_lyrics = ?4, + singers = ?5, web_url = ?6, updated_at = ?7 + WHERE id = ?8", rusqlite::params![ &song.title, &song.artist, &song.language, - &lyrics_json, - &phonetic_json, - &translations_json, &song.synced_lyrics, &singers_json, - &line_singers_json, &song.web_url, &song.updated_at, song_id, ], )?; + // Rebuild the lyric sequence from the (updated) in-memory arrays. + lyric_lines::write_song_lines(conn, &song)?; + Ok(song) }