fix(storage): kein stilles Überschreiben bei Ladefehlern und Quarantäne defekter Seiten
This commit is contained in:
@@ -139,6 +139,9 @@ impl AutosaveController {
|
|||||||
|
|
||||||
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
|
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
|
||||||
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
|
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
|
||||||
|
if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let page_data = PageData::from(page);
|
let page_data = PageData::from(page);
|
||||||
self.pending_pages.insert(page.id, page_data);
|
self.pending_pages.insert(page.id, page_data);
|
||||||
self.pending_manifest = Some(manifest.clone());
|
self.pending_manifest = Some(manifest.clone());
|
||||||
|
|||||||
@@ -304,6 +304,22 @@ pub enum PageBackground {
|
|||||||
DotGrid,
|
DotGrid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persistenzstatus einer Seite zum Schutz vor unbeabsichtigtem Überschreiben bei Ladefehlern.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum PersistState {
|
||||||
|
#[default]
|
||||||
|
Normal,
|
||||||
|
ReadOnlyAfterLoadError,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warnung beim Laden einer Seite (beschädigte Datei oder I/O-Fehler).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PageLoadWarning {
|
||||||
|
pub page_id: u64,
|
||||||
|
pub message: String,
|
||||||
|
pub quarantine_path: Option<std::path::PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes.
|
/// Eine einzelne Notizbuchseite mit Strichen, Hintergrund und gecachten Meshes.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Page {
|
pub struct Page {
|
||||||
@@ -314,6 +330,7 @@ pub struct Page {
|
|||||||
pub stroke_ranges: Vec<Range<u32>>,
|
pub stroke_ranges: Vec<Range<u32>>,
|
||||||
pub is_dirty: bool,
|
pub is_dirty: bool,
|
||||||
pub is_loaded: bool,
|
pub is_loaded: bool,
|
||||||
|
pub persist: PersistState,
|
||||||
pub history: HistoryStack,
|
pub history: HistoryStack,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +344,7 @@ impl Page {
|
|||||||
stroke_ranges: Vec::new(),
|
stroke_ranges: Vec::new(),
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
is_loaded: true,
|
is_loaded: true,
|
||||||
|
persist: PersistState::Normal,
|
||||||
history: HistoryStack::new(),
|
history: HistoryStack::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,10 +359,19 @@ impl Page {
|
|||||||
stroke_ranges: Vec::new(),
|
stroke_ranges: Vec::new(),
|
||||||
is_dirty: false,
|
is_dirty: false,
|
||||||
is_loaded: false,
|
is_loaded: false,
|
||||||
|
persist: PersistState::Normal,
|
||||||
history: HistoryStack::new(),
|
history: HistoryStack::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_readonly(&self) -> bool {
|
||||||
|
self.persist == PersistState::ReadOnlyAfterLoadError
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unlock_after_load_error(&mut self) {
|
||||||
|
self.persist = PersistState::Normal;
|
||||||
|
}
|
||||||
|
|
||||||
/// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack.
|
/// Fügt einen neuen Strich hinzu und registriert ihn im History-Stack.
|
||||||
pub fn add_stroke(&mut self, stroke: Stroke) {
|
pub fn add_stroke(&mut self, stroke: Stroke) {
|
||||||
self.history.push(HistoryAction::AddStroke(stroke.clone()));
|
self.history.push(HistoryAction::AddStroke(stroke.clone()));
|
||||||
@@ -587,6 +614,7 @@ pub struct Notebook {
|
|||||||
pub current_page_index: usize,
|
pub current_page_index: usize,
|
||||||
pub next_stroke_id: u64,
|
pub next_stroke_id: u64,
|
||||||
pub next_page_id: u64,
|
pub next_page_id: u64,
|
||||||
|
pub load_warnings: std::collections::HashMap<u64, PageLoadWarning>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Notebook {
|
impl Notebook {
|
||||||
@@ -596,6 +624,7 @@ impl Notebook {
|
|||||||
current_page_index: 0,
|
current_page_index: 0,
|
||||||
next_stroke_id: 1,
|
next_stroke_id: 1,
|
||||||
next_page_id: 1,
|
next_page_id: 1,
|
||||||
|
load_warnings: std::collections::HashMap::new(),
|
||||||
};
|
};
|
||||||
// Erste Standardseite anlegen (5mm Grid)
|
// Erste Standardseite anlegen (5mm Grid)
|
||||||
nb.add_page(PageBackground::Grid5mm);
|
nb.add_page(PageBackground::Grid5mm);
|
||||||
@@ -614,6 +643,7 @@ impl Notebook {
|
|||||||
current_page_index,
|
current_page_index,
|
||||||
next_stroke_id,
|
next_stroke_id,
|
||||||
next_page_id,
|
next_page_id,
|
||||||
|
load_warnings: std::collections::HashMap::new(),
|
||||||
};
|
};
|
||||||
if nb.pages.is_empty() {
|
if nb.pages.is_empty() {
|
||||||
nb.add_page(PageBackground::Grid5mm);
|
nb.add_page(PageBackground::Grid5mm);
|
||||||
|
|||||||
@@ -2106,6 +2106,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
ui_state.set_export_message("Server-Einstellungen gespeichert");
|
ui_state.set_export_message("Server-Einstellungen gespeichert");
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
UiAction::UnlockCorruptedPage(page_id) => {
|
||||||
|
notebook.load_warnings.remove(&page_id);
|
||||||
|
if let Some(page) = notebook.pages.iter_mut().find(|p| p.id == page_id) {
|
||||||
|
page.unlock_after_load_error();
|
||||||
|
}
|
||||||
|
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+333
-20
@@ -10,7 +10,9 @@ use std::path::{Path, PathBuf};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::canvas::InkVertex;
|
use crate::canvas::InkVertex;
|
||||||
use crate::document::{Notebook, Page, PageBackground, Stroke, StylusSample};
|
use crate::document::{
|
||||||
|
Notebook, Page, PageBackground, PageLoadWarning, PersistState, Stroke, StylusSample,
|
||||||
|
};
|
||||||
use crate::filter::OneEuroFilter;
|
use crate::filter::OneEuroFilter;
|
||||||
use crate::spline::{self, ControlPoint, SplineConfig};
|
use crate::spline::{self, ControlPoint, SplineConfig};
|
||||||
|
|
||||||
@@ -119,6 +121,20 @@ impl PageData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Metadaten einer einzelnen Seite im Manifest.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct PageMeta {
|
||||||
|
pub id: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub background: Option<PageBackground>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageMeta {
|
||||||
|
pub fn new(id: u64, background: Option<PageBackground>) -> Self {
|
||||||
|
Self { id, background }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Metadaten des gesamten Notizbuchs (`manifest.json`).
|
/// Metadaten des gesamten Notizbuchs (`manifest.json`).
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct NotebookManifest {
|
pub struct NotebookManifest {
|
||||||
@@ -127,6 +143,9 @@ pub struct NotebookManifest {
|
|||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
pub modified_at: u64,
|
pub modified_at: u64,
|
||||||
pub current_page_index: usize,
|
pub current_page_index: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pages: Vec<PageMeta>,
|
||||||
|
#[serde(default)]
|
||||||
pub page_ids: Vec<u64>,
|
pub page_ids: Vec<u64>,
|
||||||
pub next_stroke_id: u64,
|
pub next_stroke_id: u64,
|
||||||
pub next_page_id: u64,
|
pub next_page_id: u64,
|
||||||
@@ -144,11 +163,46 @@ impl NotebookManifest {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
modified_at: now,
|
modified_at: now,
|
||||||
current_page_index: 0,
|
current_page_index: 0,
|
||||||
|
pages: vec![PageMeta::new(1, Some(PageBackground::Grid5mm))],
|
||||||
page_ids: vec![1],
|
page_ids: vec![1],
|
||||||
next_stroke_id: 1,
|
next_stroke_id: 1,
|
||||||
next_page_id: 2,
|
next_page_id: 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn page_ids(&self) -> Vec<u64> {
|
||||||
|
if !self.pages.is_empty() {
|
||||||
|
self.pages.iter().map(|p| p.id).collect()
|
||||||
|
} else {
|
||||||
|
self.page_ids.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn background_for_page(&self, page_id: u64) -> Option<PageBackground> {
|
||||||
|
self.pages
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.id == page_id)
|
||||||
|
.and_then(|p| p.background)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sync_from_notebook(&mut self, notebook: &Notebook) {
|
||||||
|
self.pages = notebook
|
||||||
|
.pages
|
||||||
|
.iter()
|
||||||
|
.map(|p| PageMeta {
|
||||||
|
id: p.id,
|
||||||
|
background: Some(p.background),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
self.page_ids = self.pages.iter().map(|p| p.id).collect();
|
||||||
|
self.current_page_index = notebook.current_page_index;
|
||||||
|
self.next_stroke_id = notebook.next_stroke_id;
|
||||||
|
self.next_page_id = notebook.next_page_id;
|
||||||
|
self.modified_at = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mögliche Fehler bei Speicher- und Ladeoperationen.
|
/// Mögliche Fehler bei Speicher- und Ladeoperationen.
|
||||||
@@ -306,10 +360,43 @@ pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageErr
|
|||||||
let mut file = File::open(&path)?;
|
let mut file = File::open(&path)?;
|
||||||
let mut json_str = String::new();
|
let mut json_str = String::new();
|
||||||
file.read_to_string(&mut json_str)?;
|
file.read_to_string(&mut json_str)?;
|
||||||
let manifest: NotebookManifest = serde_json::from_str(&json_str)?;
|
let mut manifest: NotebookManifest = serde_json::from_str(&json_str)?;
|
||||||
|
if manifest.pages.is_empty() && !manifest.page_ids.is_empty() {
|
||||||
|
manifest.pages = manifest
|
||||||
|
.page_ids
|
||||||
|
.iter()
|
||||||
|
.map(|&id| PageMeta::new(id, None))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
Ok(manifest)
|
Ok(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verschiebt eine defekte Seitendatei nach `page_<id>.json.corrupt-<unix_ts>`.
|
||||||
|
/// Nur bei Parse-/Schemafehlern aufrufen, nicht bei I/O-Fehlern!
|
||||||
|
pub fn quarantine_corrupted_page(notebook_dir: &Path, page_id: u64) -> Result<PathBuf, io::Error> {
|
||||||
|
let source_path = notebook_dir.join(format!("page_{page_id}.json"));
|
||||||
|
if !source_path.exists() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
"Datei existiert nicht",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let mut target_path = notebook_dir.join(format!("page_{page_id}.json.corrupt-{ts}"));
|
||||||
|
if target_path.exists() {
|
||||||
|
let nanos = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.subsec_nanos();
|
||||||
|
target_path = notebook_dir.join(format!("page_{page_id}.json.corrupt-{ts}-{nanos}"));
|
||||||
|
}
|
||||||
|
fs::rename(&source_path, &target_path)?;
|
||||||
|
Ok(target_path)
|
||||||
|
}
|
||||||
|
|
||||||
/// Speichert eine Seite atomar in `page_<id>.json`.
|
/// Speichert eine Seite atomar in `page_<id>.json`.
|
||||||
pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> {
|
pub fn save_page(notebook_dir: &Path, page: &PageData) -> Result<(), StorageError> {
|
||||||
let path = notebook_dir.join(format!("page_{}.json", page.id));
|
let path = notebook_dir.join(format!("page_{}.json", page.id));
|
||||||
@@ -347,18 +434,11 @@ pub fn save_notebook(
|
|||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
fs::create_dir_all(notebook_dir)?;
|
fs::create_dir_all(notebook_dir)?;
|
||||||
|
|
||||||
manifest.page_ids = notebook.pages.iter().map(|p| p.id).collect();
|
manifest.sync_from_notebook(notebook);
|
||||||
manifest.current_page_index = notebook.current_page_index;
|
|
||||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
|
||||||
manifest.next_page_id = notebook.next_page_id;
|
|
||||||
manifest.modified_at = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
|
|
||||||
// Alle bereits geladenen Seiten speichern
|
// Alle bereits geladenen Seiten speichern, schreibgeschützte Seiten nach Ladefehler überspringen!
|
||||||
for page in ¬ebook.pages {
|
for page in ¬ebook.pages {
|
||||||
if page.is_loaded {
|
if page.is_loaded && page.persist == PersistState::Normal {
|
||||||
let page_data = PageData::from(page);
|
let page_data = PageData::from(page);
|
||||||
save_page(notebook_dir, &page_data)?;
|
save_page(notebook_dir, &page_data)?;
|
||||||
}
|
}
|
||||||
@@ -371,35 +451,82 @@ pub fn save_notebook(
|
|||||||
/// Lädt ein Notizbuch aus einem Ordner (erste Seite sofort, Folgeseiten als Platzhalter für Lazy Loading).
|
/// Lädt ein Notizbuch aus einem Ordner (erste Seite sofort, Folgeseiten als Platzhalter für Lazy Loading).
|
||||||
pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest), StorageError> {
|
pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest), StorageError> {
|
||||||
let manifest = load_manifest(notebook_dir)?;
|
let manifest = load_manifest(notebook_dir)?;
|
||||||
if manifest.page_ids.is_empty() {
|
let page_ids = manifest.page_ids();
|
||||||
|
if page_ids.is_empty() {
|
||||||
let nb = Notebook::new();
|
let nb = Notebook::new();
|
||||||
return Ok((nb, manifest));
|
return Ok((nb, manifest));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut pages = Vec::with_capacity(manifest.page_ids.len());
|
let mut pages = Vec::with_capacity(page_ids.len());
|
||||||
|
let mut load_warnings = std::collections::HashMap::new();
|
||||||
|
|
||||||
// Erste Seite sofort laden und initialisieren
|
// Erste Seite sofort laden und initialisieren
|
||||||
let first_id = manifest.page_ids[0];
|
let first_id = page_ids[0];
|
||||||
|
let first_bg = manifest
|
||||||
|
.background_for_page(first_id)
|
||||||
|
.unwrap_or(PageBackground::Grid5mm);
|
||||||
|
|
||||||
let first_page = match load_page(notebook_dir, first_id) {
|
let first_page = match load_page(notebook_dir, first_id) {
|
||||||
Ok(data) => data.to_page(),
|
Ok(data) => data.to_page(),
|
||||||
|
Err(StorageError::CorruptedPage(id, err_msg)) => {
|
||||||
|
let q_res = quarantine_corrupted_page(notebook_dir, id);
|
||||||
|
let q_path = q_res.ok();
|
||||||
|
eprintln!(
|
||||||
|
"[Storage] Erste Seite {id} ist beschädigt ({err_msg}). Quarantänisiert nach: {:?}",
|
||||||
|
q_path
|
||||||
|
);
|
||||||
|
load_warnings.insert(
|
||||||
|
id,
|
||||||
|
PageLoadWarning {
|
||||||
|
page_id: id,
|
||||||
|
message: err_msg,
|
||||||
|
quarantine_path: q_path,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut page = Page::new(id, first_bg);
|
||||||
|
page.persist = PersistState::ReadOnlyAfterLoadError;
|
||||||
|
page
|
||||||
|
}
|
||||||
|
Err(StorageError::Io(e)) => {
|
||||||
|
eprintln!(
|
||||||
|
"[Storage] I/O-Fehler beim Laden von Seite {first_id}: {e}. Seite wird schreibgeschützt."
|
||||||
|
);
|
||||||
|
load_warnings.insert(
|
||||||
|
first_id,
|
||||||
|
PageLoadWarning {
|
||||||
|
page_id: first_id,
|
||||||
|
message: format!("I/O-Fehler: {e}"),
|
||||||
|
quarantine_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut page = Page::new(first_id, first_bg);
|
||||||
|
page.persist = PersistState::ReadOnlyAfterLoadError;
|
||||||
|
page
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
|
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
|
||||||
Page::new(first_id, PageBackground::Grid5mm)
|
let mut page = Page::new(first_id, first_bg);
|
||||||
|
page.persist = PersistState::ReadOnlyAfterLoadError;
|
||||||
|
page
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
pages.push(first_page);
|
pages.push(first_page);
|
||||||
|
|
||||||
// Folgeseiten als ungeladene Platzhalter anlegen (Lazy Loading)
|
// Folgeseiten als ungeladene Platzhalter anlegen (Lazy Loading)
|
||||||
for &page_id in &manifest.page_ids[1..] {
|
for &page_id in &page_ids[1..] {
|
||||||
pages.push(Page::new_unloaded(page_id, PageBackground::Grid5mm));
|
let bg = manifest
|
||||||
|
.background_for_page(page_id)
|
||||||
|
.unwrap_or(PageBackground::Grid5mm);
|
||||||
|
pages.push(Page::new_unloaded(page_id, bg));
|
||||||
}
|
}
|
||||||
|
|
||||||
let notebook = Notebook::from_pages(
|
let mut notebook = Notebook::from_pages(
|
||||||
pages,
|
pages,
|
||||||
manifest.current_page_index,
|
manifest.current_page_index,
|
||||||
manifest.next_stroke_id,
|
manifest.next_stroke_id,
|
||||||
manifest.next_page_id,
|
manifest.next_page_id,
|
||||||
);
|
);
|
||||||
|
notebook.load_warnings = load_warnings;
|
||||||
|
|
||||||
Ok((notebook, manifest))
|
Ok((notebook, manifest))
|
||||||
}
|
}
|
||||||
@@ -424,10 +551,46 @@ pub fn ensure_page_loaded(
|
|||||||
notebook.pages[page_idx] = data.to_page();
|
notebook.pages[page_idx] = data.to_page();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Err(StorageError::CorruptedPage(id, err_msg)) => {
|
||||||
|
let q_res = quarantine_corrupted_page(notebook_dir, id);
|
||||||
|
let q_path = q_res.ok();
|
||||||
|
eprintln!(
|
||||||
|
"[Storage] Lazy Loading von Seite {id} fehlgeschlagen ({err_msg}). Quarantäne: {:?}",
|
||||||
|
q_path
|
||||||
|
);
|
||||||
|
notebook.load_warnings.insert(
|
||||||
|
id,
|
||||||
|
PageLoadWarning {
|
||||||
|
page_id: id,
|
||||||
|
message: err_msg.clone(),
|
||||||
|
quarantine_path: q_path,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
notebook.pages[page_idx].is_loaded = true;
|
||||||
|
notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError;
|
||||||
|
notebook.pages[page_idx].rebuild_cache_if_dirty();
|
||||||
|
Err(StorageError::CorruptedPage(id, err_msg))
|
||||||
|
}
|
||||||
|
Err(StorageError::Io(e)) => {
|
||||||
|
eprintln!("[Storage] I/O-Fehler beim Lazy Loading von Seite {page_id}: {e}");
|
||||||
|
let err_str = e.to_string();
|
||||||
|
notebook.load_warnings.insert(
|
||||||
|
page_id,
|
||||||
|
PageLoadWarning {
|
||||||
|
page_id,
|
||||||
|
message: format!("I/O-Fehler: {err_str}"),
|
||||||
|
quarantine_path: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
notebook.pages[page_idx].is_loaded = true;
|
||||||
|
notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError;
|
||||||
|
notebook.pages[page_idx].rebuild_cache_if_dirty();
|
||||||
|
Err(StorageError::Io(e))
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}");
|
eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}");
|
||||||
// Ersatzseite als geladen markieren, damit die App nicht hängt
|
|
||||||
notebook.pages[page_idx].is_loaded = true;
|
notebook.pages[page_idx].is_loaded = true;
|
||||||
|
notebook.pages[page_idx].persist = PersistState::ReadOnlyAfterLoadError;
|
||||||
notebook.pages[page_idx].rebuild_cache_if_dirty();
|
notebook.pages[page_idx].rebuild_cache_if_dirty();
|
||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
@@ -773,4 +936,154 @@ mod tests {
|
|||||||
assert_eq!(final_summaries.len(), 1);
|
assert_eq!(final_summaries.len(), 1);
|
||||||
assert_eq!(final_summaries[0].id, m1.id);
|
assert_eq!(final_summaries[0].id, m1.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_corrupted_page_quarantines_and_does_not_overwrite() {
|
||||||
|
use crate::document::PersistState;
|
||||||
|
|
||||||
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = temp_dir.path();
|
||||||
|
|
||||||
|
let manifest = NotebookManifest::new("nb_corrupt", "Test Corrupt");
|
||||||
|
save_manifest(path, &manifest).unwrap();
|
||||||
|
|
||||||
|
let corrupted_content = br#"{"id": 1, "background": "Grid5mm", "strokes": INVALID_JSON"#;
|
||||||
|
let page_file = path.join("page_1.json");
|
||||||
|
fs::write(&page_file, corrupted_content).unwrap();
|
||||||
|
|
||||||
|
// Load notebook
|
||||||
|
let (nb, mut m) =
|
||||||
|
load_notebook(path).expect("load_notebook should succeed with fallback page");
|
||||||
|
|
||||||
|
// (1) Check that page_1.json was quarantined
|
||||||
|
let dir_entries: Vec<String> = fs::read_dir(path)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
let corrupt_entry = dir_entries
|
||||||
|
.iter()
|
||||||
|
.find(|name| name.starts_with("page_1.json.corrupt-"));
|
||||||
|
assert!(
|
||||||
|
corrupt_entry.is_some(),
|
||||||
|
"Corrupt file must be renamed to page_1.json.corrupt-<ts>, found entries: {dir_entries:?}"
|
||||||
|
);
|
||||||
|
let corrupt_file = path.join(corrupt_entry.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&corrupt_file).unwrap(),
|
||||||
|
corrupted_content,
|
||||||
|
"Quarantine file must contain original corrupt content"
|
||||||
|
);
|
||||||
|
|
||||||
|
// (2) The replacement page must be in ReadOnlyAfterLoadError state
|
||||||
|
assert_eq!(
|
||||||
|
nb.pages[0].persist,
|
||||||
|
PersistState::ReadOnlyAfterLoadError,
|
||||||
|
"Page must be marked ReadOnlyAfterLoadError"
|
||||||
|
);
|
||||||
|
|
||||||
|
// (3) Calling save_notebook must NOT overwrite or create a blank page_1.json!
|
||||||
|
save_notebook(path, &nb, &mut m).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!page_file.exists(),
|
||||||
|
"save_notebook must NOT recreate page_1.json from an unedited/locked replacement page"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
corrupt_file.exists(),
|
||||||
|
"Quarantined file must remain intact after save_notebook"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn test_io_error_does_not_quarantine_and_does_not_overwrite() {
|
||||||
|
use crate::document::PersistState;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
// Skip test if running as root
|
||||||
|
if std::env::var("USER").unwrap_or_default() == "root" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = temp_dir.path();
|
||||||
|
|
||||||
|
let manifest = NotebookManifest::new("nb_io", "Test IO");
|
||||||
|
save_manifest(path, &manifest).unwrap();
|
||||||
|
|
||||||
|
let original_content = br#"{"id": 1, "background": "Grid5mm", "strokes": []}"#;
|
||||||
|
let page_file = path.join("page_1.json");
|
||||||
|
fs::write(&page_file, original_content).unwrap();
|
||||||
|
|
||||||
|
// Make file unreadable
|
||||||
|
fs::set_permissions(&page_file, fs::Permissions::from_mode(0o000)).unwrap();
|
||||||
|
|
||||||
|
let (nb, mut m) =
|
||||||
|
load_notebook(path).expect("load_notebook should succeed with fallback page");
|
||||||
|
|
||||||
|
// Restore permissions for inspection
|
||||||
|
fs::set_permissions(&page_file, fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
|
||||||
|
let dir_entries: Vec<String> = fs::read_dir(path)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
let corrupt_entry = dir_entries.iter().find(|name| name.contains("corrupt"));
|
||||||
|
assert!(
|
||||||
|
corrupt_entry.is_none(),
|
||||||
|
"I/O error must NOT trigger quarantine, found: {corrupt_entry:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
nb.pages[0].persist,
|
||||||
|
PersistState::ReadOnlyAfterLoadError,
|
||||||
|
"Page must be marked ReadOnlyAfterLoadError on I/O error"
|
||||||
|
);
|
||||||
|
|
||||||
|
save_notebook(path, &nb, &mut m).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(&page_file).unwrap(),
|
||||||
|
original_content,
|
||||||
|
"Original file content must not be overwritten"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_edit_replacement_saves_only_after_unlock() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let path = temp_dir.path();
|
||||||
|
|
||||||
|
let manifest = NotebookManifest::new("nb_unlock", "Test Unlock");
|
||||||
|
save_manifest(path, &manifest).unwrap();
|
||||||
|
|
||||||
|
let corrupted_content = br#"CORRUPTED_JSON_DATA"#;
|
||||||
|
let page_file = path.join("page_1.json");
|
||||||
|
fs::write(&page_file, corrupted_content).unwrap();
|
||||||
|
|
||||||
|
let (mut nb, mut m) = load_notebook(path).expect("load_notebook");
|
||||||
|
|
||||||
|
save_notebook(path, &nb, &mut m).unwrap();
|
||||||
|
assert!(!page_file.exists());
|
||||||
|
|
||||||
|
// Now user explicitly acknowledges and unlocks
|
||||||
|
nb.pages[0].unlock_after_load_error();
|
||||||
|
nb.pages[0].add_stroke(Stroke::new(1, vec![], vec![], [1.0, 0.0, 0.0, 1.0]));
|
||||||
|
|
||||||
|
save_notebook(path, &nb, &mut m).unwrap();
|
||||||
|
assert!(
|
||||||
|
page_file.exists(),
|
||||||
|
"Page must be saved after explicit unlock and edit"
|
||||||
|
);
|
||||||
|
|
||||||
|
let dir_entries: Vec<String> = fs::read_dir(path)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.collect();
|
||||||
|
assert!(dir_entries
|
||||||
|
.iter()
|
||||||
|
.any(|name| name.starts_with("page_1.json.corrupt-")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ pub enum UiAction {
|
|||||||
server_url: String,
|
server_url: String,
|
||||||
auth_token: Option<String>,
|
auth_token: Option<String>,
|
||||||
},
|
},
|
||||||
|
// Datenintegrität / Quarantäne
|
||||||
|
UnlockCorruptedPage(u64),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Zustand der grafischen Benutzeroberfläche.
|
/// Zustand der grafischen Benutzeroberfläche.
|
||||||
@@ -1932,6 +1934,94 @@ pub fn render_ui(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Nicht-wegklickbare Warnung bei Ladefehlern / Quarantäne (Block 1)
|
||||||
|
if let Some(warning) = notebook.load_warnings.get(¬ebook.current_page().id) {
|
||||||
|
egui::Window::new("⚠️ Datenintegritäts-Warnung")
|
||||||
|
.collapsible(false)
|
||||||
|
.resizable(false)
|
||||||
|
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
|
||||||
|
.frame(
|
||||||
|
egui::Frame::none()
|
||||||
|
.fill(egui::Color32::from_rgba_premultiplied(35, 20, 20, 245))
|
||||||
|
.rounding(egui::Rounding::same(12.0))
|
||||||
|
.stroke(egui::Stroke::new(
|
||||||
|
1.5_f32,
|
||||||
|
egui::Color32::from_rgb(235, 87, 87),
|
||||||
|
))
|
||||||
|
.inner_margin(egui::Margin::symmetric(24.0, 20.0))
|
||||||
|
.shadow(egui::epaint::Shadow {
|
||||||
|
offset: egui::vec2(0.0, 8.0),
|
||||||
|
blur: 24.0,
|
||||||
|
spread: 0.0,
|
||||||
|
color: egui::Color32::from_black_alpha(180),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_width(420.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.colored_label(egui::Color32::from_rgb(255, 80, 80), "⚠️");
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"Seite {} konnte nicht fehlerfrei geladen werden!",
|
||||||
|
warning.page_id
|
||||||
|
))
|
||||||
|
.strong()
|
||||||
|
.size(15.0)
|
||||||
|
.color(egui::Color32::WHITE),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("Fehlermeldung: {}", warning.message))
|
||||||
|
.size(12.0)
|
||||||
|
.color(egui::Color32::from_gray(200)),
|
||||||
|
);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
if let Some(ref q_path) = warning.quarantine_path {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("Die beschädigte Originaldatei wurde in Quarantäne gesichert:")
|
||||||
|
.size(12.0)
|
||||||
|
.color(egui::Color32::from_gray(220)),
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.monospace(
|
||||||
|
egui::RichText::new(q_path.display().to_string())
|
||||||
|
.size(11.0)
|
||||||
|
.color(egui::Color32::from_rgb(255, 215, 0)),
|
||||||
|
);
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"Um Datenverlust zu verhindern, ist diese Seite schreibgeschützt.\n\
|
||||||
|
Wenn Sie diese Seite bearbeiten möchten, können Sie den Schreibschutz aufheben.",
|
||||||
|
)
|
||||||
|
.size(11.5)
|
||||||
|
.color(egui::Color32::from_gray(180)),
|
||||||
|
);
|
||||||
|
ui.add_space(12.0);
|
||||||
|
if ui
|
||||||
|
.button(
|
||||||
|
egui::RichText::new("Als leere Seite bearbeiten (Schreibschutz aufheben)")
|
||||||
|
.color(egui::Color32::WHITE)
|
||||||
|
.size(12.5),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
actions.push(UiAction::UnlockCorruptedPage(warning.page_id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"Zugriffsfehler (I/O). Die Datei existiert, kann aber nicht gelesen werden.\n\
|
||||||
|
Die Seite bleibt schreibgeschützt, um ein Überschreiben zu verhindern.",
|
||||||
|
)
|
||||||
|
.size(12.0)
|
||||||
|
.color(egui::Color32::from_gray(200)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
actions
|
actions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user