Named libraries: switch between several, each syncing on its own

The app can keep any number of named libraries and switch between them
from the top bar or Info → Libraries. Each can sync to the account's other
devices or stay on this one (a scratch library to try things in).

The sync server now keeps several libraries per user under
/api/libraries; a migration makes each existing library the user's default
"Main", which /api/library keeps serving for older app builds. Every sync
lines the device's libraries up with the account's: new ones elsewhere are
downloaded, new ones here created on the server, ones deleted elsewhere
flagged rather than thrown away. The existing library keeps its files and
merge base, so upgrading asks nothing.
This commit is contained in:
2026-10-16 21:44:55 -05:00
parent 6072d44ebe
commit 81b1df8c9f
21 changed files with 2451 additions and 202 deletions

View File

@@ -20,8 +20,11 @@ type Res<T> = Result<T, String>;
/// High-level status for the top bar / first-run gating.
#[derive(Serialize)]
pub struct Status {
/// Where the library syncs: the account and server, or this device only.
/// The open library's name and where it syncs: the account and server, or
/// this device only. Changes whenever another library is opened.
pub library_label: String,
/// The open library.
pub library: OpenLibrary,
pub reference_path: String,
pub reference_ready: bool,
pub card_count: usize,
@@ -39,18 +42,25 @@ pub struct Status {
pub is_mobile: bool,
}
/// Where the library syncs, for the top bar — never the internal working
/// database, which is an implementation detail the user never sees.
#[derive(Serialize)]
pub struct OpenLibrary {
pub id: String,
pub name: String,
}
/// The open library and where it syncs, for the top bar — never the internal
/// working database, which is an implementation detail the user never sees.
fn library_label(s: &AppState) -> String {
let lib = s.active_library();
match s.sync_account() {
Some(a) if a.token.is_some() => {
Some(a) if a.token.is_some() && lib.sync => {
let host = url::Url::parse(&a.server_url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.unwrap_or_else(|| a.server_url.clone());
format!("{} @ {host}", a.user.username)
format!("{} · {} @ {host}", lib.name, a.user.username)
}
_ => "This device only (not syncing)".to_string(),
_ => format!("{} · this device only", lib.name),
}
}
@@ -60,8 +70,10 @@ fn status_of(s: &AppState) -> Status {
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
let lib = s.active_library();
Status {
library_label: library_label(s),
library: OpenLibrary { id: lib.id.clone(), name: lib.name.clone() },
reference_path: s.reference_path.display().to_string(),
reference_ready: s.catalog.is_some(),
card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0),