mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-12 07:50:57 +01:00
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
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:
parent
64bce89d87
commit
9349747629
17
AGENTS.md
17
AGENTS.md
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
126
lib/cli/cli_commands.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
32
lib/cli/cli_usage.dart
Normal 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
47
lib/cli/note_lookup.dart
Normal 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;
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
50
lib/data/note_record.dart
Normal 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);
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 = {
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'\n': ' ',
|
||||
'\r': ' ',
|
||||
};
|
||||
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,
|
||||
|
||||
@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
33
lib/ui/note_task_details.dart
Normal file
33
lib/ui/note_task_details.dart
Normal 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),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
24
lib/ui/notes_list_defaults.dart
Normal file
24
lib/ui/notes_list_defaults.dart
Normal 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);
|
||||
@ -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'),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
70
test/cli/cli_args_tasks_test.dart
Normal file
70
test/cli/cli_args_tasks_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
@ -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
79
test/cli/cli_harness.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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'));
|
||||
|
||||
117
test/cli/cli_tasks_test.dart
Normal file
117
test/cli/cli_tasks_test.dart
Normal 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'));
|
||||
});
|
||||
}
|
||||
76
test/cli/note_lookup_test.dart
Normal file
76
test/cli/note_lookup_test.dart
Normal 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'));
|
||||
});
|
||||
}
|
||||
161
test/note_repository_sync_compat_test.dart
Normal file
161
test/note_repository_sync_compat_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
95
test/note_task_fields_test.dart
Normal file
95
test/note_task_fields_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
144
test/notes_list_repo_filter_test.dart
Normal file
144
test/notes_list_repo_filter_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
117
test/notes_markdown_task_fields_test.dart
Normal file
117
test/notes_markdown_task_fields_test.dart
Normal 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 = [
|
||||
'"',
|
||||
"'",
|
||||
'<',
|
||||
'>',
|
||||
'-',
|
||||
'--',
|
||||
'-->',
|
||||
'&',
|
||||
'"',
|
||||
'&',
|
||||
' ',
|
||||
'\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');
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user