Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/lib/data/database/app_database.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class AppDatabase {
dbPath,
version: Schema.version,
onCreate: Schema.onCreate,
onUpgrade: Schema.onUpgrade,
);

if (path == null) _instance = db;
Expand Down
31 changes: 30 additions & 1 deletion app/lib/data/database/schema.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import 'package:sqflite/sqflite.dart';
/// Mirrors the Python schema in nexanote/storage/database.py.
/// Keep this file as the single source of truth for table definitions.
class Schema {
static const int version = 1;
/// 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;

static const String _createNotebooks = '''
CREATE TABLE IF NOT EXISTS notebooks (
Expand Down Expand Up @@ -38,6 +41,8 @@ class Schema {
is_archived INTEGER NOT NULL DEFAULT 0,
is_deleted INTEGER NOT NULL DEFAULT 0,
sync_status TEXT NOT NULL DEFAULT 'local_only',
remote_id TEXT,
remote_path TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (notebook_id) REFERENCES notebooks(id)
Expand Down Expand Up @@ -77,6 +82,12 @@ class Schema {
static const String _indexNotesUpdated =
'CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at)';

static const String _indexNotesRemoteId =
'CREATE INDEX IF NOT EXISTS idx_notes_remote_id ON notes(remote_id)';

static const String _indexNotesRemotePath =
'CREATE INDEX IF NOT EXISTS idx_notes_remote_path ON notes(remote_path)';

static const String _indexStrokesNote =
'CREATE INDEX IF NOT EXISTS idx_strokes_note ON strokes(note_id)';

Expand All @@ -91,7 +102,25 @@ class Schema {
await db.execute(_createStrokePoints);
await db.execute(_indexNotesNotebook);
await db.execute(_indexNotesUpdated);
await db.execute(_indexNotesRemoteId);
await db.execute(_indexNotesRemotePath);
await db.execute(_indexStrokesNote);
await db.execute(_indexStrokePointsStroke);
}

/// Forward-only migrations. Each `if` block applies the steps needed to
/// reach the next version; SQLite's ALTER TABLE ADD COLUMN is enough for
/// the nullable remote_id/remote_path additions in v2.
static Future<void> onUpgrade(
Database db,
int oldVersion,
int newVersion,
) async {
if (oldVersion < 2) {
await db.execute('ALTER TABLE notes ADD COLUMN remote_id TEXT');
await db.execute('ALTER TABLE notes ADD COLUMN remote_path TEXT');
await db.execute(_indexNotesRemoteId);
await db.execute(_indexNotesRemotePath);
}
}
}
27 changes: 26 additions & 1 deletion app/lib/data/models/note.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ import 'dart:convert';
/// [syncStatus] values: 'local_only' | 'synced' | 'modified' | 'conflict'
/// [noteType] values: 'typed' | 'handwritten' | 'mixed'
///
/// [remoteId] is the stable identifier the WebDAV/NAS source of truth uses
/// for this note (the frontmatter `id` for notes with NexaNote frontmatter,
/// or the synthetic `md.<base64>` id for plain Markdown files dropped in by
/// the user). When set it lets the sync engine adopt the remote .md file
/// instead of creating a duplicate.
///
/// [remotePath] is the relative path of the canonical .md file on the
/// remote (e.g. `notes/Hello World.md`). Stored so renames on disk can be
/// followed without losing the link.
///
/// Mirrors Note in nexanote/models/note.py.
class Note {
final String id;
Expand All @@ -21,6 +31,8 @@ class Note {
final bool isArchived;
final bool isDeleted;
final String syncStatus;
final String? remoteId;
final String? remotePath;
final DateTime createdAt;
final DateTime updatedAt;

Expand All @@ -35,6 +47,8 @@ class Note {
this.isArchived = false,
this.isDeleted = false,
this.syncStatus = 'local_only',
this.remoteId,
this.remotePath,
required this.createdAt,
required this.updatedAt,
});
Expand All @@ -53,6 +67,8 @@ class Note {
isArchived: ((map['is_archived'] as int?) ?? 0) == 1,
isDeleted: ((map['is_deleted'] as int?) ?? 0) == 1,
syncStatus: (map['sync_status'] as String?) ?? 'local_only',
remoteId: map['remote_id'] as String?,
remotePath: map['remote_path'] as String?,
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String),
);
Expand All @@ -70,12 +86,16 @@ class Note {
'is_archived': isArchived ? 1 : 0,
'is_deleted': isDeleted ? 1 : 0,
'sync_status': syncStatus,
'remote_id': remoteId,
'remote_path': remotePath,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
};
}

Note copyWith({
String? notebookId,
bool clearNotebookId = false,
String? title,
String? noteType,
List<String>? tags,
Expand All @@ -84,11 +104,14 @@ class Note {
bool? isArchived,
bool? isDeleted,
String? syncStatus,
String? remoteId,
String? remotePath,
DateTime? updatedAt,
}) {
return Note(
id: id,
notebookId: notebookId,
notebookId:
clearNotebookId ? null : (notebookId ?? this.notebookId),
title: title ?? this.title,
noteType: noteType ?? this.noteType,
tags: tags ?? this.tags,
Expand All @@ -97,6 +120,8 @@ class Note {
isArchived: isArchived ?? this.isArchived,
isDeleted: isDeleted ?? this.isDeleted,
syncStatus: syncStatus ?? this.syncStatus,
remoteId: remoteId ?? this.remoteId,
remotePath: remotePath ?? this.remotePath,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
Expand Down
65 changes: 46 additions & 19 deletions app/lib/data/repositories/note_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,20 @@ class NoteRepository {
return Note.fromMap(rows.first);
}

/// Returns the local note linked to [remoteId], or null if no local row
/// has been adopted for that remote yet. Used by SyncService to decide
/// between adopting an existing local row and inserting a new one.
Future<Note?> getNoteByRemoteId(String remoteId) async {
final rows = await _db.query(
'notes',
where: 'remote_id = ?',
whereArgs: [remoteId],
limit: 1,
);
if (rows.isEmpty) return null;
return Note.fromMap(rows.first);
}

/// Soft-deletes a note by setting [is_deleted = 1] and marking it modified.
Future<void> deleteNote(String id) async {
final now = DateTime.now().toUtc().toIso8601String();
Expand All @@ -135,25 +149,38 @@ class NoteRepository {
return rows.map(Note.fromMap).toList();
}

/// Replaces notebooks/notes with the supplied records in a single
/// transaction. Strokes and stroke_points are intentionally **not**
/// touched: handwritten ink is user content and Phase 4A sync is
/// metadata-only. Strokes whose parent note disappears are left as
/// orphans for a future stroke-aware sync phase to reconcile.
Future<void> replaceAll({
required List<Notebook> notebooks,
required List<Note> notes,
}) async {
await _db.transaction((txn) async {
await txn.delete('notes');
await txn.delete('notebooks');
for (final nb in notebooks) {
await txn.insert('notebooks', nb.toMap());
}
for (final note in notes) {
await txn.insert('notes', note.toMap());
}
});
/// Inserts [note] or replaces the existing row with the same primary key.
///
/// Used by the sync engine to adopt a remote .md file into the local DB
/// without going through the duplicating `INSERT` path of [createNote].
Future<void> upsertNote(Note note) async {
await _db.insert(
'notes',
note.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}

/// Inserts [notebook] or replaces an existing row with the same id.
Future<void> upsertNotebook(Notebook notebook) async {
await _db.insert(
'notebooks',
notebook.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}

/// Removes the row with [id] from `notes`. Hard delete — used by sync to
/// drop notes that were 'synced' locally but no longer exist on the
/// remote (the user deleted them server-side). Local-only and modified
/// notes are skipped by callers, so they survive a pull.
Future<int> hardDeleteNote(String id) async {
return _db.delete('notes', where: 'id = ?', whereArgs: [id]);
}

/// Removes the row with [id] from `notebooks` (hard delete).
Future<int> hardDeleteNotebook(String id) async {
return _db.delete('notebooks', where: 'id = ?', whereArgs: [id]);
}

// -----------------------------------------------------------------------
Expand Down
7 changes: 6 additions & 1 deletion app/lib/services/api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import 'dart:convert';
import 'package:http/http.dart' as http;

import 'title_cleaner.dart';

class Notebook {
final String id;
final String name;
Expand Down Expand Up @@ -57,7 +59,10 @@ class Note {

factory Note.fromJson(Map<String, dynamic> j) => Note(
id: j['id'],
title: j['title'],
// Strip slug/extension artifacts so list views and the editor
// never display things like `Foo__Md.Q2Hhd`. The server side keeps
// the raw title for storage; cleanup is a presentation concern.
title: cleanRemoteTitle((j['title'] as String?) ?? ''),
noteType: j['note_type'] ?? 'typed',
notebookId: j['notebook_id'],
tags: List<String>.from(j['tags'] ?? []),
Expand Down
25 changes: 11 additions & 14 deletions app/lib/services/local_note_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ class LocalNoteService {
}) =>
_repo.createNote(title, notebookId: notebookId, noteType: noteType);
Future<Note?> getNoteById(String id) => _repo.getNoteById(id);
Future<Note?> getNoteByRemoteId(String remoteId) =>
_repo.getNoteByRemoteId(remoteId);
Future<void> upsertNote(Note note) => _repo.upsertNote(note);
Future<void> upsertNotebook(Notebook notebook) =>
_repo.upsertNotebook(notebook);
Future<int> hardDeleteNote(String id) => _repo.hardDeleteNote(id);
Future<int> hardDeleteNotebook(String id) =>
_repo.hardDeleteNotebook(id);

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

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

/// Replaces local notebooks and notes with [notebooks] and [notes].
///
/// Local strokes are **preserved** — Phase 4A sync moves metadata only,
/// and ink is the user's irreplaceable content. Strokes whose parent
/// note has disappeared remain in the database as orphans until a
/// stroke-aware sync phase reconciles them.
Future<void> importAllData({
required List<Notebook> notebooks,
required List<Note> notes,
}) =>
_repo.replaceAll(notebooks: notebooks, notes: notes);

/// Closes the database opened by this service. No-op when an injected
/// [database] was supplied — the caller owns that connection.
Future<void> close() async {
Expand Down
Loading
Loading