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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,577 changes: 2,534 additions & 43 deletions lyremember-app/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions lyremember-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
135 changes: 134 additions & 1 deletion lyremember-app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Vec<LyricLine>, 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<String>,
phonetic: Option<String>,
singers: Option<Vec<String>>,
state: State<'_, DbState>,
) -> std::result::Result<LyricLine, String> {
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<LineKind>,
content: Option<String>,
phonetic: Option<String>,
singers: Option<Vec<String>>,
state: State<'_, DbState>,
) -> std::result::Result<LyricLine, String> {
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<String>,
state: State<'_, DbState>,
) -> std::result::Result<Vec<LyricLine>, 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<String>,
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<Vec<SongNote>, 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<SongNote, String> {
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<SongNote, String> {
let conn = lock_db(&state)?;
lyric_lines::update_note(&conn, &note_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, &note_id).map_err(|e| e.to_string())
}

// ==================== PRACTICE COMMANDS ====================

#[tauri::command]
Expand Down
13 changes: 13 additions & 0 deletions lyremember-app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
96 changes: 96 additions & 0 deletions lyremember-app/src/lib/line-projection.spec.ts
Original file line number Diff line number Diff line change
@@ -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<LyricLine> & { 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');
});
});
37 changes: 37 additions & 0 deletions lyremember-app/src/lib/line-projection.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
sung.forEach((l) => Object.keys(l.translations).forEach((lang) => langs.add(lang)));
let translations: Record<string, string[]> | 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 };
}
Loading
Loading