mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +01:00
NoteRepository.bulkUpdate sets status/priority on many notes in one store write (mergeLogs + replaceAll; only changed fields plus updated_at get a clock). saveReplacing saves one note and tombstones others in one write. assignToRepo moves plain notes into the repo's open note as "## title" sections via addRepoTask, demoting body headings below "##" so a template note stays one section; notes already in a repo are refused. The seeded watch stream moved to log_watch.dart for the 250-line cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWXdsB7CQPNXVN829G1
251 lines
9.8 KiB
Dart
251 lines
9.8 KiB
Dart
import 'package:crdt_sync/crdt_sync.dart';
|
|
import 'package:todo/data/created_by_backfill.dart';
|
|
import 'package:todo/data/log_watch.dart';
|
|
import 'package:todo/data/note.dart';
|
|
import 'package:todo/data/note_bulk.dart';
|
|
import 'package:todo/data/note_import.dart';
|
|
import 'package:todo/data/note_query.dart';
|
|
import 'package:todo/data/note_record.dart';
|
|
|
|
export 'package:todo/data/note_import.dart';
|
|
export 'package:todo/data/note_query.dart';
|
|
export 'package:todo/data/note_record.dart';
|
|
|
|
// Deliberately free of `dart:io`: the desktop app is a web build, and a single
|
|
// `dart:io` import anywhere in the graph makes the whole app fail to compile
|
|
// for web. Opening a repository for a real platform lives behind the
|
|
// conditional export in `repository_factory.dart`; the legacy sqlite migration
|
|
// lives in the io-only `legacy_sqlite_migration.dart`.
|
|
|
|
/// Local-first persistence for [Note]s, backed by the shared `crdt_sync`
|
|
/// [LogStore] (was `sqlite_crdt`).
|
|
///
|
|
/// Every write goes straight to local storage so the app works fully offline.
|
|
/// Each [Note] is stored *as* a crdt_sync [Record] (id + per-field
|
|
/// last-writer-wins map + sticky tombstone), so the on-disk log **is** the
|
|
/// sync wire format — no separate adapter. Filtering and sorting run in Dart
|
|
/// over the in-memory log (the record fields are opaque to the library); the
|
|
/// note count of a personal backlog is small enough that this is cheap. This
|
|
/// class owns the note↔record mapping and exposes a small, intention-revealing
|
|
/// API; the log never leaks past this boundary.
|
|
class NoteRepository {
|
|
NoteRepository._(this._store, this._nodeId);
|
|
|
|
final LogStore _store;
|
|
final String _nodeId;
|
|
|
|
/// Fires after each successful write ([upsert], [delete], [importLog], and
|
|
/// transitively [importNotes]). Emits `void` — pull the data on demand.
|
|
Stream<void> get changes => _store.changes;
|
|
|
|
/// SharedPreferences key holding this device's stable CRDT node id.
|
|
static const kNodeId = 'crdt.nodeId';
|
|
|
|
/// SharedPreferences key recording that the legacy sqlite import already ran.
|
|
static const kMigrated = 'crdt.migratedFromSqlite';
|
|
|
|
/// File name of the persisted log, used by the io factory.
|
|
static const logFileName = 'todo_notes.json';
|
|
|
|
/// Opens (or creates) the note log backed by [persistence].
|
|
///
|
|
/// Platform wiring — where the log lives, and the one-time legacy sqlite
|
|
/// import — belongs to the callers in `repository_factory_*.dart`, so that
|
|
/// this class stays free of `dart:io` and can compile for web.
|
|
static Future<NoteRepository> openWith({
|
|
required LogPersistence persistence,
|
|
required String nodeId,
|
|
}) async {
|
|
final store = LogStore(persistence: persistence, nodeId: nodeId);
|
|
await store.load();
|
|
return NoteRepository._(store, nodeId);
|
|
}
|
|
|
|
/// Replaces the whole log, used by the one-time legacy sqlite migration.
|
|
///
|
|
/// Distinct from [importLog], which merges: migration seeds a store that is
|
|
/// known to be empty and must preserve the records' own clocks verbatim.
|
|
Future<void> seedFrom(Log log) async {
|
|
if (log.isNotEmpty) await _store.replaceAll(log);
|
|
}
|
|
|
|
/// Opens a transient in-memory log; intended for tests.
|
|
static Future<NoteRepository> openInMemory({
|
|
String nodeId = 'test-node',
|
|
}) async {
|
|
final store = LogStore(persistence: MemoryPersistence(), nodeId: nodeId);
|
|
await store.load();
|
|
return NoteRepository._(store, nodeId);
|
|
}
|
|
|
|
/// Builds a record whose field clocks come from the note's own `updatedAt`
|
|
/// rather than from "now".
|
|
///
|
|
/// Used by every path that *restores* notes the user already had — the
|
|
/// legacy sqlite migration, backup recovery, and file import. Stamping "now"
|
|
/// there is actively harmful: under per-field last-writer-wins, a restored
|
|
/// copy would outrank the same note on every other device, so a device that
|
|
/// recovered from a backup would silently overwrite newer edits made
|
|
/// elsewhere. Seeding from real edit time makes a restore lose to genuinely
|
|
/// newer data, which is what a restore should do.
|
|
///
|
|
/// Public because the legacy migration lives in an io-only library that
|
|
/// cannot reach private members here.
|
|
static Record recordAtNoteTime(
|
|
Note note,
|
|
String nodeId, {
|
|
bool deleted = false,
|
|
}) {
|
|
final hlc = Hlc(
|
|
wallTimeMs: note.updatedAt.millisecondsSinceEpoch,
|
|
counter: 0,
|
|
nodeId: nodeId,
|
|
);
|
|
return Record(
|
|
id: note.id,
|
|
fields: noteFields(note, hlc),
|
|
deleted: deleted,
|
|
deletedHlc: deleted ? hlc : null,
|
|
);
|
|
}
|
|
|
|
/// Inserts a new note or updates the existing one with the same [id].
|
|
///
|
|
/// This is the single write path used by the capture screen's
|
|
/// character-by-character autosave: it is cheap and idempotent. All fields
|
|
/// share one fresh monotonic clock (they are written together).
|
|
Future<void> upsert(Note note) async {
|
|
await _put(Record(id: note.id, fields: noteFields(note, _store.nextHlc())));
|
|
}
|
|
|
|
Future<void> _put(Record record) =>
|
|
_store.upsert(keepCreatedBy(record, _store.get(record.id)));
|
|
|
|
/// Sets `created_by` on every live note that has none, from the node that
|
|
/// wrote its `created_at` (see [classifyCreatedBy]); returns id -> value.
|
|
///
|
|
/// Writes only that field, at a fresh clock, so no other field's clock
|
|
/// moves (a rewrite of `created_at` would erase the signal itself). With
|
|
/// [dryRun] nothing is written.
|
|
Future<Map<String, String>> backfillCreatedBy(
|
|
Set<String> claudeNodes, {
|
|
bool dryRun = false,
|
|
}) async {
|
|
final plan = planCreatedByBackfill(_store.snapshot(), claudeNodes);
|
|
if (dryRun || plan.isEmpty) return plan;
|
|
final hlc = _store.nextHlc();
|
|
await _patch({
|
|
for (final e in plan.entries)
|
|
e.key: Record(id: e.key, fields: {kCreatedByField: (e.value, hlc)}),
|
|
});
|
|
return plan;
|
|
}
|
|
|
|
/// Sets [priority] and/or [status] on the live notes [ids] in ONE store
|
|
/// write (see [planBulkUpdate]); a no-op when nothing changes.
|
|
Future<void> bulkUpdate(
|
|
Set<String> ids, {
|
|
Priority? priority,
|
|
Status? status,
|
|
}) => _patch(
|
|
planBulkUpdate(
|
|
_store.snapshot(),
|
|
ids,
|
|
hlc: _store.nextHlc(),
|
|
now: DateTime.now(),
|
|
priority: priority,
|
|
status: status,
|
|
),
|
|
);
|
|
|
|
/// Saves [note] and deletes [removed] in ONE store write ([planReplace]).
|
|
Future<void> saveReplacing(Note note, Iterable<String> removed) =>
|
|
_patch(planReplace(_store.snapshot(), note, removed, _store.nextHlc()));
|
|
|
|
/// Merges a partial log in one write: untouched fields keep their clocks.
|
|
Future<void> _patch(Log partial) async {
|
|
if (partial.isEmpty) return;
|
|
await _store.replaceAll(mergeLogs(_store.snapshot(), partial));
|
|
}
|
|
|
|
/// Soft-deletes a note. The log keeps a sticky tombstone so the deletion
|
|
/// propagates on the next sync instead of resurrecting the record.
|
|
Future<void> delete(String id) => _store.delete(id);
|
|
|
|
/// Merges [incoming] notes (e.g. from an imported file) into local storage.
|
|
///
|
|
/// Safe by design — it never destroys a *newer* local edit: an incoming
|
|
/// note overwrites the local one only when its [Note.updatedAt] is strictly
|
|
/// newer, or when the id is not present locally. Re-importing a stale backup
|
|
/// is therefore a no-op for notes you've since edited. Notes absent from
|
|
/// [incoming] are kept.
|
|
Future<ImportOutcome> importNotes(List<Note> incoming) async {
|
|
final existing = {for (final n in _liveNotes()) n.id: n};
|
|
var added = 0;
|
|
var updated = 0;
|
|
var skipped = 0;
|
|
for (final note in incoming) {
|
|
final local = existing[note.id];
|
|
// Restore at the note's own edit time, never "now" — see
|
|
// [recordAtNoteTime]. The freshness check above is what decides whether
|
|
// the import wins locally; the clock decides whether it wins on *other*
|
|
// devices, and an import must not win there simply by being recent.
|
|
if (local == null) {
|
|
await _put(recordAtNoteTime(note, _nodeId));
|
|
added++;
|
|
} else if (note.updatedAt.isAfter(local.updatedAt)) {
|
|
await _put(recordAtNoteTime(note, _nodeId));
|
|
updated++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
}
|
|
return ImportOutcome(added: added, updated: updated, skipped: skipped);
|
|
}
|
|
|
|
/// Returns the live notes matching [filter], ordered by [sort].
|
|
Future<List<Note>> listNotes({
|
|
NoteSort sort = NoteSort.priorityDesc,
|
|
NoteFilter filter = const NoteFilter(),
|
|
}) async => _query(sort, filter);
|
|
|
|
/// Emits the matching, ordered note list immediately and re-emits whenever
|
|
/// the log changes, so the UI stays in sync without manual refreshes.
|
|
Stream<List<Note>> watchNotes({
|
|
NoteSort sort = NoteSort.priorityDesc,
|
|
NoteFilter filter = const NoteFilter(),
|
|
}) => watchLog(_store.changes, () => _query(sort, filter));
|
|
|
|
/// Emits the live count of non-deleted notes immediately and on each change.
|
|
Stream<int> watchCount() => watchLog(_store.changes, _liveCount);
|
|
|
|
/// This device's stable node id. Names its file in the sync repo so two
|
|
/// devices never write the same path.
|
|
String get nodeId => _nodeId;
|
|
|
|
/// Returns this device's full note log for upload.
|
|
Log exportLog() => _store.snapshot();
|
|
|
|
/// Merges a remote log into local storage (conflict-free, per-field
|
|
/// last-writer-wins with sticky deletes via [mergeLogs]).
|
|
Future<void> importLog(Log remote) async {
|
|
await _store.replaceAll(mergeLogs(_store.snapshot(), remote));
|
|
}
|
|
|
|
/// Closes the underlying log and its change stream.
|
|
Future<void> close() => _store.close();
|
|
|
|
// --- querying (the note <-> record mapping is in note_record.dart) --------
|
|
|
|
Iterable<Note> _liveNotes() =>
|
|
_store.values.where((r) => !r.deleted).map(noteFromRecord);
|
|
|
|
int _liveCount() => _store.values.where((r) => !r.deleted).length;
|
|
|
|
List<Note> _query(NoteSort sort, NoteFilter filter) {
|
|
final notes = _liveNotes().where((n) => matchesFilter(n, filter)).toList()
|
|
..sort(comparatorFor(sort));
|
|
return notes;
|
|
}
|
|
}
|