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.
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- Note: library sync needs no storage permission. The user grants access
|
||||
to one folder through the system folder picker (SAF), and that grant is
|
||||
persisted across restarts — see SyncPlugin.kt. -->
|
||||
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.garrettmills.mtgc
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.result.ActivityResult
|
||||
import app.tauri.annotation.ActivityCallback
|
||||
import app.tauri.annotation.Command
|
||||
import app.tauri.annotation.InvokeArg
|
||||
import app.tauri.annotation.TauriPlugin
|
||||
import app.tauri.plugin.Invoke
|
||||
import app.tauri.plugin.JSArray
|
||||
import app.tauri.plugin.JSObject
|
||||
import app.tauri.plugin.Plugin
|
||||
import java.io.File
|
||||
|
||||
@InvokeArg
|
||||
class FileArgs {
|
||||
/** File name inside the chosen folder, e.g. "library.json". */
|
||||
lateinit var name: String
|
||||
|
||||
/** App-private staging path the bytes are copied to/from. */
|
||||
lateinit var path: String
|
||||
}
|
||||
|
||||
@InvokeArg
|
||||
class NameArgs {
|
||||
lateinit var name: String
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges the Storage Access Framework to Rust for library sync.
|
||||
*
|
||||
* The user picks one folder through the system picker; we take a *persistable*
|
||||
* URI permission on it so the grant survives restarts. That folder is a real
|
||||
* directory in shared storage (e.g. /storage/emulated/0/mtgc), so a sync tool
|
||||
* like Syncthing can see it, but this app reaches it only through SAF and
|
||||
* therefore needs no storage permission in the manifest.
|
||||
*
|
||||
* SAF hands back `content://` URIs, which SQLite cannot open — so nothing here
|
||||
* exposes a URI to Rust. Instead every operation copies bytes between the
|
||||
* chosen folder and an app-private staging path that Rust picks, and Rust only
|
||||
* ever does plain file I/O on its own private storage. That is sufficient
|
||||
* because the synced copy is never opened as a database; it is only ever
|
||||
* written whole or read whole.
|
||||
*/
|
||||
@TauriPlugin
|
||||
class SyncPlugin(private val activity: Activity) : Plugin(activity) {
|
||||
companion object {
|
||||
private const val PREFS = "mtgc-sync"
|
||||
private const val KEY_TREE_URI = "tree-uri"
|
||||
|
||||
/** Generic type: a SQLite file has no useful registered MIME type. */
|
||||
private const val MIME = "application/octet-stream"
|
||||
|
||||
private const val NO_FOLDER =
|
||||
"No synced folder is set up yet — choose one first."
|
||||
}
|
||||
|
||||
private fun prefs() = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
/**
|
||||
* The chosen folder, but only when the persisted grant is still held.
|
||||
*
|
||||
* A saved URI isn't proof of access: the user can revoke it in system
|
||||
* settings, and clearing app data drops it. Checking the live grant list
|
||||
* keeps us from failing later with an opaque SecurityException.
|
||||
*/
|
||||
private fun grantedTree(): Uri? {
|
||||
val saved = prefs().getString(KEY_TREE_URI, null) ?: return null
|
||||
val uri = Uri.parse(saved)
|
||||
val held = activity.contentResolver.persistedUriPermissions.any {
|
||||
it.uri == uri && it.isReadPermission && it.isWritePermission
|
||||
}
|
||||
return if (held) uri else null
|
||||
}
|
||||
|
||||
/** Best-effort human-readable name for the folder, for display in the UI. */
|
||||
private fun label(uri: Uri): String = try {
|
||||
val docId = DocumentsContract.getTreeDocumentId(uri)
|
||||
val parts = docId.split(":", limit = 2)
|
||||
if (parts.size == 2 && parts[0].equals("primary", ignoreCase = true)) {
|
||||
"Internal storage/${parts[1]}"
|
||||
} else {
|
||||
docId
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
uri.toString()
|
||||
}
|
||||
|
||||
private fun statusObject(): JSObject {
|
||||
val saved = prefs().getString(KEY_TREE_URI, null)
|
||||
val granted = grantedTree()
|
||||
return JSObject().apply {
|
||||
// "chosen but not granted" is a distinct, recoverable state worth
|
||||
// telling the user about — it means re-pick, not first-time setup.
|
||||
put("chosen", saved != null)
|
||||
put("granted", granted != null)
|
||||
put("label", saved?.let { label(Uri.parse(it)) })
|
||||
}
|
||||
}
|
||||
|
||||
@Command
|
||||
fun status(invoke: Invoke) {
|
||||
invoke.resolve(statusObject())
|
||||
}
|
||||
|
||||
@Command
|
||||
fun pickFolder(invoke: Invoke) {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
|
||||
addFlags(
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
)
|
||||
}
|
||||
startActivityForResult(invoke, intent, "folderPicked")
|
||||
}
|
||||
|
||||
@ActivityCallback
|
||||
fun folderPicked(invoke: Invoke, result: ActivityResult) {
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode != Activity.RESULT_OK || uri == null) {
|
||||
// Backing out of the picker is a normal choice, not a failure.
|
||||
invoke.resolve(statusObject().apply { put("cancelled", true) })
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Without this the grant dies with the activity.
|
||||
activity.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Couldn't keep access to that folder: ${e.message}")
|
||||
return
|
||||
}
|
||||
prefs().edit().putString(KEY_TREE_URI, uri.toString()).apply()
|
||||
invoke.resolve(statusObject().apply { put("cancelled", false) })
|
||||
}
|
||||
|
||||
@Command
|
||||
fun forgetFolder(invoke: Invoke) {
|
||||
prefs().getString(KEY_TREE_URI, null)?.let {
|
||||
try {
|
||||
activity.contentResolver.releasePersistableUriPermission(
|
||||
Uri.parse(it),
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
// Already gone; clearing our own record below is what matters.
|
||||
}
|
||||
}
|
||||
prefs().edit().remove(KEY_TREE_URI).apply()
|
||||
invoke.resolve(statusObject())
|
||||
}
|
||||
|
||||
/** Locate a direct child of the chosen folder by display name. */
|
||||
private fun findChild(tree: Uri, name: String): Uri? {
|
||||
val children = DocumentsContract.buildChildDocumentsUriUsingTree(
|
||||
tree,
|
||||
DocumentsContract.getTreeDocumentId(tree)
|
||||
)
|
||||
val projection = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME
|
||||
)
|
||||
activity.contentResolver.query(children, projection, null, null, null)
|
||||
?.use { c: Cursor ->
|
||||
while (c.moveToNext()) {
|
||||
if (c.getString(1) == name) {
|
||||
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of the files directly inside the chosen folder. Rust uses this to
|
||||
* discover the other devices' snapshots; which names matter is entirely its
|
||||
* business, so everything is returned unfiltered.
|
||||
*/
|
||||
@Command
|
||||
fun list(invoke: Invoke) {
|
||||
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
|
||||
try {
|
||||
val names = JSArray()
|
||||
val children = DocumentsContract.buildChildDocumentsUriUsingTree(
|
||||
tree,
|
||||
DocumentsContract.getTreeDocumentId(tree)
|
||||
)
|
||||
val projection = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
|
||||
activity.contentResolver.query(children, projection, null, null, null)
|
||||
?.use { c: Cursor ->
|
||||
while (c.moveToNext()) {
|
||||
c.getString(0)?.let { names.put(it) }
|
||||
}
|
||||
}
|
||||
invoke.resolve(JSObject().apply { put("names", names) })
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Couldn't list the synced folder: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a file from the chosen folder.
|
||||
*
|
||||
* Used only to clear away conflict copies once their contents have been
|
||||
* merged and republished — never to delete the library document itself.
|
||||
*/
|
||||
@Command
|
||||
fun deleteFile(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(NameArgs::class.java)
|
||||
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
|
||||
val doc = findChild(tree, args.name)
|
||||
if (doc == null) {
|
||||
invoke.resolve(JSObject().apply { put("removed", false) })
|
||||
return
|
||||
}
|
||||
try {
|
||||
val removed = DocumentsContract.deleteDocument(activity.contentResolver, doc)
|
||||
invoke.resolve(JSObject().apply { put("removed", removed) })
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Couldn't remove ${args.name}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy `name` out of the chosen folder into the app-private `path`. */
|
||||
@Command
|
||||
fun readInto(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(FileArgs::class.java)
|
||||
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
|
||||
val doc = findChild(tree, args.name)
|
||||
if (doc == null) {
|
||||
// A missing file is an expected state (nothing synced here yet), not an
|
||||
// error — the caller decides what to do about it.
|
||||
invoke.resolve(JSObject().apply {
|
||||
put("found", false)
|
||||
put("bytes", 0L)
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
val dest = File(args.path)
|
||||
dest.parentFile?.mkdirs()
|
||||
val copied = activity.contentResolver.openInputStream(doc)!!.use { input ->
|
||||
dest.outputStream().use { out -> input.copyTo(out) }
|
||||
}
|
||||
invoke.resolve(JSObject().apply {
|
||||
put("found", true)
|
||||
put("bytes", copied)
|
||||
})
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Couldn't read ${args.name} from the synced folder: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy the app-private `path` into the chosen folder as `name`. */
|
||||
@Command
|
||||
fun writeFrom(invoke: Invoke) {
|
||||
val args = invoke.parseArgs(FileArgs::class.java)
|
||||
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
|
||||
val src = File(args.path)
|
||||
if (!src.isFile) return invoke.reject("Nothing to upload at ${args.path}")
|
||||
try {
|
||||
// Overwrite the existing document when there is one, rather than
|
||||
// delete-and-recreate: a sync tool then sees one modified file instead
|
||||
// of a removal followed by an unrelated addition.
|
||||
val doc = findChild(tree, args.name)
|
||||
?: DocumentsContract.createDocument(
|
||||
activity.contentResolver,
|
||||
DocumentsContract.buildDocumentUriUsingTree(
|
||||
tree,
|
||||
DocumentsContract.getTreeDocumentId(tree)
|
||||
),
|
||||
MIME,
|
||||
args.name
|
||||
)
|
||||
?: return invoke.reject("Couldn't create ${args.name} in the synced folder")
|
||||
// "wt" truncates first. Plain "w" leaves any trailing bytes of a longer
|
||||
// previous file in place, which would silently corrupt a database that
|
||||
// got smaller.
|
||||
val copied = activity.contentResolver.openOutputStream(doc, "wt")!!.use { out ->
|
||||
src.inputStream().use { input -> input.copyTo(out) }
|
||||
}
|
||||
invoke.resolve(JSObject().apply { put("bytes", copied) })
|
||||
} catch (e: Exception) {
|
||||
invoke.reject("Couldn't write ${args.name} to the synced folder: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,11 @@ fn status_of(s: &AppState) -> Status {
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64);
|
||||
Status {
|
||||
library_path: s.library_path.display().to_string(),
|
||||
// Where the library document lives, not the internal working database —
|
||||
// that one is an implementation detail the user never sees. Always
|
||||
// populated: with nothing chosen it's the default in private storage,
|
||||
// so the path bar never goes blank.
|
||||
library_path: s.effective_document_path().display().to_string(),
|
||||
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),
|
||||
@@ -66,18 +70,6 @@ pub fn get_status(db: Db) -> Res<Status> {
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_library_path(db: Db, path: String) -> Res<Status> {
|
||||
let mut s = db.lock().unwrap();
|
||||
s.set_library_path(PathBuf::from(path));
|
||||
// Best-effort reopen: the chosen path must stick (and be reported back)
|
||||
// even if the reference db can't currently be attached (e.g. stale
|
||||
// schema pending an "Update card data" run) — that's a separate concern
|
||||
// from which library file is open.
|
||||
let _ = s.reopen();
|
||||
Ok(status_of(&s))
|
||||
}
|
||||
|
||||
/// Download Scryfall bulk data and (re)build reference.db, then reopen.
|
||||
/// Runs on a blocking thread so the UI stays responsive and progress events flow.
|
||||
#[tauri::command]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod commands;
|
||||
mod state;
|
||||
mod sync;
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -31,10 +32,27 @@ pub(crate) fn logged<T>(what: &str, result: Result<T, String>) -> Result<T, Stri
|
||||
result
|
||||
}
|
||||
|
||||
/// Register the Kotlin-side `SyncPlugin` and stash its handle in app state, so
|
||||
/// the sync commands can call across to SAF. See `sync.rs` for why library sync
|
||||
/// goes through a Java plugin rather than plain `std::fs`.
|
||||
#[cfg(target_os = "android")]
|
||||
fn sync_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
|
||||
tauri::plugin::Builder::new("mtgc-sync")
|
||||
.setup(|app, api| {
|
||||
let handle = api.register_android_plugin("com.garrettmills.mtgc", "SyncPlugin")?;
|
||||
app.manage(sync::SyncBridge(handle));
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init());
|
||||
#[cfg(target_os = "android")]
|
||||
let builder = builder.plugin(sync_plugin());
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
let data_dir = app
|
||||
.path()
|
||||
@@ -47,10 +65,15 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_status,
|
||||
commands::set_library_path,
|
||||
commands::update_card_data,
|
||||
commands::cancel_update_card_data,
|
||||
commands::import_reference_db,
|
||||
sync::sync_status,
|
||||
sync::sync_pick_folder,
|
||||
sync::sync_set_document,
|
||||
sync::sync_forget_document,
|
||||
sync::sync_now,
|
||||
sync::convert_legacy_library,
|
||||
commands::search_cards,
|
||||
commands::add_owned,
|
||||
commands::set_owned,
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the db paths.
|
||||
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths.
|
||||
//!
|
||||
//! The user's library is a JSON *document* in a folder they sync. The SQLite
|
||||
//! database behind it is an internal working copy under the app data dir that
|
||||
//! they never see, name, or move — it can always be rebuilt by merging the
|
||||
//! document into an empty one (see [`mtgc_core::sync`]).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use mtgc_core::{Catalog, NameIndex};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Small app-shell preferences persisted across restarts (distinct from the
|
||||
/// user's library data) — currently just which library file was last open.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
/// Small app-shell preferences persisted across restarts, distinct from the
|
||||
/// user's library data.
|
||||
#[derive(Default, Serialize, Deserialize, Clone)]
|
||||
struct PersistedConfig {
|
||||
library_path: Option<PathBuf>,
|
||||
/// The library document the user chose. Desktop only — Android reaches its
|
||||
/// folder through a SAF grant held on the Java side rather than by path,
|
||||
/// so there is no path to persist there (see `sync.rs`).
|
||||
#[serde(default)]
|
||||
document_path: Option<PathBuf>,
|
||||
/// A pre-document `library.db` inherited from an older build, kept until
|
||||
/// it has been converted so the app can offer to do that once.
|
||||
#[serde(default)]
|
||||
legacy_library_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn config_path(data_dir: &Path) -> PathBuf {
|
||||
@@ -17,10 +30,32 @@ fn config_path(data_dir: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
fn load_config(data_dir: &Path) -> PersistedConfig {
|
||||
std::fs::read_to_string(config_path(data_dir))
|
||||
let text = match std::fs::read_to_string(config_path(data_dir)) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return PersistedConfig::default(),
|
||||
};
|
||||
if let Ok(cfg) = serde_json::from_str::<PersistedConfig>(&text) {
|
||||
// Nothing to carry over if it already parses as the current shape,
|
||||
// unless it's an old config that only had `library_path`.
|
||||
if cfg.document_path.is_some() || cfg.legacy_library_path.is_some() {
|
||||
return cfg;
|
||||
}
|
||||
}
|
||||
// Older builds stored `library_path` pointing at a SQLite file the user
|
||||
// managed themselves. Remember it so the UI can offer to convert it, but
|
||||
// never open it as the working copy.
|
||||
#[derive(Deserialize)]
|
||||
struct OldConfig {
|
||||
library_path: Option<PathBuf>,
|
||||
}
|
||||
let legacy = serde_json::from_str::<OldConfig>(&text)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
.and_then(|c| c.library_path)
|
||||
.filter(|p| p.extension().is_some_and(|e| e != "json"));
|
||||
PersistedConfig {
|
||||
document_path: None,
|
||||
legacy_library_path: legacy,
|
||||
}
|
||||
}
|
||||
|
||||
fn save_config(data_dir: &Path, cfg: &PersistedConfig) {
|
||||
@@ -32,35 +67,41 @@ fn save_config(data_dir: &Path, cfg: &PersistedConfig) {
|
||||
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
|
||||
pub struct AppState {
|
||||
data_dir: PathBuf,
|
||||
pub library_path: PathBuf,
|
||||
/// Held whole so that writing one preference can't drop the others — the
|
||||
/// setters below persist this rather than reconstructing a fresh config.
|
||||
config: PersistedConfig,
|
||||
/// Internal SQLite working copy. Derived from the document; never chosen
|
||||
/// by, shown to, or moved by the user.
|
||||
pub working_db_path: PathBuf,
|
||||
pub reference_path: PathBuf,
|
||||
pub catalog: Option<Catalog>,
|
||||
pub index: Option<NameIndex>,
|
||||
/// Set while `update_card_data` is running; `cancel_update_card_data`
|
||||
/// flips it to abort the in-progress ingest.
|
||||
pub ingest_cancel: Option<mtgc_core::ingest::CancelToken>,
|
||||
/// Set while a sync is in flight. Sync fires automatically on launch and on
|
||||
/// every foreground/background transition, so overlapping calls are easy to
|
||||
/// provoke; a second one would merge the same document concurrently.
|
||||
pub sync_running: bool,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Initialize with the last-opened library path (or the default under the
|
||||
/// app data dir, on first run) and open the catalog if a reference db
|
||||
/// already exists.
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
let cfg = load_config(&data_dir);
|
||||
let library_path = cfg
|
||||
.library_path
|
||||
.unwrap_or_else(|| data_dir.join("library.db"));
|
||||
let config = load_config(&data_dir);
|
||||
let mut s = Self {
|
||||
reference_path: data_dir.join("reference.db"),
|
||||
library_path,
|
||||
working_db_path: data_dir.join("working.db"),
|
||||
config,
|
||||
data_dir,
|
||||
catalog: None,
|
||||
index: None,
|
||||
ingest_cancel: None,
|
||||
sync_running: false,
|
||||
};
|
||||
crate::mlog!(
|
||||
"AppState::new: library={} reference={} (exists={})",
|
||||
s.library_path.display(),
|
||||
"AppState::new: document={:?} working={} reference={} (exists={})",
|
||||
s.config.document_path,
|
||||
s.working_db_path.display(),
|
||||
s.reference_path.display(),
|
||||
s.reference_path.exists()
|
||||
);
|
||||
@@ -75,16 +116,44 @@ impl AppState {
|
||||
s
|
||||
}
|
||||
|
||||
/// Point at a different library file and persist it so the next launch
|
||||
/// reopens the same one.
|
||||
pub fn set_library_path(&mut self, path: PathBuf) {
|
||||
self.library_path = path.clone();
|
||||
save_config(
|
||||
&self.data_dir,
|
||||
&PersistedConfig {
|
||||
library_path: Some(path),
|
||||
},
|
||||
);
|
||||
/// The library document the user explicitly chose, if they have.
|
||||
pub fn document_path(&self) -> Option<&Path> {
|
||||
self.config.document_path.as_deref()
|
||||
}
|
||||
|
||||
/// Where the library document actually lives right now.
|
||||
///
|
||||
/// Falls back to app-private storage so there is *always* a document. The
|
||||
/// alternative — no document until the user picks one — meant edits piled
|
||||
/// up in the working copy with nowhere to be saved, and choosing a
|
||||
/// destination later would discard them. Picking a location is a
|
||||
/// preference, not a precondition for using the app.
|
||||
pub fn effective_document_path(&self) -> PathBuf {
|
||||
self.config
|
||||
.document_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.default_document_path())
|
||||
}
|
||||
|
||||
pub fn default_document_path(&self) -> PathBuf {
|
||||
self.data_dir.join("library.json")
|
||||
}
|
||||
|
||||
/// Point at (or, with `None`, forget) the library document.
|
||||
pub fn set_document_path(&mut self, path: Option<PathBuf>) {
|
||||
self.config.document_path = path;
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
|
||||
/// A pre-document `library.db` still waiting to be converted.
|
||||
pub fn legacy_library_path(&self) -> Option<&Path> {
|
||||
self.config.legacy_library_path.as_deref()
|
||||
}
|
||||
|
||||
/// Forget the legacy library, once it's been converted or dismissed.
|
||||
pub fn clear_legacy_library(&mut self) {
|
||||
self.config.legacy_library_path = None;
|
||||
save_config(&self.data_dir, &self.config);
|
||||
}
|
||||
|
||||
/// Drop the open catalog (and its name index), releasing the handles on both
|
||||
@@ -100,7 +169,7 @@ impl AppState {
|
||||
/// db and make every query fail.
|
||||
pub fn reopen(&mut self) -> Result<(), String> {
|
||||
if self.reference_path.exists() {
|
||||
let catalog = Catalog::open(&self.library_path, &self.reference_path)
|
||||
let catalog = Catalog::open(&self.working_db_path, &self.reference_path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let index = catalog.build_name_index().map_err(|e| e.to_string())?;
|
||||
self.index = Some(index);
|
||||
|
||||
744
src-tauri/src/sync.rs
Normal file
744
src-tauri/src/sync.rs
Normal file
@@ -0,0 +1,744 @@
|
||||
//! 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>>;
|
||||
|
||||
/// Document name used on Android, where the user grants a folder rather than
|
||||
/// choosing a file. Desktop uses whatever they named their file.
|
||||
#[cfg(target_os = "android")]
|
||||
const ANDROID_DOC_NAME: &str = "library.json";
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
doc_name: ANDROID_DOC_NAME.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return Ok(SyncStatus {
|
||||
chosen: true,
|
||||
granted: s.granted,
|
||||
label: s.label,
|
||||
picker_based: true,
|
||||
legacy_library,
|
||||
});
|
||||
}
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
#[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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[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"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user