Skip to content

Commit 265d7f6

Browse files
phildenhoffclaude
andauthored
feat(metadata): read/write book languages instead of hardcoded "en" (#130)
* feat(startup): foreground the app when the main window first shows The main window starts hidden and is revealed from the frontend once settings hydrate. show() made it visible but never activated the app, so Citadel could launch behind whatever window already had focus. Focus the window right after showing it, mirroring the settings-window pattern. Fixes CDL-16 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(metadata): read/write book languages instead of hardcoded "en" Book never read the languages/books_languages_link tables, BookUpdate had no language field, and OPF generation hardcoded <dc:language>en</dc:language> — multilingual libraries were silently mislabeled. - libcalibre: Language entity + queries (canonicalize ISO 639-1→639-3, set_for_book replace/clear, batched + single readers via raw SQL since there's no diesel joinable! for languages). Book.language_codes, BookUpdate.language_codes (Some=replace/empty=clear/None=untouched), BookAdd.language. - OPF emits one <dc:language> per real code (und when none). New Library::regenerate_metadata_opf rebuilds metadata.opf from DB state and runs on both add and update, so the derived OPF stays faithful for every field — not just language. - Tauri: LibraryBook.language_list, BookUpdate DTO.language_list, and ImportableBookMetadata.language now flows into to_book_add (was dropped). - UI: multi-value Languages row on Edit Book (ui TagsInput) backed by src/lib/languages.ts code↔name mapping. Fixes CDL-2 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4a3958a commit 265d7f6

25 files changed

Lines changed: 584 additions & 12 deletions

crates/libcalibre/examples/generate_stress_library.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,7 @@ fn run() -> Result<(), String> {
710710
rating: None,
711711
comments: None,
712712
identifiers: HashMap::new(),
713+
language: Some("eng".to_string()),
713714
file_paths: vec![stub_path.clone()],
714715
};
715716

crates/libcalibre/src/entities.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod author;
22
pub mod book_file;
33
pub mod book_row;
4+
pub mod language;
45
pub mod series;
56
pub mod tag;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
use diesel::prelude::*;
2+
3+
use crate::schema::languages;
4+
5+
#[derive(Clone, Debug, Queryable, QueryableByName, Selectable, Identifiable)]
6+
#[diesel(table_name = languages)]
7+
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
8+
pub struct Language {
9+
pub id: i32,
10+
pub lang_code: String,
11+
}
12+
13+
#[derive(Insertable)]
14+
#[diesel(table_name = languages)]
15+
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
16+
pub struct NewLanguage {
17+
pub lang_code: String,
18+
}

crates/libcalibre/src/library.rs

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ pub struct Book {
3737
/// Position within the series; `Some` iff the book is linked to a series.
3838
pub series_index: Option<f32>,
3939
pub description: Option<String>,
40+
/// Canonical Calibre language codes (ISO 639-2/3, e.g. `eng`, `fra`),
41+
/// ordered by `books_languages_link.item_order`. Empty when the book has
42+
/// no language metadata.
43+
pub language_codes: Vec<String>,
4044
pub identifiers: Vec<BookIdentifier>,
4145
pub has_cover: bool,
4246
pub is_read: bool,
@@ -75,6 +79,9 @@ pub struct BookAdd {
7579
pub rating: Option<i32>,
7680
pub comments: Option<String>,
7781
pub identifiers: HashMap<String, String>,
82+
/// Language code for the book (any ISO 639-1/2/3 form; canonicalized on
83+
/// write). `None` adds the book with no language metadata.
84+
pub language: Option<String>,
7885
pub file_paths: Vec<PathBuf>,
7986
}
8087

@@ -101,6 +108,10 @@ pub struct BookUpdate {
101108
/// name unlinks the book from its series. `None` leaves it unchanged.
102109
pub series: Option<String>,
103110
pub series_index: Option<f32>,
111+
/// If provided, replaces the book's language links with the given codes
112+
/// (canonicalized to Calibre's ISO 639-2/3 form, deduped, order preserved).
113+
/// An empty list clears all language links; `None` leaves them unchanged.
114+
pub language_codes: Option<Vec<String>>,
104115
pub publisher: Option<String>,
105116
pub publication_date: Option<NaiveDate>,
106117
pub rating: Option<i32>,
@@ -271,6 +282,14 @@ impl Library {
271282
crate::queries::series::link_book(&mut self.conn, series.id, BookId(book_row.id))?;
272283
}
273284

285+
if let Some(language) = &book.language {
286+
crate::queries::languages::set_for_book(
287+
&mut self.conn,
288+
BookId(book_row.id),
289+
std::slice::from_ref(language),
290+
)?;
291+
}
292+
274293
// 4. Create directories
275294
let primary_author = created_authors
276295
.first()
@@ -352,14 +371,8 @@ impl Library {
352371
}
353372
}
354373

355-
// 7. Generate metadata.opf
356-
let book_row_final = book_queries::get(&mut self.conn, BookId(book_row.id))?
357-
.ok_or(CalibreError::BookNotFound(BookId(book_row.id)))?;
358-
let metadata_opf = format_metadata_opf(&book_row_final, &created_authors);
359-
if let Ok(contents) = metadata_opf {
360-
let opf_path = library_root.join(&book_dir_relative).join("metadata.opf");
361-
let _ = std::fs::write(&opf_path, contents.as_bytes());
362-
}
374+
// 7. Generate metadata.opf from the freshly written DB state.
375+
let _ = self.regenerate_metadata_opf(BookId(book_row.id));
363376

364377
self.get_book(BookId(book_row.id))
365378
}
@@ -397,9 +410,35 @@ impl Library {
397410
self.set_book_read_state(book_id, is_read)?;
398411
}
399412

413+
// Keep the on-disk metadata.opf faithful to the DB. Best-effort: a
414+
// metadata write failure must not fail the (already committed) update.
415+
let _ = self.regenerate_metadata_opf(book_id);
416+
400417
self.get_book(book_id)
401418
}
402419

420+
/// Rewrite a book's `metadata.opf` from current DB state (title, authors,
421+
/// language codes, …). Called after both add and update so the derived
422+
/// file never drifts from the database. Missing files/dirs are surfaced as
423+
/// `Err`, but callers treat OPF writes as best-effort.
424+
fn regenerate_metadata_opf(&mut self, book_id: BookId) -> Result<(), CalibreError> {
425+
let book_row = book_queries::get(&mut self.conn, book_id)?
426+
.ok_or(CalibreError::BookNotFound(book_id))?;
427+
let author_ids = book_queries::find_authors(&mut self.conn, book_id)?;
428+
let authors = author_queries::get_many(&mut self.conn, author_ids)?;
429+
let language_codes =
430+
crate::queries::languages::find_codes_for_book(&mut self.conn, book_id)?;
431+
432+
let contents = format_metadata_opf(&book_row, &authors, &language_codes)
433+
.map_err(|_| CalibreError::DatabaseIntegrity("Failed to render metadata.opf".into()))?;
434+
435+
let opf_path = Path::new(&self.db_path.library_path)
436+
.join(&book_row.path)
437+
.join("metadata.opf");
438+
std::fs::write(&opf_path, contents.as_bytes())
439+
.map_err(|e| CalibreError::FileSystem(e.to_string()))
440+
}
441+
403442
pub fn remove_books(&mut self, book_ids: Vec<BookId>) -> Result<Vec<BookId>, CalibreError> {
404443
let mut removed = Vec::new();
405444
for book_id in book_ids {
@@ -856,6 +895,7 @@ impl Library {
856895
fn format_metadata_opf(
857896
book: &crate::entities::book_row::BookRow,
858897
authors: &[crate::entities::author::Author],
898+
language_codes: &[String],
859899
) -> Result<String, ()> {
860900
let author_sort = book.author_sort.clone().unwrap_or_else(|| {
861901
authors
@@ -876,6 +916,17 @@ fn format_metadata_opf(
876916
})
877917
.collect();
878918

919+
// Calibre emits one <dc:language> per linked language code, falling back to
920+
// "und" (undetermined) when the book has none.
921+
let languages_string: String = if language_codes.is_empty() {
922+
"<dc:language>und</dc:language>".to_string()
923+
} else {
924+
language_codes
925+
.iter()
926+
.map(|code| format!("<dc:language>{code}</dc:language>"))
927+
.collect()
928+
};
929+
879930
Ok(format!(
880931
r#"<?xml version='1.0' encoding='utf-8'?>
881932
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="uuid_id" version="2.0">
@@ -886,7 +937,7 @@ fn format_metadata_opf(
886937
{authors}
887938
<dc:contributor opf:file-as="calibre" opf:role="bkp">citadel (1.0.0) [https://github.com/every-day-things/citadel]</dc:contributor>
888939
<dc:date>{pub_date}</dc:date>
889-
<dc:language>en</dc:language>
940+
{languages}
890941
<meta name="calibre:timestamp" content="{now}"/>
891942
<meta name="calibre:title_sort" content="{book_title_sortable}"/>
892943
</metadata>
@@ -898,6 +949,7 @@ fn format_metadata_opf(
898949
calibre_uuid = book.uuid.as_deref().unwrap_or(""),
899950
book_title = book.title,
900951
authors = authors_string,
952+
languages = languages_string,
901953
pub_date = book
902954
.pubdate
903955
.unwrap_or(chrono::DateTime::UNIX_EPOCH.naive_utc())

crates/libcalibre/src/operations/books.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ use diesel::{Connection, SqliteConnection};
55

66
use crate::{
77
library::{Book, BookFileInfo, BookIdentifier, BookUpdate},
8-
queries::{authors, book_descriptions, book_files, book_identifiers, books, series, tags},
8+
queries::{
9+
authors, book_descriptions, book_files, book_identifiers, books, languages, series, tags,
10+
},
911
types::{AuthorId, BookId},
1012
BookRow, CalibreError, UpdateBookData,
1113
};
@@ -52,6 +54,10 @@ pub fn update_book(
5254
}
5355
}
5456

57+
if let Some(codes) = update.language_codes {
58+
languages::set_for_book(conn, book_id, &codes)?;
59+
}
60+
5561
if let Some(description) = update.description {
5662
let existing = book_descriptions::get(conn, book_id)?;
5763
if existing.is_some() {
@@ -172,6 +178,7 @@ pub fn get_book(conn: &mut SqliteConnection, book_id: BookId) -> Result<Book, Ca
172178
let author_models = authors::get_many(conn, author_ids)?;
173179
let tags = tags::find_for_book(conn, book_id)?;
174180
let series_name = series::find_series_name_for_book(conn, book_id)?;
181+
let language_codes = languages::find_codes_for_book(conn, book_id)?;
175182
let identifier_models = book_identifiers::get(conn, book_id)?;
176183
let file_models = book_files::find_by_book_id(conn, book_id)?;
177184

@@ -209,6 +216,7 @@ pub fn get_book(conn: &mut SqliteConnection, book_id: BookId) -> Result<Book, Ca
209216
series: series_name,
210217
identifiers,
211218
description: book_desc,
219+
language_codes,
212220
has_cover: book.has_cover.unwrap_or(false),
213221
is_read: false, // Populated by Library via read state queries
214222
files,
@@ -267,6 +275,7 @@ fn hydrate(
267275
let identifiers_map = book_identifiers::find_many_by_book_ids(conn, book_ids.clone())?;
268276
let tags_map = tags::find_tag_names_by_book_ids(conn, book_ids.clone())?;
269277
let series_map = series::find_series_names_by_book_ids(conn, book_ids.clone())?;
278+
let languages_map = languages::find_codes_by_book_ids(conn, book_ids.clone())?;
270279
let files_map = book_files::find_many_by_book_ids(conn, book_ids)?;
271280

272281
let unique_author_ids: Vec<AuthorId> = author_ids_by_book
@@ -295,6 +304,7 @@ fn hydrate(
295304
let book_description = descriptions_map.get(&book_id).cloned();
296305
let book_tags = tags_map.get(&book_id).cloned().unwrap_or_default();
297306
let book_series = series_map.get(&book_id).cloned();
307+
let book_languages = languages_map.get(&book_id).cloned().unwrap_or_default();
298308
let raw_files = files_map.get(&book_id).cloned().unwrap_or_default();
299309

300310
let book_identifiers = identifiers_map
@@ -333,6 +343,7 @@ fn hydrate(
333343
series: book_series,
334344
identifiers: book_identifiers,
335345
description: book_description,
346+
language_codes: book_languages,
336347
has_cover: book_row.has_cover.unwrap_or(false),
337348
is_read: false, // Populated by Library via read state queries
338349
files,

0 commit comments

Comments
 (0)