todo-app/lib/cli/cli_commands.dart
Krzysztof kuhy Rudnicki 9349747629
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
Repo tasks: repo/done_check/by/evidence fields, filter chips, CLI close/list/search
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>
2026-11-02 08:26:26 +01:00

127 lines
4.0 KiB
Dart

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;
}
}