todo-app/lib/sync/local_backup.dart
Krzysztof kuhy Rudnicki 6db9ee11d0 Auto-export a local Markdown backup and recover from it on launch
Third durability layer beside GitHub auto-sync and Android Auto Backup:
a plain, human/LLM-readable Markdown file kept current on local disk.

- LocalBackup (lib/sync): pure, injectable file IO. scheduleExport()
  debounces writes (a burst of keystrokes → one export); recover() parses
  the file back into notes. Reused NotesMarkdown serializer.
- CaptureScreen wires it: on launch, recover into an *empty* DB only (so a
  stale backup never clobbers existing notes), then keep the backup current
  as notes change. Platform path = ~/todo/BACKLOG.md on desktop (the path
  the user's workflow already reads) or the app documents dir on mobile
  (covered by Android Auto Backup). File IO is injected in tests.
- Added fake_async dev dep to unit-test the debounce with a virtual clock.

151 tests, 100% line coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:11:08 +02:00

57 lines
1.9 KiB
Dart

import 'dart:async';
import '../data/note.dart';
import 'notes_markdown.dart';
/// Keeps an always-current Markdown backup of all notes on local disk, and
/// recovers from it on launch.
///
/// This is a third durability layer alongside GitHub auto-sync and Android
/// Auto Backup: a plain human-readable file the user (or an LLM) can read
/// directly. File IO is injected ([reader]/[writer]) so the class is pure and
/// fully testable; the platform-specific path lives in the caller.
///
/// Writes are debounced so a burst of keystrokes produces a single export.
class LocalBackup {
LocalBackup({
required this.reader,
required this.writer,
this.debounce = const Duration(seconds: 2),
});
/// Reads the backup file's contents, or null if it does not exist.
final Future<String?> Function() reader;
/// Writes the given Markdown to the backup file (overwriting it).
final Future<void> Function(String markdown) writer;
/// How long to wait after the last change before writing.
final Duration debounce;
Timer? _timer;
/// Schedules a debounced export of [notes]. Repeated calls reset the timer,
/// so only the latest snapshot is written. A zero [debounce] writes
/// immediately (and schedules no timer).
void scheduleExport(List<Note> notes) {
_timer?.cancel();
final markdown = NotesMarkdown.export(notes);
if (debounce == Duration.zero) {
writer(markdown);
} else {
_timer = Timer(debounce, () => writer(markdown));
}
}
/// Reads the backup file and parses it into notes for recovery. Returns an
/// empty list when there is no (usable) backup.
Future<List<Note>> recover() async {
final contents = await reader();
if (contents == null || contents.trim().isEmpty) return const [];
return NotesMarkdown.parse(contents);
}
/// Cancels any pending write. Call from the owner's dispose.
void dispose() => _timer?.cancel();
}