Merge: recover a damaged catalog, and capture a crash locally

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 13:45:18 +02:00
co-authored by Claude Opus 5
15 changed files with 1954 additions and 14 deletions
+1
View File
@@ -49,6 +49,7 @@ mod net_runtime;
mod peaking;
mod preset_store;
mod presets;
mod recovery_ui;
mod remote;
mod segmentation;
mod settings_store;
+46 -7
View File
@@ -862,7 +862,13 @@ pub fn open(
// Before the scan, not after it: the grid can be filled from disk now and
// the scan is only ever going to add to it.
show_catalog_now(window, &ctl, &path, &coll_ctl);
//
// And it is the gate on the scan, not merely a prelude to it — a damaged
// catalog has a question on screen, and a scan writing into it while that
// question is unanswered is how the last good copy gets destroyed.
if !show_catalog_now(window, &ctl, &path, &coll_ctl) {
return;
}
let rx = library::spawn_scan(
conn.clone(),
@@ -1095,7 +1101,7 @@ fn drain_scan(
/// the same operation: a scan is the only request that both proves the server
/// is reachable and brings the catalog up to date. Keeping them one function
/// is what stops "retry" from quietly becoming a weaker probe than "rescan".
fn start_rescan(
pub(crate) fn start_rescan(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
@@ -1916,23 +1922,38 @@ fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
/// state already says "Scanning…", and an error here would contradict a scan
/// that is working perfectly. `Catalog::open` creates the file in that case, so
/// what the grid reads is an empty catalog rather than a failure.
fn show_catalog_now(
/// Returns whether it is safe to go on and scan.
///
/// `false` means the catalog is damaged and the recovery question is up. The
/// caller must not start a scan on that answer: `Catalog::open` succeeds on a
/// file whose header survived, so the scan would write ETags and image rows
/// into damaged pages while the user is still reading the question — turning a
/// file that had a backup into one where the backup is the only copy left.
pub(crate) fn show_catalog_now(
window: &AppWindow,
ctl: &Rc<LibraryController>,
catalog_path: &std::path::Path,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
) -> bool {
if ctl.catalog.borrow().is_some() {
return;
return true;
}
let cat = match Catalog::open(catalog_path) {
// Verified rather than plain: this is the once-per-launch moment where a
// full check is affordable and there is a user in front of it who can
// answer the question. See `dr_catalog::recovery` for why it is not on
// every open.
let cat = match Catalog::open_verified(catalog_path) {
Ok(cat) => cat,
Err(dr_catalog::CatalogError::Corrupt { detail }) => {
crate::recovery_ui::offer(window, catalog_path, &detail);
return false;
}
Err(e) => {
// Not surfaced: the scan is the thing that has to work, and it is
// still running. If it fails too, it reports for both of them.
log::info!("no catalog to show before the scan: {e}");
return;
return true;
}
};
@@ -1964,6 +1985,19 @@ fn show_catalog_now(
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
*ctl.catalog.borrow_mut() = Some(cat);
load_window(window, ctl);
true
}
/// Drop the open catalog, so the next `show_catalog_now` opens the file
/// again rather than returning early.
///
/// Only recovery needs this, and it needs it for a specific reason: the file
/// under that connection has been replaced. A handle to the catalog that was
/// there before is a handle to a file that no longer has a name, and every
/// read through it would return the damaged pages the recovery just moved out
/// of the way.
pub(crate) fn forget_catalog(ctl: &Rc<LibraryController>) {
*ctl.catalog.borrow_mut() = None;
}
/// What one cell of the outgoing model is worth keeping.
@@ -5644,6 +5678,11 @@ pub fn wire<F>(
start_rescan(&w, &ctl, &coll_ctl);
});
}
// Last, and in its own module: the answers to a damaged catalog have
// nothing to do with the library view except that they run before it
// exists.
crate::recovery_ui::wire(window, &ctl, &coll_ctl);
}
/// Reload the grid after the filter changed.
+285
View File
@@ -0,0 +1,285 @@
//! 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.
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");
}
}