diff --git a/bin/todo_note.dart b/bin/todo_note.dart index 7c2de7d..19f374a 100644 --- a/bin/todo_note.dart +++ b/bin/todo_note.dart @@ -1,8 +1,9 @@ -// Headless note CLI: add, list, get or delete a todo note from the PC. +// Headless note CLI: add, list, get, re-status or delete a todo note 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 +// dart run bin/todo_note.dart --set-status done // // 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. diff --git a/lib/cli/cli_args.dart b/lib/cli/cli_args.dart index 7a3876a..63df288 100644 --- a/lib/cli/cli_args.dart +++ b/lib/cli/cli_args.dart @@ -20,6 +20,9 @@ enum CliMode { /// Tombstone one note and push the deletion. delete, + /// Change one note's status and push it. + setStatus, + /// Print usage. help, } @@ -82,13 +85,19 @@ class CliArgs { case '--delete': modes.add(CliMode.delete); id = value(); + case '--set-status': + modes.add(CliMode.setStatus); + id = value(); + status = _byName(Status.values, value(), arg, _statusName); default: throw UsageError('unknown argument: $arg'); } } if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help); if (modes.length > 1) { - throw UsageError('--list, --get and --delete are mutually exclusive'); + throw UsageError( + '--list, --get, --delete and --set-status are mutually exclusive', + ); } final mode = modes.isEmpty ? CliMode.add : modes.single; if (mode != CliMode.add && text != null) { @@ -110,13 +119,13 @@ class CliArgs { /// Note text from `--text`; null means "read stdin" in [CliMode.add]. final String? text; - /// Note id for [CliMode.get] / [CliMode.delete]. + /// Note id for [CliMode.get] / [CliMode.delete] / [CliMode.setStatus]. final String? id; /// Priority of a new note. final Priority priority; - /// Status of a new note. + /// Status of a new note, or the target status of [CliMode.setStatus]. final Status status; /// Overrides the default data dir (a second, independent node). @@ -158,6 +167,7 @@ ordinary CRDT change as this install's own node, pushes, and prints the id. --list pull, then print live notes: id, priority, status, title --get pull, then print one note in full (exit 1 if absent) --delete pull, tombstone the note, push + --set-status pull, change the note's status (as --status), push --data-dir local log + node id (default $XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli) -h, --help this text diff --git a/lib/cli/cli_runner.dart b/lib/cli/cli_runner.dart index 9fe91b2..b9b0f77 100644 --- a/lib/cli/cli_runner.dart +++ b/lib/cli/cli_runner.dart @@ -79,6 +79,7 @@ Future runCli( CliMode.add => await cli.add(args, text!), CliMode.list => await cli.list(), CliMode.get => await cli.get(args.id!), + CliMode.setStatus => await cli.setStatus(args.id!, args.status), // Only delete is left: help returned above. _ => await cli.delete(args.id!), }; @@ -170,6 +171,25 @@ class _Cli { return 0; } + Future 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 _find(String id) async { final notes = await session.repository.listNotes(); return notes.where((n) => n.id == id).firstOrNull; diff --git a/pubspec.yaml b/pubspec.yaml index f51c804..9aabd4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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.44+1 +version: 1.0.45+1 environment: sdk: ^3.12.2 diff --git a/test/cli/cli_args_test.dart b/test/cli/cli_args_test.dart index f42b58c..cc414d9 100644 --- a/test/cli/cli_args_test.dart +++ b/test/cli/cli_args_test.dart @@ -45,6 +45,13 @@ void main() { expect(del.id, 'xyz'); }); + test('selects set-status with its id and status', () { + final set = CliArgs.parse(['--set-status', 'xyz', 'in_progress']); + expect(set.mode, CliMode.setStatus); + expect(set.id, 'xyz'); + expect(set.status, Status.inProgress); + }); + test('help wins over everything else', () { expect(CliArgs.parse(['--list', '-h']).mode, CliMode.help); expect(CliArgs.parse(['--help']).mode, CliMode.help); @@ -70,6 +77,14 @@ void main() { expectUsage(['--status', 'inProgress'], 'todo|in_progress|done'); expectUsage(['--list', '--get', 'a'], 'mutually exclusive'); expectUsage(['--list', '--text', 'x'], 'only applies when adding'); + expectUsage(['--set-status', 'a'], '--set-status needs a value'); + expectUsage(['--set-status', 'a', 'finished'], 'todo|in_progress|done'); + expectUsage([ + '--list', + '--set-status', + 'a', + 'done', + ], 'mutually exclusive'); }); }); diff --git a/test/cli/cli_runner_test.dart b/test/cli/cli_runner_test.dart index babaf99..cff0b3b 100644 --- a/test/cli/cli_runner_test.dart +++ b/test/cli/cli_runner_test.dart @@ -1,67 +1,11 @@ import 'dart:io'; -import 'package:crdt_sync/crdt_sync.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; 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 'package:todo/sync/sync_service.dart'; import 'fake_rtdb.dart'; - -/// A session over an in-memory log whose [sync] reports a scripted `pushed` -/// flag per call, optionally holding a note that already exists. -class _ScriptedSession extends CliSession { - _ScriptedSession._( - NoteRepository repo, - FirebaseRestClient firebase, - this._pushed, - ) : super('unused', repo, firebase); - - static Future<_ScriptedSession> create( - http.Client client, - List pushed, { - String? noteId, - }) async { - final repo = await NoteRepository.openInMemory(); - if (noteId != null) { - final now = DateTime.utc(2026); - await repo.upsert( - Note( - id: noteId, - text: 'Stuck', - priority: Priority.medium, - status: Status.todo, - createdAt: now, - updatedAt: now, - ), - ); - } - final firebase = FirebaseRestClient( - databaseUrl: 'https://example.test', - auth: FirebaseTokenProvider( - apiKey: 'k', - store: InMemoryCredentialStore( - FirebaseCredentials( - idToken: 'id', - refreshToken: 'rt', - expiresAt: DateTime.utc(2099), - ), - ), - ), - httpClient: client, - ); - return _ScriptedSession._(repo, firebase, pushed); - } - - final List _pushed; - - @override - Future sync({required bool push}) async => - SyncResult(mergedDevices: 0, pushed: _pushed.removeAt(0)); -} +import 'scripted_session.dart'; void main() { late Directory tmp; @@ -159,7 +103,7 @@ void main() { test('fails when the push reports nothing published', () async { Future opener(String d, {required String seedCredentials}) => - _ScriptedSession.create(db.client, [false]); + ScriptedSession.create(db.client, [false]); expect(await run(['--text', 'x'], opener: opener), 1); expect(err.toString(), contains('saved locally but nothing was pushed')); }); @@ -213,12 +157,43 @@ void main() { test('fails when the deletion push reports nothing published', () async { Future opener(String d, {required String seedCredentials}) => - _ScriptedSession.create(db.client, [true, false], noteId: 'stuck'); + ScriptedSession.create(db.client, [true, false], noteId: 'stuck'); expect(await run(['--delete', 'stuck'], opener: opener), 1); expect(err.toString(), contains('deletion saved locally')); }); }); + group('set-status', () { + test('changes the status of a note created elsewhere and pushes', () async { + final id = await add('a', 'Ship it', ['--priority', 'high']); + expect( + await run(['--set-status', id, 'done', '--data-dir', dir('b')]), + 0, + ); + expect(out.toString().trim(), '$id\tdone'); + + expect(await run(['--get', id, '--data-dir', dir('c')]), 0); + expect(out.toString(), contains('status: done')); + expect(out.toString(), contains('priority: high')); + expect(out.toString(), contains('---\nShip it')); + }); + + test('an unknown id exits 1', () async { + expect( + await run(['--set-status', 'nope', 'done', '--data-dir', dir('b')]), + 1, + ); + expect(err.toString(), contains('no live note with id nope')); + }); + + test('fails when the status push reports nothing published', () async { + Future opener(String d, {required String seedCredentials}) => + ScriptedSession.create(db.client, [true, false], noteId: 'stuck'); + expect(await run(['--set-status', 'stuck', 'done'], opener: opener), 1); + expect(err.toString(), contains('status change saved locally')); + }); + }); + group('failures', () { test('a missing Firebase session exits 1 with re-seed advice', () async { File('${env['HOME']}/.config/todo/firebase_auth.json').deleteSync(); diff --git a/test/cli/scripted_session.dart b/test/cli/scripted_session.dart new file mode 100644 index 0000000..a6d39c8 --- /dev/null +++ b/test/cli/scripted_session.dart @@ -0,0 +1,58 @@ +import 'package:crdt_sync/crdt_sync.dart'; +import 'package:http/http.dart' as http; +import 'package:todo/cli/cli_session.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/data/note_repository.dart'; +import 'package:todo/sync/sync_service.dart'; + +/// A session over an in-memory log whose [sync] reports a scripted `pushed` +/// flag per call, optionally holding a note that already exists. +class ScriptedSession extends CliSession { + ScriptedSession._( + NoteRepository repo, + FirebaseRestClient firebase, + this._pushed, + ) : super('unused', repo, firebase); + + static Future create( + http.Client client, + List pushed, { + String? noteId, + }) async { + final repo = await NoteRepository.openInMemory(); + if (noteId != null) { + final now = DateTime.utc(2026); + await repo.upsert( + Note( + id: noteId, + text: 'Stuck', + priority: Priority.medium, + status: Status.todo, + createdAt: now, + updatedAt: now, + ), + ); + } + final firebase = FirebaseRestClient( + databaseUrl: 'https://example.test', + auth: FirebaseTokenProvider( + apiKey: 'k', + store: InMemoryCredentialStore( + FirebaseCredentials( + idToken: 'id', + refreshToken: 'rt', + expiresAt: DateTime.utc(2099), + ), + ), + ), + httpClient: client, + ); + return ScriptedSession._(repo, firebase, pushed); + } + + final List _pushed; + + @override + Future sync({required bool push}) async => + SyncResult(mergedDevices: 0, pushed: _pushed.removeAt(0)); +}