Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
//! Reading and writing the user's library document, wherever they keep it.
|
|
|
|
|
//!
|
|
|
|
|
//! The reconciliation itself lives in [`mtgc_core::sync`] as a pure function.
|
|
|
|
|
//! This module is only the plumbing: getting the document in and out of a
|
|
|
|
|
//! folder that differs by platform, and running the cycle in the right order.
|
|
|
|
|
//!
|
|
|
|
|
//! Desktop points at an ordinary file. Android can't: the working database must
|
|
|
|
|
//! stay in app-private storage (SQLite can't open a `content://` URI), and the
|
|
|
|
|
//! user's folder is reachable only through the Storage Access Framework. So on
|
|
|
|
|
//! Android every transfer is staged through an app-private file that Rust owns
|
|
|
|
|
//! and the Kotlin side copies to or from — deliberately avoiding
|
|
|
|
|
//! `MANAGE_EXTERNAL_STORAGE`, which would hand the app the entire shared volume
|
|
|
|
|
//! just to reach one folder. [`Folder`] hides that split.
|
|
|
|
|
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
use mtgc_core::sync::{self as core_sync, LibraryDoc, MergeReport};
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
use serde::Deserialize;
|
|
|
|
|
use tauri::{AppHandle, Manager};
|
|
|
|
|
|
|
|
|
|
use crate::state::AppState;
|
|
|
|
|
|
|
|
|
|
type Db<'a> = tauri::State<'a, std::sync::Mutex<AppState>>;
|
|
|
|
|
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
/// Fallback name when the user has granted a folder but not yet said which file
|
|
|
|
|
/// in it is their library — i.e. a fresh setup with nothing to adopt.
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
#[cfg(target_os = "android")]
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
const DEFAULT_DOC_NAME: &str = "library.json";
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
|
|
|
|
|
/// Where transfers are staged across the SAF boundary. Inside the app data dir,
|
|
|
|
|
/// so it inherits the same private-storage guarantees as the working copy.
|
|
|
|
|
/// Android-only: desktop reads and writes the document directly.
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
fn staging_path(data_dir: &Path, name: &str) -> PathBuf {
|
|
|
|
|
data_dir.join("sync-staging").join(name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn base_path(data_dir: &Path) -> PathBuf {
|
|
|
|
|
data_dir.join("sync-base.json")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// State of the library document, as shown in the UI.
|
|
|
|
|
#[derive(Serialize, Default)]
|
|
|
|
|
pub struct SyncStatus {
|
|
|
|
|
/// Whether a document has been chosen at all.
|
|
|
|
|
pub chosen: bool,
|
|
|
|
|
/// Whether it's currently reachable. Distinct from `chosen`: an Android SAF
|
|
|
|
|
/// grant can be revoked and a desktop folder can be deleted or unmounted,
|
|
|
|
|
/// both of which need re-picking rather than first-time setup.
|
|
|
|
|
pub granted: bool,
|
|
|
|
|
pub label: Option<String>,
|
|
|
|
|
/// True where the folder is chosen through the system picker (Android)
|
|
|
|
|
/// rather than by path — the one thing the UI branches on.
|
|
|
|
|
pub picker_based: bool,
|
|
|
|
|
/// A pre-document `library.db` waiting to be converted, if any.
|
|
|
|
|
pub legacy_library: Option<String>,
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
/// Which file in the granted folder is the library (Android). `None` on
|
|
|
|
|
/// desktop, where the whole path is chosen at once.
|
|
|
|
|
pub document_name: Option<String>,
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
mod bridge {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
/// Handle on the Kotlin-side `SyncPlugin`, put into Tauri's state store
|
|
|
|
|
/// during plugin setup (see `lib.rs`).
|
|
|
|
|
pub struct SyncBridge(pub tauri::plugin::PluginHandle<tauri::Wry>);
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
struct FileArgs<'a> {
|
|
|
|
|
name: &'a str,
|
|
|
|
|
path: &'a str,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Serialize)]
|
|
|
|
|
struct NameArgs<'a> {
|
|
|
|
|
name: &'a str,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct ReadResult {
|
|
|
|
|
found: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize)]
|
|
|
|
|
struct ListResult {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
names: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Deserialize, Default)]
|
|
|
|
|
pub struct FolderStatus {
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub chosen: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub granted: bool,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
pub label: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn call<T: serde::de::DeserializeOwned>(
|
|
|
|
|
app: &AppHandle,
|
|
|
|
|
command: &str,
|
|
|
|
|
payload: impl Serialize,
|
|
|
|
|
) -> Result<T, String> {
|
|
|
|
|
let bridge = app
|
|
|
|
|
.try_state::<SyncBridge>()
|
|
|
|
|
.ok_or_else(|| "Sync plugin failed to load — see the logs.".to_string())?;
|
|
|
|
|
bridge
|
|
|
|
|
.0
|
|
|
|
|
.run_mobile_plugin::<T>(command, payload)
|
|
|
|
|
.map_err(|e| e.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn status(app: &AppHandle) -> Result<FolderStatus, String> {
|
|
|
|
|
call(app, "status", ())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn pick_folder(app: &AppHandle) -> Result<FolderStatus, String> {
|
|
|
|
|
call(app, "pickFolder", ())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn forget_folder(app: &AppHandle) -> Result<FolderStatus, String> {
|
|
|
|
|
call(app, "forgetFolder", ())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn list(app: &AppHandle) -> Result<Vec<String>, String> {
|
|
|
|
|
Ok(call::<ListResult>(app, "list", ())?.names)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn read_into(app: &AppHandle, name: &str, path: &Path) -> Result<bool, String> {
|
|
|
|
|
let res: ReadResult = call(
|
|
|
|
|
app,
|
|
|
|
|
"readInto",
|
|
|
|
|
FileArgs { name, path: &path.display().to_string() },
|
|
|
|
|
)?;
|
|
|
|
|
Ok(res.found)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn write_from(app: &AppHandle, name: &str, path: &Path) -> Result<(), String> {
|
|
|
|
|
let _: serde_json::Value = call(
|
|
|
|
|
app,
|
|
|
|
|
"writeFrom",
|
|
|
|
|
FileArgs { name, path: &path.display().to_string() },
|
|
|
|
|
)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn delete(app: &AppHandle, name: &str) -> Result<(), String> {
|
|
|
|
|
let _: serde_json::Value = call(app, "deleteFile", NameArgs { name })?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
pub use bridge::SyncBridge;
|
|
|
|
|
|
|
|
|
|
/// The folder as an ordinary directory — desktop's whole implementation. Free
|
|
|
|
|
/// functions so they stay callable, and testable, without a running app.
|
|
|
|
|
mod plain {
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
pub fn list(dir: &Path) -> Result<Vec<String>, String> {
|
|
|
|
|
let entries =
|
|
|
|
|
std::fs::read_dir(dir).map_err(|e| format!("Can't read {}: {e}", dir.display()))?;
|
|
|
|
|
let mut names = Vec::new();
|
|
|
|
|
for entry in entries.flatten() {
|
|
|
|
|
if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
|
|
|
|
names.push(entry.file_name().to_string_lossy().into_owned());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(names)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn read(dir: &Path, name: &str) -> Result<Option<String>, String> {
|
|
|
|
|
let path = dir.join(name);
|
|
|
|
|
if !path.is_file() {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
std::fs::read_to_string(&path)
|
|
|
|
|
.map(Some)
|
|
|
|
|
.map_err(|e| format!("Can't read {}: {e}", path.display()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn write(dir: &Path, name: &str, text: &str) -> Result<(), String> {
|
|
|
|
|
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
|
|
|
|
// Write beside the target and rename, so whatever is watching the
|
|
|
|
|
// folder never observes a half-written document.
|
|
|
|
|
let tmp = dir.join(format!("{name}.tmp"));
|
|
|
|
|
std::fs::write(&tmp, text)
|
|
|
|
|
.map_err(|e| format!("Can't write in {}: {e}", dir.display()))?;
|
|
|
|
|
std::fs::rename(&tmp, dir.join(name)).map_err(|e| {
|
|
|
|
|
let _ = std::fs::remove_file(&tmp);
|
|
|
|
|
format!("Can't save {name} in {}: {e}", dir.display())
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn delete(dir: &Path, name: &str) -> Result<(), String> {
|
|
|
|
|
std::fs::remove_file(dir.join(name))
|
|
|
|
|
.map_err(|e| format!("Can't remove {name}: {e}"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Where the library document lives, however this platform reaches it.
|
|
|
|
|
struct Location {
|
|
|
|
|
folder: Folder,
|
|
|
|
|
doc_name: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum Folder {
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
Picker,
|
|
|
|
|
Path(PathBuf),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Location {
|
|
|
|
|
fn list(&self, #[allow(unused_variables)] app: &AppHandle) -> Result<Vec<String>, String> {
|
|
|
|
|
match &self.folder {
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
Folder::Picker => bridge::list(app),
|
|
|
|
|
Folder::Path(dir) => plain::list(dir),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Read a document by name. `Ok(None)` means it isn't there — a normal
|
|
|
|
|
/// state (nothing published yet), not a failure.
|
|
|
|
|
fn read(
|
|
|
|
|
&self,
|
|
|
|
|
#[allow(unused_variables)] app: &AppHandle,
|
|
|
|
|
#[allow(unused_variables)] data_dir: &Path,
|
|
|
|
|
name: &str,
|
|
|
|
|
) -> Result<Option<String>, String> {
|
|
|
|
|
match &self.folder {
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
Folder::Picker => {
|
|
|
|
|
let staged = staging_path(data_dir, "incoming.json");
|
|
|
|
|
if !bridge::read_into(app, name, &staged)? {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?;
|
|
|
|
|
let _ = std::fs::remove_file(&staged);
|
|
|
|
|
Ok(Some(text))
|
|
|
|
|
}
|
|
|
|
|
Folder::Path(dir) => plain::read(dir, name),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write(
|
|
|
|
|
&self,
|
|
|
|
|
#[allow(unused_variables)] app: &AppHandle,
|
|
|
|
|
#[allow(unused_variables)] data_dir: &Path,
|
|
|
|
|
text: &str,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
match &self.folder {
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
Folder::Picker => {
|
|
|
|
|
let staged = staging_path(data_dir, "outgoing.json");
|
|
|
|
|
if let Some(parent) = staged.parent() {
|
|
|
|
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
|
|
|
|
}
|
|
|
|
|
std::fs::write(&staged, text).map_err(|e| e.to_string())?;
|
|
|
|
|
let result = bridge::write_from(app, &self.doc_name, &staged);
|
|
|
|
|
let _ = std::fs::remove_file(&staged);
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
Folder::Path(dir) => plain::write(dir, &self.doc_name, text),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn delete(&self, #[allow(unused_variables)] app: &AppHandle, name: &str) -> Result<(), String> {
|
|
|
|
|
match &self.folder {
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
Folder::Picker => bridge::delete(app, name),
|
|
|
|
|
Folder::Path(dir) => plain::delete(dir, name),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Names of files that look like conflict copies of our document — what
|
|
|
|
|
/// sync tools leave behind when two devices publish while offline.
|
|
|
|
|
///
|
|
|
|
|
/// Matched by shape (same stem, `.json`) rather than by any tool's naming
|
|
|
|
|
/// convention, so it works for Syncthing, Dropbox, Nextcloud and the rest
|
|
|
|
|
/// without knowing about any of them.
|
|
|
|
|
fn conflict_copies(&self, app: &AppHandle) -> Vec<String> {
|
|
|
|
|
conflict_copies_of(&self.doc_name, self.list(app).unwrap_or_default())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Pick the conflict copies of `doc_name` out of a directory listing.
|
|
|
|
|
///
|
|
|
|
|
/// Split out from [`Location::conflict_copies`] so the matching rule — the part
|
|
|
|
|
/// with all the edge cases — is testable without a running app.
|
|
|
|
|
fn conflict_copies_of(doc_name: &str, names: Vec<String>) -> Vec<String> {
|
|
|
|
|
let stem = doc_name.strip_suffix(".json").unwrap_or(doc_name);
|
|
|
|
|
let mut found: Vec<String> = names
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|n| {
|
|
|
|
|
n != doc_name
|
|
|
|
|
&& n.starts_with(stem)
|
|
|
|
|
&& n.ends_with(".json")
|
|
|
|
|
// Our own atomic-write temporary, if a previous run died
|
|
|
|
|
// between writing and renaming.
|
|
|
|
|
&& !n.ends_with(".json.tmp")
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
found.sort();
|
|
|
|
|
found
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve where the document lives.
|
|
|
|
|
///
|
|
|
|
|
/// Never fails for want of a choice: with nothing picked, the document sits in
|
|
|
|
|
/// app-private storage, so the library is always saved somewhere. Choosing a
|
|
|
|
|
/// synced location is a preference on top of that.
|
|
|
|
|
fn locate(#[allow(unused_variables)] app: &AppHandle, db: &Db) -> Result<Location, String> {
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
{
|
|
|
|
|
let status = bridge::status(app)?;
|
|
|
|
|
if status.chosen && !status.granted {
|
|
|
|
|
// Silently falling back to private storage here would look like the
|
|
|
|
|
// library had been emptied, so this is worth surfacing.
|
|
|
|
|
return Err("Access to your library folder was lost — choose it again.".into());
|
|
|
|
|
}
|
|
|
|
|
if status.granted {
|
|
|
|
|
return Ok(Location {
|
|
|
|
|
folder: Folder::Picker,
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
doc_name: db
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.document_name()
|
|
|
|
|
.unwrap_or(DEFAULT_DOC_NAME)
|
|
|
|
|
.to_string(),
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let path = db.lock().unwrap().effective_document_path();
|
|
|
|
|
let dir = path
|
|
|
|
|
.parent()
|
|
|
|
|
.ok_or_else(|| format!("{} has no folder.", path.display()))?
|
|
|
|
|
.to_path_buf();
|
|
|
|
|
let doc_name = path
|
|
|
|
|
.file_name()
|
|
|
|
|
.ok_or_else(|| format!("{} isn't a file name.", path.display()))?
|
|
|
|
|
.to_string_lossy()
|
|
|
|
|
.into_owned();
|
|
|
|
|
Ok(Location { folder: Folder::Path(dir), doc_name })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub fn sync_status(
|
|
|
|
|
#[allow(unused_variables)] app: AppHandle,
|
|
|
|
|
db: Db,
|
|
|
|
|
) -> Result<SyncStatus, String> {
|
|
|
|
|
let legacy_library = db
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.legacy_library_path()
|
|
|
|
|
.map(|p| p.display().to_string());
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
{
|
|
|
|
|
let s = bridge::status(&app)?;
|
|
|
|
|
if s.chosen {
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
let name = db
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.document_name()
|
|
|
|
|
.unwrap_or(DEFAULT_DOC_NAME)
|
|
|
|
|
.to_string();
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
return Ok(SyncStatus {
|
|
|
|
|
chosen: true,
|
|
|
|
|
granted: s.granted,
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
// Show the folder *and* the file, since on Android they're
|
|
|
|
|
// chosen in two separate steps.
|
|
|
|
|
label: s.label.map(|l| format!("{l}/{name}")),
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
picker_based: true,
|
|
|
|
|
legacy_library,
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
document_name: Some(name),
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// No folder picked: the library is in private storage and works, it
|
|
|
|
|
// just isn't synced anywhere yet.
|
|
|
|
|
return Ok(SyncStatus {
|
|
|
|
|
chosen: false,
|
|
|
|
|
granted: true,
|
|
|
|
|
label: Some(
|
|
|
|
|
db.lock()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.effective_document_path()
|
|
|
|
|
.display()
|
|
|
|
|
.to_string(),
|
|
|
|
|
),
|
|
|
|
|
picker_based: true,
|
|
|
|
|
legacy_library,
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
document_name: None,
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
|
|
|
{
|
|
|
|
|
let s = db.lock().unwrap();
|
|
|
|
|
let path = s.effective_document_path();
|
|
|
|
|
Ok(SyncStatus {
|
|
|
|
|
chosen: s.document_path().is_some(),
|
|
|
|
|
// The folder can be gone — deleted, or on a drive that isn't
|
|
|
|
|
// mounted. The document itself needn't exist: sync creates it.
|
|
|
|
|
granted: path.parent().map(Path::is_dir).unwrap_or(false),
|
|
|
|
|
label: Some(path.display().to_string()),
|
|
|
|
|
picker_based: false,
|
|
|
|
|
legacy_library,
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
document_name: None,
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Android only: grant access to a folder through the system picker.
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub fn sync_pick_folder(
|
|
|
|
|
#[allow(unused_variables)] app: AppHandle,
|
|
|
|
|
db: Db,
|
|
|
|
|
) -> Result<SyncStatus, String> {
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
{
|
|
|
|
|
crate::logged("sync_pick_folder", bridge::pick_folder(&app))?;
|
|
|
|
|
sync_status(app, db)
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
|
|
|
{
|
|
|
|
|
let _ = (app, db);
|
|
|
|
|
Err("This platform chooses the library file by path.".into())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Desktop only: adopt a library document the frontend picked.
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub fn sync_set_document(
|
|
|
|
|
app: AppHandle,
|
|
|
|
|
db: Db,
|
|
|
|
|
#[allow(unused_variables)] path: String,
|
|
|
|
|
) -> Result<SyncStatus, String> {
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
{
|
|
|
|
|
let _ = (&app, &db, path);
|
|
|
|
|
Err("This platform chooses the library folder through the system picker.".into())
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
|
|
|
{
|
|
|
|
|
let mut path = PathBuf::from(path);
|
|
|
|
|
if path.extension().is_none() {
|
|
|
|
|
path.set_extension("json");
|
|
|
|
|
}
|
|
|
|
|
match path.parent() {
|
|
|
|
|
Some(dir) if dir.is_dir() => {}
|
|
|
|
|
_ => return Err(format!("{} isn't in a folder that exists.", path.display())),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Two very different intents share this one command, told apart by
|
|
|
|
|
// whether the target already holds a library:
|
|
|
|
|
//
|
|
|
|
|
// * an existing document — "open that library", so the working copy
|
|
|
|
|
// is dropped and rebuilt from it;
|
|
|
|
|
// * a new file — "keep my library here from now on", so the working
|
|
|
|
|
// copy is *kept* and published into it.
|
|
|
|
|
//
|
|
|
|
|
// Getting this wrong destroys data: always resetting would discard
|
|
|
|
|
// everything the user had whenever they pointed at a fresh file.
|
|
|
|
|
let adopting = path.is_file();
|
|
|
|
|
db.lock().unwrap().set_document_path(Some(path));
|
|
|
|
|
if adopting {
|
|
|
|
|
reset_working_copy(&app, &db)?;
|
|
|
|
|
} else {
|
|
|
|
|
// Clear only the merge base, so the new file is seen as empty and
|
|
|
|
|
// the whole current library gets published into it.
|
|
|
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
|
|
|
let _ = std::fs::remove_file(base_path(&data_dir));
|
|
|
|
|
}
|
|
|
|
|
// Either way, get the document and the working copy agreeing now rather
|
|
|
|
|
// than at some later trigger.
|
|
|
|
|
if let Err(e) = sync_now(app.clone(), db.clone()) {
|
|
|
|
|
crate::mlog!("sync_set_document: initial sync failed: {e}");
|
|
|
|
|
}
|
|
|
|
|
sync_status(app, db)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.
The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.
Two bugs found while testing this on-device:
load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.
The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00
|
|
|
/// The library files already sitting in the granted folder.
|
|
|
|
|
///
|
|
|
|
|
/// Android grants a folder, not a file, so the user has to say which of its
|
|
|
|
|
/// documents is theirs. Without this the app assumes "library.json" and would
|
|
|
|
|
/// silently create an empty second library beside one named anything else.
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub fn sync_list_documents(
|
|
|
|
|
#[allow(unused_variables)] app: AppHandle,
|
|
|
|
|
db: Db,
|
|
|
|
|
) -> Result<Vec<String>, String> {
|
|
|
|
|
let location = locate(&app, &db)?;
|
|
|
|
|
let mut names: Vec<String> = location
|
|
|
|
|
.list(&app)?
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|n| n.ends_with(".json") && !n.ends_with(".json.tmp"))
|
|
|
|
|
.collect();
|
|
|
|
|
names.sort();
|
|
|
|
|
Ok(names)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Android only: choose which file in the granted folder is the library.
|
|
|
|
|
///
|
|
|
|
|
/// Switching to a different file means adopting a different library, so the
|
|
|
|
|
/// working copy is dropped and rebuilt from it — the same rule desktop applies
|
|
|
|
|
/// when opening an existing document.
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub fn sync_set_document_name(
|
|
|
|
|
app: AppHandle,
|
|
|
|
|
db: Db,
|
|
|
|
|
#[allow(unused_variables)] name: String,
|
|
|
|
|
) -> Result<SyncStatus, String> {
|
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
|
|
|
{
|
|
|
|
|
let _ = (&app, &db, name);
|
|
|
|
|
return Err("This platform chooses the library file by full path.".into());
|
|
|
|
|
}
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
{
|
|
|
|
|
let mut name = name.trim().to_string();
|
|
|
|
|
if name.is_empty() {
|
|
|
|
|
return Err("Give the library file a name.".into());
|
|
|
|
|
}
|
|
|
|
|
if !name.ends_with(".json") {
|
|
|
|
|
name.push_str(".json");
|
|
|
|
|
}
|
|
|
|
|
// Keep it a plain file name: a path would escape the granted folder,
|
|
|
|
|
// which is the whole boundary the SAF grant establishes.
|
|
|
|
|
if name.contains('/') || name.contains('\\') {
|
|
|
|
|
return Err("The library file must sit directly in the chosen folder.".into());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let existed = {
|
|
|
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
|
|
|
let location = locate(&app, &db)?;
|
|
|
|
|
location.read(&app, &data_dir, &name)?.is_some()
|
|
|
|
|
};
|
|
|
|
|
db.lock().unwrap().set_document_name(Some(name));
|
|
|
|
|
if existed {
|
|
|
|
|
// Adopting an existing library: start from it, not from whatever
|
|
|
|
|
// this device happened to be holding.
|
|
|
|
|
reset_working_copy(&app, &db)?;
|
|
|
|
|
} else {
|
|
|
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
|
|
|
let _ = std::fs::remove_file(base_path(&data_dir));
|
|
|
|
|
}
|
|
|
|
|
if let Err(e) = sync_now(app.clone(), db.clone()) {
|
|
|
|
|
crate::mlog!("sync_set_document_name: initial sync failed: {e}");
|
|
|
|
|
}
|
|
|
|
|
sync_status(app, db)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Rework library sync around a JSON document; add card filters
Sync
The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.
Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.
Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.
SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.
Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.
Filters
Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.
Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.
The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.
Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
2026-08-07 22:03:49 -05:00
|
|
|
#[tauri::command]
|
|
|
|
|
pub fn sync_forget_document(app: AppHandle, db: Db) -> Result<SyncStatus, String> {
|
|
|
|
|
#[cfg(target_os = "android")]
|
|
|
|
|
{
|
|
|
|
|
crate::logged("sync_forget_document", bridge::forget_folder(&app))?;
|
|
|
|
|
}
|
|
|
|
|
#[cfg(not(target_os = "android"))]
|
|
|
|
|
{
|
|
|
|
|
db.lock().unwrap().set_document_path(None);
|
|
|
|
|
}
|
|
|
|
|
sync_status(app, db)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Empty the working copy and forget the merge base, so the next sync adopts
|
|
|
|
|
/// whatever the document says from scratch.
|
|
|
|
|
fn reset_working_copy(app: &AppHandle, db: &Db) -> Result<(), String> {
|
|
|
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
|
|
|
let _ = std::fs::remove_file(base_path(&data_dir));
|
|
|
|
|
|
|
|
|
|
let mut s = db.lock().unwrap();
|
|
|
|
|
let working = s.working_db_path.clone();
|
|
|
|
|
// Close first: the file can't be replaced while the catalog holds it open.
|
|
|
|
|
s.close();
|
|
|
|
|
let _ = std::fs::remove_file(&working);
|
|
|
|
|
if let Err(e) = s.reopen() {
|
|
|
|
|
crate::mlog!("reset_working_copy: reopen failed: {e}");
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Outcome of one sync.
|
|
|
|
|
#[derive(Serialize, Default)]
|
|
|
|
|
pub struct SyncReport {
|
|
|
|
|
pub changed: bool,
|
|
|
|
|
pub published: bool,
|
|
|
|
|
/// Conflict copies absorbed and cleaned up, if any.
|
|
|
|
|
pub absorbed: usize,
|
|
|
|
|
pub report: MergeReport,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Reconcile the working copy with the library document.
|
|
|
|
|
///
|
|
|
|
|
/// `Ok(None)` when a sync was already in flight and this call stood down. Sync
|
|
|
|
|
/// runs automatically on launch and on every foreground/background transition,
|
|
|
|
|
/// so overlap is routine rather than exceptional.
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub fn sync_now(app: AppHandle, db: Db) -> Result<Option<SyncReport>, String> {
|
|
|
|
|
{
|
|
|
|
|
let mut state = db.lock().unwrap();
|
|
|
|
|
if state.sync_running {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
state.sync_running = true;
|
|
|
|
|
}
|
|
|
|
|
let result = sync_inner(&app, &db);
|
|
|
|
|
db.lock().unwrap().sync_running = false;
|
|
|
|
|
result.map(Some)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sync_inner(app: &AppHandle, db: &Db) -> Result<SyncReport, String> {
|
|
|
|
|
let location = locate(app, db)?;
|
|
|
|
|
let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
|
|
|
|
let working = db.lock().unwrap().working_db_path.clone();
|
|
|
|
|
|
|
|
|
|
// 1. Read the document. Absent simply means nothing has been written yet —
|
|
|
|
|
// the file the user named gets created by the first sync.
|
|
|
|
|
let existing = location.read(app, &data_dir, &location.doc_name)?;
|
|
|
|
|
let doc_exists = existing.is_some();
|
|
|
|
|
let mut theirs = match existing {
|
|
|
|
|
Some(text) => LibraryDoc::from_json(&text).map_err(|e| e.to_string())?,
|
|
|
|
|
None => LibraryDoc::default(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 2. Absorb any conflict copies, so nothing is stranded in one.
|
|
|
|
|
let base = read_base(&data_dir);
|
|
|
|
|
let copies = location.conflict_copies(app);
|
|
|
|
|
let mut absorbed = Vec::new();
|
|
|
|
|
for name in &copies {
|
|
|
|
|
let Ok(Some(text)) = location.read(app, &data_dir, name) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let Ok(copy) = LibraryDoc::from_json(&text) else {
|
|
|
|
|
continue; // Not one of ours, or truncated mid-sync; leave it alone.
|
|
|
|
|
};
|
|
|
|
|
let (merged, _) = core_sync::merge3(&base, &theirs, ©);
|
|
|
|
|
theirs = merged;
|
|
|
|
|
absorbed.push(name.clone());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3-4. Merge our edits with theirs and make the working copy match.
|
|
|
|
|
let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?;
|
|
|
|
|
let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?;
|
|
|
|
|
let (merged, report) = core_sync::merge3(&base, &ours, &theirs);
|
|
|
|
|
if merged != ours {
|
|
|
|
|
core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5. Publish only when we have something to contribute, or the file the
|
|
|
|
|
// user named doesn't exist yet — otherwise every sync would look like a
|
|
|
|
|
// modification to whatever is watching the folder.
|
|
|
|
|
let published = merged != theirs || !doc_exists;
|
|
|
|
|
if published {
|
|
|
|
|
let text = merged.to_json().map_err(|e| e.to_string())?;
|
|
|
|
|
location.write(app, &data_dir, &text)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only now that their content is safely in the published document is it
|
|
|
|
|
// safe to remove the copies it came from.
|
|
|
|
|
if published {
|
|
|
|
|
for name in &absorbed {
|
|
|
|
|
if let Err(e) = location.delete(app, name) {
|
|
|
|
|
crate::mlog!("sync: couldn't remove absorbed copy {name}: {e}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 6. Remember the document *as read*, never the merged result. If the write
|
|
|
|
|
// above was lost, the remote still matches this base, so the next sync sees
|
|
|
|
|
// our edits as ours and republishes them instead of reading their absence
|
|
|
|
|
// from the document as a deletion.
|
|
|
|
|
write_base(&data_dir, &theirs);
|
|
|
|
|
|
|
|
|
|
crate::mlog!(
|
|
|
|
|
"sync_now: changed={} published={} absorbed={} conflicts={}",
|
|
|
|
|
!report.is_empty(),
|
|
|
|
|
published,
|
|
|
|
|
absorbed.len(),
|
|
|
|
|
report.conflicts.len()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Ok(SyncReport {
|
|
|
|
|
changed: !report.is_empty(),
|
|
|
|
|
published,
|
|
|
|
|
absorbed: absorbed.len(),
|
|
|
|
|
report,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The document as last read. Missing or unreadable yields an empty document,
|
|
|
|
|
/// which is the safe default: with no ancestor, a merge never deletes anything.
|
|
|
|
|
fn read_base(data_dir: &Path) -> LibraryDoc {
|
|
|
|
|
std::fs::read_to_string(base_path(data_dir))
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|t| LibraryDoc::from_json(&t).ok())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_base(data_dir: &Path, doc: &LibraryDoc) {
|
|
|
|
|
if let Ok(text) = doc.to_json() {
|
|
|
|
|
let _ = std::fs::write(base_path(data_dir), text);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convert a pre-document `library.db` into the current format, merging it into
|
|
|
|
|
/// the working copy so the next sync publishes it.
|
|
|
|
|
#[tauri::command]
|
|
|
|
|
pub fn convert_legacy_library(app: AppHandle, db: Db, path: String) -> Result<SyncReport, String> {
|
|
|
|
|
let legacy = PathBuf::from(&path);
|
|
|
|
|
if !legacy.is_file() {
|
|
|
|
|
return Err(format!("{} isn't there.", legacy.display()));
|
|
|
|
|
}
|
|
|
|
|
let doc = crate::logged(
|
|
|
|
|
"convert_legacy_library",
|
|
|
|
|
mtgc_core::sync::convert_legacy_library(&legacy).map_err(|e| e.to_string()),
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
let working = db.lock().unwrap().working_db_path.clone();
|
|
|
|
|
let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?;
|
|
|
|
|
let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?;
|
|
|
|
|
// Merged rather than applied outright, so converting doesn't discard
|
|
|
|
|
// anything already in the working copy. With an empty ancestor nothing is
|
|
|
|
|
// treated as a deletion.
|
|
|
|
|
let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc);
|
|
|
|
|
core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?;
|
|
|
|
|
|
|
|
|
|
db.lock().unwrap().clear_legacy_library();
|
|
|
|
|
crate::mlog!(
|
|
|
|
|
"convert_legacy_library: {} cards, {} decks from {}",
|
|
|
|
|
doc.cards.len(),
|
|
|
|
|
doc.decks.len(),
|
|
|
|
|
legacy.display()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Publish immediately when a document is already configured, so the
|
|
|
|
|
// converted library reaches the other devices without waiting.
|
|
|
|
|
if locate(&app, &db).is_ok() {
|
|
|
|
|
if let Some(r) = sync_now(app, db)? {
|
|
|
|
|
return Ok(r);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(SyncReport {
|
|
|
|
|
changed: !report.is_empty(),
|
|
|
|
|
published: false,
|
|
|
|
|
absorbed: 0,
|
|
|
|
|
report,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn scratch(name: &str) -> PathBuf {
|
|
|
|
|
let dir = std::env::temp_dir().join(format!("mtgc-doc-{name}-{}", std::process::id()));
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
|
dir
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn writing_replaces_the_document_and_leaves_no_temporary() {
|
|
|
|
|
// A stray "<name>.tmp" would be picked up as a conflict copy and, worse,
|
|
|
|
|
// synced as a partial document.
|
|
|
|
|
let dir = scratch("write");
|
|
|
|
|
plain::write(&dir, "library.json", "{\"version\":1}").unwrap();
|
|
|
|
|
plain::write(&dir, "library.json", "{}").unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(plain::read(&dir, "library.json").unwrap().unwrap(), "{}");
|
|
|
|
|
assert_eq!(plain::list(&dir).unwrap(), vec!["library.json"]);
|
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn reading_a_document_that_isnt_there_is_not_an_error() {
|
|
|
|
|
// Normal before anything has been published.
|
|
|
|
|
let dir = scratch("missing");
|
|
|
|
|
assert_eq!(plain::read(&dir, "library.json").unwrap(), None);
|
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn conflict_copies_are_matched_by_shape_not_by_tool() {
|
|
|
|
|
let dir = scratch("conflicts");
|
|
|
|
|
for name in [
|
|
|
|
|
"library.json",
|
|
|
|
|
"library.sync-conflict-20260803-120000-ABCDEFG.json", // Syncthing
|
|
|
|
|
"library (SFConflict garrettmills 2026-08-03).json", // Seafile
|
|
|
|
|
"library (conflicted copy 2026-08-03).json", // Dropbox
|
|
|
|
|
"library.json.tmp", // our own, mid-write
|
|
|
|
|
"notes.json", // unrelated
|
|
|
|
|
"reference.db", // unrelated
|
|
|
|
|
] {
|
|
|
|
|
std::fs::write(dir.join(name), "{}").unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let found = conflict_copies_of("library.json", plain::list(&dir).unwrap());
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
found,
|
|
|
|
|
vec![
|
|
|
|
|
"library (SFConflict garrettmills 2026-08-03).json",
|
|
|
|
|
"library (conflicted copy 2026-08-03).json",
|
|
|
|
|
"library.sync-conflict-20260803-120000-ABCDEFG.json",
|
|
|
|
|
],
|
|
|
|
|
"should catch every tool's naming, and nothing else"
|
|
|
|
|
);
|
|
|
|
|
std::fs::remove_dir_all(&dir).unwrap();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_differently_named_document_only_claims_its_own_copies() {
|
|
|
|
|
// The user names their own file on desktop, so the rule has to key off
|
|
|
|
|
// that name rather than assuming "library".
|
|
|
|
|
let found = conflict_copies_of(
|
|
|
|
|
"Decks.json",
|
|
|
|
|
vec![
|
|
|
|
|
"Decks.json".into(),
|
|
|
|
|
"Decks (conflicted copy).json".into(),
|
|
|
|
|
"library.sync-conflict-1.json".into(),
|
|
|
|
|
"Decks-backup.txt".into(),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(found, vec!["Decks (conflicted copy).json"]);
|
|
|
|
|
}
|
|
|
|
|
}
|