From 5ee8cdc99e6ff37c05c86ae3590c245e8630bbab Mon Sep 17 00:00:00 2001 From: Mariusz Woloszyn Date: Mon, 17 Aug 2026 14:29:23 +0000 Subject: [PATCH 1/2] feat: open OneNote backup folders as notebooks --- Cargo.lock | 1 + README.md | 16 +- crates/onenote-core/Cargo.toml | 1 + .../onenote-core/examples/inspect-source.rs | 24 +- crates/onenote-core/src/backup.rs | 1373 +++++++++++++++++ crates/onenote-core/src/error.rs | 7 + crates/onenote-core/src/lib.rs | 9 +- crates/onenote-core/src/parser.rs | 295 +++- crates/onenote-core/src/resource.rs | 11 +- crates/onenote-core/tests/private_corpus.rs | 37 +- crates/onenote-viewer/src/app.rs | 973 ++++++++++-- crates/onenote-viewer/src/worker.rs | 196 ++- crates/onenote-viewer/src/workspace.rs | 182 ++- docs/MASTER-PLAN.md | 4 +- docs/plans/backup-folder-loader.md | 38 +- docs/specs/public-api.md | 18 +- 16 files changed, 2977 insertions(+), 208 deletions(-) create mode 100644 crates/onenote-core/src/backup.rs diff --git a/Cargo.lock b/Cargo.lock index f2f2870..ad6722d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1734,6 +1734,7 @@ dependencies = [ "tempfile", "thiserror 2.0.19", "typed-path", + "unicode-normalization", "uuid", ] diff --git a/README.md b/README.md index 249367a..9234ed6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ updates, removal, and the AppImage alternative. printouts, and OfficeMath equations on the freeform page canvas. - Imports complete `.onepkg` notebook exports through a guided, validated, on-disk process with progress and cancellation. +- Opens manifest-free OneNote backup folders as one reconstructed notebook, + preserving nested directories as section groups and selecting the latest + dated copy of each section by default. - Keeps multiple notebooks open in one workspace with nested section groups, collapsible navigation, scoped search, complete result paths, and indexing of page titles and stored content. @@ -60,10 +63,15 @@ updates, removal, and the AppImage alternative. ## Open Notebooks Use **Open Notebook Folder...** for a locally copied OneNote notebook directory, -or **Open OneNote File...** for a standalone `.one` section. Additional notebook -directories join the same searchable workspace without being moved. Notebook -folders placed under the configurable default notebooks location open -automatically on the next launch. +or **Open OneNote File...** for a standalone `.one` section. Use **Open OneNote +Backup Folder...** when a desktop backup contains dated `.one` section copies +but no usable notebook table of contents. The backup opens as one notebook; +folders become nested section groups, while the application menu can refresh +the source or show every backup copy. + +Additional notebook directories join the same searchable workspace without +being moved. Notebook and backup folders placed under the configurable default +notebooks location open automatically on the next launch. ## Import a OneNote Package diff --git a/crates/onenote-core/Cargo.toml b/crates/onenote-core/Cargo.toml index 9a8903f..6bfa0c0 100644 --- a/crates/onenote-core/Cargo.toml +++ b/crates/onenote-core/Cargo.toml @@ -15,6 +15,7 @@ onenote_parser.workspace = true serde.workspace = true thiserror.workspace = true typed-path.workspace = true +unicode-normalization.workspace = true uuid.workspace = true [dev-dependencies] diff --git a/crates/onenote-core/examples/inspect-source.rs b/crates/onenote-core/examples/inspect-source.rs index 34158cf..dcf2e6c 100644 --- a/crates/onenote-core/examples/inspect-source.rs +++ b/crates/onenote-core/examples/inspect-source.rs @@ -1,5 +1,8 @@ -use onenote_core::OneNoteLoader; +use onenote_core::{ + BackupFolderLoader, BackupFolderOptions, BackupLoadControl, LoadedNotebook, OneNoteLoader, +}; use std::env; +use std::path::Path; use std::process::ExitCode; fn main() -> ExitCode { @@ -8,7 +11,7 @@ fn main() -> ExitCode { return ExitCode::from(2); }; - match OneNoteLoader::default().load(&path) { + match load_source(Path::new(&path)) { Ok(loaded) => { let diagnostics = loaded .notebook @@ -48,3 +51,20 @@ fn main() -> ExitCode { } } } + +fn load_source(path: &Path) -> Result { + if !path.is_dir() { + return OneNoteLoader::default() + .load(path) + .map_err(|error| error.to_string()); + } + let loader = BackupFolderLoader::default(); + let control = BackupLoadControl::new(); + let inspection = loader + .inspect(path, BackupFolderOptions::default(), &control, |_| {}) + .map_err(|error| error.to_string())?; + loader + .load(inspection, &control, |_| {}) + .map(|result| result.loaded) + .map_err(|error| error.to_string()) +} diff --git a/crates/onenote-core/src/backup.rs b/crates/onenote-core/src/backup.rs new file mode 100644 index 0000000..deac1de --- /dev/null +++ b/crates/onenote-core/src/backup.rs @@ -0,0 +1,1373 @@ +use crate::model::{DiagnosticSeverity, SourceFingerprint, SourceId}; +use crate::parser::{self, LoadOptions, LoadedNotebook, ParseLimits}; +use blake3::Hasher; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::{OsStr, OsString}; +use std::fs::{self, File, Metadata}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; +use std::sync::Arc; +use std::time::UNIX_EPOCH; +use unicode_normalization::UnicodeNormalization; +use uuid::Uuid; + +const BACKUP_PROFILE_VERSION: u32 = 1; +const HASH_BUFFER_BYTES: usize = 64 * 1024; + +/// Selection policy applied to physical backup snapshots. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum BackupSelectionPolicy { + /// Select the newest physical snapshot for every logical section. + #[default] + LatestPerSection, + /// Expose every physical snapshot as a separate section. + AllCopies, +} + +/// A persisted, reusable description of one read-only `OneNote` source. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SourceDescriptor { + /// A standalone `.one` or manifest `.onetoc2` file. + NativeFile { + /// Source file path. + path: PathBuf, + }, + /// A manifest-free backup directory reconstructed as one notebook. + BackupFolder { + /// Selected backup root. + root: PathBuf, + /// Persisted snapshot visibility policy. + selection: BackupSelectionPolicy, + }, +} + +impl SourceDescriptor { + /// Construct a native file descriptor. + pub fn native(path: impl Into) -> Self { + Self::NativeFile { path: path.into() } + } + + /// Construct a backup-folder descriptor. + pub fn backup(root: impl Into, selection: BackupSelectionPolicy) -> Self { + Self::BackupFolder { + root: root.into(), + selection, + } + } + + /// Filesystem path used to reopen this source. + pub fn path(&self) -> &Path { + match self { + Self::NativeFile { path } => path, + Self::BackupFolder { root, .. } => root, + } + } + + /// Return a copy with a different backup policy, when applicable. + #[must_use] + pub fn with_backup_selection(&self, selection: BackupSelectionPolicy) -> Self { + match self { + Self::BackupFolder { root, .. } => Self::backup(root.clone(), selection), + Self::NativeFile { path } => Self::native(path.clone()), + } + } + + /// Whether this source uses reconstructed backup-folder semantics. + pub fn is_backup(&self) -> bool { + matches!(self, Self::BackupFolder { .. }) + } +} + +/// Handling of a root-level notebook table of contents during explicit backup loading. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum RootManifestPolicy { + /// Refuse backup reconstruction because a normal notebook manifest is present. + #[default] + Reject, + /// Ignore the manifest after the caller has explicitly confirmed fallback. + Ignore, +} + +/// Options which determine the projected backup source generation. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct BackupFolderOptions { + /// Snapshot visibility policy. + pub selection: BackupSelectionPolicy, + /// Whether an explicitly confirmed malformed root manifest may be ignored. + pub root_manifest: RootManifestPolicy, +} + +/// Resource ceilings for backup-folder discovery. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BackupFolderLimits { + /// Maximum filesystem entries visited. + pub max_entries: usize, + /// Maximum directory nesting below the selected root. + pub max_depth: usize, + /// Maximum `.one` candidates retained. + pub max_candidates: usize, + /// Maximum snapshots grouped under one logical section. + pub max_snapshots_per_section: usize, + /// Maximum diagnostics retained. + pub max_diagnostics: usize, + /// Maximum total bytes hashed to resolve equal-date collisions. + pub max_collision_hash_bytes: u64, +} + +impl Default for BackupFolderLimits { + fn default() -> Self { + Self { + max_entries: 100_000, + max_depth: 64, + max_candidates: 10_000, + max_snapshots_per_section: 1_000, + max_diagnostics: 1_000, + max_collision_hash_bytes: 512 * 1024 * 1024, + } + } +} + +/// Cloneable cooperative cancellation handle for backup inspection and loading. +#[derive(Clone, Debug, Default)] +pub struct BackupLoadControl { + cancelled: Arc, +} + +impl BackupLoadControl { + /// Create an active cancellation handle. + pub fn new() -> Self { + Self::default() + } + + /// Request cancellation. + pub fn cancel(&self) { + self.cancelled.store(true, AtomicOrdering::Release); + } + + /// Whether cancellation has been requested. + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(AtomicOrdering::Acquire) + } +} + +/// Stable phases reported by backup inspection and loading. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BackupProgressPhase { + /// Validate and canonicalize the selected root. + Classifying, + /// Traverse the source tree. + Discovering, + /// Group physical files into logical sections. + Grouping, + /// Select physical snapshots. + Selecting, + /// Parse selected native sections. + Parsing, + /// Assemble the aggregate notebook tree. + Assembling, + /// Verify that the source generation did not change. + Verifying, +} + +/// Progress snapshot emitted synchronously on the calling thread. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BackupLoadProgress { + /// Current stable phase. + pub phase: BackupProgressPhase, + /// Completed items within the phase. + pub completed: usize, + /// Known phase total, or zero when not yet known. + pub total: usize, +} + +/// Validated calendar date encoded in a recognized backup filename. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct BackupDate { + /// Four-digit year. + pub year: u16, + /// Month from 1 to 12. + pub month: u8, + /// Day from 1 to 31 as permitted by the month and year. + pub day: u8, +} + +/// Why a physical snapshot was selected or excluded. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BackupSnapshotReason { + /// The newest recognized filename date won. + FilenameDate, + /// Filesystem modification time decided an undated or tied candidate. + ModificationTime, + /// Stable path ordering was the final deterministic tie-breaker. + StablePath, + /// Every copy was requested explicitly. + AllCopies, + /// A newer candidate represented the same logical section. + OlderSnapshot, +} + +/// Selection state of one physical snapshot. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BackupSnapshotDisposition { + /// This snapshot will be parsed and projected. + Selected(BackupSnapshotReason), + /// This snapshot remains inventory-only. + Excluded(BackupSnapshotReason), +} + +/// Lightweight provenance for one physical `.one` file. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupSnapshot { + /// Path relative to the selected backup root. + pub relative_path: PathBuf, + /// Relative directory reconstructed as section groups. + pub relative_parent: PathBuf, + /// Logical section name used only for grouping and identity. + pub logical_name: String, + /// Exact physical basename with only the final `.one` extension removed. + pub display_name: String, + /// Validated date from a recognized filename profile. + pub filename_date: Option, + /// Source file length observed during inspection. + pub size: u64, + /// Snapshot selection outcome. + pub disposition: BackupSnapshotDisposition, + pub(crate) logical_key: Vec, + pub(crate) modified: Option<(u64, u32)>, +} + +/// Structured backup compatibility or reconstruction diagnostic. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackupDiagnostic { + /// Severity. + pub severity: DiagnosticSeverity, + /// Stable machine-readable code. + pub code: String, + /// Human-readable detail. + pub message: String, + /// Source-relative path when the issue is file-specific. + pub relative_path: Option, +} + +/// Immutable result of bounded discovery and deterministic selection. +#[derive(Clone, Debug)] +pub struct BackupFolderInspection { + /// Canonical selected root. + pub root: PathBuf, + /// Stable identity of the aggregate backup source. + pub source_id: SourceId, + /// Fingerprint of the complete candidate inventory and selection policy. + pub fingerprint: SourceFingerprint, + /// Backup folder display name. + pub notebook_name: String, + /// Applied options. + pub options: BackupFolderOptions, + /// Physical snapshot inventory. + pub snapshots: Vec, + /// Bounded diagnostics. + pub diagnostics: Vec, +} + +/// A projected notebook plus its backup inventory and provenance. +#[derive(Clone, Debug)] +pub struct BackupLoadResult { + /// Ordinary renderer/index-compatible notebook and lazy resources. + pub loaded: LoadedNotebook, + /// Inspection that produced this generation. + pub inspection: BackupFolderInspection, +} + +/// Errors specific to backup-folder inspection and aggregation. +#[derive(Debug, thiserror::Error)] +pub enum BackupFolderError { + /// A filesystem operation failed. + #[error("could not access {path}: {source}")] + Io { + /// Path involved in the operation. + path: PathBuf, + /// Underlying failure. + #[source] + source: io::Error, + }, + /// The selected path is not a directory. + #[error("{path} is not a directory")] + NotDirectory { + /// Rejected path. + path: PathBuf, + }, + /// A root manifest requires normal notebook loading. + #[error("{path} contains a root .onetoc2; open it as a normal notebook")] + RootManifestPresent { + /// Manifest path. + path: PathBuf, + }, + /// No section candidates were found. + #[error("{path} contains no OneNote section files")] + NoSections { + /// Selected root. + path: PathBuf, + }, + /// A configured defensive limit was exceeded. + #[error("backup-folder limit exceeded: {message}")] + Limit { + /// Limit detail without private source content. + message: String, + }, + /// The caller cancelled the operation. + #[error("backup-folder operation was cancelled")] + Cancelled, + /// The source changed while being loaded. + #[error("backup folder changed while loading; the previous generation was preserved")] + SourceChanged, + /// Native parsing or projection failed. + #[error(transparent)] + Core(#[from] crate::Error), +} + +/// Result type for backup-folder operations. +pub type BackupResult = std::result::Result; + +/// Reusable, read-only loader for manifest-free `OneNote` backup directories. +#[derive(Clone, Copy, Debug, Default)] +pub struct BackupFolderLoader { + limits: BackupFolderLimits, + parse_limits: ParseLimits, + load_options: LoadOptions, +} + +impl BackupFolderLoader { + /// Construct a loader with explicit discovery, projection, and enrichment options. + pub fn with_options( + limits: BackupFolderLimits, + parse_limits: ParseLimits, + load_options: LoadOptions, + ) -> Self { + Self { + limits, + parse_limits, + load_options, + } + } + + /// Inspect and select snapshots without parsing ordinary section content. + /// + /// # Errors + /// + /// Returns an error when the root is unavailable or unsuitable, a configured + /// resource ceiling is exceeded, or cancellation is requested. + pub fn inspect( + &self, + root: impl AsRef, + options: BackupFolderOptions, + control: &BackupLoadControl, + mut progress: impl FnMut(BackupLoadProgress), + ) -> BackupResult { + progress(progress_event(BackupProgressPhase::Classifying, 0, 0)); + check_cancelled(control)?; + let requested = root.as_ref(); + let canonical = fs::canonicalize(requested).map_err(|source| BackupFolderError::Io { + path: requested.to_path_buf(), + source, + })?; + if !canonical.is_dir() { + return Err(BackupFolderError::NotDirectory { path: canonical }); + } + + let mut diagnostics = Vec::new(); + let mut discovered = self.discover(&canonical, control, &mut diagnostics, &mut progress)?; + if let Some(manifest) = discovered.root_manifests.first() { + if options.root_manifest == RootManifestPolicy::Reject { + return Err(BackupFolderError::RootManifestPresent { + path: manifest.clone(), + }); + } + push_diagnostic( + &mut diagnostics, + self.limits.max_diagnostics, + BackupDiagnostic { + severity: DiagnosticSeverity::Warning, + code: "backup_root_manifest_ignored".to_owned(), + message: "The root notebook table of contents was ignored after explicit backup-folder fallback.".to_owned(), + relative_path: manifest.strip_prefix(&canonical).ok().map(Path::to_path_buf), + }, + ); + } + if discovered.candidates.is_empty() { + return Err(BackupFolderError::NoSections { path: canonical }); + } + + progress(progress_event( + BackupProgressPhase::Grouping, + 0, + discovered.candidates.len(), + )); + let mut snapshots = discovered + .candidates + .drain(..) + .map(|candidate| snapshot(candidate, &mut diagnostics, self.limits.max_diagnostics)) + .collect::>(); + report_normalization_collisions(&snapshots, &mut diagnostics, self.limits.max_diagnostics); + check_group_limits(&snapshots, self.limits.max_snapshots_per_section)?; + + progress(progress_event( + BackupProgressPhase::Selecting, + 0, + snapshots.len(), + )); + select_snapshots(&mut snapshots, options.selection); + inspect_equal_date_collisions( + &canonical, + &mut snapshots, + &mut diagnostics, + self.limits, + control, + )?; + snapshots.sort_by(snapshot_path_order); + let source_id = aggregate_source_id(&canonical); + let fingerprint = inventory_fingerprint(&snapshots, options); + let notebook_name = + display_component(canonical.file_name().unwrap_or(canonical.as_os_str())); + push_diagnostic( + &mut diagnostics, + self.limits.max_diagnostics, + BackupDiagnostic { + severity: DiagnosticSeverity::Info, + code: "backup_reconstructed_order".to_owned(), + message: "Section and section-group order was reconstructed because the backup has no authoritative table of contents.".to_owned(), + relative_path: None, + }, + ); + Ok(BackupFolderInspection { + root: canonical, + source_id, + fingerprint, + notebook_name, + options, + snapshots, + diagnostics, + }) + } + + /// Parse selected snapshots and assemble one ordinary notebook generation. + /// + /// # Errors + /// + /// Returns an error when parsing exceeds a defensive limit, source metadata + /// changes during loading, or cancellation is requested. + pub fn load( + &self, + inspection: BackupFolderInspection, + control: &BackupLoadControl, + mut progress: impl FnMut(BackupLoadProgress), + ) -> BackupResult { + check_cancelled(control)?; + let selected = inspection + .snapshots + .iter() + .filter(|snapshot| { + matches!(snapshot.disposition, BackupSnapshotDisposition::Selected(_)) + }) + .count(); + progress(progress_event(BackupProgressPhase::Parsing, 0, selected)); + let loaded = parser::load_backup_projection( + &inspection, + self.parse_limits, + self.load_options, + control, + |completed| { + progress(progress_event( + BackupProgressPhase::Parsing, + completed, + selected, + )); + }, + )?; + progress(progress_event(BackupProgressPhase::Assembling, 1, 1)); + progress(progress_event(BackupProgressPhase::Verifying, 0, 0)); + let verified = self.inspect(&inspection.root, inspection.options, control, |_| {})?; + if verified.fingerprint != inspection.fingerprint { + return Err(BackupFolderError::SourceChanged); + } + Ok(BackupLoadResult { loaded, inspection }) + } + + fn discover( + &self, + root: &Path, + control: &BackupLoadControl, + diagnostics: &mut Vec, + progress: &mut impl FnMut(BackupLoadProgress), + ) -> BackupResult { + let mut pending = vec![(root.to_path_buf(), 0_usize)]; + let mut entries = 0_usize; + let mut candidates = Vec::new(); + let mut root_manifests = Vec::new(); + while let Some((directory, depth)) = pending.pop() { + check_cancelled(control)?; + if depth > self.limits.max_depth { + return Err(BackupFolderError::Limit { + message: format!("directory depth exceeds {}", self.limits.max_depth), + }); + } + let read = fs::read_dir(&directory).map_err(|source| BackupFolderError::Io { + path: directory.clone(), + source, + })?; + for entry in read { + check_cancelled(control)?; + let entry = entry.map_err(|source| BackupFolderError::Io { + path: directory.clone(), + source, + })?; + entries = entries.saturating_add(1); + if entries > self.limits.max_entries { + return Err(BackupFolderError::Limit { + message: format!("entry count exceeds {}", self.limits.max_entries), + }); + } + progress(progress_event(BackupProgressPhase::Discovering, entries, 0)); + let path = entry.path(); + let file_type = entry.file_type().map_err(|source| BackupFolderError::Io { + path: path.clone(), + source, + })?; + if file_type.is_symlink() { + push_diagnostic( + diagnostics, + self.limits.max_diagnostics, + BackupDiagnostic { + severity: DiagnosticSeverity::Warning, + code: "backup_symlink_skipped".to_owned(), + message: "A symbolic link was skipped to keep traversal inside the selected backup root.".to_owned(), + relative_path: path.strip_prefix(root).ok().map(Path::to_path_buf), + }, + ); + continue; + } + if file_type.is_dir() { + pending.push((path, depth.saturating_add(1))); + } else if file_type.is_file() && has_extension(&path, "one") { + if candidates.len() >= self.limits.max_candidates { + return Err(BackupFolderError::Limit { + message: format!( + "section candidate count exceeds {}", + self.limits.max_candidates + ), + }); + } + let metadata = entry.metadata().map_err(|source| BackupFolderError::Io { + path: path.clone(), + source, + })?; + candidates.push(Candidate { + relative_path: path + .strip_prefix(root) + .expect("directory entries remain below root") + .to_path_buf(), + metadata, + }); + } else if file_type.is_file() && depth == 0 && has_extension(&path, "onetoc2") { + root_manifests.push(path); + } + } + } + root_manifests.sort(); + candidates.sort_by(|left, right| { + path_bytes(&left.relative_path).cmp(&path_bytes(&right.relative_path)) + }); + Ok(Discovery { + root_manifests, + candidates, + }) + } +} + +struct Discovery { + root_manifests: Vec, + candidates: Vec, +} + +struct Candidate { + relative_path: PathBuf, + metadata: Metadata, +} + +fn snapshot( + candidate: Candidate, + diagnostics: &mut Vec, + max_diagnostics: usize, +) -> BackupSnapshot { + let parent = candidate + .relative_path + .parent() + .unwrap_or_else(|| Path::new("")) + .to_path_buf(); + let file_stem = candidate + .relative_path + .file_stem() + .unwrap_or_else(|| candidate.relative_path.as_os_str()); + let display_name = display_component(file_stem); + let (logical_os, date, suspicious) = parse_backup_name(file_stem); + if suspicious { + push_diagnostic( + diagnostics, + max_diagnostics, + BackupDiagnostic { + severity: DiagnosticSeverity::Warning, + code: "backup_suffix_unrecognized".to_owned(), + message: "A backup-like filename suffix was not recognized and was preserved as a distinct logical section.".to_owned(), + relative_path: Some(candidate.relative_path.clone()), + }, + ); + } + BackupSnapshot { + relative_path: candidate.relative_path, + relative_parent: parent, + logical_name: display_component(&logical_os), + display_name, + filename_date: date, + size: candidate.metadata.len(), + disposition: BackupSnapshotDisposition::Excluded(BackupSnapshotReason::OlderSnapshot), + logical_key: os_bytes(&logical_os), + modified: modified_parts(&candidate.metadata), + } +} + +fn parse_backup_name(stem: &OsStr) -> (OsString, Option, bool) { + let Some(value) = stem.to_str() else { + return (stem.to_os_string(), None, false); + }; + let Some(without_close) = value.strip_suffix(')') else { + return (stem.to_os_string(), None, value.contains(" (On ")); + }; + let Some((base, date_text)) = without_close.rsplit_once(" (On ") else { + return (stem.to_os_string(), None, false); + }; + let Some((date, year_first)) = parse_filename_date(date_text) else { + return (stem.to_os_string(), None, true); + }; + let logical = if year_first { + base.strip_suffix(".one").unwrap_or(base) + } else { + base + }; + if logical.is_empty() { + return (stem.to_os_string(), None, true); + } + (OsString::from(logical), Some(date), false) +} + +fn parse_filename_date(value: &str) -> Option<(BackupDate, bool)> { + if value.len() != 10 + || value.as_bytes().get(4) != Some(&b'-') && value.as_bytes().get(2) != Some(&b'-') + { + return None; + } + let year_first = value.as_bytes().get(4) == Some(&b'-'); + let parts = value.split('-').collect::>(); + if parts.len() != 3 + || parts + .iter() + .any(|part| !part.bytes().all(|byte| byte.is_ascii_digit())) + { + return None; + } + let (year, month, day) = if year_first { + ( + parts[0].parse().ok()?, + parts[1].parse().ok()?, + parts[2].parse().ok()?, + ) + } else { + ( + parts[2].parse().ok()?, + parts[1].parse().ok()?, + parts[0].parse().ok()?, + ) + }; + valid_date(year, month, day).then_some((BackupDate { year, month, day }, year_first)) +} + +fn valid_date(year: u16, month: u8, day: u8) -> bool { + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let days = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return false, + }; + (1..=days).contains(&day) +} + +fn select_snapshots(snapshots: &mut [BackupSnapshot], policy: BackupSelectionPolicy) { + let mut groups = BTreeMap::<(Vec, Vec), Vec>::new(); + for (index, snapshot) in snapshots.iter().enumerate() { + groups + .entry(( + path_bytes(&snapshot.relative_parent), + snapshot.logical_key.clone(), + )) + .or_default() + .push(index); + } + for indices in groups.values_mut() { + indices.sort_by(|left, right| candidate_rank(&snapshots[*right], &snapshots[*left])); + if policy == BackupSelectionPolicy::AllCopies { + for index in indices { + snapshots[*index].disposition = + BackupSnapshotDisposition::Selected(BackupSnapshotReason::AllCopies); + } + continue; + } + if let Some((selected, excluded)) = indices.split_first() { + let reason = selection_reason( + &snapshots[*selected], + excluded.first().map(|i| &snapshots[*i]), + ); + snapshots[*selected].disposition = BackupSnapshotDisposition::Selected(reason); + for index in excluded { + snapshots[*index].disposition = + BackupSnapshotDisposition::Excluded(BackupSnapshotReason::OlderSnapshot); + } + } + } +} + +fn candidate_rank(left: &BackupSnapshot, right: &BackupSnapshot) -> Ordering { + left.filename_date + .is_some() + .cmp(&right.filename_date.is_some()) + .then_with(|| left.filename_date.cmp(&right.filename_date)) + .then_with(|| left.modified.cmp(&right.modified)) + .then_with(|| path_bytes(&left.relative_path).cmp(&path_bytes(&right.relative_path))) +} + +fn selection_reason( + selected: &BackupSnapshot, + runner_up: Option<&BackupSnapshot>, +) -> BackupSnapshotReason { + let Some(runner_up) = runner_up else { + return selected + .filename_date + .map_or(BackupSnapshotReason::ModificationTime, |_| { + BackupSnapshotReason::FilenameDate + }); + }; + if selected.filename_date != runner_up.filename_date && selected.filename_date.is_some() { + BackupSnapshotReason::FilenameDate + } else if selected.modified != runner_up.modified { + BackupSnapshotReason::ModificationTime + } else { + BackupSnapshotReason::StablePath + } +} + +fn inspect_equal_date_collisions( + root: &Path, + snapshots: &mut [BackupSnapshot], + diagnostics: &mut Vec, + limits: BackupFolderLimits, + control: &BackupLoadControl, +) -> BackupResult<()> { + let mut groups = BTreeMap::<(Vec, Vec, BackupDate), Vec>::new(); + for (index, snapshot) in snapshots.iter().enumerate() { + if let Some(date) = snapshot.filename_date { + groups + .entry(( + path_bytes(&snapshot.relative_parent), + snapshot.logical_key.clone(), + date, + )) + .or_default() + .push(index); + } + } + let mut hashed_bytes = 0_u64; + for indices in groups.values().filter(|indices| indices.len() > 1) { + let required = indices + .iter() + .map(|index| snapshots[*index].size) + .sum::(); + if hashed_bytes.saturating_add(required) > limits.max_collision_hash_bytes { + push_diagnostic( + diagnostics, + limits.max_diagnostics, + BackupDiagnostic { + severity: DiagnosticSeverity::Warning, + code: "backup_equal_date_unverified".to_owned(), + message: "Equal-date snapshots exceeded the collision-verification byte limit; stable path ordering was used.".to_owned(), + relative_path: None, + }, + ); + continue; + } + let mut hashes = BTreeSet::new(); + for index in indices { + check_cancelled(control)?; + let path = root.join(&snapshots[*index].relative_path); + hashes.insert(hash_file(&path, control)?); + } + hashed_bytes = hashed_bytes.saturating_add(required); + push_diagnostic( + diagnostics, + limits.max_diagnostics, + BackupDiagnostic { + severity: if hashes.len() > 1 { + DiagnosticSeverity::Warning + } else { + DiagnosticSeverity::Info + }, + code: if hashes.len() > 1 { + "backup_equal_date_conflict" + } else { + "backup_equal_date_duplicate" + } + .to_owned(), + message: if hashes.len() > 1 { + "Equal-date snapshots contain different data; deterministic metadata and path tie-breakers selected the visible copy." + } else { + "Equal-date snapshots contain identical data; deterministic path ordering selected the visible copy." + } + .to_owned(), + relative_path: None, + }, + ); + } + Ok(()) +} + +fn report_normalization_collisions( + snapshots: &[BackupSnapshot], + diagnostics: &mut Vec, + max_diagnostics: usize, +) { + let mut shadows = BTreeMap::<(Vec, String), BTreeSet>>::new(); + for snapshot in snapshots { + let shadow = snapshot + .logical_name + .nfc() + .collect::() + .to_lowercase(); + shadows + .entry((path_bytes(&snapshot.relative_parent), shadow)) + .or_default() + .insert(snapshot.logical_key.clone()); + } + for keys in shadows.values().filter(|keys| keys.len() > 1) { + let _ = keys; + push_diagnostic( + diagnostics, + max_diagnostics, + BackupDiagnostic { + severity: DiagnosticSeverity::Warning, + code: "backup_logical_name_collision".to_owned(), + message: "Section names differ only by case or Unicode normalization and were kept distinct.".to_owned(), + relative_path: None, + }, + ); + } +} + +fn check_group_limits(snapshots: &[BackupSnapshot], maximum: usize) -> BackupResult<()> { + let mut counts = BTreeMap::<(Vec, Vec), usize>::new(); + for snapshot in snapshots { + let count = counts + .entry(( + path_bytes(&snapshot.relative_parent), + snapshot.logical_key.clone(), + )) + .or_default(); + *count = count.saturating_add(1); + if *count > maximum { + return Err(BackupFolderError::Limit { + message: format!("snapshots per logical section exceed {maximum}"), + }); + } + } + Ok(()) +} + +fn inventory_fingerprint( + snapshots: &[BackupSnapshot], + options: BackupFolderOptions, +) -> SourceFingerprint { + let mut hasher = Hasher::new(); + hasher.update(&BACKUP_PROFILE_VERSION.to_le_bytes()); + hasher.update(&[match options.selection { + BackupSelectionPolicy::LatestPerSection => 0, + BackupSelectionPolicy::AllCopies => 1, + }]); + for snapshot in snapshots { + update_part(&mut hasher, &path_bytes(&snapshot.relative_path)); + update_part(&mut hasher, &snapshot.logical_key); + hasher.update(&snapshot.size.to_le_bytes()); + let (seconds, nanos) = snapshot.modified.unwrap_or_default(); + hasher.update(&seconds.to_le_bytes()); + hasher.update(&nanos.to_le_bytes()); + if let Some(date) = snapshot.filename_date { + hasher.update(&date.year.to_le_bytes()); + hasher.update(&[date.month, date.day]); + } else { + hasher.update(&0_u16.to_le_bytes()); + hasher.update(&[0, 0]); + } + hasher.update(&[u8::from(matches!( + snapshot.disposition, + BackupSnapshotDisposition::Selected(_) + ))]); + } + SourceFingerprint::new(hasher.finalize().to_hex().to_string()) +} + +fn aggregate_source_id(root: &Path) -> SourceId { + SourceId::new(stable_id(&[b"backup-folder-source-v1", &path_bytes(root)])) +} + +pub(crate) fn backup_entry_id(source_id: &SourceId, kind: &str, key: &[u8]) -> String { + stable_id(&[ + source_id.as_str().as_bytes(), + b"backup-entry-v1", + kind.as_bytes(), + key, + ]) +} + +pub(crate) fn snapshot_instance_key( + snapshot: &BackupSnapshot, + policy: BackupSelectionPolicy, +) -> Vec { + let mut key = path_bytes(&snapshot.relative_parent); + key.push(0); + key.extend_from_slice(&snapshot.logical_key); + if policy == BackupSelectionPolicy::AllCopies { + key.push(0); + key.extend_from_slice(&path_bytes(&snapshot.relative_path)); + } + key +} + +pub(crate) fn natural_cmp(left: &str, right: &str) -> Ordering { + let mut left = left.chars().peekable(); + let mut right = right.chars().peekable(); + loop { + match (left.peek().copied(), right.peek().copied()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(a), Some(b)) if a.is_ascii_digit() && b.is_ascii_digit() => { + let left_digits = take_digits(&mut left); + let right_digits = take_digits(&mut right); + let left_trimmed = left_digits.trim_start_matches('0'); + let right_trimmed = right_digits.trim_start_matches('0'); + let number_order = left_trimmed + .len() + .cmp(&right_trimmed.len()) + .then_with(|| left_trimmed.cmp(right_trimmed)) + .then_with(|| left_digits.len().cmp(&right_digits.len())); + if number_order != Ordering::Equal { + return number_order; + } + } + (Some(a), Some(b)) => { + left.next(); + right.next(); + let order = a + .to_lowercase() + .collect::() + .cmp(&b.to_lowercase().collect::()) + .then_with(|| a.cmp(&b)); + if order != Ordering::Equal { + return order; + } + } + } + } +} + +fn take_digits(iter: &mut std::iter::Peekable>) -> String { + let mut digits = String::new(); + while iter.peek().is_some_and(char::is_ascii_digit) { + digits.push(iter.next().expect("peeked digit")); + } + digits +} + +pub(crate) fn file_stamp(path: &Path) -> BackupResult<(u64, Option<(u64, u32)>)> { + let metadata = fs::metadata(path).map_err(|source| BackupFolderError::Io { + path: path.to_path_buf(), + source, + })?; + Ok((metadata.len(), modified_parts(&metadata))) +} + +fn stable_id(parts: &[&[u8]]) -> String { + let mut bytes = Vec::new(); + for part in parts { + bytes.extend_from_slice(&(part.len() as u64).to_le_bytes()); + bytes.extend_from_slice(part); + } + Uuid::new_v5(&Uuid::NAMESPACE_URL, &bytes).to_string() +} + +fn hash_file(path: &Path, control: &BackupLoadControl) -> BackupResult { + let mut file = File::open(path).map_err(|source| BackupFolderError::Io { + path: path.to_path_buf(), + source, + })?; + let mut hasher = Hasher::new(); + let mut buffer = vec![0_u8; HASH_BUFFER_BYTES].into_boxed_slice(); + loop { + check_cancelled(control)?; + let read = file + .read(&mut buffer) + .map_err(|source| BackupFolderError::Io { + path: path.to_path_buf(), + source, + })?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hasher.finalize().to_hex().to_string()) +} + +fn modified_parts(metadata: &Metadata) -> Option<(u64, u32)> { + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| (duration.as_secs(), duration.subsec_nanos())) +} + +fn push_diagnostic( + diagnostics: &mut Vec, + maximum: usize, + diagnostic: BackupDiagnostic, +) { + if diagnostics.len() < maximum { + diagnostics.push(diagnostic); + } +} + +fn progress_event( + phase: BackupProgressPhase, + completed: usize, + total: usize, +) -> BackupLoadProgress { + BackupLoadProgress { + phase, + completed, + total, + } +} + +fn check_cancelled(control: &BackupLoadControl) -> BackupResult<()> { + if control.is_cancelled() { + Err(BackupFolderError::Cancelled) + } else { + Ok(()) + } +} + +fn update_part(hasher: &mut Hasher, part: &[u8]) { + hasher.update(&(part.len() as u64).to_le_bytes()); + hasher.update(part); +} + +fn snapshot_path_order(left: &BackupSnapshot, right: &BackupSnapshot) -> Ordering { + path_bytes(&left.relative_path).cmp(&path_bytes(&right.relative_path)) +} + +fn has_extension(path: &Path, expected: &str) -> bool { + path.extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) +} + +fn display_component(value: &OsStr) -> String { + value.to_string_lossy().into_owned() +} + +#[cfg(unix)] +pub(crate) fn path_bytes(path: &Path) -> Vec { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes().to_vec() +} + +#[cfg(not(unix))] +pub(crate) fn path_bytes(path: &Path) -> Vec { + path.to_string_lossy().as_bytes().to_vec() +} + +#[cfg(unix)] +fn os_bytes(value: &OsStr) -> Vec { + use std::os::unix::ffi::OsStrExt; + value.as_bytes().to_vec() +} + +#[cfg(not(unix))] +fn os_bytes(value: &OsStr) -> Vec { + value.to_string_lossy().as_bytes().to_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn parses_supported_backup_filename_profiles() { + let first = parse_backup_name(OsStr::new("Research.one (On 2026-08-15)")); + assert_eq!(first.0, OsStr::new("Research")); + assert_eq!( + first.1, + Some(BackupDate { + year: 2026, + month: 8, + day: 15 + }) + ); + assert!(!first.2); + + let second = parse_backup_name(OsStr::new("Research (On 15-08-2026)")); + assert_eq!(second.0, OsStr::new("Research")); + assert_eq!(second.1, first.1); + assert!(!second.2); + } + + #[test] + fn invalid_dates_are_preserved_in_logical_names() { + let parsed = parse_backup_name(OsStr::new("Research (On 31-02-2026)")); + assert_eq!(parsed.0, OsStr::new("Research (On 31-02-2026)")); + assert_eq!(parsed.1, None); + assert!(parsed.2); + } + + #[test] + fn leap_year_validation_is_gregorian() { + assert!(valid_date(2024, 2, 29)); + assert!(!valid_date(2025, 2, 29)); + assert!(!valid_date(2100, 2, 29)); + assert!(valid_date(2000, 2, 29)); + } + + #[test] + fn inspection_groups_and_selects_latest_without_erasing_display_date() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let group = temporary.path().join("Group"); + fs::create_dir(&group).expect("group"); + fs::write(group.join("Research (On 14-08-2026).one"), b"old").expect("old"); + fs::write(group.join("Research (On 15-08-2026).one"), b"new").expect("new"); + + let inspection = BackupFolderLoader::default() + .inspect( + temporary.path(), + BackupFolderOptions::default(), + &BackupLoadControl::new(), + |_| {}, + ) + .expect("inspection"); + + assert_eq!(inspection.snapshots.len(), 2); + let selected = inspection + .snapshots + .iter() + .find(|snapshot| matches!(snapshot.disposition, BackupSnapshotDisposition::Selected(_))) + .expect("selected"); + assert_eq!(selected.logical_name, "Research"); + assert_eq!(selected.display_name, "Research (On 15-08-2026)"); + assert_eq!(selected.relative_parent, Path::new("Group")); + } + + #[test] + fn all_copies_selects_each_snapshot_without_changing_source_identity() { + let temporary = tempfile::tempdir().expect("temporary directory"); + fs::write( + temporary.path().join("Research (On 14-08-2026).one"), + b"old", + ) + .expect("old"); + fs::write( + temporary.path().join("Research (On 15-08-2026).one"), + b"new", + ) + .expect("new"); + let loader = BackupFolderLoader::default(); + let latest = loader + .inspect( + temporary.path(), + BackupFolderOptions::default(), + &BackupLoadControl::new(), + |_| {}, + ) + .expect("latest inspection"); + let all = loader + .inspect( + temporary.path(), + BackupFolderOptions { + selection: BackupSelectionPolicy::AllCopies, + ..BackupFolderOptions::default() + }, + &BackupLoadControl::new(), + |_| {}, + ) + .expect("all-copies inspection"); + + assert_eq!(latest.source_id, all.source_id); + assert_ne!(latest.fingerprint, all.fingerprint); + assert_eq!( + latest + .snapshots + .iter() + .filter(|snapshot| matches!( + snapshot.disposition, + BackupSnapshotDisposition::Selected(_) + )) + .count(), + 1 + ); + assert!(all.snapshots.iter().all(|snapshot| matches!( + snapshot.disposition, + BackupSnapshotDisposition::Selected(BackupSnapshotReason::AllCopies) + ))); + } + + #[test] + fn source_descriptors_preserve_backup_policy_in_json() { + let descriptor = + SourceDescriptor::backup("/backups/Notebook", BackupSelectionPolicy::AllCopies); + let encoded = serde_json::to_string(&descriptor).expect("serialize descriptor"); + let decoded: SourceDescriptor = + serde_json::from_str(&encoded).expect("deserialize descriptor"); + + assert_eq!(decoded, descriptor); + assert!(encoded.contains("all_copies")); + } + + #[test] + fn root_manifest_is_authoritative_unless_explicitly_ignored() { + let temporary = tempfile::tempdir().expect("temporary directory"); + fs::write(temporary.path().join("Open Notebook.onetoc2"), b"toc").expect("toc"); + fs::write(temporary.path().join("Section.one"), b"section").expect("section"); + let loader = BackupFolderLoader::default(); + let control = BackupLoadControl::new(); + + assert!(matches!( + loader.inspect( + temporary.path(), + BackupFolderOptions::default(), + &control, + |_| {} + ), + Err(BackupFolderError::RootManifestPresent { .. }) + )); + let ignored = loader + .inspect( + temporary.path(), + BackupFolderOptions { + root_manifest: RootManifestPolicy::Ignore, + ..BackupFolderOptions::default() + }, + &control, + |_| {}, + ) + .expect("explicit fallback"); + assert_eq!(ignored.snapshots.len(), 1); + assert!(ignored + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "backup_root_manifest_ignored")); + } + + #[test] + fn unreadable_selected_snapshot_remains_a_diagnostic_section() { + let temporary = tempfile::tempdir().expect("temporary directory"); + fs::write( + temporary.path().join("Unreadable (On 15-08-2026).one"), + b"not a OneNote section", + ) + .expect("section"); + let loader = BackupFolderLoader::default(); + let control = BackupLoadControl::new(); + let inspection = loader + .inspect( + temporary.path(), + BackupFolderOptions::default(), + &control, + |_| {}, + ) + .expect("inspection"); + let aggregate = loader + .load(inspection, &control, |_| {}) + .expect("diagnostic aggregate"); + let section = aggregate + .loaded + .notebook + .sections() + .next() + .expect("failed section remains visible"); + + assert!(section.pages.is_empty()); + assert!(section + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "backup_selected_snapshot_parse_failed" })); + } + + #[test] + fn inspection_never_follows_symlinks() { + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let temporary = tempfile::tempdir().expect("temporary directory"); + let outside = tempfile::tempdir().expect("outside directory"); + fs::write(outside.path().join("Private.one"), b"outside").expect("outside"); + fs::write(temporary.path().join("Visible.one"), b"inside").expect("inside"); + symlink(outside.path(), temporary.path().join("escape")).expect("symlink"); + + let inspection = BackupFolderLoader::default() + .inspect( + temporary.path(), + BackupFolderOptions::default(), + &BackupLoadControl::new(), + |_| {}, + ) + .expect("inspection"); + assert_eq!(inspection.snapshots.len(), 1); + assert!(inspection + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "backup_symlink_skipped")); + } + } + + #[test] + fn cancelled_inspection_stops_before_traversal() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let control = BackupLoadControl::new(); + control.cancel(); + assert!(matches!( + BackupFolderLoader::default().inspect( + temporary.path(), + BackupFolderOptions::default(), + &control, + |_| {} + ), + Err(BackupFolderError::Cancelled) + )); + } + + #[test] + fn natural_order_compares_numeric_runs_by_value() { + assert_eq!(natural_cmp("Section 2", "Section 10"), Ordering::Less); + assert_eq!(natural_cmp("Section 02", "Section 2"), Ordering::Greater); + } +} diff --git a/crates/onenote-core/src/error.rs b/crates/onenote-core/src/error.rs index a1d753b..6bb382c 100644 --- a/crates/onenote-core/src/error.rs +++ b/crates/onenote-core/src/error.rs @@ -96,6 +96,13 @@ pub enum Error { actual_bytes: u64, }, + /// Two projected payloads resolved to the same stable identifier. + #[error("resource identifier collision for {id}")] + ResourceCollision { + /// Conflicting stable resource identifier. + id: crate::ResourceId, + }, + /// No supported external CAB extractor is available. #[error("could not find 7zz or 7z in PATH; install 7-Zip to open .onepkg files")] ExtractorNotFound, diff --git a/crates/onenote-core/src/lib.rs b/crates/onenote-core/src/lib.rs index 05087ff..fd802fe 100644 --- a/crates/onenote-core/src/lib.rs +++ b/crates/onenote-core/src/lib.rs @@ -2,6 +2,7 @@ #![forbid(unsafe_code)] +mod backup; mod error; mod math; mod model; @@ -9,6 +10,12 @@ mod package; mod parser; mod resource; +pub use backup::{ + BackupDate, BackupDiagnostic, BackupFolderError, BackupFolderInspection, BackupFolderLimits, + BackupFolderLoader, BackupFolderOptions, BackupLoadControl, BackupLoadProgress, + BackupLoadResult, BackupProgressPhase, BackupResult, BackupSelectionPolicy, BackupSnapshot, + BackupSnapshotDisposition, BackupSnapshotReason, RootManifestPolicy, SourceDescriptor, +}; pub use error::{Error, Result}; pub use math::{MathExpression, MathNode, MathSpan}; pub use model::{ @@ -27,7 +34,7 @@ pub use resource::{ }; /// The crate API version during the pre-1.0 implementation phase. -pub const API_VERSION: u32 = 7; +pub const API_VERSION: u32 = 8; /// Logical display pixels per `OneNote` half-inch layout unit at 96 DPI. pub const PIXELS_PER_HALF_INCH: f32 = 48.0; diff --git a/crates/onenote-core/src/parser.rs b/crates/onenote-core/src/parser.rs index 47459fd..b9fb161 100644 --- a/crates/onenote-core/src/parser.rs +++ b/crates/onenote-core/src/parser.rs @@ -1,3 +1,8 @@ +use crate::backup::{ + backup_entry_id, file_stamp, natural_cmp, path_bytes as backup_path_bytes, + snapshot_instance_key, BackupDiagnostic, BackupFolderError, BackupFolderInspection, + BackupLoadControl, BackupResult, BackupSnapshotDisposition, +}; use crate::math::{decode_span, MathSegment}; use crate::model::{ Attachment, Color, Diagnostic, DiagnosticSeverity, ElementContent, Image, Ink, InkPoint, @@ -26,6 +31,7 @@ use onenote_parser::section::{ use onenote_parser::warn::Report; use onenote_parser::Parser; use std::borrow::Cow; +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use typed_path::{PathType, TypedPath}; use uuid::Uuid; @@ -156,6 +162,178 @@ impl OneNoteLoader { } } +pub(crate) fn load_backup_projection( + inspection: &BackupFolderInspection, + limits: ParseLimits, + options: LoadOptions, + control: &BackupLoadControl, + mut progress: impl FnMut(usize), +) -> BackupResult { + let mut projector = Projector::new_with_source( + &inspection.root, + inspection.source_id.clone(), + inspection.fingerprint.clone(), + limits, + options, + ); + let mut projected = Vec::new(); + let mut completed = 0_usize; + for snapshot in inspection + .snapshots + .iter() + .filter(|snapshot| matches!(snapshot.disposition, BackupSnapshotDisposition::Selected(_))) + { + if control.is_cancelled() { + return Err(BackupFolderError::Cancelled); + } + let path = inspection.root.join(&snapshot.relative_path); + let expected = (snapshot.size, snapshot.modified); + if file_stamp(&path).ok().as_ref() != Some(&expected) { + return Err(BackupFolderError::SourceChanged); + } + let identity = snapshot_instance_key(snapshot, inspection.options.selection); + let key = backup_entry_id(&inspection.source_id, "projection", &identity); + let parsed = Parser::new().parse_section(host_typed_path(&path)); + let section = match parsed { + Ok(parsed) => { + let checkpoint = projector.clone(); + match projector.backup_section(&parsed, &key, &identity, &snapshot.display_name) { + Ok(section) => section, + Err(error) => { + projector = checkpoint; + projector.failed_backup_section( + &identity, + &snapshot.display_name, + &snapshot.relative_path, + &error.to_string(), + )? + } + } + } + Err(error) => projector.failed_backup_section( + &identity, + &snapshot.display_name, + &snapshot.relative_path, + &error.to_string(), + )?, + }; + if file_stamp(&path).ok().as_ref() != Some(&expected) { + return Err(BackupFolderError::SourceChanged); + } + projected.push(ProjectedBackupSection { + parent: snapshot.relative_parent.clone(), + logical_name: snapshot.logical_name.clone(), + date: snapshot.filename_date, + path: snapshot.relative_path.clone(), + section, + }); + completed = completed.saturating_add(1); + progress(completed); + } + + let entries = backup_entries(&inspection.source_id, Path::new(""), &projected); + let diagnostics = inspection + .diagnostics + .iter() + .map(project_backup_diagnostic) + .collect(); + Ok(LoadedNotebook { + notebook: Notebook { + source_id: inspection.source_id.clone(), + fingerprint: inspection.fingerprint.clone(), + name: inspection.notebook_name.clone(), + color: None, + entries, + diagnostics, + }, + resources: projector.resources, + }) +} + +struct ProjectedBackupSection { + parent: PathBuf, + logical_name: String, + date: Option, + path: PathBuf, + section: Section, +} + +fn backup_entries( + source_id: &SourceId, + parent: &Path, + sections: &[ProjectedBackupSection], +) -> Vec { + let mut direct = sections + .iter() + .filter(|section| section.parent == parent) + .collect::>(); + direct.sort_by(|left, right| { + natural_cmp(&left.logical_name, &right.logical_name) + .then_with(|| right.date.cmp(&left.date)) + .then_with(|| backup_path_bytes(&left.path).cmp(&backup_path_bytes(&right.path))) + }); + let mut entries = direct + .into_iter() + .map(|section| NotebookEntry::Section(section.section.clone())) + .collect::>(); + + let mut child_groups = BTreeSet::new(); + for section in sections { + let mut cursor = section.parent.as_path(); + while !cursor.as_os_str().is_empty() { + if cursor.parent().unwrap_or_else(|| Path::new("")) == parent { + child_groups.insert(cursor.to_path_buf()); + break; + } + cursor = cursor.parent().unwrap_or_else(|| Path::new("")); + } + } + let mut child_groups = child_groups.into_iter().collect::>(); + child_groups.sort_by(|left, right| { + let left_name = left + .file_name() + .unwrap_or_else(|| left.as_os_str()) + .to_string_lossy(); + let right_name = right + .file_name() + .unwrap_or_else(|| right.as_os_str()) + .to_string_lossy(); + natural_cmp(&left_name, &right_name) + .then_with(|| backup_path_bytes(left).cmp(&backup_path_bytes(right))) + }); + entries.extend(child_groups.into_iter().map(|path| { + let name = path + .file_name() + .unwrap_or_else(|| path.as_os_str()) + .to_string_lossy() + .into_owned(); + NotebookEntry::Group(SectionGroup { + id: SectionId::new(backup_entry_id( + source_id, + "group", + &backup_path_bytes(&path), + )), + name, + entries: backup_entries(source_id, &path, sections), + }) + })); + entries +} + +fn project_backup_diagnostic(diagnostic: &BackupDiagnostic) -> Diagnostic { + let message = diagnostic.relative_path.as_ref().map_or_else( + || diagnostic.message.clone(), + |path| format!("{}: {}", path.display(), diagnostic.message), + ); + Diagnostic { + severity: diagnostic.severity, + code: diagnostic.code.clone(), + message, + page_id: None, + } +} + +#[derive(Clone)] struct Projector { source_id: SourceId, fingerprint: SourceFingerprint, @@ -177,6 +355,16 @@ impl Projector { ) -> Self { let path_bytes = path_identity(path); let source_id = SourceId::new(stable_id(&[b"source", &path_bytes])); + Self::new_with_source(path, source_id, fingerprint, limits, options) + } + + fn new_with_source( + path: &Path, + source_id: SourceId, + fingerprint: SourceFingerprint, + limits: ParseLimits, + options: LoadOptions, + ) -> Self { Self { source_id, fingerprint, @@ -290,6 +478,64 @@ impl Projector { }) } + fn backup_section( + &mut self, + section: &ParserSection, + key: &str, + identity: &[u8], + display_name: &str, + ) -> Result
{ + self.section_count += 1; + self.enforce( + self.section_count <= self.limits.max_sections, + "section limit exceeded", + )?; + let id = SectionId::new(backup_entry_id(&self.source_id, "section", identity)); + let mut pages = Vec::new(); + for (series_index, series) in section.page_series().iter().enumerate() { + for (page_index, page) in series.pages().iter().enumerate() { + let page_key = format!("{key}/{series_index}/{page_index}"); + pages.push(self.backup_page(page, &page_key, identity)?); + } + } + Ok(Section { + id, + name: display_name.to_owned(), + color: section.color().map(project_color), + pages, + diagnostics: report_diagnostics(section.report(), &self.source_id), + }) + } + + fn failed_backup_section( + &mut self, + identity: &[u8], + display_name: &str, + relative_path: &Path, + error: &str, + ) -> Result
{ + self.section_count += 1; + self.enforce( + self.section_count <= self.limits.max_sections, + "section limit exceeded", + )?; + Ok(Section { + id: SectionId::new(backup_entry_id(&self.source_id, "section", identity)), + name: display_name.to_owned(), + color: None, + pages: Vec::new(), + diagnostics: vec![Diagnostic { + severity: DiagnosticSeverity::Warning, + code: "backup_selected_snapshot_parse_failed".to_owned(), + message: format!( + "Could not read selected backup snapshot {}: {error}", + relative_path.display() + ), + page_id: None, + }], + }) + } + fn page(&mut self, page: &ParserPage, key: &str) -> Result { self.page_count += 1; self.enforce( @@ -297,6 +543,28 @@ impl Projector { "page limit exceeded", )?; let page_id = PageId::new(self.id("page", page.link_target_id())); + self.project_page_contents(page, key, page_id) + } + + fn backup_page(&mut self, page: &ParserPage, key: &str, identity: &[u8]) -> Result { + self.page_count += 1; + self.enforce( + self.page_count <= self.limits.max_pages, + "page limit exceeded", + )?; + let mut page_identity = identity.to_vec(); + page_identity.push(0); + page_identity.extend_from_slice(page.link_target_id().as_bytes()); + let page_id = PageId::new(backup_entry_id(&self.source_id, "page", &page_identity)); + self.project_page_contents(page, key, page_id) + } + + fn project_page_contents( + &mut self, + page: &ParserPage, + key: &str, + page_id: PageId, + ) -> Result { let mut objects = Vec::new(); if let Some(title) = page.title() { @@ -564,10 +832,13 @@ impl Projector { status: resource_status(image.data_status()), }; self.resources - .insert(id, ResourceLoader::Image(image.clone())); - let web_fallback = image.web_picture().map(|picture| { - self.picture_resource(picture, &format!("{key}/web-fallback"), "image-fallback") - }); + .insert(id, ResourceLoader::Image(image.clone()))?; + let web_fallback = image + .web_picture() + .map(|picture| { + self.picture_resource(picture, &format!("{key}/web-fallback"), "image-fallback") + }) + .transpose()?; Ok(Image { resource, web_fallback, @@ -629,10 +900,11 @@ impl Projector { status: resource_status(file.data_status()), }; self.resources - .insert(id, ResourceLoader::Attachment(file.clone())); + .insert(id, ResourceLoader::Attachment(file.clone()))?; let icon = file .icon() - .map(|picture| self.picture_resource(picture, &format!("{key}/icon"), "file-icon")); + .map(|picture| self.picture_resource(picture, &format!("{key}/icon"), "file-icon")) + .transpose()?; Ok(Attachment { resource, icon, @@ -641,7 +913,12 @@ impl Projector { }) } - fn picture_resource(&mut self, picture: &Picture, key: &str, stem: &str) -> ResourceRef { + fn picture_resource( + &mut self, + picture: &Picture, + key: &str, + stem: &str, + ) -> Result { let id = ResourceId::new(self.id("resource", key)); let extension = picture.extension().unwrap_or("bin").trim_start_matches('.'); let resource = ResourceRef { @@ -652,8 +929,8 @@ impl Projector { status: resource_status(picture.data_status()), }; self.resources - .insert(id, ResourceLoader::Picture(picture.clone())); - resource + .insert(id, ResourceLoader::Picture(picture.clone()))?; + Ok(resource) } fn ink_object( diff --git a/crates/onenote-core/src/resource.rs b/crates/onenote-core/src/resource.rs index 4c371ef..a66e23d 100644 --- a/crates/onenote-core/src/resource.rs +++ b/crates/onenote-core/src/resource.rs @@ -115,8 +115,15 @@ pub struct ResourceStore { } impl ResourceStore { - pub(crate) fn insert(&mut self, id: ResourceId, loader: ResourceLoader) { - self.loaders.insert(id, loader); + pub(crate) fn insert(&mut self, id: ResourceId, loader: ResourceLoader) -> Result<()> { + use std::collections::hash_map::Entry; + match self.loaders.entry(id.clone()) { + Entry::Vacant(entry) => { + entry.insert(loader); + Ok(()) + } + Entry::Occupied(_) => Err(Error::ResourceCollision { id }), + } } /// Number of lazy payloads available. diff --git a/crates/onenote-core/tests/private_corpus.rs b/crates/onenote-core/tests/private_corpus.rs index b67a89e..69d9db3 100644 --- a/crates/onenote-core/tests/private_corpus.rs +++ b/crates/onenote-core/tests/private_corpus.rs @@ -1,6 +1,7 @@ use onenote_core::{ - ElementContent, Error, MathSpan, NotebookEntry, ObjectKind, OneNoteLoader, OnePkgExtractor, - OutlineElement, PageObjectRole, ResourceRef, ResourceStatus, TextBlock, + BackupFolderLoader, BackupFolderOptions, BackupLoadControl, ElementContent, Error, MathSpan, + NotebookEntry, ObjectKind, OneNoteLoader, OnePkgExtractor, OutlineElement, PageObjectRole, + ResourceRef, ResourceStatus, TextBlock, }; use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; @@ -335,6 +336,38 @@ fn every_private_backup_section_snapshot_opens_individually() { ); } +#[test] +fn private_backup_corpus_projects_as_one_aggregate_notebook() { + let Some(corpus) = backup_corpus_path() else { + return; + }; + let loader = BackupFolderLoader::default(); + let control = BackupLoadControl::new(); + let inspection = loader + .inspect(&corpus, BackupFolderOptions::default(), &control, |_| {}) + .expect("the supplied backup corpus must inspect"); + let selected = inspection + .snapshots + .iter() + .filter(|snapshot| { + matches!( + snapshot.disposition, + onenote_core::BackupSnapshotDisposition::Selected(_) + ) + }) + .count(); + let aggregate = loader + .load(inspection, &control, |_| {}) + .expect("the supplied backup corpus must aggregate"); + + assert_eq!(aggregate.loaded.notebook.sections().count(), selected); + assert!(aggregate.loaded.notebook.pages().next().is_some()); + assert_eq!( + aggregate.loaded.notebook.source_id, + aggregate.inspection.source_id + ); +} + #[test] fn supplied_package_extracts_on_disk_to_a_complete_native_tree() { let Some(package) = package_path() else { diff --git a/crates/onenote-viewer/src/app.rs b/crates/onenote-viewer/src/app.rs index 5ef5fc6..c810348 100644 --- a/crates/onenote-viewer/src/app.rs +++ b/crates/onenote-viewer/src/app.rs @@ -13,8 +13,9 @@ use gtk::gio; use gtk::glib; use gtk::prelude::*; use onenote_core::{ + BackupLoadControl, BackupLoadProgress, BackupProgressPhase, BackupSelectionPolicy, ExtractionPhase, LoadOptions, LoadedNotebook, Notebook, ObjectId, Page, PageId, Rect, - ResourceId, ResourceRef, SectionId, SourceId, + ResourceId, ResourceRef, SectionId, SourceDescriptor, SourceId, }; use onenote_index::{IndexProfile, IndexUpdate, MatchedField, SearchHit, SearchQuery, TextRange}; use onenote_render::{HitAction, ScenePrimitive}; @@ -72,40 +73,8 @@ pub(crate) fn run(requested_sources: Vec) -> Result<()> { let settings_path = settings::path(); let persisted_settings = settings::load(&settings_path).unwrap_or_default(); let notebooks_location = persisted_settings.notebooks_location.clone(); - let configured_sources = if requested_sources.is_empty() { - persisted.sources.clone() - } else { - requested_sources - }; - let restore_target = persisted.navigation.last_page.filter(|location| { - workspace::source_is_in_workspace( - &location.source_path, - &configured_sources, - ¬ebooks_location, - ) - }); - let restore_ui = persisted.ui.map(|mut ui| { - ui.sources.retain(|source| { - workspace::source_is_in_workspace( - &source.source_path, - &configured_sources, - ¬ebooks_location, - ) - }); - ui - }); - let restore = WorkspaceRestore { - page: restore_target, - ui: restore_ui, - }; - let mut initial_sources: Vec<_> = configured_sources - .into_iter() - .filter(|source| !workspace::source_is_in_location(source, ¬ebooks_location)) - .collect(); - prioritize_source_roots(&mut initial_sources, restore.page.as_ref()); - let restore_is_outside_default_location = restore.page.as_ref().is_some_and(|target| { - !workspace::source_is_in_location(&target.source_path, ¬ebooks_location) - }); + let (restore, initial_sources, restore_is_outside_default_location) = + prepare_workspace_restore(persisted, &requested_sources, ¬ebooks_location); let application = gtk::Application::builder() .application_id("io.github.emsi.OneNoteViewer") @@ -128,15 +97,19 @@ pub(crate) fn run(requested_sources: Vec) -> Result<()> { restore.clone(), style_provider, ); - if restore_is_outside_default_location { - for source in &initial_sources { + if !requested_sources.is_empty() { + for source in &requested_sources { instance.discover(source.clone()); } + } else if restore_is_outside_default_location { + for source in &initial_sources { + instance.load_source(source.clone(), None); + } } instance.open_notebooks_location(); - if !restore_is_outside_default_location { + if requested_sources.is_empty() && !restore_is_outside_default_location { for source in &initial_sources { - instance.discover(source.clone()); + instance.load_source(source.clone(), None); } } instance.window.present(); @@ -287,7 +260,7 @@ fn smoke_quit_delay() -> Option { } struct Source { - path: PathBuf, + descriptor: SourceDescriptor, loaded: Arc, last_location: Option, } @@ -392,6 +365,46 @@ struct AttachmentContext { enum ForegroundOperationKind { PackageImport(Arc), Attachment(crate::attachment::CopyCancellation), + BackupLoad(Rc), +} + +struct BackupOperationCancellation { + source_path: PathBuf, + load: BackupLoadControl, + index: RefCell>>, +} + +impl BackupOperationCancellation { + fn new(source_path: PathBuf) -> Self { + Self { + source_path, + load: BackupLoadControl::new(), + index: RefCell::new(None), + } + } + + fn cancel(&self) { + self.load.cancel(); + if let Some(index) = self.index.borrow().as_ref() { + index.store(true, Ordering::Release); + } + } + + fn is_cancelled(&self) -> bool { + self.load.is_cancelled() + || self + .index + .borrow() + .as_ref() + .is_some_and(|cancel| cancel.load(Ordering::Acquire)) + } + + fn set_index_cancel(&self, cancel: Arc) { + if self.load.is_cancelled() { + cancel.store(true, Ordering::Release); + } + *self.index.borrow_mut() = Some(cancel); + } } struct ForegroundOperation { @@ -406,6 +419,7 @@ impl ForegroundOperation { cancel.store(true, Ordering::Release); } ForegroundOperationKind::Attachment(cancel) => cancel.cancel(), + ForegroundOperationKind::BackupLoad(cancel) => cancel.cancel(), } } @@ -413,6 +427,7 @@ impl ForegroundOperation { match &self.kind { ForegroundOperationKind::PackageImport(cancel) => cancel.load(Ordering::Acquire), ForegroundOperationKind::Attachment(cancel) => cancel.is_cancelled(), + ForegroundOperationKind::BackupLoad(cancel) => cancel.is_cancelled(), } } } @@ -430,6 +445,14 @@ struct State { restore_target: Option, pending_expansions: BTreeMap, history: NavigationHistory, + configured_sources: Vec, + pending_publications: BTreeMap<(SourceId, u64), PendingPublication>, +} + +struct PendingPublication { + descriptor: SourceDescriptor, + load: worker::SourceLoad, + operation_id: Option, } #[derive(Default)] @@ -486,7 +509,7 @@ impl SourceDisplayOrder { impl State { fn upsert_source( &mut self, - path: PathBuf, + descriptor: SourceDescriptor, loaded: Arc, restored_location: Option, ) -> usize { @@ -497,7 +520,7 @@ impl State { .position(|source| source.loaded.notebook.source_id == *source_id) { let existing = &mut self.sources[position]; - existing.path = path; + existing.descriptor = descriptor; existing.loaded = loaded; if restored_location.is_some() { existing.last_location = restored_location; @@ -505,15 +528,16 @@ impl State { return position; } + let path = descriptor.path().to_path_buf(); self.source_order.register(std::iter::once(path.clone())); let position = self.source_order.insertion_index( &path, - self.sources.iter().map(|source| source.path.as_path()), + self.sources.iter().map(|source| source.descriptor.path()), ); self.sources.insert( position, Source { - path, + descriptor, loaded, last_location: restored_location, }, @@ -602,6 +626,58 @@ impl IndexActivity { struct WorkspaceRestore { page: Option, ui: Option, + configured_sources: Vec, +} + +fn prepare_workspace_restore( + persisted: WorkspaceConfig, + requested_sources: &[PathBuf], + notebooks_location: &std::path::Path, +) -> (WorkspaceRestore, Vec, bool) { + let configured_sources = if requested_sources.is_empty() { + persisted.sources + } else { + requested_sources + .iter() + .cloned() + .map(SourceDescriptor::native) + .collect() + }; + let page = persisted.navigation.last_page.filter(|location| { + workspace::source_is_in_workspace( + &location.source_path, + &configured_sources, + notebooks_location, + ) + }); + let ui = persisted.ui.map(|mut ui| { + ui.sources.retain(|source| { + workspace::source_is_in_workspace( + &source.source_path, + &configured_sources, + notebooks_location, + ) + }); + ui + }); + let mut initial_sources = configured_sources + .iter() + .filter(|source| !workspace::source_is_in_location(source.path(), notebooks_location)) + .cloned() + .collect(); + prioritize_source_roots(&mut initial_sources, page.as_ref()); + let outside_default = page.as_ref().is_some_and(|target| { + !workspace::source_is_in_location(&target.source_path, notebooks_location) + }); + let restore = WorkspaceRestore { + page, + ui, + configured_sources: requested_sources + .is_empty() + .then_some(configured_sources) + .unwrap_or_default(), + }; + (restore, initial_sources, outside_default) } #[derive(Clone, Copy)] @@ -648,6 +724,8 @@ struct Viewer { operation_progress: gtk::ProgressBar, operation_cancel_button: gtk::Button, import_package_action: gio::SimpleAction, + refresh_source_action: gio::SimpleAction, + show_backup_copies_action: gio::SimpleAction, page_find_action: gio::SimpleAction, history_back_action: gio::SimpleAction, history_forward_action: gio::SimpleAction, @@ -767,7 +845,13 @@ impl Viewer { let open_file = gio::SimpleAction::new("open-file", None); let open_folder = gio::SimpleAction::new("open-folder", None); + let open_backup_folder = gio::SimpleAction::new("open-backup-folder", None); let import_package = gio::SimpleAction::new("import-package", None); + let refresh_source = gio::SimpleAction::new("refresh-source", None); + refresh_source.set_enabled(false); + let show_backup_copies = + gio::SimpleAction::new_stateful("show-backup-copies", None, &false.to_variant()); + show_backup_copies.set_enabled(false); let open_settings = gio::SimpleAction::new("settings", None); let show_about = gio::SimpleAction::new("about", None); let quit = gio::SimpleAction::new("quit", None); @@ -790,12 +874,26 @@ impl Viewer { let file_menu = gio::Menu::new(); file_menu.append(Some("Open OneNote File..."), Some("win.open-file")); file_menu.append(Some("Open Notebook Folder..."), Some("win.open-folder")); + file_menu.append( + Some("Open OneNote Backup Folder..."), + Some("win.open-backup-folder"), + ); file_menu.append( Some("Import OneNote Package..."), Some("win.import-package"), ); let application_menu = gio::Menu::new(); application_menu.append_section(None, &file_menu); + let source_menu = gio::Menu::new(); + source_menu.append( + Some("Refresh Selected Notebook"), + Some("win.refresh-source"), + ); + source_menu.append( + Some("Show All Backup Copies"), + Some("win.show-backup-copies"), + ); + application_menu.append_section(None, &source_menu); let navigation_menu = gio::Menu::new(); navigation_menu.append(Some("Back"), Some("win.history-back")); navigation_menu.append(Some("Forward"), Some("win.history-forward")); @@ -1095,6 +1193,8 @@ impl Viewer { operation_progress, operation_cancel_button, import_package_action: import_package.clone(), + refresh_source_action: refresh_source.clone(), + show_backup_copies_action: show_backup_copies.clone(), page_find_action: page_find.clone(), history_back_action: history_back.clone(), history_forward_action: history_forward.clone(), @@ -1107,6 +1207,7 @@ impl Viewer { restore_target: restore.page, pending_expansions, source_order, + configured_sources: restore.configured_sources, ..State::default() }), workspace_path, @@ -1143,7 +1244,10 @@ impl Viewer { } viewer.window.add_action(&open_file); viewer.window.add_action(&open_folder); + viewer.window.add_action(&open_backup_folder); viewer.window.add_action(&import_package); + viewer.window.add_action(&refresh_source); + viewer.window.add_action(&show_backup_copies); viewer.window.add_action(&open_settings); viewer.window.add_action(&show_about); viewer.window.add_action(&quit); @@ -1172,6 +1276,7 @@ impl Viewer { &quit, &close_source, ); + viewer.connect_source_actions(&open_backup_folder, &refresh_source, &show_backup_copies); viewer.connect_about(&show_about); viewer.connect_history(); viewer.connect_workspace_search(&focus_search); @@ -1449,6 +1554,41 @@ impl Viewer { }); } + fn connect_source_actions( + self: &Rc, + open_backup_folder: &gio::SimpleAction, + refresh_source: &gio::SimpleAction, + show_backup_copies: &gio::SimpleAction, + ) { + let weak = Rc::downgrade(self); + open_backup_folder.connect_activate(move |_, _| { + if let Some(viewer) = weak.upgrade() { + viewer.choose_backup_folder(); + } + }); + let weak = Rc::downgrade(self); + refresh_source.connect_activate(move |_, _| { + if let Some(viewer) = weak.upgrade() { + viewer.refresh_active_source(); + } + }); + let weak = Rc::downgrade(self); + show_backup_copies.connect_activate(move |action, _| { + let Some(viewer) = weak.upgrade() else { + return; + }; + let enabled = action + .state() + .and_then(|state| state.get::()) + .unwrap_or(false); + viewer.set_active_backup_policy(if enabled { + BackupSelectionPolicy::LatestPerSection + } else { + BackupSelectionPolicy::AllCopies + }); + }); + } + fn connect_about(self: &Rc, show_about: &gio::SimpleAction) { let weak = Rc::downgrade(self); show_about.connect_activate(move |_, _| { @@ -1487,7 +1627,7 @@ impl Viewer { button.set_sensitive(false); viewer .operation_activity_phase - .set_label("Cancelling and cleaning temporary output..."); + .set_label("Cancelling operation..."); }); } @@ -1899,19 +2039,25 @@ impl Viewer { self.handle_library_discovered(&location, result); } Event::Loaded { - path, + source, index_profile, + operation_id, result, - } => match result { - Ok(loaded) => { - self.add_source(path, Arc::clone(&loaded)); - self.queue_index(loaded, index_profile); - } - Err(error) => { - self.finish_restore_load(&path); - self.show_error("Could not read notebook", &error); + } => self.handle_source_loaded(source, index_profile, operation_id, result), + Event::BackupProgress { + operation_id, + progress, + } => self.handle_backup_progress(operation_id, progress), + Event::BackupFallbackRequired { + operation_id, + root, + manifest_error, + } => { + if self.operation_is_active(operation_id) { + self.finish_operation(operation_id); + self.show_backup_fallback_warning(root, &manifest_error); } - }, + } Event::Indexed { source_id, generation, @@ -1969,21 +2115,55 @@ impl Viewer { } } + fn handle_source_loaded( + self: &Rc, + source: SourceDescriptor, + index_profile: IndexProfile, + operation_id: Option, + result: std::result::Result, + ) { + if operation_id.is_some_and(|operation_id| !self.operation_is_active(operation_id)) { + return; + } + match result { + Ok(load) if source.is_backup() || operation_id.is_some() => { + self.queue_staged_publication(source, load, index_profile, operation_id); + } + Ok(load) => { + self.add_source(source, Arc::clone(&load.loaded)); + self.queue_index(load.loaded, index_profile); + } + Err(error) => { + self.finish_restore_load(source.path()); + if let Some(operation_id) = operation_id { + let cancelled = self.operation_was_cancelled(operation_id); + self.finish_operation(operation_id); + if cancelled { + self.status.set_label("Backup loading cancelled"); + return; + } + } + self.show_error("Could not read notebook", &error); + } + } + } + fn handle_discovered( self: &Rc, requested: &std::path::Path, - result: std::result::Result, String>, + result: std::result::Result, String>, ) { match result { Ok(mut paths) => { + apply_source_overrides(&mut paths, &self.state.borrow().configured_sources); self.register_source_order(&paths); prioritize_discovered_sources( &mut paths, self.state.borrow().restore_target.as_ref(), ); self.finish_restore_discovery(requested, &paths); - for path in paths { - self.load_source(path); + for source in paths { + self.load_source(source, None); } self.set_busy(&format!("Opening {}", requested.display())); } @@ -1997,7 +2177,7 @@ impl Viewer { fn handle_library_discovered( self: &Rc, location: &std::path::Path, - result: std::result::Result, String>, + result: std::result::Result, String>, ) { match result { Ok(paths) if paths.is_empty() => { @@ -2009,6 +2189,7 @@ impl Viewer { )); } Ok(mut paths) => { + apply_source_overrides(&mut paths, &self.state.borrow().configured_sources); self.register_source_order(&paths); prioritize_discovered_sources( &mut paths, @@ -2016,8 +2197,8 @@ impl Viewer { ); self.finish_restore_discovery(location, &paths); let count = paths.len(); - for path in paths { - self.load_source(path); + for source in paths { + self.load_source(source, None); } self.set_busy(&format!( "Opening {count} notebook{} from the default location", @@ -2145,22 +2326,54 @@ impl Viewer { let _ignored = self.source_commands.send(SourceCommand::Discover(path)); } - fn load_source(&self, path: PathBuf) { + fn load_source(&self, source: SourceDescriptor, operation: Option<(u64, BackupLoadControl)>) { + self.load_source_with_manifest_policy( + source, + operation, + onenote_core::RootManifestPolicy::Ignore, + ); + } + + fn load_source_with_manifest_policy( + &self, + source: SourceDescriptor, + operation: Option<(u64, BackupLoadControl)>, + root_manifest: onenote_core::RootManifestPolicy, + ) { let settings = self.settings.borrow(); let options = load_options(&settings); let index_profile = index_profile(&settings); - let _ignored = self.source_commands.send(SourceCommand::Load { - path, - options, - index_profile, - }); + let (operation_id, control) = operation.map_or_else( + || (None, BackupLoadControl::new()), + |(id, control)| (Some(id), control), + ); + if self + .source_commands + .send(SourceCommand::Load { + source, + options, + index_profile, + operation_id, + control, + root_manifest, + }) + .is_err() + { + if let Some(operation_id) = operation_id { + self.finish_operation(operation_id); + } + self.show_error( + "Could not read notebook", + "The source-loading worker is not available.", + ); + } } - fn register_source_order(&self, paths: &[PathBuf]) { + fn register_source_order(&self, sources: &[SourceDescriptor]) { self.state .borrow_mut() .source_order - .register(paths.iter().cloned()); + .register(sources.iter().map(|source| source.path().to_path_buf())); } fn open_notebooks_location(&self) { @@ -2181,7 +2394,8 @@ impl Viewer { .send(SourceCommand::DiscoverLibrary(location)); } - fn add_source(self: &Rc, path: PathBuf, loaded: Arc) { + fn add_source(self: &Rc, descriptor: SourceDescriptor, loaded: Arc) { + let backup_source = descriptor.is_backup(); let source_id = loaded.notebook.source_id.clone(); let mut state = self.state.borrow_mut(); let restored_tree = state @@ -2191,7 +2405,18 @@ impl Viewer { let restored_location = restore_location_for_source(state.restore_target.as_ref(), &loaded.notebook); let restoring = restored_location.is_some(); - let display_position = state.upsert_source(path, loaded, restored_location); + let previous_path = state + .sources + .iter() + .find(|source| source.loaded.notebook.source_id == source_id) + .map(|source| source.descriptor.path().to_path_buf()); + register_configured_source( + &mut state.configured_sources, + &descriptor, + &self.settings.borrow().notebooks_location, + previous_path.as_deref(), + ); + let display_position = state.upsert_source(descriptor, loaded, restored_location); let notebook = state .sources .iter() @@ -2259,7 +2484,12 @@ impl Viewer { }); } self.schedule_workspace_save(); - self.status.set_label("Notebook opened"); + self.refresh_source_actions(); + self.status.set_label(if backup_source { + "Backup notebook opened with reconstructed section order" + } else { + "Notebook opened" + }); } fn queue_index(&self, loaded: Arc, profile: IndexProfile) { @@ -2288,8 +2518,98 @@ impl Viewer { self.refresh_index_status(); } + fn queue_staged_publication( + self: &Rc, + descriptor: SourceDescriptor, + load: worker::SourceLoad, + profile: IndexProfile, + operation_id: Option, + ) { + let source_id = load.loaded.notebook.source_id.clone(); + let loaded = load.loaded.clone(); + let (generation, cancel) = self.index_activity.borrow_mut().begin(source_id.clone()); + if let Some(operation_id) = operation_id { + if let Some(ForegroundOperation { + id, + kind: ForegroundOperationKind::BackupLoad(operation), + }) = self.foreground_operation.borrow().as_ref() + { + if *id == operation_id { + operation.set_index_cancel(cancel.clone()); + } + } + } + self.state + .borrow_mut() + .pending_publications + .retain(|(pending_source, _), _| pending_source != &source_id); + self.state.borrow_mut().pending_publications.insert( + (source_id.clone(), generation), + PendingPublication { + descriptor, + load, + operation_id, + }, + ); + if self + .index_commands + .send(IndexCommand::Ensure { + loaded, + profile, + generation, + cancel, + }) + .is_err() + { + self.index_activity + .borrow_mut() + .take_current(&source_id, generation); + let pending = self + .state + .borrow_mut() + .pending_publications + .remove(&(source_id, generation)); + if let Some(operation_id) = pending.and_then(|pending| pending.operation_id) { + self.finish_operation(operation_id); + } + self.show_error( + "Could not open backup folder", + "The search-index worker is not available.", + ); + return; + } + if operation_id.is_some() { + self.operation_pulsing.set(true); + self.operation_progress.set_fraction(0.0); + self.operation_activity_phase + .set_label("Updating the search index..."); + } + self.refresh_index_status(); + } + + fn handle_backup_progress(&self, operation_id: Option, progress: BackupLoadProgress) { + let phase = backup_progress_label(progress.phase); + self.status.set_label(phase); + let Some(operation_id) = operation_id else { + return; + }; + if !self.operation_is_active(operation_id) { + return; + } + self.operation_activity_phase.set_label(phase); + if progress.total == 0 { + self.operation_pulsing.set(true); + } else { + self.operation_pulsing.set(false); + let completed = u32::try_from(progress.completed).unwrap_or(u32::MAX); + let total = u32::try_from(progress.total).unwrap_or(u32::MAX); + self.operation_progress + .set_fraction((f64::from(completed) / f64::from(total)).clamp(0.0, 1.0)); + } + } + fn finish_index( - &self, + self: &Rc, source_id: &SourceId, generation: u64, result: std::result::Result, @@ -2301,18 +2621,48 @@ impl Viewer { { return; } + let pending = self + .state + .borrow_mut() + .pending_publications + .remove(&(source_id.clone(), generation)); match result { Ok(IndexUpdate::Reused) => self.index_activity.borrow_mut().reused += 1, Ok(IndexUpdate::Rebuilt) => self.index_activity.borrow_mut().rebuilt += 1, Err(error) => { + let operation_id = pending.as_ref().and_then(|pending| pending.operation_id); + let cancelled = operation_id.is_some_and(|id| self.operation_was_cancelled(id)); + if let Some(operation_id) = operation_id { + self.finish_operation(operation_id); + } + if cancelled { + self.status.set_label("Backup loading cancelled"); + return; + } self.index_activity.borrow_mut().failed += 1; self.show_error( - "Notebook opened, but indexing failed", + if pending.is_some() { + "Could not refresh notebook" + } else { + "Notebook opened, but indexing failed" + }, &format!("{source_id}: {error}"), ); return; } } + if let Some(pending) = pending { + let backup_source = pending.descriptor.is_backup(); + self.add_source(pending.descriptor, pending.load.loaded); + if let Some(operation_id) = pending.operation_id { + self.finish_operation(operation_id); + } + if backup_source { + self.status + .set_label("Backup notebook opened with reconstructed section order"); + return; + } + } self.refresh_index_status(); } @@ -2330,17 +2680,25 @@ impl Viewer { self.index_activity.borrow_mut().cancel_all(); } - fn finish_restore_discovery(self: &Rc, requested: &std::path::Path, paths: &[PathBuf]) { + fn finish_restore_discovery( + self: &Rc, + requested: &std::path::Path, + sources: &[SourceDescriptor], + ) { + let paths = sources + .iter() + .map(|source| source.path().to_path_buf()) + .collect::>(); let mut state = self.state.borrow_mut(); let page_unavailable = - restore_missing_from_discovery(state.restore_target.as_ref(), requested, paths); + restore_missing_from_discovery(state.restore_target.as_ref(), requested, &paths); if page_unavailable { state.restore_target = None; } let before = state.pending_expansions.len(); state .pending_expansions - .retain(|_, source| !tree_state_missing_from_discovery(source, requested, paths)); + .retain(|_, source| !tree_state_missing_from_discovery(source, requested, &paths)); let tree_unavailable = before != state.pending_expansions.len(); drop(state); if page_unavailable || tree_unavailable { @@ -2483,6 +2841,7 @@ impl Viewer { self.page_selection.set_selected(NO_SELECTION); }); self.schedule_workspace_save(); + self.refresh_source_actions(); } } @@ -2701,6 +3060,7 @@ impl Viewer { self.refresh_history_actions(); self.refresh_workspace_search_scope(); self.refresh_page_find_action(); + self.refresh_source_actions(); true } @@ -3121,7 +3481,13 @@ impl Viewer { state.pages.clear(); state.restore_target = None; let removed = state.sources.remove(position); - state.source_order.remove(&removed.path); + state.source_order.remove(removed.descriptor.path()); + state + .configured_sources + .retain(|source| !same_source_path(source.path(), removed.descriptor.path())); + state + .pending_publications + .retain(|(source, _), _| source != &source_id); { let State { sources, history, .. @@ -3132,6 +3498,7 @@ impl Viewer { (position, removed, history_target) }; let removed_source_id = removed.1.loaded.notebook.source_id.clone(); + self.cancel_backup_operation_for_source(removed.1.descriptor.path()); self.cancel_index_source(&removed_source_id); let _ignored = self .index_commands @@ -3145,6 +3512,7 @@ impl Viewer { self.clear_rendered_page(); self.refresh_history_actions(); self.refresh_workspace_search_scope(); + self.refresh_source_actions(); if let Some(target) = removed.2.as_ref() { if self.activate_location( &target.source, @@ -3186,25 +3554,20 @@ impl Viewer { .iter() .find(|source| source.loaded.notebook.source_id == active.source)?; Some(PersistedPageLocation { - source_path: source.path.clone(), + source_path: source.descriptor.path().to_path_buf(), source_id: active.source.clone(), section_id: section.section_id.clone(), page_id, }) }); - let mut sources = state - .sources - .iter() - .map(|source| source.path.clone()) - .filter(|source| !workspace::source_is_in_location(source, ¬ebooks_location)) - .collect::>(); + let mut sources = state.configured_sources.clone(); if let Some(target) = state.restore_target.as_ref().filter(|target| { !workspace::source_is_in_location(&target.source_path, ¬ebooks_location) && !sources .iter() - .any(|source| same_source_path(source, &target.source_path)) + .any(|source| same_source_path(source.path(), &target.source_path)) }) { - sources.push(target.source_path.clone()); + sources.push(SourceDescriptor::native(target.source_path.clone())); } let mut source_states = state .sources @@ -3213,7 +3576,7 @@ impl Viewer { self.notebook_tree .expansion_state(&source.loaded.notebook.source_id) .map(|expansion| PersistedSourceTreeState { - source_path: source.path.clone(), + source_path: source.descriptor.path().to_path_buf(), source_id: source.loaded.notebook.source_id.clone(), notebook_expanded: expansion.notebook_expanded, expanded_groups: expansion.expanded_groups.into_iter().collect(), @@ -3330,6 +3693,167 @@ impl Viewer { ); } + fn choose_backup_folder(self: &Rc) { + let initial = gio::File::for_path(&self.settings.borrow().notebooks_location); + let dialog = gtk::FileDialog::builder() + .title("Open OneNote backup folder") + .initial_folder(&initial) + .modal(true) + .build(); + let weak = Rc::downgrade(self); + dialog.select_folder( + Some(&self.window), + None::<&gio::Cancellable>, + move |result| { + let Some(viewer) = weak.upgrade() else { + return; + }; + match result { + Ok(file) => { + if let Some(path) = file.path() { + viewer.start_backup_load( + SourceDescriptor::backup( + path, + BackupSelectionPolicy::LatestPerSection, + ), + "Opening OneNote backup folder", + onenote_core::RootManifestPolicy::Reject, + ); + } + } + Err(error) if error.matches(gtk::DialogError::Dismissed) => {} + Err(error) => { + viewer.show_error("Could not select backup folder", &error.to_string()); + } + } + }, + ); + } + + fn refresh_active_source(self: &Rc) { + let descriptor = { + let state = self.state.borrow(); + let Some(active) = state.active.as_ref() else { + return; + }; + state + .sources + .iter() + .find(|source| source.loaded.notebook.source_id == active.source) + .map(|source| source.descriptor.clone()) + }; + let Some(descriptor) = descriptor.filter(SourceDescriptor::is_backup) else { + return; + }; + self.start_backup_load( + descriptor, + "Refreshing OneNote backup folder", + onenote_core::RootManifestPolicy::Ignore, + ); + } + + fn set_active_backup_policy(self: &Rc, policy: BackupSelectionPolicy) { + let descriptor = { + let state = self.state.borrow(); + let Some(active) = state.active.as_ref() else { + return; + }; + state + .sources + .iter() + .find(|source| source.loaded.notebook.source_id == active.source) + .map(|source| source.descriptor.with_backup_selection(policy)) + }; + let Some(descriptor) = descriptor.filter(SourceDescriptor::is_backup) else { + return; + }; + self.start_backup_load( + descriptor, + "Changing backup snapshot view", + onenote_core::RootManifestPolicy::Ignore, + ); + } + + fn start_backup_load( + &self, + descriptor: SourceDescriptor, + title: &str, + root_manifest: onenote_core::RootManifestPolicy, + ) { + if self.foreground_operation.borrow().is_some() { + self.status + .set_label("Another file operation is already running"); + return; + } + let operation_id = self.allocate_operation_id(); + let cancellation = Rc::new(BackupOperationCancellation::new( + descriptor.path().to_path_buf(), + )); + let control = cancellation.load.clone(); + *self.foreground_operation.borrow_mut() = Some(ForegroundOperation { + id: operation_id, + kind: ForegroundOperationKind::BackupLoad(cancellation), + }); + self.operation_pulsing.set(true); + self.operation_cancel_button.set_sensitive(true); + self.operation_progress.set_fraction(0.0); + self.operation_activity_title.set_label(title); + self.operation_activity_phase + .set_label("Inspecting backup folder..."); + self.operation_activity.set_reveal_child(true); + self.refresh_source_action.set_enabled(false); + self.show_backup_copies_action.set_enabled(false); + self.set_busy(title); + self.load_source_with_manifest_policy( + descriptor, + Some((operation_id, control)), + root_manifest, + ); + } + + fn cancel_backup_operation_for_source(&self, source_path: &std::path::Path) { + let operation_id = { + let operation = self.foreground_operation.borrow(); + operation + .as_ref() + .and_then(|operation| match &operation.kind { + ForegroundOperationKind::BackupLoad(cancel) + if same_source_path(&cancel.source_path, source_path) => + { + operation.cancel(); + Some(operation.id) + } + _ => None, + }) + }; + if let Some(operation_id) = operation_id { + self.finish_operation(operation_id); + } + } + + fn refresh_source_actions(&self) { + let selection = { + let state = self.state.borrow(); + state.active.as_ref().and_then(|active| { + state + .sources + .iter() + .find(|source| source.loaded.notebook.source_id == active.source) + .and_then(|source| match &source.descriptor { + SourceDescriptor::BackupFolder { selection, .. } => Some(*selection), + SourceDescriptor::NativeFile { .. } => None, + }) + }) + }; + let idle = self.foreground_operation.borrow().is_none(); + self.refresh_source_action + .set_enabled(selection.is_some() && idle); + self.show_backup_copies_action + .set_enabled(selection.is_some() && idle); + self.show_backup_copies_action + .set_state(&(selection == Some(BackupSelectionPolicy::AllCopies)).to_variant()); + } + fn choose_package(self: &Rc) { if self.foreground_operation.borrow().is_some() { self.status @@ -3451,19 +3975,19 @@ impl Viewer { } fn reload_sources_for_link_detection(&self) { - let paths = self + let sources = self .state .borrow() .sources .iter() - .map(|source| source.path.clone()) + .map(|source| source.descriptor.clone()) .collect::>(); - if paths.is_empty() { + if sources.is_empty() { return; } self.set_busy("Applying link detection setting"); - for path in paths { - self.load_source(path); + for source in sources { + self.load_source(source, None); } } @@ -3543,6 +4067,7 @@ impl Viewer { self.operation_cancel_button.set_sensitive(false); self.import_package_action.set_enabled(true); self.operation_activity.set_reveal_child(false); + self.refresh_source_actions(); } fn cancel_foreground_operation_for_shutdown(&self, timeout: Duration) { @@ -3554,6 +4079,12 @@ impl Viewer { operation.cancel(); operation.id }; + let pending_index = self.state.borrow().pending_publications.iter().find_map( + |((source_id, generation), publication)| { + (publication.operation_id == Some(operation_id)) + .then(|| (source_id.clone(), *generation)) + }, + ); let deadline = std::time::Instant::now() + timeout; while std::time::Instant::now() < deadline { let remaining = deadline.saturating_duration_since(std::time::Instant::now()); @@ -3567,8 +4098,26 @@ impl Viewer { | Event::AttachmentCopied { operation_id: completed, .. + } + | Event::Loaded { + operation_id: Some(completed), + .. + } + | Event::BackupFallbackRequired { + operation_id: completed, + .. }, ) if completed == operation_id => break, + Ok(Event::Indexed { + source_id, + generation, + .. + }) if pending_index + .as_ref() + .is_some_and(|pending| pending.0 == source_id && pending.1 == generation) => + { + break; + } Ok(_) | Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Disconnected) => break, } @@ -3707,6 +4256,69 @@ impl Viewer { close.grab_focus(); dialog.present(); } + + fn show_backup_fallback_warning(self: &Rc, root: PathBuf, manifest_error: &str) { + let dialog = gtk::Window::builder() + .title("Notebook manifest could not be read") + .transient_for(&self.window) + .modal(true) + .resizable(true) + .default_width(640) + .default_height(300) + .build(); + dialog.add_css_class("error-dialog"); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 14); + content.set_margin_start(18); + content.set_margin_end(18); + content.set_margin_top(18); + content.set_margin_bottom(18); + let heading = gtk::Label::builder() + .label("The folder contains a notebook table of contents, but it could not be read.") + .wrap(true) + .xalign(0.0) + .selectable(true) + .build(); + heading.add_css_class("error-title"); + let detail = gtk::Label::builder() + .label(format!( + "{manifest_error}\n\nOpening the folder as a backup reconstructs section groups and order from filenames and directories. The source files will not be changed." + )) + .wrap(true) + .wrap_mode(gtk::pango::WrapMode::WordChar) + .xalign(0.0) + .yalign(0.0) + .selectable(true) + .vexpand(true) + .build(); + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); + actions.set_halign(gtk::Align::End); + let cancel = gtk::Button::with_label("Cancel"); + let open = gtk::Button::with_label("Open as Backup"); + open.add_css_class("suggested-action"); + actions.append(&cancel); + actions.append(&open); + content.append(&heading); + content.append(&detail); + content.append(&actions); + dialog.set_child(Some(&content)); + + let close_dialog = dialog.clone(); + cancel.connect_clicked(move |_| close_dialog.close()); + let weak = Rc::downgrade(self); + let open_dialog = dialog.clone(); + open.connect_clicked(move |_| { + open_dialog.close(); + if let Some(viewer) = weak.upgrade() { + viewer.start_backup_load( + SourceDescriptor::backup(root.clone(), BackupSelectionPolicy::LatestPerSection), + "Opening OneNote backup folder", + onenote_core::RootManifestPolicy::Ignore, + ); + } + }); + dialog.present(); + } } fn extraction_phase_label(phase: ExtractionPhase) -> &'static str { @@ -4782,31 +5394,31 @@ fn index_profile(settings: &AppSettings) -> IndexProfile { } fn prioritize_discovered_sources( - sources: &mut Vec, + sources: &mut Vec, target: Option<&PersistedPageLocation>, ) { let Some(target) = target else { return; }; move_matching_source_first(sources, |source| { - same_source_path(source, &target.source_path) + same_source_path(source.path(), &target.source_path) }); } -fn prioritize_source_roots(sources: &mut Vec, target: Option<&PersistedPageLocation>) { +fn prioritize_source_roots( + sources: &mut Vec, + target: Option<&PersistedPageLocation>, +) { let Some(target) = target else { return; }; move_matching_source_first(sources, |source| { - workspace::source_is_in_location(&target.source_path, source) + workspace::source_is_in_location(&target.source_path, source.path()) }); } -fn move_matching_source_first( - sources: &mut Vec, - predicate: impl Fn(&std::path::Path) -> bool, -) { - let Some(position) = sources.iter().position(|source| predicate(source)) else { +fn move_matching_source_first(sources: &mut Vec, predicate: impl Fn(&T) -> bool) { + let Some(position) = sources.iter().position(predicate) else { return; }; if position != 0 { @@ -4815,6 +5427,55 @@ fn move_matching_source_first( } } +fn register_configured_source( + configured: &mut Vec, + descriptor: &SourceDescriptor, + notebooks_location: &std::path::Path, + previous_path: Option<&std::path::Path>, +) { + if workspace::source_is_in_location(descriptor.path(), notebooks_location) + && !descriptor.is_backup() + { + if let Some(previous_path) = previous_path { + configured.retain(|source| !same_source_path(source.path(), previous_path)); + } + return; + } + if let Some(existing) = configured.iter_mut().find(|source| { + same_source_path(source.path(), descriptor.path()) + || previous_path.is_some_and(|path| same_source_path(source.path(), path)) + }) { + *existing = descriptor.clone(); + } else { + configured.push(descriptor.clone()); + } +} + +fn apply_source_overrides(discovered: &mut [SourceDescriptor], configured: &[SourceDescriptor]) { + for source in discovered { + let Some(override_source) = configured.iter().find(|configured| { + configured.is_backup() + && source.is_backup() + && same_source_path(configured.path(), source.path()) + }) else { + continue; + }; + *source = override_source.clone(); + } +} + +const fn backup_progress_label(phase: BackupProgressPhase) -> &'static str { + match phase { + BackupProgressPhase::Classifying => "Checking backup folder", + BackupProgressPhase::Discovering => "Discovering backup sections", + BackupProgressPhase::Grouping => "Grouping backup snapshots", + BackupProgressPhase::Selecting => "Selecting backup snapshots", + BackupProgressPhase::Parsing => "Reading backup sections", + BackupProgressPhase::Assembling => "Assembling reconstructed notebook", + BackupProgressPhase::Verifying => "Verifying backup snapshot", + } +} + fn install_resources(theme: ThemePreference) -> gtk::CssProvider { let display = gdk_display(); gtk::IconTheme::for_display(&display).add_resource_path("/io/github/emsi/OneNoteViewer/icons"); @@ -4880,33 +5541,109 @@ mod tests { fn startup_priority_does_not_change_notebook_presentation_order() { let target = persisted_page("wanted", "/notes/Wanted/Open Notebook.onetoc2"); let presentation_order = vec![ - PathBuf::from("/notes/First/Open Notebook.onetoc2"), - PathBuf::from("/notes/Wanted/Open Notebook.onetoc2"), - PathBuf::from("/notes/Last/Open Notebook.onetoc2"), + SourceDescriptor::native("/notes/First/Open Notebook.onetoc2"), + SourceDescriptor::native("/notes/Wanted/Open Notebook.onetoc2"), + SourceDescriptor::native("/notes/Last/Open Notebook.onetoc2"), ]; - let order = SourceDisplayOrder::new(presentation_order.clone()); + let order = SourceDisplayOrder::new( + presentation_order + .iter() + .map(|source| source.path().to_path_buf()), + ); let mut discovered = presentation_order.clone(); prioritize_discovered_sources(&mut discovered, Some(&target)); assert_eq!( discovered, vec![ - PathBuf::from("/notes/Wanted/Open Notebook.onetoc2"), - PathBuf::from("/notes/First/Open Notebook.onetoc2"), - PathBuf::from("/notes/Last/Open Notebook.onetoc2"), + SourceDescriptor::native("/notes/Wanted/Open Notebook.onetoc2"), + SourceDescriptor::native("/notes/First/Open Notebook.onetoc2"), + SourceDescriptor::native("/notes/Last/Open Notebook.onetoc2"), ] ); let mut visible = Vec::new(); for source in discovered { - let position = order.insertion_index(&source, visible.iter().map(PathBuf::as_path)); + let position = + order.insertion_index(source.path(), visible.iter().map(SourceDescriptor::path)); visible.insert(position, source); } assert_eq!(visible, presentation_order); - let mut roots = vec![PathBuf::from("/archive"), PathBuf::from("/notes")]; + let mut roots = vec![ + SourceDescriptor::native("/archive"), + SourceDescriptor::native("/notes"), + ]; prioritize_source_roots(&mut roots, Some(&target)); assert_eq!( roots, - vec![PathBuf::from("/notes"), PathBuf::from("/archive")] + vec![ + SourceDescriptor::native("/notes"), + SourceDescriptor::native("/archive") + ] + ); + } + + #[test] + fn persisted_backup_policy_overrides_default_library_discovery() { + let root = PathBuf::from("/notes/Backup"); + let mut discovered = vec![SourceDescriptor::backup( + &root, + BackupSelectionPolicy::LatestPerSection, + )]; + let configured = vec![SourceDescriptor::backup( + &root, + BackupSelectionPolicy::AllCopies, + )]; + + apply_source_overrides(&mut discovered, &configured); + + assert_eq!(discovered, configured); + } + + #[test] + fn default_location_keeps_only_backup_policy_overrides() { + let default = std::path::Path::new("/notes"); + let mut configured = Vec::new(); + register_configured_source( + &mut configured, + &SourceDescriptor::native("/notes/Native/Open Notebook.onetoc2"), + default, + None, + ); + register_configured_source( + &mut configured, + &SourceDescriptor::backup("/notes/Backup", BackupSelectionPolicy::AllCopies), + default, + None, + ); + + assert_eq!( + configured, + vec![SourceDescriptor::backup( + "/notes/Backup", + BackupSelectionPolicy::AllCopies + )] + ); + } + + #[test] + fn canonicalized_backup_path_replaces_the_original_workspace_entry() { + let mut configured = vec![SourceDescriptor::backup( + "/alias/Backup", + BackupSelectionPolicy::LatestPerSection, + )]; + register_configured_source( + &mut configured, + &SourceDescriptor::backup("/canonical/Backup", BackupSelectionPolicy::LatestPerSection), + std::path::Path::new("/notes"), + Some(std::path::Path::new("/alias/Backup")), + ); + + assert_eq!( + configured, + vec![SourceDescriptor::backup( + "/canonical/Backup", + BackupSelectionPolicy::LatestPerSection + )] ); } @@ -4941,6 +5678,18 @@ mod tests { assert!(activity.jobs.is_empty()); } + #[test] + fn backup_cancellation_carries_from_loading_into_indexing() { + let cancellation = BackupOperationCancellation::new(PathBuf::from("/backup")); + cancellation.cancel(); + let index = Arc::new(AtomicBool::new(false)); + + cancellation.set_index_cancel(index.clone()); + + assert!(index.load(Ordering::Acquire)); + assert!(cancellation.is_cancelled()); + } + #[test] fn workspace_search_scopes_resolve_to_stable_source_and_section_ids() { let nested = SectionId::new("nested"); diff --git a/crates/onenote-viewer/src/worker.rs b/crates/onenote-viewer/src/worker.rs index 51afa7e..bcad35b 100644 --- a/crates/onenote-viewer/src/worker.rs +++ b/crates/onenote-viewer/src/worker.rs @@ -1,6 +1,8 @@ use gtk::gio; use onenote_core::{ - ExtractionPhase, LoadOptions, LoadedNotebook, OneNoteLoader, OnePkgExtractor, SourceId, + BackupFolderLimits, BackupFolderLoader, BackupFolderOptions, BackupLoadControl, + BackupLoadProgress, ExtractionPhase, LoadOptions, LoadedNotebook, OneNoteLoader, + OnePkgExtractor, ParseLimits, RootManifestPolicy, SourceDescriptor, SourceId, }; use onenote_index::{IndexProfile, IndexUpdate, SearchHit, SearchIndex, SearchQuery}; use onenote_render::{PageScene, SceneBuilder, SceneOptions}; @@ -12,9 +14,12 @@ pub(crate) enum SourceCommand { Discover(PathBuf), DiscoverLibrary(PathBuf), Load { - path: PathBuf, + source: SourceDescriptor, options: LoadOptions, index_profile: IndexProfile, + operation_id: Option, + control: BackupLoadControl, + root_manifest: RootManifestPolicy, }, Shutdown, } @@ -38,16 +43,26 @@ pub(crate) enum IndexCommand { pub(crate) enum Event { Discovered { requested: PathBuf, - result: Result, String>, + result: Result, String>, }, LibraryDiscovered { location: PathBuf, - result: Result, String>, + result: Result, String>, }, Loaded { - path: PathBuf, + source: SourceDescriptor, index_profile: IndexProfile, - result: Result, String>, + operation_id: Option, + result: Result, + }, + BackupProgress { + operation_id: Option, + progress: BackupLoadProgress, + }, + BackupFallbackRequired { + operation_id: u64, + root: PathBuf, + manifest_error: String, }, Indexed { source_id: SourceId, @@ -83,6 +98,11 @@ pub(crate) enum Event { }, } +#[derive(Clone, Debug)] +pub(crate) struct SourceLoad { + pub(crate) loaded: Arc, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum AttachmentPurpose { Open, @@ -115,23 +135,25 @@ pub(crate) fn start_source_worker(events: mpsc::Sender) -> mpsc::Sender { - let result = OneNoteLoader::with_options(options) - .load(&path) - .map(Arc::new) - .map_err(|error| error.to_string()); - if events - .send(Event::Loaded { - path, - index_profile, - result, - }) - .is_err() - { - return; + if let Some(event) = load_source_event( + &source, + options, + index_profile, + operation_id, + &control, + root_manifest, + &events, + ) { + if events.send(event).is_err() { + return; + } } } SourceCommand::Shutdown => return, @@ -141,6 +163,92 @@ pub(crate) fn start_source_worker(events: mpsc::Sender) -> mpsc::Sender, + control: &BackupLoadControl, + root_manifest: RootManifestPolicy, + events: &mpsc::Sender, +) -> Option { + let mut published_source = source.clone(); + let result = match source { + SourceDescriptor::NativeFile { path } => OneNoteLoader::with_options(options) + .load(path) + .map(|notebook| SourceLoad { + loaded: Arc::new(notebook), + }) + .map_err(|error| error.to_string()), + SourceDescriptor::BackupFolder { root, selection } => { + let loader = BackupFolderLoader::with_options( + BackupFolderLimits::default(), + ParseLimits::default(), + options, + ); + let inspection = loader.inspect( + root, + BackupFolderOptions { + selection: *selection, + root_manifest, + }, + control, + |progress| { + let _ignored = events.send(Event::BackupProgress { + operation_id, + progress, + }); + }, + ); + match inspection { + Err(onenote_core::BackupFolderError::RootManifestPresent { path }) + if root_manifest == RootManifestPolicy::Reject => + { + match OneNoteLoader::with_options(options).load(&path) { + Ok(notebook) => { + published_source = SourceDescriptor::native(path); + Ok(SourceLoad { + loaded: Arc::new(notebook), + }) + } + Err(error) => { + let operation_id = operation_id?; + let _ignored = events.send(Event::BackupFallbackRequired { + operation_id, + root: root.clone(), + manifest_error: error.to_string(), + }); + return None; + } + } + } + Err(error) => Err(error.to_string()), + Ok(inspection) => { + published_source = + SourceDescriptor::backup(inspection.root.clone(), *selection); + loader + .load(inspection, control, |progress| { + let _ignored = events.send(Event::BackupProgress { + operation_id, + progress, + }); + }) + .map(|result| SourceLoad { + loaded: Arc::new(result.loaded), + }) + .map_err(|error| error.to_string()) + } + } + } + }; + Some(Event::Loaded { + source: published_source, + index_profile, + operation_id, + result, + }) +} + pub(crate) fn start_index_worker( index_path: PathBuf, events: mpsc::Sender, @@ -312,9 +420,12 @@ mod tests { let missing = temporary.path().join("missing.one"); source_commands .send(SourceCommand::Load { - path: missing.clone(), + source: SourceDescriptor::native(missing.clone()), options: LoadOptions::default(), index_profile: IndexProfile::new("test"), + operation_id: None, + control: BackupLoadControl::new(), + root_manifest: RootManifestPolicy::Ignore, }) .expect("queue source load"); let event = receiver @@ -323,10 +434,10 @@ mod tests { assert!(matches!( event, Event::Loaded { - path, + source, result: Err(_), .. - } if path == missing + } if source.path() == missing )); release_sender.send(()).expect("release index worker"); @@ -337,4 +448,43 @@ mod tests { .send(IndexCommand::Shutdown) .expect("stop index worker"); } + + #[test] + fn unreadable_root_manifest_requires_explicit_backup_fallback() { + let temporary = tempfile::tempdir().expect("temporary directory"); + std::fs::write(temporary.path().join("Open Notebook.onetoc2"), b"invalid") + .expect("manifest"); + std::fs::write(temporary.path().join("Section.one"), b"invalid").expect("section"); + let (events, receiver) = mpsc::channel(); + let commands = start_source_worker(events); + commands + .send(SourceCommand::Load { + source: SourceDescriptor::backup( + temporary.path(), + onenote_core::BackupSelectionPolicy::LatestPerSection, + ), + options: LoadOptions::default(), + index_profile: IndexProfile::new("test"), + operation_id: Some(7), + control: BackupLoadControl::new(), + root_manifest: RootManifestPolicy::Reject, + }) + .expect("queue source load"); + + let fallback = (0..10).find_map(|_| { + receiver + .recv_timeout(Duration::from_secs(1)) + .ok() + .and_then(|event| match event { + Event::BackupFallbackRequired { + operation_id, root, .. + } => Some((operation_id, root)), + _ => None, + }) + }); + assert_eq!(fallback, Some((7, temporary.path().to_path_buf()))); + commands + .send(SourceCommand::Shutdown) + .expect("stop source worker"); + } } diff --git a/crates/onenote-viewer/src/workspace.rs b/crates/onenote-viewer/src/workspace.rs index 1a76ad0..729b6e9 100644 --- a/crates/onenote-viewer/src/workspace.rs +++ b/crates/onenote-viewer/src/workspace.rs @@ -1,5 +1,5 @@ use anyhow::{bail, Context, Result}; -use onenote_core::{PageId, SectionId, SourceId}; +use onenote_core::{BackupSelectionPolicy, PageId, SectionId, SourceDescriptor, SourceId}; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeSet; use std::fs; @@ -10,8 +10,8 @@ pub(crate) const WORKSPACE_UI_VERSION: u32 = 1; #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub(crate) struct WorkspaceConfig { - #[serde(default)] - pub(crate) sources: Vec, + #[serde(default, deserialize_with = "deserialize_sources")] + pub(crate) sources: Vec, #[serde(default)] pub(crate) navigation: WorkspaceNavigation, #[serde( @@ -140,32 +140,91 @@ pub(crate) fn ensure_index_parent(path: &Path) -> Result<()> { set_private_directory(parent) } -pub(crate) fn discover(requested: &Path) -> Result> { - discover_path(requested, false) +pub(crate) fn discover(requested: &Path) -> Result> { + let canonical = canonical_source(requested)?; + if canonical.is_file() { + return discover_file(canonical); + } + let (manifests, sections) = discover_directory_contents(&canonical)?; + let roots = root_manifests(&canonical, manifests); + if !roots.is_empty() { + return Ok(roots.into_iter().map(SourceDescriptor::native).collect()); + } + if sections.is_empty() { + bail!("{} contains no .onetoc2 or .one files", canonical.display()); + } + bail!( + "{} has OneNote sections but no root .onetoc2; use Open OneNote Backup Folder", + canonical.display() + ) } -pub(crate) fn discover_library(requested: &Path) -> Result> { - discover_path(requested, true) +pub(crate) fn discover_library(requested: &Path) -> Result> { + let canonical = canonical_source(requested)?; + if canonical.is_file() { + return discover_file(canonical); + } + let mut sources = Vec::new(); + let read = fs::read_dir(&canonical) + .with_context(|| format!("could not read {}", canonical.display()))?; + for entry in read { + let entry = entry.with_context(|| format!("could not read {}", canonical.display()))?; + let path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("could not inspect {}", path.display()))?; + if file_type.is_symlink() { + continue; + } + if file_type.is_file() { + if matches!(extension(&path).as_deref(), Some("one" | "onetoc2")) { + sources.push(SourceDescriptor::native(path)); + } + continue; + } + if !file_type.is_dir() { + continue; + } + let (manifests, sections) = discover_directory_contents(&path)?; + let roots = root_manifests(&path, manifests); + if roots.is_empty() { + if !sections.is_empty() { + sources.push(SourceDescriptor::backup( + path, + BackupSelectionPolicy::LatestPerSection, + )); + } + } else { + sources.extend(roots.into_iter().map(SourceDescriptor::native)); + } + } + sources.sort_by(|left, right| left.path().cmp(right.path())); + sources.dedup_by(|left, right| left == right); + Ok(sources) } -fn discover_path(requested: &Path, allow_empty: bool) -> Result> { +fn canonical_source(requested: &Path) -> Result { let canonical = fs::canonicalize(requested) .with_context(|| format!("could not access {}", requested.display()))?; - if canonical.is_file() { - return match extension(&canonical).as_deref() { - Some("one" | "onetoc2") => Ok(vec![canonical]), - Some("onepkg") => bail!( - "{} is a package; use Import OneNote Package so it is extracted on disk", - canonical.display() - ), - _ => bail!("{} is not a supported OneNote source", canonical.display()), - }; - } - if !canonical.is_dir() { + if !canonical.is_file() && !canonical.is_dir() { bail!("{} is not a regular file or directory", canonical.display()); } + Ok(canonical) +} + +fn discover_file(canonical: PathBuf) -> Result> { + match extension(&canonical).as_deref() { + Some("one" | "onetoc2") => Ok(vec![SourceDescriptor::native(canonical)]), + Some("onepkg") => bail!( + "{} is a package; use Import OneNote Package so it is extracted on disk", + canonical.display() + ), + _ => bail!("{} is not a supported OneNote source", canonical.display()), + } +} - let mut pending = vec![canonical.clone()]; +fn discover_directory_contents(canonical: &Path) -> Result<(Vec, Vec)> { + let mut pending = vec![canonical.to_path_buf()]; let mut table_of_contents = Vec::new(); let mut sections = Vec::new(); let mut entries = 0_usize; @@ -198,16 +257,9 @@ fn discover_path(requested: &Path, allow_empty: bool) -> Result> { } table_of_contents.sort(); - let roots = root_manifests(&canonical, table_of_contents); - if !roots.is_empty() { - return Ok(roots); - } sections.sort(); sections.dedup(); - if sections.is_empty() && !allow_empty { - bail!("{} contains no .onetoc2 or .one files", canonical.display()); - } - Ok(sections) + Ok((table_of_contents, sections)) } pub(crate) fn source_is_in_location(source: &Path, location: &Path) -> bool { @@ -219,13 +271,34 @@ pub(crate) fn source_is_in_location(source: &Path, location: &Path) -> bool { pub(crate) fn source_is_in_workspace( source: &Path, - sources: &[PathBuf], + sources: &[SourceDescriptor], notebooks_location: &Path, ) -> bool { source_is_in_location(source, notebooks_location) || sources .iter() - .any(|configured| source_is_in_location(source, configured)) + .any(|configured| source_is_in_location(source, configured.path())) +} + +fn deserialize_sources<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum PersistedSource { + Legacy(PathBuf), + Descriptor(SourceDescriptor), + } + + let sources = Vec::::deserialize(deserializer)?; + Ok(sources + .into_iter() + .map(|source| match source { + PersistedSource::Legacy(path) => SourceDescriptor::native(path), + PersistedSource::Descriptor(source) => source, + }) + .collect()) } fn root_manifests(root: &Path, manifests: Vec) -> Vec { @@ -301,7 +374,12 @@ mod tests { let discovered = discover(temporary.path()).expect("discovery"); - assert_eq!(discovered, vec![notebook.join("Open Notebook.onetoc2")]); + assert_eq!( + discovered, + vec![SourceDescriptor::native( + notebook.join("Open Notebook.onetoc2") + )] + ); } #[test] @@ -309,7 +387,7 @@ mod tests { let temporary = tempfile::tempdir().expect("temporary directory"); let path = temporary.path().join("state/workspace.json"); let expected = WorkspaceConfig { - sources: vec![PathBuf::from("/notes/Notebook.onetoc2")], + sources: vec![SourceDescriptor::native("/notes/Notebook.onetoc2")], navigation: WorkspaceNavigation { last_page: Some(PersistedPageLocation { source_path: PathBuf::from("/notes/Notebook.onetoc2"), @@ -349,7 +427,7 @@ mod tests { assert_eq!( actual.sources, - vec![PathBuf::from("/notes/Notebook.onetoc2")] + vec![SourceDescriptor::native("/notes/Notebook.onetoc2")] ); assert_eq!(actual.navigation, WorkspaceNavigation::default()); assert_eq!(actual.ui, None); @@ -481,12 +559,42 @@ mod tests { assert_eq!( discovered, vec![ - temporary.path().join("Notebook A/Open Notebook.onetoc2"), - temporary.path().join("Notebook B/Open Notebook.onetoc2"), + SourceDescriptor::native(temporary.path().join("Notebook A/Open Notebook.onetoc2")), + SourceDescriptor::native(temporary.path().join("Notebook B/Open Notebook.onetoc2")), ] ); } + #[test] + fn library_groups_a_manifest_free_backup_directory_as_one_source() { + let temporary = tempfile::tempdir().expect("temporary directory"); + let backup = temporary.path().join("Backup"); + fs::create_dir_all(backup.join("Nested")).expect("nested group"); + fs::write(backup.join("Root (On 15-08-2026).one"), b"root").expect("root section"); + fs::write(backup.join("Nested/Section (On 15-08-2026).one"), b"nested") + .expect("nested section"); + + let discovered = discover_library(temporary.path()).expect("library discovery"); + + assert_eq!( + discovered, + vec![SourceDescriptor::backup( + backup, + BackupSelectionPolicy::LatestPerSection + )] + ); + } + + #[test] + fn ordinary_folder_open_requires_explicit_backup_mode_without_manifest() { + let temporary = tempfile::tempdir().expect("temporary directory"); + fs::write(temporary.path().join("Section.one"), b"section").expect("section"); + + let error = discover(temporary.path()).expect_err("backup mode must be explicit"); + + assert!(error.to_string().contains("Open OneNote Backup Folder")); + } + #[test] fn source_membership_uses_path_components() { let root = Path::new("/home/user/Documents/OneNoteViewer"); @@ -504,7 +612,9 @@ mod tests { #[test] fn workspace_membership_accepts_default_and_explicit_sources() { let default = Path::new("/home/user/Documents/OneNoteViewer"); - let explicit = vec![PathBuf::from("/mnt/archive/Notebook/Open Notebook.onetoc2")]; + let explicit = vec![SourceDescriptor::native( + "/mnt/archive/Notebook/Open Notebook.onetoc2", + )]; assert!(source_is_in_workspace( Path::new("/home/user/Documents/OneNoteViewer/Work/Open Notebook.onetoc2"), diff --git a/docs/MASTER-PLAN.md b/docs/MASTER-PLAN.md index 62041d6..dfd3bf7 100644 --- a/docs/MASTER-PLAN.md +++ b/docs/MASTER-PLAN.md @@ -68,8 +68,8 @@ The normative behavioral detail is in `onenote-core` provides read-only source discovery, parser isolation, immutable domain objects, geometry, diagnostics, lazy payload access, and stable source-scoped identities. Upstream parser and revision-store internals -do not cross its public boundary. Manifest-free OneNote backup directories -must be reconstructed as one synthetic notebook through the reusable +do not cross its public boundary. Manifest-free OneNote backup directories are +reconstructed as one synthetic notebook through the reusable [backup-folder loader](plans/backup-folder-loader.md), not interpreted by the viewer as unrelated standalone sections. diff --git a/docs/plans/backup-folder-loader.md b/docs/plans/backup-folder-loader.md index c2b9753..39e0528 100644 --- a/docs/plans/backup-folder-loader.md +++ b/docs/plans/backup-folder-loader.md @@ -1,14 +1,14 @@ # Reusable OneNote Backup-Folder Loader Plan -- **Status:** Planned +- **Status:** Implemented for the issue #39 baseline; later extensions remain tracked separately - **Owner:** `onenote-core`, integrated by `onenote-viewer` - **Target milestone:** Milestone 2 for default loading; Milestone 3 for historical-version browsing -- **Last reconciled:** 2026-07-27 UTC +- **Last reconciled:** 2026-08-17 UTC ## Purpose -Implement a reusable, read-only loader for OneNote backup directories that +Provide a reusable, read-only loader for OneNote backup directories that contain recursively arranged `.one` section files but no usable root `.onetoc2` table of contents. @@ -18,18 +18,38 @@ deduplicated across dated backup snapshots, and the selected sections must be available through the same public domain, rendering, and indexing interfaces as a manifest-backed notebook. +## Implemented Baseline + +The issue #39 implementation provides: + +- bounded, symlink-safe inspection through public `onenote-core` types; +- anchored recognition of the two observed dated-filename profiles; +- deterministic latest-per-section and explicit all-copies policies; +- one stable aggregate source with nested directory groups, native page order, + source-scoped identities, lazy resource loaders, and structured diagnostics; +- typed workspace persistence, legacy path-only workspace migration, manual + refresh, phase progress, and cancellation; +- staged viewer publication after transactional index replacement, preserving + the last known-good visible generation when loading or indexing fails; and +- root-manifest precedence with an explicit, copyable fallback confirmation + when a present manifest cannot be parsed. + +As-of/exact-date views, changed-section parse reuse, automatic monitoring, +viewer-local ordering overlays, and a general stabilized source-classification +facade remain future work. They are extension points rather than part of the +implemented baseline. + The loader belongs in `onenote-core`. It must not depend on GTK, the viewer workspace, SQLite, or the search index. OneNote Viewer is one consumer of the loader, not its only usable host. ## Problem Statement -The current application discovers every `.one` below a directory when it -cannot find a root `.onetoc2`. It then loads each file through the standalone -section path. Because a standalone section is projected as a notebook -containing one section, a OneNote backup directory appears as dozens of -top-level notebooks. Directory-based section groups and repeated backup -versions are lost. +Before this loader, the application discovered every `.one` below a directory +when it could not find a root `.onetoc2`. It then loaded each file through the +standalone section path, so one backup appeared as dozens of one-section +notebooks. Directory-based section groups and repeated backup versions were +lost. This behavior is correct for an explicitly opened standalone `.one` file but is not an adequate interpretation of a backup directory. diff --git a/docs/specs/public-api.md b/docs/specs/public-api.md index 2ec58d9..b47b349 100644 --- a/docs/specs/public-api.md +++ b/docs/specs/public-api.md @@ -34,12 +34,14 @@ provide HTML, Markdown, or PDF conversion. `onenote-core` is the shared foundation. `OneNoteLoader` exposes read-only operations for a `.onetoc2` or standalone `.one`, returning immutable domain -objects plus diagnostics and a lazy `ResourceStore`. Directory root discovery -currently belongs to the viewer and should move behind a reusable core API. In -particular, manifest-free backup folders require a core-owned inspection and -aggregate-loading API that returns one source, a reconstructed section-group -tree, deterministic snapshot selection, and provenance. That API is planned, -not implemented; its contract and delivery gates are in the +objects plus diagnostics and a lazy `ResourceStore`. `BackupFolderLoader` +provides bounded, cancellable inspection and aggregate loading for +manifest-free desktop backup folders. It returns a reconstructed section-group +tree, deterministic snapshot selection and provenance through the same +`LoadedNotebook` model, without depending on GTK or SQLite. Persistable +`SourceDescriptor` values distinguish native files from backup roots and retain +the selected latest/all-copies policy. The compatibility contract and future +extension points are documented in the [backup-folder loader plan](../plans/backup-folder-loader.md). `OnePkgExtractor` is a separate optional operation and is never required to consume an already extracted source. @@ -56,6 +58,10 @@ API version 6. API version 7 adds optional lazy resource handles for OneNote's browser-compatible image representation and embedded-file icon; primary payload handles remain unchanged. +Core API version 8 adds the reusable backup-folder inspection, snapshot +selection, aggregate loading, progress/cancellation, and typed source +descriptor contracts described above. + The public model includes: - source identity and fingerprint; From 81a133a9b3136468b3f84d035ca2588026c68e6d Mon Sep 17 00:00:00 2001 From: Mariusz Woloszyn Date: Mon, 17 Aug 2026 18:48:33 +0000 Subject: [PATCH 2/2] fix(viewer): release operation state before UI refresh --- crates/onenote-viewer/src/app.rs | 39 +++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/onenote-viewer/src/app.rs b/crates/onenote-viewer/src/app.rs index c810348..7d8a3aa 100644 --- a/crates/onenote-viewer/src/app.rs +++ b/crates/onenote-viewer/src/app.rs @@ -432,6 +432,21 @@ impl ForegroundOperation { } } +fn clear_foreground_operation( + operation: &RefCell>, + operation_id: u64, +) -> bool { + let mut operation = operation.borrow_mut(); + if operation + .as_ref() + .is_none_or(|operation| operation.id != operation_id) + { + return false; + } + operation.take(); + true +} + #[derive(Default)] struct State { sources: Vec, @@ -4055,14 +4070,9 @@ impl Viewer { } fn finish_operation(&self, operation_id: u64) { - let mut operation = self.foreground_operation.borrow_mut(); - if operation - .as_ref() - .is_none_or(|operation| operation.id != operation_id) - { + if !clear_foreground_operation(&self.foreground_operation, operation_id) { return; } - operation.take(); self.operation_pulsing.set(false); self.operation_cancel_button.set_sensitive(false); self.import_package_action.set_enabled(true); @@ -5690,6 +5700,23 @@ mod tests { assert!(cancellation.is_cancelled()); } + #[test] + fn foreground_operation_completion_releases_the_state_before_ui_refresh() { + let operation = RefCell::new(Some(ForegroundOperation { + id: 7, + kind: ForegroundOperationKind::PackageImport(Arc::new(AtomicBool::new(false))), + })); + + assert!(!clear_foreground_operation(&operation, 6)); + assert_eq!( + operation.borrow().as_ref().map(|operation| operation.id), + Some(7) + ); + + assert!(clear_foreground_operation(&operation, 7)); + assert!(operation.borrow().is_none()); + } + #[test] fn workspace_search_scopes_resolve_to_stable_source_and_section_ids() { let nested = SectionId::new("nested");