Repo tasks: repo/done_check/by/evidence fields, filter chips, CLI close/list/search
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file-length / file length <= 250 lines (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Four optional note fields (repo, done_check, by, evidence) in the crdt_sync
record, written only when set, so editing a plain idea stays wire-identical
to v3 (note_repository_sync_compat_test). The log is schemaless: no stored
schema version; the legacy sqlite reader stays at v3.

NoteFilter gains a sources facet (ideas / every repo task / one repo);
search also matches the repo. The notes list shows only open ideas by
default (no badge); the filter sheet adds Ideas, Repo tasks and one chip
per repo when repo tasks exist. The per-note sheet shows the task fields
read-only. BACKLOG.md markers carry the new attributes, entity-escaped.

CLI: --repo --done-check --by on add, --close <id> --evidence (refused
without), --list [--repo] [--status], --search, and every id flag takes a
unique 8-character prefix. --list prints id8, status, priority, repo, title.

install_hooks.sh: the wrapper finds pre-commit's generated hook next to
itself, so commits from a git worktree (where .git is a file) work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 08:26:26 +01:00
parent 64bce89d87
commit 9349747629
29 changed files with 1515 additions and 303 deletions

View File

@ -57,10 +57,15 @@ Three layers under `lib/`, each single-purpose:
- `data/` — domain + local storage.
- `note.dart`: `Note` model; `Priority` enum (low/medium/high, **default
medium**, no "none"); `Status` enum (todo/inProgress/done/abandoned).
- `note_repository.dart`: CRDT storage over `sqlite_crdt`. Schema is at
**version 3** with `onCreate`/`onUpgrade` migrations (v1→v2 adds the
`status` column; v2→v3 backfills legacy priority `0`→medium). Also defines
`NoteFilter` (query / priorities / statuses / created+updated date ranges,
Optional repo-task fields `repo`/`doneCheck`/`by`/`evidence`: a note
with `repo` is a repo task, one without is a plain idea.
- `note_repository.dart`: CRDT storage over the schemaless `crdt_sync`
`LogStore`; `note_record.dart` maps a note to record fields `text`,
`priority`, `status`, `created_at`, `updated_at` (+ `repo`, `done_check`,
`by`, `evidence`, written only when set, so an idea's record stays
v3-identical: `note_repository_sync_compat_test.dart`). The old sqlite
schema (v3) survives only in `legacy_sqlite_migration.dart`. Also defines
`NoteFilter` (query / priorities / statuses / sources / date ranges,
AND-combined), `NoteSort`, `watchNotes`/`watchCount` streams, and
`importNotes` (safe newer-wins merge by id).
- `sync/` — GitHub-as-storage sync.
@ -135,8 +140,8 @@ rest of the wrapper one request at a time. BACKLOG.md rewrites links to
## Testing
- **The suite must stay quick and fully green.** It is currently **494 tests at
100% line coverage** (2870 lines), ~40s on this machine with
- **The suite must stay quick and fully green.** It is currently **587 tests at
100% line coverage** (3213 lines), ~40s on this machine with
`--concurrency=3` (unbounded concurrency under `capped.sh`'s 4 GiB gets its
test processes SIGTERMed while the wrapper still exits 0 — check the last
line says "All tests passed"). Don't regress either.

View File

@ -1,9 +1,8 @@
// Headless note CLI: add, list, get, re-status or delete a todo note from the PC.
// Headless note CLI: add, list, search, get, close or delete notes from the PC.
//
// dart run bin/todo_note.dart --text "Title\n\nbody" --priority high
// echo "Title" | dart run bin/todo_note.dart
// dart run bin/todo_note.dart --get <id>
// dart run bin/todo_note.dart --set-status <id> done
// dart run bin/todo_note.dart --text "Title" --repo kuhyx/todo --by claude
// dart run bin/todo_note.dart --list --repo kuhyx/todo
// dart run bin/todo_note.dart --close <id> --evidence "<sha> exit 0"
//
// See `kUsage` in lib/cli/cli_args.dart for every flag. All behaviour lives
// in lib/cli/cli_runner.dart, where the tests reach it.

View File

@ -6,6 +6,8 @@ library;
import 'package:todo/data/note.dart';
export 'package:todo/cli/cli_usage.dart';
/// What the CLI was asked to do.
enum CliMode {
/// Create a note and push it.
@ -23,6 +25,12 @@ enum CliMode {
/// Change one note's status and push it.
setStatus,
/// Set a task done with evidence and push it.
close,
/// Print the notes matching a query, as [list] does.
search,
/// Print usage.
help,
}
@ -47,17 +55,21 @@ class CliArgs {
this.text,
this.id,
this.priority = Priority.medium,
this.status = Status.todo,
this.status,
this.dataDir,
this.opts = (
repo: null,
query: null,
doneCheck: null,
by: null,
evidence: null,
),
});
/// Parses [argv]. Throws [UsageError] on anything it does not understand.
factory CliArgs.parse(List<String> argv) {
String? text;
final opts = <String, String>{};
String? id;
String? dataDir;
var priority = Priority.medium;
var status = Status.todo;
final modes = <CliMode>{};
for (var i = 0; i < argv.length; i++) {
final arg = argv[i];
@ -69,26 +81,21 @@ class CliArgs {
switch (arg) {
case '-h' || '--help':
modes.add(CliMode.help);
case '--text':
text = value();
case '--priority':
priority = _byName(Priority.values, value(), arg);
case '--status':
status = _byName(Status.values, value(), arg, _statusName);
case '--data-dir':
dataDir = value();
case '--text' || '--priority' || '--status' || '--data-dir':
case '--repo' || '--done-check' || '--by' || '--evidence':
opts[arg] = value();
case '--list':
modes.add(CliMode.list);
case '--get':
modes.add(CliMode.get);
id = value();
case '--delete':
modes.add(CliMode.delete);
case '--search':
modes.add(CliMode.search);
opts[arg] = value();
case '--get' || '--delete' || '--close':
modes.add(CliMode.values.byName(arg.substring(2)));
id = value();
case '--set-status':
modes.add(CliMode.setStatus);
id = value();
status = _byName(Status.values, value(), arg, _statusName);
opts[arg] = value();
default:
throw UsageError('unknown argument: $arg');
}
@ -96,20 +103,31 @@ class CliArgs {
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
if (modes.length > 1) {
throw UsageError(
'--list, --get, --delete and --set-status are mutually exclusive',
'--list, --search, --get, --delete, --set-status and --close are '
'mutually exclusive',
);
}
final mode = modes.isEmpty ? CliMode.add : modes.single;
if (mode != CliMode.add && text != null) {
throw UsageError('--text only applies when adding a note');
}
_checkScopes(opts.keys, mode);
final rawStatus = opts['--set-status'] ?? opts['--status'];
return CliArgs(
mode: mode,
text: text,
text: opts['--text'],
id: id,
priority: priority,
status: status,
dataDir: dataDir,
priority: opts['--priority'] == null
? Priority.medium
: _byName(Priority.values, opts['--priority']!, '--priority'),
status: rawStatus == null
? null
: _byName(Status.values, rawStatus, '--status', _statusName),
dataDir: opts['--data-dir'],
opts: (
repo: _repo(opts['--repo']),
query: _nonBlank(opts['--search'], '--search'),
doneCheck: _nonBlank(opts['--done-check'], '--done-check'),
by: opts['--by'] == null ? null : _by(opts['--by']!),
evidence: _evidence(opts['--evidence'], mode),
),
);
}
@ -119,21 +137,85 @@ class CliArgs {
/// Note text from `--text`; null means "read stdin" in [CliMode.add].
final String? text;
/// Note id for [CliMode.get] / [CliMode.delete] / [CliMode.setStatus].
/// Full id or 8+ character prefix of the note an id mode acts on.
final String? id;
/// Priority of a new note.
final Priority priority;
/// Status of a new note, or the target status of [CliMode.setStatus].
final Status status;
/// New note's status, [CliMode.setStatus]'s target, or a list filter.
final Status? status;
/// Overrides the default data dir (a second, independent node).
final String? dataDir;
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
final ({
String? repo,
String? query,
String? doneCheck,
String? by,
String? evidence,
})
opts;
/// The command-line spelling of [status] (`in_progress`, not `inProgress`).
static String statusName(Status status) => _statusName(status);
static const _adding = 'when adding a note';
static const Map<String, (String, Set<CliMode>)> _scopes = {
'--text': (_adding, {CliMode.add}),
'--done-check': (_adding, {CliMode.add}),
'--by': (_adding, {CliMode.add}),
'--repo': (
'$_adding, to --list or to --search',
{CliMode.add, CliMode.list, CliMode.search},
),
'--evidence': ('to --close', {CliMode.close}),
};
static void _checkScopes(Iterable<String> flags, CliMode mode) {
for (final flag in flags) {
final scope = _scopes[flag];
if (scope != null && !scope.$2.contains(mode)) {
throw UsageError('$flag only applies ${scope.$1}');
}
}
}
static final _repoShape = RegExp(r'^[\w.-]+/[\w.-]+$');
static String? _repo(String? raw) {
if (raw == null || _repoShape.hasMatch(raw)) return raw;
throw UsageError(
'--repo must look like owner/name (e.g. kuhyx/todo), got "$raw"',
);
}
static String _by(String raw) {
if (kNoteAuthors.contains(raw)) return raw;
throw UsageError(
'--by must be one of ${kNoteAuthors.join('|')}, got "$raw"',
);
}
static String? _evidence(String? raw, CliMode mode) {
if (mode == CliMode.close && (raw == null || raw.trim().isEmpty)) {
throw UsageError(
'--close needs --evidence <commit sha + done check exit code>: '
'a task is only closed with proof',
);
}
return raw;
}
static String? _nonBlank(String? raw, String flag) {
if (raw != null && raw.trim().isEmpty) {
throw UsageError('$flag must not be empty');
}
return raw;
}
static String _statusName(Enum value) =>
value == Status.inProgress ? 'in_progress' : value.name;
@ -153,24 +235,3 @@ class CliArgs {
static String _plainName(Enum value) => value.name;
}
/// Usage text printed by `--help` and after a [UsageError].
const kUsage = r'''
Usage: dart run bin/todo_note.dart [options]
Adds a note to the todo app headlessly: pulls from Firebase RTDB, writes an
ordinary CRDT change as this install's own node, pushes, and prints the id.
--text <markdown> note text (first line = title); default: read stdin
--priority <p> low|medium|high (default medium)
--status <s> todo|in_progress|done|abandoned (default todo)
--list pull, then print live notes: id, priority, status, title
--get <id> pull, then print one note in full (exit 1 if absent)
--delete <id> pull, tombstone the note, push
--set-status <id> <s> pull, change the note's status (as --status), push
--data-dir <dir> local log + node id (default
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
-h, --help this text
--list and --get never push, so a throwaway --data-dir leaves nothing behind.
Exit codes: 0 ok, 1 sync/auth failure or note not found, 2 bad arguments.''';

126
lib/cli/cli_commands.dart Normal file
View File

@ -0,0 +1,126 @@
import 'package:todo/cli/cli_args.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/cli/note_lookup.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/sync_service.dart';
import 'package:uuid/uuid.dart';
/// One CLI command per method over an open [CliSession]; each returns the
/// exit code. Id arguments are resolved by [findNote].
class CliCommands {
/// Writes results to the first sink, errors and warnings to the second.
CliCommands(this.session, this._out, this._err);
/// The open session the commands act on.
final CliSession session;
final StringSink _out;
final StringSink _err;
/// Creates a note (a repo task when `--repo` is set) and pushes it.
Future<int> add(CliArgs args, String text) async {
final now = DateTime.now();
final note = Note(
id: const Uuid().v4(),
text: text,
priority: args.priority,
status: args.status ?? Status.todo,
createdAt: now,
updatedAt: now,
repo: args.opts.repo,
doneCheck: args.opts.doneCheck,
by: args.opts.by,
);
await session.repository.upsert(note);
if (!await _push('note')) return 1;
_out.writeln(note.id);
return 0;
}
/// Prints [listLine] for each note matching the filters in [args].
Future<int> list(CliArgs args) async {
_report(await session.sync(push: false));
final notes = await session.repository.listNotes(
filter: NoteFilter(
query: args.opts.query ?? '',
statuses: {?args.status},
sources: {?args.opts.repo},
),
);
notes.map(listLine).forEach(_out.writeln);
return 0;
}
/// Prints one note in full.
Future<int> get(String ref) async {
_report(await session.sync(push: false));
final note = findNote(await session.repository.listNotes(), ref);
_out
..writeln('id: ${note.id}')
..writeln('priority: ${note.priority.name}')
..writeln('status: ${CliArgs.statusName(note.status)}')
..writeln('created: ${note.createdAt.toIso8601String()}')
..writeln('updated: ${note.updatedAt.toIso8601String()}');
for (final (name, value) in [
('repo', note.repo),
('done_check', note.doneCheck),
('by', note.by),
('evidence', note.evidence),
]) {
if (value != null) _out.writeln('$name: $value');
}
_out
..writeln('---')
..writeln(note.text);
return 0;
}
/// Tombstones a note and pushes the deletion.
Future<int> delete(String ref) async {
final note = await _pullAndFind(ref);
await session.repository.delete(note.id);
if (!await _push('deletion')) return 1;
_out.writeln('deleted ${note.id}');
return 0;
}
/// Changes a note's status and pushes it.
Future<int> setStatus(String ref, Status status) async {
final note = await _pullAndFind(ref);
await _save(note.copyWith(status: status));
if (!await _push('status change')) return 1;
_out.writeln('${note.id}\t${CliArgs.statusName(status)}');
return 0;
}
/// Marks a note done with [evidence], keeping it, and pushes it.
Future<int> close(String ref, String evidence) async {
final note = await _pullAndFind(ref);
await _save(note.copyWith(status: Status.done, evidence: evidence));
if (!await _push('close')) return 1;
_out.writeln('closed ${note.id}');
return 0;
}
Future<Note> _pullAndFind(String ref) async {
_report(await session.sync(push: true));
return findNote(await session.repository.listNotes(), ref);
}
Future<void> _save(Note note) =>
session.repository.upsert(note.copyWith(updatedAt: DateTime.now()));
Future<bool> _push(String what) async {
if (_report(await session.sync(push: true)).pushed) return true;
_err.writeln('error: $what saved locally but nothing was pushed');
return false;
}
SyncResult _report(SyncResult result) {
for (final name in result.skippedFiles) {
_err.writeln('warning: could not merge peer file $name');
}
return result;
}
}

View File

@ -11,10 +11,9 @@ import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:path/path.dart' as p;
import 'package:todo/cli/cli_args.dart';
import 'package:todo/cli/cli_commands.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/data/note.dart';
import 'package:todo/sync/sync_service.dart';
import 'package:uuid/uuid.dart';
import 'package:todo/cli/note_lookup.dart';
/// Opens a [CliSession]; swapped in tests to point it at a fake backend.
typedef SessionOpener =
@ -74,15 +73,19 @@ Future<int> runCli(
'firebase_auth.json',
),
);
final cli = _Cli(session, out, err);
final cli = CliCommands(session, out, err);
return switch (args.mode) {
CliMode.add => await cli.add(args, text!),
CliMode.list => await cli.list(),
CliMode.list || CliMode.search => await cli.list(args),
CliMode.get => await cli.get(args.id!),
CliMode.setStatus => await cli.setStatus(args.id!, args.status),
CliMode.setStatus => await cli.setStatus(args.id!, args.status!),
CliMode.close => await cli.close(args.id!, args.opts.evidence!),
// Only delete is left: help returned above.
_ => await cli.delete(args.id!),
};
} on NoteLookupError catch (error) {
err.writeln('error: $error');
return 1;
} on RemoteSyncError catch (error) {
err.writeln('error: $error');
return 1;
@ -93,114 +96,3 @@ Future<int> runCli(
await session?.close();
}
}
class _Cli {
_Cli(this.session, this.out, this.err);
final CliSession session;
final StringSink out;
final StringSink err;
Future<int> add(CliArgs args, String text) async {
final now = DateTime.now();
final note = Note(
id: const Uuid().v4(),
text: text,
priority: args.priority,
status: args.status,
createdAt: now,
updatedAt: now,
);
await session.repository.upsert(note);
final result = _report(await session.sync(push: true));
if (!result.pushed) {
err.writeln('error: note saved locally but nothing was pushed');
return 1;
}
out.writeln(note.id);
return 0;
}
Future<int> list() async {
_report(await session.sync(push: false));
for (final note in await session.repository.listNotes()) {
out.writeln(
[
note.id,
note.priority.name,
CliArgs.statusName(note.status),
note.text.split('\n').first,
].join('\t'),
);
}
return 0;
}
Future<int> get(String id) async {
_report(await session.sync(push: false));
final note = await _find(id);
if (note == null) {
err.writeln('error: no live note with id $id');
return 1;
}
out
..writeln('id: ${note.id}')
..writeln('priority: ${note.priority.name}')
..writeln('status: ${CliArgs.statusName(note.status)}')
..writeln('created: ${note.createdAt.toIso8601String()}')
..writeln('updated: ${note.updatedAt.toIso8601String()}')
..writeln('---')
..writeln(note.text);
return 0;
}
Future<int> delete(String id) async {
// Pull first: the note may have been created on another device, and a
// tombstone for an id this node has never seen would only be noise.
_report(await session.sync(push: true));
if (await _find(id) == null) {
err.writeln('error: no live note with id $id');
return 1;
}
await session.repository.delete(id);
if (!_report(await session.sync(push: true)).pushed) {
err.writeln('error: deletion saved locally but nothing was pushed');
return 1;
}
out.writeln('deleted $id');
return 0;
}
Future<int> setStatus(String id, Status status) async {
// Pull first, as in [delete]: the note usually lives on another device.
_report(await session.sync(push: true));
final note = await _find(id);
if (note == null) {
err.writeln('error: no live note with id $id');
return 1;
}
await session.repository.upsert(
note.copyWith(status: status, updatedAt: DateTime.now()),
);
if (!_report(await session.sync(push: true)).pushed) {
err.writeln('error: status change saved locally but nothing was pushed');
return 1;
}
out.writeln('$id\t${CliArgs.statusName(status)}');
return 0;
}
Future<Note?> _find(String id) async {
final notes = await session.repository.listNotes();
return notes.where((n) => n.id == id).firstOrNull;
}
/// Surfaces peers that could not be merged instead of dropping them
/// silently.
SyncResult _report(SyncResult result) {
for (final name in result.skippedFiles) {
err.writeln('warning: could not merge peer file $name');
}
return result;
}
}

32
lib/cli/cli_usage.dart Normal file
View File

@ -0,0 +1,32 @@
/// Usage text printed by `--help` and after a usage error.
const kUsage = r'''
Usage: dart run bin/todo_note.dart [options]
Adds a note to the todo app headlessly: pulls from Firebase RTDB, writes an
ordinary CRDT change as this install's own node, pushes, and prints the id.
A note with --repo is a repo task; one without is a plain idea.
--text <markdown> note text (first line = title); default: read stdin
--priority <p> low|medium|high (default medium)
--status <s> todo|in_progress|done|abandoned (default todo);
with --list/--search: show only that status
--repo <owner/name> repo task's repository, e.g. kuhyx/todo;
with --list/--search: show only that repo's tasks
--done-check <cmd> shell command, run from the repo root, exit 0 = done
--by <who> claude|kuhy
--list pull, then print live notes, one per line:
8-char id, status, priority, repo (- for none), title
--search <q> as --list, only notes whose text or repo contains q
--get <id> pull, then print one note in full (exit 1 if absent)
--delete <id> pull, tombstone the note, push
--set-status <id> <s> pull, change the note's status (as --status), push
--close <id> --evidence <s>
pull, set status done + evidence (commit sha + done
check exit code), push; refused without --evidence
--data-dir <dir> local log + node id (default
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
-h, --help this text
<id> is a full id or a unique prefix of at least 8 characters.
--list, --search and --get never push.
Exit codes: 0 ok, 1 sync/auth failure or note not found, 2 bad arguments.''';

47
lib/cli/note_lookup.dart Normal file
View File

@ -0,0 +1,47 @@
import 'package:todo/cli/cli_args.dart';
import 'package:todo/data/note.dart';
const _minPrefix = 8;
/// No note, or more than one, matches an id argument; [message] says which.
class NoteLookupError extends FormatException {
NoteLookupError._(super.message);
@override
String toString() => message;
}
/// The note with id [ref], else the only one whose id starts with [ref].
Note findNote(Iterable<Note> notes, String ref) {
final exact = notes.where((n) => n.id == ref).firstOrNull;
if (exact != null) return exact;
if (ref.length < _minPrefix) {
throw NoteLookupError._(
'no live note with id $ref (an id prefix needs at least '
'$_minPrefix characters)',
);
}
final matches = notes.where((n) => n.id.startsWith(ref)).toList();
if (matches.isEmpty) throw NoteLookupError._('no live note with id $ref');
if (matches.length > 1) {
throw NoteLookupError._(
'id prefix $ref matches ${matches.length} notes; give more of the id:\n'
'${matches.map((n) => ' ${n.id} ${_title(n)}').join('\n')}',
);
}
return matches.single;
}
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>`.
String listLine(Note note) => [
if (note.id.length <= _minPrefix)
note.id
else
note.id.substring(0, _minPrefix),
CliArgs.statusName(note.status),
note.priority.name,
note.repo ?? '-',
_title(note),
].join('\t');
String _title(Note note) => note.text.split('\n').first;

View File

@ -1,9 +1,8 @@
/// Domain model for a single idea/note.
///
/// Notes are stored locally in a CRDT-backed SQLite table (see
/// [NoteRepository]). The CRDT layer manages its own metadata columns
/// (`hlc`, `node_id`, `modified`, `is_deleted`); the fields here are the
/// user-facing data only.
/// Notes are stored as crdt_sync records (see [NoteRepository] and
/// `note_record.dart`); the clocks and tombstones live there, the fields
/// here are the user-facing data only.
library;
/// Priority tier for a note, used for sorting and visual grouping.
@ -77,7 +76,16 @@ enum Status {
}
}
/// The values the optional `by` field may hold: who owns a repo task.
const Set<String> kNoteAuthors = {'claude', 'kuhy'};
/// An immutable idea/note record.
///
/// A note with a [repo] is a *repo task* (an outstanding piece of work in
/// that repository, closable with [evidence]); one without is a plain idea.
/// The four task fields are optional and set-once in practice: they are
/// written only when a user or the CLI sets them, never by ordinary edits of
/// an idea, which keeps such edits wire-identical to the v3 build.
class Note {
/// Creates a [Note] from its stored fields.
const Note({
@ -87,6 +95,10 @@ class Note {
required this.status,
required this.createdAt,
required this.updatedAt,
this.repo,
this.doneCheck,
this.by,
this.evidence,
});
/// Builds a [Note] from a raw CRDT query row.
@ -122,12 +134,31 @@ class Note {
/// When the note's content was last modified locally.
final DateTime updatedAt;
/// Repository a task belongs to (`kuhyx/<name>`); null for a plain idea.
final String? repo;
/// Shell command, run from the repo root, whose exit 0 proves the task done.
final String? doneCheck;
/// Who does the task: one of [kNoteAuthors].
final String? by;
/// Proof the task was closed (commit sha + the done check's exit code).
final String? evidence;
/// Whether this note is a repo task rather than a plain idea.
bool get isRepoTask => repo != null;
/// Returns a copy with selected fields replaced.
Note copyWith({
String? text,
Priority? priority,
Status? status,
DateTime? updatedAt,
String? repo,
String? doneCheck,
String? by,
String? evidence,
}) {
return Note(
id: id,
@ -136,6 +167,10 @@ class Note {
status: status ?? this.status,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
repo: repo ?? this.repo,
doneCheck: doneCheck ?? this.doneCheck,
by: by ?? this.by,
evidence: evidence ?? this.evidence,
);
}
}

View File

@ -9,6 +9,12 @@ library;
import 'package:todo/data/note.dart';
/// [NoteFilter.sources] entry for plain ideas (notes without a repo).
const kIdeasSource = '';
/// [NoteFilter.sources] entry admitting every repo task.
const kRepoTasksSource = '*';
/// How the history list should be ordered.
enum NoteSort {
/// Newest created first.
@ -35,6 +41,7 @@ class NoteFilter {
this.query = '',
this.priorities = const {},
this.statuses = const {},
this.sources = const {},
this.createdFrom,
this.createdTo,
this.updatedFrom,
@ -50,6 +57,10 @@ class NoteFilter {
/// Notes must have one of these statuses. Empty means "any status".
final Set<Status> statuses;
/// Which notes may show: [kIdeasSource], [kRepoTasksSource] and/or repo
/// names. Empty means every note.
final Set<String> sources;
/// Inclusive lower bound (by calendar day) on the creation date.
final DateTime? createdFrom;
@ -67,6 +78,7 @@ class NoteFilter {
query.trim().isEmpty &&
priorities.isEmpty &&
statuses.isEmpty &&
sources.isEmpty &&
createdFrom == null &&
createdTo == null &&
updatedFrom == null &&
@ -78,6 +90,7 @@ class NoteFilter {
if (query.trim().isNotEmpty) n++;
if (priorities.isNotEmpty) n++;
if (statuses.isNotEmpty) n++;
if (sources.isNotEmpty) n++;
if (createdFrom != null || createdTo != null) n++;
if (updatedFrom != null || updatedTo != null) n++;
return n;
@ -90,6 +103,7 @@ class NoteFilter {
String? query,
Set<Priority>? priorities,
Set<Status>? statuses,
Set<String>? sources,
DateTime? createdFrom,
DateTime? createdTo,
DateTime? updatedFrom,
@ -101,6 +115,7 @@ class NoteFilter {
query: query ?? this.query,
priorities: priorities ?? this.priorities,
statuses: statuses ?? this.statuses,
sources: sources ?? this.sources,
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
@ -115,7 +130,12 @@ class NoteFilter {
/// empty priority set means "any priority", not "no priorities".
bool matchesFilter(Note note, NoteFilter filter) {
final query = filter.query.trim().toLowerCase();
if (query.isNotEmpty && !note.text.toLowerCase().contains(query)) {
if (query.isNotEmpty &&
!note.text.toLowerCase().contains(query) &&
!(note.repo?.toLowerCase().contains(query) ?? false)) {
return false;
}
if (filter.sources.isNotEmpty && !_sourceAllowed(note, filter.sources)) {
return false;
}
if (filter.priorities.isNotEmpty &&
@ -134,6 +154,12 @@ bool matchesFilter(Note note, NoteFilter filter) {
return true;
}
bool _sourceAllowed(Note note, Set<String> sources) {
final repo = note.repo;
if (repo == null) return sources.contains(kIdeasSource);
return sources.contains(kRepoTasksSource) || sources.contains(repo);
}
/// Inclusive day-granularity range check, matching the old SQL bounds:
/// `from` includes its whole day; `to` includes its whole day.
bool _withinDays(DateTime value, DateTime? from, DateTime? to) {

50
lib/data/note_record.dart Normal file
View File

@ -0,0 +1,50 @@
/// Note <-> crdt_sync [Record] mapping; re-exported by `note_repository.dart`.
library;
import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/note.dart';
const _text = 'text';
const _priority = 'priority';
const _status = 'status';
const _createdAt = 'created_at';
const _updatedAt = 'updated_at';
const _repo = 'repo';
const _doneCheck = 'done_check';
const _by = 'by';
const _evidence = 'evidence';
/// Record fields for [note], all at [hlc]. Unset task fields are omitted, so
/// an idea's record is exactly the v3 field set (and cannot clear a peer's).
Map<String, Field> noteFields(Note note, Hlc hlc) => {
_text: (note.text, hlc),
_priority: (note.priority.value, hlc),
_status: (note.status.value, hlc),
_createdAt: (note.createdAt.toIso8601String(), hlc),
_updatedAt: (note.updatedAt.toIso8601String(), hlc),
if (note.repo case final v?) _repo: (v, hlc),
if (note.doneCheck case final v?) _doneCheck: (v, hlc),
if (note.by case final v?) _by: (v, hlc),
if (note.evidence case final v?) _evidence: (v, hlc),
};
/// Reads [record] as a [Note], ignoring unknown fields.
Note noteFromRecord(Record record) {
final fields = record.fields;
String? str(String name) => fields[name]?.$1 as String?;
return Note(
id: record.id,
text: str(_text) ?? '',
priority: Priority.fromValue(fields[_priority]?.$1 as int?),
status: Status.fromValue(fields[_status]?.$1 as int?),
createdAt: _parseDate(str(_createdAt)),
updatedAt: _parseDate(str(_updatedAt)),
repo: str(_repo),
doneCheck: str(_doneCheck),
by: str(_by),
evidence: str(_evidence),
);
}
DateTime _parseDate(String? iso) =>
iso == null ? DateTime.fromMillisecondsSinceEpoch(0) : DateTime.parse(iso);

View File

@ -4,9 +4,11 @@ import 'package:crdt_sync/crdt_sync.dart';
import 'package:todo/data/note.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
@ -35,14 +37,6 @@ class NoteRepository {
/// transitively [importNotes]). Emits `void` — pull the data on demand.
Stream<void> get changes => _store.changes;
// Field names inside each note [Record]; kept identical to the legacy SQL
// column names so a migrated log round-trips through the same [Note] mapping.
static const _fText = 'text';
static const _fPriority = 'priority';
static const _fStatus = 'status';
static const _fCreatedAt = 'created_at';
static const _fUpdatedAt = 'updated_at';
/// SharedPreferences key holding this device's stable CRDT node id.
static const kNodeId = 'crdt.nodeId';
@ -108,7 +102,7 @@ class NoteRepository {
);
return Record(
id: note.id,
fields: _fieldsFor(note, hlc),
fields: noteFields(note, hlc),
deleted: deleted,
deletedHlc: deleted ? hlc : null,
);
@ -121,7 +115,7 @@ class NoteRepository {
/// share one fresh monotonic clock (they are written together).
Future<void> upsert(Note note) async {
await _store.upsert(
Record(id: note.id, fields: _fieldsFor(note, _store.nextHlc())),
Record(id: note.id, fields: noteFields(note, _store.nextHlc())),
);
}
@ -209,34 +203,10 @@ class NoteRepository {
/// Closes the underlying log and its change stream.
Future<void> close() => _store.close();
// --- note <-> record mapping & querying ------------------------------------
static Map<String, Field> _fieldsFor(Note note, Hlc hlc) => {
_fText: (note.text, hlc),
_fPriority: (note.priority.value, hlc),
_fStatus: (note.status.value, hlc),
_fCreatedAt: (note.createdAt.toIso8601String(), hlc),
_fUpdatedAt: (note.updatedAt.toIso8601String(), hlc),
};
Note _toNote(Record record) {
final fields = record.fields;
return Note(
id: record.id,
text: fields[_fText]?.$1 as String? ?? '',
priority: Priority.fromValue(fields[_fPriority]?.$1 as int?),
status: Status.fromValue(fields[_fStatus]?.$1 as int?),
createdAt: _parseDate(fields[_fCreatedAt]?.$1 as String?),
updatedAt: _parseDate(fields[_fUpdatedAt]?.$1 as String?),
);
}
static DateTime _parseDate(String? iso) => iso == null
? DateTime.fromMillisecondsSinceEpoch(0)
: DateTime.parse(iso);
// --- querying (the note <-> record mapping is in note_record.dart) --------
Iterable<Note> _liveNotes() =>
_store.values.where((r) => !r.deleted).map(_toNote);
_store.values.where((r) => !r.deleted).map(noteFromRecord);
int _liveCount() => _store.values.where((r) => !r.deleted).length;

View File

@ -45,7 +45,8 @@ class NotesMarkdown {
'<!-- @note id="${note.id}" priority="${note.priority.name}" '
'status="${note.status.name}" '
'created="${note.createdAt.toIso8601String()}" '
'updated="${note.updatedAt.toIso8601String()}" -->',
'updated="${note.updatedAt.toIso8601String()}"'
'${_taskAttrs(note)} -->',
)
..writeln(toBacklogAttachmentPaths(note.text))
..writeln();
@ -87,19 +88,56 @@ class NotesMarkdown {
DateTime.tryParse(attrs['created'] ?? '') ?? DateTime.now(),
updatedAt:
DateTime.tryParse(attrs['updated'] ?? '') ?? DateTime.now(),
repo: attrs['repo'],
doneCheck: attrs['done_check'],
by: attrs['by'],
evidence: attrs['evidence'],
),
);
}
return notes;
}
/// Extracts `key="value"` pairs from a marker's attribute string.
/// Extracts `key="value"` pairs; an empty value counts as absent.
static Map<String, String> _parseAttrs(String raw) {
return {
for (final m in _attrPattern.allMatches(raw)) m.group(1)!: m.group(2)!,
for (final m in _attrPattern.allMatches(raw))
if (m.group(2)!.isNotEmpty) m.group(1)!: _unescape(m.group(2)!),
};
}
/// Set task fields only, so an idea's marker is unchanged from v3.
static String _taskAttrs(Note note) => [
for (final (name, value) in [
('repo', note.repo),
('done_check', note.doneCheck),
('by', note.by),
('evidence', note.evidence),
])
if (value != null) ' $name="${_escape(value)}"',
].join();
// Entities keep a value on one line, inside its quotes and the comment.
static const _entities = {
'&': '&amp;',
'"': '&quot;',
'<': '&lt;',
'>': '&gt;',
'\n': '&#10;',
'\r': '&#13;',
};
static final _escapable = RegExp('[&"<>\n\r]');
static final _entity = RegExp('&(amp|quot|lt|gt|#10|#13);');
static final Map<String, String> _decode = {
for (final e in _entities.entries) e.value: e.key,
};
static String _escape(String v) =>
v.replaceAllMapped(_escapable, (m) => _entities[m[0]]!);
static String _unescape(String v) =>
v.replaceAllMapped(_entity, (m) => _decode[m[0]]!);
/// Resolves an enum value by its [Enum.name], falling back to [fallback].
static T _enumByName<T extends Enum>(
List<T> values,

View File

@ -10,6 +10,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/note_task_details.dart';
/// The per-note bottom sheet: status/priority chips, and delete.
class NoteActionsSheet extends StatefulWidget {
@ -54,57 +55,60 @@ class _NoteActionsSheetState extends State<NoteActionsSheet> {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
firstLine.isEmpty ? '(empty)' : firstLine,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 16),
_EnumChips<Status>(
label: 'Status',
values: Status.values,
selected: {_status},
labelOf: (s) => s.label,
onSelected: (s) {
setState(() => _status = s);
unawaited(_persist());
},
),
const SizedBox(height: 12),
_EnumChips<Priority>(
label: 'Priority',
values: Priority.values,
selected: {_priority},
labelOf: (p) => p.label,
onSelected: (p) {
setState(() => _priority = p);
unawaited(_persist());
},
),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.delete_outline),
label: const Text('Delete note'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
firstLine.isEmpty ? '(empty)' : firstLine,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onPressed: () async {
final confirmed = await confirmDestructive(
context,
title: 'Delete note?',
message: 'This cannot be undone.',
);
if (!confirmed) return;
await widget.onDelete();
if (context.mounted) Navigator.of(context).pop();
},
),
],
const SizedBox(height: 16),
_EnumChips<Status>(
label: 'Status',
values: Status.values,
selected: {_status},
labelOf: (s) => s.label,
onSelected: (s) {
setState(() => _status = s);
unawaited(_persist());
},
),
const SizedBox(height: 12),
_EnumChips<Priority>(
label: 'Priority',
values: Priority.values,
selected: {_priority},
labelOf: (p) => p.label,
onSelected: (p) {
setState(() => _priority = p);
unawaited(_persist());
},
),
NoteTaskDetails(note: widget.note),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.delete_outline),
label: const Text('Delete note'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
onPressed: () async {
final confirmed = await confirmDestructive(
context,
title: 'Delete note?',
message: 'This cannot be undone.',
);
if (!confirmed) return;
await widget.onDelete();
if (context.mounted) Navigator.of(context).pop();
},
),
],
),
),
),
);

View File

@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
/// Read-only repo-task fields of [note]; empty for a plain idea.
class NoteTaskDetails extends StatelessWidget {
/// Shows the fields [note] has set.
const NoteTaskDetails({required this.note, super.key});
/// The note whose task fields are shown.
final Note note;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme;
final rows = [
('Repo', note.repo),
('Done check', note.doneCheck),
('By', note.by),
('Evidence', note.evidence),
].where((row) => row.$2 != null);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, value) in rows) ...[
const SizedBox(height: 12),
Text(label, style: theme.labelLarge),
const SizedBox(height: 4),
Text(value!, style: theme.bodyMedium),
],
],
);
}
}

View File

@ -11,25 +11,34 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/note_date_range_field.dart';
import 'package:todo/ui/notes_list_screen.dart' show kDefaultStatuses;
import 'package:todo/ui/notes_list_defaults.dart';
/// The filter editing sheet: priority + status multi-select and Created /
/// Last-updated date ranges (presets + a custom range picker). Edits a
/// working copy and returns it via [Navigator.pop] on "Apply".
class FilterSheet extends StatefulWidget {
/// Creates the sheet seeded with the currently applied [initial] filter.
const FilterSheet({required this.initial, super.key});
const FilterSheet({required this.initial, this.repos = const [], super.key});
/// The filter the sheet opens with; edits are made to a working copy.
final NoteFilter initial;
/// Distinct repos of the stored repo tasks, one chip each.
final List<String> repos;
@override
State<FilterSheet> createState() => _FilterSheetState();
}
const Map<String, String> _sourceLabels = {
kIdeasSource: 'Ideas',
kRepoTasksSource: 'Repo tasks',
};
class _FilterSheetState extends State<FilterSheet> {
late Set<Priority> _priorities = {...widget.initial.priorities};
late Set<Status> _statuses = {...widget.initial.statuses};
late Set<String> _sources = {...widget.initial.sources};
late DateTime? _createdFrom = widget.initial.createdFrom;
late DateTime? _createdTo = widget.initial.createdTo;
late DateTime? _updatedFrom = widget.initial.updatedFrom;
@ -45,6 +54,7 @@ class _FilterSheetState extends State<FilterSheet> {
// Reset to the default view (hide Done/Abandoned), not an empty set,
// so "Clear all" matches the app's unfiltered baseline.
_statuses = {...kDefaultStatuses};
_sources = {...kDefaultSources};
_createdFrom = null;
_createdTo = null;
_updatedFrom = null;
@ -57,6 +67,7 @@ class _FilterSheetState extends State<FilterSheet> {
return NoteFilter(
priorities: _priorities,
statuses: _statuses,
sources: _sources,
createdFrom: _createdFrom,
createdTo: _createdTo,
updatedFrom: _updatedFrom,
@ -91,6 +102,16 @@ class _FilterSheetState extends State<FilterSheet> {
],
),
const SizedBox(height: 8),
if (widget.repos.isNotEmpty) ...[
_MultiChips<String>(
label: 'Show',
values: [kIdeasSource, kRepoTasksSource, ...widget.repos],
selected: _sources,
labelOf: (s) => _sourceLabels[s] ?? s,
onToggle: (s) => _toggle(_sources, s),
),
const SizedBox(height: 12),
],
_MultiChips<Status>(
label: 'Status',
values: Status.values,

View File

@ -0,0 +1,24 @@
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
/// The default status selection: hide completed/dropped work.
const Set<Status> kDefaultStatuses = {Status.todo, Status.inProgress};
/// The default source selection: plain ideas only, repo tasks hidden.
const Set<String> kDefaultSources = {kIdeasSource};
/// The notes list's unfiltered baseline, which "Clear all" resets to.
const kDefaultNoteFilter = NoteFilter(
statuses: kDefaultStatuses,
sources: kDefaultSources,
);
/// Facets the user changed from [kDefaultNoteFilter], for the filter badge.
int userFacetCount(NoteFilter filter) {
var count = filter.activeCount;
if (_same(filter.statuses, kDefaultStatuses)) count -= 1;
if (_same(filter.sources, kDefaultSources)) count -= 1;
return count;
}
bool _same<T>(Set<T> a, Set<T> b) => a.length == b.length && a.containsAll(b);

View File

@ -7,12 +7,10 @@ import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/note_tile.dart';
import 'package:todo/ui/notes_filter_sheet.dart';
import 'package:todo/ui/notes_list_defaults.dart';
import 'package:todo/ui/notes_list_navigation.dart';
/// The default status selection: hide completed/dropped work. This is the
/// app's notion of "unfiltered", so it does not count towards the filter
/// badge and is what "Clear all" resets to.
const Set<Status> kDefaultStatuses = {Status.todo, Status.inProgress};
export 'package:todo/ui/notes_list_defaults.dart';
/// Searchable, filterable, sortable list of stored/synced notes.
///
@ -49,23 +47,8 @@ class _NotesListScreenState extends State<NotesListScreen> {
NoteSort _sort = NoteSort.priorityDesc;
/// Default view hides completed/dropped work: only To do + In progress.
/// The user can widen this (or clear it) via the filter sheet.
NoteFilter _filter = const NoteFilter(statuses: kDefaultStatuses);
/// Whether [statuses] is exactly the default selection (so the badge can
/// treat the default view as "unfiltered").
static bool _statusesAreDefault(Set<Status> statuses) =>
statuses.length == kDefaultStatuses.length &&
statuses.containsAll(kDefaultStatuses);
/// Number of *user-applied* filter facets, for the badge. Excludes the
/// default status selection so a fresh list shows no badge.
int get _badgeCount {
var count = _filter.activeCount;
if (_statusesAreDefault(_filter.statuses)) count -= 1;
return count;
}
/// Default view: open ideas only (no Done/Abandoned, no repo tasks).
NoteFilter _filter = kDefaultNoteFilter;
/// Memoised stream; only replaced when [_sort]/[_filter] actually change.
late Stream<List<Note>> _stream;
@ -108,11 +91,15 @@ class _NotesListScreenState extends State<NotesListScreen> {
/// Opens the filter sheet and adopts the edited filter (text query is
/// owned by the search box, so it is preserved across the round-trip).
Future<void> _openFilters() async {
final repos = {
for (final n in await widget.repository.listNotes()) ?n.repo,
}.toList()..sort();
if (!mounted) return;
final edited = await showModalBottomSheet<NoteFilter>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => FilterSheet(initial: _filter),
builder: (_) => FilterSheet(initial: _filter, repos: repos),
);
if (edited != null) {
_filter = edited.copyWith(query: _searchController.text);
@ -122,7 +109,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
@override
Widget build(BuildContext context) {
final badgeCount = _badgeCount;
final badgeCount = userFacetCount(_filter);
return Scaffold(
appBar: AppBar(
title: const Text('Notes'),

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.47+1
version: 1.0.48+1
environment:
sdk: ^3.12.2

View File

@ -69,7 +69,7 @@ readonly ROOT
# The read-only gates. A non-zero exit here rejects the commit, and the bump
# below never runs, so a rejected attempt leaves no stray version change.
"$ROOT/.git/hooks/pre-commit.pre-commit-generated" "$@"
"$(dirname "${BASH_SOURCE[0]}")/pre-commit.pre-commit-generated" "$@"
exec bash "$ROOT/scripts/bump_patch_version.sh"
HOOK_BODY

View File

@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/cli_args.dart';
import 'package:todo/data/note.dart';
void expectUsage(List<String> argv, String fragment) {
expect(
() => CliArgs.parse(argv),
throwsA(
isA<UsageError>().having((e) => e.message, 'message', contains(fragment)),
),
);
}
void main() {
test('add reads the task fields', () {
final args = CliArgs.parse([
'--repo',
'kuhyx/todo',
'--done-check',
'flutter test',
'--by',
'claude',
]);
expect(args.mode, CliMode.add);
expect(args.opts.repo, 'kuhyx/todo');
expect(args.opts.doneCheck, 'flutter test');
expect(args.opts.by, 'claude');
expect(args.status, isNull);
});
test('list and search take --repo and --status as filters', () {
final list = CliArgs.parse(['--list', '--repo', 'kuhyx/a.b-c_d']);
expect(list.mode, CliMode.list);
expect(list.opts.repo, 'kuhyx/a.b-c_d');
expect(list.status, isNull);
final search = CliArgs.parse(['--search', 'gate', '--status', 'done']);
expect(search.mode, CliMode.search);
expect(search.opts.query, 'gate');
expect(search.status, Status.done);
});
test('close takes an id and evidence', () {
final args = CliArgs.parse(['--close', 'abcdefgh', '--evidence', 'x 0']);
expect(args.mode, CliMode.close);
expect(args.id, 'abcdefgh');
expect(args.opts.evidence, 'x 0');
});
test('close is refused without evidence', () {
expectUsage(['--close', 'abcdefgh'], '--close needs --evidence');
expectUsage(['--close', 'a', '--evidence', ' '], '--close needs');
});
test('task flags are refused outside their modes', () {
expectUsage(['--evidence', 'e'], '--evidence only applies to --close');
expectUsage(['--list', '--by', 'kuhy'], '--by only applies when adding');
expectUsage(['--list', '--done-check', 'x'], 'only applies when adding');
expectUsage(['--get', 'a', '--repo', 'kuhyx/x'], '--repo only applies');
expectUsage(['--list', '--close', 'a'], 'mutually exclusive');
expectUsage(['--search', 'q', '--list'], 'mutually exclusive');
});
test('values are validated', () {
expectUsage(['--by', 'bob'], '--by must be one of claude|kuhy');
expectUsage(['--repo', 'todo'], '--repo must look like owner/name');
expectUsage(['--repo', 'kuhyx/to do'], 'owner/name');
expectUsage(['--done-check', ' '], '--done-check must not be empty');
expectUsage(['--search', ''], '--search must not be empty');
});
}

View File

@ -9,7 +9,7 @@ void main() {
expect(args.mode, CliMode.add);
expect(args.text, isNull);
expect(args.priority, Priority.medium);
expect(args.status, Status.todo);
expect(args.status, isNull);
expect(args.dataDir, isNull);
});

79
test/cli/cli_harness.dart Normal file
View File

@ -0,0 +1,79 @@
/// Runs `runCli` against a [FakeRtdb] from a temp HOME. Not a `*_test.dart`.
library;
import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/cli_runner.dart';
import 'package:todo/cli/cli_session.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'fake_rtdb.dart';
class CliHarness {
CliHarness() {
final home = Directory('${tmp.path}/home')..createSync();
Directory('${home.path}/.config/todo').createSync(recursive: true);
writeSeedCredentials(
tmp,
).copySync('${home.path}/.config/todo/firebase_auth.json');
env = {'HOME': home.path, 'XDG_DATA_HOME': '${tmp.path}/xdg'};
}
final tmp = Directory.systemTemp.createTempSync('cli_harness');
final db = FakeRtdb();
late final Map<String, String> env;
final out = StringBuffer();
final err = StringBuffer();
void dispose() => tmp.deleteSync(recursive: true);
String dir(String name) => '${tmp.path}/$name';
Future<int> run(List<String> argv, {SessionOpener? opener}) async {
out.clear();
err.clear();
return runCli(
argv,
env: env,
out: out,
err: err,
readStdin: () async => '',
open:
opener ??
(d, {required seedCredentials}) => CliSession.open(
d,
seedCredentials: seedCredentials,
httpClient: db.client,
),
);
}
/// Adds a note from node [node] and returns its id.
Future<String> add(String text, [List<String> extra = const []]) async {
expect(await run(['--data-dir', dir('a'), '--text', text, ...extra]), 0);
return out.toString().trim();
}
/// Publishes [notes] as another device's log.
void seedPeer(List<Note> notes) {
db.files['todo-sync/notes/peer~2Ejson'] = logToJson({
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, 'peer'),
});
}
}
Note fixtureNote(String id, String text, {String? repo}) {
final at = DateTime.utc(2026, 11);
return Note(
id: id,
text: text,
priority: Priority.medium,
status: Status.todo,
createdAt: at,
updatedAt: at,
repo: repo,
);
}

View File

@ -93,7 +93,7 @@ void main() {
test('reads the text from stdin when --text is absent', () async {
expect(await run(['--data-dir', dir('a')], stdin: 'From stdin'), 0);
expect(await run(['--list', '--data-dir', dir('a')]), 0);
expect(out.toString(), contains('\tmedium\ttodo\tFrom stdin'));
expect(out.toString(), contains('\ttodo\tmedium\t-\tFrom stdin'));
});
test('uses the XDG data dir by default', () async {
@ -119,7 +119,10 @@ void main() {
]);
expect(await run(['--list', '--data-dir', dir('b')]), 0);
expect(out.toString().trim(), '$id\thigh\tin_progress\tTitle');
expect(
out.toString().trim(),
'${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle',
);
expect(await run(['--get', id, '--data-dir', dir('b')]), 0);
expect(out.toString(), contains('id: $id'));

View File

@ -0,0 +1,117 @@
// Repo tasks through the CLI: create, list, search, close, id prefixes.
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/cli_session.dart';
import 'cli_harness.dart';
import 'scripted_session.dart';
void main() {
late CliHarness h;
setUp(() => h = CliHarness());
tearDown(() => h.dispose());
Future<String> addTask(String text, String repo) => h.add(text, [
'--repo',
repo,
'--done-check',
'test -f "a b"',
'--by',
'claude',
]);
test('a task created on one node shows its fields on another', () async {
final id = await addTask('Fix gate', 'kuhyx/todo');
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
expect(
h.out.toString(),
allOf(
contains('repo: kuhyx/todo'),
contains('done_check: test -f "a b"'),
contains('by: claude'),
isNot(contains('evidence:')),
),
);
});
test('--list filters by repo and status, one compact line each', () async {
final todo = await addTask('Fix gate', 'kuhyx/todo');
await addTask('Other', 'kuhyx/utils');
await h.add('Plain idea');
expect(await h.run(['--list', '--repo', 'kuhyx/todo']), 0);
expect(
h.out.toString(),
'${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\tFix gate\n',
);
expect(await h.run(['--list', '--status', 'done']), 0);
expect(h.out.toString(), isEmpty);
expect(await h.run(['--list']), 0);
expect(h.out.toString().trim().split('\n'), hasLength(3));
});
test('--search matches text and repo, case-insensitively', () async {
await addTask('Fix the GATE', 'kuhyx/todo');
await addTask('Other', 'kuhyx/utils');
expect(await h.run(['--search', 'gate']), 0);
expect(h.out.toString(), contains('\tkuhyx/todo\tFix the GATE'));
expect(h.out.toString(), isNot(contains('Other')));
expect(await h.run(['--search', 'UTILS', '--repo', 'kuhyx/utils']), 0);
expect(h.out.toString(), contains('Other'));
});
test('--close without evidence exits 2 before any network', () async {
expect(await h.run(['--close', 'abcdefgh']), 2);
expect(h.err.toString(), contains('--close needs --evidence'));
expect(h.db.methods, isEmpty);
});
test(
'--close by 8-char prefix sets done and evidence, keeps the note',
() async {
final id = await addTask('Fix gate', 'kuhyx/todo');
final prefix = id.substring(0, 8);
expect(
await h.run([
'--close', prefix, '--evidence', 'abc1234 exit 0', //
'--data-dir', h.dir('b'),
]),
0,
);
expect(h.out.toString().trim(), 'closed $id');
expect(await h.run(['--get', prefix, '--data-dir', h.dir('c')]), 0);
expect(h.out.toString(), contains('status: done'));
expect(h.out.toString(), contains('evidence: abc1234 exit 0'));
expect(h.out.toString(), contains('repo: kuhyx/todo'));
},
);
test(
'every id flag accepts a prefix; ambiguity names the candidates',
() async {
h.seedPeer([
fixtureNote('12345678-a', 'First'),
fixtureNote('12345678-b', 'Second'),
fixtureNote('87654321-c', 'Third'),
]);
expect(await h.run(['--set-status', '87654321', 'in_progress']), 0);
expect(h.out.toString().trim(), '87654321-c\tin_progress');
expect(await h.run(['--get', '12345678']), 1);
expect(h.err.toString(), contains('12345678-a First'));
expect(h.err.toString(), contains('12345678-b Second'));
expect(await h.run(['--delete', '12345678']), 1);
expect(await h.run(['--close', 'ffffffff', '--evidence', 'e']), 1);
expect(h.err.toString(), contains('no live note with id ffffffff'));
expect(await h.run(['--delete', '87654321']), 0);
},
);
test('--close fails when the push reports nothing published', () async {
Future<CliSession> opener(String d, {required String seedCredentials}) =>
ScriptedSession.create(h.db.client, [true, false], noteId: 'stuck');
expect(
await h.run(['--close', 'stuck', '--evidence', 'e'], opener: opener),
1,
);
expect(h.err.toString(), contains('close saved locally'));
});
}

View File

@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/cli/note_lookup.dart';
import 'package:todo/data/note.dart';
import 'cli_harness.dart';
void main() {
final notes = [
fixtureNote('abcdefgh', 'Exact'),
fixtureNote('abcdefgh-2', 'Longer'),
fixtureNote('12345678-a', 'First\nbody'),
fixtureNote('12345678-b', 'Second'),
fixtureNote('99999999-z', 'Only', repo: 'kuhyx/todo'),
];
test('an exact id wins over a longer id sharing it as prefix', () {
expect(findNote(notes, 'abcdefgh').text, 'Exact');
});
test('a unique prefix of at least 8 characters resolves', () {
expect(findNote(notes, '99999999').text, 'Only');
expect(findNote(notes, '99999999-').text, 'Only');
});
test('an ambiguous prefix names every candidate', () {
expect(
() => findNote(notes, '12345678'),
throwsA(
isA<NoteLookupError>().having(
(e) => e.toString(),
'message',
allOf([
contains('2 notes'),
contains('12345678-a First'),
contains('12345678-b Second'),
]),
),
),
);
});
test('an unknown id is not found', () {
expect(
() => findNote(notes, 'ffffffff'),
throwsA(
isA<NoteLookupError>().having(
(e) => e.message,
'message',
'no live note with id ffffffff',
),
),
);
});
test('a short non-exact id is not prefix-matched', () {
expect(
() => findNote(notes, 'abc'),
throwsA(
isA<NoteLookupError>().having(
(e) => e.message,
'message',
allOf(contains('no live note with id abc'), contains('8 characters')),
),
),
);
});
test('listLine: 8-char id, status, priority, repo, title', () {
expect(
listLine(notes[2].copyWith(status: Status.inProgress)),
'12345678\tin_progress\tmedium\t-\tFirst',
);
expect(listLine(notes[4]), '99999999\ttodo\tmedium\tkuhyx/todo\tOnly');
expect(listLine(fixtureNote('abc', 'Short')), startsWith('abc\t'));
});
}

View File

@ -0,0 +1,161 @@
// Wire compatibility between the v4 note mapping (repo task fields) and
// devices still running the v3 build, which knows only the five original
// fields. Both builds share crdt_sync 0.11.1, so the v3 side is modelled by
// its own note<->record mapping, copied verbatim below.
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
/// The exact field set the v3 build writes for every note.
const _v3Keys = {'text', 'priority', 'status', 'created_at', 'updated_at'};
/// The v3 build's `_fieldsFor`, verbatim.
Map<String, Field> _v3FieldsFor(Note note, Hlc hlc) => {
'text': (note.text, hlc),
'priority': (note.priority.value, hlc),
'status': (note.status.value, hlc),
'created_at': (note.createdAt.toIso8601String(), hlc),
'updated_at': (note.updatedAt.toIso8601String(), hlc),
};
/// The v3 build's `_toNote`, verbatim: unknown fields are never looked at.
Note _v3Read(Record record) => Note(
id: record.id,
text: record.fields['text']?.$1 as String? ?? '',
priority: Priority.fromValue(record.fields['priority']?.$1 as int?),
status: Status.fromValue(record.fields['status']?.$1 as int?),
createdAt: DateTime.parse(record.fields['created_at']!.$1! as String),
updatedAt: DateTime.parse(record.fields['updated_at']!.$1! as String),
);
Note _task({String text = 'Fix the gate'}) {
final at = DateTime.utc(2026, 11);
return Note(
id: 'task-1',
text: text,
priority: Priority.high,
status: Status.todo,
createdAt: at,
updatedAt: at,
repo: 'kuhyx/todo',
doneCheck: 'flutter test "test/a b.dart"',
by: 'claude',
evidence: 'abc123 exit 0',
);
}
/// What a peer receives: the log as it travels through RTDB (JSON text).
Log _overTheWire(Log log) => logFromJson(logToJson(log));
void main() {
test(
'v3 peer merges and reads a v4 repo-task record without error',
() async {
final v4 = await NoteRepository.openInMemory(nodeId: 'v4');
await v4.upsert(_task());
final v3 = LogStore(persistence: MemoryPersistence(), nodeId: 'v3');
await v3.load();
await v3.replaceAll(
mergeLogs(v3.snapshot(), _overTheWire(v4.exportLog())),
);
final seen = _v3Read(v3.snapshot()['task-1']!);
expect(seen.text, 'Fix the gate');
expect(seen.priority, Priority.high);
// The v3 log still carries the fields it does not understand.
expect(v3.snapshot()['task-1']!.fields['repo']!.$1, 'kuhyx/todo');
},
);
test('a v3 edit drops the new fields locally; the next merge restores '
'them and keeps the v3 edit', () async {
final v4 = await NoteRepository.openInMemory(nodeId: 'v4');
await v4.upsert(_task());
final v3 = LogStore(persistence: MemoryPersistence(), nodeId: 'v3');
await v3.load();
await v3.replaceAll(mergeLogs(v3.snapshot(), _overTheWire(v4.exportLog())));
final edited = _v3Read(v3.snapshot()['task-1']!).copyWith(
text: 'Fix the gate (edited on v3)',
updatedAt: DateTime.utc(2026, 11, 2),
);
// LogStore.upsert replaces the whole record with the v3 field set.
await v3.upsert(
Record(id: edited.id, fields: _v3FieldsFor(edited, v3.nextHlc())),
);
expect(v3.snapshot()['task-1']!.fields.keys.toSet(), _v3Keys);
await v4.importLog(_overTheWire(v3.snapshot()));
final merged = (await v4.listNotes()).single;
expect(merged.text, 'Fix the gate (edited on v3)');
expect(merged.repo, 'kuhyx/todo');
expect(merged.doneCheck, 'flutter test "test/a b.dart"');
expect(merged.by, 'claude');
expect(merged.evidence, 'abc123 exit 0');
// And the v3 device gets them back on its next pull.
await v3.replaceAll(mergeLogs(v3.snapshot(), _overTheWire(v4.exportLog())));
expect(v3.snapshot()['task-1']!.fields['repo']!.$1, 'kuhyx/todo');
});
test('v4 reads a v3 record (no new fields) as a plain idea', () async {
final at = DateTime.utc(2026);
final idea = Note(
id: 'idea-1',
text: 'Idea',
priority: Priority.low,
status: Status.todo,
createdAt: at,
updatedAt: at,
);
final hlc = Hlc(wallTimeMs: 1, counter: 0, nodeId: 'v3');
final v4 = await NoteRepository.openInMemory(nodeId: 'v4');
await v4.importLog({
'idea-1': Record(id: 'idea-1', fields: _v3FieldsFor(idea, hlc)),
});
final read = (await v4.listNotes()).single;
expect(read.text, 'Idea');
expect(read.repo, isNull);
expect(read.doneCheck, isNull);
expect(read.by, isNull);
expect(read.evidence, isNull);
});
test('v4 editing a plain idea writes exactly the v3 field set', () async {
final v4 = await NoteRepository.openInMemory(nodeId: 'v4');
final at = DateTime.utc(2026);
final idea = Note(
id: 'idea-1',
text: 'Idea',
priority: Priority.medium,
status: Status.todo,
createdAt: at,
updatedAt: at,
);
await v4.upsert(idea);
await v4.upsert(idea.copyWith(text: 'Idea, edited', status: Status.done));
await v4.importNotes([idea.copyWith(updatedAt: DateTime.utc(2027))]);
final record = _overTheWire(v4.exportLog())['idea-1']!;
expect(record.fields.keys.toSet(), _v3Keys);
expect(_v3Read(record).text, 'Idea');
});
test('a restored repo task keeps its fields at the note\'s own clock', () {
final record = NoteRepository.recordAtNoteTime(_task(), 'n');
expect(record.fields.keys.toSet(), {
..._v3Keys,
'repo',
'done_check',
'by',
'evidence',
});
expect(
record.fields['repo']!.$2.wallTimeMs,
DateTime.utc(2026, 11).millisecondsSinceEpoch,
);
});
}

View File

@ -0,0 +1,95 @@
// The repo-task fields on Note and the source facet of NoteFilter.
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
Note _note(String id, {String? repo, String text = 'x'}) {
final at = DateTime.utc(2026);
return Note(
id: id,
text: text,
priority: Priority.medium,
status: Status.todo,
createdAt: at,
updatedAt: at,
repo: repo,
);
}
void main() {
group('Note task fields', () {
test('default to null, which makes a plain idea', () {
final idea = _note('a');
expect(idea.repo, isNull);
expect(idea.doneCheck, isNull);
expect(idea.by, isNull);
expect(idea.evidence, isNull);
expect(idea.isRepoTask, isFalse);
expect(_note('b', repo: 'kuhyx/todo').isRepoTask, isTrue);
});
test('copyWith keeps them unless replaced', () {
final task = _note(
'a',
repo: 'kuhyx/todo',
).copyWith(doneCheck: 'true', by: 'kuhy', evidence: 'e');
final edited = task.copyWith(text: 'y');
expect(edited.repo, 'kuhyx/todo');
expect(edited.doneCheck, 'true');
expect(edited.by, 'kuhy');
expect(edited.evidence, 'e');
expect(task.copyWith(repo: 'kuhyx/utils').repo, 'kuhyx/utils');
});
test('kNoteAuthors is exactly claude and kuhy', () {
expect(kNoteAuthors, {'claude', 'kuhy'});
});
});
group('NoteFilter.sources', () {
final idea = _note('idea');
final todo = _note('t', repo: 'kuhyx/todo');
final utils = _note('u', repo: 'kuhyx/utils');
List<String> pass(Set<String> sources) => [
for (final n in [idea, todo, utils])
if (matchesFilter(n, NoteFilter(sources: sources))) n.id,
];
test('empty means every note', () {
expect(pass({}), ['idea', 't', 'u']);
expect(const NoteFilter().isEmpty, isTrue);
});
test('ideas only', () => expect(pass({kIdeasSource}), ['idea']));
test('the Repo tasks source lets every repo task in', () {
expect(pass({kRepoTasksSource}), ['t', 'u']);
expect(pass({kIdeasSource, kRepoTasksSource}), ['idea', 't', 'u']);
});
test('a repo source lets only that repo in', () {
expect(pass({'kuhyx/utils'}), ['u']);
expect(pass({kIdeasSource, 'kuhyx/todo'}), ['idea', 't']);
});
test('ANDs with the other facets', () {
final f = const NoteFilter(
sources: {kRepoTasksSource},
).copyWith(query: 'zzz');
expect(matchesFilter(todo, f), isFalse);
});
test('counts as one facet and survives copyWith', () {
const f = NoteFilter(sources: {kIdeasSource});
expect(f.isEmpty, isFalse);
expect(f.activeCount, 1);
expect(f.copyWith(query: 'q').sources, {kIdeasSource});
expect(f.copyWith(sources: {}).sources, isEmpty);
});
test('search also matches the repo name', () {
expect(matchesFilter(utils, const NoteFilter(query: 'UTILS')), isTrue);
expect(matchesFilter(idea, const NoteFilter(query: 'utils')), isFalse);
});
});
}

View File

@ -0,0 +1,144 @@
// Repo tasks in the notes list: hidden by default, one chip per repo plus a
// "Repo tasks" chip in the filter sheet, fields read-only in the note sheet.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/note_actions_sheet.dart';
import 'notes_list_harness.dart';
Note task(String id, String repo, {String? evidence}) =>
note(id, 'Task $id').copyWith(
repo: repo,
doneCheck: 'make check',
by: 'claude',
evidence: evidence,
);
final _seed = [
note('i', 'An idea'),
task('t', 'kuhyx/utils'),
task('u', 'kuhyx/todo'),
task('v', 'kuhyx/todo'),
];
Future<void> openSheet(WidgetTester tester) async {
await tester.tap(find.byIcon(Icons.filter_list));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
Future<void> apply(WidgetTester tester) async {
await tester.ensureVisible(find.text('Apply'));
await tester.pump();
await tester.tap(find.text('Apply'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
bool chipSelected(WidgetTester tester, String label) =>
tester.widget<FilterChip>(find.widgetWithText(FilterChip, label)).selected;
bool badgeShown(WidgetTester tester) =>
tester.widget<Badge>(find.byType(Badge)).isLabelVisible;
void main() {
testWidgets('the default view shows only ideas, with no badge', (
tester,
) async {
final repo = await pumpList(tester, seed: _seed);
expect(repo.lastFilter!.sources, {kIdeasSource});
expect(badgeShown(tester), isFalse);
});
testWidgets('the sheet offers Ideas, Repo tasks and one chip per repo', (
tester,
) async {
await pumpList(tester, seed: _seed);
await openSheet(tester);
expect(chipSelected(tester, 'Ideas'), isTrue);
expect(chipSelected(tester, 'Repo tasks'), isFalse);
expect(find.widgetWithText(FilterChip, 'kuhyx/todo'), findsOneWidget);
expect(find.widgetWithText(FilterChip, 'kuhyx/utils'), findsOneWidget);
expect(chipSelected(tester, 'kuhyx/todo'), isFalse);
});
testWidgets('Repo tasks lets every repo task in and shows the badge', (
tester,
) async {
final repo = await pumpList(tester, seed: _seed);
await openSheet(tester);
await tester.tap(find.text('Repo tasks'));
await tester.pump();
await apply(tester);
expect(repo.lastFilter!.sources, {kIdeasSource, kRepoTasksSource});
expect(badgeShown(tester), isTrue);
});
testWidgets('a repo chip, without Ideas, shows only that repo', (
tester,
) async {
final repo = await pumpList(tester, seed: _seed);
await openSheet(tester);
await tester.tap(find.text('Ideas'));
await tester.tap(find.text('kuhyx/utils'));
await tester.pump();
await apply(tester);
expect(repo.lastFilter!.sources, {'kuhyx/utils'});
});
testWidgets('Clear all resets the source chips to ideas only', (
tester,
) async {
final repo = await pumpList(tester, seed: _seed);
await openSheet(tester);
await tester.tap(find.text('kuhyx/todo'));
await tester.pump();
await tester.tap(find.text('Clear all'));
await tester.pump();
await apply(tester);
expect(repo.lastFilter!.sources, {kIdeasSource});
expect(badgeShown(tester), isFalse);
});
Future<void> pumpActions(WidgetTester tester, Note n) => tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: NoteActionsSheet(
note: n,
onChanged: (_) async {},
onDelete: () async {},
),
),
),
);
testWidgets('the note sheet shows task fields read-only when present', (
tester,
) async {
await pumpActions(tester, task('t', 'kuhyx/todo', evidence: 'abc 0'));
expect(find.text('kuhyx/todo'), findsOneWidget);
expect(find.text('make check'), findsOneWidget);
expect(find.text('claude'), findsOneWidget);
expect(find.text('abc 0'), findsOneWidget);
expect(find.text('Done check'), findsOneWidget);
expect(find.text('Evidence'), findsOneWidget);
expect(find.byType(TextField), findsNothing);
});
testWidgets('no repo tasks stored: the sheet has no source chips', (
tester,
) async {
await pumpList(tester, seed: [note('i', 'An idea')]);
await openSheet(tester);
expect(find.text('Repo tasks'), findsNothing);
});
testWidgets('a plain idea shows no task fields', (tester) async {
await pumpActions(tester, note('i', 'An idea'));
expect(find.text('Repo'), findsNothing);
expect(find.text('Done check'), findsNothing);
expect(find.text('Evidence'), findsNothing);
});
}

View File

@ -0,0 +1,117 @@
// BACKLOG.md markers carry the repo-task attributes, escaped so any value
// survives the single-line `<!-- @note ... -->` marker.
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/sync/notes_markdown.dart';
Note _note({String? repo, String? doneCheck, String? by, String? evidence}) {
final at = DateTime.utc(2026, 11, 2, 9);
return Note(
id: 'n1',
text: 'Title\n\nbody',
priority: Priority.high,
status: Status.done,
createdAt: at,
updatedAt: at,
repo: repo,
doneCheck: doneCheck,
by: by,
evidence: evidence,
);
}
void _expectSameTaskFields(Note actual, Note expected) {
expect(actual.repo, expected.repo);
expect(actual.doneCheck, expected.doneCheck);
expect(actual.by, expected.by);
expect(actual.evidence, expected.evidence);
}
void main() {
test('a plain idea exports no task attributes (byte-identical marker)', () {
final out = NotesMarkdown.export([_note()]);
expect(
out,
contains(
'<!-- @note id="n1" priority="high" status="done" '
'created="2026-11-02T09:00:00.000Z" '
'updated="2026-11-02T09:00:00.000Z" -->\n',
),
);
_expectSameTaskFields(NotesMarkdown.parse(out).single, _note());
});
test('task attributes are exported and parsed back', () {
final task = _note(
repo: 'kuhyx/todo',
doneCheck: 'test -f "a b" && echo \'ok\' --> done',
by: 'claude',
evidence: 'abc1234 exit 0',
);
final out = NotesMarkdown.export([task]);
expect(out, contains('repo="kuhyx/todo"'));
expect(out, contains('by="claude"'));
// The value can never close the comment or the attribute early.
final marker = out.split('\n').firstWhere((l) => l.contains('@note'));
expect('-->'.allMatches(marker), hasLength(1));
final parsed = NotesMarkdown.parse(out).single;
_expectSameTaskFields(parsed, task);
expect(parsed.text, task.text);
});
test('an empty attribute value reads back as absent', () {
const doc = '<!-- @note id="x" repo="" evidence="" -->\nbody\n';
final parsed = NotesMarkdown.parse(doc).single;
expect(parsed.repo, isNull);
expect(parsed.evidence, isNull);
});
test('property: hostile attribute values round-trip (seeded)', () {
// Characters that break naive markers: quotes, the comment closer,
// entity look-alikes (double-decoding), newlines and non-ASCII.
const alphabet = [
'"',
"'",
'<',
'>',
'-',
'--',
'-->',
'&',
'&quot;',
'&amp;',
'&#10;',
'\n',
'\r',
'\t',
' ',
'=',
'a',
'Z',
'0',
'/',
r'\',
'ż',
'🙂',
];
final random = Random(20261102);
String value() => [
for (var i = random.nextInt(12) + 1; i > 0; i--)
alphabet[random.nextInt(alphabet.length)],
].join();
for (var run = 0; run < 300; run++) {
final task = _note(
repo: value(),
doneCheck: value(),
by: value(),
evidence: value(),
);
final parsed = NotesMarkdown.parse(NotesMarkdown.export([task]));
expect(parsed, hasLength(1), reason: 'run $run');
_expectSameTaskFields(parsed.single, task);
expect(parsed.single.text, task.text, reason: 'run $run');
}
});
}