CLI: --set-status <id> <status> re-statuses a note and pushes
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-19 17:22:58 +02:00
parent 41947463c5
commit 341a943277
7 changed files with 143 additions and 64 deletions

View File

@ -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 // dart run bin/todo_note.dart --text "Title\n\nbody" --priority high
// echo "Title" | dart run bin/todo_note.dart // echo "Title" | dart run bin/todo_note.dart
// dart run bin/todo_note.dart --get <id> // dart run bin/todo_note.dart --get <id>
// dart run bin/todo_note.dart --set-status <id> done
// //
// See `kUsage` in lib/cli/cli_args.dart for every flag. All behaviour lives // 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. // in lib/cli/cli_runner.dart, where the tests reach it.

View File

@ -20,6 +20,9 @@ enum CliMode {
/// Tombstone one note and push the deletion. /// Tombstone one note and push the deletion.
delete, delete,
/// Change one note's status and push it.
setStatus,
/// Print usage. /// Print usage.
help, help,
} }
@ -82,13 +85,19 @@ class CliArgs {
case '--delete': case '--delete':
modes.add(CliMode.delete); modes.add(CliMode.delete);
id = value(); id = value();
case '--set-status':
modes.add(CliMode.setStatus);
id = value();
status = _byName(Status.values, value(), arg, _statusName);
default: default:
throw UsageError('unknown argument: $arg'); throw UsageError('unknown argument: $arg');
} }
} }
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help); if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
if (modes.length > 1) { 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; final mode = modes.isEmpty ? CliMode.add : modes.single;
if (mode != CliMode.add && text != null) { if (mode != CliMode.add && text != null) {
@ -110,13 +119,13 @@ class CliArgs {
/// Note text from `--text`; null means "read stdin" in [CliMode.add]. /// Note text from `--text`; null means "read stdin" in [CliMode.add].
final String? text; final String? text;
/// Note id for [CliMode.get] / [CliMode.delete]. /// Note id for [CliMode.get] / [CliMode.delete] / [CliMode.setStatus].
final String? id; final String? id;
/// Priority of a new note. /// Priority of a new note.
final Priority priority; final Priority priority;
/// Status of a new note. /// Status of a new note, or the target status of [CliMode.setStatus].
final Status status; final Status status;
/// Overrides the default data dir (a second, independent node). /// 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 --list pull, then print live notes: id, priority, status, title
--get <id> pull, then print one note in full (exit 1 if absent) --get <id> pull, then print one note in full (exit 1 if absent)
--delete <id> pull, tombstone the note, push --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 --data-dir <dir> local log + node id (default
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli) $XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
-h, --help this text -h, --help this text

View File

@ -79,6 +79,7 @@ Future<int> runCli(
CliMode.add => await cli.add(args, text!), CliMode.add => await cli.add(args, text!),
CliMode.list => await cli.list(), CliMode.list => await cli.list(),
CliMode.get => await cli.get(args.id!), CliMode.get => await cli.get(args.id!),
CliMode.setStatus => await cli.setStatus(args.id!, args.status),
// Only delete is left: help returned above. // Only delete is left: help returned above.
_ => await cli.delete(args.id!), _ => await cli.delete(args.id!),
}; };
@ -170,6 +171,25 @@ class _Cli {
return 0; 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 { Future<Note?> _find(String id) async {
final notes = await session.repository.listNotes(); final notes = await session.repository.listNotes();
return notes.where((n) => n.id == id).firstOrNull; return notes.where((n) => n.id == id).firstOrNull;

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 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 # 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. # 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: environment:
sdk: ^3.12.2 sdk: ^3.12.2

View File

@ -45,6 +45,13 @@ void main() {
expect(del.id, 'xyz'); 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', () { test('help wins over everything else', () {
expect(CliArgs.parse(['--list', '-h']).mode, CliMode.help); expect(CliArgs.parse(['--list', '-h']).mode, CliMode.help);
expect(CliArgs.parse(['--help']).mode, CliMode.help); expect(CliArgs.parse(['--help']).mode, CliMode.help);
@ -70,6 +77,14 @@ void main() {
expectUsage(['--status', 'inProgress'], 'todo|in_progress|done'); expectUsage(['--status', 'inProgress'], 'todo|in_progress|done');
expectUsage(['--list', '--get', 'a'], 'mutually exclusive'); expectUsage(['--list', '--get', 'a'], 'mutually exclusive');
expectUsage(['--list', '--text', 'x'], 'only applies when adding'); 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');
}); });
}); });

View File

@ -1,67 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.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_runner.dart';
import 'package:todo/cli/cli_session.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'; import 'fake_rtdb.dart';
import 'scripted_session.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<bool> 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<bool> _pushed;
@override
Future<SyncResult> sync({required bool push}) async =>
SyncResult(mergedDevices: 0, pushed: _pushed.removeAt(0));
}
void main() { void main() {
late Directory tmp; late Directory tmp;
@ -159,7 +103,7 @@ void main() {
test('fails when the push reports nothing published', () async { test('fails when the push reports nothing published', () async {
Future<CliSession> opener(String d, {required String seedCredentials}) => Future<CliSession> 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(await run(['--text', 'x'], opener: opener), 1);
expect(err.toString(), contains('saved locally but nothing was pushed')); 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 { test('fails when the deletion push reports nothing published', () async {
Future<CliSession> opener(String d, {required String seedCredentials}) => Future<CliSession> 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(await run(['--delete', 'stuck'], opener: opener), 1);
expect(err.toString(), contains('deletion saved locally')); 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<CliSession> 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', () { group('failures', () {
test('a missing Firebase session exits 1 with re-seed advice', () async { test('a missing Firebase session exits 1 with re-seed advice', () async {
File('${env['HOME']}/.config/todo/firebase_auth.json').deleteSync(); File('${env['HOME']}/.config/todo/firebase_auth.json').deleteSync();

View File

@ -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<ScriptedSession> create(
http.Client client,
List<bool> 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<bool> _pushed;
@override
Future<SyncResult> sync({required bool push}) async =>
SyncResult(mergedDevices: 0, pushed: _pushed.removeAt(0));
}