mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +01:00
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
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:
parent
41947463c5
commit
341a943277
@ -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 <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
|
||||
// in lib/cli/cli_runner.dart, where the tests reach it.
|
||||
|
||||
@ -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 <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
|
||||
|
||||
@ -79,6 +79,7 @@ Future<int> 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<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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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<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));
|
||||
}
|
||||
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<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(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<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(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', () {
|
||||
test('a missing Firebase session exits 1 with re-seed advice', () async {
|
||||
File('${env['HOME']}/.config/todo/firebase_auth.json').deleteSync();
|
||||
|
||||
58
test/cli/scripted_session.dart
Normal file
58
test/cli/scripted_session.dart
Normal 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));
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user