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 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/data/database/schema.dart b/app/lib/data/database/schema.dart index 1aaf2a0..1b9191f 100644 --- a/app/lib/data/database/schema.dart +++ b/app/lib/data/database/schema.dart @@ -11,7 +11,18 @@ 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. + /// + /// 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. Also + /// adds `has_content` and `blind_edit` (see Note.hasContent). + static const int version = 4; static const String _createNotebooks = ''' CREATE TABLE IF NOT EXISTS notebooks ( @@ -43,12 +54,33 @@ class Schema { sync_status TEXT NOT NULL DEFAULT 'local_only', 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, + 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', + 'text_rev', + 'text_synced_rev', + 'ink_rev', + 'ink_synced_rev', + ]; + static const String _createStrokes = ''' CREATE TABLE IF NOT EXISTS strokes ( id TEXT PRIMARY KEY, @@ -122,5 +154,33 @@ class Schema { await db.execute(_indexNotesRemoteId); await db.execute(_indexNotesRemotePath); } + 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'); + } + 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 a92a7d9..136e85d 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. @@ -19,6 +31,23 @@ 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 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. +/// +/// [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; @@ -33,6 +62,15 @@ class Note { final String syncStatus; 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 bool hasContent; + final bool blindEdit; final DateTime createdAt; final DateTime updatedAt; @@ -49,6 +87,15 @@ class Note { this.syncStatus = 'local_only', this.remoteId, this.remotePath, + this.remoteBaseline, + this.titleRev = 0, + this.titleSyncedRev = 0, + this.textRev = 0, + this.textSyncedRev = 0, + this.inkRev = 0, + this.inkSyncedRev = 0, + this.hasContent = true, + this.blindEdit = false, required this.createdAt, required this.updatedAt, }); @@ -69,6 +116,15 @@ 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?, + 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, + 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), ); @@ -88,6 +144,15 @@ class Note { 'sync_status': syncStatus, '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, + 'has_content': hasContent ? 1 : 0, + 'blind_edit': blindEdit ? 1 : 0, 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), }; @@ -106,6 +171,7 @@ class Note { String? syncStatus, String? remoteId, String? remotePath, + bool? hasContent, DateTime? updatedAt, }) { return Note( @@ -122,11 +188,25 @@ class Note { syncStatus: syncStatus ?? this.syncStatus, remoteId: remoteId ?? this.remoteId, remotePath: remotePath ?? this.remotePath, + remoteBaseline: remoteBaseline, + titleRev: titleRev, + titleSyncedRev: titleSyncedRev, + textRev: textRev, + textSyncedRev: textSyncedRev, + inkRev: inkRev, + inkSyncedRev: inkSyncedRev, + hasContent: hasContent ?? this.hasContent, + blindEdit: blindEdit, 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/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 7778d6c..fbfb583 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'; @@ -161,6 +163,357 @@ 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. + /// + /// 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 updateNoteFields( + String id, [ + Map fields = const {}, + NotePart? edited, + bool blind = false, + ]) => + _updateNoteFields(_db, id, fields, edited, blind); + + static Future _updateNoteFields( + DatabaseExecutor db, + String id, + Map fields, + 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', + ]; + return db.rawUpdate( + 'UPDATE notes SET ${assignments.join(', ')} WHERE id = ?', + [ + ...fields.values, + DateTime.now().toUtc().toIso8601String(), + id, + ], + ); + } + + /// 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 recordInk(String id, List strokes, + {bool blind = false}) { + return _db.transaction((txn) async { + if (await _updateNoteFields(txn, id, const {}, NotePart.ink, blind) == + 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 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 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], 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. + Future localIdForRemote(String remoteId) => + _localIdForRemote(_db, remoteId); + + static Future _localIdForRemote( + DatabaseExecutor db, String remoteId) async { + final rows = await db.rawQuery( + 'SELECT id FROM notes WHERE remote_id = ? ' + "ORDER BY sync_status = 'local_only', 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 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 cacheRemoteNote( + Note remote, + List 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); + 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', + { + '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, + 'has_content': 1, + 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; + }); + } + + /// 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 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 _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 + /// locally modified row newer than [pulled] is left alone. Content columns + /// and edit revisions are never touched. + Future 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 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 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 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 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> 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 after our create or our latest + /// write into it (see [Note.remoteBaseline]); null when the server didn't + /// report one. + Future setNoteRemoteId( + String id, String remoteId, String? baseline) async { + await _db.update( + 'notes', {'remote_id': remoteId, 'remote_baseline': baseline}, + 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.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; + } + + /// 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( @@ -224,41 +577,63 @@ class NoteRepository { String noteId, List strokes, ) async { - await _db.transaction((txn) async { - final existing = await txn.query( + 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 _replaceStrokes( + DatabaseExecutor txn, + String noteId, + List 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']], + ); + } + await txn.delete('strokes', where: 'note_id = ?', whereArgs: [noteId]); + + final taken = {}; + 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', - columns: ['id'], - where: 'note_id = ?', - whereArgs: [noteId], + {...stroke.toMap(), 'id': id, 'note_id': noteId}, + conflictAlgorithm: ConflictAlgorithm.replace, ); - 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]); - - for (final stroke in strokes) { - await txn.insert( - 'strokes', - stroke.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - ); - for (var i = 0; i < stroke.points.length; i++) { - await txn.insert( - 'stroke_points', - stroke.points[i].toMap(stroke.id, i), - ); - } + 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(id, i)); } - }); + } } /// Returns all strokes for [noteId] with their points, ordered by creation. - Future> getStrokesForNote(String noteId) async { - final strokeRows = await _db.query( + Future> getStrokesForNote(String noteId) => + _strokesForNote(_db, noteId); + + static Future> _strokesForNote( + DatabaseExecutor db, String noteId) async { + final strokeRows = await db.query( 'strokes', where: 'note_id = ?', whereArgs: [noteId], @@ -268,7 +643,7 @@ class NoteRepository { final strokes = []; 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/main.dart b/app/lib/main.dart index 86ccb8c..1b14604 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". @@ -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(_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 3610c5b..a164b2e 100644 --- a/app/lib/screens/connect_screen.dart +++ b/app/lib/screens/connect_screen.dart @@ -108,7 +108,7 @@ class _ConnectScreenState extends State { '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 { 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 { 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,13 +284,17 @@ class _ConnectScreenState extends State { 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), - 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.withValues(alpha: 0.7))), + ), ]), const SizedBox(height: 8), _code('cd ~/NexaNote'), diff --git a/app/lib/screens/home_screen.dart b/app/lib/screens/home_screen.dart index 4cb863e..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}); @@ -41,7 +40,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, @@ -52,6 +51,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), ), @@ -59,34 +59,25 @@ 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()), ])), ]), ); } - 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'); - final full = await state.getNote(note.id); - state.selectNote(full); + await _createAndOpenNote(context); } Future _sync(BuildContext context) async { @@ -96,6 +87,52 @@ 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) _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) { final error = state.syncError; final message = error ?? state.syncMessage ?? 'Sync complete'; @@ -139,14 +176,21 @@ 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, 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, @@ -160,10 +204,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); @@ -177,10 +218,8 @@ class _MobileLayout extends StatelessWidget { backgroundColor: const Color(0xFF6366F1), 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) { + final full = await _createAndOpenNote(context); + if (full != null && context.mounted) { Navigator.push(context, MaterialPageRoute( builder: (_) => ChangeNotifierProvider.value( value: state, @@ -193,14 +232,38 @@ class _MobileLayout extends StatelessWidget { } } -class _NotesHeader extends StatelessWidget { +class _NotesHeader extends StatefulWidget { final String title; + final String initialQuery; final ValueChanged 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: [ @@ -208,7 +271,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, @@ -220,6 +283,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}); @@ -251,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 c9455a4..c067b88 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'; @@ -12,13 +14,56 @@ 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; 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; + + /// 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>? _pendingInk; + int? _pendingInkTicket; + + /// The single local ink writer while it runs; see [_flushInk]. + Future? _inkWriter; + + /// The latest request to send this note's saved edits to the backend. + Future? _lastPush; + bool _pushFailed = false; + + Timer? _retryTimer; + int _retries = 0; + bool _disposed = false; + + /// 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), + 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; @@ -48,6 +93,9 @@ class _NoteEditorScreenState extends State { _inkStrokes = _note.pages?.isNotEmpty == true ? _note.pages!.first.strokes.whereType>().toList() : >[]; + _savedTitle = _titleCtrl.text; + _savedContent = _contentCtrl.text; + WidgetsBinding.instance.addObserver(this); _titleCtrl.addListener(_onChanged); _contentCtrl.addListener(_onChanged); } @@ -61,9 +109,21 @@ class _NoteEditorScreenState extends State { } 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. + _appState?.rememberNote(widget.note); + 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); _saveTimer?.cancel(); _saveTimer = Timer(const Duration(seconds: 2), _save); @@ -72,32 +132,54 @@ 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; + // 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, generation)); + _textSaves = save; + 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 _persistSnapshot( + String title, String content, int generation) async { final state = _appState; if (state == null) return false; try { - if (_titleCtrl.text != _note.title) { - await state.updateNoteTitle(_note.id, _titleCtrl.text); - _note = _note.copyWith(title: _titleCtrl.text); + if (title != _savedTitle) { + await state.recordTitle(_note.id, title); + _savedTitle = title; + } + if (content != _savedContent) { + await state.recordText(_note.id, content); + _savedContent = content; + } + if (generation == _editGeneration && mounted) { + setState(() => _hasChanges = false); } - await state.savePageText(_note.id, 1, _contentCtrl.text); - return true; } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( @@ -105,18 +187,14 @@ class _NoteEditorScreenState extends State { } return false; } + _pushEdits(); + return true; } 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 { @@ -131,35 +209,197 @@ class _NoteEditorScreenState extends State { setState(() => _showInk = true); } - Future _saveInk(List> strokes) async { + Future _saveInk(List> 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; + _pendingInkTicket = _appState?.stageInk(_note.id, strokes); + _retries = 0; + return _flushInk(); + } + + /// 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 _flushInk() { + _retryTimer?.cancel(); + _retryTimer = null; + return _inkWriter ??= _drainInk().whenComplete(() => _inkWriter = null); + } + + Future _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.recordInk(_note.id, next, ticket: ticket); + _inkSaveFailed = false; + _retries = 0; + wrote = true; + } 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)); + } + _scheduleRetry(); + return; + } + } + if (wrote) _pushEdits(); + } + + /// 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 _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 = _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 + /// 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.savePageInk(_note.id, 1, strokes); - _inkSaveFailed = false; + await state.deleteNote(_note.id); } 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)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('Delete failed: $e'), backgroundColor: Colors.red)); } + return; + } + 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. 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(); + if (_pendingInk != null) _flushInk(); + _pushEdits(); + } + + /// 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 didRequestAppExit() async { + _saveTimer?.cancel(); + final clock = Stopwatch()..start(); + final localWrites = >[ + if (_hasChanges) _persist() else _textSaves, + if (_pendingInk != null || _inkWriter != null) _flushInk(), + ]; + try { + await Future.wait(localWrites).timeout(_exitSaveTimeout); + final left = _exitSaveTimeout - clock.elapsed; + if (left > Duration.zero) await _pushEdits().timeout(left); + } on TimeoutException { + debugPrint('Exiting before every save of note ${_note.id} finished'); } + return AppExitResponse.exit; } @override void dispose() { + _disposed = true; + WidgetsBinding.instance.removeObserver(this); _saveTimer?.cancel(); - // Flush any pending text edits synchronously-scheduled before teardown. - // _persist captures its AppState reference, so this is safe post-unmount. - _persist(); + // 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. + if (_hasChanges) _persist(); _titleCtrl.dispose(); _contentCtrl.dispose(); super.dispose(); @@ -208,10 +448,7 @@ class _NoteEditorScreenState extends State { onDraw: () { _switchToInk(); }, - onDelete: () { - context.read().deleteNote(_note.id); - if (isMobile) Navigator.pop(context); - }, + onDelete: () => _confirmDelete(isMobile), ), // Title Padding( @@ -332,7 +569,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( @@ -369,7 +606,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( @@ -575,7 +812,7 @@ class _Btn extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.7))))); + .withValues(alpha: 0.7))))); } } @@ -591,7 +828,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 648b228..8a52916 100644 --- a/app/lib/screens/settings_screen.dart +++ b/app/lib/screens/settings_screen.dart @@ -73,6 +73,7 @@ class _SettingsScreenState extends State { Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); + if (!mounted) return; final state = context.read(); setState(() { _apiUrlCtrl.text = state.apiUrl; @@ -146,7 +147,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()); @@ -156,23 +158,35 @@ class _SettingsScreenState extends State { // 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(); 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; @@ -284,7 +298,7 @@ class _SettingsScreenState extends State { 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), @@ -303,7 +317,7 @@ class _SettingsScreenState extends State { 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( @@ -359,7 +373,7 @@ class _SettingsScreenState extends State { ? '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) { @@ -412,9 +426,9 @@ class _SettingsScreenState extends State { 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: [ @@ -466,7 +480,7 @@ class _SettingsScreenState extends State { 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: [ @@ -498,7 +512,7 @@ class _SettingsScreenState extends State { 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), @@ -508,14 +522,14 @@ class _SettingsScreenState extends State { // 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) @@ -530,7 +544,7 @@ class _SettingsScreenState extends State { '${_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), ), ), ]), @@ -552,7 +566,7 @@ class _SettingsScreenState extends State { 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: [ @@ -563,7 +577,7 @@ class _SettingsScreenState extends State { '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), ), ), ), @@ -593,7 +607,7 @@ class _SettingsScreenState extends State { 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), @@ -608,7 +622,7 @@ class _SettingsScreenState extends State { : '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( @@ -643,7 +657,7 @@ class _SettingsScreenState extends State { return; } await context.read().connect(url: url); - if (mounted) { + if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Reconnecting...')), ); @@ -659,7 +673,7 @@ class _SettingsScreenState extends State { const SizedBox(height: 24), // ── About ────────────────────────────────────────────── - _SectionTitle(icon: Icons.info_outline, title: 'About'), + const _SectionTitle(icon: Icons.info_outline, title: 'About'), Card( child: Column( children: [ @@ -688,10 +702,10 @@ class _SettingsScreenState extends State { 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'), ), ], ), @@ -747,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))), ], ), ), @@ -770,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( @@ -780,7 +794,7 @@ class _SectionTitle extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.5))), + .withValues(alpha: 0.5))), ], ), ); @@ -794,7 +808,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( @@ -807,8 +826,10 @@ class _StatChip extends StatelessWidget { color: Theme.of(context) .colorScheme .onSurface - .withOpacity(0.5))), + .withValues(alpha: 0.5))), ], + ), + ), ); } } @@ -832,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/api_client.dart b/app/lib/services/api_client.dart index beb33ed..b51e271 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,60 @@ 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); + + /// 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); - ApiClient({required this.baseUrl}); + /// `/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(); + } + } + + /// 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 +192,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 +206,36 @@ 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'))); + // 404: already gone (deleted from another device), which is the goal. + _expect(resp, what, const [200, 204, 404]); } // ---------------------------------------------------------------- @@ -199,24 +252,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 +276,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,44 +304,79 @@ 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'))); + // 404: already gone (deleted from another device), which is the goal. + _expect(resp, what, const [200, 204, 404]); } 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); } // ---------------------------------------------------------------- // Page text content // ---------------------------------------------------------------- - 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}), - ); + /// Saves page text and returns the note's updated_at after the write, as + /// the server reports it (null when it doesn't). + Future savePageText( + String noteId, int pageNum, String content) async { + 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}), + ), + timeout: uploadTimeout); + _expect(resp, what); + return _noteVersion(resp); } - Future savePageInk( + /// Saves a page's drawing; returns the note's updated_at like + /// [savePageText]. + 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}), + ), + 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; + } } // ---------------------------------------------------------------- @@ -297,10 +384,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 +395,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 +406,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 +426,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/lib/services/app_state.dart b/app/lib/services/app_state.dart index 84c9b8e..fb2fbc3 100644 --- a/app/lib/services/app_state.dart +++ b/app/lib/services/app_state.dart @@ -1,16 +1,18 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; 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); @@ -57,7 +59,22 @@ 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; + Future? _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 = + > strokes, int? rev})>{}; + int _inkTickets = 0; + final _knownNotes = {}; + final _runningPushes = >{}; + final _queuedPushes = >{}; + final _pushesStarted = {}; bool _isSyncing = false; String? _syncMessage; String? _syncError; @@ -115,7 +132,14 @@ 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; + + /// 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; @@ -132,6 +156,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'; @@ -214,21 +250,46 @@ 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() => + // 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 _uploadPendingLocalNotesOnce() 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 (e) { + _syncError = 'Could not upload notes taken offline: ${_safeErrorString(e)}'; + } + } + + /// 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 _pushEditsSavedOnDevice() async { + try { + await _ensureLocalReady(); } catch (_) { - // Intentionally ignored — see doc comment. + 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)}'; } } @@ -257,16 +318,24 @@ 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. + // 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 + // id the backend doesn't know. + if (_selectedNotebook != null && + !_notebooks.any((nb) => nb.id == _selectedNotebook!.id)) { + _selectedNotebook = null; + } await loadNotes(); } catch (e) { _isBackendAvailable = false; @@ -309,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; } @@ -345,7 +417,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 +429,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( @@ -388,9 +460,10 @@ class AppState extends ChangeNotifier { api.Note _toApiNoteWithContent( local.Note n, { List strokes = const [], + String? id, }) => api.Note( - id: n.id, + id: id ?? n.id, title: n.title, noteType: n.noteType, notebookId: n.notebookId, @@ -431,14 +504,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 +525,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(); @@ -475,19 +548,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; @@ -503,7 +592,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 +601,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; @@ -526,6 +615,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 getNote(String id) async { if (_localMode) { await _ensureLocalReady(); @@ -536,21 +630,54 @@ 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 deleteNote(String id) async { 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; } + // 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.removeWhere((n) => n.id == id); + _notes = [..._notes.where((n) => n.id != id)]; if (_selectedNote?.id == id) _selectedNote = null; _markBackendAvailable(); notifyListeners(); @@ -558,104 +685,481 @@ class AppState extends ChangeNotifier { await _handleBackendFailure(e); rethrow; } + await _dropLocalCopy(id, localId); } + /// 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 updateNoteTitle(String id, String title) async { + await recordTitle(id, title); + await pushPending(id); + } + + /// Saves page text like [updateNoteTitle] does the title. + Future 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 savePageInk( + String noteId, + int pageNum, + List> 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 recordTitle(String noteId, 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(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(); + } + + /// Records [content] as the note's newest text; see [recordTitle]. + Future recordText(String noteId, String content) async { + if (_localMode) { + await _ensureLocalReady(); + await _localService.updateNoteContent(noteId, content, blind: true); + return; } + await _recordConnected( + noteId, + (localId) => _localService.updateNoteContent(localId, content), + orSend: () => client.savePageText(noteId, 1, content), + ); } - Future savePageText(String noteId, int pageNum, String content) async { + /// Records [strokes] as the note's newest drawing; see [recordTitle]. + /// + /// 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 recordInk( + String noteId, + List> strokes, { + int? ticket, + }) async { + if (ticket != null && _stagedInk[noteId]?.ticket != ticket) return; + // 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 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(); - 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(), - )); - } + if (superseded()) return; + await _localService.recordInk(noteId, toLocal(noteId), blind: true); + _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 [recordInk]. + int stageInk(String noteId, List> strokes) { + final ticket = ++_inkTickets; + _stagedInk[noteId] = (ticket: ticket, strokes: strokes, rev: null); + return ticket; + } + + /// 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>? unsavedInk(String noteId) => + _stagedInk[noteId]?.strokes; + + 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 pushPending(String noteId) async { + if (_localMode) return; + final String? localId; try { - await client.savePageText(noteId, pageNum, content); - if (_markBackendAvailable()) notifyListeners(); + await _ensureLocalReady(); + localId = await _localService.localIdForRemote(noteId); } catch (e) { - await _handleBackendFailure(e); - rethrow; + debugPrint('No local store to push from: $e'); + return; } + if (localId == null) return; + await _push(localId); } - /// 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. - Future savePageInk( + Future _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 _startPush(String localId) { + _pushesStarted[localId] = (_pushesStarted[localId] ?? 0) + 1; + late final Future push; + push = _pushOnce(localId).whenComplete(() { + if (identical(_runningPushes[localId], push)) { + _runningPushes.remove(localId); + } + }); + _runningPushes[localId] = push; + return push; + } + + Future _pushOnce(String localId) async { + 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 || + note.syncStatus == 'local_only' || + !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 part(Future 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; + if (pushed.titlePending) { + await part(() async { + await client.updateNote(remoteId, title: pushed.title); + await _localService.markPartSynced( + localId, local.NotePart.title, pushed.titleRev); + titleSent = true; + }); + } + if (pushed.textPending) { + await part(() async { + await client.savePageText(remoteId, 1, pushed.typedContent); + await _localService.markPartSynced( + localId, local.NotePart.text, pushed.textRev); + }); + } + if (pushed.inkPending) { + await part(() async { + final ink = await _localService.readInk(localId); + 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); + } + } + }); + } + 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(error); + if (titleSent) await loadNotes(notebookId: _selectedNotebook?.id); + throw error; + } + 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 _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 _recordConnected( String noteId, - int pageNum, - List> strokes, - ) async { - if (_localMode) { + Future Function(String localId) record, { + required Future Function() orSend, + }) async { + 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 = [ - 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. - 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(), - )); + } 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; + 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', + hasContent: n?.pages != null, + createdAt: parse(n?.createdAt), + updatedAt: parse(n?.updatedAt), + ); + } + + List _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>().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 _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 _localCopyOf(String id) async { try { - await client.savePageInk(noteId, pageNum, strokes); - if (_markBackendAvailable()) notifyListeners(); + await _ensureLocalReady(); + final localId = await _localService.localIdForRemote(id); + if (localId == null) return null; + final row = await _localService.getNoteById(localId); + // 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) { - await _handleBackendFailure(e); - rethrow; + debugPrint('No local copy of note $id: $e'); + return null; + } + } + + Future _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 _dropLocalCopy(String id, String? localId) async { + _knownNotes.remove(id); + _stagedInk.remove(id); + if (localId == null) return; + _stagedInk.remove(localId); + try { + 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 + /// 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. @@ -666,6 +1170,7 @@ class AppState extends ChangeNotifier { } _beginSync(); try { + await _pushEditsSavedOnDevice(); final result = await client.triggerSync(); final summary = result['summary'] ?? 'Sync complete'; _markBackendAvailable(); @@ -698,9 +1203,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}'); @@ -716,7 +1230,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 _handleBackendFailure(Object _) async { + Future _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'; @@ -752,53 +1274,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 832396c..8edf2ae 100644 --- a/app/lib/services/local_note_service.dart +++ b/app/lib/services/local_note_service.dart @@ -58,6 +58,61 @@ class LocalNoteService { _repo.getNoteByRemoteId(remoteId); Future upsertNote(Note note) => _repo.upsertNote(note); + /// 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 updateNoteTitle(String id, String title) => + _repo.updateNoteFields(id, {'title': title}, NotePart.title); + /// [blind]: made without the note's real content at hand (local mode), + /// see Note.blindEdit. + Future 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 recordInk(String id, List 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 strokes})?> readInk(String id) => + _repo.readInk(id); + + /// The server has [part] of note [id] up to revision [rev]. + Future markPartSynced(String id, NotePart part, int rev) => + _repo.markPartSynced(id, part, rev); + + // Connected-mode copies of server notes. + Future localIdForRemote(String remoteId) => + _repo.localIdForRemote(remoteId); + Future cacheRemoteNote(Note remote, List strokes, + {bool refresh = true}) => + _repo.cacheRemoteNote(remote, strokes, refresh: refresh); + Future> getNotesWithPendingEdits() => + _repo.getNotesWithPendingEdits(); + Future detachFromRemote(String id) => _repo.detachFromRemote(id); + Future splitOffContentlessEdits(String id) => + _repo.splitOffContentlessEdits(id); + + // Pull bookkeeping used by SyncService. + Future applyPulledNote(String localId, Note pulled) => + _repo.applyPulledNote(localId, pulled); + Future insertNoteIfAbsent(Note note) => _repo.insertNoteIfAbsent(note); + Future markNoteConflict(String id) => _repo.markNoteConflict(id); + Future hardDeleteNoteIfSynced(String id) => + _repo.hardDeleteNoteIfSynced(id); + + // Push bookkeeping used by SyncService. + Future setNoteRemoteId(String id, String remoteId, String? baseline) => + _repo.setNoteRemoteId(id, remoteId, baseline); + 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..1f29892 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,34 +71,144 @@ 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. - Future<_PushCounts> pushLocal() async { + /// 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 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'; + final remoteId = note.remoteId; + final hasRemote = remoteId != null && remoteId.isNotEmpty; + if (hasRemote && !pendingUpload) continue; + 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 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 (!_isAsWeLeftIt(server, note.remoteBaseline)) { + final copy = await _api.createNote( + title: _offlineCopyTitle(note.title), + 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, intoFreshNote: true); + 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; + 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], + ); + 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 _local.markNoteSyncedIfUnchanged(note.id, note.updatedAt); + } + 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). + /// + /// Empty parts are skipped only [intoFreshNote]: a resumed upload may + /// already have put text or strokes there that the user has removed + /// since. + Future _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 || !intoFreshNote) { + final version = await _api.savePageInk( + remoteId, 1, strokes.map(inkJsonFromStroke).toList()); + await _local.setNoteRemoteId(note.id, remoteId, version); } - return _PushCounts(notebooks: notebooks, notes: notes); + } + + static const _offlineCopySuffix = ' (offline copy)'; + + static String _offlineCopyTitle(String title) { + final clean = cleanRemoteTitle(title); + return clean.endsWith(_offlineCopySuffix) + ? clean + : '$clean$_offlineCopySuffix'; + } + + Future _remoteNoteOrNull(String id) async { + try { + return await _api.getNote(id); + } on api.ApiException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } + } + + /// 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 (ours == null || current == null) return false; + return current.isAtSameMomentAs(ours); } static bool _isAlreadySynced(String status) => status == 'synced'; @@ -117,7 +228,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 pullRemote() async { final remoteNotebooks = await _api.getNotebooks(); final remoteNotes = await _api.getNotes(includeDeleted: true); @@ -168,26 +279,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); - final keepLocal = 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; } @@ -197,13 +310,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, @@ -223,11 +333,11 @@ 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); } } - return _PullCounts( + return PullCounts( notebooks: remoteNotebooks.length, notes: remoteNotes.length, adopted: adopted, @@ -265,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, ); @@ -302,17 +414,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 5a35c6a..4126473 100644 --- a/app/lib/widgets/ink_canvas.dart +++ b/app/lib/widgets/ink_canvas.dart @@ -51,7 +51,7 @@ class InkStrokeData { Map 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 @@ -255,6 +255,15 @@ 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; + 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. + final Set _touchPointers = {}; + // Outils InkTool _tool = InkTool.pen; Color _color = Colors.black; @@ -356,6 +365,50 @@ 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; + } + } + 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 || + PointerDeviceKind.touch => + true, + PointerDeviceKind.mouse => e.buttons == kPrimaryMouseButton, + _ => false, + }; + 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); + } + + 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 +441,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; @@ -421,8 +468,8 @@ class _InkCanvasState extends State { }, 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, @@ -595,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), ), @@ -604,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 a2508be..8c6ffa4 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(() { @@ -108,7 +109,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( @@ -159,8 +160,91 @@ 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'); + for (final column in Schema.v4Columns) { + 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', + '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(); + }); + + 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.v4Columns) { + await v3.execute('ALTER TABLE notes DROP COLUMN $column'); + } + final now = DateTime.utc(2024, 1, 1).toIso8601String(); + Future 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); + + Future 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/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()); @@ -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 = { '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/data/repositories/note_repository_test.dart b/app/test/data/repositories/note_repository_test.dart index bab7ddc..51ffaae 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,183 @@ 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 + await repo.replaceStrokesForNote(note.id, [stroke('s1', note.id)]); + + final id = await repo.cacheRemoteNote( + serverCopy('srv-8', text: 'empty on the server'), const []); + + // 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', + () 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..cba315f --- /dev/null +++ b/app/test/helpers/fake_backend.dart @@ -0,0 +1,192 @@ +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 = {}; + final text = {}; + final ink = >>{}; + var _ids = 0; + var _clock = DateTime.utc(2024, 1, 1); + + /// Every page save that reached the server, in order: `text:` or + /// `ink:`. + final writes = []; + + 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; + + /// 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 = >[]; + + int get heldCount => _held.length; + + void release() => _held.removeAt(0).complete(); + + static const _unreachable = + api.ApiException('Backend unreachable', statusCode: 503); + + Future _call() async { + if (down) throw _unreachable; + } + + Future _write(String id) async { + await _call(); + if (hold) { + final gate = Completer(); + _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 ping() async { + await _call(); + return true; + } + + @override + Future> getNotebooks() async { + await _call(); + return const []; + } + + @override + Future> getNotes({ + String? notebookId, + String? search, + bool includeDeleted = false, + }) async { + await _call(); + return server.notes.values.toList(); + } + + @override + Future 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 = >[]; + + void releaseRead() => _heldReads.removeAt(0).complete(); + + @override + Future 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(); + _heldReads.add(gate); + await gate.future; + } + return snapshot; + } + + @override + Future deleteNote(String id) async { + await _call(); + server.notes.remove(id); + server.text.remove(id); + server.ink.remove(id); + } + + @override + Future updateNote(String id, + {String? title, bool? isPinned, List? tags}) async { + await _write(id); + server.touch(id, title: title); + server.writes.add('title:$title'); + return server.notes[id]!; + } + + @override + Future 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); + } + + @override + Future savePageInk( + String noteId, int pageNum, List> 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/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 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 openTestStore([ + String path = inMemoryDatabasePath, +]) async { + final service = LocalNoteService(database: await openTestDatabase(path)); + await service.initialize(); + return service; +} diff --git a/app/test/screens/home_screen_test.dart b/app/test/screens/home_screen_test.dart new file mode 100644 index 0000000..4faf2e6 --- /dev/null +++ b/app/test/screens/home_screen_test.dart @@ -0,0 +1,234 @@ +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/api_client.dart' as api; +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'); + }); + + 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); + }); + + 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 { + _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); +} 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..63848c0 --- /dev/null +++ b/app/test/screens/note_editor_autosave_race_test.dart @@ -0,0 +1,201 @@ +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'; + +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 { + _SlowSaveApi() : super(baseUrl: 'http://slow.test'); + + 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 { + started.add(content); + final gate = Completer(); + pending.add(gate); + await gate.future; + landed.add(content); + return null; + } +} + +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(() async { + SharedPreferences.setMockInitialValues({}); + backend = _SlowSaveApi(); + // 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( + (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'); + }); + + 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']); + }); + } + + 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')); + }); +} 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..21d0e55 --- /dev/null +++ b/app/test/screens/note_editor_ink_save_test.dart @@ -0,0 +1,271 @@ +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'; + +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'); + + /// Stroke count of every save attempt, in order. + final attempts = []; + + /// Stroke count of every save that reached storage, in order. + final persisted = []; + + bool down = false; + bool hold = false; + final _held = >[]; + + Future 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 savePageInk( + String noteId, int pageNum, List> strokes) async { + attempts.add(strokes.length); + if (hold) { + final gate = Completer(); + _held.add(gate); + await gate.future; + } + if (down) throw _error; + persisted.add(strokes.length); + return null; + } +} + +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(() async { + SharedPreferences.setMockInitialValues({}); + backend = _InkApi(); + state = AppState( + localService: await openTestStore(), clientFactory: (_) => backend); + }); + + Future 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.value( + value: state, + child: MaterialApp(home: NoteEditorScreen(note: _sketch())), + )); + await tester.pump(); + } + + var strokeRow = 0; + Future 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); + }); + + 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(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); + }); +} diff --git a/app/test/screens/note_editor_persistence_test.dart b/app/test/screens/note_editor_persistence_test.dart index 869736c..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'; @@ -57,8 +56,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 +77,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 +115,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 +124,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 +133,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 +142,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 +166,31 @@ 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); + }); + + 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); }); } 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 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 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 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 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.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 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(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/screens/onboarding_routing_test.dart b/app/test/screens/onboarding_routing_test.dart index 2329b54..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'; @@ -114,4 +113,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); + }); } 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/api_client_test.dart b/app/test/services/api_client_test.dart new file mode 100644 index 0000000..851203f --- /dev/null +++ b/app/test/services/api_client_test.dart @@ -0,0 +1,90 @@ +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())); + }); + + if (status != 404) { + 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'); + }); + + 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( + baseUrl: 'http://backend.test', + httpClient: MockClient((_) => Completer().future), + ); + Object? error; + client.getNotes().catchError((Object e) { + error = e; + return []; + }); + + await tester.pump(ApiClient.requestTimeout - const Duration(seconds: 1)); + expect(error, isNull); + await tester.pump(const Duration(seconds: 2)); + expect(error, isA()); + }); + + testWidgets('saves get the longer upload timeout', (tester) async { + final client = ApiClient( + baseUrl: 'http://backend.test', + httpClient: MockClient((_) => Completer().future), + ); + Object? error; + 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); + expect(error, isNull); + // ...but it does not hang forever either. + await tester.pump(ApiClient.uploadTimeout); + expect(error, isA()); + }); +} diff --git a/app/test/services/app_state_test.dart b/app/test/services/app_state_test.dart index 06bec96..5b017bd 100644 --- a/app/test/services/app_state_test.dart +++ b/app/test/services/app_state_test.dart @@ -1,6 +1,7 @@ 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'; @@ -10,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 @@ -63,6 +62,35 @@ 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; + return null; + } +} + void main() { setUpAll(() { sqfliteFfiInit(); @@ -707,6 +735,56 @@ 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('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); + 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 @@ -726,4 +804,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())); + + 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())); + + expect(s.isBackendAvailable, isFalse); + }); + }); } diff --git a/app/test/services/local_first_test.dart b/app/test/services/local_first_test.dart new file mode 100644 index 0000000..8a2b760 --- /dev/null +++ b/app/test/services/local_first_test.dart @@ -0,0 +1,376 @@ +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 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 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')); + }); + + 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'); + }); + + 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); + }); +} 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 caa641c..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'; @@ -121,4 +120,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'); + }); } diff --git a/app/test/services/sync_service_test.dart b/app/test/services/sync_service_test.dart index 2e83593..934209b 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'; @@ -18,6 +17,67 @@ class FakeApiClient extends api.ApiClient { List remoteNotes = []; List> createdNotebooks = []; List> createdNotes = []; + Map savedText = {}; + Map>> savedInk = {}; + 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. + String? _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, + ]; + 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). + void editOnServer(String noteId, {required String text}) { + savedText[noteId] = text; + _touch(noteId); + } + + @override + Future savePageText( + String noteId, int pageNum, String content) async { + if (failText) throw const api.ApiException('Failed to save text'); + savedText[noteId] = content; + return _touch(noteId); + } + + @override + Future 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 savePageInk( + String noteId, int pageNum, List> strokes) async { + if (failInk) throw const api.ApiException('Failed to save drawing'); + savedInk[noteId] = strokes; + return _touch(noteId); + } @override Future> getNotebooks() async => remoteNotebooks; @@ -36,13 +96,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 +121,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 +133,8 @@ class FakeApiClient extends api.ApiClient { updatedAt: now, createdAt: now, ); + remoteNotes = [...remoteNotes, created]; + return created; } } @@ -162,6 +227,282 @@ 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)); + }); + + group('resuming an upload interrupted after the create', () { + Future interruptedUpload() async { + final note = await local.createNote('Offline'); + await local.updateNoteContent(note.id, 'written offline'); + fakeApi.failText = true; + await expectLater(sync.pushLocal(), throwsA(isA())); + 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 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'); + }); + + Future textUploadedInkFailed() 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)], + ), + ]); + fakeApi.failInk = true; + await expectLater(sync.pushLocal(), throwsA(isA())); + fakeApi.failInk = false; + final remoteId = fakeApi.remoteNotes.single.id; + expect(fakeApi.savedText[remoteId], 'caption'); + return remoteId; + } + + Iterable 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(); + + // 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('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'); + + // 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())); + } + 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())); + final remoteId = fakeApi.remoteNotes.single.id; + fakeApi.editOnServer(remoteId, text: 'online'); + for (var i = 0; i < 3; i++) { + await expectLater(sync.pushLocal(), throwsA(isA())); + } + 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', + () 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())); + } + 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(); + fakeApi.editOnServer(remoteId, text: '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 = [ @@ -383,11 +724,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 { diff --git a/app/test/widgets/ink_canvas_test.dart b/app/test/widgets/ink_canvas_test.dart new file mode 100644 index 0000000..a626e8d --- /dev/null +++ b/app/test/widgets/ink_canvas_test.dart @@ -0,0 +1,112 @@ +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); + }); + + 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}); + }); +} diff --git a/nexanote/api/routes.py b/nexanote/api/routes.py index ab1f4a8..41827a4 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 @@ -55,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") @@ -86,11 +89,15 @@ 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): 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" @@ -159,7 +166,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): @@ -235,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, @@ -293,6 +303,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 +463,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 +481,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 +490,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 +536,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) @@ -534,9 +566,10 @@ 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 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) @@ -551,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 @@ -565,7 +598,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/nexanote/storage/file_store.py b/nexanote/storage/file_store.py index 42dfa2d..107a578 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: @@ -203,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] = {} @@ -327,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]] = {} @@ -552,7 +564,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 +674,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 +692,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 +807,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 @@ -839,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/nexanote/storage/plain_store.py b/nexanote/storage/plain_store.py index 59a1332..ae091eb 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: @@ -547,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/nexanote/sync/client.py b/nexanote/sync/client.py index a21200a..8bf712a 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 @@ -113,6 +114,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 +404,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") @@ -422,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).""" @@ -437,13 +458,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 +648,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 +678,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 +731,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 +778,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 +827,32 @@ 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 _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, + 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, @@ -836,6 +898,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. @@ -900,6 +972,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 @@ -911,6 +1003,8 @@ 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( @@ -1029,7 +1123,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: @@ -1039,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) @@ -1191,13 +1297,25 @@ 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. + # 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 # no conflict when the timestamps match (identical versions). @@ -1237,6 +1355,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 +1426,19 @@ 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 + 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() nb_names = {e["name"] for e in nb_entries} @@ -1358,9 +1490,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..d10ca85 100644 --- a/nexanote/sync/webdav_provider.py +++ b/nexanote/sync/webdav_provider.py @@ -16,13 +16,16 @@ from __future__ import annotations +import copy import hashlib import io 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 +43,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") @@ -303,6 +306,7 @@ def create_collection(self, name: str) -> "NoteCollection": ) note.add_page() 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 @@ -577,64 +581,292 @@ 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. - payload_id = payload.get("id") - if ( - isinstance(payload_id, str) - and payload_id - and payload_id != self.note.id - ): - old_id = self.note.id - 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 + 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 - try: - self.db.delete_note_permanent(old_id) - except Exception: - logger.warning( - "could not delete placeholder note %s — ignoring", old_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 + 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", ) - self.note.pages.sort(key=lambda p: p.page_number) - self.note.touch() - self.db.save_note(self.note) - 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 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 + 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, 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" + _lock = threading.Lock() + + def __init__(self, data_dir: Path) -> None: + self.path = Path(data_dir) / self.FILE_NAME + + def _read(self) -> dict[str, str]: + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + 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 {} + if not isinstance(data, dict): + return {} + # 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, str]) -> None: + _atomic_write(self.path, json.dumps(entries, sort_keys=True).encode("utf-8")) + + def record(self, note: Note) -> None: + """Registers [note] as a placeholder in its current state.""" + with self._lock: + entries = self._read() + entries[note.id] = _note_fingerprint(note) + self._write(entries) + + def matches(self, note: Note) -> bool: + """True if [note] is a placeholder still in the recorded state.""" + with self._lock: + 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: + entries = self._read() + if entries.pop(note_id, None) is not None: + self._write(entries) + + +def _placeholders(db: FileNoteStore) -> _PlaceholderRegistry: + return _PlaceholderRegistry(db.data_dir) + + +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): @@ -754,6 +986,28 @@ 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", + ) + # 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] = [] for s_data in payload.get("strokes") or []: @@ -776,10 +1030,21 @@ 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) + # 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). + if untouched_placeholder: + _placeholders(self.db).record(self.note) logger.info( "Page %d mise à jour : %d strokes", self.page.page_number, @@ -911,6 +1176,7 @@ def _materialize_note( ) note.add_page() 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_api.py b/tests/test_api.py index d457254..f3745f8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -313,3 +313,32 @@ 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 + + +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"] 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 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 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<!-- nexanote:page 2 -->\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"] 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.<base64(stem)>` 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" diff --git a/tests/test_sync_reliability.py b/tests/test_sync_reliability.py index efbec6b..a1bdcdb 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) @@ -772,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() diff --git a/tests/test_webdav_sync_safety.py b/tests/test_webdav_sync_safety.py new file mode 100644 index 0000000..f9b4b7e --- /dev/null +++ b/tests/test_webdav_sync_safety.py @@ -0,0 +1,684 @@ +""" +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"<html>maintenance</html>" + 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 + + +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 + + +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 + + +# --------------------------------------------------------------------------- +# 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 + + +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" + + +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"] + + +# --------------------------------------------------------------------------- +# 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