mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:27:01 +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
|
// 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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
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