Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07f28df03f | ||
|
|
cde1fc8e91 | ||
|
|
b1e725ed52 | ||
|
|
406e7701ce | ||
|
|
6e67303ad3 | ||
|
|
a8b0e070b0 |
No files matched your search
Generated
+1
-1
@@ -1540,7 +1540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.45.2"
|
||||
version = "0.45.5"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"android_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.45.2"
|
||||
version = "0.45.5"
|
||||
edition = "2021"
|
||||
description = "Notizbuch-App für Stift, Touch und Maus unter Linux"
|
||||
repository = "https://gitea.pansi.eu/harald/ink-notes"
|
||||
|
||||
@@ -178,6 +178,7 @@ Liegen die Notizbücher in einem Ordner, den Nextcloud, Google Drive, Syncthing
|
||||
- Solange alle lokalen Änderungen gespeichert sind, prüft die App den Ordner alle 3 Sekunden (und wenn das Fenster den Fokus bekommt). Geänderte Seitendateien werden neu geladen, ein geändertes `manifest.json` bringt neue, entfernte oder umsortierte Seiten und Titel mit.
|
||||
- Konfliktkopien der Sync-Clients (`page_<id> (….json`, `page_<id>_….json`, `page_<id>.sync-conflict-….json`) werden als neue Seiten hinter dem Original eingefügt und in `<name>.imported` umbenannt.
|
||||
- Bevor die App eine Seitendatei überschreibt, die sich seit ihrem letzten Lesen oder Schreiben geändert hat, sichert sie den vorgefundenen Inhalt als `page_<id> (conflict <Unix-Zeit>).json` – außer sie hat selbst nichts geändert (dann bleibt die Datei, wie sie ist).
|
||||
- Dasselbe gilt für `manifest.json`, nur werden die beiden Fassungen zusammengeführt statt kopiert: Auf beiden Seiten hinzugefügte oder entfernte Seiten, Titel und Zähler werden kombiniert, mit dem zuletzt von der App gelesenen oder geschriebenen Manifest als gemeinsamem Vorgänger.
|
||||
|
||||
### Neuere Versionen
|
||||
Dateien mit einer höheren `format_version` als der unterstützten werden nicht geöffnet – so kann eine ältere App-Version keine Inhalte verlieren, die sie nicht kennt.
|
||||
|
||||
@@ -178,6 +178,7 @@ If the notebooks are kept in a folder synced by Nextcloud, Google Drive, Syncthi
|
||||
- While all local changes are saved, the app checks the folder every 3 seconds (and when the window gets the focus). Changed page files are reloaded, a changed `manifest.json` brings in new, removed or reordered pages and titles.
|
||||
- Conflict copies of sync clients (`page_<id> (….json`, `page_<id>_….json`, `page_<id>.sync-conflict-….json`) are added as new pages after the original and renamed to `<name>.imported`.
|
||||
- Before the app overwrites a page file that changed since it last read or wrote it, it keeps the content found there as `page_<id> (conflict <unix time>).json`, unless it has no changes of its own (then the file is left as it is).
|
||||
- The same goes for `manifest.json`, except that the two versions are merged instead of copied: pages added or removed on either side, titles and counters are combined, with the manifest the app last read or wrote as common ancestor.
|
||||
|
||||
### Newer versions
|
||||
Files with a higher `format_version` than supported are not opened – this way an older app version cannot lose content it does not know.
|
||||
|
||||
@@ -84,6 +84,21 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.45.5" date="2026-11-14">
|
||||
<description>
|
||||
<p>Extremely full pages, for example on an endless notebook, no longer end the app when they are opened.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.45.4" date="2026-11-14">
|
||||
<description>
|
||||
<p>A page added on another device is no longer dropped from the notebook when a change is saved here at the same moment.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.45.3" date="2026-11-14">
|
||||
<description>
|
||||
<p>Undo on a following page after "make space" no longer adds a stroke a second time.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.45.2" date="2026-11-14">
|
||||
<description>
|
||||
<p>A change from another device that arrives while you are writing on the same page is no longer overwritten; it is kept as a conflict copy.</p>
|
||||
|
||||
@@ -84,6 +84,21 @@
|
||||
</branding>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.45.5" date="2026-11-14">
|
||||
<description>
|
||||
<p>Extremely full pages, for example on an endless notebook, no longer end the app when they are opened.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.45.4" date="2026-11-14">
|
||||
<description>
|
||||
<p>A page added on another device is no longer dropped from the notebook when a change is saved here at the same moment.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.45.3" date="2026-11-14">
|
||||
<description>
|
||||
<p>Undo on a following page after "make space" no longer adds a stroke a second time.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.45.2" date="2026-11-14">
|
||||
<description>
|
||||
<p>A change from another device that arrives while you are writing on the same page is no longer overwritten; it is kept as a conflict copy.</p>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Notebook app for pen, touch and mouse on Linux (tested on Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Extremely full pages**: A page with so many strokes that they no longer fit into one graphics buffer (possible on an endless notebook) ended the app when it was opened. Such pages are now drawn from several buffers
|
||||
- **Sync while writing, page list**: If another device added a page just while you were writing, saving here could drop that page from the notebook's page list. The two page lists are now merged: pages added or removed on either device, titles and counters are combined
|
||||
- **Undo after "make space"**: Making space moves the content of the following pages, sometimes onto the next page. Undoing and redoing an older step on such a page could then add a stroke a second time. The older steps of the following pages are now set aside while the space is there and come back when making space is undone
|
||||
- **Sync while writing**: If a change from another device reached a page just while you were writing on it, saving your stroke overwrote that change. Now your version is saved and theirs is kept as a page of its own ("Conflict copy: …"), as for other sync conflicts; if you changed nothing on the page, their version is simply taken over
|
||||
- **Safer notes**: A review of the whole project fixed a number of rare errors. Notebooks deleted after not being edited for 30 days were removed from the trash at the next start instead of 30 days later. If saving failed while switching notebooks, pages could end up in the other notebook, and an open text field moved along; the app now saves first and stays in the notebook if that fails. A copied notebook folder is a notebook of its own instead of opening the original. Strokes keep their width after scaling and a restart, shapes are exported to PDF with their line width, and a page that could not be loaded always shows its warning. Importing broken Rnote, Saber, SVG or PDF files can no longer make a page unreadable or stop the PDF display. One invalid value in the settings file no longer resets all settings, and updates are only installed if the signed package is newer than the running version
|
||||
- **My colors**: Under the presets of the color menus (pen, highlighter, text), "➕ Save color" keeps the current shade in a row of your own colors – up to 12, shared by all three tools and kept across restarts; 🗑 removes the selected one (Issue #56)
|
||||
|
||||
+330
-77
@@ -160,8 +160,120 @@ struct NeighborPage {
|
||||
/// Papier (Dreiecke) und Muster (Rechteck für `fs_pattern`)
|
||||
paper_buffer: wgpu::Buffer,
|
||||
pattern_buffer: Option<wgpu::Buffer>,
|
||||
vertex_buffer: Option<wgpu::Buffer>,
|
||||
stroke_ranges: Vec<Range<u32>>,
|
||||
strokes: StrokeBuffers,
|
||||
}
|
||||
|
||||
/// Part of a page's stroke vertices that goes into one GPU buffer.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct ChunkPlan {
|
||||
/// Position in the page's vertex cache
|
||||
vertices: Range<usize>,
|
||||
/// The strokes in it, relative to the start of the chunk
|
||||
strokes: Vec<Range<u32>>,
|
||||
}
|
||||
|
||||
/// Splits the strokes of a page (ranges in its vertex cache) into runs of at most
|
||||
/// `max_vertices`, without cutting a stroke. Nearly every page is a single run; only a huge
|
||||
/// one, in practice an endless page, needs more than one buffer. A single stroke larger than
|
||||
/// a buffer cannot be drawn and is left out.
|
||||
fn plan_stroke_chunks(stroke_ranges: &[Range<u32>], max_vertices: usize) -> Vec<ChunkPlan> {
|
||||
let mut chunks: Vec<ChunkPlan> = Vec::new();
|
||||
for range in stroke_ranges {
|
||||
let (start, end) = (range.start as usize, range.end as usize);
|
||||
if end <= start || end - start > max_vertices {
|
||||
continue;
|
||||
}
|
||||
let fits = chunks
|
||||
.last()
|
||||
.is_some_and(|c| c.vertices.end == start && end - c.vertices.start <= max_vertices);
|
||||
if !fits {
|
||||
chunks.push(ChunkPlan {
|
||||
vertices: start..start,
|
||||
strokes: Vec::new(),
|
||||
});
|
||||
}
|
||||
if let Some(chunk) = chunks.last_mut() {
|
||||
let offset = (start - chunk.vertices.start) as u32;
|
||||
chunk.strokes.push(offset..offset + (end - start) as u32);
|
||||
chunk.vertices.end = end;
|
||||
}
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
struct StrokeChunk {
|
||||
buffer: wgpu::Buffer,
|
||||
/// In vertices
|
||||
capacity: usize,
|
||||
strokes: Vec<Range<u32>>,
|
||||
}
|
||||
|
||||
/// The finished strokes of a page on the GPU, in as many vertex buffers as the size limit
|
||||
/// of the device requires (one buffer beyond the limit is a validation error that ends the
|
||||
/// app).
|
||||
#[derive(Default)]
|
||||
struct StrokeBuffers {
|
||||
chunks: Vec<StrokeChunk>,
|
||||
/// Chunks holding the current content; further ones are kept for their capacity
|
||||
used: usize,
|
||||
}
|
||||
|
||||
impl StrokeBuffers {
|
||||
/// Uploads the vertex cache of a page. `spare` leaves room for more strokes (at least
|
||||
/// that many vertices and twice the need), so drawing does not reallocate every time.
|
||||
fn upload(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
vertices: &[InkVertex],
|
||||
stroke_ranges: &[Range<u32>],
|
||||
spare: Option<usize>,
|
||||
) {
|
||||
let vertex_size = std::mem::size_of::<InkVertex>();
|
||||
let max_vertices = (device.limits().max_buffer_size / vertex_size as u64) as usize;
|
||||
let mut used = 0;
|
||||
for part in plan_stroke_chunks(stroke_ranges, max_vertices) {
|
||||
let Some(data) = vertices.get(part.vertices.clone()) else {
|
||||
continue;
|
||||
};
|
||||
if self
|
||||
.chunks
|
||||
.get(used)
|
||||
.is_none_or(|c| c.capacity < data.len())
|
||||
{
|
||||
let capacity = match spare {
|
||||
Some(initial) => grown_vertex_capacity(data.len(), initial, max_vertices),
|
||||
None => data.len(),
|
||||
};
|
||||
let chunk = StrokeChunk {
|
||||
buffer: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Strokes Vertex Buffer"),
|
||||
size: (capacity * vertex_size) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
capacity,
|
||||
strokes: Vec::new(),
|
||||
};
|
||||
if used < self.chunks.len() {
|
||||
self.chunks[used] = chunk;
|
||||
} else {
|
||||
self.chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
let chunk = &mut self.chunks[used];
|
||||
queue.write_buffer(&chunk.buffer, 0, bytemuck::cast_slice(data));
|
||||
chunk.strokes = part.strokes;
|
||||
used += 1;
|
||||
}
|
||||
self.used = used;
|
||||
// Keep the first buffer for the next page, give the memory of the others back
|
||||
self.chunks.truncate(used.max(1));
|
||||
}
|
||||
|
||||
fn in_use(&self) -> &[StrokeChunk] {
|
||||
&self.chunks[..self.used.min(self.chunks.len())]
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CanvasState {
|
||||
@@ -238,10 +350,7 @@ pub struct CanvasState {
|
||||
pub spline_config: SplineConfig,
|
||||
|
||||
// Pipeline-Trennung: Abgeschlossene Striche der aktuellen Seite
|
||||
pub committed_vertex_buffer: wgpu::Buffer,
|
||||
pub committed_vertex_capacity: usize,
|
||||
pub committed_vertex_count: u32,
|
||||
pub committed_strokes: Vec<Range<u32>>,
|
||||
committed: StrokeBuffers,
|
||||
|
||||
// Eingefügte Bilder der aktuellen Seite
|
||||
image_layer: crate::image_layer::ImageLayer,
|
||||
@@ -575,13 +684,6 @@ impl CanvasState {
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let committed_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Committed Strokes Vertex Buffer"),
|
||||
size: (Self::INITIAL_COMMITTED_CAPACITY * std::mem::size_of::<InkVertex>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let image_layer = crate::image_layer::ImageLayer::new(
|
||||
&device,
|
||||
&shader,
|
||||
@@ -641,10 +743,7 @@ impl CanvasState {
|
||||
max_radius: 3.75, // 0.375 mm bei vollem Druck
|
||||
samples_per_segment: 16,
|
||||
},
|
||||
committed_vertex_buffer,
|
||||
committed_vertex_capacity: Self::INITIAL_COMMITTED_CAPACITY,
|
||||
committed_vertex_count: 0,
|
||||
committed_strokes: Vec::with_capacity(128),
|
||||
committed: StrokeBuffers::default(),
|
||||
image_layer,
|
||||
neighbors: Vec::new(),
|
||||
camera_layout: bind_group_layout,
|
||||
@@ -1013,33 +1112,12 @@ impl CanvasState {
|
||||
let quads = self.page_image_quads(page, self.hidden_text);
|
||||
self.image_layer.set_quads(quads);
|
||||
|
||||
self.committed_vertex_count = page.vertex_cache.len() as u32;
|
||||
self.committed_strokes = page.stroke_ranges.clone();
|
||||
|
||||
if page.vertex_cache.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if page.vertex_cache.len() > self.committed_vertex_capacity {
|
||||
let vertex_size = std::mem::size_of::<InkVertex>() as u64;
|
||||
let max_vertices = (self.device.limits().max_buffer_size / vertex_size) as usize;
|
||||
self.committed_vertex_capacity = grown_vertex_capacity(
|
||||
page.vertex_cache.len(),
|
||||
Self::INITIAL_COMMITTED_CAPACITY,
|
||||
max_vertices,
|
||||
);
|
||||
self.committed_vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Committed Strokes Vertex Buffer (Reallocated)"),
|
||||
size: (self.committed_vertex_capacity * std::mem::size_of::<InkVertex>()) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
|
||||
self.queue.write_buffer(
|
||||
&self.committed_vertex_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&page.vertex_cache),
|
||||
self.committed.upload(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&page.vertex_cache,
|
||||
&page.stroke_ranges,
|
||||
Some(Self::INITIAL_COMMITTED_CAPACITY),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1084,19 +1162,14 @@ impl CanvasState {
|
||||
resource: camera_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let vertex_buffer_with = |label: &str, vertices: &[InkVertex]| {
|
||||
(!vertices.is_empty()).then(|| {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: std::mem::size_of_val(vertices) as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
self.queue
|
||||
.write_buffer(&buffer, 0, bytemuck::cast_slice(vertices));
|
||||
buffer
|
||||
})
|
||||
};
|
||||
let mut strokes = StrokeBuffers::default();
|
||||
strokes.upload(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&page.vertex_cache,
|
||||
&page.stroke_ranges,
|
||||
None,
|
||||
);
|
||||
// Eigenes Papier und Muster (Nachbarseiten können anders groß sein)
|
||||
let paper = generate_paper_quad(self.paper_theme.paper_color(), page.size);
|
||||
let pattern = generate_pattern_quad(
|
||||
@@ -1130,8 +1203,7 @@ impl CanvasState {
|
||||
camera_bind_group,
|
||||
paper_buffer,
|
||||
pattern_buffer,
|
||||
vertex_buffer: vertex_buffer_with("Neighbor Strokes Buffer", &page.vertex_cache),
|
||||
stroke_ranges: page.stroke_ranges.clone(),
|
||||
strokes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1492,14 +1564,12 @@ impl CanvasState {
|
||||
render_pass.draw(0..6, 0..1);
|
||||
}
|
||||
self.image_layer.draw(&mut render_pass, i + 1);
|
||||
if let Some(buffer) = &neighbor.vertex_buffer {
|
||||
render_pass.set_pipeline(&self.strip_pipeline);
|
||||
render_pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
for range in &neighbor.stroke_ranges {
|
||||
if range.end > range.start {
|
||||
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||
render_pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
render_pass.set_pipeline(&self.strip_pipeline);
|
||||
for chunk in neighbor.strokes.in_use() {
|
||||
render_pass.set_vertex_buffer(0, chunk.buffer.slice(..));
|
||||
for range in &chunk.strokes {
|
||||
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||
render_pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1525,13 +1595,11 @@ impl CanvasState {
|
||||
// 4. Abgeschlossene Striche der aktiven Seite rendern (TriangleStrip mit Vertex-Farben)
|
||||
render_pass.set_pipeline(&self.strip_pipeline);
|
||||
|
||||
if self.committed_vertex_count > 0 {
|
||||
render_pass.set_vertex_buffer(0, self.committed_vertex_buffer.slice(..));
|
||||
for range in &self.committed_strokes {
|
||||
if range.end > range.start {
|
||||
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||
render_pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
for chunk in self.committed.in_use() {
|
||||
render_pass.set_vertex_buffer(0, chunk.buffer.slice(..));
|
||||
for range in &chunk.strokes {
|
||||
self.next_stroke_stencil(&mut render_pass, &mut stencil_ref);
|
||||
render_pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1741,9 +1809,7 @@ fn text_resolution(screen_px_per_unit: f32) -> f32 {
|
||||
}
|
||||
|
||||
/// Capacity for a vertex buffer that has to hold `needed` vertices: twice as much, so that
|
||||
/// it is not reallocated with every stroke, but no more than the device allows. Doubling
|
||||
/// alone asked for a buffer beyond the limit as soon as a very full page used half of it,
|
||||
/// which ends in a validation panic.
|
||||
/// it is not reallocated with every stroke, but no more than the device allows.
|
||||
fn grown_vertex_capacity(needed: usize, initial: usize, max_vertices: usize) -> usize {
|
||||
needed
|
||||
.saturating_mul(2)
|
||||
@@ -1756,6 +1822,193 @@ fn grown_vertex_capacity(needed: usize, initial: usize, max_vertices: usize) ->
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
fn test_strokes_are_split_into_buffers_within_the_limit() {
|
||||
// The usual case: one buffer, ranges as in the vertex cache
|
||||
let ranges = vec![0..10, 10..10, 10..25, 25..40];
|
||||
let plan = plan_stroke_chunks(&ranges, 1000);
|
||||
assert_eq!(
|
||||
plan,
|
||||
vec![ChunkPlan {
|
||||
vertices: 0..40,
|
||||
strokes: vec![0..10, 10..25, 25..40],
|
||||
}]
|
||||
);
|
||||
|
||||
// More than fits: several buffers, no stroke cut, ranges relative to their buffer
|
||||
let plan = plan_stroke_chunks(&ranges, 30);
|
||||
assert_eq!(
|
||||
plan,
|
||||
vec![
|
||||
ChunkPlan {
|
||||
vertices: 0..25,
|
||||
strokes: vec![0..10, 10..25],
|
||||
},
|
||||
ChunkPlan {
|
||||
vertices: 25..40,
|
||||
strokes: vec![0..15],
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(plan.iter().all(|c| c.vertices.len() <= 30));
|
||||
|
||||
// A stroke larger than a buffer is left out, the others are still drawn
|
||||
let plan = plan_stroke_chunks(&[0..10, 10..60, 60..70], 30);
|
||||
assert_eq!(
|
||||
plan,
|
||||
vec![
|
||||
ChunkPlan {
|
||||
vertices: 0..10,
|
||||
strokes: vec![0..10],
|
||||
},
|
||||
ChunkPlan {
|
||||
vertices: 60..70,
|
||||
strokes: vec![0..10],
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(plan_stroke_chunks(&[], 30).is_empty());
|
||||
}
|
||||
|
||||
/// Uploads and draws a page whose strokes exceed the buffer limit of a real device (the
|
||||
/// limit is set very low for the test). Skipped where no graphics adapter is available.
|
||||
#[test]
|
||||
fn test_page_beyond_the_buffer_limit_is_drawn_from_several_buffers() {
|
||||
let vertex_size = std::mem::size_of::<InkVertex>() as u64;
|
||||
// 40 vertices per buffer
|
||||
let limits = wgpu::Limits {
|
||||
max_buffer_size: 40 * vertex_size,
|
||||
..wgpu::Limits::default()
|
||||
};
|
||||
let instance = wgpu::Instance::default();
|
||||
let mut found = None;
|
||||
for force_fallback_adapter in [false, true] {
|
||||
let options = wgpu::RequestAdapterOptions {
|
||||
force_fallback_adapter,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(adapter) = pollster::block_on(instance.request_adapter(&options)) {
|
||||
let descriptor = wgpu::DeviceDescriptor {
|
||||
label: None,
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_limits: limits.clone(),
|
||||
};
|
||||
if let Ok(pair) = pollster::block_on(adapter.request_device(&descriptor, None)) {
|
||||
found = Some(pair);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((device, queue)) = found else {
|
||||
eprintln!("no graphics adapter, test skipped");
|
||||
return;
|
||||
};
|
||||
|
||||
// 10 strokes of 12 vertices: 120 vertices, three times the limit
|
||||
let vertices = vec![InkVertex::new(0.0, 0.0, [0.0, 0.0, 0.0, 1.0]); 120];
|
||||
let ranges: Vec<Range<u32>> = (0..10).map(|i| i * 12..(i + 1) * 12).collect();
|
||||
|
||||
// The limit is real: one buffer for everything is refused
|
||||
device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let _ = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: vertices.len() as u64 * vertex_size,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
assert!(pollster::block_on(device.pop_error_scope()).is_some());
|
||||
|
||||
device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut strokes = StrokeBuffers::default();
|
||||
strokes.upload(&device, &queue, &vertices, &ranges, Some(16));
|
||||
assert_eq!(strokes.in_use().len(), 4);
|
||||
let drawn: usize = strokes.in_use().iter().map(|c| c.strokes.len()).sum();
|
||||
assert_eq!(drawn, 10);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@vertex fn vs(@location(0) p: vec2<f32>, @location(1) c: vec4<f32>)
|
||||
-> @builtin(position) vec4<f32> { return vec4<f32>(p, 0.0, 1.0) + c * 0.0; }
|
||||
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }"
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: "vs",
|
||||
buffers: &[InkVertex::desc()],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: "fs",
|
||||
targets: &[Some(format.into())],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview: None,
|
||||
});
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 16,
|
||||
height: 16,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
for chunk in strokes.in_use() {
|
||||
pass.set_vertex_buffer(0, chunk.buffer.slice(..));
|
||||
for range in &chunk.strokes {
|
||||
pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
queue.submit(std::iter::once(encoder.finish()));
|
||||
device.poll(wgpu::Maintain::Wait);
|
||||
let error = pollster::block_on(device.pop_error_scope());
|
||||
assert!(error.is_none(), "{error:?}");
|
||||
|
||||
// A smaller page afterwards uses one buffer again and frees the others
|
||||
strokes.upload(&device, &queue, &vertices[..24], &ranges[..2], Some(16));
|
||||
assert_eq!(strokes.in_use().len(), 1);
|
||||
assert_eq!(strokes.chunks.len(), 1);
|
||||
strokes.upload(&device, &queue, &[], &[], Some(16));
|
||||
assert!(strokes.in_use().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vertex_capacity_doubles_up_to_the_device_limit() {
|
||||
assert_eq!(grown_vertex_capacity(10, 1000, 1_000_000), 1000);
|
||||
|
||||
+107
-2
@@ -166,12 +166,16 @@ impl DiskWatch {
|
||||
// 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) {
|
||||
match storage::load_manifest_as_base(&self.dir) {
|
||||
Ok((theirs, base)) => match self.apply_manifest(notebook, manifest, theirs) {
|
||||
// Taken over: from now on our saves build on this manifest
|
||||
Ok(Some(new_ids)) => {
|
||||
base.adopt();
|
||||
fresh_pages = new_ids;
|
||||
outcome.structure_changed = true;
|
||||
}
|
||||
// Nothing to take over for the pages. Whatever else differs is merged
|
||||
// at the next save, so the base stays as it is.
|
||||
Ok(None) => {}
|
||||
Err(()) => {
|
||||
retry.insert("manifest.json".to_string());
|
||||
@@ -557,6 +561,107 @@ mod tests {
|
||||
assert_eq!(conflict_copies(dir.path()).len(), 1);
|
||||
}
|
||||
|
||||
/// Writes the manifest the way a sync client does: behind the back of `storage`.
|
||||
fn write_manifest_from_another_device(dir: &Path, manifest: &NotebookManifest) {
|
||||
fs::write(
|
||||
dir.join("manifest.json"),
|
||||
serde_json::to_vec(manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn page_ids_on_disk(dir: &Path) -> Vec<u64> {
|
||||
let bytes = fs::read(dir.join("manifest.json")).unwrap();
|
||||
let manifest: NotebookManifest = serde_json::from_slice(&bytes).unwrap();
|
||||
manifest.pages.iter().map(|p| p.id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_saving_the_manifest_keeps_a_page_added_on_another_device() {
|
||||
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);
|
||||
|
||||
// The other device adds a second page
|
||||
let mut theirs = notebook.clone();
|
||||
let mut their_manifest = manifest.clone();
|
||||
let index = theirs.add_page(PageBackground::Lined);
|
||||
theirs.pages[index].add_stroke(stroke(6, 400.0));
|
||||
theirs.next_stroke_id = 7;
|
||||
their_manifest.sync_from_notebook(&theirs);
|
||||
write_from_another_device(dir.path(), &theirs.pages[index]);
|
||||
write_manifest_from_another_device(dir.path(), &their_manifest);
|
||||
let their_page = theirs.pages[index].id;
|
||||
|
||||
// Before the check ran, a stroke is drawn and saved here, twice
|
||||
for id in [2, 3] {
|
||||
notebook.current_page_mut().add_stroke(stroke(id, 200.0));
|
||||
notebook.next_stroke_id = id + 1;
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
storage::save_page(dir.path(), &PageData::from(notebook.current_page())).unwrap();
|
||||
storage::save_manifest(dir.path(), &manifest).unwrap();
|
||||
assert_eq!(page_ids_on_disk(dir.path()), vec![1, their_page]);
|
||||
}
|
||||
|
||||
// The check takes their page over, and saving stays an ordinary save afterwards
|
||||
let outcome = watch.check(&mut notebook, &mut manifest, now);
|
||||
assert!(outcome.structure_changed);
|
||||
assert_eq!(notebook.pages.len(), 2);
|
||||
assert_eq!(notebook.pages[0].strokes.len(), 3);
|
||||
assert!(notebook.next_stroke_id >= 7);
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
storage::save_manifest(dir.path(), &manifest).unwrap();
|
||||
assert_eq!(page_ids_on_disk(dir.path()), vec![1, their_page]);
|
||||
|
||||
// A page removed here afterwards is really gone
|
||||
notebook.pages.pop();
|
||||
notebook.current_page_index = 0;
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
storage::save_manifest(dir.path(), &manifest).unwrap();
|
||||
assert_eq!(page_ids_on_disk(dir.path()), vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_saving_an_unchanged_manifest_keeps_the_one_from_another_device() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (notebook, manifest) = saved_notebook(dir.path());
|
||||
|
||||
let mut their_manifest = manifest.clone();
|
||||
their_manifest.title = "Physik".to_string();
|
||||
write_manifest_from_another_device(dir.path(), &their_manifest);
|
||||
|
||||
storage::save_manifest(dir.path(), &manifest).unwrap();
|
||||
let bytes = fs::read(dir.path().join("manifest.json")).unwrap();
|
||||
let on_disk: NotebookManifest = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(on_disk.title, "Physik");
|
||||
drop(notebook);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_changed_on_both_sides_is_merged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut notebook, mut manifest) = saved_notebook(dir.path());
|
||||
let ours_new = notebook.add_page(PageBackground::Blank);
|
||||
let ours_new = notebook.pages[ours_new].id;
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
storage::save_manifest(dir.path(), &manifest).unwrap();
|
||||
|
||||
// They rename the notebook; we remove our new page again
|
||||
let mut their_manifest = manifest.clone();
|
||||
their_manifest.title = "Physik".to_string();
|
||||
write_manifest_from_another_device(dir.path(), &their_manifest);
|
||||
notebook.pages.pop();
|
||||
notebook.current_page_index = 0;
|
||||
manifest.sync_from_notebook(¬ebook);
|
||||
storage::save_manifest(dir.path(), &manifest).unwrap();
|
||||
|
||||
let bytes = fs::read(dir.path().join("manifest.json")).unwrap();
|
||||
let on_disk: NotebookManifest = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(on_disk.title, "Physik");
|
||||
assert!(!on_disk.pages.iter().any(|p| p.id == ours_new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_page_still_being_synced_is_tried_again() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
+90
-3
@@ -1542,6 +1542,11 @@ pub struct AddedPage {
|
||||
pub struct SpaceShift {
|
||||
pub moves: Vec<StrokeMove>,
|
||||
pub added_pages: Vec<AddedPage>,
|
||||
/// Undo histories of the other pages (by page id) as they were before the shift, set
|
||||
/// aside while it is applied. Their steps describe strokes at their old place and page:
|
||||
/// redoing "stroke drawn" there would put a second copy of a stroke that now sits on
|
||||
/// another page. They come back when the shift is undone.
|
||||
pub set_aside: Vec<(u64, HistoryStack)>,
|
||||
}
|
||||
|
||||
impl Notebook {
|
||||
@@ -1705,8 +1710,13 @@ impl Notebook {
|
||||
after,
|
||||
});
|
||||
}
|
||||
let shift = SpaceShift { moves, added_pages };
|
||||
let mut shift = SpaceShift {
|
||||
moves,
|
||||
added_pages,
|
||||
set_aside: Vec::new(),
|
||||
};
|
||||
let changed = self.apply_space_moves(&shift, false);
|
||||
shift.set_aside = self.set_histories_aside(&changed, self.pages[page_index].id);
|
||||
self.pages[page_index]
|
||||
.history
|
||||
.push(HistoryAction::SpaceShift(Box::new(shift)));
|
||||
@@ -1748,16 +1758,37 @@ impl Notebook {
|
||||
changed
|
||||
}
|
||||
|
||||
/// Takes the histories of the pages a shift changed, except the page that owns the
|
||||
/// step (see [`SpaceShift::set_aside`]).
|
||||
fn set_histories_aside(&mut self, changed: &[u64], owner: u64) -> Vec<(u64, HistoryStack)> {
|
||||
self.pages
|
||||
.iter_mut()
|
||||
.filter(|p| p.id != owner && changed.contains(&p.id))
|
||||
.filter(|p| p.history.can_undo() || p.history.can_redo())
|
||||
.map(|p| (p.id, std::mem::take(&mut p.history)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Macht die letzte Aktion der aktuellen Seite rückgängig; ein Platzschaffen-Schritt
|
||||
/// betrifft dabei alle beteiligten Seiten. Gibt die geänderten Seiten zurück.
|
||||
pub fn undo(&mut self) -> Option<Vec<u64>> {
|
||||
let index = self.current_page_index;
|
||||
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.undo_stack.last() {
|
||||
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.undo_stack.pop()
|
||||
let Some(HistoryAction::SpaceShift(mut shift)) =
|
||||
self.pages[index].history.undo_stack.pop()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut changed = self.apply_space_moves(&shift, true);
|
||||
// The pages are as before the shift again, and so their histories apply again.
|
||||
// A page that got steps of its own in the meantime keeps those instead.
|
||||
for (id, history) in shift.set_aside.drain(..) {
|
||||
if let Some(page) = self.pages.iter_mut().find(|p| p.id == id) {
|
||||
if !page.history.can_undo() && !page.history.can_redo() {
|
||||
page.history = history;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dafür angelegte Seiten wieder entfernen, sofern sie leer geblieben sind
|
||||
for added in shift.added_pages.iter().rev() {
|
||||
if let Some(i) = self.pages.iter().position(|p| p.id == added.id) {
|
||||
@@ -1786,7 +1817,8 @@ impl Notebook {
|
||||
pub fn redo(&mut self) -> Option<Vec<u64>> {
|
||||
let index = self.current_page_index;
|
||||
if let Some(HistoryAction::SpaceShift(_)) = self.pages[index].history.redo_stack.last() {
|
||||
let Some(HistoryAction::SpaceShift(shift)) = self.pages[index].history.redo_stack.pop()
|
||||
let Some(HistoryAction::SpaceShift(mut shift)) =
|
||||
self.pages[index].history.redo_stack.pop()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
@@ -1810,6 +1842,7 @@ impl Notebook {
|
||||
}
|
||||
let changed = self.apply_space_moves(&shift, false);
|
||||
let owner = self.current_page_index;
|
||||
shift.set_aside = self.set_histories_aside(&changed, self.pages[owner].id);
|
||||
self.pages[owner]
|
||||
.history
|
||||
.undo_stack
|
||||
@@ -2694,6 +2727,60 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo_on_a_following_page_after_make_space_does_not_duplicate() {
|
||||
let a4 = A4_SIZE.y;
|
||||
let count = |nb: &Notebook, id: u64| {
|
||||
nb.pages
|
||||
.iter()
|
||||
.flat_map(|p| &p.strokes)
|
||||
.filter(|s| s.id == id)
|
||||
.count()
|
||||
};
|
||||
let mut nb = Notebook::new();
|
||||
nb.add_page(PageBackground::Blank);
|
||||
nb.pages[0].strokes = vec![block(1, 1000.0, 100.0)];
|
||||
// Drawn on the second page, so it is in that page's history
|
||||
nb.pages[1].add_stroke(block(3, a4 - 150.0, 100.0));
|
||||
|
||||
// Pushes stroke 3 over the end of its page onto a new third page
|
||||
nb.make_space(0, 900.0, 300.0).unwrap();
|
||||
assert_eq!(nb.pages.len(), 3);
|
||||
assert!(top_of(&nb, 2, 3).is_some());
|
||||
|
||||
// The step that drew it belongs to the state before the shift: not available now
|
||||
nb.current_page_index = 1;
|
||||
assert!(nb.undo().is_none());
|
||||
assert!(nb.redo().is_none());
|
||||
assert_eq!(count(&nb, 3), 1);
|
||||
|
||||
// With the shift undone it is back and works as before
|
||||
nb.current_page_index = 0;
|
||||
assert!(nb.undo().is_some());
|
||||
assert_eq!(top_of(&nb, 1, 3), Some(a4 - 150.0));
|
||||
nb.current_page_index = 1;
|
||||
assert!(nb.undo().is_some());
|
||||
assert_eq!(count(&nb, 3), 0);
|
||||
assert!(nb.redo().is_some());
|
||||
assert_eq!(count(&nb, 3), 1);
|
||||
|
||||
// Shifted again and something new drawn on the page: that step can be undone, and
|
||||
// undoing the shift then leaves the page's newer history alone
|
||||
nb.current_page_index = 0;
|
||||
assert!(nb.redo().is_some());
|
||||
assert!(top_of(&nb, 2, 3).is_some());
|
||||
nb.pages[1].add_stroke(block(4, 200.0, 100.0));
|
||||
assert!(nb.undo().is_some());
|
||||
nb.current_page_index = 1;
|
||||
assert!(nb.undo().is_some());
|
||||
assert_eq!(count(&nb, 4), 0);
|
||||
assert!(
|
||||
nb.undo().is_none(),
|
||||
"the older history is not restored on top"
|
||||
);
|
||||
assert_eq!(count(&nb, 3), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_space_refuses_pages_that_failed_to_load() {
|
||||
let mut nb = Notebook::new();
|
||||
|
||||
+85
-13
@@ -590,16 +590,32 @@ pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), Sto
|
||||
|
||||
/// Speichert das Manifest atomar in `manifest.json`.
|
||||
/// Sichert das bisherige Manifest als `manifest.json.bak` vor dem Überschreiben.
|
||||
///
|
||||
/// Like a page (see [`save_page`]), the manifest may have been changed behind our back by a
|
||||
/// sync before `disk_watch` took the change over, e.g. the other device added a page.
|
||||
/// Overwriting it would drop that page from the list. The manifest on disk is therefore
|
||||
/// merged with ours, with the manifest the notebook in memory is based on as common ancestor
|
||||
/// ([`ManifestBase`]); if we have nothing new ourselves, theirs simply stays.
|
||||
pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result<(), StorageError> {
|
||||
let path = notebook_dir.join("manifest.json");
|
||||
if path.exists() {
|
||||
let ours = serde_json::to_vec(manifest)?;
|
||||
let key = seen_key(&path);
|
||||
let mut merged = None;
|
||||
if let Ok(theirs) = fs::read(&path) {
|
||||
let base = manifest_bases().get(&key).cloned();
|
||||
if base.as_ref() != Some(&theirs) && theirs != ours {
|
||||
if base.as_ref() == Some(&ours) {
|
||||
// Nothing changed here since the notebook was loaded or last saved
|
||||
return Ok(());
|
||||
}
|
||||
// Theirs unreadable (e.g. half written): ours replaces it
|
||||
merged = merge_manifests(base.as_deref(), &ours, &theirs);
|
||||
}
|
||||
let bak_path = notebook_dir.join("manifest.json.v0.bak");
|
||||
if !bak_path.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
if let Ok(old_m) = serde_json::from_str::<NotebookManifest>(&content) {
|
||||
if old_m.format_version == 0 {
|
||||
let _ = fs::copy(&path, &bak_path);
|
||||
}
|
||||
if let Ok(old_m) = serde_json::from_slice::<NotebookManifest>(&theirs) {
|
||||
if old_m.format_version == 0 {
|
||||
let _ = fs::copy(&path, &bak_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -607,10 +623,51 @@ pub fn save_manifest(notebook_dir: &Path, manifest: &NotebookManifest) -> Result
|
||||
let rolling_bak = notebook_dir.join("manifest.json.bak");
|
||||
let _ = fs::copy(&path, &rolling_bak);
|
||||
}
|
||||
atomic_write_json(&path, manifest)
|
||||
atomic_write_file(&path, merged.as_ref().unwrap_or(&ours))?;
|
||||
// Ours, not the merged file: the notebook in memory does not contain their part until
|
||||
// `disk_watch` has taken it over, so the next save has to merge it in again
|
||||
manifest_bases().insert(key, ours);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_manifest_from_file(path: &Path) -> Result<NotebookManifest, StorageError> {
|
||||
/// Three-way merge of manifests as JSON (page lists by page id, see
|
||||
/// `cloud_sync::merge_json`); `None` if one of them can't be read or the result is no
|
||||
/// valid manifest.
|
||||
fn merge_manifests(base: Option<&[u8]>, ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> {
|
||||
let ours: serde_json::Value = serde_json::from_slice(ours).ok()?;
|
||||
let theirs: serde_json::Value = serde_json::from_slice(theirs).ok()?;
|
||||
let base: Option<serde_json::Value> = base.and_then(|b| serde_json::from_slice(b).ok());
|
||||
let merged = crate::cloud_sync::merge_json(base.as_ref(), &ours, &theirs);
|
||||
serde_json::from_value::<NotebookManifest>(merged.clone()).ok()?;
|
||||
serde_json::to_vec(&merged).ok()
|
||||
}
|
||||
|
||||
/// Content of `manifest.json` that the notebooks in memory are based on, by file.
|
||||
fn manifest_bases() -> std::sync::MutexGuard<'static, std::collections::HashMap<PathBuf, Vec<u8>>> {
|
||||
static BASES: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<PathBuf, Vec<u8>>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
BASES
|
||||
.get_or_init(Default::default)
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// A manifest as read from disk, to be declared the state the notebook in memory is based on
|
||||
/// once it has been taken over (see [`save_manifest`]). Reading a manifest alone, e.g. for
|
||||
/// the list of notebooks, must not do that.
|
||||
pub struct ManifestBase {
|
||||
key: PathBuf,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ManifestBase {
|
||||
pub fn adopt(self) {
|
||||
manifest_bases().insert(self.key, self.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_manifest_from_file(path: &Path) -> Result<(NotebookManifest, Vec<u8>), StorageError> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut json_str = String::new();
|
||||
file.read_to_string(&mut json_str)?;
|
||||
@@ -630,7 +687,7 @@ fn load_manifest_from_file(path: &Path) -> Result<NotebookManifest, StorageError
|
||||
.map(|&id| PageMeta::new(id, None))
|
||||
.collect();
|
||||
}
|
||||
Ok(manifest)
|
||||
Ok((manifest, json_str.into_bytes()))
|
||||
}
|
||||
|
||||
/// Lädt das Manifest aus `manifest.json`.
|
||||
@@ -640,7 +697,15 @@ fn load_manifest_from_file(path: &Path) -> Result<NotebookManifest, StorageError
|
||||
/// `<base>/<id>`. A copied or renamed folder still carries the id of the original in its
|
||||
/// manifest, so the id is always taken from the folder.
|
||||
pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> {
|
||||
let mut manifest = load_manifest_or_backup(notebook_dir)?;
|
||||
load_manifest_as_base(notebook_dir).map(|(manifest, _)| manifest)
|
||||
}
|
||||
|
||||
/// Like [`load_manifest`], for a manifest that is taken over into the open notebook: call
|
||||
/// [`ManifestBase::adopt`] once that has happened.
|
||||
pub fn load_manifest_as_base(
|
||||
notebook_dir: &Path,
|
||||
) -> Result<(NotebookManifest, ManifestBase), StorageError> {
|
||||
let (mut manifest, bytes) = load_manifest_or_backup(notebook_dir)?;
|
||||
// Canonical, so that a path like `.` still yields the folder name
|
||||
let dir = notebook_dir.canonicalize();
|
||||
let name = dir
|
||||
@@ -653,10 +718,16 @@ pub fn load_manifest(notebook_dir: &Path) -> Result<NotebookManifest, StorageErr
|
||||
manifest.id = name.to_string();
|
||||
}
|
||||
}
|
||||
Ok(manifest)
|
||||
let base = ManifestBase {
|
||||
key: seen_key(¬ebook_dir.join("manifest.json")),
|
||||
bytes,
|
||||
};
|
||||
Ok((manifest, base))
|
||||
}
|
||||
|
||||
fn load_manifest_or_backup(notebook_dir: &Path) -> Result<NotebookManifest, StorageError> {
|
||||
fn load_manifest_or_backup(
|
||||
notebook_dir: &Path,
|
||||
) -> Result<(NotebookManifest, Vec<u8>), StorageError> {
|
||||
let path = notebook_dir.join("manifest.json");
|
||||
if !path.exists() {
|
||||
let bak_path = notebook_dir.join("manifest.json.bak");
|
||||
@@ -871,7 +942,8 @@ pub fn save_notebook(
|
||||
/// 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> {
|
||||
let _ = cleanup_orphaned_temp_files(notebook_dir, std::time::Duration::from_secs(3600));
|
||||
let manifest = load_manifest(notebook_dir)?;
|
||||
let (manifest, base) = load_manifest_as_base(notebook_dir)?;
|
||||
base.adopt();
|
||||
let page_ids = manifest.page_ids();
|
||||
if page_ids.is_empty() {
|
||||
let nb = Notebook::new();
|
||||
|
||||
Reference in new issue
Block a user