feat: sync notebooks between devices via a synced folder (Issue #49, stage 1)
- Storage location setting: the notebooks can be moved into a folder kept in sync by Nextcloud, Google Drive, Syncthing etc. (subfolder `ink-notes`); existing notebooks are copied there if it is still empty, the originals stay. - Changes from other devices are taken over while everything local is saved: changed pages are reloaded, new/removed/reordered pages and titles come in through the manifest. A page file still being synced is retried later instead of being set aside as damaged. - Conflict copies made by sync clients become pages of their own. - `.opened_by.json` marks the device that has a notebook open; opening a notebook open elsewhere shows a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41ee32e1f7
commit
9b34e30475
13 files changed
+976
-9
No files matched your search
@@ -0,0 +1,555 @@
|
||||
//! Notices changes that another device made to the open notebook through a sync client
|
||||
//! such as Nextcloud or Google Drive (Issue #49).
|
||||
//!
|
||||
//! Every few seconds the notebook folder is scanned (only file times and sizes). Pages whose
|
||||
//! file changed are compared with the page in memory and reloaded if they differ; a changed
|
||||
//! page order, new or removed pages and titles from the manifest are taken over; conflict
|
||||
//! copies made by sync clients (`page_3 (conflicted copy …).json`, `page_3 (1).json`,
|
||||
//! `page_3.sync-conflict-….json`) become pages of their own, so nothing is lost. The check
|
||||
//! only runs while all local changes are saved, so it never overwrites anything unsaved.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::document::{Notebook, Page, PageBackground};
|
||||
use crate::storage::{self, NotebookManifest, OpenMarker, PageData};
|
||||
|
||||
/// Time between two checks.
|
||||
pub const INTERVAL: Duration = Duration::from_secs(3);
|
||||
|
||||
type Fingerprint = (Option<SystemTime>, u64);
|
||||
|
||||
/// What a check found and took over.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Outcome {
|
||||
/// Pages reloaded because their file changed
|
||||
pub reloaded_pages: usize,
|
||||
/// Pages added, removed, reordered or retitled through the manifest
|
||||
pub structure_changed: bool,
|
||||
/// Ids of the conflict copies added as pages (they need saving)
|
||||
pub conflict_pages: Vec<u64>,
|
||||
/// Another device has just opened the notebook
|
||||
pub opened_elsewhere: Option<OpenMarker>,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == Outcome::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DiskWatch {
|
||||
pub dir: PathBuf,
|
||||
known: HashMap<String, Fingerprint>,
|
||||
/// Files that are neither pages nor conflict copies we could read
|
||||
ignored: HashSet<String>,
|
||||
next_check: Instant,
|
||||
}
|
||||
|
||||
/// Fingerprints of the JSON files in the notebook folder.
|
||||
fn scan(dir: &Path) -> HashMap<String, Fingerprint> {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !name.ends_with(".json") {
|
||||
return None;
|
||||
}
|
||||
let meta = entry.metadata().ok()?;
|
||||
meta.is_file()
|
||||
.then(|| (name, (meta.modified().ok(), meta.len())))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Page id of a regular page file (`page_<id>.json`).
|
||||
fn page_file_id(name: &str) -> Option<u64> {
|
||||
name.strip_prefix("page_")?
|
||||
.strip_suffix(".json")?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Page id a conflict copy belongs to: `page_<id>` followed by something else.
|
||||
pub fn conflict_copy_of(name: &str) -> Option<u64> {
|
||||
if page_file_id(name).is_some() {
|
||||
return None;
|
||||
}
|
||||
let rest = name.strip_prefix("page_")?.strip_suffix(".json")?;
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
let tail = &rest[digits.len()..];
|
||||
let marked =
|
||||
tail.starts_with(" (") || tail.starts_with(".sync-conflict") || tail.starts_with('_');
|
||||
(marked && !digits.is_empty())
|
||||
.then(|| digits.parse().ok())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Page content without the format version (that differs between app versions).
|
||||
fn content(mut data: PageData) -> PageData {
|
||||
data.format_version = 0;
|
||||
data
|
||||
}
|
||||
|
||||
impl DiskWatch {
|
||||
pub fn new(dir: PathBuf, now: Instant) -> Self {
|
||||
Self {
|
||||
known: scan(&dir),
|
||||
dir,
|
||||
ignored: HashSet::new(),
|
||||
next_check: now + INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deadline(&self) -> Instant {
|
||||
self.next_check
|
||||
}
|
||||
|
||||
pub fn is_due(&self, now: Instant) -> bool {
|
||||
now >= self.next_check
|
||||
}
|
||||
|
||||
/// Skips the check that is due now (e.g. while drawing).
|
||||
pub fn postpone(&mut self, now: Instant) {
|
||||
self.next_check = now + INTERVAL;
|
||||
}
|
||||
|
||||
/// Checks right away at the next opportunity (e.g. when the window gets the focus).
|
||||
pub fn check_soon(&mut self, now: Instant) {
|
||||
self.next_check = now;
|
||||
}
|
||||
|
||||
/// Takes over what changed on disk. Call only while all local changes are saved.
|
||||
pub fn check(
|
||||
&mut self,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
now: Instant,
|
||||
) -> Outcome {
|
||||
self.next_check = now + INTERVAL;
|
||||
let mut outcome = Outcome::default();
|
||||
let current = scan(&self.dir);
|
||||
let changed = |name: &str| current.get(name) != self.known.get(name);
|
||||
let mut retry: HashSet<String> = HashSet::new();
|
||||
|
||||
if changed(storage::OPEN_MARKER_FILE) {
|
||||
outcome.opened_elsewhere = fs::read(self.dir.join(storage::OPEN_MARKER_FILE))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<OpenMarker>(&b).ok())
|
||||
.filter(|m| m.device != storage::device_name());
|
||||
}
|
||||
|
||||
// Page order, titles, new and removed pages
|
||||
let mut fresh_pages: HashSet<u64> = HashSet::new();
|
||||
if changed("manifest.json") {
|
||||
match storage::load_manifest(&self.dir) {
|
||||
Ok(theirs) => match self.apply_manifest(notebook, manifest, theirs) {
|
||||
Ok(Some(new_ids)) => {
|
||||
fresh_pages = new_ids;
|
||||
outcome.structure_changed = true;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(()) => {
|
||||
retry.insert("manifest.json".to_string());
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
retry.insert("manifest.json".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Changed page files
|
||||
for name in current.keys().filter(|n| changed(n)) {
|
||||
let Some(id) = page_file_id(name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(page) = notebook.pages.iter_mut().find(|p| p.id == id) else {
|
||||
continue;
|
||||
};
|
||||
if !page.is_loaded || fresh_pages.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
match storage::load_page(&self.dir, id) {
|
||||
Ok(data) => {
|
||||
if content(PageData::from(&*page)) != content(data.clone()) {
|
||||
let mut reloaded = data.to_page();
|
||||
reloaded.title = reloaded.title.or_else(|| page.title.clone());
|
||||
*page = reloaded;
|
||||
outcome.reloaded_pages += 1;
|
||||
}
|
||||
}
|
||||
// Possibly still being written by the sync client: try again next time
|
||||
Err(_) => {
|
||||
retry.insert(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict copies of sync clients become pages of their own
|
||||
let mut copies: Vec<&String> = current
|
||||
.keys()
|
||||
.filter(|n| !self.ignored.contains(*n))
|
||||
.filter(|n| conflict_copy_of(n).is_some())
|
||||
.collect();
|
||||
copies.sort();
|
||||
for name in copies {
|
||||
let path = self.dir.join(name);
|
||||
let data = fs::read(&path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<PageData>(&b).ok());
|
||||
match data {
|
||||
Some(data) => {
|
||||
let of = conflict_copy_of(name).unwrap_or(0);
|
||||
outcome
|
||||
.conflict_pages
|
||||
.push(add_conflict_page(notebook, data, of));
|
||||
// Keep the file, but don't take it over again
|
||||
let _ = fs::rename(&path, path.with_extension("json.imported"));
|
||||
}
|
||||
None => {
|
||||
self.ignored.insert(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !outcome.conflict_pages.is_empty() {
|
||||
manifest.sync_from_notebook(notebook);
|
||||
}
|
||||
|
||||
// Remember what we have seen, except files to look at again
|
||||
let mut known = current;
|
||||
for name in retry {
|
||||
match self.known.get(&name) {
|
||||
Some(old) => known.insert(name, *old),
|
||||
None => known.remove(&name),
|
||||
};
|
||||
}
|
||||
self.known = known;
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Takes over the page list of a manifest written elsewhere. Returns the ids of pages
|
||||
/// that are now unloaded placeholders, or `None` if nothing about the pages changed.
|
||||
/// Fails (to be tried again) while the page to show can't be read yet.
|
||||
fn apply_manifest(
|
||||
&self,
|
||||
notebook: &mut Notebook,
|
||||
manifest: &mut NotebookManifest,
|
||||
theirs: NotebookManifest,
|
||||
) -> Result<Option<HashSet<u64>>, ()> {
|
||||
let mut ours = manifest.clone();
|
||||
ours.sync_from_notebook(notebook);
|
||||
if ours.pages == theirs.pages && ours.title == theirs.title {
|
||||
return Ok(None);
|
||||
}
|
||||
let current_id = notebook.current_page().id;
|
||||
|
||||
// The page shown afterwards must be loaded. Read it before changing anything: a file
|
||||
// the sync client is still writing must not end up set aside as damaged.
|
||||
let shown = theirs
|
||||
.pages
|
||||
.iter()
|
||||
.find(|m| m.id == current_id)
|
||||
.or(theirs.pages.first())
|
||||
.map(|m| m.id);
|
||||
let mut shown_data = None;
|
||||
if let Some(id) = shown {
|
||||
if !notebook.pages.iter().any(|p| p.id == id && p.is_loaded) {
|
||||
match storage::load_page(&self.dir, id) {
|
||||
Ok(data) => shown_data = Some(data),
|
||||
// Never saved because still empty
|
||||
Err(storage::StorageError::PageNotFound(_)) => {}
|
||||
Err(_) => return Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut old: HashMap<u64, Page> = notebook.pages.drain(..).map(|p| (p.id, p)).collect();
|
||||
let mut placeholders = HashSet::new();
|
||||
for meta in &theirs.pages {
|
||||
let background = meta.background.unwrap_or(PageBackground::Grid5mm);
|
||||
let size = storage::page_size_from(meta.size);
|
||||
let mut page = match old.remove(&meta.id) {
|
||||
Some(mut page) => {
|
||||
page.background = background;
|
||||
page.size = size;
|
||||
page
|
||||
}
|
||||
None => {
|
||||
placeholders.insert(meta.id);
|
||||
Page::new_unloaded(meta.id, background).with_size(size)
|
||||
}
|
||||
};
|
||||
page.title = meta.title.clone();
|
||||
notebook.pages.push(page);
|
||||
}
|
||||
if notebook.pages.is_empty() {
|
||||
notebook.pages.push(Page::new(1, PageBackground::Grid5mm));
|
||||
}
|
||||
notebook.current_page_index = notebook
|
||||
.pages
|
||||
.iter()
|
||||
.position(|p| p.id == current_id)
|
||||
.unwrap_or(0)
|
||||
.min(notebook.pages.len() - 1);
|
||||
notebook.next_page_id = notebook.next_page_id.max(theirs.next_page_id);
|
||||
notebook.next_stroke_id = notebook.next_stroke_id.max(theirs.next_stroke_id);
|
||||
let index = notebook.current_page_index;
|
||||
if !notebook.pages[index].is_loaded {
|
||||
let page = &mut notebook.pages[index];
|
||||
let mut loaded = match shown_data {
|
||||
Some(data) => data.to_page(),
|
||||
None => Page::new(page.id, page.background).with_size(page.size),
|
||||
};
|
||||
loaded.title = loaded.title.or(page.title.take());
|
||||
*page = loaded;
|
||||
placeholders.remove(&page.id);
|
||||
}
|
||||
let layout = manifest.layout;
|
||||
*manifest = theirs;
|
||||
manifest.layout = manifest.layout.or(layout);
|
||||
manifest.sync_from_notebook(notebook);
|
||||
Ok(Some(placeholders))
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a conflict copy as a new page after page `of` (or at the end), with fresh ids.
|
||||
/// Returns the id of the new page.
|
||||
fn add_conflict_page(notebook: &mut Notebook, mut data: PageData, of: u64) -> u64 {
|
||||
let id = notebook.next_page_id;
|
||||
data.id = id;
|
||||
notebook.next_page_id += 1;
|
||||
for stroke in &mut data.strokes {
|
||||
stroke.id = notebook.next_stroke_id;
|
||||
notebook.next_stroke_id += 1;
|
||||
}
|
||||
let original = notebook.pages.iter().position(|p| p.id == of);
|
||||
let title = original
|
||||
.and_then(|i| notebook.pages[i].title.clone())
|
||||
.or(data.title.take());
|
||||
data.title = Some(match title {
|
||||
Some(title) => crate::t!("Konfliktkopie: {title}", title = title).to_string(),
|
||||
None => crate::t!("Konfliktkopie").to_string(),
|
||||
});
|
||||
let page = data.to_page();
|
||||
let at = original.map_or(notebook.pages.len(), |i| i + 1);
|
||||
notebook.pages.insert(at, page);
|
||||
if at <= notebook.current_page_index {
|
||||
notebook.current_page_index += 1;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::{Stroke, StylusSample};
|
||||
|
||||
fn stroke(id: u64, y: f32) -> Stroke {
|
||||
let points = (0..5)
|
||||
.map(|i| StylusSample {
|
||||
position: glam::Vec2::new(100.0 + 50.0 * i as f32, y),
|
||||
pressure: 0.5,
|
||||
timestamp: 0.0,
|
||||
})
|
||||
.collect();
|
||||
Stroke::new(id, points, Vec::new(), [0.0, 0.0, 0.0, 1.0])
|
||||
}
|
||||
|
||||
/// Saved notebook with one stroke on its only page.
|
||||
fn saved_notebook(dir: &Path) -> (Notebook, NotebookManifest) {
|
||||
let mut notebook = Notebook::new();
|
||||
notebook.current_page_mut().add_stroke(stroke(1, 100.0));
|
||||
notebook.next_stroke_id = 2;
|
||||
let mut manifest = NotebookManifest::new("nb", "Mathe");
|
||||
storage::save_notebook(dir, ¬ebook, &mut manifest).unwrap();
|
||||
(notebook, manifest)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_copy_names() {
|
||||
assert_eq!(
|
||||
conflict_copy_of("page_3 (conflicted copy 2026-10-27).json"),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(conflict_copy_of("page_12 (1).json"), Some(12));
|
||||
assert_eq!(
|
||||
conflict_copy_of("page_3.sync-conflict-20261027-1200-ABC.json"),
|
||||
Some(3)
|
||||
);
|
||||
assert_eq!(conflict_copy_of("page_3_conflict.json"), Some(3));
|
||||
assert_eq!(conflict_copy_of("page_3.json"), None);
|
||||
assert_eq!(conflict_copy_of("page_x (1).json"), None);
|
||||
assert_eq!(conflict_copy_of("manifest (1).json"), None);
|
||||
assert_eq!(conflict_copy_of("page_3.json.imported"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_own_saves_are_not_reported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
let now = Instant::now();
|
||||
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||
|
||||
notebook.current_page_mut().add_stroke(stroke(2, 200.0));
|
||||
notebook.add_page(PageBackground::Blank);
|
||||
storage::save_notebook(dir.path(), ¬ebook, &mut manifest).unwrap();
|
||||
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.is_empty(), "{outcome:?}");
|
||||
assert_eq!(notebook.pages.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_changes_from_another_device_are_taken_over() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
let now = Instant::now();
|
||||
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||
assert!(!watch.is_due(now));
|
||||
|
||||
// The other device adds a stroke on page 1 and a second page with a title
|
||||
let (mut theirs, mut their_manifest) = storage::load_notebook(dir.path()).unwrap();
|
||||
theirs.current_page_mut().add_stroke(stroke(5, 300.0));
|
||||
let index = theirs.add_page(PageBackground::Lined);
|
||||
theirs.pages[index].add_stroke(stroke(6, 400.0));
|
||||
theirs.pages[index].title = Some("Übung".to_string());
|
||||
theirs.next_stroke_id = 7;
|
||||
storage::save_notebook(dir.path(), &theirs, &mut their_manifest).unwrap();
|
||||
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert_eq!(outcome.reloaded_pages, 1);
|
||||
assert!(outcome.structure_changed);
|
||||
assert!(outcome.conflict_pages.is_empty());
|
||||
assert_eq!(notebook.pages.len(), 2);
|
||||
assert_eq!(notebook.pages[0].strokes.len(), 2);
|
||||
assert_eq!(notebook.pages[1].title.as_deref(), Some("Übung"));
|
||||
assert_eq!(notebook.pages[1].background, PageBackground::Lined);
|
||||
assert!(notebook.next_stroke_id >= 7);
|
||||
assert_eq!(manifest.pages.len(), 2);
|
||||
|
||||
// The new page loads from disk when needed
|
||||
storage::ensure_page_loaded(dir.path(), &mut notebook, 1).unwrap();
|
||||
assert_eq!(notebook.pages[1].strokes.len(), 1);
|
||||
|
||||
// Nothing new: nothing to report
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.is_empty(), "{outcome:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_still_being_synced_is_tried_again() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
let now = Instant::now();
|
||||
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||
|
||||
// Elsewhere page 1 was deleted and page 2 added; its file is still half written
|
||||
let (mut theirs, mut their_manifest) = storage::load_notebook(dir.path()).unwrap();
|
||||
let index = theirs.add_page(PageBackground::Blank);
|
||||
theirs.pages[index].add_stroke(stroke(9, 100.0));
|
||||
storage::save_notebook(dir.path(), &theirs, &mut their_manifest).unwrap();
|
||||
their_manifest.pages.remove(0);
|
||||
storage::save_manifest(dir.path(), &their_manifest).unwrap();
|
||||
let page_file = dir.path().join("page_2.json");
|
||||
let complete = fs::read(&page_file).unwrap();
|
||||
fs::write(&page_file, &complete[..complete.len() / 2]).unwrap();
|
||||
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.is_empty(), "{outcome:?}");
|
||||
assert_eq!(notebook.pages.len(), 1);
|
||||
assert_eq!(notebook.pages[0].id, 1);
|
||||
assert!(page_file.exists(), "must not be set aside as damaged");
|
||||
|
||||
fs::write(&page_file, &complete).unwrap();
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.structure_changed);
|
||||
assert_eq!(notebook.pages.len(), 1);
|
||||
assert_eq!(notebook.current_page().id, 2);
|
||||
assert!(notebook.current_page().is_loaded);
|
||||
assert_eq!(notebook.current_page().strokes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_copy_becomes_a_page() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
notebook.add_page(PageBackground::Blank);
|
||||
notebook.current_page_index = 1;
|
||||
storage::save_notebook(dir.path(), ¬ebook, &mut manifest).unwrap();
|
||||
let now = Instant::now();
|
||||
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||
|
||||
let mut copy = PageData::from(¬ebook.pages[0]);
|
||||
copy.strokes.push(
|
||||
PageData::from(&{
|
||||
let mut page = Page::new(1, PageBackground::Blank);
|
||||
page.add_stroke(stroke(1, 500.0));
|
||||
page
|
||||
})
|
||||
.strokes[0]
|
||||
.clone(),
|
||||
);
|
||||
let name = "page_1 (conflicted copy 2026-10-27).json";
|
||||
fs::write(dir.path().join(name), serde_json::to_vec(©).unwrap()).unwrap();
|
||||
fs::write(dir.path().join("page_2 (1).json"), b"{ kaputt").unwrap();
|
||||
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert_eq!(outcome.conflict_pages.len(), 1);
|
||||
assert_eq!(notebook.pages.len(), 3);
|
||||
let added = ¬ebook.pages[1];
|
||||
assert_eq!(added.id, outcome.conflict_pages[0]);
|
||||
assert_eq!(added.strokes.len(), 2);
|
||||
assert!(added.title.is_some());
|
||||
// Fresh ids, no clash with the original page
|
||||
assert!(added.strokes.iter().all(|s| s.id >= 2));
|
||||
assert_eq!(notebook.pages[0].strokes.len(), 1);
|
||||
// The current page stays the same
|
||||
assert_eq!(notebook.current_page_index, 2);
|
||||
assert_eq!(manifest.pages.len(), 3);
|
||||
assert!(!dir.path().join(name).exists());
|
||||
assert!(dir.path().join(format!("{name}.imported")).exists());
|
||||
|
||||
// Taken over only once; the unreadable file is left alone
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.is_empty(), "{outcome:?}");
|
||||
assert!(dir.path().join("page_2 (1).json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_open_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
let marker_path = dir.path().join(storage::OPEN_MARKER_FILE);
|
||||
let foreign = OpenMarker {
|
||||
device: "tablet-of-pia".to_string(),
|
||||
since: 1,
|
||||
};
|
||||
fs::write(&marker_path, serde_json::to_vec(&foreign).unwrap()).unwrap();
|
||||
|
||||
// Opening warns about the other device and puts our own marker in place
|
||||
let lock = storage::NotebookLock::acquire(dir.path()).unwrap();
|
||||
assert_eq!(lock.open_elsewhere.as_ref(), Some(&foreign));
|
||||
let ours: OpenMarker = serde_json::from_slice(&fs::read(&marker_path).unwrap()).unwrap();
|
||||
assert_eq!(ours.device, storage::device_name());
|
||||
|
||||
// The other device opens it again
|
||||
let now = Instant::now();
|
||||
let mut watch = DiskWatch::new(dir.path().to_path_buf(), now);
|
||||
fs::write(&marker_path, serde_json::to_vec(&foreign).unwrap()).unwrap();
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert_eq!(outcome.opened_elsewhere, Some(foreign));
|
||||
|
||||
// A foreign marker is not removed on closing
|
||||
drop(lock);
|
||||
assert!(marker_path.exists());
|
||||
let lock = storage::NotebookLock::acquire(dir.path()).unwrap();
|
||||
drop(lock);
|
||||
assert!(!marker_path.exists());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user