From 25b40ae9d1b57d1f26b98dd878c04333a2a77106 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:52:24 +0000 Subject: [PATCH 01/44] fix: keep settings storage stats inside the card on small phones The four stat chips sat in a plain Row and overflowed by 4-45px on a 320-360px wide phone, which also made settings_overflow_test fail. --- app/lib/screens/settings_screen.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index 648b228..966710b 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -794,7 +794,12 @@ class _StatChip extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( + // Four chips share one row; on a 320px phone (or with large counts) they + // shrink to fit instead of overflowing the card. + return Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( children: [ Text(value, style: const TextStyle( @@ -809,6 +814,8 @@ class _StatChip extends StatelessWidget { .onSurface .withOpacity(0.5))), ], + ), + ), ); } } From 0798f5a18f7fbafb6df1c99cb7b685433b415be8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:52:28 +0000 Subject: [PATCH 02/44] fix: build new note lists instead of mutating the API client's AppState inserted into and removed from whatever list the client handed back. With an unmodifiable list (as returned by the test backend stub) creating a note threw "Cannot add to an unmodifiable list", which made the local-to-backend app_state test fail. --- app/lib/services/app_state.dart | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index 84c9b8e..3efd026 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -431,14 +431,14 @@ class AppState extends ChangeNotifier { await _ensureLocalReady(); final nb = await _localService.createNotebook(name, color: color); final apiNb = _toApiNotebook(nb); - _notebooks.insert(0, apiNb); + _notebooks = [apiNb, ..._notebooks]; _hasLocalData = true; notifyListeners(); return apiNb; } try { final nb = await client.createNotebook(name: name, color: color); - _notebooks.insert(0, nb); + _notebooks = [nb, ..._notebooks]; _markBackendAvailable(); notifyListeners(); return nb; @@ -452,14 +452,14 @@ class AppState extends ChangeNotifier { if (_localMode) { await _ensureLocalReady(); await _localService.hardDeleteNotebook(id); - _notebooks.removeWhere((n) => n.id == id); + _notebooks = [..._notebooks.where((n) => n.id != id)]; if (_selectedNotebook?.id == id) { _selectedNotebook = null; _notes = []; } notifyListeners(); return; } try { await client.deleteNotebook(id); - _notebooks.removeWhere((n) => n.id == id); + _notebooks = [..._notebooks.where((n) => n.id != id)]; if (_selectedNotebook?.id == id) { _selectedNotebook = null; _notes = []; } _markBackendAvailable(); notifyListeners(); @@ -503,7 +503,7 @@ class AppState extends ChangeNotifier { noteType: noteType, ); final apiNote = _toApiNote(note); - _notes.insert(0, apiNote); + _notes = [apiNote, ..._notes]; _hasLocalData = true; notifyListeners(); return apiNote; @@ -512,7 +512,7 @@ class AppState extends ChangeNotifier { final note = await client.createNote( title: title, noteType: noteType, notebookId: _selectedNotebook?.id, template: template); - _notes.insert(0, note); + _notes = [note, ..._notes]; _markBackendAvailable(); notifyListeners(); return note; @@ -543,14 +543,14 @@ class AppState extends ChangeNotifier { if (_localMode) { await _ensureLocalReady(); await _localService.deleteNote(id); - _notes.removeWhere((n) => n.id == id); + _notes = [..._notes.where((n) => n.id != id)]; if (_selectedNote?.id == id) _selectedNote = null; notifyListeners(); return; } try { await client.deleteNote(id); - _notes.removeWhere((n) => n.id == id); + _notes = [..._notes.where((n) => n.id != id)]; if (_selectedNote?.id == id) _selectedNote = null; _markBackendAvailable(); notifyListeners(); From 772ec4993328dc3d68fa0ea2fbde47a99719f908 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:53:08 +0000 Subject: [PATCH 03/44] fix: stop editor autosave from losing or reordering edits Typing while a save was in flight was never saved: the finished save cleared the unsaved flag, so the next debounce returned early and the header showed "Saved". Saves also ran concurrently, so with a slow backend an older save could land last and put old text back. Text saves are now queued one at a time and only clear the flag when nothing was typed meanwhile. Ink saves are coalesced through a single writer for the same reason. Locally, title, text and ink saves each did read-modify-write on the whole note row, so overlapping saves wrote back stale copies of each other (text erased, rename reverted). They now update only their own column. The editor persistence test also never ran to completion: it awaited real sqflite I/O inside the widget test's fake clock and deadlocked. It now goes through tester.runAsync. --- .../data/repositories/note_repository.dart | 26 ++++ app/lib/screens/note_editor_screen.dart | 117 ++++++++++----- app/lib/services/app_state.dart | 26 +--- app/lib/services/local_note_service.dart | 8 + .../note_editor_autosave_race_test.dart | 137 ++++++++++++++++++ .../screens/note_editor_persistence_test.dart | 59 +++++--- .../services/offline_persistence_test.dart | 30 ++++ 7 files changed, 322 insertions(+), 81 deletions(-) create mode 100644 app/test/screens/note_editor_autosave_race_test.dart diff --git a/app/lib/data/repositories/note_repository.dart b/app/lib/data/repositories/note_repository.dart index 7778d6c..c12d5cd 100644 --- a/app/lib/data/repositories/note_repository.dart +++ b/app/lib/data/repositories/note_repository.dart @@ -161,6 +161,32 @@ class NoteRepository { ); } + /// Records a local edit to note [id]: writes only the given [fields], + /// bumps `updated_at` and flags a synced note as `modified`. + /// + /// A single UPDATE instead of read-modify-write via [upsertNote]: the editor + /// saves title, text and ink independently, and a full-row upsert built from + /// an earlier read would put back stale copies of the other fields. + Future updateNoteFields( + String id, [ + Map fields = const {}, + ]) async { + final assignments = [ + for (final column in fields.keys) '$column = ?', + 'updated_at = ?', + "sync_status = CASE sync_status WHEN 'synced' THEN 'modified' " + 'ELSE sync_status END', + ]; + await _db.rawUpdate( + 'UPDATE notes SET ${assignments.join(', ')} WHERE id = ?', + [ + ...fields.values, + DateTime.now().toUtc().toIso8601String(), + id, + ], + ); + } + /// Inserts [notebook] or replaces an existing row with the same id. Future upsertNotebook(Notebook notebook) async { await _db.insert( diff --git a/app/lib/screens/note_editor_screen.dart b/app/lib/screens/note_editor_screen.dart index c9455a4..574dfcd 100644 --- a/app/lib/screens/note_editor_screen.dart +++ b/app/lib/screens/note_editor_screen.dart @@ -19,6 +19,20 @@ class _NoteEditorScreenState extends State { bool _isSaving = false; bool _hasChanges = false; bool _inkSaveFailed = false; + + /// Bumped on every edit so a save knows whether the user typed while it ran. + int _editGeneration = 0; + + /// Tail of the queue of text saves; see [_persist]. + Future _textSaves = Future.value(true); + + /// What storage holds as far as this editor knows, so a save only writes + /// the fields that changed. + late String _savedTitle; + late String _savedContent; + + List>? _pendingInk; + bool _inkSaveRunning = false; late Note _note; bool _showInk = false; @@ -48,6 +62,8 @@ class _NoteEditorScreenState extends State { _inkStrokes = _note.pages?.isNotEmpty == true ? _note.pages!.first.strokes.whereType>().toList() : >[]; + _savedTitle = _titleCtrl.text; + _savedContent = _contentCtrl.text; _titleCtrl.addListener(_onChanged); _contentCtrl.addListener(_onChanged); } @@ -64,6 +80,7 @@ class _NoteEditorScreenState extends State { } void _onChanged() { + _editGeneration++; if (!_hasChanges) setState(() => _hasChanges = true); _saveTimer?.cancel(); _saveTimer = Timer(const Duration(seconds: 2), _save); @@ -72,31 +89,49 @@ class _NoteEditorScreenState extends State { Future _save() async { if (!_hasChanges) return; if (mounted) setState(() => _isSaving = true); - final ok = await _persist(); - if (!mounted) return; - setState(() { - _isSaving = false; - if (ok) _hasChanges = false; - }); + final save = _persist(); + await save; + // A newer save may have been queued meanwhile; it owns the indicator now. + if (!mounted || !identical(save, _textSaves)) return; + setState(() => _isSaving = false); } - /// Writes the title and body to local/remote storage. + /// Queues a save of the current title and body behind any save already in + /// flight, so saves reach storage one at a time and in edit order. Without + /// that, a slow save could land after a newer one and put old text back. /// /// This deliberately saves typed text even while the Draw canvas is visible. /// Without that, a user could type, immediately switch to Draw before the /// debounce fires, and have the final text skipped because `_showInk` became - /// true. The method is free of `setState`/`context` use so [dispose] and mode - /// changes can call it safely to flush the last edits. - Future _persist() async { - if (!_hasChanges) return true; + /// true. Free of `context` use so [dispose] can call it to flush the last + /// edits after unmount. + Future _persist() { + final title = _titleCtrl.text; + final content = _contentCtrl.text; + final previous = _textSaves; + final save = previous.then((_) => _persistSnapshot(title, content)); + _textSaves = save; + return save; + } + + Future _persistSnapshot(String title, String content) async { final state = _appState; if (state == null) return false; + // Edits made after this point are newer than the snapshot and keep the + // note marked unsaved. + final generation = _editGeneration; try { - if (_titleCtrl.text != _note.title) { - await state.updateNoteTitle(_note.id, _titleCtrl.text); - _note = _note.copyWith(title: _titleCtrl.text); + if (title != _savedTitle) { + await state.updateNoteTitle(_note.id, title); + _savedTitle = title; + } + if (content != _savedContent) { + await state.savePageText(_note.id, 1, content); + _savedContent = content; + } + if (generation == _editGeneration && mounted) { + setState(() => _hasChanges = false); } - await state.savePageText(_note.id, 1, _contentCtrl.text); return true; } catch (e) { if (mounted) { @@ -110,13 +145,7 @@ class _NoteEditorScreenState extends State { Future _flushTextBeforeModeSwitch() async { _saveTimer?.cancel(); if (!_hasChanges) return; - if (mounted) setState(() => _isSaving = true); - final ok = await _persist(); - if (!mounted) return; - setState(() { - _isSaving = false; - if (ok) _hasChanges = false; - }); + await _save(); } Future _switchToText() async { @@ -135,31 +164,45 @@ class _NoteEditorScreenState extends State { // Keep the on-screen drawing in memory first so a failed persist (or a // mode toggle) never drops what the user just drew. _inkStrokes = strokes; + _pendingInk = strokes; + // Each save sends the whole drawing, so only the newest one matters. One + // writer drains it: concurrent saves could otherwise land out of order + // and replace the drawing with an older copy missing the last strokes. + if (_inkSaveRunning) return; final state = _appState; if (state == null) return; + _inkSaveRunning = true; try { - await state.savePageInk(_note.id, 1, strokes); - _inkSaveFailed = false; - } catch (e) { - debugPrint('Ink save error: $e'); - // Surface a friendly, throttled error rather than silently dropping the - // stroke. The drawing stays on screen regardless. - if (mounted && !_inkSaveFailed) { - _inkSaveFailed = true; - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text( - 'Could not save drawing — it stays on screen, retrying as you draw.'), - backgroundColor: Colors.red)); + while (_pendingInk != null) { + final next = _pendingInk!; + _pendingInk = null; + try { + await state.savePageInk(_note.id, 1, next); + _inkSaveFailed = false; + } catch (e) { + debugPrint('Ink save error: $e'); + // Surface a friendly, throttled error rather than silently dropping + // the stroke. The drawing stays on screen regardless. + if (mounted && !_inkSaveFailed) { + _inkSaveFailed = true; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text( + 'Could not save drawing — it stays on screen, retrying as you draw.'), + backgroundColor: Colors.red)); + } + } } + } finally { + _inkSaveRunning = false; } } @override void dispose() { _saveTimer?.cancel(); - // Flush any pending text edits synchronously-scheduled before teardown. - // _persist captures its AppState reference, so this is safe post-unmount. - _persist(); + // Flush pending text edits. The save is queued behind any in-flight one + // and only uses the captured AppState, so it is safe after unmount. + if (_hasChanges) _persist(); _titleCtrl.dispose(); _contentCtrl.dispose(); super.dispose(); diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index 3efd026..9181e19 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -563,14 +563,7 @@ class AppState extends ChangeNotifier { Future updateNoteTitle(String id, String title) async { if (_localMode) { await _ensureLocalReady(); - final n = await _localService.getNoteById(id); - if (n != null) { - await _localService.upsertNote(n.copyWith( - title: title, - syncStatus: n.syncStatus == 'synced' ? 'modified' : n.syncStatus, - updatedAt: DateTime.now().toUtc(), - )); - } + await _localService.updateNoteTitle(id, title); await loadNotes(notebookId: _selectedNotebook?.id); return; } @@ -587,14 +580,7 @@ class AppState extends ChangeNotifier { Future savePageText(String noteId, int pageNum, String content) async { if (_localMode) { await _ensureLocalReady(); - final n = await _localService.getNoteById(noteId); - if (n != null) { - await _localService.upsertNote(n.copyWith( - typedContent: content, - syncStatus: n.syncStatus == 'synced' ? 'modified' : n.syncStatus, - updatedAt: DateTime.now().toUtc(), - )); - } + await _localService.updateNoteContent(noteId, content); return; } try { @@ -636,13 +622,7 @@ class AppState extends ChangeNotifier { await _localService.replaceStrokesForNote(noteId, localStrokes); // Mark the note modified so a future sync uploads the drawing. The // note's content/type is untouched — only the sync bookkeeping changes. - final n = await _localService.getNoteById(noteId); - if (n != null) { - await _localService.upsertNote(n.copyWith( - syncStatus: n.syncStatus == 'synced' ? 'modified' : n.syncStatus, - updatedAt: DateTime.now().toUtc(), - )); - } + await _localService.markNoteModified(noteId); return; } try { diff --git a/app/lib/services/local_note_service.dart b/app/lib/services/local_note_service.dart index 832396c..1780792 100644 --- a/app/lib/services/local_note_service.dart +++ b/app/lib/services/local_note_service.dart @@ -58,6 +58,14 @@ class LocalNoteService { _repo.getNoteByRemoteId(remoteId); Future upsertNote(Note note) => _repo.upsertNote(note); + /// Local edits. Each writes only its own column (plus the sync bookkeeping) + /// so concurrent title, text and ink saves can't overwrite each other. + Future updateNoteTitle(String id, String title) => + _repo.updateNoteFields(id, {'title': title}); + Future updateNoteContent(String id, String typedContent) => + _repo.updateNoteFields(id, {'typed_content': typedContent}); + Future markNoteModified(String id) => _repo.updateNoteFields(id); + /// Soft-deletes a note (sets `is_deleted`, marks it `modified`) so the /// deletion can later propagate once a backend is configured. Used by the /// local-mode delete path; sync uses [hardDeleteNote] for server-driven diff --git a/app/test/screens/note_editor_autosave_race_test.dart b/app/test/screens/note_editor_autosave_race_test.dart new file mode 100644 index 0000000..e7613af --- /dev/null +++ b/app/test/screens/note_editor_autosave_race_test.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:nexanote/screens/note_editor_screen.dart'; +import 'package:nexanote/services/api_client.dart' as api; +import 'package:nexanote/services/app_state.dart'; + +/// Backend whose text saves only complete when the test says so, so we can +/// type while a save is in flight and complete saves out of order. +class _SlowSaveApi extends api.ApiClient { + _SlowSaveApi() : super(baseUrl: 'http://slow.test'); + + final pending = >[]; + final started = []; + final landed = []; + + @override + Future savePageText(String noteId, int pageNum, String content) async { + started.add(content); + final gate = Completer(); + pending.add(gate); + await gate.future; + landed.add(content); + } +} + +api.Note _note() => api.Note( + id: 'n1', + title: 'Title', + noteType: 'typed', + tags: const [], + isPinned: false, + isDeleted: false, + pageCount: 1, + updatedAt: '', + createdAt: '', + pages: [ + api.NotePage( + pageNumber: 1, template: 'blank', typedContent: '', strokes: []), + ], + ); + +void main() { + late _SlowSaveApi backend; + late AppState state; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + backend = _SlowSaveApi(); + // Not in local mode: saves go through the (slow) backend. + state = AppState(clientFactory: (_) => backend); + }); + + Finder body() => find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Start writing...'); + + Future pumpEditor(WidgetTester tester) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(360, 740); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: state, + child: MaterialApp(home: NoteEditorScreen(note: _note())), + )); + } + + Future releaseAll(WidgetTester tester) async { + while (backend.pending.isNotEmpty) { + backend.pending.removeAt(0).complete(); + await tester.pump(); + } + } + + testWidgets('text typed while a save is in flight is saved afterwards', + (tester) async { + await pumpEditor(tester); + + await tester.enterText(body(), 'first'); + await tester.pump(const Duration(seconds: 2)); // debounce fires + expect(backend.started, ['first']); + + // Keep typing while the first save is still waiting on the network. + await tester.enterText(body(), 'first second'); + await tester.pump(); + + await releaseAll(tester); + await tester.pump(const Duration(seconds: 2)); + await releaseAll(tester); + await tester.pump(const Duration(seconds: 2)); + await releaseAll(tester); + + expect(backend.landed.last, 'first second'); + expect(find.byTooltip('Unsaved'), findsNothing); + expect(find.byTooltip('Saved'), findsOneWidget); + }); + + testWidgets('an older save never lands after a newer one', (tester) async { + await pumpEditor(tester); + + await tester.enterText(body(), 'old'); + await tester.pump(const Duration(seconds: 2)); + await tester.enterText(body(), 'new'); + await tester.pump(const Duration(seconds: 2)); + + // Complete whatever is in flight newest-first, as a flaky network might. + while (backend.pending.isNotEmpty) { + backend.pending.removeLast().complete(); + await tester.pump(); + } + await tester.pump(const Duration(seconds: 2)); + await releaseAll(tester); + + expect(backend.landed.last, 'new'); + }); + + testWidgets('leaving the editor during a save still flushes the last edit', + (tester) async { + await pumpEditor(tester); + + await tester.enterText(body(), 'draft'); + await tester.pump(const Duration(seconds: 2)); + await tester.enterText(body(), 'draft final'); + await tester.pump(); + + // Leave before the in-flight save finishes. + await tester.pumpWidget(const SizedBox()); + await releaseAll(tester); + await releaseAll(tester); + + expect(backend.landed.last, 'draft final'); + }); +} diff --git a/app/test/screens/note_editor_persistence_test.dart b/app/test/screens/note_editor_persistence_test.dart index 869736c..8a9f918 100644 --- a/app/test/screens/note_editor_persistence_test.dart +++ b/app/test/screens/note_editor_persistence_test.dart @@ -57,8 +57,20 @@ void main() { await tester.pumpAndSettle(); } - /// Re-reads [getNoteById] until [predicate] holds or we run out of pumps — - /// deterministic without depending on exact save-future timing. + /// Real sqflite (FFI) I/O never completes inside the widget test's fake + /// clock, so every database read goes through [WidgetTester.runAsync], and + /// we let real time pass between pumps so the editor's own saves can finish. + Future io(WidgetTester tester, Future Function() body) async => + (await tester.runAsync(body)) as T; + + Future settle(WidgetTester tester) async { + for (var i = 0; i < 10; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 5))); + await tester.pump(const Duration(milliseconds: 20)); + } + } + Future pollContent( WidgetTester tester, String id, @@ -66,27 +78,34 @@ void main() { ) async { String? content; for (var i = 0; i < 25; i++) { - content = (await service.getNoteById(id))?.typedContent; + content = await io(tester, () async { + return (await service.getNoteById(id))?.typedContent; + }); if (predicate(content)) break; - await tester.pump(const Duration(milliseconds: 20)); + await settle(tester); } return content; } Future> pollStrokes(WidgetTester tester, String id) async { - var strokes = await service.getStrokesForNote(id); + var strokes = await io(tester, () => service.getStrokesForNote(id)); for (var i = 0; i < 25 && strokes.isEmpty; i++) { - await tester.pump(const Duration(milliseconds: 20)); - strokes = await service.getStrokesForNote(id); + await settle(tester); + strokes = await io(tester, () => service.getStrokesForNote(id)); } return strokes; } + Future newNote(WidgetTester tester, String title, String type) => + io(tester, () async { + final created = await state.createNote(title: title, noteType: type); + return state.getNote(created.id); + }); + testWidgets('typing then leaving the editor flushes the text to local storage', (tester) async { - final created = await state.createNote(title: 'Notes', noteType: 'typed'); - final note = await state.getNote(created.id); - await pumpEditor(tester, note); + final created = await newNote(tester, 'Notes', 'typed'); + await pumpEditor(tester, created); await tester.enterText( find.byWidgetPredicate((w) => @@ -97,7 +116,7 @@ void main() { // Leave the editor *before* the 2s debounce fires by tearing the widget // down. The dispose-time flush must still persist the edit. await tester.pumpWidget(const SizedBox()); - await tester.pumpAndSettle(); + await settle(tester); final content = await pollContent(tester, created.id, (c) => c == 'remember the milk'); @@ -106,9 +125,8 @@ void main() { testWidgets('debounced autosave persists typed text without leaving', (tester) async { - final created = await state.createNote(title: 'Notes', noteType: 'typed'); - final note = await state.getNote(created.id); - await pumpEditor(tester, note); + final created = await newNote(tester, 'Notes', 'typed'); + await pumpEditor(tester, created); await tester.enterText( find.byWidgetPredicate((w) => @@ -116,7 +134,7 @@ void main() { 'autosaved body'); // Let the 2s debounce timer fire. await tester.pump(const Duration(seconds: 2)); - await tester.pumpAndSettle(); + await settle(tester); final content = await pollContent(tester, created.id, (c) => c == 'autosaved body'); @@ -125,17 +143,15 @@ void main() { testWidgets('drawing a stroke persists locally and survives a Text/Draw toggle', (tester) async { - final created = - await state.createNote(title: 'Sketch', noteType: 'handwritten'); - final note = await state.getNote(created.id); - await pumpEditor(tester, note); + final created = await newNote(tester, 'Sketch', 'handwritten'); + await pumpEditor(tester, created); // Handwritten notes open straight into the ink canvas. expect(find.byType(InkCanvas), findsOneWidget); // Draw a stroke across the canvas. await tester.drag(find.byType(InkCanvas), const Offset(40, 40)); - await tester.pumpAndSettle(); + await settle(tester); expect(await pollStrokes(tester, created.id), isNotEmpty); @@ -151,6 +167,7 @@ void main() { expect(canvas.initialStrokes, isNotEmpty); // And the drawing is still on disk after the round-trip. - expect(await service.getStrokesForNote(created.id), isNotEmpty); + expect(await io(tester, () => service.getStrokesForNote(created.id)), + isNotEmpty); }); } diff --git a/app/test/services/offline_persistence_test.dart b/app/test/services/offline_persistence_test.dart index caa641c..5854f4f 100644 --- a/app/test/services/offline_persistence_test.dart +++ b/app/test/services/offline_persistence_test.dart @@ -121,4 +121,34 @@ void main() { final after = await service.getNoteById(note.id); expect(after!.syncStatus, 'modified'); }); + + test('concurrent title, text and ink saves never clobber each other', + () async { + final created = await state.createNote(title: 'Old', noteType: 'mixed'); + + // The editor can have a debounced text save, a title rename and an ink + // save in flight at the same time. Each one must only touch its own + // field, so none of them writes back a stale copy of the others. + await Future.wait([ + state.savePageText(created.id, 1, 'typed body'), + state.updateNoteTitle(created.id, 'New title'), + state.savePageInk(created.id, 1, sampleStrokes()), + ]); + + final reopened = await state.getNote(created.id); + expect(reopened.title, 'New title'); + expect(reopened.pages!.first.typedContent, 'typed body'); + expect(reopened.pages!.first.strokes, hasLength(2)); + }); + + test('a rename racing an ink save keeps the new title', () async { + final created = await state.createNote(title: 'Old', noteType: 'mixed'); + + await Future.wait([ + state.savePageInk(created.id, 1, sampleStrokes()), + state.updateNoteTitle(created.id, 'Renamed'), + ]); + + expect((await service.getNoteById(created.id))!.title, 'Renamed'); + }); } From 6fb0cef79a2c670878cd63e2f81554432f6ad1a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:53:37 +0000 Subject: [PATCH 04/44] fix: fail API writes on HTTP errors and time out stalled requests savePageText, savePageInk, deleteNote, deleteNotebook, restoreNote and configureSync ignored the response status, so a 404/413/500 from the backend (or an HTML error page from a reverse proxy) was reported as a successful save and the editor showed "Saved" for text that never reached the server. Every call now checks its expected status. Only ping had a timeout. A backend that accepts the connection and never answers left saves spinning forever and the sync button disabled. Normal requests now time out after 20s, /sync/trigger after 5 minutes. Settings now reports a rejected sync configuration instead of an unhandled exception, and does not start a sync with the old settings. --- app/lib/screens/settings_screen.dart | 26 ++- app/lib/services/api_client.dart | 245 ++++++++++++++++--------- app/test/services/api_client_test.dart | 60 ++++++ 3 files changed, 236 insertions(+), 95 deletions(-) create mode 100644 app/test/services/api_client_test.dart diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index 966710b..f919307 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -146,7 +146,8 @@ class _SettingsScreenState extends State { } catch (_) {} } - Future _saveSettings() async { + /// Returns false when the backend refused or never got the sync settings. + Future _saveSettings() async { final prefs = await SharedPreferences.getInstance(); await prefs.setString('nas_url', _nasUrlCtrl.text.trim()); await prefs.setString('nas_user', _nasUserCtrl.text.trim()); @@ -158,21 +159,32 @@ class _SettingsScreenState extends State { // values locally so they're ready once the user connects one. final state = context.read(); if (state.isBackendConfigured) { - await state.client.configureSync( - serverUrl: _nasUrlCtrl.text.trim(), - username: _nasUserCtrl.text.trim(), - password: _nasPassCtrl.text, - ); + try { + await state.client.configureSync( + serverUrl: _nasUrlCtrl.text.trim(), + username: _nasUserCtrl.text.trim(), + password: _nasPassCtrl.text, + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Could not send sync settings to the backend: $e'), + backgroundColor: Colors.red)); + } + return false; + } } + if (!mounted) return true; setState(() => _isSaved = true); Future.delayed(const Duration(seconds: 2), () { if (mounted) setState(() => _isSaved = false); }); + return true; } Future _triggerSync() async { - await _saveSettings(); + if (!await _saveSettings() || !mounted) return; setState(() { _isSyncing = true; _syncMessage = null; diff --git a/app/lib/services/api_client.dart b/app/lib/services/api_client.dart index beb33ed..1091fe0 100644 --- a/app/lib/services/api_client.dart +++ b/app/lib/services/api_client.dart @@ -1,6 +1,7 @@ // lib/services/api_client.dart // Parle avec le backend Python (API REST sur port 8766) +import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; @@ -125,10 +126,56 @@ class NotePage { ); } +/// A backend call that did not succeed: unexpected HTTP status, or a request +/// that took longer than its timeout. +class ApiException implements Exception { + final String message; + final int? statusCode; + const ApiException(this.message, {this.statusCode}); + + @override + String toString() => + statusCode == null ? message : '$message (HTTP $statusCode)'; +} + class ApiClient { final String baseUrl; + final http.Client? _httpClient; + + /// Upper bound for ordinary requests. Without one, a server that accepts + /// the connection and never answers leaves a save "Saving…" forever. + static const Duration requestTimeout = Duration(seconds: 20); + + /// `/sync/trigger` runs a whole WebDAV sync before answering. + static const Duration syncTimeout = Duration(minutes: 5); + + ApiClient({required this.baseUrl, http.Client? httpClient}) + : _httpClient = httpClient; + + Future _send( + String what, + Future Function(http.Client client) request, { + Duration timeout = requestTimeout, + }) async { + final injected = _httpClient; + final client = injected ?? http.Client(); + try { + return await request(client).timeout(timeout); + } on TimeoutException { + throw ApiException('$what timed out after ${timeout.inSeconds}s'); + } finally { + if (injected == null) client.close(); + } + } - ApiClient({required this.baseUrl}); + /// Throws unless [resp] has one of the [expected] status codes. Write calls + /// must not report success when the server refused the change. + static void _expect(http.Response resp, String what, + [List expected = const [200]]) { + if (!expected.contains(resp.statusCode)) { + throw ApiException(what, statusCode: resp.statusCode); + } + } Map get _headers => { 'Content-Type': 'application/json', @@ -141,9 +188,9 @@ class ApiClient { Future ping() async { try { - final resp = await http - .get(Uri.parse('$baseUrl/health')) - .timeout(const Duration(seconds: 5)); + final resp = await _send('Ping', + (c) => c.get(Uri.parse('$baseUrl/health')), + timeout: const Duration(seconds: 5)); return resp.statusCode == 200; } catch (_) { return false; @@ -155,34 +202,35 @@ class ApiClient { // ---------------------------------------------------------------- Future> getNotebooks() async { - final resp = await http.get( - Uri.parse('$baseUrl/notebooks'), - headers: _headers, - ); - if (resp.statusCode == 200) { - final List data = jsonDecode(resp.body); - return data.map((j) => Notebook.fromJson(j)).toList(); - } - throw Exception('Failed to load notebooks: ${resp.statusCode}'); + const what = 'Failed to load notebooks'; + final resp = await _send( + what, (c) => c.get(Uri.parse('$baseUrl/notebooks'), headers: _headers)); + _expect(resp, what); + final List data = jsonDecode(resp.body); + return data.map((j) => Notebook.fromJson(j)).toList(); } Future createNotebook({ required String name, String color = '#6366f1', }) async { - final resp = await http.post( - Uri.parse('$baseUrl/notebooks'), - headers: _headers, - body: jsonEncode({'name': name, 'color': color}), - ); - if (resp.statusCode == 201) { - return Notebook.fromJson(jsonDecode(resp.body)); - } - throw Exception('Failed to create notebook'); + const what = 'Failed to create notebook'; + final resp = await _send( + what, + (c) => c.post( + Uri.parse('$baseUrl/notebooks'), + headers: _headers, + body: jsonEncode({'name': name, 'color': color}), + )); + _expect(resp, what, const [201]); + return Notebook.fromJson(jsonDecode(resp.body)); } Future deleteNotebook(String id) async { - await http.delete(Uri.parse('$baseUrl/notebooks/$id')); + const what = 'Failed to delete notebook'; + final resp = await _send( + what, (c) => c.delete(Uri.parse('$baseUrl/notebooks/$id'))); + _expect(resp, what, const [200, 204]); } // ---------------------------------------------------------------- @@ -199,24 +247,22 @@ class ApiClient { if (search != null && search.isNotEmpty) params['q'] = search; if (includeDeleted) params['include_deleted'] = 'true'; + const what = 'Failed to load notes'; final uri = Uri.parse('$baseUrl/notes').replace(queryParameters: params); - final resp = await http.get(uri, headers: _headers); - if (resp.statusCode == 200) { - final List data = jsonDecode(resp.body); - return data.map((j) => Note.fromJson(j)).toList(); - } - throw Exception('Failed to load notes'); + final resp = await _send(what, (c) => c.get(uri, headers: _headers)); + _expect(resp, what); + final List data = jsonDecode(resp.body); + return data.map((j) => Note.fromJson(j)).toList(); } Future getNote(String id) async { - final resp = await http.get( - Uri.parse('$baseUrl/notes/$id?pages=true'), - headers: _headers, - ); - if (resp.statusCode == 200) { - return Note.fromJson(jsonDecode(resp.body)); - } - throw Exception('Failed to load note'); + const what = 'Failed to load note'; + final resp = await _send( + what, + (c) => + c.get(Uri.parse('$baseUrl/notes/$id?pages=true'), headers: _headers)); + _expect(resp, what); + return Note.fromJson(jsonDecode(resp.body)); } Future createNote({ @@ -225,20 +271,21 @@ class ApiClient { String? notebookId, String template = 'blank', }) async { - final resp = await http.post( - Uri.parse('$baseUrl/notes'), - headers: _headers, - body: jsonEncode({ - 'title': title, - 'note_type': noteType, - if (notebookId != null) 'notebook_id': notebookId, - 'template': template, - }), - ); - if (resp.statusCode == 201) { - return Note.fromJson(jsonDecode(resp.body)); - } - throw Exception('Failed to create note'); + const what = 'Failed to create note'; + final resp = await _send( + what, + (c) => c.post( + Uri.parse('$baseUrl/notes'), + headers: _headers, + body: jsonEncode({ + 'title': title, + 'note_type': noteType, + if (notebookId != null) 'notebook_id': notebookId, + 'template': template, + }), + )); + _expect(resp, what, const [201]); + return Note.fromJson(jsonDecode(resp.body)); } Future updateNote( @@ -252,23 +299,30 @@ class ApiClient { if (isPinned != null) body['is_pinned'] = isPinned; if (tags != null) body['tags'] = tags; - final resp = await http.put( - Uri.parse('$baseUrl/notes/$id'), - headers: _headers, - body: jsonEncode(body), - ); - if (resp.statusCode == 200) { - return Note.fromJson(jsonDecode(resp.body)); - } - throw Exception('Failed to update note'); + const what = 'Failed to update note'; + final resp = await _send( + what, + (c) => c.put( + Uri.parse('$baseUrl/notes/$id'), + headers: _headers, + body: jsonEncode(body), + )); + _expect(resp, what); + return Note.fromJson(jsonDecode(resp.body)); } Future deleteNote(String id) async { - await http.delete(Uri.parse('$baseUrl/notes/$id')); + const what = 'Failed to delete note'; + final resp = + await _send(what, (c) => c.delete(Uri.parse('$baseUrl/notes/$id'))); + _expect(resp, what, const [200, 204]); } Future restoreNote(String id) async { - await http.post(Uri.parse('$baseUrl/notes/$id/restore')); + const what = 'Failed to restore note'; + final resp = await _send( + what, (c) => c.post(Uri.parse('$baseUrl/notes/$id/restore'))); + _expect(resp, what); } // ---------------------------------------------------------------- @@ -276,20 +330,28 @@ class ApiClient { // ---------------------------------------------------------------- Future savePageText(String noteId, int pageNum, String content) async { - await http.put( - Uri.parse('$baseUrl/notes/$noteId/pages/$pageNum/text'), - headers: _headers, - body: jsonEncode({'typed_content': content}), - ); + const what = 'Failed to save text'; + final resp = await _send( + what, + (c) => c.put( + Uri.parse('$baseUrl/notes/$noteId/pages/$pageNum/text'), + headers: _headers, + body: jsonEncode({'typed_content': content}), + )); + _expect(resp, what); } Future savePageInk( String noteId, int pageNum, List> strokes) async { - await http.put( - Uri.parse('$baseUrl/notes/$noteId/pages/$pageNum/ink'), - headers: _headers, - body: jsonEncode({'strokes': strokes}), - ); + const what = 'Failed to save drawing'; + final resp = await _send( + what, + (c) => c.put( + Uri.parse('$baseUrl/notes/$noteId/pages/$pageNum/ink'), + headers: _headers, + body: jsonEncode({'strokes': strokes}), + )); + _expect(resp, what); } // ---------------------------------------------------------------- @@ -297,10 +359,10 @@ class ApiClient { // ---------------------------------------------------------------- Future> triggerSync() async { - final resp = await http.post( - Uri.parse('$baseUrl/sync/trigger'), - headers: _headers, - ); + final resp = await _send( + 'Sync', + (c) => c.post(Uri.parse('$baseUrl/sync/trigger'), headers: _headers), + timeout: syncTimeout); if (resp.statusCode == 200) { return jsonDecode(resp.body); } @@ -308,7 +370,8 @@ class ApiClient { } Future> getSyncStatus() async { - final resp = await http.get(Uri.parse('$baseUrl/sync/status')); + final resp = await _send( + 'Sync status', (c) => c.get(Uri.parse('$baseUrl/sync/status'))); if (resp.statusCode == 200) return jsonDecode(resp.body); return {'status': 'unknown'}; } @@ -318,15 +381,19 @@ class ApiClient { required String username, required String password, }) async { - await http.post( - Uri.parse('$baseUrl/sync/configure'), - headers: _headers, - body: jsonEncode({ - 'server_url': serverUrl, - 'username': username, - 'password': password, - }), - ); + const what = 'Failed to configure sync'; + final resp = await _send( + what, + (c) => c.post( + Uri.parse('$baseUrl/sync/configure'), + headers: _headers, + body: jsonEncode({ + 'server_url': serverUrl, + 'username': username, + 'password': password, + }), + )); + _expect(resp, what); } // ---------------------------------------------------------------- @@ -334,13 +401,15 @@ class ApiClient { // ---------------------------------------------------------------- Future> getStats() async { - final resp = await http.get(Uri.parse('$baseUrl/stats')); + final resp = + await _send('Stats', (c) => c.get(Uri.parse('$baseUrl/stats'))); if (resp.statusCode == 200) return jsonDecode(resp.body); return {}; } Future> getStorageInfo() async { - final resp = await http.get(Uri.parse('$baseUrl/storage')); + final resp = + await _send('Storage info', (c) => c.get(Uri.parse('$baseUrl/storage'))); if (resp.statusCode == 200) return jsonDecode(resp.body); return {}; } diff --git a/app/test/services/api_client_test.dart b/app/test/services/api_client_test.dart new file mode 100644 index 0000000..5589c28 --- /dev/null +++ b/app/test/services/api_client_test.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:nexanote/services/api_client.dart'; + +void main() { + ApiClient clientReturning(int status, [String body = '']) => ApiClient( + baseUrl: 'http://backend.test', + httpClient: MockClient((_) async => http.Response(body, status)), + ); + + group('writes fail loudly when the server refuses them', () { + for (final status in [400, 404, 413, 500, 502]) { + test('savePageText throws on HTTP $status', () async { + await expectLater(clientReturning(status).savePageText('n', 1, 'x'), + throwsA(isA())); + }); + + test('savePageInk throws on HTTP $status', () async { + await expectLater(clientReturning(status).savePageInk('n', 1, const []), + throwsA(isA())); + }); + + test('deleteNote throws on HTTP $status', () async { + await expectLater(clientReturning(status).deleteNote('n'), + throwsA(isA())); + }); + + test('deleteNotebook throws on HTTP $status', () async { + await expectLater(clientReturning(status).deleteNotebook('nb'), + throwsA(isA())); + }); + } + }); + + test('successful writes complete', () async { + await clientReturning(200, '{}').savePageText('n', 1, 'x'); + await clientReturning(200, '{}').savePageInk('n', 1, const []); + await clientReturning(204).deleteNote('n'); + await clientReturning(204).deleteNotebook('nb'); + }); + + testWidgets('a backend that never answers times out instead of hanging', + (tester) async { + final client = ApiClient( + baseUrl: 'http://backend.test', + httpClient: MockClient((_) => Completer().future), + ); + Object? error; + client.savePageText('n', 1, 'x').catchError((Object e) => error = e); + + await tester.pump(ApiClient.requestTimeout - const Duration(seconds: 1)); + expect(error, isNull); + await tester.pump(const Duration(seconds: 2)); + expect(error, isA()); + }); +} From f309b8046de36c7f1d879c458a264cf7cff84f57 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:54:08 +0000 Subject: [PATCH 05/44] fix: keep home screen alive during list reloads and open the picked note The app shell showed the startup splash whenever AppState.isLoading was true, and every notes reload (search keystroke, notebook switch, rename) sets it. HomeScreen was torn down each time: the search field lost its text and the open editor was disposed and rebuilt from the note as it was when first opened. The splash now only covers init(). On the wide layout the editor had no key, so selecting another note kept the old editor State: the panel kept showing the first note. Reloads and note opening also ignore stale results now, so slow search results for "gr" can't replace those for "groc", and a slow load can't override a newer selection. The active search query is kept across reloads, and a note that fails to open shows an error instead of the tap doing nothing. --- app/lib/main.dart | 2 +- app/lib/screens/home_screen.dart | 42 +++++--- app/lib/services/app_state.dart | 57 ++++++++++- app/test/screens/home_screen_test.dart | 135 +++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 19 deletions(-) create mode 100644 app/test/screens/home_screen_test.dart diff --git a/app/lib/main.dart b/app/lib/main.dart index 86ccb8c..d7c3516 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -28,7 +28,7 @@ class NexaNoteApp extends StatelessWidget { themeMode: ThemeMode.system, home: Consumer( builder: (context, state, _) { - if (state.isLoading) { + if (state.isStarting) { return const _SplashScreen(); } // First launch only: offer "Use offline" or "Connect to a server". diff --git a/app/lib/screens/home_screen.dart b/app/lib/screens/home_screen.dart index 4cb863e..a0a3e96 100644 --- a/app/lib/screens/home_screen.dart +++ b/app/lib/screens/home_screen.dart @@ -59,16 +59,17 @@ class _DesktopLayout extends StatelessWidget { notes: state.notes, selected: state.selectedNote, isLoading: state.isLoading, - onSelect: (note) async { - final full = await state.getNote(note.id); - state.selectNote(full); - }, + onSelect: (note) => _openNote(context, note.id), onDelete: (note) => state.deleteNote(note.id), )), ])), VerticalDivider(width: 1, color: scheme.outlineVariant), Expanded(child: state.selectedNote != null - ? NoteEditorScreen(note: state.selectedNote!) + // Keyed by note id: without it Flutter reuses the editor State + // and keeps showing (and saving to) the previously opened note. + ? NoteEditorScreen( + key: ValueKey(state.selectedNote!.id), + note: state.selectedNote!) : _EmptyEditor()), ])), ]), @@ -85,8 +86,7 @@ class _DesktopLayout extends StatelessWidget { Future _createNote(BuildContext context) async { final state = context.read(); final note = await state.createNote(title: 'Untitled', noteType: 'typed'); - final full = await state.getNote(note.id); - state.selectNote(full); + if (context.mounted) await _openNote(context, note.id); } Future _sync(BuildContext context) async { @@ -96,6 +96,23 @@ class _DesktopLayout extends StatelessWidget { } } +/// Opens note [id] and returns it, or null when it is stale (another note was +/// picked meanwhile) or could not be loaded; the error is shown to the user +/// instead of the tap silently doing nothing. +Future _openNote(BuildContext context, String id) async { + try { + return await context.read().openNote(id); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Could not open note: $e'), + backgroundColor: Colors.red.shade700, + )); + } + return null; + } +} + void _showSyncResult(BuildContext context, AppState state) { final error = state.syncError; final message = error ?? state.syncMessage ?? 'Sync complete'; @@ -144,9 +161,8 @@ class _MobileLayout extends StatelessWidget { selected: state.selectedNote, isLoading: state.isLoading, onSelect: (note) async { - final full = await state.getNote(note.id); - state.selectNote(full); - if (context.mounted) { + final full = await _openNote(context, note.id); + if (full != null && context.mounted) { Navigator.push(context, MaterialPageRoute( builder: (_) => ChangeNotifierProvider.value( value: state, @@ -178,9 +194,9 @@ class _MobileLayout extends StatelessWidget { foregroundColor: Colors.white, onPressed: () async { final note = await state.createNote(title: 'Untitled', noteType: 'typed'); - final full = await state.getNote(note.id); - state.selectNote(full); - if (context.mounted) { + if (!context.mounted) return; + final full = await _openNote(context, note.id); + if (full != null && context.mounted) { Navigator.push(context, MaterialPageRoute( builder: (_) => ChangeNotifierProvider.value( value: state, diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index 9181e19..d07146c 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -57,7 +57,11 @@ class AppState extends ChangeNotifier { String? _backendErrorMessage; String? _lastConnectError; bool _hasLocalData = false; + bool _isStarting = false; bool _isLoading = false; + String? _searchQuery; + int _notesRequest = 0; + int _openRequest = 0; bool _isSyncing = false; String? _syncMessage; String? _syncError; @@ -115,6 +119,10 @@ class AppState extends ChangeNotifier { String? get backendErrorMessage => _backendErrorMessage; String? get lastConnectError => _lastConnectError; bool get hasLocalData => _hasLocalData; + /// True only while [init] runs at app start; the shell shows the splash. + /// Later reloads use [isLoading], which only drives the notes list spinner, + /// so they never tear down HomeScreen (search field, open editor). + bool get isStarting => _isStarting; bool get isLoading => _isLoading; bool get isSyncing => _isSyncing; String? get syncMessage => _syncMessage; @@ -132,6 +140,18 @@ class AppState extends ChangeNotifier { LocalNoteService get localService => _localService; Future init() async { + // Set before the first await so the very first frame shows the splash + // rather than flashing onboarding while preferences load. + _isStarting = true; + try { + await _init(); + } finally { + _isStarting = false; + notifyListeners(); + } + } + + Future _init() async { await initLocal(); final prefs = await SharedPreferences.getInstance(); _apiUrl = prefs.getString(kPrefsApiUrl) ?? 'http://127.0.0.1:8766'; @@ -345,7 +365,8 @@ class AppState extends ChangeNotifier { /// Reloads [_notes] from the local SQLite store, applying the same /// notebook/search filters the backend would. Used by the local-mode CRUD /// paths so list views stay in sync with on-device data. - Future _refreshLocalNotes({String? notebookId, String? search}) async { + Future> _queryLocalNotes( + {String? notebookId, String? search}) async { await _ensureLocalReady(); final snapshot = await _localService.exportAllData(); Iterable notes = snapshot.notes.where((n) => !n.isDeleted); @@ -356,8 +377,7 @@ class AppState extends ChangeNotifier { if (query != null && query.isNotEmpty) { notes = notes.where((n) => n.title.toLowerCase().contains(query)); } - _notes = notes.map(_toApiNote).toList(); - _hasLocalData = _notebooks.isNotEmpty || _notes.isNotEmpty; + return notes.map(_toApiNote).toList(); } api.Notebook _toApiNotebook(local.Notebook n) => api.Notebook( @@ -475,19 +495,35 @@ class AppState extends ChangeNotifier { loadNotes(notebookId: nb?.id); } + /// Reloads the notes list. [search] replaces the active search query when + /// given; otherwise the current one is kept, so a reload triggered by a + /// rename or a notebook switch doesn't silently drop the user's filter. + /// + /// Only the latest call may publish its result: with a slow backend, the + /// results for "gr" could otherwise arrive after those for "groc". Future loadNotes({String? notebookId, String? search}) async { + if (search != null) _searchQuery = search; + final query = _searchQuery; + final request = ++_notesRequest; _isLoading = true; notifyListeners(); if (_localMode) { - await _refreshLocalNotes(notebookId: notebookId, search: search); + final notes = + await _queryLocalNotes(notebookId: notebookId, search: query); + if (request != _notesRequest) return; + _notes = notes; + _hasLocalData = _notebooks.isNotEmpty || _notes.isNotEmpty; _isLoading = false; notifyListeners(); return; } try { - _notes = await client.getNotes(notebookId: notebookId, search: search); + final notes = await client.getNotes(notebookId: notebookId, search: query); + if (request != _notesRequest) return; + _notes = notes; _markBackendAvailable(); } catch (e) { + if (request != _notesRequest) return; await _handleBackendFailure(e); } _isLoading = false; @@ -636,6 +672,17 @@ class AppState extends ChangeNotifier { void selectNote(api.Note? note) { _selectedNote = note; notifyListeners(); } + /// Loads note [id] with its content and selects it. Returns null when the + /// user picked another note before this one finished loading, so a slow + /// load can't replace the newer selection. + Future openNote(String id) async { + final request = ++_openRequest; + final full = await getNote(id); + if (request != _openRequest) return null; + selectNote(full); + return full; + } + Future triggerSync() async { if (_localMode) { // No backend configured — sync is a friendly no-op, not an error. diff --git a/app/test/screens/home_screen_test.dart b/app/test/screens/home_screen_test.dart new file mode 100644 index 0000000..6519323 --- /dev/null +++ b/app/test/screens/home_screen_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:nexanote/data/database/schema.dart'; +import 'package:nexanote/main.dart'; +import 'package:nexanote/services/app_state.dart'; +import 'package:nexanote/services/local_note_service.dart'; + +/// HomeScreen behaviour on the wide (desktop / Linux) layout, driven through +/// the real app shell in local mode. +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + late Database db; + late LocalNoteService service; + late AppState state; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + db = await openDatabase( + inMemoryDatabasePath, + version: Schema.version, + onCreate: Schema.onCreate, + ); + service = LocalNoteService(database: db); + state = AppState(localService: service); + await state.enableLocalMode(); + }); + + tearDown(() async { + await db.close(); + }); + + Future pumpApp(WidgetTester tester) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 800); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: state, + child: const NexaNoteApp(), + )); + await tester.pumpAndSettle(); + } + + /// Pumps until the real (FFI) database work behind an interaction settles. + Future settle(WidgetTester tester) async { + for (var i = 0; i < 10; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 5))); + await tester.pump(const Duration(milliseconds: 20)); + } + } + + Future seed(String title, String body) async { + final n = await state.createNote(title: title, noteType: 'typed'); + await state.savePageText(n.id, 1, body); + } + + Finder editorBody() => find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Start writing...'); + + String editorText(WidgetTester tester) => + tester.widget(editorBody()).controller!.text; + + testWidgets('selecting another note opens it in the editor panel', + (tester) async { + await tester.runAsync(() async { + await seed('Alpha', 'alpha body'); + await seed('Beta', 'beta body'); + await state.loadNotes(); + }); + await pumpApp(tester); + + await tester.tap(find.text('Alpha')); + await settle(tester); + expect(editorText(tester), 'alpha body'); + + await tester.tap(find.text('Beta')); + await settle(tester); + expect(editorText(tester), 'beta body'); + }); + + testWidgets('searching keeps the home screen and the typed query', + (tester) async { + await tester.runAsync(() async { + await seed('Groceries', ''); + await seed('Meeting', ''); + await state.loadNotes(); + }); + await pumpApp(tester); + + final search = find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Search notes...'); + await tester.enterText(search, 'groc'); + await tester.pump(); + // The app shell must not swap HomeScreen for the startup splash while the + // list reloads: that would throw away the search field and the editor. + expect(find.text('Almost ready...'), findsNothing); + expect(find.text('Starting...'), findsNothing); + await settle(tester); + + expect(search, findsOneWidget); + expect(find.descendant(of: search, matching: find.text('groc')), + findsOneWidget); + expect(find.text('Groceries'), findsOneWidget); + expect(find.text('Meeting'), findsNothing); + }); + + testWidgets('an edit in the open note survives a notes list reload', + (tester) async { + await tester.runAsync(() async { + await seed('Draft', 'v1'); + await state.loadNotes(); + }); + await pumpApp(tester); + + await tester.tap(find.text('Draft')); + await settle(tester); + await tester.enterText(editorBody(), 'v2 typed'); + await tester.pump(); + + // Something else reloads the list (sync, notebook switch, rename...). + await tester.runAsync(() => state.loadNotes()); + await settle(tester); + + expect(editorBody(), findsOneWidget); + expect(editorText(tester), 'v2 typed'); + }); +} From cda08d2925bae1b00a9271c09d67eee49fac5ecb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:54:39 +0000 Subject: [PATCH 06/44] fix: confirm before deleting a note from the editor The editor's trash button deleted the note on a single tap, while the list swipe asks first, and the app has no trash view to restore from. It now uses the same confirmation and reports a failed delete. --- app/lib/screens/note_editor_screen.dart | 39 +++++++++++++++++-- .../screens/note_editor_persistence_test.dart | 24 ++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/app/lib/screens/note_editor_screen.dart b/app/lib/screens/note_editor_screen.dart index 574dfcd..ebc331c 100644 --- a/app/lib/screens/note_editor_screen.dart +++ b/app/lib/screens/note_editor_screen.dart @@ -197,6 +197,40 @@ class _NoteEditorScreenState extends State { } } + /// The app has no trash view to restore from, so deleting from the editor + /// asks first, like the swipe-to-delete in the notes list. + Future _confirmDelete(bool isMobile) async { + final scheme = Theme.of(context).colorScheme; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete note?'), + content: Text('Move "${_titleCtrl.text}" to trash?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom(backgroundColor: scheme.error), + child: const Text('Delete')), + ], + ), + ); + if (confirmed != true || !mounted) return; + final state = context.read(); + try { + await state.deleteNote(_note.id); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Delete failed: $e'), backgroundColor: Colors.red)); + } + return; + } + if (isMobile && mounted) Navigator.pop(context); + } + @override void dispose() { _saveTimer?.cancel(); @@ -251,10 +285,7 @@ class _NoteEditorScreenState extends State { onDraw: () { _switchToInk(); }, - onDelete: () { - context.read().deleteNote(_note.id); - if (isMobile) Navigator.pop(context); - }, + onDelete: () => _confirmDelete(isMobile), ), // Title Padding( diff --git a/app/test/screens/note_editor_persistence_test.dart b/app/test/screens/note_editor_persistence_test.dart index 8a9f918..01a0e34 100644 --- a/app/test/screens/note_editor_persistence_test.dart +++ b/app/test/screens/note_editor_persistence_test.dart @@ -170,4 +170,28 @@ void main() { expect(await io(tester, () => service.getStrokesForNote(created.id)), isNotEmpty); }); + + testWidgets('deleting from the editor asks for confirmation first', + (tester) async { + final created = await newNote(tester, 'Keep me', 'typed'); + await pumpEditor(tester, created); + + Future isDeleted() => io(tester, () async { + return (await service.getNoteById(created.id))!.isDeleted; + }); + + await tester.tap(find.byTooltip('Delete note')); + await tester.pumpAndSettle(); + expect(find.text('Delete note?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + await settle(tester); + expect(await isDeleted(), isFalse); + + await tester.tap(find.byTooltip('Delete note')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await settle(tester); + expect(await isDeleted(), isTrue); + }); } From 84789c4a5e428f3b9f027fb6555a17ccaed23fa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 07:59:11 +0000 Subject: [PATCH 07/44] fix: upload offline notes with their content, once When a local-mode user connected a backend, SyncService.pushLocal only created empty notes from the titles: the text and drawing never reached the server, and in connected mode the app then showed those empty copies. It also sent the local notebook id, which the server rejects with a 404, pushed notes that had been deleted locally, and never marked anything as pushed, so every later sync duplicated notes and notebooks again. pushLocal now re-keys pushed notebooks to the server id, uploads text and ink after creating a note, skips notes deleted before they were pushed, and marks pushed notes synced with their remote id. A push interrupted after the create resumes the upload instead of creating the note again. The upload ran only on the first connect after local mode and swallowed failures, so one network error meant offline notes were never uploaded. It now retries on every connect while something is pending and reports failures through syncError. --- app/lib/data/models/notebook.dart | 15 +++ .../data/repositories/note_repository.dart | 35 +++++++ app/lib/services/app_state.dart | 85 ++++------------- app/lib/services/ink_json.dart | 52 +++++++++++ app/lib/services/local_note_service.dart | 8 ++ app/lib/services/sync_service.dart | 77 +++++++++++---- app/test/services/app_state_test.dart | 47 ++++++++++ app/test/services/sync_service_test.dart | 93 ++++++++++++++++++- 8 files changed, 320 insertions(+), 92 deletions(-) create mode 100644 app/lib/services/ink_json.dart diff --git a/app/lib/data/models/notebook.dart b/app/lib/data/models/notebook.dart index d1448d7..4040169 100644 --- a/app/lib/data/models/notebook.dart +++ b/app/lib/data/models/notebook.dart @@ -82,6 +82,21 @@ class Notebook { ); } + /// Same notebook under another primary key (used when the server assigns + /// its own id to a notebook created locally). + Notebook withId(String newId) => Notebook( + id: newId, + parentId: parentId, + name: name, + description: description, + color: color, + icon: icon, + isArchived: isArchived, + syncStatus: syncStatus, + createdAt: createdAt, + updatedAt: updatedAt, + ); + @override String toString() => 'Notebook(id: $id, name: $name)'; } diff --git a/app/lib/data/repositories/note_repository.dart b/app/lib/data/repositories/note_repository.dart index c12d5cd..617f53b 100644 --- a/app/lib/data/repositories/note_repository.dart +++ b/app/lib/data/repositories/note_repository.dart @@ -187,6 +187,41 @@ class NoteRepository { ); } + /// Links local note [id] to the note the server created for it, keeping it + /// pending (`local_only`) until its content has been uploaded too. + Future setNoteRemoteId(String id, String remoteId) async { + await _db.update('notes', {'remote_id': remoteId}, + where: 'id = ?', whereArgs: [id]); + } + + /// Marks note [id] as synced, unless it was edited after [seenUpdatedAt] + /// (the version that was uploaded). Returns whether it was marked. + Future markNoteSyncedIfUnchanged( + String id, DateTime seenUpdatedAt) async { + final changed = await _db.update( + 'notes', + {'sync_status': 'synced'}, + where: 'id = ? AND updated_at = ?', + whereArgs: [id, seenUpdatedAt.toIso8601String()], + ); + return changed > 0; + } + + /// Re-keys local notebook [localId] to [remote], the copy the server just + /// created for it, and moves its notes and child notebooks along, so the + /// local store refers to the notebook by the id the server knows. + Future adoptRemoteNotebook(String localId, Notebook remote) async { + await _db.transaction((txn) async { + await txn.insert('notebooks', remote.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace); + await txn.update('notes', {'notebook_id': remote.id}, + where: 'notebook_id = ?', whereArgs: [localId]); + await txn.update('notebooks', {'parent_id': remote.id}, + where: 'parent_id = ?', whereArgs: [localId]); + await txn.delete('notebooks', where: 'id = ?', whereArgs: [localId]); + }); + } + /// Inserts [notebook] or replaces an existing row with the same id. Future upsertNotebook(Notebook notebook) async { await _db.insert( diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index d07146c..c27eed4 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -4,13 +4,13 @@ import 'package:shared_preferences/shared_preferences.dart'; // Aliased so the API DTOs are unambiguous: there are also `Notebook` / `Note` // classes in data/models reachable through [LocalNoteService]. import 'api_client.dart' as api; +import 'ink_json.dart'; import 'local_note_service.dart'; import 'server_url.dart'; import 'sync_service.dart'; import '../data/models/note.dart' as local; import '../data/models/notebook.dart' as local; import '../data/models/stroke.dart'; -import '../data/models/point.dart'; typedef ApiClientFactory = api.ApiClient Function(String baseUrl); @@ -234,21 +234,23 @@ class AppState extends ChangeNotifier { } } - /// Best-effort upload of notes the user created while offline, run once when - /// they leave local mode by connecting a backend. Reuses the existing - /// [SyncService] push so the storage/sync layout is unchanged. Failures are - /// swallowed: a connection must never be blocked by a migration hiccup — the - /// notes stay in the local store and can be synced again later. - Future _migrateLocalNotesToBackend() async { + /// Uploads notes the user created while offline (local mode). Runs on every + /// successful connect, not just the first one after leaving local mode: if + /// the upload fails halfway (network drop, backend error), the remaining + /// notes are still pending locally and are retried on the next connect. + /// Already-uploaded notes are marked synced, so a retry doesn't duplicate + /// them. A failure never blocks the connection, but it is reported through + /// [syncError] instead of being dropped silently. + Future _uploadPendingLocalNotes() async { try { final snapshot = await _localService.exportAllData(); - final hasLocal = snapshot.notebooks.isNotEmpty || - snapshot.notes.any((n) => !n.isDeleted); - if (!hasLocal) return; + final pending = snapshot.notebooks.any((n) => n.syncStatus != 'synced') || + snapshot.notes.any((n) => n.syncStatus != 'synced'); + if (!pending) return; final svc = SyncService(apiClient: client, local: _localService); await svc.pushLocal(); - } catch (_) { - // Intentionally ignored — see doc comment. + } catch (e) { + _syncError = 'Could not upload notes taken offline: ${_safeErrorString(e)}'; } } @@ -277,15 +279,12 @@ class AppState extends ChangeNotifier { _backendErrorMessage = null; // A reachable backend means one is now configured — drop out of local // mode so CRUD and sync use the backend again. - final wasLocalMode = _localMode; await _clearLocalMode(); await _markEverConnected(); try { - if (wasLocalMode) { - // The user took notes offline before configuring a server — upload - // them so nothing created in local mode is lost on the switch. - await _migrateLocalNotesToBackend(); - } + // Notes taken offline before (or while) a server was configured are + // uploaded so nothing created in local mode is lost on the switch. + await _uploadPendingLocalNotes(); await loadNotebooks(); await loadNotes(); } catch (e) { @@ -779,53 +778,3 @@ class AppState extends ChangeNotifier { notifyListeners(); } } - -/// Converts an editor/wire ink stroke (`{id,color,width,tool,points:[…]}`) into -/// the local [Stroke] model for SQLite persistence. The editor emits point -/// timestamps under `ts`; the local model stores them as `timestampMs`. -/// Tolerant of missing fields so a malformed stroke degrades gracefully rather -/// than throwing and losing the whole drawing. -Stroke strokeFromInkJson( - String noteId, - Map json, - DateTime createdAt, -) { - final rawPoints = (json['points'] as List?) ?? const []; - return Stroke( - id: (json['id'] as String?) ?? - DateTime.now().microsecondsSinceEpoch.toString(), - noteId: noteId, - color: (json['color'] as String?) ?? '#000000', - width: (json['width'] as num?)?.toDouble() ?? 2.0, - tool: (json['tool'] as String?) ?? 'pen', - createdAt: createdAt, - points: [ - for (final p in rawPoints) - if (p is Map) - StrokePoint( - x: (p['x'] as num?)?.toDouble() ?? 0, - y: (p['y'] as num?)?.toDouble() ?? 0, - pressure: (p['pressure'] as num?)?.toDouble() ?? 0.5, - timestampMs: (p['ts'] as num?)?.toInt() ?? 0, - ), - ], - ); -} - -/// Inverse of [strokeFromInkJson]: renders a local [Stroke] back into the -/// editor/wire shape so the ink canvas can replay a saved drawing. -Map inkJsonFromStroke(Stroke stroke) => { - 'id': stroke.id, - 'color': stroke.color, - 'width': stroke.width, - 'tool': stroke.tool, - 'points': [ - for (final p in stroke.points) - { - 'x': p.x, - 'y': p.y, - 'pressure': p.pressure, - 'ts': p.timestampMs, - }, - ], - }; diff --git a/app/lib/services/ink_json.dart b/app/lib/services/ink_json.dart new file mode 100644 index 0000000..fd94313 --- /dev/null +++ b/app/lib/services/ink_json.dart @@ -0,0 +1,52 @@ +import '../data/models/point.dart'; +import '../data/models/stroke.dart'; + +/// Converts an editor/wire ink stroke (`{id,color,width,tool,points:[…]}`) into +/// the local [Stroke] model for SQLite persistence. The editor emits point +/// timestamps under `ts`; the local model stores them as `timestampMs`. +/// Tolerant of missing fields so a malformed stroke degrades gracefully rather +/// than throwing and losing the whole drawing. +Stroke strokeFromInkJson( + String noteId, + Map json, + DateTime createdAt, +) { + final rawPoints = (json['points'] as List?) ?? const []; + return Stroke( + id: (json['id'] as String?) ?? + DateTime.now().microsecondsSinceEpoch.toString(), + noteId: noteId, + color: (json['color'] as String?) ?? '#000000', + width: (json['width'] as num?)?.toDouble() ?? 2.0, + tool: (json['tool'] as String?) ?? 'pen', + createdAt: createdAt, + points: [ + for (final p in rawPoints) + if (p is Map) + StrokePoint( + x: (p['x'] as num?)?.toDouble() ?? 0, + y: (p['y'] as num?)?.toDouble() ?? 0, + pressure: (p['pressure'] as num?)?.toDouble() ?? 0.5, + timestampMs: (p['ts'] as num?)?.toInt() ?? 0, + ), + ], + ); +} + +/// Inverse of [strokeFromInkJson]: renders a local [Stroke] back into the +/// editor/wire shape so the ink canvas can replay a saved drawing. +Map inkJsonFromStroke(Stroke stroke) => { + 'id': stroke.id, + 'color': stroke.color, + 'width': stroke.width, + 'tool': stroke.tool, + 'points': [ + for (final p in stroke.points) + { + 'x': p.x, + 'y': p.y, + 'pressure': p.pressure, + 'ts': p.timestampMs, + }, + ], + }; diff --git a/app/lib/services/local_note_service.dart b/app/lib/services/local_note_service.dart index 1780792..b9c914c 100644 --- a/app/lib/services/local_note_service.dart +++ b/app/lib/services/local_note_service.dart @@ -66,6 +66,14 @@ class LocalNoteService { _repo.updateNoteFields(id, {'typed_content': typedContent}); Future markNoteModified(String id) => _repo.updateNoteFields(id); + // Push bookkeeping used by SyncService. + Future setNoteRemoteId(String id, String remoteId) => + _repo.setNoteRemoteId(id, remoteId); + Future markNoteSyncedIfUnchanged(String id, DateTime seenUpdatedAt) => + _repo.markNoteSyncedIfUnchanged(id, seenUpdatedAt); + Future adoptRemoteNotebook(String localId, Notebook remote) => + _repo.adoptRemoteNotebook(localId, remote); + /// Soft-deletes a note (sets `is_deleted`, marks it `modified`) so the /// deletion can later propagate once a backend is configured. Used by the /// local-mode delete path; sync uses [hardDeleteNote] for server-driven diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 14149d3..2312ec6 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import '../data/models/note.dart'; import '../data/models/notebook.dart'; import 'api_client.dart' as api; +import 'ink_json.dart'; import 'local_note_service.dart'; import 'title_cleaner.dart' as title_cleaner; @@ -70,32 +71,66 @@ class SyncService { ); } - /// Uploads only locally-originated records that have never been seen on - /// the server. A row is considered "needs push" iff it has no - /// [Note.remoteId] AND its `syncStatus` is not `synced`. After a pull, - /// every adopted note carries `synced` plus a `remoteId`, so subsequent - /// pushes are no-ops until the user creates something new. + /// Uploads locally-originated records the server has never seen. + /// + /// Notebooks are created first and the local row is re-keyed to the id the + /// server minted, so notes are filed under a notebook the server knows. + /// + /// A note is created, then its text and drawing are uploaded, then it is + /// marked `synced` with the server id as its [Note.remoteId]. If an upload + /// fails after the create, the note stays `local_only` with its remoteId + /// set and the next push resumes the upload instead of creating it again. + /// Adopted notes (remoteId set by a pull, status synced/modified/conflict) + /// are never pushed: local rows of pulled notes hold no page content, so + /// uploading them would blank the server copy. Future<_PushCounts> pushLocal() async { final snapshot = await _local.exportAllData(); var notebooks = 0; var notes = 0; + final serverNotebookIds = {}; for (final nb in snapshot.notebooks) { - if (_isAlreadySynced(nb.syncStatus)) continue; - await _api.createNotebook(name: nb.name, color: nb.color); + if (_isAlreadySynced(nb.syncStatus)) { + serverNotebookIds[nb.id] = nb.id; + continue; + } + final created = await _api.createNotebook(name: nb.name, color: nb.color); + await _local.adoptRemoteNotebook( + nb.id, + nb.copyWith(syncStatus: 'synced').withId(created.id), + ); + serverNotebookIds[nb.id] = created.id; notebooks++; } for (final note in snapshot.notes) { if (_isAlreadySynced(note.syncStatus)) continue; - // Already adopted from a remote .md file — never re-create on the - // server. The non-idempotent createNote endpoint would mint a fresh - // UUID and a duplicate file alongside the existing one. - if (note.remoteId != null && note.remoteId!.isNotEmpty) continue; - await _api.createNote( - title: cleanRemoteTitle(note.title), - noteType: note.noteType, - notebookId: note.notebookId, - ); - notes++; + final pendingUpload = note.syncStatus == 'local_only'; + var remoteId = note.remoteId; + final hasRemote = remoteId != null && remoteId.isNotEmpty; + if (hasRemote && !pendingUpload) continue; + if (!hasRemote) { + // Deleted before it ever reached the server: nothing to propagate. + if (note.isDeleted) continue; + final notebookId = note.notebookId; + final created = await _api.createNote( + title: cleanRemoteTitle(note.title), + noteType: note.noteType, + // A notebook deleted locally is unknown to the server, which would + // reject the note; file it unsorted rather than abort the push. + notebookId: notebookId == null ? null : serverNotebookIds[notebookId], + ); + remoteId = created.id; + await _local.setNoteRemoteId(note.id, remoteId); + notes++; + } + if (note.typedContent.isNotEmpty) { + await _api.savePageText(remoteId, 1, note.typedContent); + } + final strokes = await _local.getStrokesForNote(note.id); + if (strokes.isNotEmpty) { + await _api.savePageInk( + remoteId, 1, strokes.map(inkJsonFromStroke).toList()); + } + await _local.markNoteSyncedIfUnchanged(note.id, note.updatedAt); } return _PushCounts(notebooks: notebooks, notes: notes); } @@ -172,8 +207,12 @@ class SyncService { // 'modified' row beats an older or equal-time remote so user edits // aren't clobbered before the next push round. final remoteUpdated = _parseUtc(r.updatedAt); - final keepLocal = existing.syncStatus == 'modified' && - existing.updatedAt.isAfter(remoteUpdated); + // A local_only row with a remoteId was created by our own push and + // still has content to upload; adopting the remote copy now would + // mark it synced and the upload would never be retried. + final keepLocal = existing.syncStatus == 'local_only' || + (existing.syncStatus == 'modified' && + existing.updatedAt.isAfter(remoteUpdated)); if (keepLocal) continue; await _local.upsertNote(existing.copyWith( notebookId: r.notebookId, diff --git a/app/test/services/app_state_test.dart b/app/test/services/app_state_test.dart index 06bec96..340efd6 100644 --- a/app/test/services/app_state_test.dart +++ b/app/test/services/app_state_test.dart @@ -63,6 +63,33 @@ class _StubApi extends api.ApiClient { } } +/// Backend that is reachable but fails the first note upload, as a dropped +/// connection mid-migration would. +class _FlakyUploadApi extends _StubApi { + int failuresLeft = 1; + final savedText = {}; + + @override + Future createNote({ + required String title, + required String noteType, + String? notebookId, + String template = 'blank', + }) { + if (failuresLeft > 0) { + failuresLeft--; + throw Exception('connection reset'); + } + return super.createNote( + title: title, noteType: noteType, notebookId: notebookId); + } + + @override + Future savePageText(String noteId, int pageNum, String content) async { + savedText[noteId] = content; + } +} + void main() { setUpAll(() { sqfliteFfiInit(); @@ -707,6 +734,26 @@ void main() { reason: 'the offline note should be pushed to the backend on switch'); }); + test('offline notes are uploaded on a later connect if the first upload ' + 'failed', () async { + final stub = _FlakyUploadApi(); + final s = AppState(localService: service, clientFactory: (_) => stub); + await s.enableLocalMode(); + final note = await s.createNote(title: 'Offline note', noteType: 'typed'); + await s.savePageText(note.id, 1, 'only copy of this text'); + + await s.connect(url: 'http://192.0.2.10:8766'); + expect(s.syncError, contains('Could not upload notes taken offline')); + expect(stub.savedText, isEmpty); + + await s.connect(); + expect(stub.savedText.values, ['only copy of this text']); + expect(stub.createNoteCalls, 1); + + await s.connect(); + expect(stub.createNoteCalls, 1, reason: 'already uploaded, not again'); + }); + test('existing connected users are not reset to onboarding or local mode', () async { // Simulates an upgrade from a pre-local-mode build: the only persisted diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index 2e83593..386e800 100644 --- a/app/test/services/sync_service_test.dart +++ b/app/test/services/sync_service_test.dart @@ -18,6 +18,19 @@ class FakeApiClient extends api.ApiClient { List remoteNotes = []; List> createdNotebooks = []; List> createdNotes = []; + Map savedText = {}; + Map>> savedInk = {}; + + @override + Future savePageText(String noteId, int pageNum, String content) async { + savedText[noteId] = content; + } + + @override + Future savePageInk( + String noteId, int pageNum, List> strokes) async { + savedInk[noteId] = strokes; + } @override Future> getNotebooks() async => remoteNotebooks; @@ -36,13 +49,16 @@ class FakeApiClient extends api.ApiClient { String color = '#6366f1', }) async { createdNotebooks.add({'name': name, 'color': color}); - return api.Notebook( + final created = api.Notebook( id: 'remote-nb-${createdNotebooks.length}', name: name, color: color, icon: 'notebook', updatedAt: DateTime.now().toUtc().toIso8601String(), ); + // Like the real server, list what was created on the next pull. + remoteNotebooks = [...remoteNotebooks, created]; + return created; } @override @@ -58,7 +74,7 @@ class FakeApiClient extends api.ApiClient { 'notebookId': notebookId, }); final now = DateTime.now().toUtc().toIso8601String(); - return api.Note( + final created = api.Note( id: 'remote-note-${createdNotes.length}', title: title, noteType: noteType, @@ -70,6 +86,8 @@ class FakeApiClient extends api.ApiClient { updatedAt: now, createdAt: now, ); + remoteNotes = [...remoteNotes, created]; + return created; } } @@ -162,6 +180,69 @@ void main() { ); }); + test('pushLocal uploads the note body and drawing, not just the title', + () async { + final note = await local.createNote('Offline', noteType: 'mixed'); + await local.updateNoteContent(note.id, 'written offline'); + await local.replaceStrokesForNote(note.id, [ + Stroke( + id: 's1', + noteId: note.id, + createdAt: DateTime.utc(2024), + points: const [ + StrokePoint(x: 1, y: 2, pressure: 0.4), + StrokePoint(x: 3, y: 4, pressure: 0.6), + ], + ), + ]); + + await sync.pushLocal(); + + expect(fakeApi.savedText['remote-note-1'], 'written offline'); + expect(fakeApi.savedInk['remote-note-1'], hasLength(1)); + expect(fakeApi.savedInk['remote-note-1']!.first['points'], hasLength(2)); + }); + + test('pushLocal files notes under the server id of their notebook', + () async { + final nb = await local.createNotebook('Work'); + await local.createNote('Plan', notebookId: nb.id); + + await sync.pushLocal(); + + // The server minted its own notebook id; sending the local one would + // be rejected (404) and abort the push. + expect(fakeApi.createdNotes.single['notebookId'], 'remote-nb-1'); + }); + + test('pushLocal does not resurrect notes deleted before they were pushed', + () async { + final note = await local.createNote('Scratch'); + await local.deleteNote(note.id); + + final counts = await sync.pushLocal(); + + expect(counts.notes, 0); + expect(fakeApi.createdNotes, isEmpty); + }); + + test('syncing again after a push does not duplicate notes or notebooks', + () async { + final nb = await local.createNotebook('Work'); + await local.createNote('Plan', notebookId: nb.id); + + await sync.sync(); + await sync.sync(); + await sync.sync(); + + expect(fakeApi.createdNotebooks, hasLength(1)); + expect(fakeApi.createdNotes, hasLength(1)); + final snapshot = await local.exportAllData(); + expect(snapshot.notes.where((n) => !n.isDeleted), hasLength(1)); + expect(snapshot.notes.single.syncStatus, 'synced'); + expect(snapshot.notebooks, hasLength(1)); + }); + test('pushLocal skips records already marked synced', () async { fakeApi.remoteNotebooks = [_remoteNotebook(id: 'nb-pulled', name: 'Pulled')]; fakeApi.remoteNotes = [ @@ -383,11 +464,13 @@ void main() { expect(result.notebooksPushed, 1); expect(result.notesPushed, 1); - expect(result.notebooksPulled, 1); - expect(result.notesPulled, 0); + expect(result.notebooksPulled, 2); + expect(result.notesPulled, 1); + // The pushed notebook is now known by the id the server gave it. final notebooks = await local.getNotebooks(); - expect(notebooks.map((n) => n.id), containsAll([nb.id, 'nb-r'])); + expect(notebooks.map((n) => n.id), containsAll(['remote-nb-1', 'nb-r'])); + expect(notebooks.map((n) => n.id), isNot(contains(nb.id))); }); test('pullRemote preserves local strokes (metadata-only sync)', () async { From 27a54de180cbad2ce66e7e15f45b085cf18f1042 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:06:15 +0000 Subject: [PATCH 08/44] fix: serialise REST writes to the same note The text, ink, update, delete and restore routes read the whole note, change one part and save it back. FastAPI runs them on a thread pool, so the app's text autosave and ink save for one note could overlap and the slower one wrote back a stale copy: strokes vanished after a text save, or a rename was reverted by an autosave. Those routes now take a per-note lock; writes to different notes still run in parallel. --- nexanote/api/routes.py | 25 +++++++ tests/test_concurrent_note_writes.py | 105 +++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/test_concurrent_note_writes.py diff --git a/nexanote/api/routes.py b/nexanote/api/routes.py index ab1f4a8..a0ea6f4 100644 --- a/nexanote/api/routes.py +++ b/nexanote/api/routes.py @@ -38,9 +38,11 @@ from __future__ import annotations +import functools import json import logging import os +import threading from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path @@ -293,6 +295,24 @@ async def lifespan(app: FastAPI): # Le mot de passe est exclu intentionnellement — jamais écrit en clair. _PERSIST_FIELDS = {"server_url", "username", "conflict_strategy"} + # EN: Write routes read the whole note, change one part and save it back. + # FastAPI runs them on a thread pool, so the app's text and ink + # autosaves for one note can overlap and each would write back the + # other's stale copy. One lock per note serialises them. + # FR: Un verrou par note pour que les sauvegardes texte/encre + # concurrentes ne s'écrasent pas. + _note_locks: dict[str, threading.Lock] = {} + _note_locks_guard = threading.Lock() + + def _locked_by_note(route): + @functools.wraps(route) + def wrapper(note_id: str, *args, **kwargs): + with _note_locks_guard: + lock = _note_locks.setdefault(note_id, threading.Lock()) + with lock: + return route(note_id, *args, **kwargs) + return wrapper + _last_sync_report: dict = {} _sync_config: dict = {} _sync_config_path = db.data_dir / "sync_config.json" @@ -435,6 +455,7 @@ def get_note(note_id: str, pages: bool = True): return _note_to_schema(note, include_pages=pages) @app.put("/notes/{note_id}", response_model=NoteSchema) + @_locked_by_note def update_note(note_id: str, data: NoteUpdateSchema): note = db.get_note(note_id, load_pages=False) if not note: @@ -452,6 +473,7 @@ def update_note(note_id: str, data: NoteUpdateSchema): return _note_to_schema(note) @app.delete("/notes/{note_id}", status_code=204) + @_locked_by_note def delete_note(note_id: str): note = db.get_note(note_id, load_pages=False) if not note: @@ -460,6 +482,7 @@ def delete_note(note_id: str): db.save_note(note, save_pages=False) @app.post("/notes/{note_id}/restore", response_model=NoteSchema) + @_locked_by_note def restore_note(note_id: str): note = db.get_note(note_id, load_pages=False) if not note: @@ -505,6 +528,7 @@ def get_page(note_id: str, page_num: int): return _page_to_schema(page) @app.put("/notes/{note_id}/pages/{page_num}/ink", response_model=PageSchema) + @_locked_by_note def update_ink(note_id: str, page_num: int, data: InkUpdateSchema): """Remplace tous les strokes d'une page — appelé après chaque session d'écriture.""" note = db.get_note(note_id, load_pages=True) @@ -537,6 +561,7 @@ def update_ink(note_id: str, page_num: int, data: InkUpdateSchema): return _page_to_schema(page) @app.put("/notes/{note_id}/pages/{page_num}/text", response_model=PageSchema) + @_locked_by_note def update_text(note_id: str, page_num: int, data: TextUpdateSchema): """Met à jour le contenu texte/markdown d'une page.""" note = db.get_note(note_id, load_pages=True) diff --git a/tests/test_concurrent_note_writes.py b/tests/test_concurrent_note_writes.py new file mode 100644 index 0000000..3eb2f2f --- /dev/null +++ b/tests/test_concurrent_note_writes.py @@ -0,0 +1,105 @@ +""" +Concurrent REST writes to one note. + +The app saves text and ink independently, so with a backend configured a +text autosave and an ink save for the same note can be in flight at once. +Each route reads the note, changes one part and saves it back; without +serialisation the slower one writes back a stale copy of the other's part. +""" + +import sys +import threading +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import pytest +from fastapi.testclient import TestClient + +from nexanote.api.routes import create_app +from nexanote.storage import FileNoteStore + + +@pytest.fixture +def api(tmp_path): + db = FileNoteStore(tmp_path / "store") + app = create_app(db) + with TestClient(app) as c: + yield c, db + db.close() + + +def _slow_reads(db, delay=0.2): + """Delay every note read so overlapping requests both read before + either one writes, which is the window a slow disk or NAS opens.""" + real_get_note = db.get_note + + def get_note(*args, **kwargs): + note = real_get_note(*args, **kwargs) + time.sleep(delay) + return note + + db.get_note = get_note + return real_get_note + + +def _run_together(*requests): + threads = [threading.Thread(target=r) for r in requests] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10) + + +STROKE = {"id": "s1", "points": [{"x": 1, "y": 1}, {"x": 2, "y": 2}]} + + +def test_text_and_ink_saves_for_one_note_both_survive(api): + c, db = api + nid = c.post("/notes", json={"title": "T"}).json()["id"] + real_get_note = _slow_reads(db) + + _run_together( + lambda: c.put(f"/notes/{nid}/pages/1/text", json={"typed_content": "TEXT"}), + lambda: c.put(f"/notes/{nid}/pages/1/ink", json={"strokes": [STROKE]}), + ) + + db.get_note = real_get_note + page = c.get(f"/notes/{nid}/pages/1").json() + assert page["typed_content"] == "TEXT" + assert len(page["strokes"]) == 1 + + +def test_rename_during_text_autosave_is_kept(api): + c, db = api + nid = c.post("/notes", json={"title": "Old title"}).json()["id"] + real_get_note = _slow_reads(db) + + _run_together( + lambda: c.put(f"/notes/{nid}/pages/1/text", json={"typed_content": "body"}), + lambda: c.put(f"/notes/{nid}", json={"title": "New title"}), + ) + + db.get_note = real_get_note + note = c.get(f"/notes/{nid}").json() + assert note["title"] == "New title" + assert note["pages"][0]["typed_content"] == "body" + + +def test_writes_to_different_notes_do_not_wait_for_each_other(api): + c, db = api + a = c.post("/notes", json={"title": "A"}).json()["id"] + b = c.post("/notes", json={"title": "B"}).json()["id"] + real_get_note = _slow_reads(db, delay=1.0) + + started = time.monotonic() + _run_together( + lambda: c.put(f"/notes/{a}/pages/1/text", json={"typed_content": "a"}), + lambda: c.put(f"/notes/{b}/pages/1/text", json={"typed_content": "b"}), + ) + elapsed = time.monotonic() - started + + db.get_note = real_get_note + # One slow read per save: run one after the other they would take ~2s. + assert elapsed < 1.8 From 2d2c07c4dad2de765df8ffaf6cc37301a1f9cce0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:12:03 +0000 Subject: [PATCH 09/44] fix: stop WebDAV sync from reverting or dropping local edits Several ways a sync lost data, all reproduced against a live WsgiDAV server in test_webdav_sync_safety.py: - Remote pages were rebuilt with updated_at=now(), so the merge always preferred the server text and a newer offline edit was reverted. - The conflict resolver stamps its winner SYNCED, so a local edit that won (or was merged) was never pushed and later got overwritten. - A failed PROPFIND (401/5xx/HTML body/timeout) was read as an empty server: pull was skipped and push overwrote newer remote notes while the report said success. Listing failures now abort the sync, and a notebook that can't be listed is not pushed into. This also makes a network drop after ping fail in a few requests instead of retrying the whole chain for every note. - A failed page_N.ink download was read as "no strokes" and the pulled note replaced the local drawing. - note.json carries no notebook or archived flag, so every pull dropped them from the local note. - After an upload, the snapshot was re-saved as SYNCED, reverting and hiding an edit made meanwhile. - The WebDAV server stamped its own clock on every PUT, so an uploaded note always looked newer than an edit made right after the upload. It now keeps the timestamps the client sends. The reliability test fixture had a page edited after its own note; it only passed because remote pages used to be stamped "now". --- nexanote/sync/client.py | 127 +++++++++++++--- nexanote/sync/webdav_provider.py | 37 ++++- tests/test_sync_reliability.py | 2 + tests/test_webdav_sync_safety.py | 239 +++++++++++++++++++++++++++++++ 4 files changed, 380 insertions(+), 25 deletions(-) create mode 100644 tests/test_webdav_sync_safety.py diff --git a/nexanote/sync/client.py b/nexanote/sync/client.py index a21200a..66eb6bd 100644 --- a/nexanote/sync/client.py +++ b/nexanote/sync/client.py @@ -113,6 +113,11 @@ class name only (``ConnectTimeout``, ``ReadTimeout``, ``ConnectionError``) return type(exc).__name__ +class RemoteReadError(Exception): + """A listing or download from the WebDAV server failed (as opposed to the + resource legitimately not existing).""" + + @dataclass class OperationAttempt: """ @@ -398,23 +403,33 @@ def ping(self) -> bool: return resp is not None and resp.status_code < 400 def list_notebooks(self) -> list[dict]: - """PROPFIND sur / — retourne la liste des carnets.""" - return self._propfind(self.base_url, depth=1, path_label="/") + """PROPFIND sur / — retourne la liste des carnets. + + Raises RemoteReadError when the listing fails: an unreadable server + must never look like an empty one, or push would overwrite remote + notes that pull never got to see. + """ + return self._listing(self.base_url, "/") def list_notes(self, notebook_slug: str) -> list[dict]: - """PROPFIND sur /{notebook} — retourne la liste des notes.""" - return self._propfind( - self._url(notebook_slug), depth=1, path_label=notebook_slug - ) + """PROPFIND sur /{notebook} — retourne la liste des notes. + + A notebook folder that does not exist (404) lists as empty. + """ + return self._listing(self._url(notebook_slug), notebook_slug) def list_note_files(self, notebook_slug: str, note_slug: str) -> list[dict]: """PROPFIND sur /{notebook}/{note} — retourne les fichiers.""" - return self._propfind( - self._url(notebook_slug, note_slug), - depth=1, - path_label=f"{notebook_slug}/{note_slug}", + return self._listing( + self._url(notebook_slug, note_slug), f"{notebook_slug}/{note_slug}" ) + def _listing(self, url: str, path_label: str) -> list[dict]: + entries = self._propfind(url, depth=1, path_label=path_label) + if entries is None: + raise RemoteReadError(f"PROPFIND {path_label} failed") + return entries + def get_note_meta(self, notebook_slug: str, note_slug: str) -> Optional[dict]: """GET /{notebook}/{note}/note.json (avec retry transitoire).""" url = self._url(notebook_slug, note_slug, "note.json") @@ -437,13 +452,20 @@ def get_ink_page(self, notebook_slug: str, note_slug: str, page_num: int) -> Opt resp = self._execute( "GET", path, lambda: self.session.get(url, timeout=self.config.timeout_seconds) ) - if resp is None or resp.status_code != 200: + # EN: Only a 404 means "this page has no drawing". Any other failure + # must not be read as "no strokes": the pulled note would then + # be saved over the local copy and its drawing wiped. + # FR: Seul un 404 signifie « pas de dessin » ; toute autre erreur + # est remontée pour ne pas effacer les traits locaux. + if resp is not None and resp.status_code == 404: return None + if resp is None or resp.status_code != 200: + status = "no response" if resp is None else resp.status_code + raise RemoteReadError(f"GET {path} failed ({status})") try: return resp.json() except ValueError as e: - logger.error(f"GET page.ink invalid JSON ({path}): {e}") - return None + raise RemoteReadError(f"GET {path} returned invalid JSON: {e}") from e def put_note_meta( self, notebook_slug: str, note_slug: str, data: dict @@ -620,10 +642,11 @@ def create_note_dir(self, notebook_slug: str, note_slug: str) -> bool: def _propfind( self, url: str, depth: int = 1, path_label: Optional[str] = None - ) -> list[dict]: + ) -> Optional[list[dict]]: """ PROPFIND WebDAV — liste les ressources à un niveau donné (avec retry). - Retourne une liste simplifiée de {name, href, is_collection, last_modified}. + Retourne une liste simplifiée de {name, href, is_collection, last_modified}, + [] si la collection n'existe pas (404), None si le listing a échoué. """ body = b""" @@ -649,9 +672,11 @@ def _propfind( ) if resp is not None and resp.status_code == 207: # Multi-Status return self._parse_propfind(resp.text, url) - return [] + if resp is not None and resp.status_code == 404: + return [] + return None - def _parse_propfind(self, xml_text: str, base_url: str) -> list[dict]: + def _parse_propfind(self, xml_text: str, base_url: str) -> Optional[list[dict]]: """Parse la réponse XML PROPFIND en liste de ressources.""" import xml.etree.ElementTree as ET @@ -700,6 +725,7 @@ def _parse_propfind(self, xml_text: str, base_url: str) -> list[dict]: }) except ET.ParseError as e: logger.error(f"Erreur parsing PROPFIND XML: {e}") + return None return resources @@ -746,6 +772,16 @@ def _deserialize_note(meta: dict, ink_pages: dict[int, dict]) -> Note: template=page_data.get("template", "blank"), typed_content=page_data.get("typed_content", ""), ) + # EN: Page() defaults updated_at to now(), which made every remote + # page look newer than any local edit, so a merge always took + # the remote text. Use the page's own timestamp when the server + # sends one, else the remote note's. + # FR: Sans ça, la page distante paraît toujours plus récente et + # écrase le texte local lors d'une fusion. + if page_data.get("updated_at"): + page.updated_at = datetime.fromisoformat(page_data["updated_at"]) + else: + page.updated_at = note.updated_at # Charger les strokes si disponibles if num in ink_pages: for s_data in ink_pages[num].get("strokes", []): @@ -785,12 +821,27 @@ def _serialize_note_meta(note: Note) -> dict: "page_number": p.page_number, "template": p.template, "typed_content": p.typed_content, + "updated_at": p.updated_at.isoformat(), } for p in note.pages ], } +def _carry_local_fields(remote: Note, local: Note) -> None: + """ + EN: note.json does not carry the notebook or the archived/deleted flags, + so a note deserialized from the server has none. Keep the local + values instead of wiping them every time a pull applies a remote + version. + FR: note.json ne transporte ni le carnet ni les drapeaux archivé / + supprimé : on garde les valeurs locales. + """ + remote.notebook_id = local.notebook_id + remote.is_archived = local.is_archived + remote.is_deleted = local.is_deleted + + def _serialize_ink_page(page: Page) -> dict: return { "page_id": page.id, @@ -911,6 +962,7 @@ def sync(self) -> SyncReport: """ report = SyncReport() report.dry_run = self.dry_run + self._unreadable_notebooks: set[str] = set() self.plan = SyncPlan() report.plan = self.plan logger.info( @@ -1029,7 +1081,16 @@ def _pull(self, report: SyncReport) -> None: self._pull_notebook(nb_slug, report) def _pull_notebook(self, nb_slug: str, report: SyncReport) -> None: - remote_notes = self.client.list_notes(nb_slug) + try: + remote_notes = self.client.list_notes(nb_slug) + except RemoteReadError as e: + # Its notes were not compared with the server, so pushing into + # this notebook could overwrite newer remote versions. + self._unreadable_notebooks.add(nb_slug) + msg = f"Erreur pull carnet {nb_slug}: {e}" + logger.error(msg) + report.errors.append(msg) + return logger.debug(f" Carnet {nb_slug} : {len(remote_notes)} notes") for note_entry in remote_notes: @@ -1191,13 +1252,23 @@ def _pull_note(self, nb_slug: str, note_slug: str, report: SyncReport) -> None: )) logger.info(f" ← Importée : {remote_note.title}") - elif local_note.sync_status == SyncStatus.MODIFIED: + elif local_note.sync_status in (SyncStatus.MODIFIED, SyncStatus.LOCAL_ONLY): + # LOCAL_ONLY with a remote twin: an earlier push reached the + # server but the note changed (or the push failed) before it + # could be marked synced, so it still holds unpushed edits. # Conflict path — local has unsynced edits and a remote copy # exists. Snapshot the local version *before* resolving so its # edits can be preserved even when the chosen strategy would # overwrite them. local_snapshot = copy.deepcopy(local_note) + _carry_local_fields(remote_note, local_note) result = self.resolver.resolve(local_note, remote_note) + # The resolver stamps its winner SYNCED, but unless the winner + # is the remote version as-is (local edits kept in a conflict + # copy, if any), it holds changes the server doesn't have yet. + # Leave it MODIFIED so this sync's push uploads them. + if result.winner is not remote_note: + result.winner.sync_status = SyncStatus.MODIFIED # A genuine conflict = both sides changed. The resolver reports # no conflict when the timestamps match (identical versions). @@ -1237,6 +1308,7 @@ def _pull_note(self, nb_slug: str, note_slug: str, report: SyncReport) -> None: else: # Pas de modification locale — appliquer la version distante si plus récente if remote_note.updated_at > local_note.updated_at: + _carry_local_fields(remote_note, local_note) self._apply_save_note(remote_note) report.notes_pulled += 1 plan.add_pull(remote_note.id, remote_note.title) @@ -1307,6 +1379,13 @@ def _push_note(self, note: Note, report: SyncReport) -> None: report.errors.append(f"Échec partiel push : {note.title} — {msg}") return + if nb_slug in getattr(self, "_unreadable_notebooks", ()): + report.errors.append( + f"Échec partiel push : {note.title} — notebook {nb_slug} " + f"could not be listed, push skipped" + ) + return + # Créer le dossier carnet sur le serveur si nécessaire nb_entries = self.client.list_notebooks() nb_names = {e["name"] for e in nb_entries} @@ -1358,9 +1437,13 @@ def _push_note(self, note: Note, report: SyncReport) -> None: page_reasons.append(page_reason) if meta_ok and pages_ok: - # Marquer comme SYNCED - note.sync_status = SyncStatus.SYNCED - self._apply_save_note(note, save_pages=False) + # Marquer comme SYNCED — unless the note was edited while it was + # uploading: re-saving our snapshot would revert that edit and + # mark it synced although the server never got it. + current = self.db.get_note(note.id, load_pages=False) + if current is not None and current.updated_at == note.updated_at: + current.sync_status = SyncStatus.SYNCED + self._apply_save_note(current, save_pages=False) report.notes_pushed += 1 self._ensure_plan().add_push(note.id, note.title) report.events.append(SyncEvent( diff --git a/nexanote/sync/webdav_provider.py b/nexanote/sync/webdav_provider.py index e06d9fd..726ce11 100644 --- a/nexanote/sync/webdav_provider.py +++ b/nexanote/sync/webdav_provider.py @@ -626,8 +626,10 @@ def close(self) -> None: page.typed_content = page_data.get( "typed_content", page.typed_content ) + _apply_client_timestamp(page, page_data.get("updated_at")) self.note.pages.sort(key=lambda p: p.page_number) - self.note.touch() + if not _apply_client_timestamp(self.note, payload.get("updated_at")): + self.note.touch() self.db.save_note(self.note) logger.info("Note mise à jour via WebDAV PUT : %s", self.note.title) except DAVError: @@ -637,6 +639,29 @@ def close(self) -> None: raise _safe_dav_error(exc, "saving note failed") from exc +def _apply_client_timestamp(obj, value) -> bool: + """ + EN: Keep the edit time a syncing client sends with note.json / page_N.ink + instead of stamping the upload time. Stamping made every uploaded + note look newer than edits made on the device right after the + upload, so the next pull put the old version back over them. + Returns False when no usable timestamp was sent. + FR: Conserve l'heure de modification envoyée par le client plutôt que + l'heure d'envoi, sinon la note paraît plus récente que les + modifications faites juste après sur l'appareil. + """ + if not isinstance(value, str) or not value: + return False + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return False + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + obj.updated_at = parsed + return True + + class InkFile(DAVNonCollection): """ page_N.ink — données de strokes manuscrits d'une page. @@ -776,9 +801,15 @@ def close(self) -> None: new_strokes.append(stroke) self.page.strokes = new_strokes - self.page.touch() + if _apply_client_timestamp(self.page, payload.get("updated_at")): + # The note's own timestamp arrives with note.json; only move + # it forward if this drawing is newer. + if self.page.updated_at > self.note.updated_at: + self.note.updated_at = self.page.updated_at + else: + self.page.touch() + self.note.touch() self.db.save_page(self.page) - self.note.touch() self.db.save_note(self.note, save_pages=False) logger.info( "Page %d mise à jour : %d strokes", diff --git a/tests/test_sync_reliability.py b/tests/test_sync_reliability.py index efbec6b..5fa60c7 100644 --- a/tests/test_sync_reliability.py +++ b/tests/test_sync_reliability.py @@ -396,6 +396,8 @@ def test_both_changed_preserves_local_copy(self, tmp_path): local = Note(id=REAL_ID, title="Doc", note_type=NoteType.TYPED) local.add_page().typed_content = "LOCAL-EDIT" local.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + # The page was edited along with its note, not after it. + local.pages[0].updated_at = local.updated_at local.sync_status = SyncStatus.MODIFIED engine.db.save_note(local) diff --git a/tests/test_webdav_sync_safety.py b/tests/test_webdav_sync_safety.py new file mode 100644 index 0000000..2f59a24 --- /dev/null +++ b/tests/test_webdav_sync_safety.py @@ -0,0 +1,239 @@ +""" +WebDAV sync engine against a real (in-process) WsgiDAV server: cases where +a sync used to lose or revert data. +""" + +import socket +import sys +import threading +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import pytest +import requests +from cheroot import wsgi as cheroot_wsgi + +from nexanote.models.note import InkStroke, Note, Notebook, NoteType, Point, SyncStatus +from nexanote.storage import FileNoteStore +from nexanote.sync.client import NexaNoteSyncEngine, SyncConfig +from nexanote.sync.server import build_app, ensure_default_notebook, ensure_storage_layout + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def server(tmp_path): + db = FileNoteStore(tmp_path / "server_store") + ensure_storage_layout(db) + ensure_default_notebook(db) + app = build_app(db, username="user", password="pass", verbose=False) + port = _free_port() + httpd = cheroot_wsgi.Server(bind_addr=("127.0.0.1", port), wsgi_app=app, numthreads=4) + threading.Thread(target=httpd.start, daemon=True).start() + url = f"http://127.0.0.1:{port}/" + deadline = time.time() + 5 + while time.time() < deadline: + try: + requests.options(url, timeout=1) + break + except requests.RequestException: + time.sleep(0.05) + yield {"url": url, "db": db} + httpd.stop() + + +@pytest.fixture +def engine_for(server): + def make(db): + cfg = SyncConfig( + server_url=server["url"], username="user", password="pass", + timeout_seconds=5, backoff_seconds=(0.0,), + ) + return NexaNoteSyncEngine(db, cfg) + return make + + +def _server_text(server_db, note_id): + note = server_db.get_note(note_id, load_pages=True) + return note.pages[0].typed_content if note and note.pages else None + + +def _edit_text(db, note_id, text): + """What PUT /notes/{id}/pages/1/text does.""" + note = db.get_note(note_id, load_pages=True) + page = note.get_page(1) + page.typed_content = text + page.touch() + note.touch() + db.save_page(page) + db.save_note(note, save_pages=False) + + +def _new_note(db, title="Doc", text="v1", **kwargs): + note = Note(title=title, **kwargs) + note.add_page().typed_content = text + db.save_note(note) + return note + + +def test_edit_of_synced_note_is_pushed(tmp_path, server, engine_for): + db = FileNoteStore(tmp_path / "device") + note = _new_note(db) + assert engine_for(db).sync().success() + + _edit_text(db, note.id, "v2") + report = engine_for(db).sync() + + assert report.success(), report.errors + assert _server_text(server["db"], note.id) == "v2" + assert db.get_note(note.id).sync_status == SyncStatus.SYNCED + + +def test_offline_text_edit_is_not_reverted_by_sync(tmp_path, server, engine_for): + db = FileNoteStore(tmp_path / "device") + note = _new_note(db) + assert engine_for(db).sync().success() + assert engine_for(db).sync().success() + + _edit_text(db, note.id, "my offline edit") + assert engine_for(db).sync().success() + + assert db.get_note(note.id).pages[0].typed_content == "my offline edit" + assert _server_text(server["db"], note.id) == "my offline edit" + + +def test_sync_round_trip_keeps_notebook_and_archived_flag(tmp_path, server, engine_for): + db = FileNoteStore(tmp_path / "device") + nb = Notebook(name="Work") + db.save_notebook(nb) + filed = _new_note(db, title="Meeting", notebook_id=nb.id) + archived = _new_note(db, title="Old stuff") + archived.is_archived = True + db.save_note(archived) + + for _ in range(3): + assert engine_for(db).sync().success() + + assert db.get_note(filed.id).notebook_id == nb.id + assert db.get_note(archived.id).is_archived is True + + +def _fail_propfind(engine, status=503): + real_request = engine.client.session.request + + def request(method, url, *args, **kwargs): + if method == "PROPFIND": + resp = requests.Response() + resp.status_code = status + resp._content = b"maintenance" + return resp + return real_request(method, url, *args, **kwargs) + + engine.client.session.request = request + + +@pytest.mark.parametrize("status", [401, 500, 503]) +def test_failed_listing_aborts_instead_of_overwriting_remote(tmp_path, server, engine_for, status): + a = FileNoteStore(tmp_path / "deviceA") + b = FileNoteStore(tmp_path / "deviceB") + note = _new_note(a) + assert engine_for(a).sync().success() + assert engine_for(b).sync().success() + + # B makes a newer edit and syncs it; A has an older, unsynced one. + _edit_text(a, note.id, "A older edit") + time.sleep(0.01) + _edit_text(b, note.id, "B newer edit") + assert engine_for(b).sync().success() + + engine = engine_for(a) + _fail_propfind(engine, status) + report = engine.sync() + + # Without a listing there was no pull to compare against, so nothing may + # be pushed, and the sync must not claim success. + assert not report.success() + assert _server_text(server["db"], note.id) == "B newer edit" + assert db_text(a, note.id) == "A older edit" + + +def db_text(db, note_id): + return db.get_note(note_id).pages[0].typed_content + + +def test_failed_ink_download_keeps_local_strokes(tmp_path, server, engine_for): + db = FileNoteStore(tmp_path / "device") + note = Note(title="Sketch", note_type=NoteType.HANDWRITTEN) + note.add_page().strokes.append(InkStroke(points=[Point(1, 1), Point(2, 2)])) + db.save_note(note) + assert engine_for(db).sync().success() + + engine = engine_for(db) + real_get = engine.client.session.get + + def get(url, *args, **kwargs): + if url.endswith(".ink"): + raise requests.ConnectionError("dropped") + return real_get(url, *args, **kwargs) + + engine.client.session.get = get + report = engine.sync() + + assert not report.success() + assert len(db.get_note(note.id).pages[0].strokes) == 1 + + +def test_edit_made_during_upload_is_not_marked_synced(tmp_path, server, engine_for): + db = FileNoteStore(tmp_path / "device") + note = _new_note(db) + engine = engine_for(db) + real_put = engine.client.put_note_meta + edited = [] + + def put_then_edit(*args, **kwargs): + result = real_put(*args, **kwargs) + if not edited: + edited.append(True) + time.sleep(0.01) + _edit_text(db, note.id, "typed while syncing") + return result + + engine.client.put_note_meta = put_then_edit + assert engine.sync().success() + + local = db.get_note(note.id) + assert local.pages[0].typed_content == "typed while syncing" + assert local.sync_status != SyncStatus.SYNCED + + assert engine_for(db).sync().success() + assert _server_text(server["db"], note.id) == "typed while syncing" + + +def test_network_drop_after_ping_fails_fast(tmp_path, server, engine_for): + db = FileNoteStore(tmp_path / "device") + for i in range(10): + _new_note(db, title=f"n{i}") + engine = engine_for(db) + real_request = engine.client.session.request + calls = [] + + def dead(method, url, *args, **kwargs): + if method == "OPTIONS": + return real_request(method, url, *args, **kwargs) + calls.append(method) + raise requests.ConnectTimeout("timeout") + + engine.client.session.request = dead + engine.client.session.put = lambda url, *a, **kw: dead("PUT", url, *a, **kw) + report = engine.sync() + + assert not report.success() + # The root listing fails, so the sync stops there instead of retrying + # the whole PROPFIND/MKCOL/PUT chain for every note. + assert len(calls) <= 3 From 135d88aa0dca95623cf1c608695899fa1596acbd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:12:58 +0000 Subject: [PATCH 10/44] fix: refuse a second sync while one runs on the same data dir POST /sync/trigger built a fresh engine per request with no lock, so two quick taps on Sync (or a manual sync during an automatic one) ran two sessions at once. They resolved the same conflicts twice and wrote duplicate conflict copies. The engine now takes a non-blocking lock per data directory and reports "sync already running" to the second caller. --- nexanote/sync/client.py | 31 +++++++++++++++++++++ tests/test_sync_reliability.py | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/nexanote/sync/client.py b/nexanote/sync/client.py index 66eb6bd..d41bf68 100644 --- a/nexanote/sync/client.py +++ b/nexanote/sync/client.py @@ -15,6 +15,7 @@ import copy import json import logging +import threading import time import uuid from dataclasses import dataclass, field @@ -887,6 +888,16 @@ def _make_local_conflict_copy(local: Note) -> Note: return conflict_copy +_SYNC_LOCKS: dict[str, threading.Lock] = {} +_SYNC_LOCKS_GUARD = threading.Lock() + + +def _sync_lock_for(data_dir: Path) -> threading.Lock: + key = str(Path(data_dir).resolve()) + with _SYNC_LOCKS_GUARD: + return _SYNC_LOCKS.setdefault(key, threading.Lock()) + + class NexaNoteSyncEngine: """ Moteur de synchronisation principal. @@ -951,6 +962,26 @@ def _apply_touch_ignored(self, remote_path: str) -> None: # ------------------------------------------------------------------ def sync(self) -> SyncReport: + """ + EN: Run a sync session unless one is already running on the same data + directory (two quick taps on "Sync", a manual sync during an + automatic one). Overlapping sessions resolved the same conflicts + twice and wrote duplicate conflict copies. + FR: Refuse une session concurrente sur le même dossier de données. + """ + lock = _sync_lock_for(self.db.data_dir) + if not lock.acquire(blocking=False): + report = SyncReport() + report.dry_run = self.dry_run + report.errors.append("Une synchronisation est déjà en cours") + report.finish() + return report + try: + return self._sync() + finally: + lock.release() + + def _sync(self) -> SyncReport: """ EN: Run a full sync session: ping → pull → push. The ``SyncPlan`` is built as decisions are made; in dry-run no files, sync state, or diff --git a/tests/test_sync_reliability.py b/tests/test_sync_reliability.py index 5fa60c7..a1bdcdb 100644 --- a/tests/test_sync_reliability.py +++ b/tests/test_sync_reliability.py @@ -774,3 +774,52 @@ def test_successful_retry_still_produces_normal_report(self, tmp_path): # The retried PUT is still visible in the diagnostics (2 attempts). assert any(a["operation"] == "PUT" and a["attempts"] == 2 for a in report.operation_attempts) + + +class TestOverlappingSyncs: + def test_second_sync_on_same_data_dir_is_refused_while_one_runs(self, tmp_path): + import threading + + layout = { + "carnet__01234567": { + f"remote__{REAL_ID[:8]}": _meta( + REAL_ID, "Doc", "REMOTE-EDIT", "2026-06-01T00:00:00+00:00" + ), + } + } + first, stub = _make_engine(tmp_path / "client", layout) + local = Note(id=REAL_ID, title="Doc", note_type=NoteType.TYPED) + local.add_page().typed_content = "LOCAL-EDIT" + local.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc) + local.pages[0].updated_at = local.updated_at + local.sync_status = SyncStatus.MODIFIED + first.db.save_note(local) + + in_pull = threading.Event() + release = threading.Event() + real_get_meta = stub.get_note_meta + + def slow_get_meta(nb, note): + in_pull.set() + release.wait(5) + return real_get_meta(nb, note) + + stub.get_note_meta = slow_get_meta + reports = {} + t = threading.Thread(target=lambda: reports.setdefault("first", first.sync())) + t.start() + assert in_pull.wait(5) + + second, _ = _make_engine(tmp_path / "client", layout) + reports["second"] = second.sync() + release.set() + t.join(5) + + assert not reports["second"].success() + assert "déjà en cours" in reports["second"].errors[0] + assert reports["first"].success(), reports["first"].errors + titles = [n.title for n in first.db.list_notes(include_archived=True)] + assert sum("conflit" in t for t in titles) == 1, titles + + # Once the first one is done, syncing works again. + assert _make_engine(tmp_path / "client", layout)[0].sync().success() From cc11dd63a659ed8e930f1951f69b4b7d4b0ea5fb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:14:00 +0000 Subject: [PATCH 11/44] fix: don't delete an existing note on a WebDAV PUT with another id When note.json arrived with an id different from the note behind the path, the provider assumed that note was an MKCOL placeholder and deleted it permanently. But the path can also resolve to a real note through the title-slug or id-prefix fallbacks, so another client writing to a colliding path destroyed it, drawings included. Only an empty placeholder minted from the same id prefix is replaced now; anything else gets a 409. --- nexanote/sync/webdav_provider.py | 20 ++++++++++++++++++++ tests/test_webdav_sync_safety.py | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/nexanote/sync/webdav_provider.py b/nexanote/sync/webdav_provider.py index 726ce11..e0e9b04 100644 --- a/nexanote/sync/webdav_provider.py +++ b/nexanote/sync/webdav_provider.py @@ -589,6 +589,19 @@ def close(self) -> None: and payload_id and payload_id != self.note.id ): + # EN: The note behind this path can also be a real note found + # by the title-slug or id-prefix fallbacks. Only an empty + # MKCOL placeholder minted from this same id prefix may be + # replaced; deleting anything else would destroy a note + # (and its drawings) because another client wrote to a + # colliding path. + # FR: Seul un placeholder vide créé par MKCOL avec le même + # préfixe d'ID peut être remplacé. + if not _is_placeholder_for(self.note, payload_id): + raise DAVError( + HTTP_CONFLICT, + context_info="note.json id does not match the note at this path", + ) old_id = self.note.id self.note.id = payload_id for page in self.note.pages: @@ -639,6 +652,13 @@ def close(self) -> None: raise _safe_dav_error(exc, "saving note failed") from exc +def _is_placeholder_for(note: Note, payload_id: str) -> bool: + """True for the empty note MKCOL creates ahead of a client's note.json.""" + if note.id[:8] != payload_id[:8]: + return False + return all(not p.typed_content and not p.strokes for p in note.pages) + + def _apply_client_timestamp(obj, value) -> bool: """ EN: Keep the edit time a syncing client sends with note.json / page_N.ink diff --git a/tests/test_webdav_sync_safety.py b/tests/test_webdav_sync_safety.py index 2f59a24..6865b57 100644 --- a/tests/test_webdav_sync_safety.py +++ b/tests/test_webdav_sync_safety.py @@ -237,3 +237,26 @@ def dead(method, url, *args, **kwargs): # The root listing fails, so the sync stops there instead of retrying # the whole PROPFIND/MKCOL/PUT chain for every note. assert len(calls) <= 3 + + +def test_put_with_a_foreign_id_does_not_delete_the_note_at_that_path(server): + sdb = server["db"] + nb = [n for n in sdb.list_notebooks() if n.id.startswith("00000000")][0] + victim = Note(title="My Note", notebook_id=nb.id) + page = victim.add_page() + page.typed_content = "victim text" + page.strokes.append(InkStroke(points=[Point(1, 1), Point(2, 2)])) + sdb.save_note(victim) + + # "uncategorized/my-note" resolves to the victim through the title-slug + # fallback; the body belongs to an unrelated note. + body = {"id": "ffffffff-0000-0000-0000-000000000001", "title": "Other", + "pages": [{"page_number": 1, "typed_content": "other"}]} + resp = requests.put(server["url"] + "uncategorized/my-note/note.json", + json=body, auth=("user", "pass"), timeout=5) + + assert resp.status_code == 409 + kept = sdb.get_note(victim.id, load_pages=True) + assert kept is not None + assert kept.pages[0].typed_content == "victim text" + assert len(kept.pages[0].strokes) == 1 From 5501c0d7d6b6052988deb5eb75ba0ea9e1d9a47b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:17:37 +0000 Subject: [PATCH 12/44] fix: skip unreadable files instead of failing whole listings Only OSError was caught while listing notes and notebooks, so one file with invalid UTF-8, a malformed frontmatter value or an id-less plain sidecar made GET /notes, /notebooks, /stats and the WebDAV listing (and so every sync) fail with a 500. Those files are now logged and skipped, and left untouched on disk. Timestamps without a timezone (hand edits, YAML dates) are read as UTC instead of being skipped: mixing them with aware datetimes broke the sort of the whole notes list. --- nexanote/storage/file_store.py | 59 ++++++++++++------ nexanote/storage/plain_store.py | 36 ++++++----- tests/test_corrupt_files.py | 103 ++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 tests/test_corrupt_files.py diff --git a/nexanote/storage/file_store.py b/nexanote/storage/file_store.py index 42dfa2d..6588b5c 100644 --- a/nexanote/storage/file_store.py +++ b/nexanote/storage/file_store.py @@ -88,12 +88,21 @@ def _now() -> datetime: def _parse_dt(value) -> datetime: - """Parse an ISO datetime string (or pass through datetime).""" + """Parse an ISO datetime string (or pass through datetime). + + Timestamps without a timezone (hand-edited frontmatter, YAML's + `2024-01-01 10:00`) are read as UTC: mixing naive and aware datetimes + makes comparisons and sorts raise. + """ if isinstance(value, datetime): - return value - if isinstance(value, str): - return datetime.fromisoformat(value) - raise TypeError(f"cannot parse datetime from {type(value).__name__}") + parsed = value + elif isinstance(value, str): + parsed = datetime.fromisoformat(value) + else: + raise TypeError(f"cannot parse datetime from {type(value).__name__}") + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed def _fmt_dt(dt: datetime) -> str: @@ -552,7 +561,9 @@ def list_notebooks(self, include_archived: bool = False) -> list[Notebook]: for path in sorted(self.notebooks_dir.glob("*.yaml")): try: nb = deserialize_notebook(path.read_text(encoding="utf-8")) - except OSError as exc: + except Exception as exc: + # One unreadable or malformed file must not hide every other + # notebook. The file itself is left untouched. logger.warning(f"skip unreadable notebook {path}: {exc}") continue if nb is None: @@ -660,14 +671,9 @@ def list_notes( out: list[Note] = [] needle = search_title.lower() if search_title else None for path in self.notes_dir.glob("*.md"): - try: - md_text = path.read_text(encoding="utf-8") - except OSError as exc: - logger.warning(f"skip unreadable note {path}: {exc}") - continue - note = deserialize_note(md_text, None) + note = self._read_listed_note(path) if note is None: - note = synthesize_plain_md_note(path, md_text) + continue note.pages = [] # listings are metadata-only if not include_deleted and note.is_deleted: @@ -683,6 +689,25 @@ def list_notes( out.sort(key=lambda n: n.updated_at, reverse=True) return out + @staticmethod + def _read_listed_note(path: Path) -> Optional[Note]: + """ + EN: Read one note for a listing, or None (logged) if the file can't be + read or parsed: invalid UTF-8, malformed frontmatter, unknown + enum values... One bad file used to make GET /notes, /stats and + the sync push fail as a whole. The file itself is left untouched. + FR: Lit une note pour un listing ; None si le fichier est illisible. + """ + try: + md_text = path.read_text(encoding="utf-8") + note = deserialize_note(md_text, None) + if note is None: + note = synthesize_plain_md_note(path, md_text) + return note + except Exception as exc: + logger.warning(f"skip unreadable note {path}: {exc}") + return None + def delete_note_permanent(self, note_id: str) -> None: """Permanent delete (purge from trash).""" note_path = self._note_path(note_id) @@ -779,13 +804,9 @@ def get_stats(self) -> dict: notebooks += 1 for path in self.notes_dir.glob("*.md"): - try: - md_text = path.read_text(encoding="utf-8") - except OSError: - continue - note = deserialize_note(md_text, None) + note = self._read_listed_note(path) if note is None: - note = synthesize_plain_md_note(path, md_text) + continue if note.is_deleted: notes_deleted += 1 continue diff --git a/nexanote/storage/plain_store.py b/nexanote/storage/plain_store.py index 59a1332..225d9f5 100644 --- a/nexanote/storage/plain_store.py +++ b/nexanote/storage/plain_store.py @@ -221,7 +221,9 @@ def list_notebooks(self, include_archived: bool = False) -> list[Notebook]: for path in sorted(self.notebooks_dir.glob("*.yaml")): try: nb = deserialize_notebook(path.read_text(encoding="utf-8")) - except OSError as exc: + except Exception as exc: + # One unreadable or malformed file must not hide every other + # notebook. The file itself is left untouched. logger.warning(f"skip unreadable notebook {path}: {exc}") continue if nb is None: @@ -366,19 +368,21 @@ def list_notes( needle = search_title.lower() if search_title else None for md_path in self.notes_dir.glob("*.md"): sidecar_path = self._sidecar_path(md_path.stem) - if sidecar_path.exists(): - data = self._safe_read_sidecar(sidecar_path) - if data is None: - continue - # Read the body lazily — only when required to build pages. - note = self._note_from_sidecar(data, md_text="") - else: - try: + try: + if sidecar_path.exists(): + data = self._safe_read_sidecar(sidecar_path) + if data is None: + continue + # Read the body lazily — only when required to build pages. + note = self._note_from_sidecar(data, md_text="") + else: text = md_path.read_text(encoding="utf-8") - except OSError as exc: - logger.warning(f"skip unreadable note {md_path}: {exc}") - continue - note = synthesize_plain_md_note(md_path, text) + note = synthesize_plain_md_note(md_path, text) + except Exception as exc: + # A malformed sidecar or non-UTF-8 file must not make the + # whole listing fail. The files are left untouched. + logger.warning(f"skip unreadable note {md_path}: {exc}") + continue note.pages = [] # listings stay metadata-only if not include_deleted and note.is_deleted: @@ -494,7 +498,11 @@ def get_stats(self) -> dict: notebooks += 1 for md_path in self.notes_dir.glob("*.md"): - note = self._read_note_from_stem(md_path.stem, load_pages=True) + try: + note = self._read_note_from_stem(md_path.stem, load_pages=True) + except Exception as exc: + logger.warning(f"skip unreadable note {md_path}: {exc}") + continue if note is None: continue if note.is_deleted: diff --git a/tests/test_corrupt_files.py b/tests/test_corrupt_files.py new file mode 100644 index 0000000..7341dde --- /dev/null +++ b/tests/test_corrupt_files.py @@ -0,0 +1,103 @@ +""" +A single unreadable or malformed file in the data directory (hand edits in +Obsidian, a half-copied file on the NAS, a non-UTF-8 export) must not take +down the listings the app and the sync depend on. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import pytest +from fastapi.testclient import TestClient + +from nexanote.api.routes import create_app +from nexanote.storage import FileNoteStore, PlainMarkdownNoteStore + + +@pytest.fixture(params=[FileNoteStore, PlainMarkdownNoteStore]) +def api(request, tmp_path): + db = request.param(tmp_path / "data") + with TestClient(create_app(db), raise_server_exceptions=False) as c: + yield c, db + db.close() + + +@pytest.fixture +def file_api(tmp_path): + """Frontmatter store: metadata lives in the .md file itself.""" + db = FileNoteStore(tmp_path / "data") + with TestClient(create_app(db), raise_server_exceptions=False) as c: + yield c, db + db.close() + + +def _assert_listings_still_work(c, good_title="good"): + for route in ("/notes", "/notebooks", "/stats", "/health"): + assert c.get(route).status_code == 200, route + assert good_title in [n["title"] for n in c.get("/notes").json()] + + +def test_invalid_utf8_note(api): + c, db = api + c.post("/notes", json={"title": "good"}) + (db.notes_dir / "latin1.md").write_bytes("caf\xe9 cr\xe8me".encode("latin-1")) + _assert_listings_still_work(c) + + +@pytest.mark.parametrize("bad", [ + "note_type: bogus", + "sync_status: weird", + "updated_at: 'not a date'", + "tags: 5", +]) +def test_bad_frontmatter_value(file_api, bad): + c, db = file_api + c.post("/notes", json={"title": "good"}) + (db.notes_dir / "bad.md").write_text( + f"---\nid: bad\ntitle: x\n{bad}\n---\nbody\n", encoding="utf-8" + ) + _assert_listings_still_work(c) + + +def test_timestamp_without_timezone_is_read_as_utc(file_api): + c, db = file_api + c.post("/notes", json={"title": "good"}) + (db.notes_dir / "handedited.md").write_text( + "---\nid: hand\ntitle: hand edited\nupdated_at: 2026-01-01T10:00:00\n---\nbody\n", + encoding="utf-8", + ) + _assert_listings_still_work(c) + # Not skipped: a naive timestamp is valid data. + assert "hand edited" in [n["title"] for n in c.get("/notes").json()] + + +def test_bad_notebook_file(api): + c, db = api + c.post("/notes", json={"title": "good"}) + c.post("/notebooks", json={"name": "ok"}) + (db.notebooks_dir / "x.yaml").write_bytes(b"id: x\nname: \xff\xfe\n") + _assert_listings_still_work(c) + assert [nb["name"] for nb in c.get("/notebooks").json()] == ["ok"] + + +def test_plain_sidecar_without_id(tmp_path): + db = PlainMarkdownNoteStore(tmp_path / "data") + with TestClient(create_app(db), raise_server_exceptions=False) as c: + c.post("/notes", json={"title": "good"}) + (db.notes_dir / "Recipe.md").write_text("# Recipe\n", encoding="utf-8") + (db.notes_dir / "Recipe.json").write_text('{"servings": 4}', encoding="utf-8") + _assert_listings_still_work(c) + db.close() + + +def test_corrupt_file_is_left_untouched(api): + c, db = api + c.post("/notes", json={"title": "good"}) + raw = "caf\xe9".encode("latin-1") + path = db.notes_dir / "latin1.md" + path.write_bytes(raw) + c.get("/notes") + c.get("/stats") + assert path.read_bytes() == raw From 7f7bd49a28bdf829e0082023ab80c6b50edf553b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:18:47 +0000 Subject: [PATCH 13/44] fix: keep note text that contains a page-marker line Single-page notes are written without page markers, but the reader split the body on any `` line it found. A note whose text contained that line came back empty, in both stores. The body is only split now when the metadata lists more than one page. --- nexanote/storage/file_store.py | 9 ++++--- nexanote/storage/plain_store.py | 6 ++++- tests/test_page_marker_content.py | 39 +++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 tests/test_page_marker_content.py diff --git a/nexanote/storage/file_store.py b/nexanote/storage/file_store.py index 6588b5c..f0d1fbe 100644 --- a/nexanote/storage/file_store.py +++ b/nexanote/storage/file_store.py @@ -212,15 +212,18 @@ def _join_frontmatter(meta: dict, body: str) -> str: return f"{FRONTMATTER_DELIM}\n{yaml_text}\n{FRONTMATTER_DELIM}\n\n{body_part}" -def _split_pages_body(body: str) -> dict[int, str]: +def _split_pages_body(body: str, page_count: Optional[int] = None) -> dict[int, str]: """ EN: Extract per-page content from the markdown body. - With no markers: the entire body belongs to page 1. - With `` markers: split accordingly. + A note whose metadata lists a single page is written without + markers (see `_join_pages_body`), so its body is never split: text + that happens to contain a marker line would otherwise be lost. FR: Extrait le contenu page par page depuis le corps markdown. """ matches = list(PAGE_MARKER_RE.finditer(body)) - if not matches: + if not matches or (page_count is not None and page_count <= 1): return {1: body.rstrip("\n")} pages: dict[int, str] = {} @@ -336,8 +339,8 @@ def deserialize_note(md_text: str, drawings: Optional[dict]) -> Optional[Note]: if not meta or "id" not in meta: return None - pages_text = _split_pages_body(body) pages_meta = meta.get("pages") or [] + pages_text = _split_pages_body(body, page_count=len(pages_meta)) # Strokes per page_number strokes_by_page: dict[int, list[InkStroke]] = {} diff --git a/nexanote/storage/plain_store.py b/nexanote/storage/plain_store.py index 225d9f5..ae091eb 100644 --- a/nexanote/storage/plain_store.py +++ b/nexanote/storage/plain_store.py @@ -555,7 +555,11 @@ def _build_sidecar(self, note: Note) -> dict: } def _note_from_sidecar(self, sidecar: dict, md_text: str) -> Note: - pages_text = _split_pages_body(md_text) if md_text else {} + pages_text = ( + _split_pages_body(md_text, page_count=len(sidecar.get("pages") or [])) + if md_text + else {} + ) pages: list[Page] = [] for pm in sidecar.get("pages") or []: num = int(pm.get("page_number", 1)) diff --git a/tests/test_page_marker_content.py b/tests/test_page_marker_content.py new file mode 100644 index 0000000..0562777 --- /dev/null +++ b/tests/test_page_marker_content.py @@ -0,0 +1,39 @@ +""" +Note text that contains the page-marker comment must round-trip intact. +Single-page notes are stored without markers, so a marker line in the text +used to be read as a page split and everything around it was lost. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import pytest + +from nexanote.models.note import Note +from nexanote.storage import FileNoteStore, PlainMarkdownNoteStore + +TEXT = "intro\n\nmore" + + +@pytest.mark.parametrize("store_cls", [FileNoteStore, PlainMarkdownNoteStore]) +def test_marker_line_in_single_page_note_survives(tmp_path, store_cls): + db = store_cls(tmp_path / "store") + note = Note(title="doc") + note.add_page().typed_content = TEXT + db.save_note(note) + + assert db.get_note(note.id).pages[0].typed_content == TEXT + + +@pytest.mark.parametrize("store_cls", [FileNoteStore, PlainMarkdownNoteStore]) +def test_multi_page_notes_still_split_by_page(tmp_path, store_cls): + db = store_cls(tmp_path / "store") + note = Note(title="doc") + note.add_page().typed_content = "first page" + note.add_page().typed_content = "second page" + db.save_note(note) + + pages = db.get_note(note.id).pages + assert [p.typed_content for p in pages] == ["first page", "second page"] From e07f4525b4efeeb7cfd38c2f92b8ae96312c6b49 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:19:44 +0000 Subject: [PATCH 14/44] fix: reject plain-Markdown ids that point outside the notes dir The stem decoded from a synthetic md. id was joined into notes_dir as-is, so an id built from "../../x" let GET and PUT on /notes/{id} read and write .md files anywhere the backend can reach. The API has no auth and allows any origin, so a web page could do it. Such stems are now rejected and the id resolves to nothing (404). --- nexanote/storage/file_store.py | 10 +++++++++- tests/test_path_traversal.py | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/test_path_traversal.py diff --git a/nexanote/storage/file_store.py b/nexanote/storage/file_store.py index f0d1fbe..107a578 100644 --- a/nexanote/storage/file_store.py +++ b/nexanote/storage/file_store.py @@ -863,9 +863,17 @@ def stem_from_plain_md_id(note_id: str) -> Optional[str]: return None pad = (-len(encoded)) % 4 try: - return base64.urlsafe_b64decode(encoded + "=" * pad).decode("utf-8") + stem = base64.urlsafe_b64decode(encoded + "=" * pad).decode("utf-8") except (ValueError, UnicodeDecodeError): return None + # EN: The stem is joined into notes_dir, so it must name a file in that + # directory: an id built from "../../x" would otherwise let the API + # read and write `.md` files anywhere on disk. + # FR: Le stem doit désigner un fichier de notes_dir, sinon un id forgé + # ("../../x") sortirait du dossier. + if stem in ("", ".", "..") or any(c in stem for c in ("/", "\\", "\0")): + return None + return stem def synthesize_plain_md_note(path: Path, md_text: str) -> Note: diff --git a/tests/test_path_traversal.py b/tests/test_path_traversal.py new file mode 100644 index 0000000..052cfe7 --- /dev/null +++ b/tests/test_path_traversal.py @@ -0,0 +1,36 @@ +""" +Synthetic `md.` ids address plain Markdown files by name, so a +forged id must not reach files outside the notes directory. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import pytest +from fastapi.testclient import TestClient + +from nexanote.api.routes import create_app +from nexanote.storage import FileNoteStore, PlainMarkdownNoteStore +from nexanote.storage.file_store import plain_md_id_from_stem + + +@pytest.fixture(params=[FileNoteStore, PlainMarkdownNoteStore]) +def api(request, tmp_path): + db = request.param(tmp_path / "data") + with TestClient(create_app(db), raise_server_exceptions=False) as c: + yield c, db + db.close() + + +@pytest.mark.parametrize("stem", ["../../outside_secret", "..\\..\\outside_secret", ".."]) +def test_plain_md_id_cannot_escape_the_notes_dir(api, tmp_path, stem): + c, db = api + (tmp_path / "outside_secret.md").write_text("TOP SECRET", encoding="utf-8") + evil_id = plain_md_id_from_stem(stem) + + assert c.get(f"/notes/{evil_id}").status_code == 404 + assert c.put(f"/notes/{evil_id}/pages/1/text", + json={"typed_content": "pwned"}).status_code == 404 + assert (tmp_path / "outside_secret.md").read_text(encoding="utf-8") == "TOP SECRET" From 77a6378a7c8e8f8719eb188478dc8d3e86b7406b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:20:45 +0000 Subject: [PATCH 15/44] fix: validate note_type and conflict_strategy in the REST API POST /notes with an unknown note_type crashed in NoteType(...) with a 500. /sync/configure accepted any conflict_strategy, saved it to disk, and every later /sync/trigger then failed with a 500 until the user reconfigured sync. Both fields are typed as their enums now, so bad values get a 422 and nothing is saved. --- nexanote/api/routes.py | 10 +++++++--- tests/test_api.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/nexanote/api/routes.py b/nexanote/api/routes.py index a0ea6f4..874ed20 100644 --- a/nexanote/api/routes.py +++ b/nexanote/api/routes.py @@ -57,6 +57,7 @@ ) from nexanote.storage.file_store import FileNoteStore from nexanote.sync.client import NexaNoteSyncEngine, SyncConfig, SyncReport +from nexanote.sync.conflict import ConflictStrategy logger = logging.getLogger("nexanote.api") @@ -92,7 +93,8 @@ class PageSchema(BaseModel): class NoteCreateSchema(BaseModel): title: str = "Sans titre" - note_type: str = "typed" + # Typed so an unknown value is a 422, not a 500 from NoteType(...). + note_type: NoteType = NoteType.TYPED notebook_id: Optional[str] = None tags: list[str] = Field(default_factory=list) template: str = "blank" @@ -161,7 +163,9 @@ class SyncConfigSchema(BaseModel): server_url: str username: str = "nexanote" password: str = "nexanote" - conflict_strategy: str = "merge_strokes" + # Typed so a bad value is rejected up front instead of being saved and + # making every later /sync/trigger fail. + conflict_strategy: ConflictStrategy = ConflictStrategy.MERGE_STROKES class SyncReportSchema(BaseModel): @@ -590,7 +594,7 @@ def configure_sync(config: SyncConfigSchema): FR: Sauvegarde les paramètres WebDAV en mémoire et persiste les champs sûrs sur disque. Le mot de passe reste en mémoire uniquement. """ - _sync_config.update(config.model_dump()) + _sync_config.update(config.model_dump(mode="json")) _save_sync_config_to_disk() return {"status": "configured", "server_url": config.server_url} diff --git a/tests/test_api.py b/tests/test_api.py index d457254..d1493a9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -313,3 +313,20 @@ def test_sync_status_empty(self, client): resp = client.get("/sync/status") assert resp.status_code == 200 assert resp.json()["status"] == "never_synced" + + +class TestInputValidation: + def test_unknown_note_type_is_rejected_with_422(self, client): + resp = client.post("/notes", json={"title": "x", "note_type": "drawing"}) + assert resp.status_code == 422 + + def test_unknown_conflict_strategy_is_rejected_and_not_saved(self, client): + resp = client.post("/sync/configure", json={ + "server_url": "http://nas.local/", "conflict_strategy": "newest", + }) + assert resp.status_code == 422 + + ok = client.post("/sync/configure", json={ + "server_url": "http://nas.local/", "conflict_strategy": "keep_both", + }) + assert ok.status_code == 200 From bd4995850c03690a73a3204ecc1d0cd5b525c0ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:22:52 +0000 Subject: [PATCH 16/44] fix: keep ink strokes tied to the pointer that started them The canvas fed every pointer into the same current stroke: - A palm or finger touching down while writing with the stylus started a new stroke over the one in progress, so the stylus stroke was lost and the palm's was saved instead. - A two-finger pinch to zoom always left a zig-zag stroke between the fingers. - Stylus plus palm counted as a two-pointer pinch and zoomed the page under the pen. - Right or middle mouse clicks drew. The canvas now tracks the one pointer that is drawing, drops the stroke when a second finger turns the touch into a pinch, and only zooms while nothing is drawing. --- app/lib/widgets/ink_canvas.dart | 64 +++++++++++++++---- app/test/widgets/ink_canvas_test.dart | 92 +++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 13 deletions(-) create mode 100644 app/test/widgets/ink_canvas_test.dart diff --git a/app/lib/widgets/ink_canvas.dart b/app/lib/widgets/ink_canvas.dart index 5a35c6a..47356be 100644 --- a/app/lib/widgets/ink_canvas.dart +++ b/app/lib/widgets/ink_canvas.dart @@ -255,6 +255,14 @@ class _InkCanvasState extends State { final List> _undoStack = []; InkStrokeData? _currentStroke; + /// The one pointer currently drawing. Other pointers (a palm, a second + /// finger) must not add points to its stroke or start a new one over it. + int? _drawingPointer; + + /// Touch pointers currently down. Two or more is a pinch/pan gesture, not + /// ink, so any stroke started by the first finger is discarded. + final Set _touchPointers = {}; + // Outils InkTool _tool = InkTool.pen; Color _color = Colors.black; @@ -356,6 +364,42 @@ class _InkCanvasState extends State { widget.onStrokesChanged([]); } + void _onPointerDown(PointerDownEvent e) { + if (e.kind == PointerDeviceKind.touch) { + _touchPointers.add(e.pointer); + if (_touchPointers.length > 1) { + // Second finger: this is a pinch/pan. Drop the stroke the first + // finger started instead of committing a zig-zag between them. + if (_drawingPointer != null && + _touchPointers.contains(_drawingPointer)) { + _drawingPointer = null; + setState(() => _currentStroke = null); + } + return; + } + } + if (_drawingPointer != null) return; + final drawable = switch (e.kind) { + PointerDeviceKind.stylus || + PointerDeviceKind.invertedStylus || + PointerDeviceKind.touch => + true, + PointerDeviceKind.mouse => e.buttons == kPrimaryMouseButton, + _ => false, + }; + if (!drawable) return; + _drawingPointer = e.pointer; + final pressure = e.pressure > 0 ? e.pressure : 0.5; + _startStroke(_toCanvasPos(e.localPosition), pressure); + } + + void _onPointerEnd(PointerEvent e) { + _touchPointers.remove(e.pointer); + if (e.pointer != _drawingPointer) return; + _drawingPointer = null; + _endStroke(); + } + Offset _toCanvasPos(Offset localPos) { return Offset( (localPos.dx - _offset.dx) / _scale, @@ -388,29 +432,23 @@ class _InkCanvasState extends State { Expanded( child: ClipRect( child: Listener( - onPointerDown: (e) { - if (e.kind == PointerDeviceKind.stylus || - e.kind == PointerDeviceKind.invertedStylus || - e.kind == PointerDeviceKind.mouse || - e.kind == PointerDeviceKind.touch) { - final pressure = e.pressure > 0 ? e.pressure : 0.5; - _startStroke(_toCanvasPos(e.localPosition), pressure); - } - }, + onPointerDown: _onPointerDown, onPointerMove: (e) { - if (_currentStroke == null) return; + if (e.pointer != _drawingPointer) return; final pressure = e.pressure > 0 ? e.pressure : 0.5; _addPoint(_toCanvasPos(e.localPosition), pressure); }, - onPointerUp: (_) => _endStroke(), - onPointerCancel: (_) => _endStroke(), + onPointerUp: _onPointerEnd, + onPointerCancel: _onPointerEnd, child: GestureDetector( // Zoom avec pinch onScaleStart: (d) { _lastFocalPoint = d.focalPoint; }, onScaleUpdate: (d) { - if (d.pointerCount == 2) { + // Never zoom under a pen that is drawing (stylus + palm + // also counts as two pointers). + if (d.pointerCount == 2 && _drawingPointer == null) { setState(() { _scale = (_scale * d.scale).clamp(0.5, 5.0); final delta = d.focalPoint - _lastFocalPoint; diff --git a/app/test/widgets/ink_canvas_test.dart b/app/test/widgets/ink_canvas_test.dart new file mode 100644 index 0000000..062678a --- /dev/null +++ b/app/test/widgets/ink_canvas_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:nexanote/widgets/ink_canvas.dart'; + +void main() { + late List>> saves; + + Future pumpCanvas(WidgetTester tester) async { + saves = []; + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(800, 900); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: InkCanvas( + initialStrokes: const [], + template: 'blank', + onStrokesChanged: saves.add, + ), + ), + )); + } + + Offset canvasPoint(WidgetTester tester, double dx, double dy) => + tester.getTopLeft(find.byType(CustomPaint).last) + Offset(dx, dy); + + testWidgets('a one-finger drag draws one stroke', (tester) async { + await pumpCanvas(tester); + final g = await tester.startGesture(canvasPoint(tester, 100, 200), + kind: PointerDeviceKind.touch); + await g.moveBy(const Offset(30, 0)); + await g.moveBy(const Offset(30, 0)); + await g.up(); + await tester.pump(); + + expect(saves, hasLength(1)); + expect(saves.single, hasLength(1)); + }); + + testWidgets('a two-finger pinch does not leave a stroke behind', + (tester) async { + await pumpCanvas(tester); + final a = await tester.startGesture(canvasPoint(tester, 100, 200), + pointer: 1, kind: PointerDeviceKind.touch); + final b = await tester.startGesture(canvasPoint(tester, 300, 400), + pointer: 2, kind: PointerDeviceKind.touch); + for (var i = 0; i < 5; i++) { + await a.moveBy(const Offset(-10, -10)); + await b.moveBy(const Offset(10, 10)); + } + await a.up(); + await b.up(); + await tester.pump(); + + expect(saves, isEmpty); + }); + + testWidgets('a second finger does not hijack the stroke being drawn', + (tester) async { + await pumpCanvas(tester); + final pen = await tester.startGesture(canvasPoint(tester, 100, 200), + pointer: 1, kind: PointerDeviceKind.stylus); + await pen.moveBy(const Offset(20, 0)); + // Palm or finger touches down elsewhere while the stylus is drawing. + final palm = await tester.startGesture(canvasPoint(tester, 400, 600), + pointer: 2, kind: PointerDeviceKind.touch); + await palm.moveBy(const Offset(5, 5)); + await palm.up(); + await pen.moveBy(const Offset(20, 0)); + await pen.up(); + await tester.pump(); + + expect(saves, hasLength(1)); + final points = saves.single.single['points'] as List; + // Only the stylus points, all on the stylus' horizontal line. + expect(points.map((p) => (p as Map)['y']).toSet(), hasLength(1)); + }); + + testWidgets('a right click does not draw', (tester) async { + await pumpCanvas(tester); + final g = await tester.startGesture(canvasPoint(tester, 100, 200), + kind: PointerDeviceKind.mouse, buttons: kSecondaryMouseButton); + await g.moveBy(const Offset(40, 0)); + await g.up(); + await tester.pump(); + + expect(saves, isEmpty); + }); +} From 4c588a9c03e15d450bc998444c3e9c974ca369f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:24:54 +0000 Subject: [PATCH 17/44] fix: save pending text when the app leaves the foreground Text was only saved by the 2s debounce or when the editor was disposed. Switching apps right after typing, then having Android kill the backgrounded process (or closing the desktop window), lost the last edits since neither ran. The editor now saves as soon as the app stops being resumed. --- app/lib/screens/note_editor_screen.dart | 16 ++++++++++++- .../note_editor_autosave_race_test.dart | 23 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/lib/screens/note_editor_screen.dart b/app/lib/screens/note_editor_screen.dart index ebc331c..6aa644d 100644 --- a/app/lib/screens/note_editor_screen.dart +++ b/app/lib/screens/note_editor_screen.dart @@ -12,7 +12,8 @@ class NoteEditorScreen extends StatefulWidget { State createState() => _NoteEditorScreenState(); } -class _NoteEditorScreenState extends State { +class _NoteEditorScreenState extends State + with WidgetsBindingObserver { late TextEditingController _titleCtrl; late TextEditingController _contentCtrl; Timer? _saveTimer; @@ -64,6 +65,7 @@ class _NoteEditorScreenState extends State { : >[]; _savedTitle = _titleCtrl.text; _savedContent = _contentCtrl.text; + WidgetsBinding.instance.addObserver(this); _titleCtrl.addListener(_onChanged); _contentCtrl.addListener(_onChanged); } @@ -231,8 +233,20 @@ class _NoteEditorScreenState extends State { if (isMobile && mounted) Navigator.pop(context); } + /// Android can kill a backgrounded app without running any more Dart + /// code, and closing a desktop window doesn't dispose widgets either, so + /// pending text can't wait for the debounce timer once the app leaves the + /// foreground. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) return; + _saveTimer?.cancel(); + if (_hasChanges) _save(); + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _saveTimer?.cancel(); // Flush pending text edits. The save is queued behind any in-flight one // and only uses the captured AppState, so it is safe after unmount. diff --git a/app/test/screens/note_editor_autosave_race_test.dart b/app/test/screens/note_editor_autosave_race_test.dart index e7613af..79be614 100644 --- a/app/test/screens/note_editor_autosave_race_test.dart +++ b/app/test/screens/note_editor_autosave_race_test.dart @@ -134,4 +134,27 @@ void main() { expect(backend.landed.last, 'draft final'); }); + + for (final lifecycle in [ + AppLifecycleState.inactive, + AppLifecycleState.hidden, + AppLifecycleState.paused, + ]) { + testWidgets('going to the background ($lifecycle) saves pending text ' + 'right away', (tester) async { + await pumpEditor(tester); + + await tester.enterText(body(), 'typed just before switching apps'); + await tester.pump(); + expect(backend.started, isEmpty); // still inside the 2s debounce + + // Android may kill a backgrounded app without running more Dart code, + // so the save can't wait for the debounce timer. + tester.binding.handleAppLifecycleStateChanged(lifecycle); + await tester.pump(); + await releaseAll(tester); + + expect(backend.landed, ['typed just before switching apps']); + }); + } } From 868a958e9e30897b6a41d22e698f44290f12602e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:27:04 +0000 Subject: [PATCH 18/44] fix: reject blank notebook names and report failed creations On phones the notebook dialog accepted an empty or whitespace-only name and created a nameless notebook. Creating a note or notebook while the backend refused the write threw an unhandled exception and the tap silently did nothing. Names are trimmed and blank ones ignored on both layouts, and failures now show a snackbar. --- app/lib/screens/home_screen.dart | 63 ++++++++++++++--------- app/test/screens/home_screen_test.dart | 70 ++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 24 deletions(-) diff --git a/app/lib/screens/home_screen.dart b/app/lib/screens/home_screen.dart index a0a3e96..fe7b5bc 100644 --- a/app/lib/screens/home_screen.dart +++ b/app/lib/screens/home_screen.dart @@ -41,7 +41,7 @@ class _DesktopLayout extends StatelessWidget { notebooks: state.notebooks, selected: state.selectedNotebook, onSelect: state.selectNotebook, - onCreate: () => _createNotebook(context), + onCreate: () => _promptCreateNotebook(context), onSync: () => _sync(context), isSyncing: state.isSyncing, hasSyncError: state.syncError != null, @@ -76,17 +76,8 @@ class _DesktopLayout extends StatelessWidget { ); } - Future _createNotebook(BuildContext context) async { - final name = await _inputDialog(context, 'New Notebook', 'Name'); - if (name != null && name.isNotEmpty) { - await context.read().createNotebook(name, '#6366f1'); - } - } - Future _createNote(BuildContext context) async { - final state = context.read(); - final note = await state.createNote(title: 'Untitled', noteType: 'typed'); - if (context.mounted) await _openNote(context, note.id); + await _createAndOpenNote(context); } Future _sync(BuildContext context) async { @@ -103,14 +94,43 @@ Future _openNote(BuildContext context, String id) async { try { return await context.read().openNote(id); } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Could not open note: $e'), - backgroundColor: Colors.red.shade700, - )); - } + if (context.mounted) _showError(context, 'Could not open note: $e'); + return null; + } +} + +/// Creates an untitled note in the current notebook and opens it. A failure +/// (backend unreachable, server error) is shown rather than dropped. +Future _createAndOpenNote(BuildContext context) async { + final state = context.read(); + final Note note; + try { + note = await state.createNote(title: 'Untitled', noteType: 'typed'); + } catch (e) { + if (context.mounted) _showError(context, 'Could not create note: $e'); return null; } + if (!context.mounted) return null; + return _openNote(context, note.id); +} + +/// Asks for a name and creates the notebook. Blank names are ignored. +Future _promptCreateNotebook(BuildContext context) async { + final state = context.read(); + final name = (await _inputDialog(context, 'New Notebook', 'Name'))?.trim(); + if (name == null || name.isEmpty) return; + try { + await state.createNotebook(name, '#6366f1'); + } catch (e) { + if (context.mounted) _showError(context, 'Could not create notebook: $e'); + } +} + +void _showError(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(message), + backgroundColor: Colors.red.shade700, + )); } void _showSyncResult(BuildContext context, AppState state) { @@ -176,10 +196,7 @@ class _MobileLayout extends StatelessWidget { notebooks: state.notebooks, selected: state.selectedNotebook, onSelect: (nb) { state.selectNotebook(nb); Navigator.pop(context); }, - onCreate: () async { - final name = await _inputDialog(context, 'New Notebook', 'Name'); - if (name != null) await state.createNotebook(name, '#6366f1'); - }, + onCreate: () => _promptCreateNotebook(context), onSync: () async { await state.triggerSync(); if (context.mounted) _showSyncResult(context, state); @@ -193,9 +210,7 @@ class _MobileLayout extends StatelessWidget { backgroundColor: const Color(0xFF6366F1), foregroundColor: Colors.white, onPressed: () async { - final note = await state.createNote(title: 'Untitled', noteType: 'typed'); - if (!context.mounted) return; - final full = await _openNote(context, note.id); + final full = await _createAndOpenNote(context); if (full != null && context.mounted) { Navigator.push(context, MaterialPageRoute( builder: (_) => ChangeNotifierProvider.value( diff --git a/app/test/screens/home_screen_test.dart b/app/test/screens/home_screen_test.dart index 6519323..2ad8307 100644 --- a/app/test/screens/home_screen_test.dart +++ b/app/test/screens/home_screen_test.dart @@ -6,6 +6,7 @@ import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:nexanote/data/database/schema.dart'; import 'package:nexanote/main.dart'; +import 'package:nexanote/services/api_client.dart' as api; import 'package:nexanote/services/app_state.dart'; import 'package:nexanote/services/local_note_service.dart'; @@ -132,4 +133,73 @@ void main() { expect(editorBody(), findsOneWidget); expect(editorText(tester), 'v2 typed'); }); + + Future pumpPhone(WidgetTester tester, AppState appState) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(400, 800); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(ChangeNotifierProvider.value( + value: appState, + child: const NexaNoteApp(), + )); + await tester.pumpAndSettle(); + } + + testWidgets('a blank notebook name is not created on phones', (tester) async { + await pumpPhone(tester, state); + + await tester.tap(find.byTooltip('Open navigation menu')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('New notebook')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).last, ' '); + await tester.tap(find.text('Create')); + await settle(tester); + + expect(state.notebooks, isEmpty); + }); + + testWidgets('a failed note creation is reported, not dropped', + (tester) async { + // Backend configured but refusing writes. + final failing = AppState( + localService: service, + clientFactory: (_) => _RefusingApi(), + ); + await tester.runAsync(() => failing.connect(url: 'http://refusing.test')); + await pumpPhone(tester, failing); + + await tester.tap(find.byType(FloatingActionButton)); + await settle(tester); + + expect(find.textContaining('Could not create note'), findsOneWidget); + }); +} + +class _RefusingApi extends api.ApiClient { + _RefusingApi() : super(baseUrl: 'http://refusing.test'); + + @override + Future ping() async => true; + + @override + Future> getNotebooks() async => []; + + @override + Future> getNotes({ + String? notebookId, + String? search, + bool includeDeleted = false, + }) async => + []; + + @override + Future createNote({ + required String title, + required String noteType, + String? notebookId, + String template = 'blank', + }) async => + throw const api.ApiException('Failed to create note', statusCode: 500); } From d7b357a67464ade7a0b3371231e736d24e1b5e2c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:27:28 +0000 Subject: [PATCH 19/44] fix: stop the welcome screen help header overflowing on phones "How to start the server" sat in a plain Row and overflowed by 19px on a 360px wide screen (more with a large system font). --- app/lib/screens/connect_screen.dart | 14 +++++++++----- app/test/screens/onboarding_routing_test.dart | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/app/lib/screens/connect_screen.dart b/app/lib/screens/connect_screen.dart index 3610c5b..01ed0bc 100644 --- a/app/lib/screens/connect_screen.dart +++ b/app/lib/screens/connect_screen.dart @@ -286,11 +286,15 @@ class _ConnectScreenState extends State { size: 16, color: scheme.onSurface.withOpacity(0.5)), const SizedBox(width: 6), - Text('How to start the server', - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, - color: scheme.onSurface.withOpacity(0.7))), + // Flexible so it wraps on narrow phones or with a + // large system font instead of overflowing. + Flexible( + child: Text('How to start the server', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: scheme.onSurface.withOpacity(0.7))), + ), ]), const SizedBox(height: 8), _code('cd ~/NexaNote'), diff --git a/app/test/screens/onboarding_routing_test.dart b/app/test/screens/onboarding_routing_test.dart index 2329b54..0288bd8 100644 --- a/app/test/screens/onboarding_routing_test.dart +++ b/app/test/screens/onboarding_routing_test.dart @@ -114,4 +114,23 @@ void main() { expect(find.byType(FloatingActionButton), findsOneWidget); expect(find.text('Use NexaNote offline'), findsNothing); }); + + testWidgets('the welcome screen fits a small phone without overflowing', + (tester) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(360, 740); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final state = AppState( + localService: service, + clientFactory: (_) => _OfflineStubApi(), + ); + await state.initLocal(); + + await tester.pumpWidget(app(state)); + await tester.pumpAndSettle(); + + expect(find.text('Use NexaNote offline'), findsOneWidget); + expect(tester.takeException(), isNull); + }); } From 3c2c7aeabaeb88ebf77c75850d76c0b10c8378c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:34:28 +0000 Subject: [PATCH 20/44] fix: keep an edit made while a save is queued marked unsaved The text save queue read the edit counter when a queued save started, not when its snapshot was taken. An edit made while the save waited behind a slow one was counted as saved when the queued save finished, so its own debounce and the dispose flush both skipped it. The counter is now captured with the snapshot. --- app/lib/screens/note_editor_screen.dart | 13 ++++--- .../note_editor_autosave_race_test.dart | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/app/lib/screens/note_editor_screen.dart b/app/lib/screens/note_editor_screen.dart index 6aa644d..988d5c3 100644 --- a/app/lib/screens/note_editor_screen.dart +++ b/app/lib/screens/note_editor_screen.dart @@ -110,18 +110,21 @@ class _NoteEditorScreenState extends State Future _persist() { final title = _titleCtrl.text; final content = _contentCtrl.text; + // Taken with the snapshot, not when the save gets to run: an edit made + // while this save waits in the queue is not in the snapshot and must + // keep the note marked unsaved. + final generation = _editGeneration; final previous = _textSaves; - final save = previous.then((_) => _persistSnapshot(title, content)); + final save = + previous.then((_) => _persistSnapshot(title, content, generation)); _textSaves = save; return save; } - Future _persistSnapshot(String title, String content) async { + Future _persistSnapshot( + String title, String content, int generation) async { final state = _appState; if (state == null) return false; - // Edits made after this point are newer than the snapshot and keep the - // note marked unsaved. - final generation = _editGeneration; try { if (title != _savedTitle) { await state.updateNoteTitle(_note.id, title); diff --git a/app/test/screens/note_editor_autosave_race_test.dart b/app/test/screens/note_editor_autosave_race_test.dart index 79be614..4bb464a 100644 --- a/app/test/screens/note_editor_autosave_race_test.dart +++ b/app/test/screens/note_editor_autosave_race_test.dart @@ -17,6 +17,19 @@ class _SlowSaveApi extends api.ApiClient { final pending = >[]; final started = []; final landed = []; + final titles = []; + + @override + Future updateNote(String id, + {String? title, bool? isPinned, List? tags}) async { + titles.add(title!); + return _note(); + } + + @override + Future> getNotes( + {String? notebookId, String? search, bool includeDeleted = false}) async => + []; @override Future savePageText(String noteId, int pageNum, String content) async { @@ -157,4 +170,26 @@ void main() { expect(backend.landed, ['typed just before switching apps']); }); } + + testWidgets('an edit made while a save waits in the queue is saved too', + (tester) async { + await pumpEditor(tester); + final title = find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Untitled'); + + await tester.enterText(body(), 'a'); + await tester.pump(const Duration(seconds: 2)); // save A in flight + await tester.enterText(body(), 'ab'); + await tester.pump(const Duration(seconds: 2)); // save B queued behind A + await tester.enterText(title, 'New title'); // edited while B waits + await tester.pump(); + + await releaseAll(tester); // A then B land + await tester.pump(const Duration(seconds: 3)); // title debounce + await tester.pumpWidget(const SizedBox()); + await releaseAll(tester); + + expect(backend.landed.last, 'ab'); + expect(backend.titles, contains('New title')); + }); } From 9b86e78e099b19a46709868670933f5640d35cf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 08:36:42 +0000 Subject: [PATCH 21/44] fix: don't push notes whose remote copy failed to read The earlier listing fix only covered notebooks. When note.json or a page_N.ink failed to download (500, timeout, invalid body) the note was skipped by pull, then push still uploaded the local copy over a newer remote version. note.json failures were not even reported. Both reads now raise unless the file is really gone (404), and push skips any note whose pull failed. A note deleted locally was also left MODIFIED after conflict resolution, but push never sends deleted notes, so every later sync re-ran the conflict. --- nexanote/sync/client.py | 30 +++++++++++++++--- tests/test_webdav_sync_safety.py | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/nexanote/sync/client.py b/nexanote/sync/client.py index d41bf68..8bf712a 100644 --- a/nexanote/sync/client.py +++ b/nexanote/sync/client.py @@ -438,13 +438,18 @@ def get_note_meta(self, notebook_slug: str, note_slug: str) -> Optional[dict]: resp = self._execute( "GET", path, lambda: self.session.get(url, timeout=self.config.timeout_seconds) ) - if resp is None or resp.status_code != 200: + # Only a 404 means the note is gone; any other failure must not be + # mistaken for it, or push would overwrite the version we failed + # to read. + if resp is not None and resp.status_code == 404: return None + if resp is None or resp.status_code != 200: + status = "no response" if resp is None else resp.status_code + raise RemoteReadError(f"GET {path} failed ({status})") try: return resp.json() except ValueError as e: - logger.error(f"GET note.json invalid JSON ({path}): {e}") - return None + raise RemoteReadError(f"GET {path} returned invalid JSON: {e}") from e def get_ink_page(self, notebook_slug: str, note_slug: str, page_num: int) -> Optional[dict]: """GET /{notebook}/{note}/page_N.ink (avec retry transitoire).""" @@ -829,6 +834,11 @@ def _serialize_note_meta(note: Note) -> dict: } +def _slug_id_prefix(note_slug: str) -> str: + """The note id prefix a `__<id[:8]>` slug ends with.""" + return note_slug.rsplit("__", 1)[-1] + + def _carry_local_fields(remote: Note, local: Note) -> None: """ EN: note.json does not carry the notebook or the archived/deleted flags, @@ -994,6 +1004,7 @@ def _sync(self) -> SyncReport: report = SyncReport() report.dry_run = self.dry_run self._unreadable_notebooks: set[str] = set() + self._unreadable_note_prefixes: set[str] = set() self.plan = SyncPlan() report.plan = self.plan logger.info( @@ -1131,6 +1142,9 @@ def _pull_notebook(self, nb_slug: str, report: SyncReport) -> None: try: self._pull_note(nb_slug, note_slug, report) except Exception as e: + # This note was not compared with the server, so pushing the + # local copy could overwrite a newer remote version. + self._unreadable_note_prefixes.add(_slug_id_prefix(note_slug)) msg = f"Erreur pull note {note_slug}: {e}" logger.error(msg) report.errors.append(msg) @@ -1298,7 +1312,9 @@ def _pull_note(self, nb_slug: str, note_slug: str, report: SyncReport) -> None: # is the remote version as-is (local edits kept in a conflict # copy, if any), it holds changes the server doesn't have yet. # Leave it MODIFIED so this sync's push uploads them. - if result.winner is not remote_note: + # A deleted winner stays as resolved: push never sends deleted + # notes, so MODIFIED would re-run this conflict on every sync. + if result.winner is not remote_note and not result.winner.is_deleted: result.winner.sync_status = SyncStatus.MODIFIED # A genuine conflict = both sides changed. The resolver reports @@ -1416,6 +1432,12 @@ def _push_note(self, note: Note, report: SyncReport) -> None: f"could not be listed, push skipped" ) return + if note.id[:8] in getattr(self, "_unreadable_note_prefixes", ()): + report.errors.append( + f"Échec partiel push : {note.title} — remote copy could not " + f"be read, push skipped" + ) + return # Créer le dossier carnet sur le serveur si nécessaire nb_entries = self.client.list_notebooks() diff --git a/tests/test_webdav_sync_safety.py b/tests/test_webdav_sync_safety.py index 6865b57..18fcde2 100644 --- a/tests/test_webdav_sync_safety.py +++ b/tests/test_webdav_sync_safety.py @@ -260,3 +260,57 @@ def test_put_with_a_foreign_id_does_not_delete_the_note_at_that_path(server): assert kept is not None assert kept.pages[0].typed_content == "victim text" assert len(kept.pages[0].strokes) == 1 + + +def _fail_get(engine, suffix, status=500): + real_get = engine.client.session.get + + def get(url, *args, **kwargs): + if url.endswith(suffix): + resp = requests.Response() + resp.status_code = status + resp.reason = "Internal Server Error" + resp._content = b"boom" + return resp + return real_get(url, *args, **kwargs) + + engine.client.session.get = get + + +@pytest.mark.parametrize("failing_file", ["note.json", "page_1.ink"]) +def test_failed_note_read_does_not_let_push_overwrite_it(tmp_path, server, engine_for, failing_file): + a = FileNoteStore(tmp_path / "deviceA") + b = FileNoteStore(tmp_path / "deviceB") + note = _new_note(a) + assert engine_for(a).sync().success() + assert engine_for(b).sync().success() + + _edit_text(a, note.id, "A older edit") + time.sleep(0.01) + _edit_text(b, note.id, "B newer edit") + assert engine_for(b).sync().success() + + engine = engine_for(a) + _fail_get(engine, failing_file) + report = engine.sync() + + assert not report.success() + assert _server_text(server["db"], note.id) == "B newer edit" + # A's edit is still pending and goes through the conflict path next time. + assert db_text(a, note.id) == "A older edit" + assert a.get_note(note.id).sync_status != SyncStatus.SYNCED + + +def test_locally_deleted_note_does_not_conflict_on_every_sync(tmp_path, server, engine_for): + db = FileNoteStore(tmp_path / "device") + note = _new_note(db, title="Trash me") + assert engine_for(db).sync().success() + + deleted = db.get_note(note.id, load_pages=False) + deleted.soft_delete() + db.save_note(deleted, save_pages=False) + + conflicts = [engine_for(db).sync().conflicts_resolved for _ in range(3)] + + assert conflicts[1:] == [0, 0] + assert db.get_note(note.id).is_deleted From 80f5383f331afd95a683ebe0ba98590df2a71635 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 08:37:29 +0000 Subject: [PATCH 22/44] fix: don't treat HTTP client errors as the backend being down Since API writes started checking status codes, any 4xx went through the backend-failure path: deleting a note another device had already removed showed the offline banner and replaced the notes list with the local cache (empty for users who never synced). Only timeouts, network errors and 5xx switch to offline now, and a 404 on a delete counts as done since the item is already gone. --- app/lib/services/api_client.dart | 6 ++- app/lib/services/app_state.dart | 10 ++++- app/test/services/api_client_test.dart | 24 +++++++---- app/test/services/app_state_test.dart | 58 ++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/app/lib/services/api_client.dart b/app/lib/services/api_client.dart index 1091fe0..c1207cc 100644 --- a/app/lib/services/api_client.dart +++ b/app/lib/services/api_client.dart @@ -230,7 +230,8 @@ class ApiClient { const what = 'Failed to delete notebook'; final resp = await _send( what, (c) => c.delete(Uri.parse('$baseUrl/notebooks/$id'))); - _expect(resp, what, const [200, 204]); + // 404: already gone (deleted from another device), which is the goal. + _expect(resp, what, const [200, 204, 404]); } // ---------------------------------------------------------------- @@ -315,7 +316,8 @@ class ApiClient { const what = 'Failed to delete note'; final resp = await _send(what, (c) => c.delete(Uri.parse('$baseUrl/notes/$id'))); - _expect(resp, what, const [200, 204]); + // 404: already gone (deleted from another device), which is the goal. + _expect(resp, what, const [200, 204, 404]); } Future<void> restoreNote(String id) async { diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index c27eed4..59873ff 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -742,7 +742,15 @@ class AppState extends ChangeNotifier { /// refreshes [_notebooks]/[_notes] from the local SQLite store so the UI can /// keep working offline. Reuses the same flag and message as the /// startup-time path so the existing offline banner kicks in unchanged. - Future<void> _handleBackendFailure(Object _) async { + Future<void> _handleBackendFailure(Object error) async { + // The server answered with a client error (404 for a note another device + // already deleted, 409, 422...): it is up, so don't switch the whole app + // to the offline banner and the local cache. + if (error is api.ApiException && + error.statusCode != null && + error.statusCode! < 500) { + return; + } final wasAvailable = _isBackendAvailable; _isBackendAvailable = false; _backendErrorMessage = 'Offline mode — backend unavailable'; diff --git a/app/test/services/api_client_test.dart b/app/test/services/api_client_test.dart index 5589c28..4e5e2a3 100644 --- a/app/test/services/api_client_test.dart +++ b/app/test/services/api_client_test.dart @@ -24,15 +24,17 @@ void main() { throwsA(isA<ApiException>())); }); - test('deleteNote throws on HTTP $status', () async { - await expectLater(clientReturning(status).deleteNote('n'), - throwsA(isA<ApiException>())); - }); + if (status != 404) { + test('deleteNote throws on HTTP $status', () async { + await expectLater(clientReturning(status).deleteNote('n'), + throwsA(isA<ApiException>())); + }); - test('deleteNotebook throws on HTTP $status', () async { - await expectLater(clientReturning(status).deleteNotebook('nb'), - throwsA(isA<ApiException>())); - }); + test('deleteNotebook throws on HTTP $status', () async { + await expectLater(clientReturning(status).deleteNotebook('nb'), + throwsA(isA<ApiException>())); + }); + } } }); @@ -43,6 +45,12 @@ void main() { await clientReturning(204).deleteNotebook('nb'); }); + test('deleting something that is already gone (404) is not an error', + () async { + await clientReturning(404).deleteNote('n'); + await clientReturning(404).deleteNotebook('nb'); + }); + testWidgets('a backend that never answers times out instead of hanging', (tester) async { final client = ApiClient( diff --git a/app/test/services/app_state_test.dart b/app/test/services/app_state_test.dart index 340efd6..e429263 100644 --- a/app/test/services/app_state_test.dart +++ b/app/test/services/app_state_test.dart @@ -1,4 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; @@ -773,4 +775,60 @@ void main() { expect(s.apiUrl, 'http://192.0.2.10:8766'); }); }); + + group('HTTP client errors', () { + const notesJson = + '[{"id":"a","title":"A","note_type":"typed","tags":[],"is_pinned":false,' + '"is_deleted":false,"page_count":1,"updated_at":"","created_at":""},' + '{"id":"b","title":"B","note_type":"typed","tags":[],"is_pinned":false,' + '"is_deleted":false,"page_count":1,"updated_at":"","created_at":""}]'; + + AppState backendAnswering(int Function(http.Request) statusFor) { + final mock = MockClient((req) async { + final path = req.url.path; + if (path == '/health') return http.Response('{}', 200); + if (path == '/notebooks') return http.Response('[]', 200); + if (path == '/notes' && req.method == 'GET') { + return http.Response(notesJson, 200); + } + return http.Response('{"detail":"x"}', statusFor(req)); + }); + return AppState( + localService: service, + clientFactory: (url) => api.ApiClient(baseUrl: url, httpClient: mock)); + } + + test('deleting a note another device already deleted is not an outage', + () async { + final s = backendAnswering((_) => 404); + await s.connect(url: 'http://srv.test'); + + await s.deleteNote('a'); + + expect(s.isBackendAvailable, isTrue); + expect(s.notes.map((n) => n.id), ['b']); + }); + + test('a 4xx on a save is reported but keeps the app online', () async { + final s = backendAnswering((_) => 422); + await s.connect(url: 'http://srv.test'); + + await expectLater( + s.savePageText('a', 1, 'x'), throwsA(isA<api.ApiException>())); + + expect(s.isBackendAvailable, isTrue); + expect(s.backendErrorMessage, isNull); + expect(s.notes.map((n) => n.id), ['a', 'b']); + }); + + test('a 5xx still switches to offline mode', () async { + final s = backendAnswering((_) => 503); + await s.connect(url: 'http://srv.test'); + + await expectLater( + s.savePageText('a', 1, 'x'), throwsA(isA<api.ApiException>())); + + expect(s.isBackendAvailable, isFalse); + }); + }); } From b513b70df91c0e2c88539b47b0193cd0f5940030 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 08:37:53 +0000 Subject: [PATCH 23/44] fix: run the offline-note upload once at a time The upload now runs on every connect, and two quick Reconnect taps ran two uploads concurrently. Both saw the same pending notes and created each one twice on the server. Concurrent connects now share the in-flight upload. --- app/lib/services/app_state.dart | 9 ++++++++- app/test/services/app_state_test.dart | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index 59873ff..b62b262 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -62,6 +62,7 @@ class AppState extends ChangeNotifier { String? _searchQuery; int _notesRequest = 0; int _openRequest = 0; + Future<void>? _localUpload; bool _isSyncing = false; String? _syncMessage; String? _syncError; @@ -241,7 +242,13 @@ class AppState extends ChangeNotifier { /// Already-uploaded notes are marked synced, so a retry doesn't duplicate /// them. A failure never blocks the connection, but it is reported through /// [syncError] instead of being dropped silently. - Future<void> _uploadPendingLocalNotes() async { + Future<void> _uploadPendingLocalNotes() => + // One upload at a time: two quick Reconnects would otherwise both see + // the same pending notes and create each of them twice on the server. + _localUpload ??= + _uploadPendingLocalNotesOnce().whenComplete(() => _localUpload = null); + + Future<void> _uploadPendingLocalNotesOnce() async { try { final snapshot = await _localService.exportAllData(); final pending = snapshot.notebooks.any((n) => n.syncStatus != 'synced') || diff --git a/app/test/services/app_state_test.dart b/app/test/services/app_state_test.dart index e429263..b01bb46 100644 --- a/app/test/services/app_state_test.dart +++ b/app/test/services/app_state_test.dart @@ -756,6 +756,21 @@ void main() { expect(stub.createNoteCalls, 1, reason: 'already uploaded, not again'); }); + test('two quick connects upload an offline note only once', () async { + final stub = _FlakyUploadApi()..failuresLeft = 0; + final s = AppState(localService: service, clientFactory: (_) => stub); + await s.enableLocalMode(); + final note = await s.createNote(title: 'Offline', noteType: 'typed'); + await s.savePageText(note.id, 1, 'body'); + + await Future.wait([ + s.connect(url: 'http://192.0.2.10:8766'), + s.connect(url: 'http://192.0.2.10:8766'), + ]); + + expect(stub.createNoteCalls, 1); + }); + test('existing connected users are not reset to onboarding or local mode', () async { // Simulates an upgrade from a pre-local-mode build: the only persisted From 6c17e9057e8f528b8736899ff2eb6407e1d29d1d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 08:38:45 +0000 Subject: [PATCH 24/44] fix: don't overwrite online edits when resuming an offline upload If the create succeeded but the text upload failed, the next connect re-sent the local text to that note, overwriting anything the user had written in it online since (pull keeps pending rows, so the local copy was never refreshed). A resumed upload now checks the server copy first: still empty, the content goes in as before; already written in, the offline text is uploaded as a separate "(offline copy)" note. --- app/lib/services/sync_service.dart | 59 +++++++++++++++++++----- app/test/services/sync_service_test.dart | 55 ++++++++++++++++++++++ 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 2312ec6..dd30025 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -104,10 +104,29 @@ class SyncService { for (final note in snapshot.notes) { if (_isAlreadySynced(note.syncStatus)) continue; final pendingUpload = note.syncStatus == 'local_only'; - var remoteId = note.remoteId; + final remoteId = note.remoteId; final hasRemote = remoteId != null && remoteId.isNotEmpty; if (hasRemote && !pendingUpload) continue; - if (!hasRemote) { + var target = hasRemote ? remoteId : null; + if (target != null) { + // Resuming an upload interrupted after the create. The user may have + // written in that note online since then; never overwrite that. + final server = await _remoteNoteOrNull(target); + if (server == null) { + target = null; // deleted on the server meanwhile: create it again + } else if (_hasContent(server)) { + final copy = await _api.createNote( + title: '${cleanRemoteTitle(note.title)} (offline copy)', + noteType: note.noteType, + notebookId: server.notebookId, + ); + await _uploadContent(note, copy.id); + await _local.markNoteSyncedIfUnchanged(note.id, note.updatedAt); + notes++; + continue; + } + } + if (target == null) { // Deleted before it ever reached the server: nothing to propagate. if (note.isDeleted) continue; final notebookId = note.notebookId; @@ -118,23 +137,39 @@ class SyncService { // reject the note; file it unsorted rather than abort the push. notebookId: notebookId == null ? null : serverNotebookIds[notebookId], ); - remoteId = created.id; - await _local.setNoteRemoteId(note.id, remoteId); + target = created.id; + await _local.setNoteRemoteId(note.id, target); notes++; } - if (note.typedContent.isNotEmpty) { - await _api.savePageText(remoteId, 1, note.typedContent); - } - final strokes = await _local.getStrokesForNote(note.id); - if (strokes.isNotEmpty) { - await _api.savePageInk( - remoteId, 1, strokes.map(inkJsonFromStroke).toList()); - } + await _uploadContent(note, target); await _local.markNoteSyncedIfUnchanged(note.id, note.updatedAt); } return _PushCounts(notebooks: notebooks, notes: notes); } + Future<void> _uploadContent(Note note, String remoteId) async { + if (note.typedContent.isNotEmpty) { + await _api.savePageText(remoteId, 1, note.typedContent); + } + final strokes = await _local.getStrokesForNote(note.id); + if (strokes.isNotEmpty) { + await _api.savePageInk( + remoteId, 1, strokes.map(inkJsonFromStroke).toList()); + } + } + + Future<api.Note?> _remoteNoteOrNull(String id) async { + try { + return await _api.getNote(id); + } on api.ApiException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } + } + + static bool _hasContent(api.Note note) => (note.pages ?? const []).any( + (p) => p.typedContent.isNotEmpty || p.strokes.isNotEmpty); + static bool _isAlreadySynced(String status) => status == 'synced'; /// Adopts the remote view of notebooks and notes into the local DB. The diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index 386e800..5d44a32 100644 --- a/app/test/services/sync_service_test.dart +++ b/app/test/services/sync_service_test.dart @@ -20,12 +20,30 @@ class FakeApiClient extends api.ApiClient { List<Map<String, dynamic>> createdNotes = []; Map<String, String> savedText = {}; Map<String, List<Map<String, dynamic>>> savedInk = {}; + bool failText = false; @override Future<void> savePageText(String noteId, int pageNum, String content) async { + if (failText) throw const api.ApiException('Failed to save text'); savedText[noteId] = content; } + @override + Future<api.Note> getNote(String id) async { + final note = remoteNotes.where((n) => n.id == id).firstOrNull; + if (note == null) { + throw const api.ApiException('Failed to load note', statusCode: 404); + } + return note.copyWith(pages: [ + api.NotePage( + pageNumber: 1, + template: 'blank', + typedContent: savedText[id] ?? '', + strokes: savedInk[id] ?? const [], + ), + ]); + } + @override Future<void> savePageInk( String noteId, int pageNum, List<Map<String, dynamic>> strokes) async { @@ -243,6 +261,43 @@ void main() { expect(snapshot.notebooks, hasLength(1)); }); + group('resuming an upload interrupted after the create', () { + Future<String> interruptedUpload() async { + final note = await local.createNote('Offline'); + await local.updateNoteContent(note.id, 'written offline'); + fakeApi.failText = true; + await expectLater(sync.pushLocal(), throwsA(isA<api.ApiException>())); + fakeApi.failText = false; + return fakeApi.remoteNotes.single.id; + } + + test('uploads the content into the note it already created', () async { + final remoteId = await interruptedUpload(); + + await sync.pushLocal(); + + expect(fakeApi.createdNotes, hasLength(1)); + expect(fakeApi.savedText[remoteId], 'written offline'); + }); + + test('never overwrites what was written in that note online since', + () async { + final remoteId = await interruptedUpload(); + fakeApi.savedText[remoteId] = 'typed on the server afterwards'; + + await sync.sync(); + await sync.sync(); + + expect(fakeApi.savedText[remoteId], 'typed on the server afterwards'); + // The offline text is not dropped: it lands in a separate copy, once. + final copies = fakeApi.createdNotes + .where((n) => n['title'] == 'Offline (offline copy)') + .toList(); + expect(copies, hasLength(1)); + expect(fakeApi.savedText['remote-note-2'], 'written offline'); + }); + }); + test('pushLocal skips records already marked synced', () async { fakeApi.remoteNotebooks = [_remoteNotebook(id: 'nb-pulled', name: 'Pulled')]; fakeApi.remoteNotes = [ From 7b756ea1990d13b87779cb94499a0f14405dbfa5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 08:39:11 +0000 Subject: [PATCH 25/44] fix: let the stylus take over from a resting palm A palm touching the screen before the pen became the drawing pointer, so the stylus was ignored and the palm's stroke was saved. A stylus now takes over from a touch and the touch stroke is dropped. --- app/lib/widgets/ink_canvas.dart | 11 ++++++++++- app/test/widgets/ink_canvas_test.dart | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/lib/widgets/ink_canvas.dart b/app/lib/widgets/ink_canvas.dart index 47356be..56b6ae4 100644 --- a/app/lib/widgets/ink_canvas.dart +++ b/app/lib/widgets/ink_canvas.dart @@ -258,6 +258,7 @@ class _InkCanvasState extends State<InkCanvas> { /// The one pointer currently drawing. Other pointers (a palm, a second /// finger) must not add points to its stroke or start a new one over it. int? _drawingPointer; + bool _drawingWithTouch = false; /// Touch pointers currently down. Two or more is a pinch/pan gesture, not /// ink, so any stroke started by the first finger is discarded. @@ -378,7 +379,14 @@ class _InkCanvasState extends State<InkCanvas> { return; } } - if (_drawingPointer != null) return; + final isStylus = e.kind == PointerDeviceKind.stylus || + e.kind == PointerDeviceKind.invertedStylus; + if (_drawingPointer != null) { + // A palm resting on the screen before the pen lands is not ink: the + // stylus takes over and the touch stroke is dropped. + if (!isStylus || !_drawingWithTouch) return; + setState(() => _currentStroke = null); + } final drawable = switch (e.kind) { PointerDeviceKind.stylus || PointerDeviceKind.invertedStylus || @@ -389,6 +397,7 @@ class _InkCanvasState extends State<InkCanvas> { }; if (!drawable) return; _drawingPointer = e.pointer; + _drawingWithTouch = e.kind == PointerDeviceKind.touch; final pressure = e.pressure > 0 ? e.pressure : 0.5; _startStroke(_toCanvasPos(e.localPosition), pressure); } diff --git a/app/test/widgets/ink_canvas_test.dart b/app/test/widgets/ink_canvas_test.dart index 062678a..a626e8d 100644 --- a/app/test/widgets/ink_canvas_test.dart +++ b/app/test/widgets/ink_canvas_test.dart @@ -89,4 +89,24 @@ void main() { expect(saves, isEmpty); }); + + testWidgets('a palm already resting on the screen does not block the stylus', + (tester) async { + await pumpCanvas(tester); + final palm = await tester.startGesture(canvasPoint(tester, 400, 600), + pointer: 1, kind: PointerDeviceKind.touch); + await palm.moveBy(const Offset(5, 5)); + final pen = await tester.startGesture(canvasPoint(tester, 100, 200), + pointer: 2, kind: PointerDeviceKind.stylus); + await pen.moveBy(const Offset(20, 0)); + await pen.moveBy(const Offset(20, 0)); + await pen.up(); + await palm.moveBy(const Offset(5, 5)); + await palm.up(); + await tester.pump(); + + expect(saves, hasLength(1)); + final points = saves.single.single['points'] as List; + expect(points.map((p) => (p as Map)['y']).toSet(), {200.0}); + }); } From e53491e4debb8c7c1b1c2dd005d802ca924903ce Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 08:40:40 +0000 Subject: [PATCH 26/44] fix: keep the remembered search visible after a layout switch Notes reloads keep the active search query, but only the wide layout has a search field. After narrowing the window or rotating a tablet the list stayed filtered with no way to see or clear the filter, and a recreated search field started empty while still filtering. The phone layout now shows the active filter with a clear button, and the search field starts from the current query. --- app/lib/screens/home_screen.dart | 67 ++++++++++++++++++++++++-- app/lib/services/app_state.dart | 3 ++ app/test/screens/home_screen_test.dart | 29 +++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/app/lib/screens/home_screen.dart b/app/lib/screens/home_screen.dart index fe7b5bc..5e1f9bb 100644 --- a/app/lib/screens/home_screen.dart +++ b/app/lib/screens/home_screen.dart @@ -52,6 +52,7 @@ class _DesktopLayout extends StatelessWidget { SizedBox(width: 300, child: Column(children: [ _NotesHeader( title: state.selectedNotebook?.name ?? 'All Notes', + initialQuery: state.searchQuery, onSearch: (q) => state.loadNotes(notebookId: state.selectedNotebook?.id, search: q), onNewNote: () => _createNote(context), ), @@ -176,6 +177,14 @@ class _MobileLayout extends StatelessWidget { body: Column(children: [ if (!state.isBackendAvailable && !state.localMode) _OfflineBanner(message: state.backendErrorMessage), + // This layout has no search field, so a search started on the wide + // layout must be visible and clearable here. + if (state.searchQuery.isNotEmpty) + _ActiveSearchBar( + query: state.searchQuery, + onClear: () => state.loadNotes( + notebookId: state.selectedNotebook?.id, search: ''), + ), Expanded(child: NotesList( notes: state.notes, selected: state.selectedNote, @@ -224,14 +233,38 @@ class _MobileLayout extends StatelessWidget { } } -class _NotesHeader extends StatelessWidget { +class _NotesHeader extends StatefulWidget { final String title; + final String initialQuery; final ValueChanged<String> onSearch; final VoidCallback onNewNote; - const _NotesHeader({required this.title, required this.onSearch, required this.onNewNote}); + const _NotesHeader( + {required this.title, + required this.initialQuery, + required this.onSearch, + required this.onNewNote}); + + @override + State<_NotesHeader> createState() => _NotesHeaderState(); +} + +class _NotesHeaderState extends State<_NotesHeader> { + // Starts from the active query: the list stays filtered across layout + // switches, so the field must show what it is filtered by. + late final TextEditingController _query = + TextEditingController(text: widget.initialQuery); + + @override + void dispose() { + _query.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { + final title = widget.title; + final onSearch = widget.onSearch; + final onNewNote = widget.onNewNote; return Padding(padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -239,7 +272,7 @@ class _NotesHeader extends StatelessWidget { Expanded(child: Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))), IconButton(icon: const Icon(Icons.add, color: Color(0xFF6366F1)), onPressed: onNewNote, tooltip: 'New note'), ]), - TextField(onChanged: onSearch, decoration: InputDecoration( + TextField(controller: _query, onChanged: onSearch, decoration: InputDecoration( hintText: 'Search notes...', prefixIcon: const Icon(Icons.search, size: 18), isDense: true, @@ -251,6 +284,34 @@ class _NotesHeader extends StatelessWidget { } } +class _ActiveSearchBar extends StatelessWidget { + final String query; + final VoidCallback onClear; + const _ActiveSearchBar({required this.query, required this.onClear}); + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.only(left: 12), + child: Row(children: [ + const Icon(Icons.search, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text('Filtered by "$query"', + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12))), + IconButton( + icon: const Icon(Icons.close, size: 18), + tooltip: 'Clear search', + onPressed: onClear), + ]), + ), + ); + } +} + class _OfflineBanner extends StatelessWidget { final String? message; const _OfflineBanner({this.message}); diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index b62b262..86ea934 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -125,6 +125,9 @@ class AppState extends ChangeNotifier { /// so they never tear down HomeScreen (search field, open editor). bool get isStarting => _isStarting; bool get isLoading => _isLoading; + + /// The active notes search, kept across list reloads ('' or null: none). + String get searchQuery => _searchQuery ?? ''; bool get isSyncing => _isSyncing; String? get syncMessage => _syncMessage; String? get syncError => _syncError; diff --git a/app/test/screens/home_screen_test.dart b/app/test/screens/home_screen_test.dart index 2ad8307..4faf2e6 100644 --- a/app/test/screens/home_screen_test.dart +++ b/app/test/screens/home_screen_test.dart @@ -175,6 +175,35 @@ void main() { expect(find.textContaining('Could not create note'), findsOneWidget); }); + + testWidgets('a search started on the wide layout stays visible and ' + 'clearable after switching to the phone layout', (tester) async { + await tester.runAsync(() async { + await seed('Alpha', ''); + await seed('Beta', ''); + await state.loadNotes(); + }); + await pumpApp(tester); + final search = find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Search notes...'); + await tester.enterText(search, 'alp'); + await settle(tester); + expect(find.text('Beta'), findsNothing); + + // Window narrowed / tablet rotated: phone layout, no search field. + tester.view.physicalSize = const Size(400, 800); + await settle(tester); + expect(find.text('Filtered by "alp"'), findsOneWidget); + await tester.tap(find.byTooltip('Clear search')); + await settle(tester); + expect(find.text('Beta'), findsOneWidget); + + // Back to wide: the field shows the current (cleared) query. + tester.view.physicalSize = const Size(1280, 800); + await settle(tester); + expect(find.descendant(of: search, matching: find.text('alp')), + findsNothing); + }); } class _RefusingApi extends api.ApiClient { From 7fdf858114816754fd43c6ac51f5aa51a33483d3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 08:41:28 +0000 Subject: [PATCH 27/44] fix: give page saves a longer timeout than ordinary requests The 20s request timeout also applied to savePageInk and savePageText, which send the whole page. A long drawing on a slow mobile uplink could time out on every attempt and never be saved. Saves now get 2 minutes; other requests keep 20s. --- app/lib/services/api_client.dart | 10 ++++++++-- app/test/services/api_client_test.dart | 21 ++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/lib/services/api_client.dart b/app/lib/services/api_client.dart index c1207cc..78fa7cd 100644 --- a/app/lib/services/api_client.dart +++ b/app/lib/services/api_client.dart @@ -146,6 +146,10 @@ class ApiClient { /// the connection and never answers leaves a save "Saving…" forever. static const Duration requestTimeout = Duration(seconds: 20); + /// Saves carry the whole page (a long drawing can be megabytes), so on a + /// slow mobile uplink they get more time than ordinary requests. + static const Duration uploadTimeout = Duration(minutes: 2); + /// `/sync/trigger` runs a whole WebDAV sync before answering. static const Duration syncTimeout = Duration(minutes: 5); @@ -339,7 +343,8 @@ class ApiClient { Uri.parse('$baseUrl/notes/$noteId/pages/$pageNum/text'), headers: _headers, body: jsonEncode({'typed_content': content}), - )); + ), + timeout: uploadTimeout); _expect(resp, what); } @@ -352,7 +357,8 @@ class ApiClient { Uri.parse('$baseUrl/notes/$noteId/pages/$pageNum/ink'), headers: _headers, body: jsonEncode({'strokes': strokes}), - )); + ), + timeout: uploadTimeout); _expect(resp, what); } diff --git a/app/test/services/api_client_test.dart b/app/test/services/api_client_test.dart index 4e5e2a3..1f90722 100644 --- a/app/test/services/api_client_test.dart +++ b/app/test/services/api_client_test.dart @@ -58,11 +58,30 @@ void main() { httpClient: MockClient((_) => Completer<http.Response>().future), ); Object? error; - client.savePageText('n', 1, 'x').catchError((Object e) => error = e); + client.getNotes().catchError((Object e) { + error = e; + return <Note>[]; + }); await tester.pump(ApiClient.requestTimeout - const Duration(seconds: 1)); expect(error, isNull); await tester.pump(const Duration(seconds: 2)); expect(error, isA<ApiException>()); }); + + testWidgets('saves get the longer upload timeout', (tester) async { + final client = ApiClient( + baseUrl: 'http://backend.test', + httpClient: MockClient((_) => Completer<http.Response>().future), + ); + Object? error; + client.savePageInk('n', 1, const []).catchError((Object e) => error = e); + + // Still uploading well past the normal request timeout... + await tester.pump(ApiClient.requestTimeout * 2); + expect(error, isNull); + // ...but it does not hang forever either. + await tester.pump(ApiClient.uploadTimeout); + expect(error, isA<ApiException>()); + }); } From 879326904a6cc09ef1c62b525c5eb1852d232495 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 08:41:28 +0000 Subject: [PATCH 28/44] fix: drop a selected notebook the backend doesn't know after connect Uploading offline notes re-keys their notebooks to the server ids, but the selected notebook kept its local id, so the next note created in it was sent with an unknown notebook_id and rejected. The selection is cleared when the notebook isn't on the backend. --- app/lib/services/app_state.dart | 7 +++++++ app/test/services/app_state_test.dart | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index 86ea934..4861f8e 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -296,6 +296,13 @@ class AppState extends ChangeNotifier { // uploaded so nothing created in local mode is lost on the switch. await _uploadPendingLocalNotes(); await loadNotebooks(); + // The upload re-keys notebooks to their server ids, and a notebook + // may be gone on this server: don't keep filing new notes under an + // id the backend doesn't know. + if (_selectedNotebook != null && + !_notebooks.any((nb) => nb.id == _selectedNotebook!.id)) { + _selectedNotebook = null; + } await loadNotes(); } catch (e) { _isBackendAvailable = false; diff --git a/app/test/services/app_state_test.dart b/app/test/services/app_state_test.dart index b01bb46..ebe80c7 100644 --- a/app/test/services/app_state_test.dart +++ b/app/test/services/app_state_test.dart @@ -756,6 +756,21 @@ void main() { expect(stub.createNoteCalls, 1, reason: 'already uploaded, not again'); }); + test('a notebook selected offline is not kept under its old local id', + () async { + final stub = _FlakyUploadApi()..failuresLeft = 0; + final s = AppState(localService: service, clientFactory: (_) => stub); + await s.enableLocalMode(); + final nb = await s.createNotebook('Work', '#6366f1'); + s.selectNotebook(nb); + + await s.connect(url: 'http://192.0.2.10:8766'); + + // The upload re-keyed the notebook; the stale local id must not be + // used for new notes. + expect(s.selectedNotebook?.id, isNot(nb.id)); + }); + test('two quick connects upload an offline note only once', () async { final stub = _FlakyUploadApi()..failuresLeft = 0; final s = AppState(localService: service, clientFactory: (_) => stub); From a93664a997f71b11595df5ddf3f31cce27acf367 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 12:51:27 +0000 Subject: [PATCH 29/44] fix: only resume an offline upload into a provably untouched note When an offline note's create reached the server but its content upload failed, the next push treated an empty remote note as our placeholder and wrote the old local content into it. A note someone had edited online and left empty on purpose was overwritten that way. The server's updated_at at create time is now stored with the remote id (schema v3, remote_baseline). An upload is only resumed into the remote note if it is still empty and its updated_at is unchanged; every server write moves it. Otherwise, including when there is no usable baseline, the local version is uploaded as a separate "(offline copy)" note and the remote one is left alone. --- app/lib/data/database/schema.dart | 10 ++- app/lib/data/models/note.dart | 9 ++ .../data/repositories/note_repository.dart | 8 +- app/lib/services/local_note_service.dart | 4 +- app/lib/services/sync_service.dart | 25 ++++-- app/test/data/database/app_database_test.dart | 24 ++++++ app/test/services/sync_service_test.dart | 85 ++++++++++++++++++- 7 files changed, 153 insertions(+), 12 deletions(-) diff --git a/app/lib/data/database/schema.dart b/app/lib/data/database/schema.dart index 1aaf2a0..ffa6bd7 100644 --- a/app/lib/data/database/schema.dart +++ b/app/lib/data/database/schema.dart @@ -11,7 +11,11 @@ class Schema { /// Bumped to 2 when remote_id/remote_path columns were added so the /// SyncService can map a local note to its canonical .md file on the /// WebDAV/NAS without inventing a fresh row on every pull. - static const int version = 2; + /// + /// Bumped to 3 for remote_baseline: the server's updated_at when our push + /// created a note, so an interrupted upload is only resumed into a remote + /// note that provably hasn't changed since. + static const int version = 3; static const String _createNotebooks = ''' CREATE TABLE IF NOT EXISTS notebooks ( @@ -43,6 +47,7 @@ class Schema { sync_status TEXT NOT NULL DEFAULT 'local_only', remote_id TEXT, remote_path TEXT, + remote_baseline TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (notebook_id) REFERENCES notebooks(id) @@ -122,5 +127,8 @@ class Schema { await db.execute(_indexNotesRemoteId); await db.execute(_indexNotesRemotePath); } + if (oldVersion < 3) { + await db.execute('ALTER TABLE notes ADD COLUMN remote_baseline TEXT'); + } } } diff --git a/app/lib/data/models/note.dart b/app/lib/data/models/note.dart index a92a7d9..e7ad077 100644 --- a/app/lib/data/models/note.dart +++ b/app/lib/data/models/note.dart @@ -19,6 +19,10 @@ import 'dart:convert'; /// remote (e.g. `notes/Hello World.md`). Stored so renames on disk can be /// followed without losing the link. /// +/// [remoteBaseline] is the server's `updated_at` for the note right after +/// our push created it. While an upload is pending it is the only proof +/// that the remote note is still the empty one we created. +/// /// Mirrors Note in nexanote/models/note.py. class Note { final String id; @@ -33,6 +37,7 @@ class Note { final String syncStatus; final String? remoteId; final String? remotePath; + final String? remoteBaseline; final DateTime createdAt; final DateTime updatedAt; @@ -49,6 +54,7 @@ class Note { this.syncStatus = 'local_only', this.remoteId, this.remotePath, + this.remoteBaseline, required this.createdAt, required this.updatedAt, }); @@ -69,6 +75,7 @@ class Note { syncStatus: (map['sync_status'] as String?) ?? 'local_only', remoteId: map['remote_id'] as String?, remotePath: map['remote_path'] as String?, + remoteBaseline: map['remote_baseline'] as String?, createdAt: DateTime.parse(map['created_at'] as String), updatedAt: DateTime.parse(map['updated_at'] as String), ); @@ -88,6 +95,7 @@ class Note { 'sync_status': syncStatus, 'remote_id': remoteId, 'remote_path': remotePath, + 'remote_baseline': remoteBaseline, 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), }; @@ -122,6 +130,7 @@ class Note { syncStatus: syncStatus ?? this.syncStatus, remoteId: remoteId ?? this.remoteId, remotePath: remotePath ?? this.remotePath, + remoteBaseline: remoteBaseline, createdAt: createdAt, updatedAt: updatedAt ?? this.updatedAt, ); diff --git a/app/lib/data/repositories/note_repository.dart b/app/lib/data/repositories/note_repository.dart index 617f53b..d82f9da 100644 --- a/app/lib/data/repositories/note_repository.dart +++ b/app/lib/data/repositories/note_repository.dart @@ -189,8 +189,12 @@ class NoteRepository { /// Links local note [id] to the note the server created for it, keeping it /// pending (`local_only`) until its content has been uploaded too. - Future<void> setNoteRemoteId(String id, String remoteId) async { - await _db.update('notes', {'remote_id': remoteId}, + /// [baseline] is the server's updated_at for that fresh note (see + /// [Note.remoteBaseline]); null when the server didn't report one. + Future<void> setNoteRemoteId( + String id, String remoteId, String? baseline) async { + await _db.update( + 'notes', {'remote_id': remoteId, 'remote_baseline': baseline}, where: 'id = ?', whereArgs: [id]); } diff --git a/app/lib/services/local_note_service.dart b/app/lib/services/local_note_service.dart index b9c914c..b5dad9a 100644 --- a/app/lib/services/local_note_service.dart +++ b/app/lib/services/local_note_service.dart @@ -67,8 +67,8 @@ class LocalNoteService { Future<void> markNoteModified(String id) => _repo.updateNoteFields(id); // Push bookkeeping used by SyncService. - Future<void> setNoteRemoteId(String id, String remoteId) => - _repo.setNoteRemoteId(id, remoteId); + Future<void> setNoteRemoteId(String id, String remoteId, String? baseline) => + _repo.setNoteRemoteId(id, remoteId, baseline); Future<bool> markNoteSyncedIfUnchanged(String id, DateTime seenUpdatedAt) => _repo.markNoteSyncedIfUnchanged(id, seenUpdatedAt); Future<void> adoptRemoteNotebook(String localId, Notebook remote) => diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index dd30025..0eb7f9d 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -109,12 +109,14 @@ class SyncService { if (hasRemote && !pendingUpload) continue; var target = hasRemote ? remoteId : null; if (target != null) { - // Resuming an upload interrupted after the create. The user may have - // written in that note online since then; never overwrite that. + // Resuming an upload interrupted after the create. Only write into + // that remote note if it is provably still the empty one we created; + // anything else (edited online, even emptied on purpose, or a state + // we can't compare) keeps both versions. final server = await _remoteNoteOrNull(target); if (server == null) { target = null; // deleted on the server meanwhile: create it again - } else if (_hasContent(server)) { + } else if (!_isUntouchedSinceCreate(server, note.remoteBaseline)) { final copy = await _api.createNote( title: '${cleanRemoteTitle(note.title)} (offline copy)', noteType: note.noteType, @@ -138,7 +140,7 @@ class SyncService { notebookId: notebookId == null ? null : serverNotebookIds[notebookId], ); target = created.id; - await _local.setNoteRemoteId(note.id, target); + await _local.setNoteRemoteId(note.id, target, created.updatedAt); notes++; } await _uploadContent(note, target); @@ -167,8 +169,19 @@ class SyncService { } } - static bool _hasContent(api.Note note) => (note.pages ?? const []).any( - (p) => p.typedContent.isNotEmpty || p.strokes.isNotEmpty); + /// True only when [server] is still exactly the note our push created: + /// same updated_at as recorded at create time ([baseline]) and no content. + /// Every server-side write moves updated_at, so an emptied note or a note + /// edited and reverted does not pass. Without a comparable baseline there + /// is no proof, so this is false. + static bool _isUntouchedSinceCreate(api.Note server, String? baseline) { + final created = baseline == null ? null : DateTime.tryParse(baseline); + final current = DateTime.tryParse(server.updatedAt); + if (created == null || current == null) return false; + if (!current.isAtSameMomentAs(created)) return false; + return !(server.pages ?? const []).any( + (p) => p.typedContent.isNotEmpty || p.strokes.isNotEmpty); + } static bool _isAlreadySynced(String status) => status == 'synced'; diff --git a/app/test/data/database/app_database_test.dart b/app/test/data/database/app_database_test.dart index a2508be..1181704 100644 --- a/app/test/data/database/app_database_test.dart +++ b/app/test/data/database/app_database_test.dart @@ -159,8 +159,32 @@ void main() { expect(row['title'], 'Legacy'); expect(row['remote_id'], isNull); expect(row['remote_path'], isNull); + expect(row['remote_baseline'], isNull); await v1.close(); }); + + test('adds remote_baseline when migrating v2 → v3, keeping pending rows', + () async { + final v2 = await openDatabase(inMemoryDatabasePath, + version: 1, onCreate: (db, _) => Schema.onCreate(db, 1)); + await v2.execute('ALTER TABLE notes DROP COLUMN remote_baseline'); + final now = DateTime.utc(2024, 1, 1).toIso8601String(); + await v2.insert('notes', { + 'id': 'pending', 'title': 'Pending', 'remote_id': 'srv-1', + 'sync_status': 'local_only', 'created_at': now, 'updated_at': now, + }); + + await Schema.onUpgrade(v2, 2, Schema.version); + + final row = + (await v2.query('notes', where: 'id = ?', whereArgs: ['pending'])) + .first; + expect(row['remote_id'], 'srv-1'); + // No baseline recorded by older builds: a resumed upload has no proof + // and keeps both versions rather than writing into the remote note. + expect(row['remote_baseline'], isNull); + await v2.close(); + }); }); } diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index 5d44a32..5ad5c96 100644 --- a/app/test/services/sync_service_test.dart +++ b/app/test/services/sync_service_test.dart @@ -21,11 +21,32 @@ class FakeApiClient extends api.ApiClient { Map<String, String> savedText = {}; Map<String, List<Map<String, dynamic>>> savedInk = {}; bool failText = false; + bool failInk = false; + + /// Like the real backend, every write to a note moves its updated_at. + void _touch(String noteId) { + remoteNotes = [ + for (final n in remoteNotes) + n.id == noteId + ? n.copyWith( + updatedAt: DateTime.parse(n.updatedAt) + .add(const Duration(seconds: 1)) + .toIso8601String()) + : n, + ]; + } + + /// Someone edits the note on the server (another device, the web UI). + void editOnServer(String noteId, {required String text}) { + savedText[noteId] = text; + _touch(noteId); + } @override Future<void> savePageText(String noteId, int pageNum, String content) async { if (failText) throw const api.ApiException('Failed to save text'); savedText[noteId] = content; + _touch(noteId); } @override @@ -47,7 +68,9 @@ class FakeApiClient extends api.ApiClient { @override Future<void> savePageInk( String noteId, int pageNum, List<Map<String, dynamic>> strokes) async { + if (failInk) throw const api.ApiException('Failed to save drawing'); savedInk[noteId] = strokes; + _touch(noteId); } @override @@ -280,10 +303,70 @@ void main() { expect(fakeApi.savedText[remoteId], 'written offline'); }); + test('never overwrites a remote note that was edited and left empty', + () async { + final remoteId = await interruptedUpload(); + // Someone opens the note online and saves it empty on purpose. + fakeApi.editOnServer(remoteId, text: ''); + + await sync.pushLocal(); + + // Empty is not proof it is still our untouched placeholder: the + // remote edit stays, the offline text goes to a separate copy. + expect(fakeApi.savedText[remoteId], ''); + final copy = fakeApi.createdNotes.last; + expect(copy['title'], 'Offline (offline copy)'); + expect(fakeApi.savedText['remote-note-2'], 'written offline'); + }); + + test('keeps both versions when the remote state cannot be compared', + () async { + final remoteId = await interruptedUpload(); + // A server that reports no usable updated_at gives no proof either way. + fakeApi.remoteNotes = [ + for (final n in fakeApi.remoteNotes) + n.id == remoteId ? n.copyWith(updatedAt: 'not a date') : n, + ]; + + await sync.pushLocal(); + + expect(fakeApi.savedText[remoteId] ?? '', ''); + expect(fakeApi.savedText['remote-note-2'], 'written offline'); + }); + + test('a partly uploaded note is not resumed over its own remote copy', + () async { + final note = await local.createNote('Sketch', noteType: 'mixed'); + await local.updateNoteContent(note.id, 'caption'); + await local.replaceStrokesForNote(note.id, [ + Stroke( + id: 's1', + noteId: note.id, + createdAt: DateTime.utc(2024), + points: const [StrokePoint(x: 1, y: 2), StrokePoint(x: 3, y: 4)], + ), + ]); + // Text upload succeeds, the drawing upload fails. + fakeApi.failInk = true; + await expectLater(sync.pushLocal(), throwsA(isA<api.ApiException>())); + fakeApi.failInk = false; + final remoteId = fakeApi.remoteNotes.single.id; + expect(fakeApi.savedText[remoteId], 'caption'); + + await sync.pushLocal(); + + // The remote changed since the create (our own text write is + // indistinguishable from someone else's), so nothing is overwritten + // and the complete local version is kept as a copy. + expect(fakeApi.savedText[remoteId], 'caption'); + expect(fakeApi.savedText['remote-note-2'], 'caption'); + expect(fakeApi.savedInk['remote-note-2'], hasLength(1)); + }); + test('never overwrites what was written in that note online since', () async { final remoteId = await interruptedUpload(); - fakeApi.savedText[remoteId] = 'typed on the server afterwards'; + fakeApi.editOnServer(remoteId, text: 'typed on the server afterwards'); await sync.sync(); await sync.sync(); From b99057eb3f66c8da9ca2006d8fcdce0ee88555f3 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 12:54:40 +0000 Subject: [PATCH 30/44] fix: only let a recorded MKCOL placeholder be replaced by note.json The provider treated any note that was empty and shared the payload's 8-char id prefix as an MKCOL placeholder: it deleted it and took over the payload id. A real empty note with a colliding prefix was replaced that way. Placeholders are now recorded when MKCOL (or a PUT into a missing path) creates them, in .webdav_placeholders.json in the data directory so the claim survives a restart, and only a recorded id can be replaced. Everything else gets a 409. The claim no longer depends on the placeholder being empty either, so a drawing PUT before note.json is kept instead of blocking the note with a 409 forever. The placeholder is deleted only after the note is saved under its real id, and when the client's id already exists on the server (the note moved notebooks), that note is updated in place and keeps its drawing instead of being overwritten by the placeholder's empty pages. --- nexanote/sync/webdav_provider.py | 120 +++++++++++++++++++++------ tests/test_webdav_sync_safety.py | 134 +++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 24 deletions(-) diff --git a/nexanote/sync/webdav_provider.py b/nexanote/sync/webdav_provider.py index e0e9b04..e87775a 100644 --- a/nexanote/sync/webdav_provider.py +++ b/nexanote/sync/webdav_provider.py @@ -21,8 +21,10 @@ import json import logging import re +import threading import uuid from datetime import datetime, timezone +from pathlib import Path from typing import Optional from wsgidav.dav_provider import DAVCollection, DAVNonCollection, DAVProvider @@ -40,7 +42,7 @@ HTTP_BAD_REQUEST = getattr(_dav_error, "HTTP_BAD_REQUEST", 400) from nexanote.models.note import InkStroke, Note, Notebook, NoteType, Page, Point -from nexanote.storage.file_store import FileNoteStore +from nexanote.storage.file_store import FileNoteStore, _atomic_write logger = logging.getLogger("nexanote.webdav") @@ -302,6 +304,7 @@ def create_collection(self, name: str) -> "NoteCollection": title=title, ) note.add_page() + _placeholders(self.db).add(note.id) self.db.save_note(note) except Exception as exc: logger.exception("MKCOL note failed: %s", name) @@ -583,6 +586,7 @@ def close(self) -> None: # only the slug's 8-char prefix), drop the placeholder so the # client's full id wins. Without this the on-disk file id would # diverge from the client's id and break future updates. + placeholder_id: Optional[str] = None payload_id = payload.get("id") if ( isinstance(payload_id, str) @@ -590,28 +594,30 @@ def close(self) -> None: and payload_id != self.note.id ): # EN: The note behind this path can also be a real note found - # by the title-slug or id-prefix fallbacks. Only an empty - # MKCOL placeholder minted from this same id prefix may be - # replaced; deleting anything else would destroy a note - # (and its drawings) because another client wrote to a - # colliding path. - # FR: Seul un placeholder vide créé par MKCOL avec le même - # préfixe d'ID peut être remplacé. - if not _is_placeholder_for(self.note, payload_id): + # by the title-slug or id-prefix fallbacks, and a real + # note can be empty and share the 8-char prefix. Only a + # note this server recorded as an MKCOL placeholder may be + # replaced; anything else gets a 409. + # FR: Seul un placeholder enregistré lors du MKCOL peut être + # remplacé ; une vraie note (même vide) donne un 409. + if not _is_placeholder_for(self.db, self.note, payload_id): raise DAVError( HTTP_CONFLICT, context_info="note.json id does not match the note at this path", ) - old_id = self.note.id - self.note.id = payload_id - for page in self.note.pages: - page.note_id = payload_id - try: - self.db.delete_note_permanent(old_id) - except Exception: - logger.warning( - "could not delete placeholder note %s — ignoring", old_id - ) + placeholder_id = self.note.id + existing = self.db.get_note(payload_id, load_pages=True) + if existing is not None: + # The client's note already exists here (e.g. it moved to + # this notebook): update it, keeping its pages and + # drawings, instead of overwriting it with the + # placeholder's empty ones. + existing.notebook_id = self.note.notebook_id + self.note = existing + else: + self.note.id = payload_id + for page in self.note.pages: + page.note_id = payload_id self.note.title = payload.get("title", self.note.title) self.note.tags = payload.get("tags", self.note.tags) or [] @@ -644,6 +650,16 @@ def close(self) -> None: if not _apply_client_timestamp(self.note, payload.get("updated_at")): self.note.touch() self.db.save_note(self.note) + if placeholder_id is not None: + # Only now that the note is saved under its real id. + try: + self.db.delete_note_permanent(placeholder_id) + except Exception: + logger.warning( + "could not delete placeholder note %s — ignoring", + placeholder_id, + ) + _placeholders(self.db).remove(placeholder_id) logger.info("Note mise à jour via WebDAV PUT : %s", self.note.title) except DAVError: raise @@ -652,11 +668,66 @@ def close(self) -> None: raise _safe_dav_error(exc, "saving note failed") from exc -def _is_placeholder_for(note: Note, payload_id: str) -> bool: - """True for the empty note MKCOL creates ahead of a client's note.json.""" - if note.id[:8] != payload_id[:8]: - return False - return all(not p.typed_content and not p.strokes for p in note.pages) +def _is_placeholder_for(db: FileNoteStore, note: Note, payload_id: str) -> bool: + """ + True only for a note this server created itself as a stand-in (MKCOL, or + a PUT into a path that didn't exist yet) for the client note + `payload_id`. Being empty and sharing the id prefix is not enough: a real + note can be both. + """ + return note.id[:8] == payload_id[:8] and _placeholders(db).contains(note.id) + + +class _PlaceholderRegistry: + """ + EN: Ids of the stand-in notes created ahead of a client's note.json, + persisted in the data directory so a server restart between MKCOL + and PUT doesn't turn the placeholder into an unclaimable note. + FR: Ids des notes provisoires créées avant le note.json du client, + persistés pour survivre à un redémarrage. + """ + + FILE_NAME = ".webdav_placeholders.json" + _lock = threading.Lock() + + def __init__(self, data_dir: Path) -> None: + self.path = Path(data_dir) / self.FILE_NAME + + def _read(self) -> set[str]: + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + return set() + except (OSError, ValueError) as exc: + # Unreadable registry: claim nothing. A placeholder then gets a + # 409 instead of a real note possibly being replaced. + logger.warning("unreadable placeholder registry %s: %s", self.path, exc) + return set() + return {i for i in data if isinstance(i, str)} if isinstance(data, list) else set() + + def _write(self, ids: set[str]) -> None: + _atomic_write(self.path, json.dumps(sorted(ids)).encode("utf-8")) + + def add(self, note_id: str) -> None: + with self._lock: + ids = self._read() + ids.add(note_id) + self._write(ids) + + def remove(self, note_id: str) -> None: + with self._lock: + ids = self._read() + if note_id in ids: + ids.discard(note_id) + self._write(ids) + + def contains(self, note_id: str) -> bool: + with self._lock: + return note_id in self._read() + + +def _placeholders(db: FileNoteStore) -> _PlaceholderRegistry: + return _PlaceholderRegistry(db.data_dir) def _apply_client_timestamp(obj, value) -> bool: @@ -961,6 +1032,7 @@ def _materialize_note( title=title, ) note.add_page() + _placeholders(db).add(note.id) db.save_note(note) except Exception: logger.exception("auto-materialize note failed: %s", slug) diff --git a/tests/test_webdav_sync_safety.py b/tests/test_webdav_sync_safety.py index 18fcde2..129a043 100644 --- a/tests/test_webdav_sync_safety.py +++ b/tests/test_webdav_sync_safety.py @@ -314,3 +314,137 @@ def test_locally_deleted_note_does_not_conflict_on_every_sync(tmp_path, server, assert conflicts[1:] == [0, 0] assert db.get_note(note.id).is_deleted + + +# --------------------------------------------------------------------------- +# note.json PUT with an id that differs from the note at that path. Only a +# stand-in note the server created itself (MKCOL, or a PUT into a missing +# path) may be replaced; a real note, even empty and sharing the 8-char id +# prefix, must be left alone. +# --------------------------------------------------------------------------- + +AUTH = ("user", "pass") +REAL_ID = "abcd1234-0000-4000-8000-000000000001" +CLIENT_ID = "abcd1234-9999-4999-9999-999999999999" # same 8-char prefix + + +def _uncategorized(sdb): + return [n for n in sdb.list_notebooks() if n.id.startswith("00000000")][0] + + +def _put_meta(server, path, note_id, text="client text", title="Doc"): + body = {"id": note_id, "title": title, "type": "typed", + "pages": [{"page_number": 1, "typed_content": text}]} + return requests.put(server["url"] + path + "/note.json", json=body, + auth=AUTH, timeout=5) + + +def _mkcol(server, path): + return requests.request("MKCOL", server["url"] + path, auth=AUTH, timeout=5) + + +def test_empty_real_note_with_same_id_prefix_is_not_taken_for_a_placeholder(server): + sdb = server["db"] + real = Note(id=REAL_ID, title="Empty", notebook_id=_uncategorized(sdb).id) + real.add_page() + sdb.save_note(real) + + resp = _put_meta(server, "uncategorized/empty__abcd1234", CLIENT_ID) + + assert resp.status_code == 409 + kept = sdb.get_note(REAL_ID, load_pages=True) + assert kept is not None and kept.title == "Empty" + assert sdb.get_note(CLIENT_ID) is None + + +def test_real_note_content_survives_an_id_mismatch(server): + sdb = server["db"] + real = Note(id=REAL_ID, title="Kept", notebook_id=_uncategorized(sdb).id) + page = real.add_page() + page.typed_content = "precious text" + page.strokes.append(InkStroke(points=[Point(1, 1), Point(2, 2)])) + sdb.save_note(real) + + resp = _put_meta(server, "uncategorized/kept__abcd1234", CLIENT_ID) + + assert resp.status_code == 409 + kept = sdb.get_note(REAL_ID, load_pages=True) + assert kept.pages[0].typed_content == "precious text" + assert len(kept.pages[0].strokes) == 1 + + +def test_mkcol_placeholder_is_claimed_by_the_clients_note_json(server): + sdb = server["db"] + assert _mkcol(server, "uncategorized/doc__abcd1234").status_code in (200, 201) + + resp = _put_meta(server, "uncategorized/doc__abcd1234", CLIENT_ID) + + assert resp.status_code < 300 + claimed = sdb.get_note(CLIENT_ID, load_pages=True) + assert claimed.pages[0].typed_content == "client text" + # The stand-in is gone, not left behind as a second note. + titles = [n.title for n in sdb.list_notes(include_archived=True)] + assert titles.count("Doc") == 1 + + +def test_placeholder_claim_survives_a_server_restart(tmp_path, server): + sdb = server["db"] + assert _mkcol(server, "uncategorized/doc__abcd1234").status_code in (200, 201) + + # New server process on the same data directory. + restarted_db = FileNoteStore(sdb.data_dir) + app = build_app(restarted_db, username="user", password="pass", verbose=False) + port = _free_port() + httpd = cheroot_wsgi.Server(bind_addr=("127.0.0.1", port), wsgi_app=app, numthreads=2) + threading.Thread(target=httpd.start, daemon=True).start() + try: + url = f"http://127.0.0.1:{port}/" + for _ in range(100): + try: + requests.options(url, timeout=1) + break + except requests.RequestException: + time.sleep(0.05) + resp = _put_meta({"url": url}, "uncategorized/doc__abcd1234", CLIENT_ID) + finally: + httpd.stop() + + assert resp.status_code < 300 + assert restarted_db.get_note(CLIENT_ID) is not None + + +def test_drawing_uploaded_before_note_json_is_kept_on_claim(server): + sdb = server["db"] + assert _mkcol(server, "uncategorized/sketch__abcd1234").status_code in (200, 201) + ink = {"strokes": [{"id": "s1", "points": [{"x": 1, "y": 1}, {"x": 2, "y": 2}]}]} + r = requests.put(server["url"] + "uncategorized/sketch__abcd1234/page_1.ink", + json=ink, auth=AUTH, timeout=5) + assert r.status_code < 300 + + resp = _put_meta(server, "uncategorized/sketch__abcd1234", CLIENT_ID, title="Sketch") + + assert resp.status_code < 300 + claimed = sdb.get_note(CLIENT_ID, load_pages=True) + assert len(claimed.pages[0].strokes) == 1 + + +def test_claiming_with_an_id_that_already_exists_keeps_its_drawing(server): + sdb = server["db"] + other_nb = Notebook(name="Elsewhere") + sdb.save_notebook(other_nb) + existing = Note(id=CLIENT_ID, title="Doc", notebook_id=other_nb.id) + page = existing.add_page() + page.typed_content = "old text" + page.strokes.append(InkStroke(points=[Point(1, 1), Point(2, 2)])) + sdb.save_note(existing) + # The client moved the note: it lands in a folder where it isn't found, + # so MKCOL mints a placeholder for it there. + assert _mkcol(server, "uncategorized/doc__abcd1234").status_code in (200, 201) + + resp = _put_meta(server, "uncategorized/doc__abcd1234", CLIENT_ID, text="new text") + + assert resp.status_code < 300 + note = sdb.get_note(CLIENT_ID, load_pages=True) + assert note.pages[0].typed_content == "new text" + assert len(note.pages[0].strokes) == 1 + assert note.notebook_id == _uncategorized(sdb).id From 9e4ff07f8873c2a0b8736ebe9890181e792efff5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 12:58:13 +0000 Subject: [PATCH 31/44] fix: keep a failed ink save pending until it is persisted The ink writer took the pending snapshot before saving it, so when the last save failed and nothing else was drawn, nothing retried it: the drawing stayed on screen for the session but was never stored. The failed snapshot now stays pending (unless a newer drawing replaced it meanwhile, which is tried instead and never overtaken by the older one) and is retried with backoff, when the app leaves the foreground, and when the editor is closed. After the editor is gone, retries stop once the backoff list is exhausted. Closing the desktop window now waits up to 5s for pending text and ink saves instead of exiting with them in flight. --- app/lib/screens/note_editor_screen.dart | 129 +++++++--- .../screens/note_editor_ink_save_test.dart | 229 ++++++++++++++++++ 2 files changed, 331 insertions(+), 27 deletions(-) create mode 100644 app/test/screens/note_editor_ink_save_test.dart diff --git a/app/lib/screens/note_editor_screen.dart b/app/lib/screens/note_editor_screen.dart index 988d5c3..3d481cb 100644 --- a/app/lib/screens/note_editor_screen.dart +++ b/app/lib/screens/note_editor_screen.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:ui' show AppExitResponse; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/api_client.dart'; @@ -32,8 +34,30 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> late String _savedTitle; late String _savedContent; + /// The newest complete drawing not yet confirmed saved. Stays set after a + /// failed save so the same snapshot can be retried; only a newer drawing + /// replaces it. List<Map<String, dynamic>>? _pendingInk; - bool _inkSaveRunning = false; + + /// The single ink writer while it runs; see [_flushInk]. + Future<void>? _inkWriter; + Timer? _inkRetryTimer; + int _inkRetries = 0; + bool _disposed = false; + + /// Backoff between automatic retries of a failed ink save. While the editor + /// is open the last delay repeats; once it is closed, retries stop after + /// going through the list once. + static const _inkRetryDelays = [ + Duration(seconds: 2), + Duration(seconds: 5), + Duration(seconds: 15), + Duration(seconds: 30), + Duration(seconds: 60), + ]; + + /// How long closing the desktop window waits for pending saves. + static const _exitSaveTimeout = Duration(seconds: 5); late Note _note; bool _showInk = false; @@ -165,43 +189,68 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> setState(() => _showInk = true); } - Future<void> _saveInk(List<Map<String, dynamic>> strokes) async { + Future<void> _saveInk(List<Map<String, dynamic>> strokes) { // Keep the on-screen drawing in memory first so a failed persist (or a // mode toggle) never drops what the user just drew. _inkStrokes = strokes; _pendingInk = strokes; - // Each save sends the whole drawing, so only the newest one matters. One - // writer drains it: concurrent saves could otherwise land out of order - // and replace the drawing with an older copy missing the last strokes. - if (_inkSaveRunning) return; + _inkRetries = 0; + return _flushInk(); + } + + /// Starts the ink writer unless it is already running, and completes when + /// it goes idle. Each save sends the whole drawing, so only the newest + /// snapshot matters; a single writer means saves can't land out of order + /// and put an older drawing back. + Future<void> _flushInk() { + _inkRetryTimer?.cancel(); + _inkRetryTimer = null; + return _inkWriter ??= _drainInk().whenComplete(() => _inkWriter = null); + } + + Future<void> _drainInk() async { final state = _appState; if (state == null) return; - _inkSaveRunning = true; - try { - while (_pendingInk != null) { - final next = _pendingInk!; - _pendingInk = null; - try { - await state.savePageInk(_note.id, 1, next); - _inkSaveFailed = false; - } catch (e) { - debugPrint('Ink save error: $e'); - // Surface a friendly, throttled error rather than silently dropping - // the stroke. The drawing stays on screen regardless. - if (mounted && !_inkSaveFailed) { - _inkSaveFailed = true; - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text( - 'Could not save drawing — it stays on screen, retrying as you draw.'), - backgroundColor: Colors.red)); - } + while (_pendingInk != null) { + final next = _pendingInk!; + _pendingInk = null; + try { + await state.savePageInk(_note.id, 1, next); + _inkSaveFailed = false; + _inkRetries = 0; + } catch (e) { + debugPrint('Ink save error: $e'); + // Keep this snapshot pending unless a newer drawing arrived while it + // was saving: the newer one supersedes it and is tried right away, + // and the older one must never be saved after it. + _pendingInk ??= next; + if (!identical(_pendingInk, next)) continue; + // Surface a friendly, throttled error rather than silently dropping + // the stroke. The drawing stays on screen regardless. + if (mounted && !_inkSaveFailed) { + _inkSaveFailed = true; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text( + 'Could not save drawing — it stays on screen and will be retried.'), + backgroundColor: Colors.red)); } + _scheduleInkRetry(); + return; } - } finally { - _inkSaveRunning = false; } } + void _scheduleInkRetry() { + if (_disposed && _inkRetries >= _inkRetryDelays.length) { + debugPrint('Giving up retrying the ink save for note ${_note.id}'); + return; + } + final delay = + _inkRetryDelays[_inkRetries.clamp(0, _inkRetryDelays.length - 1)]; + _inkRetries++; + _inkRetryTimer = Timer(delay, _flushInk); + } + /// The app has no trash view to restore from, so deleting from the editor /// asks first, like the swipe-to-delete in the notes list. Future<void> _confirmDelete(bool isMobile) async { @@ -245,12 +294,38 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> if (state == AppLifecycleState.resumed) return; _saveTimer?.cancel(); if (_hasChanges) _save(); + // A drawing whose save failed would otherwise wait for its retry timer, + // which never fires if the backgrounded process is killed. + if (_pendingInk != null) _flushInk(); + } + + /// Desktop window close: give pending text and ink saves a bounded chance + /// to finish before the process exits, without blocking the close on a + /// backend that never answers. + @override + Future<AppExitResponse> didRequestAppExit() async { + _saveTimer?.cancel(); + final saves = <Future<void>>[ + if (_hasChanges) _persist() else _textSaves, + if (_pendingInk != null || _inkWriter != null) _flushInk(), + ]; + try { + await Future.wait(saves).timeout(_exitSaveTimeout); + } on TimeoutException { + debugPrint('Exiting with saves still pending for note ${_note.id}'); + } + return AppExitResponse.exit; } @override void dispose() { + _disposed = true; WidgetsBinding.instance.removeObserver(this); _saveTimer?.cancel(); + // A failed drawing save keeps retrying (bounded) with the captured + // AppState after the editor is gone; start right away rather than + // waiting for the backoff. + if (_pendingInk != null) _flushInk(); // Flush pending text edits. The save is queued behind any in-flight one // and only uses the captured AppState, so it is safe after unmount. if (_hasChanges) _persist(); diff --git a/app/test/screens/note_editor_ink_save_test.dart b/app/test/screens/note_editor_ink_save_test.dart new file mode 100644 index 0000000..a481e1f --- /dev/null +++ b/app/test/screens/note_editor_ink_save_test.dart @@ -0,0 +1,229 @@ +import 'dart:async'; +import 'dart:ui' show AppExitResponse; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:nexanote/screens/note_editor_screen.dart'; +import 'package:nexanote/services/api_client.dart' as api; +import 'package:nexanote/services/app_state.dart'; +import 'package:nexanote/widgets/ink_canvas.dart'; + +/// Backend whose ink saves can be held open and failed on demand. +class _InkApi extends api.ApiClient { + _InkApi() : super(baseUrl: 'http://ink.test'); + + /// Stroke count of every save attempt, in order. + final attempts = <int>[]; + + /// Stroke count of every save that reached storage, in order. + final persisted = <int>[]; + + bool down = false; + bool hold = false; + final _held = <Completer<void>>[]; + + Future<void> releaseNext({bool fail = false}) async { + final gate = _held.removeAt(0); + fail ? gate.completeError(_error) : gate.complete(); + } + + int get heldCount => _held.length; + + static const _error = api.ApiException('Failed to save drawing', statusCode: 503); + + @override + Future<void> savePageInk( + String noteId, int pageNum, List<Map<String, dynamic>> strokes) async { + attempts.add(strokes.length); + if (hold) { + final gate = Completer<void>(); + _held.add(gate); + await gate.future; + } + if (down) throw _error; + persisted.add(strokes.length); + } +} + +api.Note _sketch() => api.Note( + id: 'n1', + title: 'Sketch', + noteType: 'handwritten', + tags: const [], + isPinned: false, + isDeleted: false, + pageCount: 1, + updatedAt: '', + createdAt: '', + pages: [ + api.NotePage( + pageNumber: 1, template: 'blank', typedContent: '', strokes: []), + ], + ); + +void main() { + late _InkApi backend; + late AppState state; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + backend = _InkApi(); + state = AppState(clientFactory: (_) => backend); + }); + + Future<void> pumpEditor(WidgetTester tester) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(420, 900); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(ChangeNotifierProvider<AppState>.value( + value: state, + child: MaterialApp(home: NoteEditorScreen(note: _sketch())), + )); + await tester.pump(); + } + + var strokeRow = 0; + Future<void> drawStroke(WidgetTester tester) async { + final origin = tester.getTopLeft(find.byType(InkCanvas)) + + Offset(100, 150 + 40.0 * strokeRow++); + final g = await tester.startGesture(origin); + await g.moveBy(const Offset(30, 0)); + await g.moveBy(const Offset(30, 0)); + await g.up(); + await tester.pump(); + } + + setUp(() => strokeRow = 0); + + testWidgets('a failed last save stays pending and is retried on background', + (tester) async { + await pumpEditor(tester); + backend.down = true; + + await drawStroke(tester); + expect(backend.attempts, [1]); + expect(backend.persisted, isEmpty); + + // No new stroke. Going to the background must retry the same snapshot + // rather than letting the drawing vanish with the process. + backend.down = false; + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + + expect(backend.persisted, [1]); + }); + + testWidgets('a failed save is retried on its own once the backend is back', + (tester) async { + await pumpEditor(tester); + backend.down = true; + await drawStroke(tester); + expect(backend.persisted, isEmpty); + + backend.down = false; + await tester.pump(const Duration(minutes: 2)); + + expect(backend.persisted, [1]); + }); + + testWidgets('only the newest complete drawing is saved after a slow save', + (tester) async { + await pumpEditor(tester); + backend.hold = true; + + await drawStroke(tester); // save of 1 stroke in flight + await drawStroke(tester); // queued + await drawStroke(tester); // replaces the queued one + expect(backend.heldCount, 1); + + await backend.releaseNext(); + await tester.pump(); + expect(backend.heldCount, 1); + await backend.releaseNext(); + await tester.pump(); + + expect(backend.attempts, [1, 3]); + expect(backend.persisted.last, 3); + }); + + testWidgets('a failure on an older drawing never brings it back', + (tester) async { + await pumpEditor(tester); + backend.hold = true; + + await drawStroke(tester); // 1 stroke in flight + await drawStroke(tester); // 2 strokes queued + await backend.releaseNext(fail: true); // the 1-stroke save fails + await tester.pump(); + await backend.releaseNext(); // the 2-stroke save succeeds + await tester.pump(); + + backend.hold = false; + await tester.pump(const Duration(minutes: 3)); // any retries run here + + expect(backend.persisted, [2]); + expect(backend.attempts, [1, 2]); + }); + + testWidgets('a drawing that failed to save is retried after leaving the ' + 'editor', (tester) async { + await pumpEditor(tester); + backend.down = true; + await drawStroke(tester); + + await tester.pumpWidget(const SizedBox()); + backend.down = false; + await tester.pump(const Duration(minutes: 2)); + + expect(backend.persisted, [1]); + }); + + testWidgets('retries after leaving the editor stop eventually', + (tester) async { + await pumpEditor(tester); + backend.down = true; + await drawStroke(tester); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(minutes: 30)); + final attempts = backend.attempts.length; + await tester.pump(const Duration(minutes: 30)); + + expect(backend.attempts.length, attempts); + }); + + testWidgets('closing the desktop window waits for the pending drawing', + (tester) async { + await pumpEditor(tester); + backend.hold = true; + await drawStroke(tester); + + AppExitResponse? response; + tester.binding.handleRequestAppExit().then((r) => response = r); + await tester.pump(); + expect(response, isNull, reason: 'exit waits while the save is in flight'); + + await backend.releaseNext(); + await tester.pump(); + + expect(response, AppExitResponse.exit); + expect(backend.persisted, [1]); + }); + + testWidgets('closing the window is not blocked forever by a stuck save', + (tester) async { + await pumpEditor(tester); + backend.hold = true; + await drawStroke(tester); + + AppExitResponse? response; + tester.binding.handleRequestAppExit().then((r) => response = r); + await tester.pump(const Duration(seconds: 10)); + + expect(response, AppExitResponse.exit); + }); +} From 67254b3779e7fbd84d7bbc96b2b92608052d71d6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 13:01:36 +0000 Subject: [PATCH 32/44] chore: fix analyzer findings so flutter analyze can gate CI flutter analyze exited 1 on main and on this branch (82 findings), so it could only run as `|| true`. This clears all of them so the new CI job can fail on any new finding: - Deprecated APIs replaced by their direct equivalents: withOpacity -> withValues(alpha:), Color.value -> toARGB32(), Matrix4 translate/scale -> translateByDouble/scaleByDouble, Switch activeColor -> activeThumbColor. - `path` was imported without being declared; it is now a direct dependency. pubspec.lock regenerated by `flutter pub get` (the other entries are the SDK-pinned packages of current stable). - Two Settings methods read context after an await without checking mounted, and the Reconnect snackbar checked the State instead of the builder context. - The rest is mechanical (const, unused imports, test local names). - analysis_options.yaml: the build/platform excludes flutter analyze adds on its own. --- app/analysis_options.yaml | 5 ++ app/lib/main.dart | 8 +-- app/lib/screens/connect_screen.dart | 10 ++-- app/lib/screens/home_screen.dart | 5 +- app/lib/screens/note_editor_screen.dart | 8 +-- app/lib/screens/settings_screen.dart | 52 ++++++++++--------- app/lib/services/sync_service.dart | 18 ++++--- app/lib/widgets/ink_canvas.dart | 16 +++--- app/lib/widgets/notebook_sidebar.dart | 12 ++--- app/lib/widgets/notes_list.dart | 22 ++++---- app/pubspec.lock | 18 +++---- app/pubspec.yaml | 1 + app/test/data/database/app_database_test.dart | 2 +- app/test/data/models/note_test.dart | 38 +++++++------- app/test/data/models/notebook_test.dart | 24 ++++----- app/test/data/models/stroke_test.dart | 14 ++--- .../screens/note_editor_persistence_test.dart | 1 - app/test/screens/onboarding_routing_test.dart | 1 - app/test/screens/settings_overflow_test.dart | 1 - app/test/services/app_state_test.dart | 7 +-- .../services/local_note_service_test.dart | 1 - .../services/offline_persistence_test.dart | 1 - app/test/services/sync_service_test.dart | 1 - 23 files changed, 133 insertions(+), 133 deletions(-) diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml index 0d29021..a52bb86 100644 --- a/app/analysis_options.yaml +++ b/app/analysis_options.yaml @@ -7,6 +7,11 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/app/lib/main.dart b/app/lib/main.dart index d7c3516..1b14604 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -67,7 +67,7 @@ class NexaNoteApp extends StatelessWidget { ), navigationRailTheme: NavigationRailThemeData( backgroundColor: isDark ? const Color(0xFF181825) : const Color(0xFFF5F5FF), - indicatorColor: primary.withOpacity(0.15), + indicatorColor: primary.withValues(alpha: 0.15), selectedIconTheme: const IconThemeData(color: primary), selectedLabelTextStyle: const TextStyle(color: primary, fontWeight: FontWeight.w600), ), @@ -149,7 +149,7 @@ class _SplashScreenState extends State<_SplashScreen> with SingleTickerProviderS borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: const Color(0xFF6366F1).withOpacity(0.3), + color: const Color(0xFF6366F1).withValues(alpha: 0.3), blurRadius: 20, spreadRadius: 2, ), @@ -176,7 +176,7 @@ class _SplashScreenState extends State<_SplashScreen> with SingleTickerProviderS _loadingMessage, key: ValueKey<String>(_loadingMessage), style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), ), ), ), @@ -184,7 +184,7 @@ class _SplashScreenState extends State<_SplashScreen> with SingleTickerProviderS SizedBox( width: 120, child: LinearProgressIndicator( - backgroundColor: const Color(0xFF6366F1).withOpacity(0.2), + backgroundColor: const Color(0xFF6366F1).withValues(alpha: 0.2), color: const Color(0xFF6366F1), borderRadius: BorderRadius.circular(4), ), diff --git a/app/lib/screens/connect_screen.dart b/app/lib/screens/connect_screen.dart index 01ed0bc..a164b2e 100644 --- a/app/lib/screens/connect_screen.dart +++ b/app/lib/screens/connect_screen.dart @@ -108,7 +108,7 @@ class _ConnectScreenState extends State<ConnectScreen> { 'device. Connecting a server is optional and only needed to ' 'sync across devices.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: scheme.onSurface.withOpacity(0.6), + color: scheme.onSurface.withValues(alpha: 0.6), ), ), const SizedBox(height: 24), @@ -146,7 +146,7 @@ class _ConnectScreenState extends State<ConnectScreen> { child: Text('or connect to a server', style: TextStyle( fontSize: 12, - color: scheme.onSurface.withOpacity(0.5))), + color: scheme.onSurface.withValues(alpha: 0.5))), ), const Expanded(child: Divider()), ]), @@ -275,7 +275,7 @@ class _ConnectScreenState extends State<ConnectScreen> { Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: scheme.surfaceContainerHighest.withOpacity(0.5), + color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(10), ), child: Column( @@ -284,7 +284,7 @@ class _ConnectScreenState extends State<ConnectScreen> { Row(children: [ Icon(Icons.info_outline, size: 16, - color: scheme.onSurface.withOpacity(0.5)), + color: scheme.onSurface.withValues(alpha: 0.5)), const SizedBox(width: 6), // Flexible so it wraps on narrow phones or with a // large system font instead of overflowing. @@ -293,7 +293,7 @@ class _ConnectScreenState extends State<ConnectScreen> { style: TextStyle( fontWeight: FontWeight.w600, fontSize: 13, - color: scheme.onSurface.withOpacity(0.7))), + color: scheme.onSurface.withValues(alpha: 0.7))), ), ]), const SizedBox(height: 8), diff --git a/app/lib/screens/home_screen.dart b/app/lib/screens/home_screen.dart index 5e1f9bb..4612743 100644 --- a/app/lib/screens/home_screen.dart +++ b/app/lib/screens/home_screen.dart @@ -5,7 +5,6 @@ import '../services/api_client.dart'; import '../widgets/notebook_sidebar.dart'; import '../widgets/notes_list.dart'; import 'note_editor_screen.dart'; -import 'settings_screen.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); @@ -343,10 +342,10 @@ class _EmptyEditor extends StatelessWidget { @override Widget build(BuildContext context) { return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.edit_note, size: 64, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.2)), + Icon(Icons.edit_note, size: 64, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.2)), const SizedBox(height: 16), Text('Select a note or create one', - style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4))), + style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4))), ])); } } diff --git a/app/lib/screens/note_editor_screen.dart b/app/lib/screens/note_editor_screen.dart index 3d481cb..6804632 100644 --- a/app/lib/screens/note_editor_screen.dart +++ b/app/lib/screens/note_editor_screen.dart @@ -498,7 +498,7 @@ class _ModeToggle extends StatelessWidget { return Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration( - color: scheme.surfaceContainerHighest.withOpacity(0.5), + color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(10)), child: Row(mainAxisSize: MainAxisSize.min, children: [ _ModeBtn( @@ -535,7 +535,7 @@ class _ModeBtn extends StatelessWidget { Widget build(BuildContext context) { final fg = selected ? Colors.white - : Theme.of(context).colorScheme.onSurface.withOpacity(0.6); + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6); return Tooltip( message: label, child: GestureDetector( @@ -741,7 +741,7 @@ class _Btn extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.7))))); + .withValues(alpha: 0.7))))); } } @@ -757,7 +757,7 @@ class _WordCountFooter extends StatelessWidget { Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final style = - TextStyle(fontSize: 11, color: scheme.onSurface.withOpacity(0.45)); + TextStyle(fontSize: 11, color: scheme.onSurface.withValues(alpha: 0.45)); return Container( constraints: const BoxConstraints(minHeight: 36), alignment: Alignment.centerLeft, diff --git a/app/lib/screens/settings_screen.dart b/app/lib/screens/settings_screen.dart index f919307..8a52916 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -73,6 +73,7 @@ class _SettingsScreenState extends State<SettingsScreen> { Future<void> _loadSettings() async { final prefs = await SharedPreferences.getInstance(); + if (!mounted) return; final state = context.read<AppState>(); setState(() { _apiUrlCtrl.text = state.apiUrl; @@ -157,6 +158,7 @@ class _SettingsScreenState extends State<SettingsScreen> { // Push the WebDAV credentials to the backend, which performs the actual // sync. In local mode there is no backend to configure — just persist the // values locally so they're ready once the user connects one. + if (!mounted) return false; final state = context.read<AppState>(); if (state.isBackendConfigured) { try { @@ -296,7 +298,7 @@ class _SettingsScreenState extends State<SettingsScreen> { const SizedBox(height: 24), // ── Stats ────────────────────────────────────────────── - _SectionTitle(icon: Icons.bar_chart, title: 'Storage'), + const _SectionTitle(icon: Icons.bar_chart, title: 'Storage'), Card( child: Padding( padding: const EdgeInsets.all(16), @@ -315,7 +317,7 @@ class _SettingsScreenState extends State<SettingsScreen> { const SizedBox(height: 24), // ── Sync NAS WebDAV ──────────────────────────────────── - _SectionTitle(icon: Icons.sync, title: 'WebDAV Sync — NAS'), + const _SectionTitle(icon: Icons.sync, title: 'WebDAV Sync — NAS'), Card( child: Padding( @@ -371,7 +373,7 @@ class _SettingsScreenState extends State<SettingsScreen> { ? 'Available once a backend is configured' : 'Sync automatically in background'), value: _autoSync && !localMode, - activeColor: const Color(0xFF6366F1), + activeThumbColor: const Color(0xFF6366F1), onChanged: localMode ? null : (v) { @@ -424,9 +426,9 @@ class _SettingsScreenState extends State<SettingsScreen> { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: _syncColor.withOpacity(0.1), + color: _syncColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), - border: Border.all(color: _syncColor.withOpacity(0.3)), + border: Border.all(color: _syncColor.withValues(alpha: 0.3)), ), child: Row( children: [ @@ -478,7 +480,7 @@ class _SettingsScreenState extends State<SettingsScreen> { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: scheme.onSurface.withOpacity(0.5))), + color: scheme.onSurface.withValues(alpha: 0.5))), const SizedBox(height: 6), Row( children: [ @@ -510,7 +512,7 @@ class _SettingsScreenState extends State<SettingsScreen> { const SizedBox(height: 24), // ── Dossier de notes ─────────────────────────────────── - _SectionTitle(icon: Icons.folder_outlined, title: 'Dossier de notes'), + const _SectionTitle(icon: Icons.folder_outlined, title: 'Dossier de notes'), Card( child: Padding( padding: const EdgeInsets.all(16), @@ -520,14 +522,14 @@ class _SettingsScreenState extends State<SettingsScreen> { // Dossier actuel (lu depuis l'API) if (_storageInfo['data_dir'] != null) ...[ Row(children: [ - Icon(Icons.folder, size: 16, color: const Color(0xFF6366F1)), + const Icon(Icons.folder, size: 16, color: Color(0xFF6366F1)), const SizedBox(width: 8), Expanded( child: Text( 'Actuel : ${_storageInfo['data_dir']}', style: TextStyle( fontSize: 12, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), fontFamily: 'monospace', ), // Wrap long paths onto a second line (then ellipsize) @@ -542,7 +544,7 @@ class _SettingsScreenState extends State<SettingsScreen> { '${_storageInfo['db_size_mb']} MB', style: TextStyle( fontSize: 11, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4), ), ), ]), @@ -564,7 +566,7 @@ class _SettingsScreenState extends State<SettingsScreen> { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: const Color(0xFF6366F1).withOpacity(0.08), + color: const Color(0xFF6366F1).withValues(alpha: 0.08), borderRadius: BorderRadius.circular(8), ), child: Row(children: [ @@ -575,7 +577,7 @@ class _SettingsScreenState extends State<SettingsScreen> { 'Le changement sera pris en compte au prochain démarrage via nexanote.sh', style: TextStyle( fontSize: 11, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), ), ), ), @@ -605,7 +607,7 @@ class _SettingsScreenState extends State<SettingsScreen> { const SizedBox(height: 24), // ── Backend API (optional) ───────────────────────────── - _SectionTitle(icon: Icons.api, title: 'Backend API (optional)'), + const _SectionTitle(icon: Icons.api, title: 'Backend API (optional)'), Card( child: Padding( padding: const EdgeInsets.all(16), @@ -620,7 +622,7 @@ class _SettingsScreenState extends State<SettingsScreen> { : 'Connect a NexaNote backend for WebDAV/NAS sync.', style: TextStyle( fontSize: 12, - color: scheme.onSurface.withOpacity(0.6)), + color: scheme.onSurface.withValues(alpha: 0.6)), ), const SizedBox(height: 12), TextField( @@ -655,7 +657,7 @@ class _SettingsScreenState extends State<SettingsScreen> { return; } await context.read<AppState>().connect(url: url); - if (mounted) { + if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Reconnecting...')), ); @@ -671,7 +673,7 @@ class _SettingsScreenState extends State<SettingsScreen> { const SizedBox(height: 24), // ── About ────────────────────────────────────────────── - _SectionTitle(icon: Icons.info_outline, title: 'About'), + const _SectionTitle(icon: Icons.info_outline, title: 'About'), Card( child: Column( children: [ @@ -700,10 +702,10 @@ class _SettingsScreenState extends State<SettingsScreen> { trailing: const Icon(Icons.open_in_new, size: 16), onTap: () {}, ), - ListTile( - leading: const Icon(Icons.balance, size: 20), - title: const Text('License'), - subtitle: const Text('MPL 2.0'), + const ListTile( + leading: Icon(Icons.balance, size: 20), + title: Text('License'), + subtitle: Text('MPL 2.0'), ), ], ), @@ -759,7 +761,7 @@ class _ModeCard extends StatelessWidget { Text(subtitle, style: TextStyle( fontSize: 12, - color: scheme.onSurface.withOpacity(0.6))), + color: scheme.onSurface.withValues(alpha: 0.6))), ], ), ), @@ -782,7 +784,7 @@ class _SectionTitle extends StatelessWidget { child: Row( children: [ Icon(icon, size: 16, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.5)), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5)), const SizedBox(width: 6), Text(title.toUpperCase(), style: TextStyle( @@ -792,7 +794,7 @@ class _SectionTitle extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.5))), + .withValues(alpha: 0.5))), ], ), ); @@ -824,7 +826,7 @@ class _StatChip extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.5))), + .withValues(alpha: 0.5))), ], ), ), @@ -851,7 +853,7 @@ class _SyncStat extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.5))), + .withValues(alpha: 0.5))), ], ); } diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 0eb7f9d..8d2d4c1 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -83,7 +83,7 @@ class SyncService { /// Adopted notes (remoteId set by a pull, status synced/modified/conflict) /// are never pushed: local rows of pulled notes hold no page content, so /// uploading them would blank the server copy. - Future<_PushCounts> pushLocal() async { + Future<PushCounts> pushLocal() async { final snapshot = await _local.exportAllData(); var notebooks = 0; var notes = 0; @@ -146,7 +146,7 @@ class SyncService { await _uploadContent(note, target); await _local.markNoteSyncedIfUnchanged(note.id, note.updatedAt); } - return _PushCounts(notebooks: notebooks, notes: notes); + return PushCounts(notebooks: notebooks, notes: notes); } Future<void> _uploadContent(Note note, String remoteId) async { @@ -200,7 +200,7 @@ class SyncService { /// - When a remote note carries the same cleaned title as an unrelated /// local row (different id), both are kept and the local row is /// flagged with `sync_status='conflict'`. - Future<_PullCounts> pullRemote() async { + Future<PullCounts> pullRemote() async { final remoteNotebooks = await _api.getNotebooks(); final remoteNotes = await _api.getNotes(includeDeleted: true); @@ -314,7 +314,7 @@ class SyncService { } } - return _PullCounts( + return PullCounts( notebooks: remoteNotebooks.length, notes: remoteNotes.length, adopted: adopted, @@ -389,17 +389,19 @@ class SyncService { title_cleaner.cleanRemoteTitle(raw); } -class _PushCounts { +/// Records created on the server by [SyncService.pushLocal]. +class PushCounts { final int notebooks; final int notes; - const _PushCounts({required this.notebooks, required this.notes}); + const PushCounts({required this.notebooks, required this.notes}); } -class _PullCounts { +/// What [SyncService.pullRemote] read from the server. +class PullCounts { final int notebooks; final int notes; final int adopted; - const _PullCounts({ + const PullCounts({ required this.notebooks, required this.notes, required this.adopted, diff --git a/app/lib/widgets/ink_canvas.dart b/app/lib/widgets/ink_canvas.dart index 56b6ae4..4126473 100644 --- a/app/lib/widgets/ink_canvas.dart +++ b/app/lib/widgets/ink_canvas.dart @@ -51,7 +51,7 @@ class InkStrokeData { Map<String, dynamic> toJson() => { 'id': id, 'color': - '#${color.value.toRadixString(16).padLeft(8, '0').substring(2)}', + '#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}', 'width': width, 'tool': tool.name, 'points': points.map((p) => p.toJson()).toList(), @@ -118,7 +118,7 @@ class _InkPainter extends CustomPainter { void _drawTemplate(Canvas canvas, Size size) { final paint = Paint() - ..color = const Color(0xFFDDDDFF).withOpacity(0.5) + ..color = const Color(0xFFDDDDFF).withValues(alpha: 0.5) ..strokeWidth = 0.5; switch (template) { @@ -140,7 +140,7 @@ class _InkPainter extends CustomPainter { case 'dotted': const spacing = 40.0; final dotPaint = Paint() - ..color = const Color(0xFFAAAACC).withOpacity(0.5) + ..color = const Color(0xFFAAAACC).withValues(alpha: 0.5) ..strokeWidth = 1.5 ..strokeCap = StrokeCap.round; for (double y = spacing; y < size.height; y += spacing) { @@ -178,7 +178,7 @@ class _InkPainter extends CustomPainter { final paint = Paint() ..color = isHighlighter - ? stroke.color.withOpacity(0.35) + ? stroke.color.withValues(alpha: 0.35) : stroke.color ..strokeWidth = w ..strokeCap = StrokeCap.round @@ -468,8 +468,8 @@ class _InkCanvasState extends State<InkCanvas> { }, child: Transform( transform: Matrix4.identity() - ..translate(_offset.dx, _offset.dy) - ..scale(_scale), + ..translateByDouble(_offset.dx, _offset.dy, 0, 1) + ..scaleByDouble(_scale, _scale, _scale, 1), child: CustomPaint( painter: _InkPainter( strokes: _strokes, @@ -642,7 +642,7 @@ class _ToolBtn extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( color: selected - ? const Color(0xFF6366F1).withOpacity(0.15) + ? const Color(0xFF6366F1).withValues(alpha: 0.15) : Colors.transparent, borderRadius: BorderRadius.circular(8), ), @@ -651,7 +651,7 @@ class _ToolBtn extends StatelessWidget { size: 20, color: selected ? const Color(0xFF6366F1) - : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), ), ), ), diff --git a/app/lib/widgets/notebook_sidebar.dart b/app/lib/widgets/notebook_sidebar.dart index 479f615..52b6099 100644 --- a/app/lib/widgets/notebook_sidebar.dart +++ b/app/lib/widgets/notebook_sidebar.dart @@ -122,12 +122,12 @@ class NotebookSidebar extends StatelessWidget { size: 20, color: selected == null ? const Color(0xFF6366F1) - : scheme.onSurface.withOpacity(0.6), + : scheme.onSurface.withValues(alpha: 0.6), ), title: const Text('All Notes', style: TextStyle(fontSize: 14)), selected: selected == null, selectedColor: const Color(0xFF6366F1), - selectedTileColor: const Color(0xFF6366F1).withOpacity(0.08), + selectedTileColor: const Color(0xFF6366F1).withValues(alpha: 0.08), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), onTap: () => onSelect(null), @@ -142,7 +142,7 @@ class NotebookSidebar extends StatelessWidget { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: scheme.onSurface.withOpacity(0.4), + color: scheme.onSurface.withValues(alpha: 0.4), letterSpacing: 0.8)), const Spacer(), IconButton( @@ -189,7 +189,7 @@ class NotebookSidebar extends StatelessWidget { selected: isSelected, selectedColor: const Color(0xFF6366F1), selectedTileColor: - const Color(0xFF6366F1).withOpacity(0.08), + const Color(0xFF6366F1).withValues(alpha: 0.08), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), onTap: () => onSelect(nb), @@ -204,11 +204,11 @@ class NotebookSidebar extends StatelessWidget { ListTile( dense: true, leading: Icon(Icons.settings_outlined, - size: 18, color: scheme.onSurface.withOpacity(0.5)), + size: 18, color: scheme.onSurface.withValues(alpha: 0.5)), title: Text('Settings', style: TextStyle( fontSize: 13, - color: scheme.onSurface.withOpacity(0.6))), + color: scheme.onSurface.withValues(alpha: 0.6))), onTap: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const SettingsScreen()), diff --git a/app/lib/widgets/notes_list.dart b/app/lib/widgets/notes_list.dart index f8ba7ac..20d66a6 100644 --- a/app/lib/widgets/notes_list.dart +++ b/app/lib/widgets/notes_list.dart @@ -36,14 +36,14 @@ class NotesList extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.2)), + .withValues(alpha: 0.2)), const SizedBox(height: 12), Text('No notes yet', style: TextStyle( color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.4))), + .withValues(alpha: 0.4))), ], ), ); @@ -56,14 +56,14 @@ class NotesList extends StatelessWidget { return ListView( children: [ if (pinned.isNotEmpty) ...[ - _SectionHeader(title: 'PINNED'), + const _SectionHeader(title: 'PINNED'), ...pinned.map((n) => _NoteCard( note: n, isSelected: selected?.id == n.id, onTap: () => onSelect(n), onDelete: () => onDelete(n), )), - _SectionHeader(title: 'NOTES'), + const _SectionHeader(title: 'NOTES'), ], ...regular.map((n) => _NoteCard( note: n, @@ -89,7 +89,7 @@ class _SectionHeader extends StatelessWidget { style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4), letterSpacing: 0.8, ), ), @@ -164,12 +164,12 @@ class _NoteCard extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: isSelected - ? const Color(0xFF6366F1).withOpacity(0.1) + ? const Color(0xFF6366F1).withValues(alpha: 0.1) : Colors.transparent, borderRadius: BorderRadius.circular(10), border: isSelected ? Border.all( - color: const Color(0xFF6366F1).withOpacity(0.3)) + color: const Color(0xFF6366F1).withValues(alpha: 0.3)) : null, ), child: Padding( @@ -183,8 +183,8 @@ class _NoteCard extends StatelessWidget { height: 32, decoration: BoxDecoration( color: isHandwritten - ? const Color(0xFFEC4899).withOpacity(0.12) - : const Color(0xFF6366F1).withOpacity(0.12), + ? const Color(0xFFEC4899).withValues(alpha: 0.12) + : const Color(0xFF6366F1).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), child: Icon( @@ -228,7 +228,7 @@ class _NoteCard extends StatelessWidget { _formatDate(note.updatedAt), style: TextStyle( fontSize: 11, - color: scheme.onSurface.withOpacity(0.4), + color: scheme.onSurface.withValues(alpha: 0.4), ), ), if (note.tags.isNotEmpty) ...[ @@ -245,7 +245,7 @@ class _NoteCard extends StatelessWidget { style: TextStyle( fontSize: 10, color: scheme.onSurface - .withOpacity(0.5))), + .withValues(alpha: 0.5))), )), ], ], diff --git a/app/pubspec.lock b/app/pubspec.lock index 2fcff0b..898f39e 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -204,10 +204,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -228,10 +228,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.19.0" native_toolchain_c: dependency: transitive description: @@ -265,7 +265,7 @@ packages: source: hosted version: "4.1.0" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" @@ -529,10 +529,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.12" typed_data: dependency: transitive description: @@ -553,10 +553,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index b13a757..27f5065 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: # UUID pour les IDs locaux uuid: ^4.3.3 + path: ^1.9.0 path_provider: ^2.1.5 xdg_directories: ^1.1.0 diff --git a/app/test/data/database/app_database_test.dart b/app/test/data/database/app_database_test.dart index 1181704..481f74f 100644 --- a/app/test/data/database/app_database_test.dart +++ b/app/test/data/database/app_database_test.dart @@ -108,7 +108,7 @@ void main() { group('Schema.onUpgrade', () { test('adds remote_id/remote_path when migrating v1 → v2', () async { - final dbPath = inMemoryDatabasePath; + const dbPath = inMemoryDatabasePath; // Open at v1 with the v1 schema (no remote_id/remote_path). final v1 = await openDatabase( diff --git a/app/test/data/models/note_test.dart b/app/test/data/models/note_test.dart index 2d327d6..ebfc126 100644 --- a/app/test/data/models/note_test.dart +++ b/app/test/data/models/note_test.dart @@ -5,7 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nexanote/data/models/note.dart'; void main() { - final _ts = DateTime.utc(2024, 6, 1, 9, 0); + final ts = DateTime.utc(2024, 6, 1, 9, 0); group('Note', () { test('toMap / fromMap round-trip preserves all fields', () { @@ -22,8 +22,8 @@ void main() { syncStatus: 'modified', remoteId: 'md.SGVsbG8', remotePath: 'notes/Hello.md', - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); final restored = Note.fromMap(note.toMap()); @@ -48,8 +48,8 @@ void main() { final note = Note( id: 'note-default', title: 'Default', - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); expect(note.remoteId, isNull); expect(note.remotePath, isNull); @@ -62,8 +62,8 @@ void main() { final note = Note( id: 'n', title: 'T', - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); final updated = note.copyWith( remoteId: 'md.AAAA', @@ -78,8 +78,8 @@ void main() { id: 'note-1', title: 'Tagged', tags: ['a', 'b', 'c'], - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); final map = note.toMap(); expect(map['tags'], isA<String>()); @@ -92,8 +92,8 @@ void main() { id: 'note-2', title: 'No Tags', tags: [], - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); final restored = Note.fromMap(note.toMap()); expect(restored.tags, isEmpty); @@ -106,8 +106,8 @@ void main() { isPinned: true, isArchived: false, isDeleted: true, - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); final map = note.toMap(); expect(map['is_pinned'], 1); @@ -120,28 +120,28 @@ void main() { id: 'note-4', notebookId: 'nb-1', title: 'Original', - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); final updated = note.copyWith( title: 'Updated', syncStatus: 'modified', - updatedAt: _ts.add(const Duration(minutes: 5)), + updatedAt: ts.add(const Duration(minutes: 5)), ); expect(updated.id, 'note-4'); expect(updated.notebookId, 'nb-1'); expect(updated.title, 'Updated'); expect(updated.syncStatus, 'modified'); - expect(updated.createdAt, _ts); + expect(updated.createdAt, ts); }); test('null notebookId survives round-trip', () { final note = Note( id: 'note-5', title: 'Orphan', - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); final restored = Note.fromMap(note.toMap()); expect(restored.notebookId, isNull); diff --git a/app/test/data/models/notebook_test.dart b/app/test/data/models/notebook_test.dart index 25a34df..465dfa4 100644 --- a/app/test/data/models/notebook_test.dart +++ b/app/test/data/models/notebook_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nexanote/data/models/notebook.dart'; void main() { - final _ts = DateTime.utc(2024, 6, 1, 9, 0); + final ts = DateTime.utc(2024, 6, 1, 9, 0); group('Notebook', () { test('toMap / fromMap round-trip preserves all fields', () { @@ -16,8 +16,8 @@ void main() { icon: 'briefcase', isArchived: false, syncStatus: 'synced', - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); final restored = Notebook.fromMap(nb.toMap()); @@ -44,8 +44,8 @@ void main() { 'icon': 'notebook', 'is_archived': 0, 'sync_status': 'local_only', - 'created_at': _ts.toIso8601String(), - 'updated_at': _ts.toIso8601String(), + 'created_at': ts.toIso8601String(), + 'updated_at': ts.toIso8601String(), }; final nb = Notebook.fromMap(map); expect(nb.parentId, isNull); @@ -56,20 +56,20 @@ void main() { final nb = Notebook( id: 'nb-1', name: 'Old', - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); final updated = nb.copyWith( name: 'New', syncStatus: 'modified', - updatedAt: _ts.add(const Duration(hours: 1)), + updatedAt: ts.add(const Duration(hours: 1)), ); expect(updated.id, 'nb-1'); expect(updated.name, 'New'); expect(updated.syncStatus, 'modified'); - expect(updated.createdAt, _ts); - expect(updated.updatedAt, isNot(_ts)); + expect(updated.createdAt, ts); + expect(updated.updatedAt, isNot(ts)); }); test('toMap encodes isArchived as 0/1 integer', () { @@ -77,8 +77,8 @@ void main() { id: 'nb-2', name: 'X', isArchived: true, - createdAt: _ts, - updatedAt: _ts, + createdAt: ts, + updatedAt: ts, ); expect(nb.toMap()['is_archived'], 1); }); diff --git a/app/test/data/models/stroke_test.dart b/app/test/data/models/stroke_test.dart index e32fd97..e162a8d 100644 --- a/app/test/data/models/stroke_test.dart +++ b/app/test/data/models/stroke_test.dart @@ -4,7 +4,7 @@ import 'package:nexanote/data/models/stroke.dart'; import 'package:nexanote/data/models/point.dart'; void main() { - final _ts = DateTime.utc(2024, 6, 1, 9, 0); + final ts = DateTime.utc(2024, 6, 1, 9, 0); group('StrokePoint', () { test('fromMap reads all fields', () { @@ -28,7 +28,7 @@ void main() { }); test('toMap includes stroke_id and seq', () { - final pt = const StrokePoint(x: 5.0, y: 10.0, pressure: 0.8, timestampMs: 100); + const pt = StrokePoint(x: 5.0, y: 10.0, pressure: 0.8, timestampMs: 100); final map = pt.toMap('stroke-99', 3); expect(map['stroke_id'], 'stroke-99'); expect(map['seq'], 3); @@ -50,7 +50,7 @@ void main() { color: '#0000ff', width: 3.5, tool: 'highlighter', - createdAt: _ts, + createdAt: ts, points: points, ); @@ -64,7 +64,7 @@ void main() { expect(restored.color, '#0000ff'); expect(restored.width, 3.5); expect(restored.tool, 'highlighter'); - expect(restored.createdAt, _ts); + expect(restored.createdAt, ts); expect(restored.points.length, 2); }); @@ -72,7 +72,7 @@ void main() { final map = <String, dynamic>{ 'id': 'stroke-2', 'note_id': 'note-1', - 'created_at': _ts.toIso8601String(), + 'created_at': ts.toIso8601String(), }; final stroke = Stroke.fromMap(map); expect(stroke.color, '#000000'); @@ -84,7 +84,7 @@ void main() { final stroke = Stroke( id: 'stroke-3', noteId: 'note-1', - createdAt: _ts, + createdAt: ts, ); final updated = stroke.copyWith(color: '#ff0000', width: 5.0); expect(updated.id, 'stroke-3'); @@ -97,7 +97,7 @@ void main() { final stroke = Stroke( id: 'stroke-4', noteId: 'note-1', - createdAt: _ts, + createdAt: ts, points: points, ); final map = stroke.toMap(); diff --git a/app/test/screens/note_editor_persistence_test.dart b/app/test/screens/note_editor_persistence_test.dart index 01a0e34..85f842b 100644 --- a/app/test/screens/note_editor_persistence_test.dart +++ b/app/test/screens/note_editor_persistence_test.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:nexanote/data/database/schema.dart'; diff --git a/app/test/screens/onboarding_routing_test.dart b/app/test/screens/onboarding_routing_test.dart index 0288bd8..5c6dad5 100644 --- a/app/test/screens/onboarding_routing_test.dart +++ b/app/test/screens/onboarding_routing_test.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:nexanote/data/database/schema.dart'; diff --git a/app/test/screens/settings_overflow_test.dart b/app/test/screens/settings_overflow_test.dart index e3fe49d..b526d31 100644 --- a/app/test/screens/settings_overflow_test.dart +++ b/app/test/screens/settings_overflow_test.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:nexanote/data/database/schema.dart'; diff --git a/app/test/services/app_state_test.dart b/app/test/services/app_state_test.dart index ebe80c7..7d83d10 100644 --- a/app/test/services/app_state_test.dart +++ b/app/test/services/app_state_test.dart @@ -2,7 +2,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:nexanote/data/database/schema.dart'; @@ -12,11 +11,9 @@ import 'package:nexanote/services/local_note_service.dart'; import 'package:nexanote/services/sync_service.dart'; class _StubApi extends api.ApiClient { - _StubApi({bool shouldThrow = false, this.pingResult = true}) - : shouldThrow = shouldThrow, - super(baseUrl: 'http://stub.test'); + _StubApi({this.shouldThrow = false}) : super(baseUrl: 'http://stub.test'); bool shouldThrow; - final bool pingResult; + final bool pingResult = true; int createNoteCalls = 0; @override diff --git a/app/test/services/local_note_service_test.dart b/app/test/services/local_note_service_test.dart index e5e73c3..8154342 100644 --- a/app/test/services/local_note_service_test.dart +++ b/app/test/services/local_note_service_test.dart @@ -1,5 +1,4 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:nexanote/data/database/schema.dart'; diff --git a/app/test/services/offline_persistence_test.dart b/app/test/services/offline_persistence_test.dart index 5854f4f..5f822e1 100644 --- a/app/test/services/offline_persistence_test.dart +++ b/app/test/services/offline_persistence_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:nexanote/data/database/schema.dart'; diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index 5ad5c96..60ff0f1 100644 --- a/app/test/services/sync_service_test.dart +++ b/app/test/services/sync_service_test.dart @@ -1,5 +1,4 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite/sqflite.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:nexanote/data/database/schema.dart'; From fba0a35af79121571f0e4182dcffa5a4419ca4d1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 13:02:48 +0000 Subject: [PATCH 33/44] ci: run flutter analyze and flutter test on pull requests Only the Python backend was checked on pull requests; the Flutter client was analyzed and tested in the APK release job with `|| true`, so a failing test never turned anything red. A separate Flutter app workflow now runs flutter pub get, flutter analyze and flutter test on PRs and pushes to main, on the same stable channel as the release build, and fails on any analyzer finding or test failure. --- .github/workflows/flutter-app.yml | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/flutter-app.yml diff --git a/.github/workflows/flutter-app.yml b/.github/workflows/flutter-app.yml new file mode 100644 index 0000000..8741ace --- /dev/null +++ b/.github/workflows/flutter-app.yml @@ -0,0 +1,48 @@ +# Analyzes and tests the Flutter client (app/) on every pull request to main. +# +# Unlike the APK release job, nothing here is allowed to fail softly: an +# analyzer finding (info, warning or error) or a failing test turns the check +# red. The Python backend is covered by python-app.yml. + +name: Flutter app + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + analyze-and-test: + name: Flutter analyze and tests + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: app + + steps: + - uses: actions/checkout@v4 + + # Same channel as the Android release build, so the PR check runs on + # the toolchain the APK is built with. + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Flutter version + run: flutter --version + + - name: Install dependencies + run: flutter pub get + + - name: Analyze + run: flutter analyze + + - name: Run tests + run: flutter test From c6d8a5a48973a0bbc2b1f19c5bc75120d11e845d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 13:04:58 +0000 Subject: [PATCH 34/44] fix: don't let a claim overwrite text typed into a placeholder A recorded MKCOL placeholder is a normal note in the app until its note.json arrives. If someone wrote in it meanwhile, the claim replaced that text with the payload's. A placeholder with text is no longer claimable (409); strokes are still fine since the claim keeps them. --- nexanote/sync/webdav_provider.py | 9 ++++++++- tests/test_webdav_sync_safety.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/nexanote/sync/webdav_provider.py b/nexanote/sync/webdav_provider.py index e87775a..ec1d25f 100644 --- a/nexanote/sync/webdav_provider.py +++ b/nexanote/sync/webdav_provider.py @@ -674,8 +674,15 @@ def _is_placeholder_for(db: FileNoteStore, note: Note, payload_id: str) -> bool: a PUT into a path that didn't exist yet) for the client note `payload_id`. Being empty and sharing the id prefix is not enough: a real note can be both. + + The placeholder's text must also still be empty: the claim replaces it + with the payload's, so text someone typed into the stand-in (through + the app) would be lost. Strokes are allowed (a page_N.ink PUT may land + before note.json); the claim keeps them. """ - return note.id[:8] == payload_id[:8] and _placeholders(db).contains(note.id) + if note.id[:8] != payload_id[:8] or not _placeholders(db).contains(note.id): + return False + return all(not p.typed_content for p in note.pages) class _PlaceholderRegistry: diff --git a/tests/test_webdav_sync_safety.py b/tests/test_webdav_sync_safety.py index 129a043..871eebb 100644 --- a/tests/test_webdav_sync_safety.py +++ b/tests/test_webdav_sync_safety.py @@ -448,3 +448,20 @@ def test_claiming_with_an_id_that_already_exists_keeps_its_drawing(server): assert note.pages[0].typed_content == "new text" assert len(note.pages[0].strokes) == 1 assert note.notebook_id == _uncategorized(sdb).id + + +def test_placeholder_edited_by_a_user_is_not_overwritten_on_claim(server): + sdb = server["db"] + assert _mkcol(server, "uncategorized/doc__abcd1234").status_code in (200, 201) + placeholder = [n for n in sdb.list_notes() if n.id.startswith("abcd1234")][0] + # Someone opens the stand-in note in the app and writes in it. + note = sdb.get_note(placeholder.id, load_pages=True) + note.pages[0].typed_content = "typed into the placeholder" + note.touch() + sdb.save_note(note) + + resp = _put_meta(server, "uncategorized/doc__abcd1234", CLIENT_ID, text="client text") + + assert resp.status_code == 409 + kept = sdb.get_note(placeholder.id, load_pages=True) + assert kept.pages[0].typed_content == "typed into the placeholder" From 1e95629fc9e47efe08b5df4eb5a2713c76cff9eb Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 13:06:41 +0000 Subject: [PATCH 35/44] fix: never let a closed editor's ink retry overwrite a newer drawing Failed ink saves keep retrying after the editor is closed. Reopening the note started from the stored copy (without the unsaved stroke), and the old editor's retry could then land after the new editor's save, putting an older drawing back. AppState now tracks the newest unsaved drawing per note: a reopened editor shows it and takes over saving it, and a save whose snapshot has been superseded is skipped. --- app/lib/screens/note_editor_screen.dart | 15 +++++++- app/lib/services/app_state.dart | 34 +++++++++++++++++ .../screens/note_editor_ink_save_test.dart | 38 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/app/lib/screens/note_editor_screen.dart b/app/lib/screens/note_editor_screen.dart index 6804632..298dc76 100644 --- a/app/lib/screens/note_editor_screen.dart +++ b/app/lib/screens/note_editor_screen.dart @@ -38,6 +38,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> /// failed save so the same snapshot can be retried; only a newer drawing /// replaces it. List<Map<String, dynamic>>? _pendingInk; + int? _pendingInkTicket; /// The single ink writer while it runs; see [_flushInk]. Future<void>? _inkWriter; @@ -103,8 +104,18 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> } catch (_) { _appState = null; } + // A drawing of this note that an earlier editor couldn't save yet is + // newer than the stored copy we were opened with: show it and take + // over saving it. + final unsaved = _appState?.unsavedInk(_note.id); + if (!_adoptedUnsavedInk && unsaved != null) { + _adoptedUnsavedInk = true; + _saveInk(unsaved); + } } + bool _adoptedUnsavedInk = false; + void _onChanged() { _editGeneration++; if (!_hasChanges) setState(() => _hasChanges = true); @@ -194,6 +205,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> // mode toggle) never drops what the user just drew. _inkStrokes = strokes; _pendingInk = strokes; + _pendingInkTicket = _appState?.stageInk(_note.id, strokes); _inkRetries = 0; return _flushInk(); } @@ -213,9 +225,10 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> if (state == null) return; while (_pendingInk != null) { final next = _pendingInk!; + final ticket = _pendingInkTicket; _pendingInk = null; try { - await state.savePageInk(_note.id, 1, next); + await state.savePageInk(_note.id, 1, next, ticket: ticket); _inkSaveFailed = false; _inkRetries = 0; } catch (e) { diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index 4861f8e..ce4cdf1 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -63,6 +63,9 @@ class AppState extends ChangeNotifier { int _notesRequest = 0; int _openRequest = 0; Future<void>? _localUpload; + final _stagedInk = + <String, ({int ticket, List<Map<String, dynamic>> strokes})>{}; + int _inkTickets = 0; bool _isSyncing = false; String? _syncMessage; String? _syncError; @@ -653,7 +656,38 @@ class AppState extends ChangeNotifier { /// /// [strokes] is the editor's wire shape (`{id,color,width,tool,points:[…]}`), /// the same payload the backend already accepts. + /// + /// With a [ticket] from [stageInk], the save is skipped when a newer + /// drawing of the same note has been staged since: retries of an older + /// snapshot (for instance from an editor that was closed) can never land + /// after a newer one. Future<void> savePageInk( + String noteId, + int pageNum, + List<Map<String, dynamic>> strokes, { + int? ticket, + }) async { + if (ticket != null && _stagedInk[noteId]?.ticket != ticket) return; + await _writePageInk(noteId, pageNum, strokes); + if (ticket != null && _stagedInk[noteId]?.ticket == ticket) { + _stagedInk.remove(noteId); + } + } + + /// Records [strokes] as the newest drawing of [noteId] until it is saved, + /// and returns the ticket to pass to [savePageInk]. + int stageInk(String noteId, List<Map<String, dynamic>> strokes) { + final ticket = ++_inkTickets; + _stagedInk[noteId] = (ticket: ticket, strokes: strokes); + return ticket; + } + + /// The newest drawing of [noteId] that has not been saved yet, if any. A + /// reopened editor starts from it rather than from the older stored copy. + List<Map<String, dynamic>>? unsavedInk(String noteId) => + _stagedInk[noteId]?.strokes; + + Future<void> _writePageInk( String noteId, int pageNum, List<Map<String, dynamic>> strokes, diff --git a/app/test/screens/note_editor_ink_save_test.dart b/app/test/screens/note_editor_ink_save_test.dart index a481e1f..f3dcc5d 100644 --- a/app/test/screens/note_editor_ink_save_test.dart +++ b/app/test/screens/note_editor_ink_save_test.dart @@ -226,4 +226,42 @@ void main() { expect(response, AppExitResponse.exit); }); + + testWidgets('reopening a note shows and takes over its unsaved drawing', + (tester) async { + await pumpEditor(tester); + backend.down = true; + await drawStroke(tester); + expect(backend.persisted, isEmpty); + + // Leave and reopen the note: the server copy has no strokes yet. + await tester.pumpWidget(const SizedBox()); + await pumpEditor(tester); + final canvas = tester.widget<InkCanvas>(find.byType(InkCanvas)); + expect(canvas.initialStrokes, hasLength(1)); + + backend.down = false; + await tester.pump(const Duration(minutes: 3)); + expect(backend.persisted, isNotEmpty); + expect(backend.persisted.last, 1); + }); + + testWidgets('a closed editor\'s retry never overwrites a newer drawing', + (tester) async { + await pumpEditor(tester); + backend.down = true; + await drawStroke(tester); // 1 stroke, save failed, retry scheduled + + await tester.pumpWidget(const SizedBox()); + await pumpEditor(tester); // reopened: starts from the unsaved stroke + backend.down = false; + await drawStroke(tester); // 2 strokes, saved + expect(backend.persisted.last, 2); + + // The first editor's retry timers are still around; none of them may + // put the 1-stroke drawing back. + await tester.pump(const Duration(minutes: 5)); + expect(backend.persisted.last, 2); + expect(backend.persisted.where((n) => n < 2), isEmpty); + }); } From eb8c303b5946871f31afc8ceb41ad62474e42df9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 13:17:59 +0000 Subject: [PATCH 36/44] fix: keep app-made drawings and file names when a placeholder is claimed Follow-up to the placeholder registry, from a second review pass: - A drawing made in a recorded placeholder through the app was lost: the claim no longer required an empty placeholder, and the client's page ink PUT right after it replaced the strokes. The registry now records the strokes the client itself sent into each placeholder, and a placeholder holding any other stroke (or text) can't be claimed. - A page ink PUT addressed to another note (the client sends note_id) overwrote whatever note the path resolved to through the slug/prefix fallbacks, even after note.json had been refused. It now gets a 409, except for the client's own claimable placeholder. - Ink the client sent into the placeholder before note.json was dropped when the client's note already existed; it now replaces that page's strokes on the existing note, as the same PUT would have. - In the plain store a claimed note ended up as "Doc (2).md" because the placeholder's file was deleted after the save. Since the placeholder only holds what the client sent, it is deleted first again. --- nexanote/sync/webdav_provider.py | 143 +++++++++++++++++++++++-------- tests/test_webdav_sync_safety.py | 86 +++++++++++++++++++ 2 files changed, 191 insertions(+), 38 deletions(-) diff --git a/nexanote/sync/webdav_provider.py b/nexanote/sync/webdav_provider.py index ec1d25f..815b565 100644 --- a/nexanote/sync/webdav_provider.py +++ b/nexanote/sync/webdav_provider.py @@ -605,14 +605,38 @@ def close(self) -> None: HTTP_CONFLICT, context_info="note.json id does not match the note at this path", ) - placeholder_id = self.note.id + placeholder = self.note + placeholder_id = placeholder.id + # The placeholder holds nothing but what this client sent + # (checked above) and its content is carried over in memory, + # so its files go first: in the plain store the claimed note + # then gets the placeholder's file name, not "Doc (2).md". + try: + self.db.delete_note_permanent(placeholder_id) + except Exception: + logger.warning( + "could not delete placeholder note %s — ignoring", + placeholder_id, + ) existing = self.db.get_note(payload_id, load_pages=True) if existing is not None: # The client's note already exists here (e.g. it moved to # this notebook): update it, keeping its pages and # drawings, instead of overwriting it with the - # placeholder's empty ones. - existing.notebook_id = self.note.notebook_id + # placeholder's empty ones. A page this client already + # sent ink for (into the placeholder) takes that ink, as + # the same PUT would have done on the real note. + existing.notebook_id = placeholder.notebook_id + for page in placeholder.pages: + if not page.strokes: + continue + target = existing.get_page(page.page_number) + if target is None: + page.note_id = payload_id + existing.pages.append(page) + else: + target.strokes = page.strokes + existing.pages.sort(key=lambda p: p.page_number) self.note = existing else: self.note.id = payload_id @@ -651,14 +675,6 @@ def close(self) -> None: self.note.touch() self.db.save_note(self.note) if placeholder_id is not None: - # Only now that the note is saved under its real id. - try: - self.db.delete_note_permanent(placeholder_id) - except Exception: - logger.warning( - "could not delete placeholder note %s — ignoring", - placeholder_id, - ) _placeholders(self.db).remove(placeholder_id) logger.info("Note mise à jour via WebDAV PUT : %s", self.note.title) except DAVError: @@ -675,23 +691,31 @@ def _is_placeholder_for(db: FileNoteStore, note: Note, payload_id: str) -> bool: `payload_id`. Being empty and sharing the id prefix is not enough: a real note can be both. - The placeholder's text must also still be empty: the claim replaces it - with the payload's, so text someone typed into the stand-in (through - the app) would be lost. Strokes are allowed (a page_N.ink PUT may land - before note.json); the claim keeps them. + The placeholder must also hold nothing but what the client itself sent: + no text (the claim replaces it with the payload's) and no strokes other + than the ones received through WebDAV for it (a page_N.ink PUT can land + before note.json). Text or a drawing someone made in the stand-in + through the app would otherwise be overwritten by the client's push. """ - if note.id[:8] != payload_id[:8] or not _placeholders(db).contains(note.id): + if note.id[:8] != payload_id[:8]: + return False + client_strokes = _placeholders(db).client_strokes(note.id) + if client_strokes is None: return False - return all(not p.typed_content for p in note.pages) + return all( + not p.typed_content and all(s.id in client_strokes for s in p.strokes) + for p in note.pages + ) class _PlaceholderRegistry: """ - EN: Ids of the stand-in notes created ahead of a client's note.json, - persisted in the data directory so a server restart between MKCOL - and PUT doesn't turn the placeholder into an unclaimable note. - FR: Ids des notes provisoires créées avant le note.json du client, - persistés pour survivre à un redémarrage. + EN: Stand-in notes created ahead of a client's note.json, with the ids of + the strokes the client has PUT into each one, persisted in the data + directory so a server restart between MKCOL and PUT doesn't turn the + placeholder into an unclaimable note. + FR: Notes provisoires créées avant le note.json du client (et traits reçus + par WebDAV pour chacune), persistées pour survivre à un redémarrage. """ FILE_NAME = ".webdav_placeholders.json" @@ -700,37 +724,55 @@ class _PlaceholderRegistry: def __init__(self, data_dir: Path) -> None: self.path = Path(data_dir) / self.FILE_NAME - def _read(self) -> set[str]: + def _read(self) -> dict[str, list[str]]: try: data = json.loads(self.path.read_text(encoding="utf-8")) except FileNotFoundError: - return set() + return {} except (OSError, ValueError) as exc: # Unreadable registry: claim nothing. A placeholder then gets a # 409 instead of a real note possibly being replaced. logger.warning("unreadable placeholder registry %s: %s", self.path, exc) - return set() - return {i for i in data if isinstance(i, str)} if isinstance(data, list) else set() - - def _write(self, ids: set[str]) -> None: - _atomic_write(self.path, json.dumps(sorted(ids)).encode("utf-8")) + return {} + if not isinstance(data, dict): + return {} + return { + k: [i for i in v if isinstance(i, str)] + for k, v in data.items() + if isinstance(k, str) and isinstance(v, list) + } + + def _write(self, entries: dict[str, list[str]]) -> None: + _atomic_write(self.path, json.dumps(entries, sort_keys=True).encode("utf-8")) def add(self, note_id: str) -> None: with self._lock: - ids = self._read() - ids.add(note_id) - self._write(ids) + entries = self._read() + entries.setdefault(note_id, []) + self._write(entries) + + def record_strokes(self, note_id: str, stroke_ids: list[str]) -> None: + """Remembers strokes the client sent through WebDAV, if [note_id] is + a placeholder; a no-op for any other note.""" + with self._lock: + entries = self._read() + if note_id not in entries: + return + entries[note_id] = sorted(set(entries[note_id]) | set(stroke_ids)) + self._write(entries) def remove(self, note_id: str) -> None: with self._lock: - ids = self._read() - if note_id in ids: - ids.discard(note_id) - self._write(ids) + entries = self._read() + if entries.pop(note_id, None) is not None: + self._write(entries) - def contains(self, note_id: str) -> bool: + def client_strokes(self, note_id: str) -> Optional[set[str]]: + """Strokes the client sent into placeholder [note_id], or None if it + is not a recorded placeholder.""" with self._lock: - return note_id in self._read() + entries = self._read() + return set(entries[note_id]) if note_id in entries else None def _placeholders(db: FileNoteStore) -> _PlaceholderRegistry: @@ -877,6 +919,25 @@ def close(self) -> None: context_info=f"invalid ink page body: {exc}", ) from exc + # EN: Like note.json, an ink page addressed to another note (the + # NexaNote client sends its note_id) must not overwrite the note + # this path resolved to through the slug/prefix fallbacks. The + # one exception is the client's own claimable placeholder (ink + # that lands before note.json). + # FR: Un .ink destiné à une autre note est refusé (409), sauf pour le + # placeholder réclamable de ce client. + payload_note_id = payload.get("note_id") + if ( + isinstance(payload_note_id, str) + and payload_note_id + and payload_note_id != self.note.id + and not _is_placeholder_for(self.db, self.note, payload_note_id) + ): + raise DAVError( + HTTP_CONFLICT, + context_info="page ink note_id does not match the note at this path", + ) + try: new_strokes: list[InkStroke] = [] for s_data in payload.get("strokes") or []: @@ -909,6 +970,12 @@ def close(self) -> None: self.note.touch() self.db.save_page(self.page) self.db.save_note(self.note, save_pages=False) + # Strokes the client sends into its own placeholder don't stop + # the placeholder from being claimed; strokes drawn in it through + # the app do (see _is_placeholder_for). + _placeholders(self.db).record_strokes( + self.note.id, [s.id for s in new_strokes] + ) logger.info( "Page %d mise à jour : %d strokes", self.page.page_number, diff --git a/tests/test_webdav_sync_safety.py b/tests/test_webdav_sync_safety.py index 871eebb..287a1b3 100644 --- a/tests/test_webdav_sync_safety.py +++ b/tests/test_webdav_sync_safety.py @@ -465,3 +465,89 @@ def test_placeholder_edited_by_a_user_is_not_overwritten_on_claim(server): assert resp.status_code == 409 kept = sdb.get_note(placeholder.id, load_pages=True) assert kept.pages[0].typed_content == "typed into the placeholder" + + +def test_drawing_made_in_a_placeholder_through_the_app_is_not_claimed(server): + sdb = server["db"] + assert _mkcol(server, "uncategorized/doc__abcd1234").status_code in (200, 201) + placeholder = [n for n in sdb.list_notes() if n.id.startswith("abcd1234")][0] + # The app user draws in the stand-in note (REST ink save). + note = sdb.get_note(placeholder.id, load_pages=True) + note.pages[0].strokes = [InkStroke(id="user-stroke", points=[Point(1, 1), Point(2, 2)])] + sdb.save_note(note) + + resp = _put_meta(server, "uncategorized/doc__abcd1234", CLIENT_ID) + # The engine follows note.json with the page ink even when it failed. + ink = requests.put(server["url"] + "uncategorized/doc__abcd1234/page_1.ink", + json={"note_id": CLIENT_ID, "strokes": []}, + auth=AUTH, timeout=5) + + assert resp.status_code == 409 + assert ink.status_code == 409 + kept = sdb.get_note(placeholder.id, load_pages=True) + assert [s.id for s in kept.pages[0].strokes] == ["user-stroke"] + + +def test_drawing_put_before_note_json_is_kept_when_the_note_exists(server): + sdb = server["db"] + other_nb = Notebook(name="Elsewhere") + sdb.save_notebook(other_nb) + existing = Note(id=CLIENT_ID, title="Doc", notebook_id=other_nb.id) + existing.add_page() + sdb.save_note(existing) + assert _mkcol(server, "uncategorized/doc__abcd1234").status_code in (200, 201) + ink = {"note_id": CLIENT_ID, + "strokes": [{"id": "s1", "points": [{"x": 1, "y": 1}, {"x": 2, "y": 2}]}]} + assert requests.put(server["url"] + "uncategorized/doc__abcd1234/page_1.ink", + json=ink, auth=AUTH, timeout=5).status_code < 300 + + resp = _put_meta(server, "uncategorized/doc__abcd1234", CLIENT_ID) + + assert resp.status_code < 300 + note = sdb.get_note(CLIENT_ID, load_pages=True) + assert [s.id for s in note.pages[0].strokes] == ["s1"] + + +@pytest.fixture +def plain_server(tmp_path): + from nexanote.storage import PlainMarkdownNoteStore + + db = PlainMarkdownNoteStore(tmp_path / "plain_store") + ensure_storage_layout(db) + ensure_default_notebook(db) + app = build_app(db, username="user", password="pass", verbose=False) + port = _free_port() + httpd = cheroot_wsgi.Server(bind_addr=("127.0.0.1", port), wsgi_app=app, numthreads=4) + threading.Thread(target=httpd.start, daemon=True).start() + url = f"http://127.0.0.1:{port}/" + for _ in range(100): + try: + requests.options(url, timeout=1) + break + except requests.RequestException: + time.sleep(0.05) + yield {"url": url, "db": db} + httpd.stop() + + +def test_claimed_note_keeps_its_plain_file_name(plain_server): + assert _mkcol(plain_server, "uncategorized/doc__abcd1234").status_code in (200, 201) + + resp = _put_meta(plain_server, "uncategorized/doc__abcd1234", CLIENT_ID) + + assert resp.status_code < 300 + files = sorted(p.name for p in plain_server["db"].notes_dir.iterdir()) + assert files == ["Doc.json", "Doc.md"] + + +def test_ink_for_another_note_does_not_overwrite_the_note_at_that_path(server): + sdb = server["db"] + real = Note(id=REAL_ID, title="Sketch", notebook_id=_uncategorized(sdb).id) + real.add_page().strokes.append(InkStroke(id="mine", points=[Point(1, 1), Point(2, 2)])) + sdb.save_note(real) + + resp = requests.put(server["url"] + "uncategorized/sketch__abcd1234/page_1.ink", + json={"note_id": CLIENT_ID, "strokes": []}, auth=AUTH, timeout=5) + + assert resp.status_code == 409 + assert [s.id for s in sdb.get_note(REAL_ID, load_pages=True).pages[0].strokes] == ["mine"] From b4310cf47b8704f4c9ea608123c2cf522291f9f6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 13:19:11 +0000 Subject: [PATCH 37/44] fix: resume into the offline copy instead of creating another one When the remote note had changed, pushLocal created an "(offline copy)" note but never recorded it. If the copy's content upload failed, every later push created another empty copy. The copy now becomes the row's remote note (with its own baseline) before the upload, so a retry resumes into it under the same rules. --- app/lib/services/sync_service.dart | 4 ++++ app/test/services/sync_service_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 8d2d4c1..ada8625 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -122,6 +122,10 @@ class SyncService { noteType: note.noteType, notebookId: server.notebookId, ); + // From now on the copy is this row's remote note: if its upload + // fails too, the next push resumes into it (same proof rules) + // instead of leaving it empty and creating another one. + await _local.setNoteRemoteId(note.id, copy.id, copy.updatedAt); await _uploadContent(note, copy.id); await _local.markNoteSyncedIfUnchanged(note.id, note.updatedAt); notes++; diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index 60ff0f1..f7f0d99 100644 --- a/app/test/services/sync_service_test.dart +++ b/app/test/services/sync_service_test.dart @@ -362,6 +362,28 @@ void main() { expect(fakeApi.savedInk['remote-note-2'], hasLength(1)); }); + test('a failed copy upload is resumed into the same copy, not repeated', + () async { + final remoteId = await interruptedUpload(); + fakeApi.editOnServer(remoteId, text: 'edited online'); + + // The copy gets created but its content upload keeps failing. + fakeApi.failText = true; + for (var i = 0; i < 3; i++) { + await expectLater(sync.pushLocal(), throwsA(isA<api.ApiException>())); + } + fakeApi.failText = false; + await sync.pushLocal(); + await sync.pushLocal(); + + final copies = fakeApi.createdNotes + .where((n) => n['title'] == 'Offline (offline copy)') + .toList(); + expect(copies, hasLength(1)); + expect(fakeApi.savedText['remote-note-2'], 'written offline'); + expect(fakeApi.savedText[remoteId], 'edited online'); + }); + test('never overwrites what was written in that note online since', () async { final remoteId = await interruptedUpload(); From 7c18666a6e4be0eee7303c2824fa7345dc7b7993 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 14:02:16 +0000 Subject: [PATCH 38/44] feat: return the note's updated_at from page text and ink saves PUT /notes/{id}/pages/{n}/text and /ink now include note_updated_at, the note's version right after the write (taken under the per-note lock). The app uses it to tell its own partial uploads apart from someone else's edits when it resumes an interrupted upload. Older clients ignore the extra field. --- nexanote/api/routes.py | 10 +++++++--- tests/test_api.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/nexanote/api/routes.py b/nexanote/api/routes.py index 874ed20..41827a4 100644 --- a/nexanote/api/routes.py +++ b/nexanote/api/routes.py @@ -89,6 +89,9 @@ class PageSchema(BaseModel): typed_content: str = "" strokes: list[StrokeSchema] = Field(default_factory=list) updated_at: Optional[str] = None + # The note's updated_at right after a page save, so a client resuming an + # interrupted upload can tell its own writes from someone else's. + note_updated_at: Optional[str] = None class NoteCreateSchema(BaseModel): @@ -241,8 +244,9 @@ def _note_to_schema(note: Note, include_pages: bool = False) -> NoteSchema: ) -def _page_to_schema(page: Page) -> PageSchema: +def _page_to_schema(page: Page, note: Optional[Note] = None) -> PageSchema: return PageSchema( + note_updated_at=note.updated_at.isoformat() if note else None, page_number=page.page_number, template=page.template, width_px=page.width_px, @@ -562,7 +566,7 @@ def update_ink(note_id: str, page_num: int, data: InkUpdateSchema): note.touch() db.save_page(page) db.save_note(note, save_pages=False) - return _page_to_schema(page) + return _page_to_schema(page, note) @app.put("/notes/{note_id}/pages/{page_num}/text", response_model=PageSchema) @_locked_by_note @@ -580,7 +584,7 @@ def update_text(note_id: str, page_num: int, data: TextUpdateSchema): note.touch() db.save_page(page) db.save_note(note, save_pages=False) - return _page_to_schema(page) + return _page_to_schema(page, note) # ------------------------------------------------------------------ # Sync diff --git a/tests/test_api.py b/tests/test_api.py index d1493a9..f3745f8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -330,3 +330,15 @@ def test_unknown_conflict_strategy_is_rejected_and_not_saved(self, client): "server_url": "http://nas.local/", "conflict_strategy": "keep_both", }) assert ok.status_code == 200 + + +class TestPageSaveReportsNoteVersion: + def test_text_and_ink_saves_return_the_notes_new_updated_at(self, client): + nid = client.post("/notes", json={"title": "x"}).json()["id"] + + text = client.put(f"/notes/{nid}/pages/1/text", json={"typed_content": "a"}).json() + assert text["note_updated_at"] == client.get(f"/notes/{nid}").json()["updated_at"] + + ink = client.put(f"/notes/{nid}/pages/1/ink", json={"strokes": []}).json() + assert ink["note_updated_at"] == client.get(f"/notes/{nid}").json()["updated_at"] + assert ink["note_updated_at"] != text["note_updated_at"] From a8334d82fc008f74f51fd48daa4e0e085590ed0f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 14:02:16 +0000 Subject: [PATCH 39/44] fix: make the WebDAV placeholder claim atomic and fingerprint-checked The note.json claim deleted the placeholder before the new note was saved, and parsed the payload while already mutating it. An invalid page entry or a storage error halfway through lost the placeholder and any ink the client had already sent into it. The body is now validated first (400 on bad types or page numbers), the claimed note is built on a copy, saved, and only then is the placeholder deleted and removed from the registry. A failed delete keeps the registry entry so the placeholder stays claimable. The plain store re-saves the claimed note afterwards so it gets the placeholder's file name back. The registry now stores a fingerprint of each placeholder's state (title, type, tags, flags, notebook, pages, strokes) instead of only checking text and strokes. Any edit made to a placeholder through the app makes it unclaimable (409). Ink the client sends into its own untouched placeholder updates the fingerprint, so that flow keeps working. --- nexanote/sync/webdav_provider.py | 367 ++++++++++++++++++------------- tests/test_webdav_sync_safety.py | 131 +++++++++++ 2 files changed, 349 insertions(+), 149 deletions(-) diff --git a/nexanote/sync/webdav_provider.py b/nexanote/sync/webdav_provider.py index 815b565..d10ca85 100644 --- a/nexanote/sync/webdav_provider.py +++ b/nexanote/sync/webdav_provider.py @@ -16,6 +16,7 @@ from __future__ import annotations +import copy import hashlib import io import json @@ -304,8 +305,8 @@ def create_collection(self, name: str) -> "NoteCollection": title=title, ) note.add_page() - _placeholders(self.db).add(note.id) self.db.save_note(note) + _placeholders(self.db).record(self.db.get_note(note.id, load_pages=True)) except Exception as exc: logger.exception("MKCOL note failed: %s", name) raise _safe_dav_error(exc, "could not create note") from exc @@ -580,142 +581,220 @@ def close(self) -> None: context_info=f"invalid note.json body: {exc}", ) from exc - try: - # If MKCOL minted a placeholder id that the PUT body now wants to - # replace (e.g. client and server both generated UUIDs sharing - # only the slug's 8-char prefix), drop the placeholder so the - # client's full id wins. Without this the on-disk file id would - # diverge from the client's id and break future updates. - placeholder_id: Optional[str] = None - payload_id = payload.get("id") - if ( - isinstance(payload_id, str) - and payload_id - and payload_id != self.note.id - ): - # EN: The note behind this path can also be a real note found - # by the title-slug or id-prefix fallbacks, and a real - # note can be empty and share the 8-char prefix. Only a - # note this server recorded as an MKCOL placeholder may be - # replaced; anything else gets a 409. - # FR: Seul un placeholder enregistré lors du MKCOL peut être - # remplacé ; une vraie note (même vide) donne un 409. - if not _is_placeholder_for(self.db, self.note, payload_id): - raise DAVError( - HTTP_CONFLICT, - context_info="note.json id does not match the note at this path", - ) - placeholder = self.note - placeholder_id = placeholder.id - # The placeholder holds nothing but what this client sent - # (checked above) and its content is carried over in memory, - # so its files go first: in the plain store the claimed note - # then gets the placeholder's file name, not "Doc (2).md". - try: - self.db.delete_note_permanent(placeholder_id) - except Exception: - logger.warning( - "could not delete placeholder note %s — ignoring", - placeholder_id, - ) - existing = self.db.get_note(payload_id, load_pages=True) - if existing is not None: - # The client's note already exists here (e.g. it moved to - # this notebook): update it, keeping its pages and - # drawings, instead of overwriting it with the - # placeholder's empty ones. A page this client already - # sent ink for (into the placeholder) takes that ink, as - # the same PUT would have done on the real note. - existing.notebook_id = placeholder.notebook_id - for page in placeholder.pages: - if not page.strokes: - continue - target = existing.get_page(page.page_number) - if target is None: - page.note_id = payload_id - existing.pages.append(page) - else: - target.strokes = page.strokes - existing.pages.sort(key=lambda p: p.page_number) - self.note = existing - else: - self.note.id = payload_id - for page in self.note.pages: + # Phase 1: validate the whole body and build the resulting note in + # memory. Nothing on disk changes until it is complete, so a bad + # payload can't leave a half-claimed placeholder behind. + fields = _parse_note_payload(payload) + placeholder_id: Optional[str] = None + payload_id = fields["id"] + target = copy.deepcopy(self.note) + if payload_id and payload_id != self.note.id: + # EN: The note behind this path can also be a real note found by + # the title-slug or id-prefix fallbacks, and a real note can be + # empty and share the 8-char prefix. Only a recorded, + # untouched MKCOL placeholder may be replaced; anything else + # gets a 409. + # FR: Seul un placeholder enregistré et intact peut être remplacé. + if not _is_placeholder_for(self.db, self.note, payload_id): + raise DAVError( + HTTP_CONFLICT, + context_info="note.json id does not match the note at this path", + ) + placeholder_id = self.note.id + existing = self.db.get_note(payload_id, load_pages=True) + if existing is not None: + # The client's note already exists here (e.g. it moved to this + # notebook): update it, keeping its pages and drawings. A page + # this client already sent ink for (into the placeholder) + # takes that ink, as the same PUT on the real note would have. + existing.notebook_id = self.note.notebook_id + for page in copy.deepcopy(self.note.pages): + if not page.strokes: + continue + current = existing.get_page(page.page_number) + if current is None: page.note_id = payload_id - - self.note.title = payload.get("title", self.note.title) - self.note.tags = payload.get("tags", self.note.tags) or [] - self.note.is_pinned = bool( - payload.get("is_pinned", self.note.is_pinned) - ) - note_type = payload.get("type") - if note_type: - try: - self.note.note_type = NoteType(note_type) - except ValueError: - logger.warning("ignoring unknown note type: %s", note_type) - for page_data in payload.get("pages") or []: - page_number = page_data.get("page_number") - if page_number is None: - continue - page = self.note.get_page(page_number) - if page is None: - page = Page( - note_id=self.note.id, - page_number=int(page_number), - template=page_data.get("template", "blank"), - ) - self.note.pages.append(page) - page.typed_content = page_data.get( - "typed_content", page.typed_content + existing.pages.append(page) + else: + current.strokes = page.strokes + target = existing + else: + target.id = payload_id + for page in target.pages: + page.note_id = payload_id + + if fields["title"] is not None: + target.title = fields["title"] + if fields["tags"] is not None: + target.tags = fields["tags"] + if fields["is_pinned"] is not None: + target.is_pinned = fields["is_pinned"] + if fields["note_type"] is not None: + target.note_type = fields["note_type"] + for page_data in fields["pages"]: + page = target.get_page(page_data["page_number"]) + if page is None: + page = Page( + note_id=target.id, + page_number=page_data["page_number"], + template=page_data["template"] or "blank", ) - _apply_client_timestamp(page, page_data.get("updated_at")) - self.note.pages.sort(key=lambda p: p.page_number) - if not _apply_client_timestamp(self.note, payload.get("updated_at")): - self.note.touch() - self.db.save_note(self.note) - if placeholder_id is not None: - _placeholders(self.db).remove(placeholder_id) - logger.info("Note mise à jour via WebDAV PUT : %s", self.note.title) - except DAVError: - raise + target.pages.append(page) + if page_data["typed_content"] is not None: + page.typed_content = page_data["typed_content"] + _apply_client_timestamp(page, page_data["updated_at"]) + target.pages.sort(key=lambda p: p.page_number) + if not _apply_client_timestamp(target, fields["updated_at"]): + target.touch() + + # Phase 2: commit, then clean up. The placeholder (and its registry + # entry) is only removed once the claimed note is safely saved. + try: + self.db.save_note(target) except Exception as exc: logger.exception("note.json write failed") raise _safe_dav_error(exc, "saving note failed") from exc + self.note = target + if placeholder_id is not None: + self._drop_placeholder(placeholder_id) + logger.info("Note mise à jour via WebDAV PUT : %s", target.title) + + def _drop_placeholder(self, placeholder_id: str) -> None: + try: + self.db.delete_note_permanent(placeholder_id) + except Exception: + # Keep it registered: still claimable later, not an orphan. + logger.warning("could not delete placeholder note %s", placeholder_id) + return + _placeholders(self.db).remove(placeholder_id) + # In the plain store the claimed note was saved while the placeholder + # still owned "<title>.md"; saving again takes that name back instead + # of leaving "<title> (2).md". + try: + self.db.save_note(self.note) + except Exception: + logger.warning("could not re-save claimed note %s", self.note.id) + + +def _parse_note_payload(payload) -> dict: + """ + Validates a note.json body and returns its fields normalised (None for + absent ones). Raises a 400 before anything is written when the body is + malformed: wrong types, a page_number that isn't a positive integer... + """ + + def bad(reason: str) -> DAVError: + return DAVError(HTTP_BAD_REQUEST, context_info=f"invalid note.json: {reason}") + + if not isinstance(payload, dict): + raise bad("body is not an object") + + def optional(key, kind): + value = payload.get(key) + if value is not None and not isinstance(value, kind): + raise bad(f"{key} has the wrong type") + return value + + note_id = optional("id", str) + title = optional("title", str) + tags = payload.get("tags") + if tags is not None and not ( + isinstance(tags, list) and all(isinstance(t, str) for t in tags) + ): + raise bad("tags must be a list of strings") + is_pinned = payload.get("is_pinned") + note_type = None + raw_type = optional("type", str) + if raw_type: + try: + note_type = NoteType(raw_type) + except ValueError: + logger.warning("ignoring unknown note type: %s", raw_type) + + raw_pages = payload.get("pages") + if raw_pages is None: + raw_pages = [] + if not isinstance(raw_pages, list): + raise bad("pages must be a list") + pages = [] + for raw in raw_pages: + if not isinstance(raw, dict): + raise bad("each page must be an object") + number = raw.get("page_number") + if isinstance(number, str) and number.isdigit(): + number = int(number) + if isinstance(number, bool) or not isinstance(number, int) or number < 1: + raise bad("page_number must be a positive integer") + typed = raw.get("typed_content") + if typed is not None and not isinstance(typed, str): + raise bad("typed_content must be a string") + template = raw.get("template") + if template is not None and not isinstance(template, str): + raise bad("template must be a string") + pages.append({ + "page_number": number, + "typed_content": typed, + "template": template, + "updated_at": raw.get("updated_at"), + }) + + return { + "id": note_id or None, + "title": title, + "tags": tags, + "is_pinned": bool(is_pinned) if is_pinned is not None else None, + "note_type": note_type, + "pages": pages, + "updated_at": payload.get("updated_at"), + } def _is_placeholder_for(db: FileNoteStore, note: Note, payload_id: str) -> bool: """ - True only for a note this server created itself as a stand-in (MKCOL, or - a PUT into a path that didn't exist yet) for the client note - `payload_id`. Being empty and sharing the id prefix is not enough: a real - note can be both. - - The placeholder must also hold nothing but what the client itself sent: - no text (the claim replaces it with the payload's) and no strokes other - than the ones received through WebDAV for it (a page_N.ink PUT can land - before note.json). Text or a drawing someone made in the stand-in - through the app would otherwise be overwritten by the client's push. + True only for a stand-in note this server created itself (MKCOL, or a + PUT into a path that didn't exist yet) for the client note `payload_id`, + and that nobody has changed since. Being empty and sharing the id prefix + is not enough: a real note can be both. + + "Unchanged" is checked against the fingerprint recorded with the + placeholder (title, type, tags, flags, notebook, pages, strokes), so any + edit made to it through the app, whatever the field, makes it a real note + that a client's push must not replace. Ink the client itself sends into + the placeholder updates the recorded fingerprint. """ if note.id[:8] != payload_id[:8]: return False - client_strokes = _placeholders(db).client_strokes(note.id) - if client_strokes is None: - return False - return all( - not p.typed_content and all(s.id in client_strokes for s in p.strokes) - for p in note.pages - ) + return _placeholders(db).matches(note) + + +def _note_fingerprint(note: Note) -> str: + """Digest of everything a user can change on a note (not timestamps).""" + state = { + "title": note.title, + "type": note.note_type.value, + "tags": list(note.tags), + "pinned": note.is_pinned, + "archived": note.is_archived, + "deleted": note.is_deleted, + "notebook": note.notebook_id, + "pages": [ + [p.page_number, p.template, p.typed_content, + [[st.id, len(st.points)] for st in p.strokes]] + for p in sorted(note.pages, key=lambda p: p.page_number) + ], + } + raw = json.dumps(state, sort_keys=True, ensure_ascii=False).encode("utf-8") + return hashlib.sha256(raw).hexdigest() class _PlaceholderRegistry: """ - EN: Stand-in notes created ahead of a client's note.json, with the ids of - the strokes the client has PUT into each one, persisted in the data - directory so a server restart between MKCOL and PUT doesn't turn the - placeholder into an unclaimable note. - FR: Notes provisoires créées avant le note.json du client (et traits reçus - par WebDAV pour chacune), persistées pour survivre à un redémarrage. + EN: Stand-in notes created ahead of a client's note.json, each with the + fingerprint of its state as this server last wrote it. Persisted in + the data directory so a server restart between MKCOL and PUT doesn't + turn the placeholder into an unclaimable note. + FR: Notes provisoires créées avant le note.json du client, avec + l'empreinte de leur état, persistées pour survivre à un redémarrage. """ FILE_NAME = ".webdav_placeholders.json" @@ -724,7 +803,7 @@ class _PlaceholderRegistry: def __init__(self, data_dir: Path) -> None: self.path = Path(data_dir) / self.FILE_NAME - def _read(self) -> dict[str, list[str]]: + def _read(self) -> dict[str, str]: try: data = json.loads(self.path.read_text(encoding="utf-8")) except FileNotFoundError: @@ -736,30 +815,25 @@ def _read(self) -> dict[str, list[str]]: return {} if not isinstance(data, dict): return {} - return { - k: [i for i in v if isinstance(i, str)] - for k, v in data.items() - if isinstance(k, str) and isinstance(v, list) - } + # Entries without a fingerprint (older formats) can't be verified and + # are not claimable. + return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)} - def _write(self, entries: dict[str, list[str]]) -> None: + def _write(self, entries: dict[str, str]) -> None: _atomic_write(self.path, json.dumps(entries, sort_keys=True).encode("utf-8")) - def add(self, note_id: str) -> None: + def record(self, note: Note) -> None: + """Registers [note] as a placeholder in its current state.""" with self._lock: entries = self._read() - entries.setdefault(note_id, []) + entries[note.id] = _note_fingerprint(note) self._write(entries) - def record_strokes(self, note_id: str, stroke_ids: list[str]) -> None: - """Remembers strokes the client sent through WebDAV, if [note_id] is - a placeholder; a no-op for any other note.""" + def matches(self, note: Note) -> bool: + """True if [note] is a placeholder still in the recorded state.""" with self._lock: - entries = self._read() - if note_id not in entries: - return - entries[note_id] = sorted(set(entries[note_id]) | set(stroke_ids)) - self._write(entries) + recorded = self._read().get(note.id) + return recorded is not None and recorded == _note_fingerprint(note) def remove(self, note_id: str) -> None: with self._lock: @@ -767,13 +841,6 @@ def remove(self, note_id: str) -> None: if entries.pop(note_id, None) is not None: self._write(entries) - def client_strokes(self, note_id: str) -> Optional[set[str]]: - """Strokes the client sent into placeholder [note_id], or None if it - is not a recorded placeholder.""" - with self._lock: - entries = self._read() - return set(entries[note_id]) if note_id in entries else None - def _placeholders(db: FileNoteStore) -> _PlaceholderRegistry: return _PlaceholderRegistry(db.data_dir) @@ -937,6 +1004,9 @@ def close(self) -> None: HTTP_CONFLICT, context_info="page ink note_id does not match the note at this path", ) + # Ink the client sends into its own, still untouched placeholder keeps + # it claimable: the recorded fingerprint follows this write. + untouched_placeholder = _placeholders(self.db).matches(self.note) try: new_strokes: list[InkStroke] = [] @@ -973,9 +1043,8 @@ def close(self) -> None: # Strokes the client sends into its own placeholder don't stop # the placeholder from being claimed; strokes drawn in it through # the app do (see _is_placeholder_for). - _placeholders(self.db).record_strokes( - self.note.id, [s.id for s in new_strokes] - ) + if untouched_placeholder: + _placeholders(self.db).record(self.note) logger.info( "Page %d mise à jour : %d strokes", self.page.page_number, @@ -1106,8 +1175,8 @@ def _materialize_note( title=title, ) note.add_page() - _placeholders(db).add(note.id) db.save_note(note) + _placeholders(db).record(db.get_note(note.id, load_pages=True)) except Exception: logger.exception("auto-materialize note failed: %s", slug) return None diff --git a/tests/test_webdav_sync_safety.py b/tests/test_webdav_sync_safety.py index 287a1b3..f9b4b7e 100644 --- a/tests/test_webdav_sync_safety.py +++ b/tests/test_webdav_sync_safety.py @@ -551,3 +551,134 @@ def test_ink_for_another_note_does_not_overwrite_the_note_at_that_path(server): assert resp.status_code == 409 assert [s.id for s in sdb.get_note(REAL_ID, load_pages=True).pages[0].strokes] == ["mine"] + + +# --------------------------------------------------------------------------- +# A claim must be atomic: the placeholder (and anything the client already +# sent into it) is only removed once the claimed note has been saved. +# --------------------------------------------------------------------------- + +def _placeholder_with_ink(server): + sdb = server["db"] + assert _mkcol(server, "uncategorized/doc__abcd1234").status_code in (200, 201) + ink = {"note_id": CLIENT_ID, + "strokes": [{"id": "s1", "points": [{"x": 1, "y": 1}, {"x": 2, "y": 2}]}]} + assert requests.put(server["url"] + "uncategorized/doc__abcd1234/page_1.ink", + json=ink, auth=AUTH, timeout=5).status_code < 300 + return [n for n in sdb.list_notes() if n.id.startswith("abcd1234")][0].id + + +def _registry(sdb): + path = sdb.data_dir / ".webdav_placeholders.json" + import json + return json.loads(path.read_text()) if path.exists() else {} + + +def _assert_placeholder_intact(sdb, placeholder_id): + kept = sdb.get_note(placeholder_id, load_pages=True) + assert kept is not None + assert [s.id for s in kept.pages[0].strokes] == ["s1"] + assert placeholder_id in _registry(sdb) + assert sdb.get_note(CLIENT_ID) is None + + +@pytest.mark.parametrize("bad_pages", [ + [{"page_number": "abc", "typed_content": "x"}], + [{"page_number": 1, "typed_content": 42}], + "not a list", + [{"page_number": 0, "typed_content": "x"}], +]) +def test_invalid_note_json_leaves_the_placeholder_and_its_ink(server, bad_pages): + sdb = server["db"] + placeholder_id = _placeholder_with_ink(server) + body = {"id": CLIENT_ID, "title": "Doc", "pages": bad_pages} + + resp = requests.put(server["url"] + "uncategorized/doc__abcd1234/note.json", + json=body, auth=AUTH, timeout=5) + + assert resp.status_code == 400 + _assert_placeholder_intact(sdb, placeholder_id) + + +def test_storage_error_during_claim_leaves_the_placeholder(server): + sdb = server["db"] + placeholder_id = _placeholder_with_ink(server) + real_save = sdb.save_note + + def failing_save(note, *args, **kwargs): + if note.id == CLIENT_ID: + raise OSError("disk full") + return real_save(note, *args, **kwargs) + + sdb.save_note = failing_save + try: + resp = _put_meta(server, "uncategorized/doc__abcd1234", CLIENT_ID) + finally: + sdb.save_note = real_save + + assert resp.status_code >= 500 + _assert_placeholder_intact(sdb, placeholder_id) + + +def test_placeholder_is_removed_only_after_the_claimed_note_is_saved(server): + sdb = server["db"] + placeholder_id = _placeholder_with_ink(server) + real_delete = sdb.delete_note_permanent + seen = [] + + def spying_delete(note_id): + if note_id == placeholder_id: + claimed = sdb.get_note(CLIENT_ID, load_pages=True) + seen.append(claimed is not None and len(claimed.pages[0].strokes) == 1) + seen.append(placeholder_id in _registry(sdb)) + return real_delete(note_id) + + sdb.delete_note_permanent = spying_delete + try: + resp = _put_meta(server, "uncategorized/doc__abcd1234", CLIENT_ID) + finally: + sdb.delete_note_permanent = real_delete + + assert resp.status_code < 300 + # At deletion time the claimed note (with the ink) was already saved and + # the registry entry was still there. + assert seen == [True, True] + assert sdb.get_note(placeholder_id) is None + assert placeholder_id not in _registry(sdb) + claimed = sdb.get_note(CLIENT_ID, load_pages=True) + assert claimed.pages[0].typed_content == "client text" + assert [s.id for s in claimed.pages[0].strokes] == ["s1"] + + +@pytest.mark.parametrize("edit", ["title", "tags", "pinned", "type", "notebook"]) +def test_any_user_edit_to_a_placeholder_makes_it_unclaimable(server, edit): + sdb = server["db"] + assert _mkcol(server, "uncategorized/doc__abcd1234").status_code in (200, 201) + placeholder = [n for n in sdb.list_notes() if n.id.startswith("abcd1234")][0] + note = sdb.get_note(placeholder.id, load_pages=True) + if edit == "title": + note.title = "Renamed by the user" + elif edit == "tags": + note.tags = ["work"] + elif edit == "pinned": + note.is_pinned = True + elif edit == "type": + note.note_type = NoteType.HANDWRITTEN + elif edit == "notebook": + other = Notebook(name="Elsewhere") + sdb.save_notebook(other) + note.notebook_id = other.id + note.touch() + sdb.save_note(note) + + resp = _put_meta(server, "uncategorized/doc__abcd1234", CLIENT_ID) + if edit == "notebook": + # Moved away: the path no longer resolves to it, so a fresh placeholder + # is minted and claimed; the user's note itself must be untouched. + assert sdb.get_note(placeholder.id).notebook_id == note.notebook_id + return + + assert resp.status_code == 409 + kept = sdb.get_note(placeholder.id, load_pages=True) + assert kept is not None + assert sdb.get_note(CLIENT_ID) is None From a3dda896408230e9bbe1f57a608f3aa47bd0128c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 14:10:02 +0000 Subject: [PATCH 40/44] fix: resume a half-done upload into its own remote note Page saves now return the note's updated_at, and every successful content write moves the row's remote_baseline to it. A resume only needs the remote to be exactly as our last write left it, so a text upload followed by a failed drawing upload finishes in place instead of creating an offline copy. A copy title never gets the suffix twice. --- app/lib/services/api_client.dart | 21 +++- app/lib/services/sync_service.dart | 50 +++++--- .../note_editor_autosave_race_test.dart | 4 +- .../screens/note_editor_ink_save_test.dart | 3 +- app/test/services/api_client_test.dart | 5 +- app/test/services/app_state_test.dart | 4 +- app/test/services/sync_service_test.dart | 109 ++++++++++++++++-- 7 files changed, 161 insertions(+), 35 deletions(-) diff --git a/app/lib/services/api_client.dart b/app/lib/services/api_client.dart index 78fa7cd..b51e271 100644 --- a/app/lib/services/api_client.dart +++ b/app/lib/services/api_client.dart @@ -335,7 +335,10 @@ class ApiClient { // Page text content // ---------------------------------------------------------------- - Future<void> savePageText(String noteId, int pageNum, String content) async { + /// Saves page text and returns the note's updated_at after the write, as + /// the server reports it (null when it doesn't). + Future<String?> savePageText( + String noteId, int pageNum, String content) async { const what = 'Failed to save text'; final resp = await _send( what, @@ -346,9 +349,12 @@ class ApiClient { ), timeout: uploadTimeout); _expect(resp, what); + return _noteVersion(resp); } - Future<void> savePageInk( + /// Saves a page's drawing; returns the note's updated_at like + /// [savePageText]. + Future<String?> savePageInk( String noteId, int pageNum, List<Map<String, dynamic>> strokes) async { const what = 'Failed to save drawing'; final resp = await _send( @@ -360,6 +366,17 @@ class ApiClient { ), timeout: uploadTimeout); _expect(resp, what); + return _noteVersion(resp); + } + + static String? _noteVersion(http.Response resp) { + try { + final body = jsonDecode(resp.body); + final version = body is Map ? body['note_updated_at'] : null; + return version is String && version.isNotEmpty ? version : null; + } on FormatException { + return null; + } } // ---------------------------------------------------------------- diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index ada8625..d451082 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -110,15 +110,15 @@ class SyncService { var target = hasRemote ? remoteId : null; if (target != null) { // Resuming an upload interrupted after the create. Only write into - // that remote note if it is provably still the empty one we created; - // anything else (edited online, even emptied on purpose, or a state - // we can't compare) keeps both versions. + // that remote note if it is provably still exactly as our last write + // left it; anything else (edited online, even emptied on purpose, or + // a state we can't compare) keeps both versions. final server = await _remoteNoteOrNull(target); if (server == null) { target = null; // deleted on the server meanwhile: create it again - } else if (!_isUntouchedSinceCreate(server, note.remoteBaseline)) { + } else if (!_isAsWeLeftIt(server, note.remoteBaseline)) { final copy = await _api.createNote( - title: '${cleanRemoteTitle(note.title)} (offline copy)', + title: _offlineCopyTitle(note.title), noteType: note.noteType, notebookId: server.notebookId, ); @@ -153,17 +153,33 @@ class SyncService { return PushCounts(notebooks: notebooks, notes: notes); } + /// Uploads the note's text and drawing into [remoteId]. After each write + /// the version the server reports becomes the row's baseline, so if the + /// next write fails, a resume can still tell "only our own writes since" + /// apart from "changed by someone else" (and not make an offline copy of + /// a note that is just half uploaded). Future<void> _uploadContent(Note note, String remoteId) async { if (note.typedContent.isNotEmpty) { - await _api.savePageText(remoteId, 1, note.typedContent); + final version = await _api.savePageText(remoteId, 1, note.typedContent); + await _local.setNoteRemoteId(note.id, remoteId, version); } final strokes = await _local.getStrokesForNote(note.id); if (strokes.isNotEmpty) { - await _api.savePageInk( + final version = await _api.savePageInk( remoteId, 1, strokes.map(inkJsonFromStroke).toList()); + await _local.setNoteRemoteId(note.id, remoteId, version); } } + static const _offlineCopySuffix = ' (offline copy)'; + + static String _offlineCopyTitle(String title) { + final clean = cleanRemoteTitle(title); + return clean.endsWith(_offlineCopySuffix) + ? clean + : '$clean$_offlineCopySuffix'; + } + Future<api.Note?> _remoteNoteOrNull(String id) async { try { return await _api.getNote(id); @@ -173,18 +189,16 @@ class SyncService { } } - /// True only when [server] is still exactly the note our push created: - /// same updated_at as recorded at create time ([baseline]) and no content. - /// Every server-side write moves updated_at, so an emptied note or a note - /// edited and reverted does not pass. Without a comparable baseline there - /// is no proof, so this is false. - static bool _isUntouchedSinceCreate(api.Note server, String? baseline) { - final created = baseline == null ? null : DateTime.tryParse(baseline); + /// True only when [server] is still exactly as our push left it: its + /// updated_at is the one recorded after our create or our last content + /// write ([baseline]). Every server-side write moves updated_at, so a note + /// edited elsewhere, emptied, or edited and reverted does not pass. + /// Without a comparable baseline there is no proof, so this is false. + static bool _isAsWeLeftIt(api.Note server, String? baseline) { + final ours = baseline == null ? null : DateTime.tryParse(baseline); final current = DateTime.tryParse(server.updatedAt); - if (created == null || current == null) return false; - if (!current.isAtSameMomentAs(created)) return false; - return !(server.pages ?? const []).any( - (p) => p.typedContent.isNotEmpty || p.strokes.isNotEmpty); + if (ours == null || current == null) return false; + return current.isAtSameMomentAs(ours); } static bool _isAlreadySynced(String status) => status == 'synced'; diff --git a/app/test/screens/note_editor_autosave_race_test.dart b/app/test/screens/note_editor_autosave_race_test.dart index 4bb464a..9ed11e2 100644 --- a/app/test/screens/note_editor_autosave_race_test.dart +++ b/app/test/screens/note_editor_autosave_race_test.dart @@ -32,12 +32,14 @@ class _SlowSaveApi extends api.ApiClient { []; @override - Future<void> savePageText(String noteId, int pageNum, String content) async { + Future<String?> savePageText( + String noteId, int pageNum, String content) async { started.add(content); final gate = Completer<void>(); pending.add(gate); await gate.future; landed.add(content); + return null; } } diff --git a/app/test/screens/note_editor_ink_save_test.dart b/app/test/screens/note_editor_ink_save_test.dart index f3dcc5d..8267906 100644 --- a/app/test/screens/note_editor_ink_save_test.dart +++ b/app/test/screens/note_editor_ink_save_test.dart @@ -35,7 +35,7 @@ class _InkApi extends api.ApiClient { static const _error = api.ApiException('Failed to save drawing', statusCode: 503); @override - Future<void> savePageInk( + Future<String?> savePageInk( String noteId, int pageNum, List<Map<String, dynamic>> strokes) async { attempts.add(strokes.length); if (hold) { @@ -45,6 +45,7 @@ class _InkApi extends api.ApiClient { } if (down) throw _error; persisted.add(strokes.length); + return null; } } diff --git a/app/test/services/api_client_test.dart b/app/test/services/api_client_test.dart index 1f90722..851203f 100644 --- a/app/test/services/api_client_test.dart +++ b/app/test/services/api_client_test.dart @@ -75,7 +75,10 @@ void main() { httpClient: MockClient((_) => Completer<http.Response>().future), ); Object? error; - client.savePageInk('n', 1, const []).catchError((Object e) => error = e); + client.savePageInk('n', 1, const []).catchError((Object e) { + error = e; + return null; + }); // Still uploading well past the normal request timeout... await tester.pump(ApiClient.requestTimeout * 2); diff --git a/app/test/services/app_state_test.dart b/app/test/services/app_state_test.dart index 7d83d10..5b017bd 100644 --- a/app/test/services/app_state_test.dart +++ b/app/test/services/app_state_test.dart @@ -84,8 +84,10 @@ class _FlakyUploadApi extends _StubApi { } @override - Future<void> savePageText(String noteId, int pageNum, String content) async { + Future<String?> savePageText( + String noteId, int pageNum, String content) async { savedText[noteId] = content; + return null; } } diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index f7f0d99..690a9ac 100644 --- a/app/test/services/sync_service_test.dart +++ b/app/test/services/sync_service_test.dart @@ -22,8 +22,12 @@ class FakeApiClient extends api.ApiClient { bool failText = false; bool failInk = false; + /// Whether page saves report the note's new updated_at, like the current + /// backend does. Older servers don't. + bool reportsVersion = true; + /// Like the real backend, every write to a note moves its updated_at. - void _touch(String noteId) { + String? _touch(String noteId) { remoteNotes = [ for (final n in remoteNotes) n.id == noteId @@ -33,6 +37,8 @@ class FakeApiClient extends api.ApiClient { .toIso8601String()) : n, ]; + if (!reportsVersion) return null; + return remoteNotes.where((n) => n.id == noteId).firstOrNull?.updatedAt; } /// Someone edits the note on the server (another device, the web UI). @@ -42,10 +48,11 @@ class FakeApiClient extends api.ApiClient { } @override - Future<void> savePageText(String noteId, int pageNum, String content) async { + Future<String?> savePageText( + String noteId, int pageNum, String content) async { if (failText) throw const api.ApiException('Failed to save text'); savedText[noteId] = content; - _touch(noteId); + return _touch(noteId); } @override @@ -65,11 +72,11 @@ class FakeApiClient extends api.ApiClient { } @override - Future<void> savePageInk( + Future<String?> savePageInk( String noteId, int pageNum, List<Map<String, dynamic>> strokes) async { if (failInk) throw const api.ApiException('Failed to save drawing'); savedInk[noteId] = strokes; - _touch(noteId); + return _touch(noteId); } @override @@ -333,8 +340,7 @@ void main() { expect(fakeApi.savedText['remote-note-2'], 'written offline'); }); - test('a partly uploaded note is not resumed over its own remote copy', - () async { + Future<String> textUploadedInkFailed() async { final note = await local.createNote('Sketch', noteType: 'mixed'); await local.updateNoteContent(note.id, 'caption'); await local.replaceStrokesForNote(note.id, [ @@ -345,21 +351,102 @@ void main() { points: const [StrokePoint(x: 1, y: 2), StrokePoint(x: 3, y: 4)], ), ]); - // Text upload succeeds, the drawing upload fails. fakeApi.failInk = true; await expectLater(sync.pushLocal(), throwsA(isA<api.ApiException>())); fakeApi.failInk = false; final remoteId = fakeApi.remoteNotes.single.id; expect(fakeApi.savedText[remoteId], 'caption'); + return remoteId; + } + + Iterable<String> copyTitles() => fakeApi.createdNotes + .map((n) => n['title'] as String) + .where((t) => t.contains('(offline copy)')); + + test('a partly uploaded note resumes into the same remote note', + () async { + final remoteId = await textUploadedInkFailed(); await sync.pushLocal(); - // The remote changed since the create (our own text write is - // indistinguishable from someone else's), so nothing is overwritten - // and the complete local version is kept as a copy. + // Only our own text write happened since the create, so the upload + // just finishes there: no copy. + expect(fakeApi.createdNotes, hasLength(1)); + expect(copyTitles(), isEmpty); expect(fakeApi.savedText[remoteId], 'caption'); + expect(fakeApi.savedInk[remoteId], hasLength(1)); + final row = (await local.exportAllData()).notes.single; + expect(row.syncStatus, 'synced'); + expect(row.remoteId, remoteId); + }); + + test('repeated failed resumes never make more than one copy', () async { + final remoteId = await textUploadedInkFailed(); + fakeApi.editOnServer(remoteId, text: 'edited online'); + + // The copy's text goes up, its drawing keeps failing. + fakeApi.failInk = true; + for (var i = 0; i < 3; i++) { + await expectLater(sync.pushLocal(), throwsA(isA<api.ApiException>())); + } + fakeApi.failInk = false; + await sync.pushLocal(); + await sync.pushLocal(); + + expect(fakeApi.createdNotes, hasLength(2)); + expect(copyTitles(), ['Sketch (offline copy)']); expect(fakeApi.savedText['remote-note-2'], 'caption'); expect(fakeApi.savedInk['remote-note-2'], hasLength(1)); + expect(fakeApi.savedText[remoteId], 'edited online'); + }); + + test('an edit made online between our writes is kept, not overwritten', + () async { + final remoteId = await textUploadedInkFailed(); + // After our text went up, someone edits the note on the server. + fakeApi.editOnServer(remoteId, text: 'changed on the web'); + + await sync.pushLocal(); + + expect(fakeApi.savedText[remoteId], 'changed on the web'); + expect(fakeApi.savedInk[remoteId], isNull); + expect(copyTitles(), ['Sketch (offline copy)']); + expect(fakeApi.savedText['remote-note-2'], 'caption'); + expect(fakeApi.savedInk['remote-note-2'], hasLength(1)); + }); + + test('a resume makes a copy when the server reports no version', + () async { + fakeApi.reportsVersion = false; + final remoteId = await textUploadedInkFailed(); + + await sync.pushLocal(); + + // Without a version there is no proof the text write was the only + // change, so the original stays as is and a copy takes everything. + expect(fakeApi.savedInk[remoteId], isNull); + expect(copyTitles(), ['Sketch (offline copy)']); + expect(fakeApi.savedInk['remote-note-2'], hasLength(1)); + }); + + test('offline copy titles never stack the suffix', () async { + final note = await local.createNote('Plan (offline copy)'); + await local.updateNoteContent(note.id, 'v2'); + fakeApi.failText = true; + await expectLater(sync.pushLocal(), throwsA(isA<api.ApiException>())); + final remoteId = fakeApi.remoteNotes.single.id; + fakeApi.editOnServer(remoteId, text: 'online'); + for (var i = 0; i < 3; i++) { + await expectLater(sync.pushLocal(), throwsA(isA<api.ApiException>())); + } + fakeApi.failText = false; + await sync.sync(); + await sync.sync(); + + expect(fakeApi.createdNotes.map((n) => n['title']), + ['Plan (offline copy)', 'Plan (offline copy)']); + expect(fakeApi.createdNotes.map((n) => n['title']), + everyElement(isNot(contains('(offline copy) (offline copy)')))); }); test('a failed copy upload is resumed into the same copy, not repeated', From e82c70b732935f24c6cc5cf59f5af78da03e2277 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 14:32:53 +0000 Subject: [PATCH 41/44] fix: keep every edit in SQLite until the backend confirms it In connected mode the newest title, text and drawing only lived in the editor and in AppState memory until the server answered, so a killed process, a desktop close with a stuck backend or a crash lost them. Edits now go to the local store first in every mode. Each part (title, text, ink) has a revision; the backend push reads the newest local version and only marks that revision synced, so a late answer for an older save can't mark a newer edit as sent. Pending edits are shown when the note is reopened (also from a new process, also offline) and are pushed on connect and sync. Pulls decide against the row as it is when written, so they never revert a pending edit. The ink tickets stay as the guard against older snapshots. Without a usable local store (the database can't be opened), saves keep going straight to the backend as before. --- app/lib/data/database/schema.dart | 29 +- app/lib/data/models/note.dart | 57 +- .../data/repositories/note_repository.dart | 311 +++++++++-- app/lib/screens/note_editor_screen.dart | 142 +++-- app/lib/services/app_state.dart | 505 +++++++++++++++--- app/lib/services/local_note_service.dart | 46 +- app/lib/services/sync_service.dart | 55 +- app/test/data/database/app_database_test.dart | 38 ++ .../repositories/note_repository_test.dart | 131 +++++ app/test/helpers/fake_backend.dart | 181 +++++++ app/test/helpers/test_store.dart | 32 ++ .../note_editor_autosave_race_test.dart | 10 +- .../screens/note_editor_ink_save_test.dart | 7 +- .../screens/note_editor_restart_test.dart | 285 ++++++++++ app/test/services/local_first_test.dart | 260 +++++++++ 15 files changed, 1902 insertions(+), 187 deletions(-) create mode 100644 app/test/helpers/fake_backend.dart create mode 100644 app/test/helpers/test_store.dart create mode 100644 app/test/screens/note_editor_restart_test.dart create mode 100644 app/test/services/local_first_test.dart diff --git a/app/lib/data/database/schema.dart b/app/lib/data/database/schema.dart index ffa6bd7..bcc76f7 100644 --- a/app/lib/data/database/schema.dart +++ b/app/lib/data/database/schema.dart @@ -15,7 +15,13 @@ class Schema { /// Bumped to 3 for remote_baseline: the server's updated_at when our push /// created a note, so an interrupted upload is only resumed into a remote /// note that provably hasn't changed since. - static const int version = 3; + /// + /// Bumped to 4 for the per-part edit revisions (title/text/ink `_rev` and + /// `_synced_rev`): every local edit bumps its part's revision, and a part + /// is waiting for the server while its revision is ahead of the last one + /// the server confirmed. That makes SQLite the durable copy of an edit + /// until the backend has acknowledged it, even in connected mode. + static const int version = 4; static const String _createNotebooks = ''' CREATE TABLE IF NOT EXISTS notebooks ( @@ -48,12 +54,27 @@ class Schema { remote_id TEXT, remote_path TEXT, remote_baseline TEXT, + title_rev INTEGER NOT NULL DEFAULT 0, + title_synced_rev INTEGER NOT NULL DEFAULT 0, + text_rev INTEGER NOT NULL DEFAULT 0, + text_synced_rev INTEGER NOT NULL DEFAULT 0, + ink_rev INTEGER NOT NULL DEFAULT 0, + ink_synced_rev INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (notebook_id) REFERENCES notebooks(id) ) '''; + static const editRevisionColumns = [ + 'title_rev', + 'title_synced_rev', + 'text_rev', + 'text_synced_rev', + 'ink_rev', + 'ink_synced_rev', + ]; + static const String _createStrokes = ''' CREATE TABLE IF NOT EXISTS strokes ( id TEXT PRIMARY KEY, @@ -130,5 +151,11 @@ class Schema { if (oldVersion < 3) { await db.execute('ALTER TABLE notes ADD COLUMN remote_baseline TEXT'); } + if (oldVersion < 4) { + for (final column in editRevisionColumns) { + await db.execute( + 'ALTER TABLE notes ADD COLUMN $column INTEGER NOT NULL DEFAULT 0'); + } + } } } diff --git a/app/lib/data/models/note.dart b/app/lib/data/models/note.dart index e7ad077..cac44ca 100644 --- a/app/lib/data/models/note.dart +++ b/app/lib/data/models/note.dart @@ -1,5 +1,17 @@ import 'dart:convert'; +/// The independently saved parts of a note, each with its own edit +/// revision (see [Note.titleRev] and friends). +enum NotePart { + title('title_rev', 'title_synced_rev'), + text('text_rev', 'text_synced_rev'), + ink('ink_rev', 'ink_synced_rev'); + + const NotePart(this.revColumn, this.syncedRevColumn); + final String revColumn; + final String syncedRevColumn; +} + /// A note in a notebook. /// /// For Phase 2 this model covers the notes table directly. @@ -20,8 +32,14 @@ import 'dart:convert'; /// followed without losing the link. /// /// [remoteBaseline] is the server's `updated_at` for the note right after -/// our push created it. While an upload is pending it is the only proof -/// that the remote note is still the empty one we created. +/// our push created it or last wrote content into it. While an upload is +/// pending it is the only proof that nobody else changed the remote note +/// since. +/// +/// The `*Rev` / `*SyncedRev` pairs count local edits of the title, the text +/// and the drawing, and the last of each the server has confirmed. A part +/// whose revision is ahead is a local edit not yet on the server; the local +/// row is its only durable copy until then. /// /// Mirrors Note in nexanote/models/note.py. class Note { @@ -38,6 +56,12 @@ class Note { final String? remoteId; final String? remotePath; final String? remoteBaseline; + final int titleRev; + final int titleSyncedRev; + final int textRev; + final int textSyncedRev; + final int inkRev; + final int inkSyncedRev; final DateTime createdAt; final DateTime updatedAt; @@ -55,6 +79,12 @@ class Note { this.remoteId, this.remotePath, this.remoteBaseline, + this.titleRev = 0, + this.titleSyncedRev = 0, + this.textRev = 0, + this.textSyncedRev = 0, + this.inkRev = 0, + this.inkSyncedRev = 0, required this.createdAt, required this.updatedAt, }); @@ -76,6 +106,12 @@ class Note { remoteId: map['remote_id'] as String?, remotePath: map['remote_path'] as String?, remoteBaseline: map['remote_baseline'] as String?, + titleRev: (map['title_rev'] as int?) ?? 0, + titleSyncedRev: (map['title_synced_rev'] as int?) ?? 0, + textRev: (map['text_rev'] as int?) ?? 0, + textSyncedRev: (map['text_synced_rev'] as int?) ?? 0, + inkRev: (map['ink_rev'] as int?) ?? 0, + inkSyncedRev: (map['ink_synced_rev'] as int?) ?? 0, createdAt: DateTime.parse(map['created_at'] as String), updatedAt: DateTime.parse(map['updated_at'] as String), ); @@ -96,6 +132,12 @@ class Note { 'remote_id': remoteId, 'remote_path': remotePath, 'remote_baseline': remoteBaseline, + 'title_rev': titleRev, + 'title_synced_rev': titleSyncedRev, + 'text_rev': textRev, + 'text_synced_rev': textSyncedRev, + 'ink_rev': inkRev, + 'ink_synced_rev': inkSyncedRev, 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), }; @@ -131,11 +173,22 @@ class Note { remoteId: remoteId ?? this.remoteId, remotePath: remotePath ?? this.remotePath, remoteBaseline: remoteBaseline, + titleRev: titleRev, + titleSyncedRev: titleSyncedRev, + textRev: textRev, + textSyncedRev: textSyncedRev, + inkRev: inkRev, + inkSyncedRev: inkSyncedRev, createdAt: createdAt, updatedAt: updatedAt ?? this.updatedAt, ); } + bool get titlePending => titleRev > titleSyncedRev; + bool get textPending => textRev > textSyncedRev; + bool get inkPending => inkRev > inkSyncedRev; + bool get hasPendingEdits => titlePending || textPending || inkPending; + @override String toString() => 'Note(id: $id, title: $title, type: $noteType)'; } diff --git a/app/lib/data/repositories/note_repository.dart b/app/lib/data/repositories/note_repository.dart index d82f9da..aa8264c 100644 --- a/app/lib/data/repositories/note_repository.dart +++ b/app/lib/data/repositories/note_repository.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; @@ -167,17 +169,29 @@ class NoteRepository { /// A single UPDATE instead of read-modify-write via [upsertNote]: the editor /// saves title, text and ink independently, and a full-row upsert built from /// an earlier read would put back stale copies of the other fields. - Future<void> updateNoteFields( + /// + /// Returns the number of rows changed: 0 when note [id] doesn't exist. + Future<int> updateNoteFields( String id, [ Map<String, Object?> fields = const {}, - ]) async { + NotePart? edited, + ]) => + _updateNoteFields(_db, id, fields, edited); + + static Future<int> _updateNoteFields( + DatabaseExecutor db, + String id, + Map<String, Object?> fields, + NotePart? edited, + ) async { final assignments = [ for (final column in fields.keys) '$column = ?', + if (edited != null) '${edited.revColumn} = ${edited.revColumn} + 1', 'updated_at = ?', "sync_status = CASE sync_status WHEN 'synced' THEN 'modified' " 'ELSE sync_status END', ]; - await _db.rawUpdate( + return db.rawUpdate( 'UPDATE notes SET ${assignments.join(', ')} WHERE id = ?', [ ...fields.values, @@ -187,10 +201,221 @@ class NoteRepository { ); } + /// Durably records a new drawing for note [id]: replaces its strokes and + /// bumps the ink revision in one transaction, so the stored drawing and + /// its revision always match. Returns the new ink revision, or null when + /// note [id] doesn't exist (nothing is written then). + Future<int?> recordInk(String id, List<Stroke> strokes) { + return _db.transaction((txn) async { + if (await _updateNoteFields(txn, id, const {}, NotePart.ink) == 0) { + return null; + } + await _replaceStrokes(txn, id, strokes); + final rows = await txn.query('notes', + columns: ['ink_rev'], where: 'id = ?', whereArgs: [id]); + return rows.first['ink_rev'] as int; + }); + } + + /// The current ink revision of note [id] with the drawing it stands for, + /// read together so they can't come from two different saves. + Future<({int rev, List<Stroke> strokes})?> readInk(String id) { + return _db.transaction((txn) async { + final rows = await txn.query('notes', + columns: ['ink_rev'], where: 'id = ?', whereArgs: [id], limit: 1); + if (rows.isEmpty) return null; + return ( + rev: rows.first['ink_rev'] as int, + strokes: await _strokesForNote(txn, id), + ); + }); + } + + /// Records that the server has [part] of note [id] up to revision [rev]. + /// Never moves backwards, so an older save finishing late can't mark a + /// newer edit as synced. A connected-mode copy with nothing left to send + /// goes back to `synced`. + Future<void> markPartSynced(String id, NotePart part, int rev) async { + await _db.transaction((txn) async { + await txn.rawUpdate( + 'UPDATE notes SET ${part.syncedRevColumn} = ' + 'MAX(${part.syncedRevColumn}, ?) WHERE id = ?', + [rev, id], + ); + await txn.rawUpdate( + "UPDATE notes SET sync_status = 'synced' " + "WHERE id = ? AND sync_status = 'modified' AND remote_id IS NOT NULL " + 'AND title_rev <= title_synced_rev AND text_rev <= text_synced_rev ' + 'AND ink_rev <= ink_synced_rev', + [id], + ); + }); + } + + /// Id of the local row that holds remote note [remoteId] for connected + /// mode, or null. Rows still waiting for their first upload (`local_only`) + /// don't count: SyncService.pushLocal owns those and their content. + /// + /// Falls back to a row whose own id is [remoteId]: notes listed from the + /// local store while the backend is down are opened by their local id. + Future<String?> localIdForRemote(String remoteId) => + _localIdForRemote(_db, remoteId); + + static Future<String?> _localIdForRemote( + DatabaseExecutor db, String remoteId) async { + final rows = await db.rawQuery( + 'SELECT id FROM notes WHERE remote_id = ? ' + "AND sync_status != 'local_only' ORDER BY id = remote_id DESC LIMIT 1", + [remoteId], + ); + if (rows.isNotEmpty) return rows.first['id'] as String; + final own = await db.query('notes', + columns: ['id'], where: 'id = ?', whereArgs: [remoteId], limit: 1); + return own.isEmpty ? null : remoteId; + } + + /// Stores the server's copy of a note (opened in connected mode) as the + /// local copy of it, so it can be read without the backend, and returns + /// the local row's id. [remote] carries the server id as both `id` and + /// `remoteId`. + /// + /// Parts with a local edit still waiting for the server keep the local + /// version. With [refresh] false an existing copy is left as it is (used + /// to make sure a row exists before recording an edit). Rows with older + /// local changes that aren't tracked per part (`modified` without a + /// pending revision, `conflict`, `local_only`) are never overwritten. + Future<String> cacheRemoteNote( + Note remote, + List<Stroke> strokes, { + bool refresh = true, + }) { + return _db.transaction((txn) async { + final localId = await _localIdForRemote(txn, remote.id); + if (localId == null) { + await txn.insert('notes', remote.toMap()); + await _replaceStrokes(txn, remote.id, strokes); + return remote.id; + } + if (!refresh) return localId; + final rows = await txn.query('notes', + where: 'id = ?', whereArgs: [localId], limit: 1); + final local = Note.fromMap(rows.first); + final tracked = local.syncStatus == 'synced' || + (local.syncStatus == 'modified' && local.hasPendingEdits); + if (!tracked) return localId; + await txn.update( + 'notes', + { + 'notebook_id': remote.notebookId, + 'note_type': remote.noteType, + 'tags': jsonEncode(remote.tags), + 'is_pinned': remote.isPinned ? 1 : 0, + if (!local.titlePending) 'title': remote.title, + if (!local.textPending) 'typed_content': remote.typedContent, + if (!local.hasPendingEdits) ...{ + 'sync_status': 'synced', + 'updated_at': remote.updatedAt.toIso8601String(), + }, + }, + where: 'id = ?', + whereArgs: [localId], + ); + if (!local.inkPending) await _replaceStrokes(txn, localId, strokes); + return localId; + }); + } + + /// Applies a note's metadata from a pull to local row [localId]. Decided + /// against the row as it is now, not as an earlier snapshot saw it: a row + /// with an edit still waiting for the server, a row not uploaded yet, or a + /// locally modified row newer than [pulled] is left alone. Content columns + /// and edit revisions are never touched. + Future<void> applyPulledNote(String localId, Note pulled) async { + await _db.transaction((txn) async { + final rows = await txn.query('notes', + where: 'id = ?', whereArgs: [localId], limit: 1); + if (rows.isEmpty) return; + final local = Note.fromMap(rows.first); + final keepLocal = local.syncStatus == 'local_only' || + local.hasPendingEdits || + (local.syncStatus == 'modified' && + local.updatedAt.isAfter(pulled.updatedAt)); + if (keepLocal) return; + await txn.update( + 'notes', + { + 'notebook_id': pulled.notebookId, + 'title': pulled.title, + 'note_type': pulled.noteType, + 'tags': jsonEncode(pulled.tags), + 'is_pinned': pulled.isPinned ? 1 : 0, + 'is_deleted': pulled.isDeleted ? 1 : 0, + 'sync_status': 'synced', + 'remote_id': pulled.remoteId, + 'remote_path': pulled.remotePath, + 'updated_at': pulled.updatedAt.toIso8601String(), + }, + where: 'id = ?', + whereArgs: [localId], + ); + }); + } + + /// Unlinks note [id] from a remote note that no longer exists, keeping its + /// content: it becomes a note to upload (`local_only`) like one taken + /// offline, and SyncService.pushLocal creates it on the server again. + Future<void> detachFromRemote(String id) async { + await _db.rawUpdate( + "UPDATE notes SET remote_id = NULL, remote_baseline = NULL, " + "sync_status = 'local_only', title_synced_rev = 0, text_synced_rev = 0, " + 'ink_synced_rev = 0 WHERE id = ?', + [id], + ); + } + + /// Inserts [note] unless a row with its id already exists. + Future<void> insertNoteIfAbsent(Note note) async { + await _db.insert('notes', note.toMap(), + conflictAlgorithm: ConflictAlgorithm.ignore); + } + + /// Flags note [id] as conflicting, leaving everything else as it is. + Future<void> markNoteConflict(String id) async { + await _db.update('notes', {'sync_status': 'conflict'}, + where: 'id = ?', whereArgs: [id]); + } + + /// Hard-deletes note [id] only if it is still `synced` with nothing + /// waiting for the server, checked at delete time. Returns rows removed. + Future<int> hardDeleteNoteIfSynced(String id) { + return _db.delete( + 'notes', + where: "id = ? AND sync_status = 'synced' " + 'AND title_rev <= title_synced_rev AND text_rev <= text_synced_rev ' + 'AND ink_rev <= ink_synced_rev', + whereArgs: [id], + ); + } + + /// Notes linked to a remote note with an edit the server hasn't confirmed + /// yet. Notes taken in local mode and not uploaded yet + /// (`local_only`) go through SyncService.pushLocal instead. + Future<List<Note>> getNotesWithPendingEdits() async { + final rows = await _db.query( + 'notes', + where: "remote_id IS NOT NULL AND sync_status != 'local_only' " + 'AND is_deleted = 0 ' + 'AND (title_rev > title_synced_rev OR text_rev > text_synced_rev ' + 'OR ink_rev > ink_synced_rev)', + ); + return rows.map(Note.fromMap).toList(); + } + /// Links local note [id] to the note the server created for it, keeping it /// pending (`local_only`) until its content has been uploaded too. - /// [baseline] is the server's updated_at for that fresh note (see - /// [Note.remoteBaseline]); null when the server didn't report one. + /// [baseline] is the server's updated_at after our create or our latest + /// write into it (see [Note.remoteBaseline]); null when the server didn't + /// report one. Future<void> setNoteRemoteId( String id, String remoteId, String? baseline) async { await _db.update( @@ -202,11 +427,11 @@ class NoteRepository { /// (the version that was uploaded). Returns whether it was marked. Future<bool> markNoteSyncedIfUnchanged( String id, DateTime seenUpdatedAt) async { - final changed = await _db.update( - 'notes', - {'sync_status': 'synced'}, - where: 'id = ? AND updated_at = ?', - whereArgs: [id, seenUpdatedAt.toIso8601String()], + final changed = await _db.rawUpdate( + "UPDATE notes SET sync_status = 'synced', " + 'title_synced_rev = title_rev, text_synced_rev = text_rev, ' + 'ink_synced_rev = ink_rev WHERE id = ? AND updated_at = ?', + [id, seenUpdatedAt.toIso8601String()], ); return changed > 0; } @@ -289,41 +514,51 @@ class NoteRepository { String noteId, List<Stroke> strokes, ) async { - await _db.transaction((txn) async { - final existing = await txn.query( - 'strokes', - columns: ['id'], - where: 'note_id = ?', - whereArgs: [noteId], + await _db.transaction((txn) => _replaceStrokes(txn, noteId, strokes)); + } + + static Future<void> _replaceStrokes( + DatabaseExecutor txn, + String noteId, + List<Stroke> strokes, + ) async { + final existing = await txn.query( + 'strokes', + columns: ['id'], + where: 'note_id = ?', + whereArgs: [noteId], + ); + for (final row in existing) { + await txn.delete( + 'stroke_points', + where: 'stroke_id = ?', + whereArgs: [row['id']], ); - for (final row in existing) { - await txn.delete( - 'stroke_points', - where: 'stroke_id = ?', - whereArgs: [row['id']], - ); - } - await txn.delete('strokes', where: 'note_id = ?', whereArgs: [noteId]); + } + await txn.delete('strokes', where: 'note_id = ?', whereArgs: [noteId]); - for (final stroke in strokes) { + for (final stroke in strokes) { + await txn.insert( + 'strokes', + {...stroke.toMap(), 'note_id': noteId}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + for (var i = 0; i < stroke.points.length; i++) { await txn.insert( - 'strokes', - stroke.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, + 'stroke_points', + stroke.points[i].toMap(stroke.id, i), ); - for (var i = 0; i < stroke.points.length; i++) { - await txn.insert( - 'stroke_points', - stroke.points[i].toMap(stroke.id, i), - ); - } } - }); + } } /// Returns all strokes for [noteId] with their points, ordered by creation. - Future<List<Stroke>> getStrokesForNote(String noteId) async { - final strokeRows = await _db.query( + Future<List<Stroke>> getStrokesForNote(String noteId) => + _strokesForNote(_db, noteId); + + static Future<List<Stroke>> _strokesForNote( + DatabaseExecutor db, String noteId) async { + final strokeRows = await db.query( 'strokes', where: 'note_id = ?', whereArgs: [noteId], @@ -333,7 +568,7 @@ class NoteRepository { final strokes = <Stroke>[]; for (final strokeRow in strokeRows) { final strokeId = strokeRow['id'] as String; - final pointRows = await _db.query( + final pointRows = await db.query( 'stroke_points', where: 'stroke_id = ?', whereArgs: [strokeId], diff --git a/app/lib/screens/note_editor_screen.dart b/app/lib/screens/note_editor_screen.dart index 298dc76..c067b88 100644 --- a/app/lib/screens/note_editor_screen.dart +++ b/app/lib/screens/note_editor_screen.dart @@ -34,22 +34,27 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> late String _savedTitle; late String _savedContent; - /// The newest complete drawing not yet confirmed saved. Stays set after a - /// failed save so the same snapshot can be retried; only a newer drawing - /// replaces it. + /// The newest complete drawing not yet written to the local store. Stays + /// set after a failed write so the same snapshot can be retried; only a + /// newer drawing replaces it. List<Map<String, dynamic>>? _pendingInk; int? _pendingInkTicket; - /// The single ink writer while it runs; see [_flushInk]. + /// The single local ink writer while it runs; see [_flushInk]. Future<void>? _inkWriter; - Timer? _inkRetryTimer; - int _inkRetries = 0; + + /// The latest request to send this note's saved edits to the backend. + Future<void>? _lastPush; + bool _pushFailed = false; + + Timer? _retryTimer; + int _retries = 0; bool _disposed = false; - /// Backoff between automatic retries of a failed ink save. While the editor - /// is open the last delay repeats; once it is closed, retries stop after - /// going through the list once. - static const _inkRetryDelays = [ + /// Backoff between automatic retries of a failed ink write or backend + /// push. While the editor is open the last delay repeats; once it is + /// closed, retries stop after going through the list once. + static const _retryDelays = [ Duration(seconds: 2), Duration(seconds: 5), Duration(seconds: 15), @@ -107,6 +112,7 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> // A drawing of this note that an earlier editor couldn't save yet is // newer than the stored copy we were opened with: show it and take // over saving it. + _appState?.rememberNote(widget.note); final unsaved = _appState?.unsavedInk(_note.id); if (!_adoptedUnsavedInk && unsaved != null) { _adoptedUnsavedInk = true; @@ -156,23 +162,24 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> return save; } + /// Writes the snapshot to the local store only, so a backend that hangs + /// never holds up the next save; [_pushEdits] then sends it on. Future<bool> _persistSnapshot( String title, String content, int generation) async { final state = _appState; if (state == null) return false; try { if (title != _savedTitle) { - await state.updateNoteTitle(_note.id, title); + await state.recordTitle(_note.id, title); _savedTitle = title; } if (content != _savedContent) { - await state.savePageText(_note.id, 1, content); + await state.recordText(_note.id, content); _savedContent = content; } if (generation == _editGeneration && mounted) { setState(() => _hasChanges = false); } - return true; } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( @@ -180,6 +187,8 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> } return false; } + _pushEdits(); + return true; } Future<void> _flushTextBeforeModeSwitch() async { @@ -206,31 +215,33 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> _inkStrokes = strokes; _pendingInk = strokes; _pendingInkTicket = _appState?.stageInk(_note.id, strokes); - _inkRetries = 0; + _retries = 0; return _flushInk(); } - /// Starts the ink writer unless it is already running, and completes when - /// it goes idle. Each save sends the whole drawing, so only the newest - /// snapshot matters; a single writer means saves can't land out of order - /// and put an older drawing back. + /// Starts the local ink writer unless it is already running, and + /// completes when it goes idle. Each write stores the whole drawing, so + /// only the newest snapshot matters; a single writer means writes can't + /// land out of order and put an older drawing back. Future<void> _flushInk() { - _inkRetryTimer?.cancel(); - _inkRetryTimer = null; + _retryTimer?.cancel(); + _retryTimer = null; return _inkWriter ??= _drainInk().whenComplete(() => _inkWriter = null); } Future<void> _drainInk() async { final state = _appState; if (state == null) return; + var wrote = false; while (_pendingInk != null) { final next = _pendingInk!; final ticket = _pendingInkTicket; _pendingInk = null; try { - await state.savePageInk(_note.id, 1, next, ticket: ticket); + await state.recordInk(_note.id, next, ticket: ticket); _inkSaveFailed = false; - _inkRetries = 0; + _retries = 0; + wrote = true; } catch (e) { debugPrint('Ink save error: $e'); // Keep this snapshot pending unless a newer drawing arrived while it @@ -247,21 +258,62 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> 'Could not save drawing — it stays on screen and will be retried.'), backgroundColor: Colors.red)); } - _scheduleInkRetry(); + _scheduleRetry(); return; } } + if (wrote) _pushEdits(); } - void _scheduleInkRetry() { - if (_disposed && _inkRetries >= _inkRetryDelays.length) { - debugPrint('Giving up retrying the ink save for note ${_note.id}'); + /// Sends the edits saved in the local store to the backend (a no-op in + /// local mode). The edits are already safe on the device, so a failure + /// only means they reach the server later: it is reported once and + /// retried with backoff. The returned future never fails. + Future<void> _pushEdits() { + final state = _appState; + if (state == null) return Future.value(); + final push = state.pushPending(_note.id); + _lastPush = push; + return push.then((_) { + if (!identical(_lastPush, push)) return; + _pushFailed = false; + _retries = 0; + if (_pendingInk == null) { + _retryTimer?.cancel(); + _retryTimer = null; + } + }, onError: (Object e) { + debugPrint('Backend save error for note ${_note.id}: $e'); + // A newer push was requested meanwhile; it reports its own outcome. + if (!identical(_lastPush, push)) return; + if (mounted && !_pushFailed) { + _pushFailed = true; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Saved on this device. It will be sent to the ' + 'server once it can be reached.'))); + } + _scheduleRetry(); + }); + } + + void _scheduleRetry() { + _retryTimer?.cancel(); + if (_disposed && _retries >= _retryDelays.length) { + debugPrint('Giving up retrying the save of note ${_note.id}'); return; } - final delay = - _inkRetryDelays[_inkRetries.clamp(0, _inkRetryDelays.length - 1)]; - _inkRetries++; - _inkRetryTimer = Timer(delay, _flushInk); + final delay = _retryDelays[_retries.clamp(0, _retryDelays.length - 1)]; + _retries++; + _retryTimer = Timer(delay, _retry); + } + + void _retry() { + _retryTimer = null; + if (_pendingInk != null) { + _flushInk(); + } else { + _pushEdits(); + } } /// The app has no trash view to restore from, so deleting from the editor @@ -301,31 +353,37 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> /// Android can kill a backgrounded app without running any more Dart /// code, and closing a desktop window doesn't dispose widgets either, so /// pending text can't wait for the debounce timer once the app leaves the - /// foreground. + /// foreground. The local writes are what makes the edits survive; the + /// push is a best effort that would otherwise wait for its retry timer, + /// which never fires if the backgrounded process is killed. @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) return; _saveTimer?.cancel(); if (_hasChanges) _save(); - // A drawing whose save failed would otherwise wait for its retry timer, - // which never fires if the backgrounded process is killed. if (_pendingInk != null) _flushInk(); + _pushEdits(); } - /// Desktop window close: give pending text and ink saves a bounded chance - /// to finish before the process exits, without blocking the close on a - /// backend that never answers. + /// Desktop window close: first let the pending text and ink reach the + /// local store, then give the backend push what is left of a bounded + /// wait. A backend that never answers can't block the close, and nothing + /// is lost by closing anyway: the edits are on disk and pushed on the + /// next start. @override Future<AppExitResponse> didRequestAppExit() async { _saveTimer?.cancel(); - final saves = <Future<void>>[ + final clock = Stopwatch()..start(); + final localWrites = <Future<void>>[ if (_hasChanges) _persist() else _textSaves, if (_pendingInk != null || _inkWriter != null) _flushInk(), ]; try { - await Future.wait(saves).timeout(_exitSaveTimeout); + await Future.wait(localWrites).timeout(_exitSaveTimeout); + final left = _exitSaveTimeout - clock.elapsed; + if (left > Duration.zero) await _pushEdits().timeout(left); } on TimeoutException { - debugPrint('Exiting with saves still pending for note ${_note.id}'); + debugPrint('Exiting before every save of note ${_note.id} finished'); } return AppExitResponse.exit; } @@ -335,9 +393,9 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> _disposed = true; WidgetsBinding.instance.removeObserver(this); _saveTimer?.cancel(); - // A failed drawing save keeps retrying (bounded) with the captured - // AppState after the editor is gone; start right away rather than - // waiting for the backoff. + // A failed drawing write or push keeps retrying (bounded) with the + // captured AppState after the editor is gone; start right away rather + // than waiting for the backoff. if (_pendingInk != null) _flushInk(); // Flush pending text edits. The save is queued behind any in-flight one // and only uses the captured AppState, so it is safe after unmount. diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index ce4cdf1..0c79ecd 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -63,9 +63,16 @@ class AppState extends ChangeNotifier { int _notesRequest = 0; int _openRequest = 0; Future<void>? _localUpload; + + /// Newest drawing per note not yet in its final storage. `rev` is the + /// local ink revision it was recorded as, once it has been. final _stagedInk = - <String, ({int ticket, List<Map<String, dynamic>> strokes})>{}; + <String, ({int ticket, List<Map<String, dynamic>> strokes, int? rev})>{}; int _inkTickets = 0; + final _knownNotes = <String, api.Note>{}; + final _runningPushes = <String, Future<void>>{}; + final _queuedPushes = <String, Future<void>>{}; + final _pushesStarted = <String, int>{}; bool _isSyncing = false; String? _syncMessage; String? _syncError; @@ -267,6 +274,23 @@ class AppState extends ChangeNotifier { } } + /// Sends edits recorded on this device that the backend never confirmed + /// (the app was closed, killed or offline before they got through). A + /// failure is reported through [syncError]; the edits stay pending. + Future<void> _pushEditsSavedOnDevice() async { + try { + await _ensureLocalReady(); + } catch (_) { + return; // no local store, so nothing can be pending in it + } + try { + await _pushAllPending(); + } catch (e) { + _syncError = + 'Could not upload edits saved on this device: ${_safeErrorString(e)}'; + } + } + Future<void> connect({String? url}) async { if (url != null) { _apiUrl = url; @@ -298,6 +322,7 @@ class AppState extends ChangeNotifier { // Notes taken offline before (or while) a server was configured are // uploaded so nothing created in local mode is lost on the switch. await _uploadPendingLocalNotes(); + await _pushEditsSavedOnDevice(); await loadNotebooks(); // The upload re-keys notebooks to their server ids, and a notebook // may be gone on this server: don't keep filing new notes under an @@ -427,9 +452,10 @@ class AppState extends ChangeNotifier { api.Note _toApiNoteWithContent( local.Note n, { List<Stroke> strokes = const [], + String? id, }) => api.Note( - id: n.id, + id: id ?? n.id, title: n.title, noteType: n.noteType, notebookId: n.notebookId, @@ -581,6 +607,11 @@ class AppState extends ChangeNotifier { /// from the backend when connected, or from the local SQLite store in local /// mode. Screens call this instead of `client.getNote` directly so the /// editor opens offline. + /// + /// Connected, the server copy is cached locally, and parts with a local + /// edit the server hasn't confirmed yet (for instance saved just before + /// the app was killed) are shown in their local version. When the backend + /// can't be reached the local copy is returned, if there is one. Future<api.Note> getNote(String id) async { if (_localMode) { await _ensureLocalReady(); @@ -591,7 +622,37 @@ class AppState extends ChangeNotifier { final strokes = await _localService.getStrokesForNote(id); return _toApiNoteWithContent(n, strokes: strokes); } - return client.getNote(id); + final before = await _pushState(id); + final api.Note remote; + try { + remote = await client.getNote(id); + } catch (e) { + if (_isClientError(e)) rethrow; + final cached = await _localCopyOf(id); + if (cached == null) rethrow; + await _handleBackendFailure(e); + return cached; + } + // A push of this note that ran while the server answered may have + // landed after the copy we got was read: it can't replace ours then. + final after = await _pushState(id); + final raced = before != null && + (before.running || after == null || after.started != before.started); + return _withLocalEdits(remote, serverMayBeBehind: raced); + } + + Future<({bool running, int started})?> _pushState(String id) async { + try { + await _ensureLocalReady(); + final localId = await _localService.localIdForRemote(id); + if (localId == null) return null; + return ( + running: _runningPushes.containsKey(localId), + started: _pushesStarted[localId] ?? 0, + ); + } catch (_) { + return null; + } } Future<void> deleteNote(String id) async { @@ -613,113 +674,424 @@ class AppState extends ChangeNotifier { await _handleBackendFailure(e); rethrow; } + await _dropLocalCopy(id); } + /// Saves the note's title: durably on this device first, then to the + /// backend when one is configured. Throws when the backend save fails; the + /// title is still kept locally and sent on a later [pushPending]. Future<void> updateNoteTitle(String id, String title) async { + await recordTitle(id, title); + await pushPending(id); + } + + /// Saves page text like [updateNoteTitle] does the title. + Future<void> savePageText(String noteId, int pageNum, String content) async { + await recordText(noteId, content); + await pushPending(noteId); + } + + /// Saves a page's ink [strokes] like [updateNoteTitle] does the title. + /// [strokes] is the editor's wire shape (`{id,color,width,tool,points:[…]}`), + /// the same payload the backend already accepts. See [recordInk] for + /// [ticket]. + Future<void> savePageInk( + String noteId, + int pageNum, + List<Map<String, dynamic>> strokes, { + int? ticket, + }) async { + await recordInk(noteId, strokes, ticket: ticket); + await pushPending(noteId); + } + + /// Records [title] as the note's newest title in the local store, without + /// waiting for the network. In local mode that is the only copy; with a + /// backend it is the durable copy until [pushPending] gets it there. + Future<void> recordTitle(String noteId, String title) async { if (_localMode) { await _ensureLocalReady(); - await _localService.updateNoteTitle(id, title); + await _localService.updateNoteTitle(noteId, title); await loadNotes(notebookId: _selectedNotebook?.id); return; } - try { - await client.updateNote(id, title: title); - _markBackendAvailable(); - await loadNotes(notebookId: _selectedNotebook?.id); - } catch (e) { - await _handleBackendFailure(e); - rethrow; - } + await _recordConnected( + noteId, + (localId) => _localService.updateNoteTitle(localId, title), + orSend: () => client.updateNote(noteId, title: title), + ); + _notes = [ + for (final n in _notes) n.id == noteId ? n.copyWith(title: title) : n, + ]; + notifyListeners(); } - Future<void> savePageText(String noteId, int pageNum, String content) async { + /// Records [content] as the note's newest text; see [recordTitle]. + Future<void> recordText(String noteId, String content) async { if (_localMode) { await _ensureLocalReady(); await _localService.updateNoteContent(noteId, content); return; } - try { - await client.savePageText(noteId, pageNum, content); - if (_markBackendAvailable()) notifyListeners(); - } catch (e) { - await _handleBackendFailure(e); - rethrow; - } + await _recordConnected( + noteId, + (localId) => _localService.updateNoteContent(localId, content), + orSend: () => client.savePageText(noteId, 1, content), + ); } - /// Persists a page's ink [strokes], honouring the current mode. In local mode - /// the drawing is written to the on-device SQLite store (replacing the note's - /// previous strokes) and the note is flagged for later sync; with a backend - /// configured it goes through the existing API. Throws on failure so the - /// editor can surface a friendly error rather than silently dropping a - /// drawing. - /// - /// [strokes] is the editor's wire shape (`{id,color,width,tool,points:[…]}`), - /// the same payload the backend already accepts. + /// Records [strokes] as the note's newest drawing; see [recordTitle]. /// - /// With a [ticket] from [stageInk], the save is skipped when a newer + /// With a [ticket] from [stageInk], nothing is written when a newer /// drawing of the same note has been staged since: retries of an older /// snapshot (for instance from an editor that was closed) can never land /// after a newer one. - Future<void> savePageInk( + Future<void> recordInk( String noteId, - int pageNum, List<Map<String, dynamic>> strokes, { int? ticket, }) async { if (ticket != null && _stagedInk[noteId]?.ticket != ticket) return; - await _writePageInk(noteId, pageNum, strokes); - if (ticket != null && _stagedInk[noteId]?.ticket == ticket) { - _stagedInk.remove(noteId); + // A single created-at instant per save, nudged forward per stroke so the + // load order matches the draw order (getStrokesForNote sorts by it). + final base = DateTime.now().toUtc(); + List<Stroke> toLocal(String localId) => [ + for (var i = 0; i < strokes.length; i++) + strokeFromInkJson( + localId, strokes[i], base.add(Duration(milliseconds: i))), + ]; + // Checked again right before the write: a newer drawing may have been + // staged while this one waited for the store. + bool superseded() => + ticket != null && _stagedInk[noteId]?.ticket != ticket; + if (_localMode) { + await _ensureLocalReady(); + if (superseded()) return; + await _localService.recordInk(noteId, toLocal(noteId)); + _unstageInk(noteId, ticket); + return; + } + int? rev; + final local = await _recordConnected( + noteId, + (localId) async { + if (superseded()) return 1; + rev = await _localService.recordInk(localId, toLocal(localId)); + return rev == null ? 0 : 1; + }, + orSend: () => client.savePageInk(noteId, 1, strokes), + ); + final staged = _stagedInk[noteId]; + if (staged == null || ticket == null || staged.ticket != ticket) return; + if (!local) { + _stagedInk.remove(noteId); // sent straight to the backend + } else { + // Stays staged until the backend has this revision. + _stagedInk[noteId] = (ticket: ticket, strokes: strokes, rev: rev); } } /// Records [strokes] as the newest drawing of [noteId] until it is saved, - /// and returns the ticket to pass to [savePageInk]. + /// and returns the ticket to pass to [recordInk]. int stageInk(String noteId, List<Map<String, dynamic>> strokes) { final ticket = ++_inkTickets; - _stagedInk[noteId] = (ticket: ticket, strokes: strokes); + _stagedInk[noteId] = (ticket: ticket, strokes: strokes, rev: null); return ticket; } - /// The newest drawing of [noteId] that has not been saved yet, if any. A - /// reopened editor starts from it rather than from the older stored copy. + /// The newest drawing of [noteId] that has not reached its final storage + /// yet (the backend when one is configured), if any. A reopened editor + /// starts from it rather than from the older copy it was opened with. List<Map<String, dynamic>>? unsavedInk(String noteId) => _stagedInk[noteId]?.strokes; - Future<void> _writePageInk( - String noteId, - int pageNum, - List<Map<String, dynamic>> strokes, - ) async { - if (_localMode) { + void _unstageInk(String noteId, int? ticket) { + if (ticket != null && _stagedInk[noteId]?.ticket == ticket) { + _stagedInk.remove(noteId); + } + } + + /// Remembers [note] as the latest known server copy, used to fill in the + /// local copy if an edit to it is recorded before [getNote] cached it. + void rememberNote(api.Note note) { + if (_localMode || _knownNotes.containsKey(note.id)) return; + // Only needed until the note's local copy exists; keep a few at most. + if (_knownNotes.length >= 16) _knownNotes.remove(_knownNotes.keys.first); + _knownNotes[note.id] = note; + } + + /// Sends the edits of [noteId] recorded locally that the backend hasn't + /// confirmed yet. No-op in local mode or when nothing is pending. + /// + /// Each push reads the newest local version when it starts, and a part + /// is only marked synced up to the revision that was sent, so an edit + /// made meanwhile stays pending. One push per note at a time; requests + /// made while one runs share a single follow-up push. Throws when the + /// backend can't be reached or refuses the save. + Future<void> pushPending(String noteId) async { + if (_localMode) return; + final String? localId; + try { await _ensureLocalReady(); - // A single created-at instant per save, nudged forward per stroke so the - // load order matches the draw order (getStrokesForNote sorts by it). - final base = DateTime.now().toUtc(); - final localStrokes = <Stroke>[ - for (var i = 0; i < strokes.length; i++) - strokeFromInkJson( - noteId, - strokes[i], - base.add(Duration(milliseconds: i)), - ), - ]; - await _localService.replaceStrokesForNote(noteId, localStrokes); - // Mark the note modified so a future sync uploads the drawing. The - // note's content/type is untouched — only the sync bookkeeping changes. - await _localService.markNoteModified(noteId); + localId = await _localService.localIdForRemote(noteId); + } catch (e) { + debugPrint('No local store to push from: $e'); + return; + } + if (localId == null) return; + await _push(localId); + } + + Future<void> _push(String localId) { + final queued = _queuedPushes[localId]; + if (queued != null) return queued; + final running = _runningPushes[localId]; + if (running == null) return _startPush(localId); + final next = running.then((_) {}, onError: (_) {}).then((_) { + _queuedPushes.remove(localId); + return _startPush(localId); + }); + _queuedPushes[localId] = next; + return next; + } + + Future<void> _startPush(String localId) { + _pushesStarted[localId] = (_pushesStarted[localId] ?? 0) + 1; + late final Future<void> push; + push = _pushOnce(localId).whenComplete(() { + if (identical(_runningPushes[localId], push)) { + _runningPushes.remove(localId); + } + }); + _runningPushes[localId] = push; + return push; + } + + Future<void> _pushOnce(String localId) async { + final note = await _localService.getNoteById(localId); + final remoteId = note?.remoteId; + if (note == null || + remoteId == null || + note.syncStatus == 'local_only' || + !note.hasPendingEdits) { return; } + var titleSent = false; try { - await client.savePageInk(noteId, pageNum, strokes); - if (_markBackendAvailable()) notifyListeners(); + if (note.titlePending) { + await client.updateNote(remoteId, title: note.title); + await _localService.markPartSynced( + localId, local.NotePart.title, note.titleRev); + titleSent = true; + } + if (note.textPending) { + await client.savePageText(remoteId, 1, note.typedContent); + await _localService.markPartSynced( + localId, local.NotePart.text, note.textRev); + } + if (note.inkPending) { + final ink = await _localService.readInk(localId); + if (ink != null) { + await client.savePageInk( + remoteId, 1, ink.strokes.map(inkJsonFromStroke).toList()); + await _localService.markPartSynced( + localId, local.NotePart.ink, ink.rev); + // The editor stages by the id it opened the note with. + for (final key in {remoteId, localId}) { + final stagedRev = _stagedInk[key]?.rev; + if (stagedRev != null && stagedRev <= ink.rev) { + _stagedInk.remove(key); + } + } + } + } } catch (e) { + if (e is api.ApiException && e.statusCode == 404) { + // Deleted on the server meanwhile: keep the edits as a note of its + // own, uploaded like one taken offline, rather than retrying into + // a note that is gone. + await _localService.detachFromRemote(localId); + } await _handleBackendFailure(e); rethrow; } + if (_markBackendAvailable()) notifyListeners(); + if (titleSent) await loadNotes(notebookId: _selectedNotebook?.id); } + /// Pushes every note with edits recorded locally that the backend hasn't + /// confirmed, for instance from before the app was closed or killed. One + /// note failing doesn't stop the others; the first error is rethrown at + /// the end. + Future<void> _pushAllPending() async { + final pending = await _localService.getNotesWithPendingEdits(); + Object? firstError; + for (final note in pending) { + try { + await _push(note.id); + } catch (e) { + firstError ??= e; + } + } + if (firstError != null) throw firstError; + } + + /// Runs [record] against the local copy of server note [noteId], creating + /// that copy if needed. Returns false when there is no local store (it + /// can't be opened on this platform or device) and the edit went straight + /// to the backend through [orSend] instead. A local write that fails on a + /// working store is rethrown, never sent around it: an older edit still + /// pending locally would otherwise be pushed after this newer one. + Future<bool> _recordConnected( + String noteId, + Future<int> Function(String localId) record, { + required Future<Object?> Function() orSend, + }) async { + try { + await _ensureLocalReady(); + } catch (e) { + debugPrint('Local store unavailable, saving to the backend only: $e'); + try { + await orSend(); + if (_markBackendAvailable()) notifyListeners(); + } catch (e) { + await _handleBackendFailure(e); + rethrow; + } + return false; + } + // A pull may remove a synced copy between finding it and writing to + // it; the second round recreates it. + for (var attempt = 0; attempt < 2; attempt++) { + final localId = await _localService.cacheRemoteNote( + _mirrorOf(_knownNote(noteId), noteId), + _strokesOf(_knownNote(noteId), noteId), + refresh: false, + ); + if (await record(localId) > 0) { + _knownNotes.remove(noteId); + return true; + } + } + throw StateError('Could not record the edit of note $noteId locally'); + } + + api.Note? _knownNote(String id) { + if (_selectedNote?.id == id) return _selectedNote; + return _knownNotes[id] ?? _notes.where((n) => n.id == id).firstOrNull; + } + + /// The local row for server note [n] (or a bare one for [id] when nothing + /// is known about it), as a synced copy of the server version. + local.Note _mirrorOf(api.Note? n, String id) { + final now = DateTime.now().toUtc(); + DateTime parse(String? iso) => + (iso == null ? null : DateTime.tryParse(iso))?.toUtc() ?? now; + final page = n?.pages?.where((p) => p.pageNumber == 1).firstOrNull; + return local.Note( + id: id, + remoteId: id, + notebookId: n?.notebookId, + title: n?.title ?? '', + noteType: n?.noteType ?? 'typed', + tags: n?.tags ?? const [], + isPinned: n?.isPinned ?? false, + typedContent: page?.typedContent ?? '', + syncStatus: 'synced', + createdAt: parse(n?.createdAt), + updatedAt: parse(n?.updatedAt), + ); + } + + List<Stroke> _strokesOf(api.Note? n, String id) { + final page = n?.pages?.where((p) => p.pageNumber == 1).firstOrNull; + final base = DateTime.now().toUtc(); + final strokes = + (page?.strokes ?? const []).whereType<Map<String, dynamic>>().toList(); + return [ + for (var i = 0; i < strokes.length; i++) + strokeFromInkJson(id, strokes[i], base.add(Duration(milliseconds: i))), + ]; + } + + /// Caches [remote] locally and returns it with the parts that have a + /// pending local edit replaced by their local version. When + /// [serverMayBeBehind] (one of our pushes raced the read), the local copy + /// is kept as it is and wins for every part. A local store problem never + /// keeps the note from opening. + Future<api.Note> _withLocalEdits( + api.Note remote, { + bool serverMayBeBehind = false, + }) async { + try { + await _ensureLocalReady(); + final localId = await _localService.cacheRemoteNote( + _mirrorOf(remote, remote.id), _strokesOf(remote, remote.id), + refresh: !serverMayBeBehind); + _knownNotes.remove(remote.id); + final row = await _localService.getNoteById(localId); + if (row == null) return remote; + final useTitle = serverMayBeBehind || row.titlePending; + final useText = serverMayBeBehind || row.textPending; + final useInk = serverMayBeBehind || row.inkPending; + if (!useTitle && !useText && !useInk) return remote; + final ink = useInk ? await _localService.readInk(localId) : null; + final pages = [...?remote.pages]; + final i = pages.indexWhere((p) => p.pageNumber == 1); + final page = i < 0 ? null : pages[i]; + final merged = api.NotePage( + pageNumber: 1, + template: page?.template ?? 'blank', + typedContent: useText ? row.typedContent : page?.typedContent ?? '', + strokes: ink != null + ? ink.strokes.map(inkJsonFromStroke).toList() + : page?.strokes ?? const [], + ); + if (i < 0) { + pages.insert(0, merged); + } else { + pages[i] = merged; + } + return remote.copyWith( + title: useTitle ? row.title : remote.title, + pages: pages, + ); + } catch (e) { + debugPrint('Could not use the local copy of note ${remote.id}: $e'); + return remote; + } + } + + /// The local copy of server note [id] in the editor's shape, or null. + Future<api.Note?> _localCopyOf(String id) async { + try { + await _ensureLocalReady(); + final localId = await _localService.localIdForRemote(id); + if (localId == null) return null; + final row = await _localService.getNoteById(localId); + if (row == null || row.isDeleted) return null; + final strokes = await _localService.getStrokesForNote(localId); + return _toApiNoteWithContent(row, strokes: strokes, id: id); + } catch (e) { + debugPrint('No local copy of note $id: $e'); + return null; + } + } + + /// Forgets the local copy of a note deleted on the server. + Future<void> _dropLocalCopy(String id) async { + _knownNotes.remove(id); + try { + final localId = await _localService.localIdForRemote(id); + if (localId != null) await _localService.hardDeleteNote(localId); + } catch (e) { + debugPrint('Could not drop the local copy of note $id: $e'); + } + } + + static bool _isClientError(Object e) => + e is api.ApiException && e.statusCode != null && e.statusCode! < 500; + void selectNote(api.Note? note) { _selectedNote = note; notifyListeners(); } /// Loads note [id] with its content and selects it. Returns null when the @@ -775,9 +1147,18 @@ class AppState extends ChangeNotifier { _beginSync(); try { await initLocal(); + // Edits saved on this device go first, so the pull below can't be + // older than them. A note that fails to push doesn't stop the sync. + Object? pushError; + try { + await _pushAllPending(); + } catch (e) { + pushError = e; + } final svc = service ?? SyncService(apiClient: client, local: _localService); final result = await svc.sync(); + if (pushError != null) throw pushError; _markBackendAvailable(); await _markEverConnected(); _finishSync(message: 'Sync complete — ${result.summary}'); diff --git a/app/lib/services/local_note_service.dart b/app/lib/services/local_note_service.dart index b5dad9a..636ef0f 100644 --- a/app/lib/services/local_note_service.dart +++ b/app/lib/services/local_note_service.dart @@ -58,13 +58,45 @@ class LocalNoteService { _repo.getNoteByRemoteId(remoteId); Future<void> upsertNote(Note note) => _repo.upsertNote(note); - /// Local edits. Each writes only its own column (plus the sync bookkeeping) - /// so concurrent title, text and ink saves can't overwrite each other. - Future<void> updateNoteTitle(String id, String title) => - _repo.updateNoteFields(id, {'title': title}); - Future<void> updateNoteContent(String id, String typedContent) => - _repo.updateNoteFields(id, {'typed_content': typedContent}); - Future<void> markNoteModified(String id) => _repo.updateNoteFields(id); + /// Local edits. Each writes only its own column (plus the sync bookkeeping + /// and its part's edit revision) so concurrent title, text and ink saves + /// can't overwrite each other. + /// They return the number of rows changed (0: no such note). + Future<int> updateNoteTitle(String id, String title) => + _repo.updateNoteFields(id, {'title': title}, NotePart.title); + Future<int> updateNoteContent(String id, String typedContent) => _repo + .updateNoteFields(id, {'typed_content': typedContent}, NotePart.text); + + /// Replaces the note's drawing and records it as a local edit, atomically. + /// Returns the new ink revision, or null when there is no such note. + Future<int?> recordInk(String id, List<Stroke> strokes) => + _repo.recordInk(id, strokes); + + /// The note's current drawing with the ink revision it belongs to. + Future<({int rev, List<Stroke> strokes})?> readInk(String id) => + _repo.readInk(id); + + /// The server has [part] of note [id] up to revision [rev]. + Future<void> markPartSynced(String id, NotePart part, int rev) => + _repo.markPartSynced(id, part, rev); + + // Connected-mode copies of server notes. + Future<String?> localIdForRemote(String remoteId) => + _repo.localIdForRemote(remoteId); + Future<String> cacheRemoteNote(Note remote, List<Stroke> strokes, + {bool refresh = true}) => + _repo.cacheRemoteNote(remote, strokes, refresh: refresh); + Future<List<Note>> getNotesWithPendingEdits() => + _repo.getNotesWithPendingEdits(); + Future<void> detachFromRemote(String id) => _repo.detachFromRemote(id); + + // Pull bookkeeping used by SyncService. + Future<void> applyPulledNote(String localId, Note pulled) => + _repo.applyPulledNote(localId, pulled); + Future<void> insertNoteIfAbsent(Note note) => _repo.insertNoteIfAbsent(note); + Future<void> markNoteConflict(String id) => _repo.markNoteConflict(id); + Future<int> hardDeleteNoteIfSynced(String id) => + _repo.hardDeleteNoteIfSynced(id); // Push bookkeeping used by SyncService. Future<void> setNoteRemoteId(String id, String remoteId, String? baseline) => diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index d451082..5cfd1e5 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -269,30 +269,28 @@ class SyncService { localByRemoteId[r.id] ?? (derivedPath != null ? localByRemotePath[derivedPath] : null); if (existing != null) { - // Same identity on both sides — newer timestamp wins, but a local - // 'modified' row beats an older or equal-time remote so user edits - // aren't clobbered before the next push round. - final remoteUpdated = _parseUtc(r.updatedAt); - // A local_only row with a remoteId was created by our own push and - // still has content to upload; adopting the remote copy now would - // mark it synced and the upload would never be retried. - final keepLocal = existing.syncStatus == 'local_only' || - (existing.syncStatus == 'modified' && - existing.updatedAt.isAfter(remoteUpdated)); - if (keepLocal) continue; - await _local.upsertNote(existing.copyWith( - notebookId: r.notebookId, - clearNotebookId: r.notebookId == null, - title: cleanedRemoteTitle, - noteType: r.noteType, - tags: r.tags, - isPinned: r.isPinned, - isDeleted: r.isDeleted, - syncStatus: 'synced', - remoteId: r.id, - remotePath: derivedPath ?? existing.remotePath, - updatedAt: remoteUpdated, - )); + // Same identity on both sides: newer timestamp wins, but a local + // edit the server doesn't have yet is never clobbered. A local_only + // row with a remoteId was created by our own push and still has + // content to upload; adopting the remote copy now would mark it + // synced and the upload would never be retried. Decided against the + // row as it is when written, since the editor may be saving into it + // while this pull runs. + await _local.applyPulledNote( + existing.id, + existing.copyWith( + notebookId: r.notebookId, + clearNotebookId: r.notebookId == null, + title: cleanedRemoteTitle, + noteType: r.noteType, + tags: r.tags, + isPinned: r.isPinned, + isDeleted: r.isDeleted, + remoteId: r.id, + remotePath: derivedPath ?? existing.remotePath, + updatedAt: _parseUtc(r.updatedAt), + ), + ); continue; } @@ -302,13 +300,10 @@ class SyncService { final titleClash = localTitleIndex[cleanedRemoteTitle.toLowerCase()]; if (titleClash != null && titleClash.remoteId == null) { - await _local.upsertNote(titleClash.copyWith( - syncStatus: 'conflict', - updatedAt: titleClash.updatedAt, - )); + await _local.markNoteConflict(titleClash.id); } - await _local.upsertNote(_toLocalNote( + await _local.insertNoteIfAbsent(_toLocalNote( r, cleanedTitle: cleanedRemoteTitle, derivedPath: derivedPath, @@ -328,7 +323,7 @@ class SyncService { // Drop only fully-synced rows. Local-only / modified / conflict // rows are preserved; the next push promotes them upstream. if (localNote.syncStatus == 'synced') { - await _local.hardDeleteNote(localNote.id); + await _local.hardDeleteNoteIfSynced(localNote.id); } } diff --git a/app/test/data/database/app_database_test.dart b/app/test/data/database/app_database_test.dart index 481f74f..fbcb5ca 100644 --- a/app/test/data/database/app_database_test.dart +++ b/app/test/data/database/app_database_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:nexanote/data/database/schema.dart'; +import 'package:nexanote/data/models/note.dart'; void main() { setUpAll(() { @@ -169,6 +170,9 @@ void main() { final v2 = await openDatabase(inMemoryDatabasePath, version: 1, onCreate: (db, _) => Schema.onCreate(db, 1)); await v2.execute('ALTER TABLE notes DROP COLUMN remote_baseline'); + for (final column in Schema.editRevisionColumns) { + await v2.execute('ALTER TABLE notes DROP COLUMN $column'); + } final now = DateTime.utc(2024, 1, 1).toIso8601String(); await v2.insert('notes', { 'id': 'pending', 'title': 'Pending', 'remote_id': 'srv-1', @@ -186,5 +190,39 @@ void main() { expect(row['remote_baseline'], isNull); await v2.close(); }); + + test( + 'adds the edit revisions when migrating v3 → v4, keeping rows as ' + 'they were', () async { + final v3 = await openDatabase(inMemoryDatabasePath, + version: 1, onCreate: (db, _) => Schema.onCreate(db, 1)); + for (final column in Schema.editRevisionColumns) { + await v3.execute('ALTER TABLE notes DROP COLUMN $column'); + } + final now = DateTime.utc(2024, 1, 1).toIso8601String(); + await v3.insert('notes', { + 'id': 'n', + 'title': 'Kept', + 'typed_content': 'body', + 'remote_id': 'srv-1', + 'remote_baseline': now, + 'sync_status': 'local_only', + 'created_at': now, + 'updated_at': now, + }); + + await Schema.onUpgrade(v3, 3, Schema.version); + + final row = Note.fromMap( + (await v3.query('notes', where: 'id = ?', whereArgs: ['n'])).first); + expect(row.title, 'Kept'); + expect(row.typedContent, 'body'); + expect(row.syncStatus, 'local_only'); + expect(row.remoteBaseline, now); + // Older rows carry no per-part revision: nothing looks pending, and + // what they still have to upload stays with pushLocal via local_only. + expect(row.hasPendingEdits, isFalse); + await v3.close(); + }); }); } diff --git a/app/test/data/repositories/note_repository_test.dart b/app/test/data/repositories/note_repository_test.dart index bab7ddc..1801a16 100644 --- a/app/test/data/repositories/note_repository_test.dart +++ b/app/test/data/repositories/note_repository_test.dart @@ -5,6 +5,7 @@ import 'package:nexanote/data/database/schema.dart'; import 'package:nexanote/data/repositories/note_repository.dart'; import 'package:nexanote/data/models/stroke.dart'; import 'package:nexanote/data/models/point.dart'; +import 'package:nexanote/data/models/note.dart'; void main() { setUpAll(() { @@ -259,4 +260,134 @@ void main() { expect(strokesB, isEmpty); }); }); + + // ----------------------------------------------------------------------- + // Edit revisions (local-first saves) + // ----------------------------------------------------------------------- + + group('edit revisions', () { + Stroke stroke(String id, String noteId) => Stroke( + id: id, + noteId: noteId, + createdAt: DateTime.utc(2024), + points: const [StrokePoint(x: 1, y: 2)], + ); + + Note serverCopy(String id, {String title = 'Server', String text = ''}) { + final at = DateTime.utc(2024, 1, 1); + return Note( + id: id, + remoteId: id, + title: title, + typedContent: text, + syncStatus: 'synced', + createdAt: at, + updatedAt: at, + ); + } + + test('every edit bumps its own part only', () async { + final note = await repo.createNote('A'); + await repo.updateNoteFields(note.id, {'title': 'B'}, NotePart.title); + await repo.updateNoteFields( + note.id, {'typed_content': 'x'}, NotePart.text); + await repo.updateNoteFields( + note.id, {'typed_content': 'xy'}, NotePart.text); + final rev = await repo.recordInk(note.id, [stroke('s1', note.id)]); + + final stored = (await repo.getNoteById(note.id))!; + expect(stored.titleRev, 1); + expect(stored.textRev, 2); + expect(stored.inkRev, 1); + expect(rev, 1); + }); + + test('recording ink for a missing note writes nothing', () async { + expect(await repo.recordInk('gone', [stroke('s1', 'gone')]), isNull); + expect(await repo.getStrokesForNote('gone'), isEmpty); + }); + + test('a confirmation for an older revision leaves a newer edit pending', + () async { + final id = await repo.cacheRemoteNote(serverCopy('srv-1'), const []); + await repo.updateNoteFields(id, {'typed_content': 'A'}, NotePart.text); + await repo.updateNoteFields(id, {'typed_content': 'B'}, NotePart.text); + + // The save of A (revision 1) comes back after B was recorded. + await repo.markPartSynced(id, NotePart.text, 1); + var stored = (await repo.getNoteById(id))!; + expect(stored.textPending, isTrue); + expect(stored.syncStatus, 'modified'); + + await repo.markPartSynced(id, NotePart.text, 2); + // A late confirmation of A can't move it back. + await repo.markPartSynced(id, NotePart.text, 1); + stored = (await repo.getNoteById(id))!; + expect(stored.textPending, isFalse); + expect(stored.textSyncedRev, 2); + expect(stored.syncStatus, 'synced'); + }); + + test('caching the server copy keeps parts with a pending local edit', + () async { + final id = await repo.cacheRemoteNote( + serverCopy('srv-1', title: 'T0', text: 'old'), + [stroke('s0', 'srv-1')]); + await repo.updateNoteFields(id, {'typed_content': 'mine'}, NotePart.text); + await repo.recordInk(id, [stroke('s1', id), stroke('s2', id)]); + + await repo.cacheRemoteNote( + serverCopy('srv-1', title: 'T1', text: 'theirs'), + [stroke('s9', 'srv-1')]); + + final stored = (await repo.getNoteById(id))!; + expect(stored.title, 'T1'); // nothing pending: refreshed + expect(stored.typedContent, 'mine'); + expect((await repo.getStrokesForNote(id)).map((s) => s.id), ['s1', 's2']); + }); + + test('caching resolves a note uploaded from local mode to its row', + () async { + final note = await repo.createNote('Offline'); + await repo.setNoteRemoteId(note.id, 'srv-7', null); + await repo.markNoteSyncedIfUnchanged(note.id, note.updatedAt); + + final id = await repo.cacheRemoteNote(serverCopy('srv-7'), const []); + + expect(id, note.id); + expect(await repo.getAllNotes(), hasLength(1)); + }); + + test('caching never overwrites a row that still has to be uploaded', + () async { + final note = await repo.createNote('Offline'); + await repo.updateNoteFields( + note.id, {'typed_content': 'only copy'}, NotePart.text); + await repo.setNoteRemoteId(note.id, 'srv-8', null); // still local_only + + final id = await repo.cacheRemoteNote( + serverCopy('srv-8', text: 'empty on the server'), const []); + + expect(id, isNot(note.id), reason: 'pushLocal owns the local_only row'); + expect((await repo.getNoteById(note.id))!.typedContent, 'only copy'); + }); + + test('a pull applied from an older snapshot keeps a pending edit', + () async { + final id = await repo.cacheRemoteNote(serverCopy('srv-1'), const []); + final snapshot = (await repo.getNoteById(id))!; + await repo.updateNoteFields(id, {'title': 'Mine'}, NotePart.title); + + await repo.applyPulledNote( + id, + snapshot.copyWith( + title: 'Theirs', updatedAt: DateTime.utc(2030), isPinned: true)); + + final stored = (await repo.getNoteById(id))!; + expect(stored.title, 'Mine'); + expect(stored.titlePending, isTrue); + expect(await repo.hardDeleteNoteIfSynced(id), 0); + expect(await repo.getNotesWithPendingEdits(), hasLength(1)); + }); + }); } diff --git a/app/test/helpers/fake_backend.dart b/app/test/helpers/fake_backend.dart new file mode 100644 index 0000000..57f22f0 --- /dev/null +++ b/app/test/helpers/fake_backend.dart @@ -0,0 +1,181 @@ +import 'dart:async'; + +import 'package:nexanote/services/api_client.dart' as api; + +/// What the NexaNote backend stores, shared by every [FakeBackendClient]. +/// Each simulated app process gets its own client, so a "killed" process +/// can be cut off without touching the server or the next process. +class FakeServer { + final notes = <String, api.Note>{}; + final text = <String, String>{}; + final ink = <String, List<Map<String, dynamic>>>{}; + var _ids = 0; + var _clock = DateTime.utc(2024, 1, 1); + + /// Every page save that reached the server, in order: `text:<content>` or + /// `ink:<stroke count>`. + final writes = <String>[]; + + String _tick() { + _clock = _clock.add(const Duration(seconds: 1)); + return _clock.toIso8601String(); + } + + api.Note create(String title, String noteType) { + final now = _tick(); + final note = api.Note( + id: 'srv-${++_ids}', + title: title, + noteType: noteType, + tags: const [], + isPinned: false, + isDeleted: false, + pageCount: 1, + updatedAt: now, + createdAt: now, + ); + notes[note.id] = note; + return note; + } + + String touch(String id, {String? title}) { + final now = _tick(); + notes[id] = notes[id]!.copyWith(title: title, updatedAt: now); + return now; + } + + api.Note full(String id) => notes[id]!.copyWith(pages: [ + api.NotePage( + pageNumber: 1, + template: 'blank', + typedContent: text[id] ?? '', + strokes: [...?ink[id]], + ), + ]); +} + +/// [api.ApiClient] backed by a [FakeServer]. [down] makes every call fail +/// like an unreachable backend; [hold] keeps page saves waiting until +/// [release] (or forever). +class FakeBackendClient extends api.ApiClient { + FakeBackendClient(this.server) : super(baseUrl: 'http://fake.test'); + + final FakeServer server; + bool down = false; + bool hold = false; + final _held = <Completer<void>>[]; + + int get heldCount => _held.length; + + void release() => _held.removeAt(0).complete(); + + static const _unreachable = + api.ApiException('Backend unreachable', statusCode: 503); + + Future<void> _call() async { + if (down) throw _unreachable; + } + + Future<void> _write(String id) async { + await _call(); + if (hold) { + final gate = Completer<void>(); + _held.add(gate); + await gate.future; + } + if (down) throw _unreachable; + if (!server.notes.containsKey(id)) { + throw const api.ApiException('Note not found', statusCode: 404); + } + } + + @override + Future<bool> ping() async { + await _call(); + return true; + } + + @override + Future<List<api.Notebook>> getNotebooks() async { + await _call(); + return const []; + } + + @override + Future<List<api.Note>> getNotes({ + String? notebookId, + String? search, + bool includeDeleted = false, + }) async { + await _call(); + return server.notes.values.toList(); + } + + @override + Future<api.Note> createNote({ + required String title, + required String noteType, + String? notebookId, + String template = 'blank', + }) async { + await _call(); + return server.create(title, noteType); + } + + /// Note reads answer with the server state at request time, but only + /// once [releaseRead] is called, like a response still on the wire. + bool holdReads = false; + final _heldReads = <Completer<void>>[]; + + void releaseRead() => _heldReads.removeAt(0).complete(); + + @override + Future<api.Note> getNote(String id) async { + await _call(); + if (!server.notes.containsKey(id)) { + throw const api.ApiException('Note not found', statusCode: 404); + } + final snapshot = server.full(id); + if (holdReads) { + final gate = Completer<void>(); + _heldReads.add(gate); + await gate.future; + } + return snapshot; + } + + @override + Future<void> deleteNote(String id) async { + await _call(); + server.notes.remove(id); + server.text.remove(id); + server.ink.remove(id); + } + + @override + Future<api.Note> updateNote(String id, + {String? title, bool? isPinned, List<String>? tags}) async { + await _write(id); + server.touch(id, title: title); + server.writes.add('title:$title'); + return server.notes[id]!; + } + + @override + Future<String?> savePageText( + String noteId, int pageNum, String content) async { + await _write(noteId); + server.text[noteId] = content; + server.writes.add('text:$content'); + return server.touch(noteId); + } + + @override + Future<String?> savePageInk( + String noteId, int pageNum, List<Map<String, dynamic>> strokes) async { + await _write(noteId); + server.ink[noteId] = strokes; + server.writes.add('ink:${strokes.length}'); + return server.touch(noteId); + } +} diff --git a/app/test/helpers/test_store.dart b/app/test/helpers/test_store.dart new file mode 100644 index 0000000..e23c038 --- /dev/null +++ b/app/test/helpers/test_store.dart @@ -0,0 +1,32 @@ +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:nexanote/data/database/schema.dart'; +import 'package:nexanote/services/local_note_service.dart'; + +/// Opens the app's SQLite schema at [path] (in memory by default). +/// +/// Uses the FFI factory without a background isolate, so database calls +/// complete inside widget tests' fake clock instead of needing runAsync. +/// Every call opens its own connection, even to the same file: a second +/// open of one file is what a restarted process would see. +Future<Database> openTestDatabase([String path = inMemoryDatabasePath]) { + sqfliteFfiInit(); + return databaseFactoryFfiNoIsolate.openDatabase( + path, + options: OpenDatabaseOptions( + version: Schema.version, + onCreate: Schema.onCreate, + onUpgrade: Schema.onUpgrade, + singleInstance: false, + ), + ); +} + +/// A [LocalNoteService] over [openTestDatabase]. +Future<LocalNoteService> openTestStore([ + String path = inMemoryDatabasePath, +]) async { + final service = LocalNoteService(database: await openTestDatabase(path)); + await service.initialize(); + return service; +} diff --git a/app/test/screens/note_editor_autosave_race_test.dart b/app/test/screens/note_editor_autosave_race_test.dart index 9ed11e2..63848c0 100644 --- a/app/test/screens/note_editor_autosave_race_test.dart +++ b/app/test/screens/note_editor_autosave_race_test.dart @@ -9,6 +9,8 @@ import 'package:nexanote/screens/note_editor_screen.dart'; import 'package:nexanote/services/api_client.dart' as api; import 'package:nexanote/services/app_state.dart'; +import '../helpers/test_store.dart'; + /// Backend whose text saves only complete when the test says so, so we can /// type while a save is in flight and complete saves out of order. class _SlowSaveApi extends api.ApiClient { @@ -63,11 +65,13 @@ void main() { late _SlowSaveApi backend; late AppState state; - setUp(() { + setUp(() async { SharedPreferences.setMockInitialValues({}); backend = _SlowSaveApi(); - // Not in local mode: saves go through the (slow) backend. - state = AppState(clientFactory: (_) => backend); + // Not in local mode: saves go through the (slow) backend, after being + // recorded in the local store. + state = AppState( + localService: await openTestStore(), clientFactory: (_) => backend); }); Finder body() => find.byWidgetPredicate( diff --git a/app/test/screens/note_editor_ink_save_test.dart b/app/test/screens/note_editor_ink_save_test.dart index 8267906..21d0e55 100644 --- a/app/test/screens/note_editor_ink_save_test.dart +++ b/app/test/screens/note_editor_ink_save_test.dart @@ -11,6 +11,8 @@ import 'package:nexanote/services/api_client.dart' as api; import 'package:nexanote/services/app_state.dart'; import 'package:nexanote/widgets/ink_canvas.dart'; +import '../helpers/test_store.dart'; + /// Backend whose ink saves can be held open and failed on demand. class _InkApi extends api.ApiClient { _InkApi() : super(baseUrl: 'http://ink.test'); @@ -69,10 +71,11 @@ void main() { late _InkApi backend; late AppState state; - setUp(() { + setUp(() async { SharedPreferences.setMockInitialValues({}); backend = _InkApi(); - state = AppState(clientFactory: (_) => backend); + state = AppState( + localService: await openTestStore(), clientFactory: (_) => backend); }); Future<void> pumpEditor(WidgetTester tester) async { diff --git a/app/test/screens/note_editor_restart_test.dart b/app/test/screens/note_editor_restart_test.dart new file mode 100644 index 0000000..2024bac --- /dev/null +++ b/app/test/screens/note_editor_restart_test.dart @@ -0,0 +1,285 @@ +import 'dart:io'; +import 'dart:ui' show AppExitResponse; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:nexanote/data/models/note.dart' as local; +import 'package:nexanote/screens/note_editor_screen.dart'; +import 'package:nexanote/services/api_client.dart' as api; +import 'package:nexanote/services/app_state.dart'; +import 'package:nexanote/services/local_note_service.dart'; +import 'package:nexanote/widgets/ink_canvas.dart'; + +import '../helpers/fake_backend.dart'; +import '../helpers/test_store.dart'; + +/// One run of the app: its own database connection, backend client and +/// AppState. A new [_Process] on the same database file is what the app +/// sees after being killed and started again. +class _Process { + _Process(this.state, this.client); + final AppState state; + final FakeBackendClient client; +} + +void main() { + late FakeServer server; + late Directory dir; + late String dbPath; + late List<LocalNoteService> stores; + + setUp(() { + SharedPreferences.setMockInitialValues({ + kPrefsApiUrl: 'http://fake.test', + kPrefsHasEverConnected: true, + }); + server = FakeServer(); + dir = Directory.systemTemp.createTempSync('nexanote_restart_'); + dbPath = p.join(dir.path, 'nexanote.db'); + stores = []; + }); + + tearDown(() async { + for (final store in stores) { + await store.close(); + } + dir.deleteSync(recursive: true); + }); + + /// Opening a database file does real file I/O, which only completes + /// outside the widget test's fake clock; everything after that is + /// synchronous FFI. + Future<LocalNoteService> store(WidgetTester tester) async { + final s = (await tester.runAsync(() => openTestStore(dbPath)))!; + stores.add(s); + return s; + } + + Future<_Process> start(WidgetTester tester, + {bool backendDown = false}) async { + final client = FakeBackendClient(server)..down = backendDown; + final state = AppState( + localService: await store(tester), clientFactory: (_) => client); + await state.init(); + return _Process(state, client); + } + + /// The process dies: it never reaches the backend again and its editor is + /// gone. Whatever it had not written to disk is lost. + Future<void> kill(WidgetTester tester, _Process process) async { + process.client.down = true; + await tester.pumpWidget(const SizedBox()); + } + + /// What the database file holds for server note [id], read through a + /// connection of its own. + Future<(local.Note, int)> onDisk(WidgetTester tester, String id) async { + final reader = await store(tester); + final localId = await reader.localIdForRemote(id); + final note = await reader.getNoteById(localId!); + final strokes = await reader.getStrokesForNote(localId); + return (note!, strokes.length); + } + + Future<void> pumpEditor( + WidgetTester tester, AppState state, api.Note note) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(360, 740); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(ChangeNotifierProvider<AppState>.value( + value: state, + child: MaterialApp(home: NoteEditorScreen(note: note)), + )); + await tester.pump(); + } + + Finder titleField() => find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Untitled'); + Finder body() => find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == 'Start writing...'); + + var strokeRow = 0; + setUp(() => strokeRow = 0); + Future<void> drawStroke(WidgetTester tester) async { + final origin = tester.getTopLeft(find.byType(InkCanvas)) + + Offset(100, 150 + 40.0 * strokeRow++); + final g = await tester.startGesture(origin); + await g.moveBy(const Offset(30, 0)); + await g.moveBy(const Offset(30, 0)); + await g.up(); + await tester.pump(); + } + + testWidgets( + 'a drawing made while the backend is down survives a restart and ' + 'reaches the server', (tester) async { + final first = await start(tester); + final id = server.create('Sketch', 'handwritten').id; + await pumpEditor(tester, first.state, (await first.state.openNote(id))!); + + first.client.down = true; + await drawStroke(tester); + await drawStroke(tester); + + // On disk before the server ever acknowledged it. + final (stored, strokes) = await onDisk(tester, id); + expect(strokes, 2); + expect(stored.inkPending, isTrue); + expect(server.ink[id], isNull); + + await kill(tester, first); + final second = await start(tester, backendDown: true); + final reopened = (await second.state.openNote(id))!; + await pumpEditor(tester, second.state, reopened); + expect(tester.widget<InkCanvas>(find.byType(InkCanvas)).initialStrokes, + hasLength(2)); + + second.client.down = false; + await second.state.connect(); + expect(server.ink[id], hasLength(2)); + expect((await onDisk(tester, id)).$1.inkPending, isFalse); + + // Nothing older shows up later, from either process. + await tester.pump(const Duration(minutes: 5)); + expect(server.writes, ['ink:2']); + }); + + testWidgets('the final text survives a restart and wins on the server', + (tester) async { + final first = await start(tester); + final id = server.create('Draft', 'typed').id; + await pumpEditor(tester, first.state, (await first.state.openNote(id))!); + + await tester.enterText(body(), 'version 1'); + await tester.pump(const Duration(seconds: 2)); + expect(server.text[id], 'version 1'); + + first.client.down = true; + await tester.enterText(body(), 'version finale'); + await tester.pump(const Duration(seconds: 2)); + expect((await onDisk(tester, id)).$1.typedContent, 'version finale'); + expect(server.text[id], 'version 1'); + + await kill(tester, first); + final second = await start(tester, backendDown: true); + final reopened = (await second.state.openNote(id))!; + expect(reopened.pages!.first.typedContent, 'version finale'); + await pumpEditor(tester, second.state, reopened); + expect(find.text('version finale'), findsOneWidget); + + second.client.down = false; + await second.state.connect(); + expect(server.text[id], 'version finale'); + + await tester.pump(const Duration(minutes: 5)); + expect(server.text[id], 'version finale'); + expect(server.writes.last, 'text:version finale'); + }); + + testWidgets('a new title and text both survive a restart', (tester) async { + final first = await start(tester); + final id = server.create('Old title', 'typed').id; + await pumpEditor(tester, first.state, (await first.state.openNote(id))!); + + first.client.down = true; + await tester.enterText(titleField(), 'New title'); + await tester.enterText(body(), 'new body'); + await tester.pump(const Duration(seconds: 2)); + final (stored, _) = await onDisk(tester, id); + expect(stored.title, 'New title'); + expect(stored.typedContent, 'new body'); + + await kill(tester, first); + final second = await start(tester, backendDown: true); + final reopened = (await second.state.openNote(id))!; + expect(reopened.title, 'New title'); + expect(reopened.pages!.first.typedContent, 'new body'); + + second.client.down = false; + await second.state.connect(); + expect(server.notes[id]!.title, 'New title'); + expect(server.text[id], 'new body'); + expect(second.state.notes.single.title, 'New title'); + await tester.pump(const Duration(minutes: 5)); + }); + + testWidgets( + 'text typed just before Android pauses the app is on disk when the ' + 'process is recreated', (tester) async { + final first = await start(tester); + final id = server.create('Groceries', 'typed').id; + await pumpEditor(tester, first.state, (await first.state.openNote(id))!); + + first.client.down = true; + await tester.enterText(body(), 'milk, eggs'); + // Backgrounded before the autosave debounce: the process may never run + // again after this. + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + expect((await onDisk(tester, id)).$1.typedContent, 'milk, eggs'); + + await kill(tester, first); + final second = await start(tester); + expect(server.text[id], 'milk, eggs'); + final reopened = (await second.state.openNote(id))!; + expect(reopened.pages!.first.typedContent, 'milk, eggs'); + await tester.pump(const Duration(minutes: 5)); + }); + + testWidgets( + 'a drawing made just before Android pauses the app is on disk when the ' + 'process is recreated', (tester) async { + final first = await start(tester); + final id = server.create('Sketch', 'handwritten').id; + await pumpEditor(tester, first.state, (await first.state.openNote(id))!); + + first.client.down = true; + await drawStroke(tester); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + expect((await onDisk(tester, id)).$2, 1); + + await kill(tester, first); + final second = await start(tester, backendDown: true); + final reopened = (await second.state.openNote(id))!; + expect(reopened.pages!.first.strokes, hasLength(1)); + second.client.down = false; + await second.state.connect(); + expect(server.ink[id], hasLength(1)); + await tester.pump(const Duration(minutes: 5)); + }); + + testWidgets( + 'closing the desktop window with the backend stuck keeps the edit on ' + 'disk and sends it on the next start', (tester) async { + final first = await start(tester); + final id = server.create('Plan', 'typed').id; + await pumpEditor(tester, first.state, (await first.state.openNote(id))!); + + first.client.hold = true; // every save hangs, forever + await tester.enterText(body(), 'written right before closing'); + + AppExitResponse? response; + tester.binding.handleRequestAppExit().then((r) => response = r); + await tester.pump(); + // The local write is done before the exit waits on anything else. + expect((await onDisk(tester, id)).$1.typedContent, + 'written right before closing'); + expect(response, isNull, reason: 'the backend push gets its chance'); + await tester.pump(const Duration(seconds: 6)); + expect(response, AppExitResponse.exit); + expect(server.text[id], isNull); + + await kill(tester, first); + final second = await start(tester); + expect(server.text[id], 'written right before closing'); + final reopened = (await second.state.openNote(id))!; + expect(reopened.pages!.first.typedContent, 'written right before closing'); + await tester.pump(const Duration(minutes: 5)); + }); +} diff --git a/app/test/services/local_first_test.dart b/app/test/services/local_first_test.dart new file mode 100644 index 0000000..eb7d596 --- /dev/null +++ b/app/test/services/local_first_test.dart @@ -0,0 +1,260 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:nexanote/services/app_state.dart'; +import 'package:nexanote/services/local_note_service.dart'; +import 'package:nexanote/services/sync_service.dart'; + +import '../helpers/fake_backend.dart'; +import '../helpers/test_store.dart'; + +/// A device whose local database can't be opened. +class _BrokenStore extends LocalNoteService { + @override + Future<void> initialize() async => throw StateError('no SQLite here'); +} + +void main() { + late FakeServer server; + late FakeBackendClient client; + late LocalNoteService store; + late AppState state; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + server = FakeServer(); + client = FakeBackendClient(server); + store = await openTestStore(); + state = AppState(localService: store, clientFactory: (_) => client); + await state.connect(url: 'http://fake.test'); + }); + + tearDown(() => store.close()); + + Future<String> openedNote() async { + final id = server.create('Note', 'typed').id; + await state.openNote(id); + return id; + } + + test('an edit is on disk before the backend answers', () async { + final id = await openedNote(); + client.hold = true; + + final save = state.savePageText(id, 1, 'typed'); + await pumpEventQueue(); + + expect(client.heldCount, 1); + final row = (await store.getNoteById(id))!; + expect(row.typedContent, 'typed'); + expect(row.textPending, isTrue); + + client.release(); + await save; + expect((await store.getNoteById(id))!.textPending, isFalse); + }); + + test('the backend confirming save A never marks the later edit B synced', + () async { + final id = await openedNote(); + client.hold = true; + + final saveA = state.savePageText(id, 1, 'A'); + await pumpEventQueue(); + await state.recordText(id, 'B'); // typed while A is on the wire + client.release(); + await saveA; + + final row = (await store.getNoteById(id))!; + expect(server.text[id], 'A'); + expect(row.typedContent, 'B'); + expect(row.textPending, isTrue, reason: 'B has not been sent yet'); + + client.hold = false; + await state.pushPending(id); + expect(server.text[id], 'B'); + expect((await store.getNoteById(id))!.textPending, isFalse); + }); + + test( + 'pushes requested while one runs collapse into one with the newest ' + 'version', () async { + final id = await openedNote(); + client.hold = true; + + final first = state.savePageText(id, 1, 'v1'); + await pumpEventQueue(); + final later = [ + state.savePageText(id, 1, 'v2'), + state.savePageText(id, 1, 'v3'), + ]; + await pumpEventQueue(); + client.release(); + await pumpEventQueue(); + client.release(); + await Future.wait([first, ...later]); + + expect(server.writes, ['text:v1', 'text:v3']); + }); + + test('opening a note shows a local edit the server does not have yet', + () async { + final id = await openedNote(); + client.down = true; + await state.recordTitle(id, 'Local title'); + await state.recordText(id, 'local text'); + + client.down = false; + final reopened = (await state.openNote(id))!; + + expect(reopened.title, 'Local title'); + expect(reopened.pages!.first.typedContent, 'local text'); + // Opening alone doesn't count as sent. + expect(server.text[id], isNull); + expect((await store.getNoteById(id))!.hasPendingEdits, isTrue); + }); + + test('a note opened while the backend is down comes from the local copy', + () async { + final id = await openedNote(); + await state.savePageText(id, 1, 'cached'); + + client.down = true; + final offline = (await state.openNote(id))!; + + expect(offline.id, id); + expect(offline.pages!.first.typedContent, 'cached'); + expect(state.isBackendAvailable, isFalse); + }); + + test('connecting sends edits left over from an earlier run', () async { + final id = await openedNote(); + client.down = true; + await expectLater( + state.savePageText(id, 1, 'left over'), throwsA(anything)); + + client.down = false; + await state.connect(); + + expect(server.text[id], 'left over'); + expect(await store.getNotesWithPendingEdits(), isEmpty); + }); + + test('a sync never reverts a pending edit to the server version', () async { + final id = await openedNote(); + client.down = true; + await state.recordTitle(id, 'Mine'); + + // The server copy moves on meanwhile (another device renames it). + server.touch(id, title: 'Theirs'); + client.down = false; + // Pull only: the pending title must survive the merge. + await SyncService(apiClient: client, local: store).pullRemote(); + + expect((await store.getNoteById(id))!.title, 'Mine'); + await state.syncNow(); + expect(server.notes[id]!.title, 'Mine'); + }); + + test('deleting a note drops its local copy so nothing is pushed later', + () async { + final id = await openedNote(); + client.down = true; + await state.recordText(id, 'unsent'); + client.down = false; + + await state.deleteNote(id); + await state.connect(); + + expect(await store.localIdForRemote(id), isNull); + expect(server.writes, isEmpty); + }); + + test('without a usable local store, saves still go to the backend', () async { + final s = + AppState(localService: _BrokenStore(), clientFactory: (_) => client); + await s.connect(url: 'http://fake.test'); + final id = server.create('Note', 'typed').id; + + await s.savePageText(id, 1, 'straight through'); + await s.updateNoteTitle(id, 'Renamed'); + + expect(server.text[id], 'straight through'); + expect(server.notes[id]!.title, 'Renamed'); + }); + + test( + 'opening a note while its own push is on the wire keeps the local ' + 'version', () async { + final id = await openedNote(); + client.hold = true; + final push = state.savePageText(id, 1, 'mine'); + await pumpEventQueue(); + + // The server still has the old text: the push hasn't landed yet. + final reopened = (await state.openNote(id))!; + expect(reopened.pages!.first.typedContent, 'mine'); + expect((await store.getNoteById(id))!.typedContent, 'mine'); + + client.release(); + await push; + expect(server.text[id], 'mine'); + }); + + test( + 'a server copy read before our push landed never replaces the local ' + 'one', () async { + final id = await openedNote(); + client.down = true; + await state.recordText(id, 'mine'); + client.down = false; + + // The read leaves the server before the push lands, its answer comes + // back after the push was confirmed. + client.holdReads = true; + final open = state.openNote(id); + await pumpEventQueue(); + await state.pushPending(id); + expect(server.text[id], 'mine'); + client.releaseRead(); + final reopened = (await open)!; + + expect(reopened.pages!.first.typedContent, 'mine'); + expect((await store.getNoteById(id))!.typedContent, 'mine'); + }); + + test( + 'an edit to a note deleted on the server is kept and uploaded as a ' + 'note of its own', () async { + final id = await openedNote(); + client.down = true; + await state.recordText(id, 'kept'); + client.down = false; + server.notes.remove(id); // deleted from another device + + await expectLater(state.pushPending(id), throwsA(anything)); + final row = (await store.getNoteById(id))!; + expect(row.remoteId, isNull); + expect(row.syncStatus, 'local_only'); + expect(row.typedContent, 'kept'); + + await state.syncNow(); + final recreated = server.notes.values.single; + expect(server.text[recreated.id], 'kept'); + }); + + test('one note failing to push does not hold back the others', () async { + final gone = await openedNote(); + final other = await openedNote(); + client.down = true; + await state.recordText(gone, 'a'); + await state.recordText(other, 'b'); + client.down = false; + server.notes.remove(gone); + + await state.connect(); + + expect(server.text[other], 'b'); + expect(state.syncError, contains('Could not upload edits')); + }); +} From 2f675ec54268f031e0bc04f2f2b72014f075c05f Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 14:35:02 +0000 Subject: [PATCH 42/44] test: cover a push dying between text and ink, and five syncs in a row --- app/test/helpers/fake_backend.dart | 4 ++ app/test/services/local_first_test.dart | 49 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/app/test/helpers/fake_backend.dart b/app/test/helpers/fake_backend.dart index 57f22f0..946dea4 100644 --- a/app/test/helpers/fake_backend.dart +++ b/app/test/helpers/fake_backend.dart @@ -63,6 +63,9 @@ class FakeBackendClient extends api.ApiClient { final FakeServer server; bool down = false; bool hold = false; + + /// Drawing saves fail while text and title saves go through. + bool failInk = false; final _held = <Completer<void>>[]; int get heldCount => _held.length; @@ -174,6 +177,7 @@ class FakeBackendClient extends api.ApiClient { Future<String?> savePageInk( String noteId, int pageNum, List<Map<String, dynamic>> strokes) async { await _write(noteId); + if (failInk) throw _unreachable; server.ink[noteId] = strokes; server.writes.add('ink:${strokes.length}'); return server.touch(noteId); diff --git a/app/test/services/local_first_test.dart b/app/test/services/local_first_test.dart index eb7d596..a5641dc 100644 --- a/app/test/services/local_first_test.dart +++ b/app/test/services/local_first_test.dart @@ -257,4 +257,53 @@ void main() { expect(server.text[other], 'b'); expect(state.syncError, contains('Could not upload edits')); }); + + test('a push that dies between the text and the drawing resends only the ' + 'drawing', () async { + final id = server.create('Sketch', 'mixed').id; + await state.openNote(id); + client.down = true; + await state.recordText(id, 'caption'); + await state.recordInk(id, [ + { + 'id': 's1', + 'points': [ + {'x': 1, 'y': 2}, + ], + }, + ]); + client.down = false; + client.failInk = true; + + await expectLater(state.pushPending(id), throwsA(anything)); + var row = (await store.getNoteById(id))!; + expect(row.textPending, isFalse); + expect(row.inkPending, isTrue); + + client.failInk = false; + await state.pushPending(id); + expect(server.writes, ['text:caption', 'ink:1']); + row = (await store.getNoteById(id))!; + expect(row.hasPendingEdits, isFalse); + expect(row.syncStatus, 'synced'); + }); + + test('syncing five times in a row sends an edit once and adds nothing', + () async { + final id = await openedNote(); + client.down = true; + await state.recordText(id, 'offline edit'); + client.down = false; + + for (var i = 0; i < 5; i++) { + await state.syncNow(); + } + + expect(server.writes, ['text:offline edit']); + expect(server.notes, hasLength(1)); + final rows = (await store.exportAllData()).notes; + expect(rows, hasLength(1)); + expect(rows.single.typedContent, 'offline edit'); + expect(rows.single.syncStatus, 'synced'); + }); } From 62829586df66bbd428101fc952629f016d5ecb90 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 14:47:43 +0000 Subject: [PATCH 43/44] fix: send emptied text and drawing when resuming an upload A resume can now write into a note that already holds our earlier upload, so skipping empty parts left text or strokes the user had removed on the server while the row was marked synced. --- app/lib/services/sync_service.dart | 20 +++++++++++++++----- app/test/services/sync_service_test.dart | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 5cfd1e5..4c6f68e 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -126,7 +126,7 @@ class SyncService { // fails too, the next push resumes into it (same proof rules) // instead of leaving it empty and creating another one. await _local.setNoteRemoteId(note.id, copy.id, copy.updatedAt); - await _uploadContent(note, copy.id); + await _uploadContent(note, copy.id, intoFreshNote: true); await _local.markNoteSyncedIfUnchanged(note.id, note.updatedAt); notes++; continue; @@ -146,8 +146,10 @@ class SyncService { target = created.id; await _local.setNoteRemoteId(note.id, target, created.updatedAt); notes++; + await _uploadContent(note, target, intoFreshNote: true); + } else { + await _uploadContent(note, target, intoFreshNote: false); } - await _uploadContent(note, target); await _local.markNoteSyncedIfUnchanged(note.id, note.updatedAt); } return PushCounts(notebooks: notebooks, notes: notes); @@ -158,13 +160,21 @@ class SyncService { /// next write fails, a resume can still tell "only our own writes since" /// apart from "changed by someone else" (and not make an offline copy of /// a note that is just half uploaded). - Future<void> _uploadContent(Note note, String remoteId) async { - if (note.typedContent.isNotEmpty) { + /// + /// Empty parts are skipped only [intoFreshNote]: a resumed upload may + /// already have put text or strokes there that the user has removed + /// since. + Future<void> _uploadContent( + Note note, + String remoteId, { + required bool intoFreshNote, + }) async { + if (note.typedContent.isNotEmpty || !intoFreshNote) { final version = await _api.savePageText(remoteId, 1, note.typedContent); await _local.setNoteRemoteId(note.id, remoteId, version); } final strokes = await _local.getStrokesForNote(note.id); - if (strokes.isNotEmpty) { + if (strokes.isNotEmpty || !intoFreshNote) { final version = await _api.savePageInk( remoteId, 1, strokes.map(inkJsonFromStroke).toList()); await _local.setNoteRemoteId(note.id, remoteId, version); diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index 690a9ac..934209b 100644 --- a/app/test/services/sync_service_test.dart +++ b/app/test/services/sync_service_test.dart @@ -380,6 +380,20 @@ void main() { expect(row.remoteId, remoteId); }); + test('text emptied before a resume is emptied on the server too', + () async { + final remoteId = await textUploadedInkFailed(); + final row = (await local.exportAllData()).notes.single; + await local.updateNoteContent(row.id, ''); + await local.replaceStrokesForNote(row.id, const []); + + await sync.pushLocal(); + + expect(fakeApi.savedText[remoteId], ''); + expect(fakeApi.savedInk[remoteId], isEmpty); + expect(copyTitles(), isEmpty); + }); + test('repeated failed resumes never make more than one copy', () async { final remoteId = await textUploadedInkFailed(); fakeApi.editOnServer(remoteId, text: 'edited online'); From 65229d07357411c91f5b839c5819cee25f0c39f2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 7 Oct 2026 14:47:43 +0000 Subject: [PATCH 44/44] fix: never send edits made over content the device doesn't have Rows created by a pull hold a note's metadata but not its text or drawing (has_content = 0). Text or ink typed over such a row in local mode was pushed as the note's content and wiped the server copy. Those blind edits are now split off into an "(offline copy)" note, and such a row no longer opens offline as an empty page. Also from review: - a half-uploaded row is the local copy of its remote note, so opening that note no longer creates a second row and steals its strokes; stroke ids shared by two notes are stored per note - pre-v4 local edits kept as `modified` are marked pending by the migration instead of being overwritten by the next refresh - pending edits are pushed when the backend comes back and on Sync, and a part the server refuses doesn't hold back the others - a delete resolves the local row before the request, so a push failing on that note can't turn it into a note to upload again --- app/lib/data/database/schema.dart | 27 +++- app/lib/data/models/note.dart | 18 +++ .../data/repositories/note_repository.dart | 111 ++++++++++++--- app/lib/services/app_state.dart | 128 +++++++++++++----- app/lib/services/local_note_service.dart | 15 +- app/lib/services/sync_service.dart | 2 + app/test/data/database/app_database_test.dart | 64 ++++++--- .../repositories/note_repository_test.dart | 51 ++++++- app/test/helpers/fake_backend.dart | 7 + app/test/services/local_first_test.dart | 67 +++++++++ 10 files changed, 409 insertions(+), 81 deletions(-) diff --git a/app/lib/data/database/schema.dart b/app/lib/data/database/schema.dart index bcc76f7..1b9191f 100644 --- a/app/lib/data/database/schema.dart +++ b/app/lib/data/database/schema.dart @@ -20,7 +20,8 @@ class Schema { /// `_synced_rev`): every local edit bumps its part's revision, and a part /// is waiting for the server while its revision is ahead of the last one /// the server confirmed. That makes SQLite the durable copy of an edit - /// until the backend has acknowledged it, even in connected mode. + /// until the backend has acknowledged it, even in connected mode. Also + /// adds `has_content` and `blind_edit` (see Note.hasContent). static const int version = 4; static const String _createNotebooks = ''' @@ -60,12 +61,17 @@ class Schema { text_synced_rev INTEGER NOT NULL DEFAULT 0, ink_rev INTEGER NOT NULL DEFAULT 0, ink_synced_rev INTEGER NOT NULL DEFAULT 0, + has_content INTEGER NOT NULL DEFAULT 1, + blind_edit INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (notebook_id) REFERENCES notebooks(id) ) '''; + /// Every column added by version 4. + static const v4Columns = [...editRevisionColumns, 'has_content', 'blind_edit']; + static const editRevisionColumns = [ 'title_rev', 'title_synced_rev', @@ -156,6 +162,25 @@ class Schema { await db.execute( 'ALTER TABLE notes ADD COLUMN $column INTEGER NOT NULL DEFAULT 0'); } + await db.execute( + 'ALTER TABLE notes ADD COLUMN has_content INTEGER NOT NULL DEFAULT 1'); + await db.execute( + 'ALTER TABLE notes ADD COLUMN blind_edit INTEGER NOT NULL DEFAULT 0'); + // Rows a pull created carry the server id as their own id and hold + // no text or drawing. + await db.execute( + 'UPDATE notes SET has_content = 0 WHERE remote_id = id ' + "AND sync_status != 'local_only'"); + // Older builds left local edits of server notes as `modified` without + // saying which part changed: treat every part as waiting to be sent, + // so they are neither lost to a refresh nor marked synced unsent. + await db.execute( + 'UPDATE notes SET title_rev = 1, text_rev = 1, ink_rev = 1 ' + "WHERE sync_status = 'modified' AND remote_id IS NOT NULL"); + // Those edits, when made on a row without content, were made blind. + await db.execute( + 'UPDATE notes SET blind_edit = 1 ' + "WHERE has_content = 0 AND sync_status = 'modified'"); } } } diff --git a/app/lib/data/models/note.dart b/app/lib/data/models/note.dart index cac44ca..136e85d 100644 --- a/app/lib/data/models/note.dart +++ b/app/lib/data/models/note.dart @@ -41,6 +41,13 @@ enum NotePart { /// whose revision is ahead is a local edit not yet on the server; the local /// row is its only durable copy until then. /// +/// [hasContent] is false for rows that only hold a note's metadata (what a +/// pull brings): their text and drawing were never downloaded, so they +/// are not the note's content. [blindEdit] marks text or ink edited into +/// such a row in local mode, on what looked like an empty page: sending +/// that into the server note would wipe its real content, so it becomes a +/// note of its own instead. +/// /// Mirrors Note in nexanote/models/note.py. class Note { final String id; @@ -62,6 +69,8 @@ class Note { final int textSyncedRev; final int inkRev; final int inkSyncedRev; + final bool hasContent; + final bool blindEdit; final DateTime createdAt; final DateTime updatedAt; @@ -85,6 +94,8 @@ class Note { this.textSyncedRev = 0, this.inkRev = 0, this.inkSyncedRev = 0, + this.hasContent = true, + this.blindEdit = false, required this.createdAt, required this.updatedAt, }); @@ -112,6 +123,8 @@ class Note { textSyncedRev: (map['text_synced_rev'] as int?) ?? 0, inkRev: (map['ink_rev'] as int?) ?? 0, inkSyncedRev: (map['ink_synced_rev'] as int?) ?? 0, + hasContent: ((map['has_content'] as int?) ?? 1) == 1, + blindEdit: ((map['blind_edit'] as int?) ?? 0) == 1, createdAt: DateTime.parse(map['created_at'] as String), updatedAt: DateTime.parse(map['updated_at'] as String), ); @@ -138,6 +151,8 @@ class Note { 'text_synced_rev': textSyncedRev, 'ink_rev': inkRev, 'ink_synced_rev': inkSyncedRev, + 'has_content': hasContent ? 1 : 0, + 'blind_edit': blindEdit ? 1 : 0, 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), }; @@ -156,6 +171,7 @@ class Note { String? syncStatus, String? remoteId, String? remotePath, + bool? hasContent, DateTime? updatedAt, }) { return Note( @@ -179,6 +195,8 @@ class Note { textSyncedRev: textSyncedRev, inkRev: inkRev, inkSyncedRev: inkSyncedRev, + hasContent: hasContent ?? this.hasContent, + blindEdit: blindEdit, createdAt: createdAt, updatedAt: updatedAt ?? this.updatedAt, ); diff --git a/app/lib/data/repositories/note_repository.dart b/app/lib/data/repositories/note_repository.dart index aa8264c..fbfb583 100644 --- a/app/lib/data/repositories/note_repository.dart +++ b/app/lib/data/repositories/note_repository.dart @@ -171,22 +171,28 @@ class NoteRepository { /// an earlier read would put back stale copies of the other fields. /// /// Returns the number of rows changed: 0 when note [id] doesn't exist. + /// + /// [blind] says the edit was made without the note's real content at hand + /// (local mode): on a row without content it sets [Note.blindEdit]. Future<int> updateNoteFields( String id, [ Map<String, Object?> fields = const {}, NotePart? edited, + bool blind = false, ]) => - _updateNoteFields(_db, id, fields, edited); + _updateNoteFields(_db, id, fields, edited, blind); static Future<int> _updateNoteFields( DatabaseExecutor db, String id, Map<String, Object?> fields, - NotePart? edited, - ) async { + NotePart? edited, [ + bool blind = false, + ]) async { final assignments = [ for (final column in fields.keys) '$column = ?', if (edited != null) '${edited.revColumn} = ${edited.revColumn} + 1', + if (blind) 'blind_edit = CASE has_content WHEN 0 THEN 1 ELSE blind_edit END', 'updated_at = ?', "sync_status = CASE sync_status WHEN 'synced' THEN 'modified' " 'ELSE sync_status END', @@ -205,9 +211,11 @@ class NoteRepository { /// bumps the ink revision in one transaction, so the stored drawing and /// its revision always match. Returns the new ink revision, or null when /// note [id] doesn't exist (nothing is written then). - Future<int?> recordInk(String id, List<Stroke> strokes) { + Future<int?> recordInk(String id, List<Stroke> strokes, + {bool blind = false}) { return _db.transaction((txn) async { - if (await _updateNoteFields(txn, id, const {}, NotePart.ink) == 0) { + if (await _updateNoteFields(txn, id, const {}, NotePart.ink, blind) == + 0) { return null; } await _replaceStrokes(txn, id, strokes); @@ -252,9 +260,10 @@ class NoteRepository { }); } - /// Id of the local row that holds remote note [remoteId] for connected - /// mode, or null. Rows still waiting for their first upload (`local_only`) - /// don't count: SyncService.pushLocal owns those and their content. + /// Id of the local row that holds remote note [remoteId], or null. A row + /// still waiting to finish its first upload (`local_only`) counts too, + /// after any other: it is the local copy of that note, and edits recorded + /// into it are uploaded by SyncService.pushLocal with its usual checks. /// /// Falls back to a row whose own id is [remoteId]: notes listed from the /// local store while the backend is down are opened by their local id. @@ -265,7 +274,7 @@ class NoteRepository { DatabaseExecutor db, String remoteId) async { final rows = await db.rawQuery( 'SELECT id FROM notes WHERE remote_id = ? ' - "AND sync_status != 'local_only' ORDER BY id = remote_id DESC LIMIT 1", + "ORDER BY sync_status = 'local_only', id = remote_id DESC LIMIT 1", [remoteId], ); if (rows.isNotEmpty) return rows.first['id'] as String; @@ -281,9 +290,11 @@ class NoteRepository { /// /// Parts with a local edit still waiting for the server keep the local /// version. With [refresh] false an existing copy is left as it is (used - /// to make sure a row exists before recording an edit). Rows with older - /// local changes that aren't tracked per part (`modified` without a - /// pending revision, `conflict`, `local_only`) are never overwritten. + /// to make sure a row exists before recording an edit). Rows that aren't + /// a plain copy of the server note (`conflict`, `local_only`) are never + /// overwritten. A row without content (see [Note.hasContent]) gets the + /// server's; text or ink edited into it first are split off into a note + /// of their own, since they were made without seeing the real content. Future<String> cacheRemoteNote( Note remote, List<Stroke> strokes, { @@ -299,10 +310,16 @@ class NoteRepository { if (!refresh) return localId; final rows = await txn.query('notes', where: 'id = ?', whereArgs: [localId], limit: 1); - final local = Note.fromMap(rows.first); + var local = Note.fromMap(rows.first); final tracked = local.syncStatus == 'synced' || (local.syncStatus == 'modified' && local.hasPendingEdits); if (!tracked) return localId; + if (local.blindEdit) { + await _splitOffContentlessEdits(txn, local); + local = Note.fromMap((await txn.query('notes', + where: 'id = ?', whereArgs: [localId], limit: 1)) + .first); + } await txn.update( 'notes', { @@ -312,6 +329,7 @@ class NoteRepository { 'is_pinned': remote.isPinned ? 1 : 0, if (!local.titlePending) 'title': remote.title, if (!local.textPending) 'typed_content': remote.typedContent, + 'has_content': 1, if (!local.hasPendingEdits) ...{ 'sync_status': 'synced', 'updated_at': remote.updatedAt.toIso8601String(), @@ -325,6 +343,51 @@ class NoteRepository { }); } + /// Text or ink edited blind into row [id] (see [Note.blindEdit]) can't be + /// sent into the server note: they were made on an empty page and would + /// wipe what is really there. They are split + /// off into a new note of their own, uploaded like one taken offline. + /// Returns whether anything was split off. + Future<bool> splitOffContentlessEdits(String id) { + return _db.transaction((txn) async { + final rows = + await txn.query('notes', where: 'id = ?', whereArgs: [id], limit: 1); + if (rows.isEmpty) return false; + return _splitOffContentlessEdits(txn, Note.fromMap(rows.first)); + }); + } + + Future<bool> _splitOffContentlessEdits(Transaction txn, Note row) async { + if (!row.blindEdit) return false; + if (!(row.textPending || row.inkPending)) { + await txn.update('notes', {'blind_edit': 0}, + where: 'id = ?', whereArgs: [row.id]); + return false; + } + final now = DateTime.now().toUtc(); + final copy = Note( + id: _uuid.v4(), + notebookId: row.notebookId, + title: '${row.title} (offline copy)', + noteType: row.noteType, + tags: row.tags, + typedContent: row.textPending ? row.typedContent : '', + createdAt: now, + updatedAt: now, + ); + await txn.insert('notes', copy.toMap()); + if (row.inkPending) { + await txn.update('strokes', {'note_id': copy.id}, + where: 'note_id = ?', whereArgs: [row.id]); + } + await txn.rawUpdate( + "UPDATE notes SET typed_content = '', text_synced_rev = text_rev, " + 'ink_synced_rev = ink_rev, blind_edit = 0 WHERE id = ?', + [row.id], + ); + return true; + } + /// Applies a note's metadata from a pull to local row [localId]. Decided /// against the row as it is now, not as an earlier snapshot saw it: a row /// with an edit still waiting for the server, a row not uploaded yet, or a @@ -517,6 +580,9 @@ class NoteRepository { await _db.transaction((txn) => _replaceStrokes(txn, noteId, strokes)); } + /// Stroke ids are a global primary key. A stroke whose id another note + /// already uses (the same drawing in two notes) is stored under an id of + /// its own for this note, never moved away from the other one. static Future<void> _replaceStrokes( DatabaseExecutor txn, String noteId, @@ -537,17 +603,26 @@ class NoteRepository { } await txn.delete('strokes', where: 'note_id = ?', whereArgs: [noteId]); + final taken = <String>{}; + final ids = strokes.map((s) => s.id).toList(); + for (var i = 0; i < ids.length; i += 500) { + final chunk = ids.sublist(i, i + 500 > ids.length ? ids.length : i + 500); + final rows = await txn.query('strokes', + columns: ['id'], + where: 'id IN (${List.filled(chunk.length, '?').join(', ')})', + whereArgs: chunk); + taken.addAll(rows.map((r) => r['id'] as String)); + } for (final stroke in strokes) { + final id = taken.contains(stroke.id) ? '${stroke.id}@$noteId' : stroke.id; await txn.insert( 'strokes', - {...stroke.toMap(), 'note_id': noteId}, + {...stroke.toMap(), 'id': id, 'note_id': noteId}, conflictAlgorithm: ConflictAlgorithm.replace, ); + await txn.delete('stroke_points', where: 'stroke_id = ?', whereArgs: [id]); for (var i = 0; i < stroke.points.length; i++) { - await txn.insert( - 'stroke_points', - stroke.points[i].toMap(stroke.id, i), - ); + await txn.insert('stroke_points', stroke.points[i].toMap(id, i)); } } } diff --git a/app/lib/services/app_state.dart b/app/lib/services/app_state.dart index 0c79ecd..fb2fbc3 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -321,8 +323,11 @@ class AppState extends ChangeNotifier { try { // Notes taken offline before (or while) a server was configured are // uploaded so nothing created in local mode is lost on the switch. - await _uploadPendingLocalNotes(); + // Edits saved on this device first: one made on a note whose + // content was never downloaded becomes a note of its own, which + // the upload right after then sends. await _pushEditsSavedOnDevice(); + await _uploadPendingLocalNotes(); await loadNotebooks(); // The upload re-keys notebooks to their server ids, and a notebook // may be gone on this server: don't keep filing new notes under an @@ -373,6 +378,9 @@ class AppState extends ChangeNotifier { _isBackendAvailable = true; _isConnected = true; _backendErrorMessage = null; + // Back online: send what was saved on this device meanwhile, also for + // notes whose editor is closed and gave up retrying. + unawaited(_pushEditsSavedOnDevice()); return true; } @@ -664,6 +672,9 @@ class AppState extends ChangeNotifier { notifyListeners(); return; } + // Found before the delete: a push failing on the deleted note may + // unlink the row from it meanwhile. + final localId = await _localIdOrNull(id); try { await client.deleteNote(id); _notes = [..._notes.where((n) => n.id != id)]; @@ -674,7 +685,7 @@ class AppState extends ChangeNotifier { await _handleBackendFailure(e); rethrow; } - await _dropLocalCopy(id); + await _dropLocalCopy(id, localId); } /// Saves the note's title: durably on this device first, then to the @@ -730,7 +741,7 @@ class AppState extends ChangeNotifier { Future<void> recordText(String noteId, String content) async { if (_localMode) { await _ensureLocalReady(); - await _localService.updateNoteContent(noteId, content); + await _localService.updateNoteContent(noteId, content, blind: true); return; } await _recordConnected( @@ -767,7 +778,7 @@ class AppState extends ChangeNotifier { if (_localMode) { await _ensureLocalReady(); if (superseded()) return; - await _localService.recordInk(noteId, toLocal(noteId)); + await _localService.recordInk(noteId, toLocal(noteId), blind: true); _unstageInk(noteId, ticket); return; } @@ -868,7 +879,14 @@ class AppState extends ChangeNotifier { } Future<void> _pushOnce(String localId) async { - final note = await _localService.getNoteById(localId); + var note = await _localService.getNoteById(localId); + if (note != null && note.blindEdit) { + // Text or ink typed over a note whose content this device never had + // would wipe the server's: they go to a note of their own instead. + if (await _localService.splitOffContentlessEdits(localId)) { + note = await _localService.getNoteById(localId); + } + } final remoteId = note?.remoteId; if (note == null || remoteId == null || @@ -876,44 +894,68 @@ class AppState extends ChangeNotifier { !note.hasPendingEdits) { return; } + final pushed = note; + // Each part on its own: one the server refuses (say a 413 for a huge + // text) doesn't keep the others from going through. + Object? failure; + Future<void> part(Future<void> Function() send) async { + if (failure is api.ApiException && + (failure as api.ApiException).statusCode == 404) { + return; + } + try { + await send(); + } catch (e) { + failure ??= e; + } + } + var titleSent = false; - try { - if (note.titlePending) { - await client.updateNote(remoteId, title: note.title); + if (pushed.titlePending) { + await part(() async { + await client.updateNote(remoteId, title: pushed.title); await _localService.markPartSynced( - localId, local.NotePart.title, note.titleRev); + localId, local.NotePart.title, pushed.titleRev); titleSent = true; - } - if (note.textPending) { - await client.savePageText(remoteId, 1, note.typedContent); + }); + } + if (pushed.textPending) { + await part(() async { + await client.savePageText(remoteId, 1, pushed.typedContent); await _localService.markPartSynced( - localId, local.NotePart.text, note.textRev); - } - if (note.inkPending) { + localId, local.NotePart.text, pushed.textRev); + }); + } + if (pushed.inkPending) { + await part(() async { final ink = await _localService.readInk(localId); - if (ink != null) { - await client.savePageInk( - remoteId, 1, ink.strokes.map(inkJsonFromStroke).toList()); - await _localService.markPartSynced( - localId, local.NotePart.ink, ink.rev); - // The editor stages by the id it opened the note with. - for (final key in {remoteId, localId}) { - final stagedRev = _stagedInk[key]?.rev; - if (stagedRev != null && stagedRev <= ink.rev) { - _stagedInk.remove(key); - } + if (ink == null) return; + await client.savePageInk( + remoteId, 1, ink.strokes.map(inkJsonFromStroke).toList()); + await _localService.markPartSynced( + localId, local.NotePart.ink, ink.rev); + // The editor stages by the id it opened the note with. + for (final key in {remoteId, localId}) { + final stagedRev = _stagedInk[key]?.rev; + if (stagedRev != null && stagedRev <= ink.rev) { + _stagedInk.remove(key); } } - } - } catch (e) { - if (e is api.ApiException && e.statusCode == 404) { + }); + } + final error = failure; + if (error != null) { + if (error is api.ApiException && error.statusCode == 404) { // Deleted on the server meanwhile: keep the edits as a note of its // own, uploaded like one taken offline, rather than retrying into // a note that is gone. await _localService.detachFromRemote(localId); + _stagedInk.remove(remoteId); + _stagedInk.remove(localId); } - await _handleBackendFailure(e); - rethrow; + await _handleBackendFailure(error); + if (titleSent) await loadNotes(notebookId: _selectedNotebook?.id); + throw error; } if (_markBackendAvailable()) notifyListeners(); if (titleSent) await loadNotes(notebookId: _selectedNotebook?.id); @@ -998,6 +1040,7 @@ class AppState extends ChangeNotifier { isPinned: n?.isPinned ?? false, typedContent: page?.typedContent ?? '', syncStatus: 'synced', + hasContent: n?.pages != null, createdAt: parse(n?.createdAt), updatedAt: parse(n?.updatedAt), ); @@ -1069,7 +1112,8 @@ class AppState extends ChangeNotifier { final localId = await _localService.localIdForRemote(id); if (localId == null) return null; final row = await _localService.getNoteById(localId); - if (row == null || row.isDeleted) return null; + // A row without content would open as an empty page. + if (row == null || row.isDeleted || !row.hasContent) return null; final strokes = await _localService.getStrokesForNote(localId); return _toApiNoteWithContent(row, strokes: strokes, id: id); } catch (e) { @@ -1078,12 +1122,23 @@ class AppState extends ChangeNotifier { } } - /// Forgets the local copy of a note deleted on the server. - Future<void> _dropLocalCopy(String id) async { + Future<String?> _localIdOrNull(String id) async { + try { + await _ensureLocalReady(); + return await _localService.localIdForRemote(id); + } catch (_) { + return null; + } + } + + /// Forgets the local copy ([localId]) of note [id], deleted on the server. + Future<void> _dropLocalCopy(String id, String? localId) async { _knownNotes.remove(id); + _stagedInk.remove(id); + if (localId == null) return; + _stagedInk.remove(localId); try { - final localId = await _localService.localIdForRemote(id); - if (localId != null) await _localService.hardDeleteNote(localId); + await _localService.hardDeleteNote(localId); } catch (e) { debugPrint('Could not drop the local copy of note $id: $e'); } @@ -1115,6 +1170,7 @@ class AppState extends ChangeNotifier { } _beginSync(); try { + await _pushEditsSavedOnDevice(); final result = await client.triggerSync(); final summary = result['summary'] ?? 'Sync complete'; _markBackendAvailable(); diff --git a/app/lib/services/local_note_service.dart b/app/lib/services/local_note_service.dart index 636ef0f..8edf2ae 100644 --- a/app/lib/services/local_note_service.dart +++ b/app/lib/services/local_note_service.dart @@ -64,13 +64,18 @@ class LocalNoteService { /// They return the number of rows changed (0: no such note). Future<int> updateNoteTitle(String id, String title) => _repo.updateNoteFields(id, {'title': title}, NotePart.title); - Future<int> updateNoteContent(String id, String typedContent) => _repo - .updateNoteFields(id, {'typed_content': typedContent}, NotePart.text); + /// [blind]: made without the note's real content at hand (local mode), + /// see Note.blindEdit. + Future<int> updateNoteContent(String id, String typedContent, + {bool blind = false}) => + _repo.updateNoteFields( + id, {'typed_content': typedContent}, NotePart.text, blind); /// Replaces the note's drawing and records it as a local edit, atomically. /// Returns the new ink revision, or null when there is no such note. - Future<int?> recordInk(String id, List<Stroke> strokes) => - _repo.recordInk(id, strokes); + Future<int?> recordInk(String id, List<Stroke> strokes, + {bool blind = false}) => + _repo.recordInk(id, strokes, blind: blind); /// The note's current drawing with the ink revision it belongs to. Future<({int rev, List<Stroke> strokes})?> readInk(String id) => @@ -89,6 +94,8 @@ class LocalNoteService { Future<List<Note>> getNotesWithPendingEdits() => _repo.getNotesWithPendingEdits(); Future<void> detachFromRemote(String id) => _repo.detachFromRemote(id); + Future<bool> splitOffContentlessEdits(String id) => + _repo.splitOffContentlessEdits(id); // Pull bookkeeping used by SyncService. Future<void> applyPulledNote(String localId, Note pulled) => diff --git a/app/lib/services/sync_service.dart b/app/lib/services/sync_service.dart index 4c6f68e..1f29892 100644 --- a/app/lib/services/sync_service.dart +++ b/app/lib/services/sync_service.dart @@ -375,6 +375,8 @@ class SyncService { syncStatus: 'synced', remoteId: n.id, remotePath: derivedPath ?? _derivedRemotePath(n.id), + // A pull lists notes without their pages. + hasContent: false, createdAt: created, updatedAt: updated, ); diff --git a/app/test/data/database/app_database_test.dart b/app/test/data/database/app_database_test.dart index fbcb5ca..8c6ffa4 100644 --- a/app/test/data/database/app_database_test.dart +++ b/app/test/data/database/app_database_test.dart @@ -170,7 +170,7 @@ void main() { final v2 = await openDatabase(inMemoryDatabasePath, version: 1, onCreate: (db, _) => Schema.onCreate(db, 1)); await v2.execute('ALTER TABLE notes DROP COLUMN remote_baseline'); - for (final column in Schema.editRevisionColumns) { + for (final column in Schema.v4Columns) { await v2.execute('ALTER TABLE notes DROP COLUMN $column'); } final now = DateTime.utc(2024, 1, 1).toIso8601String(); @@ -196,32 +196,54 @@ void main() { 'they were', () async { final v3 = await openDatabase(inMemoryDatabasePath, version: 1, onCreate: (db, _) => Schema.onCreate(db, 1)); - for (final column in Schema.editRevisionColumns) { + for (final column in Schema.v4Columns) { await v3.execute('ALTER TABLE notes DROP COLUMN $column'); } final now = DateTime.utc(2024, 1, 1).toIso8601String(); - await v3.insert('notes', { - 'id': 'n', - 'title': 'Kept', - 'typed_content': 'body', - 'remote_id': 'srv-1', - 'remote_baseline': now, - 'sync_status': 'local_only', - 'created_at': now, - 'updated_at': now, - }); + Future<void> insert(String id, String? remoteId, String status) => + v3.insert('notes', { + 'id': id, + 'title': 'Kept', + 'typed_content': 'body', + 'remote_id': remoteId, + 'remote_baseline': now, + 'sync_status': status, + 'created_at': now, + 'updated_at': now, + }); + await insert('uploading', 'srv-1', 'local_only'); + await insert('pulled', 'pulled', 'synced'); + await insert('pulled-edited', 'pulled-edited', 'modified'); + await insert('uploaded-edited', 'srv-2', 'modified'); await Schema.onUpgrade(v3, 3, Schema.version); - final row = Note.fromMap( - (await v3.query('notes', where: 'id = ?', whereArgs: ['n'])).first); - expect(row.title, 'Kept'); - expect(row.typedContent, 'body'); - expect(row.syncStatus, 'local_only'); - expect(row.remoteBaseline, now); - // Older rows carry no per-part revision: nothing looks pending, and - // what they still have to upload stays with pushLocal via local_only. - expect(row.hasPendingEdits, isFalse); + Future<Note> row(String id) async => Note.fromMap( + (await v3.query('notes', where: 'id = ?', whereArgs: [id])).first); + final uploading = await row('uploading'); + expect(uploading.title, 'Kept'); + expect(uploading.typedContent, 'body'); + expect(uploading.syncStatus, 'local_only'); + expect(uploading.remoteBaseline, now); + // What it still has to upload stays with pushLocal via local_only. + expect(uploading.hasPendingEdits, isFalse); + expect(uploading.hasContent, isTrue); + + // A pulled row never had the note's text or drawing. + final pulled = await row('pulled'); + expect(pulled.hasContent, isFalse); + expect(pulled.hasPendingEdits, isFalse); + + // Local edits older builds kept as `modified` stay pending instead of + // being overwritten by the next refresh; on a pulled row they were + // made blind. + final pulledEdited = await row('pulled-edited'); + expect(pulledEdited.hasPendingEdits, isTrue); + expect(pulledEdited.blindEdit, isTrue); + final uploadedEdited = await row('uploaded-edited'); + expect(uploadedEdited.hasPendingEdits, isTrue); + expect(uploadedEdited.hasContent, isTrue); + expect(uploadedEdited.blindEdit, isFalse); await v3.close(); }); }); diff --git a/app/test/data/repositories/note_repository_test.dart b/app/test/data/repositories/note_repository_test.dart index 1801a16..51ffaae 100644 --- a/app/test/data/repositories/note_repository_test.dart +++ b/app/test/data/repositories/note_repository_test.dart @@ -364,12 +364,61 @@ void main() { await repo.updateNoteFields( note.id, {'typed_content': 'only copy'}, NotePart.text); await repo.setNoteRemoteId(note.id, 'srv-8', null); // still local_only + await repo.replaceStrokesForNote(note.id, [stroke('s1', note.id)]); final id = await repo.cacheRemoteNote( serverCopy('srv-8', text: 'empty on the server'), const []); - expect(id, isNot(note.id), reason: 'pushLocal owns the local_only row'); + // It is the local copy of that note, left for pushLocal to finish: + // no second row, and nothing of it is moved or replaced. + expect(id, note.id); + expect(await repo.getAllNotes(), hasLength(1)); expect((await repo.getNoteById(note.id))!.typedContent, 'only copy'); + expect(await repo.getStrokesForNote(note.id), hasLength(1)); + }); + + test('a stroke id used by another note never moves it over', () async { + final a = await repo.createNote('A'); + final b = await repo.createNote('B'); + await repo.replaceStrokesForNote(a.id, [stroke('same', a.id)]); + + await repo.replaceStrokesForNote(b.id, [stroke('same', b.id)]); + await repo.replaceStrokesForNote(b.id, [stroke('same', b.id)]); + + expect(await repo.getStrokesForNote(a.id), hasLength(1)); + expect(await repo.getStrokesForNote(b.id), hasLength(1)); + expect((await repo.getStrokesForNote(b.id)).single.points, hasLength(1)); + }); + + test('text typed in local mode over a note without content becomes a ' + 'note of its own', () async { + final at = DateTime.utc(2024, 1, 1); + await repo.upsertNote(Note( + id: 'srv-3', + remoteId: 'srv-3', + title: 'Pulled', + syncStatus: 'synced', + hasContent: false, + createdAt: at, + updatedAt: at, + )); + await repo.updateNoteFields( + 'srv-3', {'typed_content': 'typed blind'}, NotePart.text, true); + await repo.recordInk('srv-3', [stroke('s1', 'srv-3')], blind: true); + + await repo.cacheRemoteNote( + serverCopy('srv-3', text: 'the real text'), [stroke('r1', 'srv-3')]); + + final original = (await repo.getNoteById('srv-3'))!; + expect(original.typedContent, 'the real text'); + expect(original.hasPendingEdits, isFalse); + expect((await repo.getStrokesForNote('srv-3')).map((s) => s.id), ['r1']); + final copy = + (await repo.getAllNotes()).singleWhere((n) => n.id != 'srv-3'); + expect(copy.title, 'Pulled (offline copy)'); + expect(copy.typedContent, 'typed blind'); + expect(copy.syncStatus, 'local_only'); + expect(await repo.getStrokesForNote(copy.id), hasLength(1)); }); test('a pull applied from an older snapshot keeps a pending edit', diff --git a/app/test/helpers/fake_backend.dart b/app/test/helpers/fake_backend.dart index 946dea4..cba315f 100644 --- a/app/test/helpers/fake_backend.dart +++ b/app/test/helpers/fake_backend.dart @@ -66,6 +66,9 @@ class FakeBackendClient extends api.ApiClient { /// Drawing saves fail while text and title saves go through. bool failInk = false; + + /// Text saves are refused with this HTTP status (say 413). + int? refuseText; final _held = <Completer<void>>[]; int get heldCount => _held.length; @@ -168,6 +171,10 @@ class FakeBackendClient extends api.ApiClient { Future<String?> savePageText( String noteId, int pageNum, String content) async { await _write(noteId); + final status = refuseText; + if (status != null) { + throw api.ApiException('Failed to save text', statusCode: status); + } server.text[noteId] = content; server.writes.add('text:$content'); return server.touch(noteId); diff --git a/app/test/services/local_first_test.dart b/app/test/services/local_first_test.dart index a5641dc..8a2b760 100644 --- a/app/test/services/local_first_test.dart +++ b/app/test/services/local_first_test.dart @@ -306,4 +306,71 @@ void main() { expect(rows.single.typedContent, 'offline edit'); expect(rows.single.syncStatus, 'synced'); }); + + test('text typed in local mode over a pulled note never wipes its content', + () async { + final id = server.create('Report', 'mixed').id; + server.text[id] = 'important paragraphs'; + server.ink[id] = [ + {'id': 'r1', 'points': const []}, + ]; + // A pull lists the note without its pages. + await SyncService(apiClient: client, local: store).pullRemote(); + await state.enableLocalMode(); + + final blank = (await state.openNote(id))!; + expect(blank.pages!.first.typedContent, isEmpty); + await state.recordText(id, 'one new line'); + + await state.connect(url: 'http://fake.test'); + + expect(server.text[id], 'important paragraphs'); + expect(server.ink[id], hasLength(1)); + final copy = server.notes.values.singleWhere((n) => n.id != id); + expect(copy.title, 'Report (offline copy)'); + expect(server.text[copy.id], 'one new line'); + }); + + test('a note the device never downloaded does not open as an empty page ' + 'offline', () async { + final id = server.create('Report', 'typed').id; + server.text[id] = 'important paragraphs'; + await SyncService(apiClient: client, local: store).pullRemote(); + + client.down = true; + await expectLater(state.openNote(id), throwsA(anything)); + }); + + test('edits of a closed note go out as soon as the backend is back', + () async { + final id = await openedNote(); + client.down = true; + await expectLater(state.savePageText(id, 1, 'while down'), throwsA(anything)); + expect(state.isBackendAvailable, isFalse); + + client.down = false; + await state.loadNotes(); // any request that gets through + await pumpEventQueue(); + + expect(server.text[id], 'while down'); + }); + + test('a part the server refuses does not hold back the others', () async { + final id = server.create('Sketch', 'mixed').id; + await state.openNote(id); + client.down = true; + await state.recordText(id, 'too big'); + await state.recordInk(id, [ + {'id': 's1', 'points': const []}, + ]); + client.down = false; + client.refuseText = 413; + + await expectLater(state.pushPending(id), throwsA(anything)); + + expect(server.ink[id], hasLength(1)); + final row = (await store.getNoteById(id))!; + expect(row.inkPending, isFalse); + expect(row.textPending, isTrue); + }); }