Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
289 lines
11 KiB
Rust
289 lines
11 KiB
Rust
//! The two offers made when the catalog turns out to be damaged.
|
|
//!
|
|
//! `dr_catalog::recovery` owns the mechanism — the integrity check, the
|
|
//! backups, the restore, setting the damaged file aside. This module owns the
|
|
//! *conversation*: what a user is told has happened, which of the two answers
|
|
//! are available, and what runs afterwards.
|
|
//!
|
|
//! # The thing that has to be said first
|
|
//!
|
|
//! **The photographs are fine, and so are the edits.** A user told that their
|
|
//! library database is corrupt will assume they have lost their work, because
|
|
//! in every other photo application they would have. Here they have not:
|
|
//! sources are read-only to this application (NFR-R4), and ratings, keywords
|
|
//! and edit graphs live in sidecars beside the images for every catalogued
|
|
//! photograph, whether or not an account exists (FR-CAT-8, invariant §5.2.4).
|
|
//! That sentence is the first line of the dialogue, before the diagnosis,
|
|
//! because it is the answer to the question the user is actually asking.
|
|
//!
|
|
//! # Why the two answers are not interchangeable
|
|
//!
|
|
//! A restore brings back **collections**; a rebuild cannot. Every other thing
|
|
//! the catalog holds has authoritative backing outside it, which is what makes
|
|
//! a rebuild survivable — but a manual collection is a set of images the user
|
|
//! assembled by hand and nothing in the filesystem records it
|
|
//! (`docs/catalog.md` §8.1). So the labels say which one loses them, and the
|
|
//! rebuild is not given the affirmative styling while a restore is on offer.
|
|
//!
|
|
//! # Why the scan is held back
|
|
//!
|
|
//! `library_ui::open` shows the catalog and then starts a scan. On a damaged
|
|
//! catalog the scan is actively harmful: a plain `Catalog::open` on a file
|
|
//! whose header is intact succeeds, and the scan would then write folder
|
|
//! ETags and image rows into damaged pages — turning a recoverable file into
|
|
//! one whose backup is the only copy left, and doing it in the seconds while
|
|
//! the user is still reading the question. So `show_catalog_now` reports
|
|
//! whether it is safe to continue, and this module restarts the scan itself
|
|
//! once the file underneath has been replaced.
|
|
|
|
use std::cell::RefCell;
|
|
use std::path::{Path, PathBuf};
|
|
use std::rc::Rc;
|
|
|
|
use dr_catalog::recovery;
|
|
use slint::ComponentHandle;
|
|
|
|
use crate::library_ui::LibraryController;
|
|
use crate::AppWindow;
|
|
|
|
// What the open question is about.
|
|
//
|
|
// A thread-local rather than a field on `LibraryController`, because the
|
|
// question is asked *before* that controller has a catalog and is answered by
|
|
// callbacks wired at startup. Thread-local is sound here for the same reason
|
|
// `crate::memory`'s registry is: everything below runs on the Slint event
|
|
// loop thread, which is the only thread that has an `AppWindow` to show it
|
|
// on.
|
|
//
|
|
// Plain `//` rather than `///`: rustdoc does not document a macro invocation,
|
|
// and `-D warnings` rejects a doc comment that can never be rendered.
|
|
thread_local! {
|
|
static PENDING: RefCell<Option<Pending>> = const { RefCell::new(None) };
|
|
}
|
|
|
|
/// The damaged catalog and what can be done about it.
|
|
struct Pending {
|
|
catalog: PathBuf,
|
|
/// Newest first. Empty is the ordinary case on a young install and is not
|
|
/// an error — it removes one offer, not both.
|
|
backups: Vec<recovery::Backup>,
|
|
}
|
|
|
|
/// Ask what should happen to a damaged catalog.
|
|
///
|
|
/// Called from `library_ui::show_catalog_now` when the startup integrity check
|
|
/// fails. `detail` is what SQLite said, carried through verbatim: a diagnosis
|
|
/// the user can quote into a bug report is worth more than a reassurance they
|
|
/// cannot check.
|
|
pub(crate) fn offer(window: &AppWindow, catalog: &Path, detail: &str) {
|
|
let backups = recovery::backups(catalog);
|
|
log::error!(
|
|
"catalog {} failed its integrity check: {detail} ({} backup(s) available)",
|
|
catalog.display(),
|
|
backups.len()
|
|
);
|
|
|
|
window.set_recovery_title("This library's index is damaged".into());
|
|
window.set_recovery_detail(
|
|
// Two facts and their order matters: what is safe, then what is lost.
|
|
"Your photographs and your edits are safe — they are in the files \
|
|
themselves and in the sidecars beside them. What is damaged is only \
|
|
DarkRoom's index of them, which can be rebuilt."
|
|
.into(),
|
|
);
|
|
window.set_recovery_diagnosis(detail.into());
|
|
|
|
match backups.first() {
|
|
Some(newest) => {
|
|
window.set_recovery_can_restore(true);
|
|
window.set_recovery_restore_label(
|
|
format!(
|
|
"Restore the backup from {} · keeps your collections",
|
|
describe_age(newest.taken_at)
|
|
)
|
|
.into(),
|
|
);
|
|
}
|
|
None => {
|
|
window.set_recovery_can_restore(false);
|
|
window.set_recovery_restore_label(slint::SharedString::new());
|
|
}
|
|
}
|
|
|
|
window.set_recovery_rebuild_label(
|
|
if backups.is_empty() {
|
|
// Nothing to compare it against, so the label states the cost
|
|
// rather than the difference.
|
|
"Rebuild from your photographs · rescans the library"
|
|
} else {
|
|
"Rebuild from your photographs · loses your collections"
|
|
}
|
|
.into(),
|
|
);
|
|
window.set_recovery_busy(false);
|
|
// The scan was held back, so the "Scanning…" the grid is showing behind
|
|
// this would be a lie the moment the question is dismissed.
|
|
window.set_library_scanning(false);
|
|
|
|
PENDING.with(|p| {
|
|
*p.borrow_mut() = Some(Pending {
|
|
catalog: catalog.to_path_buf(),
|
|
backups,
|
|
})
|
|
});
|
|
}
|
|
|
|
/// Close the question without answering it.
|
|
///
|
|
/// Leaves the banner set, because the library genuinely does not work and a
|
|
/// dialogue that vanishes leaving no trace of why nothing loads is worse than
|
|
/// no dialogue at all.
|
|
fn dismiss(window: &AppWindow) {
|
|
PENDING.with(|p| *p.borrow_mut() = None);
|
|
window.set_recovery_title(slint::SharedString::new());
|
|
window.set_library_scanning(false);
|
|
window.set_library_error(
|
|
"The library index is damaged. Rescan to rebuild it, or restore a backup.".into(),
|
|
);
|
|
}
|
|
|
|
/// Install the three answers.
|
|
///
|
|
/// Called at the end of `library_ui::wire`, which is where every other
|
|
/// window-level callback in this area is installed.
|
|
pub(crate) fn wire(
|
|
window: &AppWindow,
|
|
ctl: &Rc<LibraryController>,
|
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
|
) {
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let coll = coll_ctl.clone();
|
|
window.on_recovery_restore(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
answer(&w, &ctl, &coll, Answer::Restore);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
let ctl = ctl.clone();
|
|
let coll = coll_ctl.clone();
|
|
window.on_recovery_rebuild(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
answer(&w, &ctl, &coll, Answer::Rebuild);
|
|
});
|
|
}
|
|
|
|
{
|
|
let weak = window.as_weak();
|
|
window.on_recovery_dismiss(move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
dismiss(&w);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Which of the two the user chose.
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum Answer {
|
|
Restore,
|
|
Rebuild,
|
|
}
|
|
|
|
/// Carry out an answer, then get the library going again.
|
|
///
|
|
/// Both answers end the same way — the file under `catalog_path` is one this
|
|
/// build can open — so both continue into the same two steps: open the catalog
|
|
/// for the grid, and start a scan. A rebuild needs the scan to have anything
|
|
/// at all; a restore needs it because the backup is by definition older than
|
|
/// the library.
|
|
fn answer(
|
|
window: &AppWindow,
|
|
ctl: &Rc<LibraryController>,
|
|
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
|
which: Answer,
|
|
) {
|
|
let Some(pending) = PENDING.with(|p| p.borrow_mut().take()) else {
|
|
return;
|
|
};
|
|
window.set_recovery_busy(true);
|
|
|
|
// Before the file moves, not after. There is normally no open catalog here
|
|
// — `show_catalog_now` returned before storing one — but "normally" is not
|
|
// a guarantee worth resting a file rename on, and a connection to a file
|
|
// that has just been renamed out from under it reads the damaged pages
|
|
// forever.
|
|
crate::library_ui::forget_catalog(ctl);
|
|
|
|
// Synchronous, on the UI thread, and that is a considered choice rather
|
|
// than an oversight: this is a file copy of a catalog — tens of megabytes
|
|
// at 50k images — at a moment when there is nothing else on screen to
|
|
// block, no scan running, and no frame worth keeping smooth. Moving it to
|
|
// a worker would buy a spinner and cost the guarantee that nothing else
|
|
// touches the file while it is being replaced.
|
|
let outcome = match which {
|
|
Answer::Restore => match pending.backups.first() {
|
|
Some(b) => recovery::restore(&pending.catalog, &b.path),
|
|
None => Ok(()),
|
|
},
|
|
Answer::Rebuild => recovery::set_aside(&pending.catalog).map(|_| ()),
|
|
};
|
|
|
|
if let Err(e) = outcome {
|
|
// The question stays up: the *other* answer may still work, and a
|
|
// failed restore in particular leaves the rebuild untouched.
|
|
log::error!("recovery failed: {e}");
|
|
window.set_recovery_busy(false);
|
|
window.set_recovery_diagnosis(format!("That did not work: {e}").into());
|
|
PENDING.with(|p| *p.borrow_mut() = Some(pending));
|
|
return;
|
|
}
|
|
|
|
window.set_recovery_busy(false);
|
|
window.set_recovery_title(slint::SharedString::new());
|
|
window.set_library_error(slint::SharedString::new());
|
|
|
|
if crate::library_ui::show_catalog_now(window, ctl, &pending.catalog, coll_ctl) {
|
|
crate::library_ui::start_rescan(window, ctl, coll_ctl);
|
|
}
|
|
}
|
|
|
|
/// "today", "3 days ago" — enough to choose by, without a date library.
|
|
///
|
|
/// The user is deciding how much work a restore costs them, and the answer to
|
|
/// that is an *age*, not a timestamp: "yesterday" is immediately actionable
|
|
/// and "1756512000" is not. Whole days, because an hour's precision would
|
|
/// invite a confidence the backup schedule does not earn.
|
|
fn describe_age(taken_at: i64) -> String {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0);
|
|
let days = (now - taken_at).max(0) / 86_400;
|
|
match days {
|
|
0 => "today".to_string(),
|
|
1 => "yesterday".to_string(),
|
|
d => format!("{d} days ago"),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn an_age_reads_as_an_age() {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs() as i64;
|
|
assert_eq!(describe_age(now), "today");
|
|
assert_eq!(describe_age(now - 86_400), "yesterday");
|
|
assert_eq!(describe_age(now - 5 * 86_400), "5 days ago");
|
|
// A clock that has gone backwards must not produce "-2 days ago".
|
|
assert_eq!(describe_age(now + 86_400), "today");
|
|
}
|
|
}
|