mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 21:47:01 +01:00
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
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>
238 lines
6.4 KiB
Dart
238 lines
6.4 KiB
Dart
/// Hand-rolled argument parsing for `bin/todo_note.dart`.
|
|
///
|
|
/// Deliberately not `package:args`: a handful of flags do not justify a new
|
|
/// exact-pinned dependency under the freshness gate.
|
|
library;
|
|
|
|
import 'package:todo/data/note.dart';
|
|
|
|
export 'package:todo/cli/cli_usage.dart';
|
|
|
|
/// What the CLI was asked to do.
|
|
enum CliMode {
|
|
/// Create a note and push it.
|
|
add,
|
|
|
|
/// Print every live note, one per line.
|
|
list,
|
|
|
|
/// Print one note in full.
|
|
get,
|
|
|
|
/// Tombstone one note and push the deletion.
|
|
delete,
|
|
|
|
/// Change one note's status and push it.
|
|
setStatus,
|
|
|
|
/// Set a task done with evidence and push it.
|
|
close,
|
|
|
|
/// Print the notes matching a query, as [list] does.
|
|
search,
|
|
|
|
/// Print usage.
|
|
help,
|
|
}
|
|
|
|
/// Thrown for a malformed command line; maps to exit code 2.
|
|
class UsageError implements Exception {
|
|
/// Creates a [UsageError] carrying [message].
|
|
UsageError(this.message);
|
|
|
|
/// What was wrong with the arguments.
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
/// The parsed command line.
|
|
class CliArgs {
|
|
/// Creates parsed arguments; see [parse].
|
|
const CliArgs({
|
|
required this.mode,
|
|
this.text,
|
|
this.id,
|
|
this.priority = Priority.medium,
|
|
this.status,
|
|
this.dataDir,
|
|
this.opts = (
|
|
repo: null,
|
|
query: null,
|
|
doneCheck: null,
|
|
by: null,
|
|
evidence: null,
|
|
),
|
|
});
|
|
|
|
/// Parses [argv]. Throws [UsageError] on anything it does not understand.
|
|
factory CliArgs.parse(List<String> argv) {
|
|
final opts = <String, String>{};
|
|
String? id;
|
|
final modes = <CliMode>{};
|
|
for (var i = 0; i < argv.length; i++) {
|
|
final arg = argv[i];
|
|
String value() {
|
|
if (i + 1 >= argv.length) throw UsageError('$arg needs a value');
|
|
return argv[++i];
|
|
}
|
|
|
|
switch (arg) {
|
|
case '-h' || '--help':
|
|
modes.add(CliMode.help);
|
|
case '--text' || '--priority' || '--status' || '--data-dir':
|
|
case '--repo' || '--done-check' || '--by' || '--evidence':
|
|
opts[arg] = value();
|
|
case '--list':
|
|
modes.add(CliMode.list);
|
|
case '--search':
|
|
modes.add(CliMode.search);
|
|
opts[arg] = value();
|
|
case '--get' || '--delete' || '--close':
|
|
modes.add(CliMode.values.byName(arg.substring(2)));
|
|
id = value();
|
|
case '--set-status':
|
|
modes.add(CliMode.setStatus);
|
|
id = value();
|
|
opts[arg] = value();
|
|
default:
|
|
throw UsageError('unknown argument: $arg');
|
|
}
|
|
}
|
|
if (modes.contains(CliMode.help)) return const CliArgs(mode: CliMode.help);
|
|
if (modes.length > 1) {
|
|
throw UsageError(
|
|
'--list, --search, --get, --delete, --set-status and --close are '
|
|
'mutually exclusive',
|
|
);
|
|
}
|
|
final mode = modes.isEmpty ? CliMode.add : modes.single;
|
|
_checkScopes(opts.keys, mode);
|
|
final rawStatus = opts['--set-status'] ?? opts['--status'];
|
|
return CliArgs(
|
|
mode: mode,
|
|
text: opts['--text'],
|
|
id: id,
|
|
priority: opts['--priority'] == null
|
|
? Priority.medium
|
|
: _byName(Priority.values, opts['--priority']!, '--priority'),
|
|
status: rawStatus == null
|
|
? null
|
|
: _byName(Status.values, rawStatus, '--status', _statusName),
|
|
dataDir: opts['--data-dir'],
|
|
opts: (
|
|
repo: _repo(opts['--repo']),
|
|
query: _nonBlank(opts['--search'], '--search'),
|
|
doneCheck: _nonBlank(opts['--done-check'], '--done-check'),
|
|
by: opts['--by'] == null ? null : _by(opts['--by']!),
|
|
evidence: _evidence(opts['--evidence'], mode),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// The selected mode.
|
|
final CliMode mode;
|
|
|
|
/// Note text from `--text`; null means "read stdin" in [CliMode.add].
|
|
final String? text;
|
|
|
|
/// Full id or 8+ character prefix of the note an id mode acts on.
|
|
final String? id;
|
|
|
|
/// Priority of a new note.
|
|
final Priority priority;
|
|
|
|
/// New note's status, [CliMode.setStatus]'s target, or a list filter.
|
|
final Status? status;
|
|
|
|
/// Overrides the default data dir (a second, independent node).
|
|
final String? dataDir;
|
|
|
|
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
|
|
final ({
|
|
String? repo,
|
|
String? query,
|
|
String? doneCheck,
|
|
String? by,
|
|
String? evidence,
|
|
})
|
|
opts;
|
|
|
|
/// The command-line spelling of [status] (`in_progress`, not `inProgress`).
|
|
static String statusName(Status status) => _statusName(status);
|
|
|
|
static const _adding = 'when adding a note';
|
|
static const Map<String, (String, Set<CliMode>)> _scopes = {
|
|
'--text': (_adding, {CliMode.add}),
|
|
'--done-check': (_adding, {CliMode.add}),
|
|
'--by': (_adding, {CliMode.add}),
|
|
'--repo': (
|
|
'$_adding, to --list or to --search',
|
|
{CliMode.add, CliMode.list, CliMode.search},
|
|
),
|
|
'--evidence': ('to --close', {CliMode.close}),
|
|
};
|
|
|
|
static void _checkScopes(Iterable<String> flags, CliMode mode) {
|
|
for (final flag in flags) {
|
|
final scope = _scopes[flag];
|
|
if (scope != null && !scope.$2.contains(mode)) {
|
|
throw UsageError('$flag only applies ${scope.$1}');
|
|
}
|
|
}
|
|
}
|
|
|
|
static final _repoShape = RegExp(r'^[\w.-]+/[\w.-]+$');
|
|
|
|
static String? _repo(String? raw) {
|
|
if (raw == null || _repoShape.hasMatch(raw)) return raw;
|
|
throw UsageError(
|
|
'--repo must look like owner/name (e.g. kuhyx/todo), got "$raw"',
|
|
);
|
|
}
|
|
|
|
static String _by(String raw) {
|
|
if (kNoteAuthors.contains(raw)) return raw;
|
|
throw UsageError(
|
|
'--by must be one of ${kNoteAuthors.join('|')}, got "$raw"',
|
|
);
|
|
}
|
|
|
|
static String? _evidence(String? raw, CliMode mode) {
|
|
if (mode == CliMode.close && (raw == null || raw.trim().isEmpty)) {
|
|
throw UsageError(
|
|
'--close needs --evidence <commit sha + done check exit code>: '
|
|
'a task is only closed with proof',
|
|
);
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
static String? _nonBlank(String? raw, String flag) {
|
|
if (raw != null && raw.trim().isEmpty) {
|
|
throw UsageError('$flag must not be empty');
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
static String _statusName(Enum value) =>
|
|
value == Status.inProgress ? 'in_progress' : value.name;
|
|
|
|
static T _byName<T extends Enum>(
|
|
List<T> values,
|
|
String raw,
|
|
String flag, [
|
|
String Function(Enum) nameOf = _plainName,
|
|
]) {
|
|
for (final v in values) {
|
|
if (nameOf(v) == raw) return v;
|
|
}
|
|
throw UsageError(
|
|
'$flag must be one of ${values.map(nameOf).join('|')}, got "$raw"',
|
|
);
|
|
}
|
|
|
|
static String _plainName(Enum value) => value.name;
|
|
}
|