Skip to content

Commit 668dcb6

Browse files
committed
fix: adopt remote Markdown notes during sync
Treat the WebDAV/NAS Markdown files as the canonical note representation and merge them into the local DB instead of wiping local rows on every pull. Each adopted note now carries the server-side identity (`remote_id` plus `remote_path` derived from synthetic `md.<base64>` ids) so repeated syncs upsert in place rather than minting duplicates. Push side respects the adopted identity and never POSTs a note that already has a remote_id. Conflict policy keeps both rows when titles collide on different ids and prefers the newer `updated_at` on identity matches. Display titles are stripped of `.md` and `__<id-prefix>` slug artifacts at the API ingestion boundary so the corrupted `__Md.Q2Hhd` form never reaches the UI. https://claude.ai/code/session_01T83ptxrcqCCk9Yx7WerGUd
1 parent 3cedaef commit 668dcb6

12 files changed

Lines changed: 794 additions & 162 deletions

File tree

app/lib/data/database/app_database.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class AppDatabase {
2828
dbPath,
2929
version: Schema.version,
3030
onCreate: Schema.onCreate,
31+
onUpgrade: Schema.onUpgrade,
3132
);
3233

3334
if (path == null) _instance = db;

app/lib/data/database/schema.dart

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import 'package:sqflite/sqflite.dart';
88
/// Mirrors the Python schema in nexanote/storage/database.py.
99
/// Keep this file as the single source of truth for table definitions.
1010
class Schema {
11-
static const int version = 1;
11+
/// Bumped to 2 when remote_id/remote_path columns were added so the
12+
/// SyncService can map a local note to its canonical .md file on the
13+
/// WebDAV/NAS without inventing a fresh row on every pull.
14+
static const int version = 2;
1215

1316
static const String _createNotebooks = '''
1417
CREATE TABLE IF NOT EXISTS notebooks (
@@ -38,6 +41,8 @@ class Schema {
3841
is_archived INTEGER NOT NULL DEFAULT 0,
3942
is_deleted INTEGER NOT NULL DEFAULT 0,
4043
sync_status TEXT NOT NULL DEFAULT 'local_only',
44+
remote_id TEXT,
45+
remote_path TEXT,
4146
created_at TEXT NOT NULL,
4247
updated_at TEXT NOT NULL,
4348
FOREIGN KEY (notebook_id) REFERENCES notebooks(id)
@@ -77,6 +82,12 @@ class Schema {
7782
static const String _indexNotesUpdated =
7883
'CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at)';
7984

85+
static const String _indexNotesRemoteId =
86+
'CREATE INDEX IF NOT EXISTS idx_notes_remote_id ON notes(remote_id)';
87+
88+
static const String _indexNotesRemotePath =
89+
'CREATE INDEX IF NOT EXISTS idx_notes_remote_path ON notes(remote_path)';
90+
8091
static const String _indexStrokesNote =
8192
'CREATE INDEX IF NOT EXISTS idx_strokes_note ON strokes(note_id)';
8293

@@ -91,7 +102,25 @@ class Schema {
91102
await db.execute(_createStrokePoints);
92103
await db.execute(_indexNotesNotebook);
93104
await db.execute(_indexNotesUpdated);
105+
await db.execute(_indexNotesRemoteId);
106+
await db.execute(_indexNotesRemotePath);
94107
await db.execute(_indexStrokesNote);
95108
await db.execute(_indexStrokePointsStroke);
96109
}
110+
111+
/// Forward-only migrations. Each `if` block applies the steps needed to
112+
/// reach the next version; SQLite's ALTER TABLE ADD COLUMN is enough for
113+
/// the nullable remote_id/remote_path additions in v2.
114+
static Future<void> onUpgrade(
115+
Database db,
116+
int oldVersion,
117+
int newVersion,
118+
) async {
119+
if (oldVersion < 2) {
120+
await db.execute('ALTER TABLE notes ADD COLUMN remote_id TEXT');
121+
await db.execute('ALTER TABLE notes ADD COLUMN remote_path TEXT');
122+
await db.execute(_indexNotesRemoteId);
123+
await db.execute(_indexNotesRemotePath);
124+
}
125+
}
97126
}

app/lib/data/models/note.dart

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ import 'dart:convert';
99
/// [syncStatus] values: 'local_only' | 'synced' | 'modified' | 'conflict'
1010
/// [noteType] values: 'typed' | 'handwritten' | 'mixed'
1111
///
12+
/// [remoteId] is the stable identifier the WebDAV/NAS source of truth uses
13+
/// for this note (the frontmatter `id` for notes with NexaNote frontmatter,
14+
/// or the synthetic `md.<base64>` id for plain Markdown files dropped in by
15+
/// the user). When set it lets the sync engine adopt the remote .md file
16+
/// instead of creating a duplicate.
17+
///
18+
/// [remotePath] is the relative path of the canonical .md file on the
19+
/// remote (e.g. `notes/Hello World.md`). Stored so renames on disk can be
20+
/// followed without losing the link.
21+
///
1222
/// Mirrors Note in nexanote/models/note.py.
1323
class Note {
1424
final String id;
@@ -21,6 +31,8 @@ class Note {
2131
final bool isArchived;
2232
final bool isDeleted;
2333
final String syncStatus;
34+
final String? remoteId;
35+
final String? remotePath;
2436
final DateTime createdAt;
2537
final DateTime updatedAt;
2638

@@ -35,6 +47,8 @@ class Note {
3547
this.isArchived = false,
3648
this.isDeleted = false,
3749
this.syncStatus = 'local_only',
50+
this.remoteId,
51+
this.remotePath,
3852
required this.createdAt,
3953
required this.updatedAt,
4054
});
@@ -53,6 +67,8 @@ class Note {
5367
isArchived: ((map['is_archived'] as int?) ?? 0) == 1,
5468
isDeleted: ((map['is_deleted'] as int?) ?? 0) == 1,
5569
syncStatus: (map['sync_status'] as String?) ?? 'local_only',
70+
remoteId: map['remote_id'] as String?,
71+
remotePath: map['remote_path'] as String?,
5672
createdAt: DateTime.parse(map['created_at'] as String),
5773
updatedAt: DateTime.parse(map['updated_at'] as String),
5874
);
@@ -70,12 +86,16 @@ class Note {
7086
'is_archived': isArchived ? 1 : 0,
7187
'is_deleted': isDeleted ? 1 : 0,
7288
'sync_status': syncStatus,
89+
'remote_id': remoteId,
90+
'remote_path': remotePath,
7391
'created_at': createdAt.toIso8601String(),
7492
'updated_at': updatedAt.toIso8601String(),
7593
};
7694
}
7795

7896
Note copyWith({
97+
String? notebookId,
98+
bool clearNotebookId = false,
7999
String? title,
80100
String? noteType,
81101
List<String>? tags,
@@ -84,11 +104,14 @@ class Note {
84104
bool? isArchived,
85105
bool? isDeleted,
86106
String? syncStatus,
107+
String? remoteId,
108+
String? remotePath,
87109
DateTime? updatedAt,
88110
}) {
89111
return Note(
90112
id: id,
91-
notebookId: notebookId,
113+
notebookId:
114+
clearNotebookId ? null : (notebookId ?? this.notebookId),
92115
title: title ?? this.title,
93116
noteType: noteType ?? this.noteType,
94117
tags: tags ?? this.tags,
@@ -97,6 +120,8 @@ class Note {
97120
isArchived: isArchived ?? this.isArchived,
98121
isDeleted: isDeleted ?? this.isDeleted,
99122
syncStatus: syncStatus ?? this.syncStatus,
123+
remoteId: remoteId ?? this.remoteId,
124+
remotePath: remotePath ?? this.remotePath,
100125
createdAt: createdAt,
101126
updatedAt: updatedAt ?? this.updatedAt,
102127
);

app/lib/data/repositories/note_repository.dart

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,20 @@ class NoteRepository {
109109
return Note.fromMap(rows.first);
110110
}
111111

112+
/// Returns the local note linked to [remoteId], or null if no local row
113+
/// has been adopted for that remote yet. Used by SyncService to decide
114+
/// between adopting an existing local row and inserting a new one.
115+
Future<Note?> getNoteByRemoteId(String remoteId) async {
116+
final rows = await _db.query(
117+
'notes',
118+
where: 'remote_id = ?',
119+
whereArgs: [remoteId],
120+
limit: 1,
121+
);
122+
if (rows.isEmpty) return null;
123+
return Note.fromMap(rows.first);
124+
}
125+
112126
/// Soft-deletes a note by setting [is_deleted = 1] and marking it modified.
113127
Future<void> deleteNote(String id) async {
114128
final now = DateTime.now().toUtc().toIso8601String();
@@ -135,25 +149,38 @@ class NoteRepository {
135149
return rows.map(Note.fromMap).toList();
136150
}
137151

138-
/// Replaces notebooks/notes with the supplied records in a single
139-
/// transaction. Strokes and stroke_points are intentionally **not**
140-
/// touched: handwritten ink is user content and Phase 4A sync is
141-
/// metadata-only. Strokes whose parent note disappears are left as
142-
/// orphans for a future stroke-aware sync phase to reconcile.
143-
Future<void> replaceAll({
144-
required List<Notebook> notebooks,
145-
required List<Note> notes,
146-
}) async {
147-
await _db.transaction((txn) async {
148-
await txn.delete('notes');
149-
await txn.delete('notebooks');
150-
for (final nb in notebooks) {
151-
await txn.insert('notebooks', nb.toMap());
152-
}
153-
for (final note in notes) {
154-
await txn.insert('notes', note.toMap());
155-
}
156-
});
152+
/// Inserts [note] or replaces the existing row with the same primary key.
153+
///
154+
/// Used by the sync engine to adopt a remote .md file into the local DB
155+
/// without going through the duplicating `INSERT` path of [createNote].
156+
Future<void> upsertNote(Note note) async {
157+
await _db.insert(
158+
'notes',
159+
note.toMap(),
160+
conflictAlgorithm: ConflictAlgorithm.replace,
161+
);
162+
}
163+
164+
/// Inserts [notebook] or replaces an existing row with the same id.
165+
Future<void> upsertNotebook(Notebook notebook) async {
166+
await _db.insert(
167+
'notebooks',
168+
notebook.toMap(),
169+
conflictAlgorithm: ConflictAlgorithm.replace,
170+
);
171+
}
172+
173+
/// Removes the row with [id] from `notes`. Hard delete — used by sync to
174+
/// drop notes that were 'synced' locally but no longer exist on the
175+
/// remote (the user deleted them server-side). Local-only and modified
176+
/// notes are skipped by callers, so they survive a pull.
177+
Future<int> hardDeleteNote(String id) async {
178+
return _db.delete('notes', where: 'id = ?', whereArgs: [id]);
179+
}
180+
181+
/// Removes the row with [id] from `notebooks` (hard delete).
182+
Future<int> hardDeleteNotebook(String id) async {
183+
return _db.delete('notebooks', where: 'id = ?', whereArgs: [id]);
157184
}
158185

159186
// -----------------------------------------------------------------------

app/lib/services/api_client.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import 'dart:convert';
55
import 'package:http/http.dart' as http;
66

7+
import 'title_cleaner.dart';
8+
79
class Notebook {
810
final String id;
911
final String name;
@@ -57,7 +59,10 @@ class Note {
5759

5860
factory Note.fromJson(Map<String, dynamic> j) => Note(
5961
id: j['id'],
60-
title: j['title'],
62+
// Strip slug/extension artifacts so list views and the editor
63+
// never display things like `Foo__Md.Q2Hhd`. The server side keeps
64+
// the raw title for storage; cleanup is a presentation concern.
65+
title: cleanRemoteTitle((j['title'] as String?) ?? ''),
6166
noteType: j['note_type'] ?? 'typed',
6267
notebookId: j['notebook_id'],
6368
tags: List<String>.from(j['tags'] ?? []),

app/lib/services/local_note_service.dart

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ class LocalNoteService {
5454
}) =>
5555
_repo.createNote(title, notebookId: notebookId, noteType: noteType);
5656
Future<Note?> getNoteById(String id) => _repo.getNoteById(id);
57+
Future<Note?> getNoteByRemoteId(String remoteId) =>
58+
_repo.getNoteByRemoteId(remoteId);
59+
Future<void> upsertNote(Note note) => _repo.upsertNote(note);
60+
Future<void> upsertNotebook(Notebook notebook) =>
61+
_repo.upsertNotebook(notebook);
62+
Future<int> hardDeleteNote(String id) => _repo.hardDeleteNote(id);
63+
Future<int> hardDeleteNotebook(String id) =>
64+
_repo.hardDeleteNotebook(id);
5765

5866
// Strokes
5967
Future<void> saveStroke(Stroke stroke) => _repo.saveStroke(stroke);
@@ -62,26 +70,15 @@ class LocalNoteService {
6270

6371
/// Snapshot of all locally-stored notebooks and notes.
6472
///
65-
/// Strokes are intentionally excluded — Phase 4A sync covers metadata only.
66-
/// Used by SyncService to push local state to the backend.
73+
/// Strokes are intentionally excluded — sync covers metadata only.
74+
/// Used by SyncService to enumerate the local state both for the push
75+
/// half of a cycle and for the merge step of a pull.
6776
Future<LocalSnapshot> exportAllData() async {
6877
final notebooks = await _repo.getNotebooks(includeArchived: true);
6978
final notes = await _repo.getAllNotes(includeDeleted: true);
7079
return LocalSnapshot(notebooks: notebooks, notes: notes);
7180
}
7281

73-
/// Replaces local notebooks and notes with [notebooks] and [notes].
74-
///
75-
/// Local strokes are **preserved** — Phase 4A sync moves metadata only,
76-
/// and ink is the user's irreplaceable content. Strokes whose parent
77-
/// note has disappeared remain in the database as orphans until a
78-
/// stroke-aware sync phase reconciles them.
79-
Future<void> importAllData({
80-
required List<Notebook> notebooks,
81-
required List<Note> notes,
82-
}) =>
83-
_repo.replaceAll(notebooks: notebooks, notes: notes);
84-
8582
/// Closes the database opened by this service. No-op when an injected
8683
/// [database] was supplied — the caller owns that connection.
8784
Future<void> close() async {

0 commit comments

Comments
 (0)