mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 16:38:36 +01:00
Track who filed a note (created_by)
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
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
New optional note field created_by (kuhy|claude), stored like by/repo and omitted when unset so older builds stay wire-compatible. App UI stamps kuhy; MCP add_task and the CLI stamp claude. list/get/search output includes it. Human notes sort above Claude's; Claude notes are hidden behind a per-device eye toggle. CLI --backfill-created-by [--dry-run] classifies old notes by the node that wrote created_at, writing only the created_by field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cc9789f530
commit
57019eec30
@ -4,49 +4,12 @@
|
||||
/// exact-pinned dependency under the freshness gate.
|
||||
library;
|
||||
|
||||
import 'package:todo/cli/cli_mode.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
|
||||
export 'package:todo/cli/cli_mode.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].
|
||||
@ -58,6 +21,7 @@ class CliArgs {
|
||||
this.status,
|
||||
this.dataDir,
|
||||
this.newNote = false,
|
||||
this.dryRun = false,
|
||||
this.opts = (
|
||||
repo: null,
|
||||
query: null,
|
||||
@ -89,6 +53,10 @@ class CliArgs {
|
||||
opts[arg] = value();
|
||||
case '--list':
|
||||
modes.add(CliMode.list);
|
||||
case '--backfill-created-by':
|
||||
modes.add(CliMode.backfill);
|
||||
case '--dry-run':
|
||||
opts[arg] = 'true';
|
||||
case '--search':
|
||||
modes.add(CliMode.search);
|
||||
opts[arg] = value();
|
||||
@ -106,8 +74,8 @@ class CliArgs {
|
||||
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',
|
||||
'--list, --search, --get, --delete, --set-status, --close and '
|
||||
'--backfill-created-by are mutually exclusive',
|
||||
);
|
||||
}
|
||||
final mode = modes.isEmpty ? CliMode.add : modes.single;
|
||||
@ -128,6 +96,7 @@ class CliArgs {
|
||||
opts.containsKey('--new-note') &&
|
||||
(opts['--repo'] != null ||
|
||||
(throw UsageError('--new-note needs --repo'))),
|
||||
dryRun: opts.containsKey('--dry-run'),
|
||||
opts: (
|
||||
repo: _repo(opts['--repo']),
|
||||
query: _nonBlank(opts['--search'], '--search'),
|
||||
@ -159,6 +128,9 @@ class CliArgs {
|
||||
/// `--new-note`: the `--repo` task gets its own note.
|
||||
final bool newNote;
|
||||
|
||||
/// Whether `--dry-run` was given: report what would change, change nothing.
|
||||
final bool dryRun;
|
||||
|
||||
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
|
||||
final ({
|
||||
String? repo,
|
||||
@ -183,6 +155,7 @@ class CliArgs {
|
||||
),
|
||||
'--evidence': ('to --close', {CliMode.close}),
|
||||
'--new-note': (_adding, {CliMode.add}),
|
||||
'--dry-run': ('to --backfill-created-by', {CliMode.backfill}),
|
||||
};
|
||||
|
||||
static void _checkScopes(Iterable<String> flags, CliMode mode) {
|
||||
|
||||
@ -38,6 +38,7 @@ class CliCommands {
|
||||
repo: repo,
|
||||
doneCheck: args.opts.doneCheck,
|
||||
by: args.opts.by,
|
||||
createdBy: 'claude',
|
||||
);
|
||||
await session.repository.upsert(note);
|
||||
if (!await _push('note')) return 1;
|
||||
@ -57,6 +58,7 @@ class CliCommands {
|
||||
doneCheck: args.opts.doneCheck,
|
||||
priority: args.priority,
|
||||
by: args.opts.by,
|
||||
createdBy: 'claude',
|
||||
);
|
||||
await _save(added.note);
|
||||
if (!await _push('note')) return 1;
|
||||
@ -79,6 +81,7 @@ class CliCommands {
|
||||
doneCheck: args.opts.doneCheck,
|
||||
priority: args.priority,
|
||||
by: args.opts.by,
|
||||
createdBy: 'claude',
|
||||
);
|
||||
await _save(added.note);
|
||||
if (!await _push('note')) return 1;
|
||||
@ -142,6 +145,35 @@ class CliCommands {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Sets `created_by` where unset (see [NoteRepository.backfillCreatedBy]).
|
||||
/// Prints one `<id>\t<value>\t<title>` line per note, then the totals. With
|
||||
/// [dryRun] nothing is written or pushed, and peers are only read.
|
||||
Future<int> backfillCreatedBy(
|
||||
Set<String> claudeNodes, {
|
||||
required bool dryRun,
|
||||
}) async {
|
||||
_report(await session.sync(push: !dryRun));
|
||||
final plan = await session.repository.backfillCreatedBy(
|
||||
claudeNodes,
|
||||
dryRun: dryRun,
|
||||
);
|
||||
final titles = {
|
||||
for (final n in await session.repository.listNotes()) n.id: n,
|
||||
};
|
||||
for (final MapEntry(:key, :value) in plan.entries) {
|
||||
final note = titles[key];
|
||||
final title = note == null ? '' : note.text.split('\n').first;
|
||||
_out.writeln(
|
||||
'${key.substring(0, key.length.clamp(0, 8))}\t$value\t$title',
|
||||
);
|
||||
}
|
||||
final claude = plan.values.where((v) => v == 'claude').length;
|
||||
final kuhy = plan.length - claude;
|
||||
_out.writeln('${dryRun ? 'dry run' : 'set'}: claude $claude, kuhy $kuhy');
|
||||
if (dryRun || plan.isEmpty) return 0;
|
||||
return await _push('backfill') ? 0 : 1;
|
||||
}
|
||||
|
||||
Future<Note> _pullAndFind(String ref) async {
|
||||
_report(await session.sync(push: true));
|
||||
return findNote(await session.repository.listNotes(), ref);
|
||||
|
||||
44
lib/cli/cli_mode.dart
Normal file
44
lib/cli/cli_mode.dart
Normal file
@ -0,0 +1,44 @@
|
||||
/// What the CLI can be asked to do, and how a bad command line is reported.
|
||||
library;
|
||||
|
||||
/// 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,
|
||||
|
||||
/// Set `created_by` on notes that lack it.
|
||||
backfill,
|
||||
|
||||
/// 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;
|
||||
}
|
||||
@ -80,6 +80,10 @@ Future<int> runCli(
|
||||
CliMode.get => await cli.get(args.id!),
|
||||
CliMode.setStatus => await cli.setStatus(args.id!, args.status!),
|
||||
CliMode.close => await cli.close(args.id!, args.opts.evidence!),
|
||||
CliMode.backfill => await cli.backfillCreatedBy(
|
||||
claudeNodeIds(env),
|
||||
dryRun: args.dryRun,
|
||||
),
|
||||
// Only delete is left: help returned above.
|
||||
_ => await cli.delete(args.id!),
|
||||
};
|
||||
|
||||
@ -29,6 +29,19 @@ String defaultDataDir(Map<String, String> env) {
|
||||
return p.join(base, 'todo-cli');
|
||||
}
|
||||
|
||||
/// Node ids of the installs Claude files notes through: the MCP server and
|
||||
/// the CLI under the same data root as [defaultDataDir]. Missing ones are
|
||||
/// skipped.
|
||||
Set<String> claudeNodeIds(Map<String, String> env) {
|
||||
final cli = defaultDataDir(env);
|
||||
return {
|
||||
for (final dir in [cli, p.join(p.dirname(cli), 'todo-mcp')])
|
||||
if (File(p.join(dir, CliSession.nodeIdFileName)) case final f
|
||||
when f.existsSync())
|
||||
f.readAsStringSync().trim(),
|
||||
}..remove('');
|
||||
}
|
||||
|
||||
/// The open state of one CLI invocation. Call [close] when done.
|
||||
class CliSession {
|
||||
/// Wraps already-open parts; [open] is the normal way in. Public so a test
|
||||
|
||||
@ -25,6 +25,9 @@ A note with --repo is a repo task; one without is a plain idea.
|
||||
--close <id> --evidence <s>
|
||||
pull, set status done + evidence (commit sha + done
|
||||
check exit code), push; refused without --evidence
|
||||
--backfill-created-by [--dry-run]
|
||||
pull, set created_by (claude|kuhy) on every note that
|
||||
has none, push; --dry-run prints the plan only
|
||||
--data-dir <dir> local log + node id (default
|
||||
$XDG_DATA_HOME/todo-cli or ~/.local/share/todo-cli)
|
||||
-h, --help this text
|
||||
|
||||
@ -32,7 +32,7 @@ Note findNote(Iterable<Note> notes, String ref) {
|
||||
return matches.single;
|
||||
}
|
||||
|
||||
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>`.
|
||||
/// `<8-char id>\t<status>\t<priority>\t<repo or ->\t<title>\t<created_by>`.
|
||||
String listLine(Note note) => [
|
||||
if (note.id.length <= _minPrefix)
|
||||
note.id
|
||||
@ -42,6 +42,7 @@ String listLine(Note note) => [
|
||||
note.priority.name,
|
||||
note.repo ?? '-',
|
||||
_title(note),
|
||||
note.createdBy ?? 'kuhy',
|
||||
].join('\t');
|
||||
|
||||
String _title(Note note) => note.text.split('\n').first;
|
||||
@ -57,6 +58,7 @@ String noteDetail(Note note) => [
|
||||
if (note.doneCheck case final v?) 'done_check: $v',
|
||||
if (note.by case final v?) 'by: $v',
|
||||
if (note.evidence case final v?) 'evidence: $v',
|
||||
'created_by: ${note.createdBy ?? 'kuhy'}',
|
||||
'---',
|
||||
note.text,
|
||||
].join('\n');
|
||||
|
||||
@ -19,10 +19,14 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
/// sibling to `changesets/<nodeId>.json`.
|
||||
const String kAdvancedModeRemotePath = 'settings/advancedMode';
|
||||
|
||||
/// App-wide, non-sync preferences (currently just `advancedMode`).
|
||||
/// App-wide, non-sync preferences (`advancedMode`, `showClaudeNotes`).
|
||||
class AppSettings {
|
||||
/// Creates an [AppSettings] from its stored fields.
|
||||
const AppSettings({required this.advancedMode, this.advancedModeUpdatedAt});
|
||||
const AppSettings({
|
||||
required this.advancedMode,
|
||||
this.advancedModeUpdatedAt,
|
||||
this.showClaudeNotes = false,
|
||||
});
|
||||
|
||||
/// Whether the priority/status/template/mode chrome and the routine
|
||||
/// save/sync status text are shown. Off by default: casual capture needs
|
||||
@ -35,6 +39,11 @@ class AppSettings {
|
||||
/// a value that has never round-tripped through Firebase.
|
||||
final DateTime? advancedModeUpdatedAt;
|
||||
|
||||
/// Whether the notes list shows notes Claude filed. Per device: it lives
|
||||
/// only in this device's preferences and is never mirrored to Firebase.
|
||||
final bool showClaudeNotes;
|
||||
|
||||
static const _kShowClaude = 'app.showClaudeNotes';
|
||||
static const _kAdvancedMode = 'app.advancedMode';
|
||||
static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
|
||||
|
||||
@ -44,6 +53,7 @@ class AppSettings {
|
||||
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
|
||||
return AppSettings(
|
||||
advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
|
||||
showClaudeNotes: prefs.getBool(_kShowClaude) ?? false,
|
||||
advancedModeUpdatedAt: updatedAtMillis == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
|
||||
@ -71,6 +81,7 @@ class AppSettings {
|
||||
final updated = AppSettings(
|
||||
advancedMode: value,
|
||||
advancedModeUpdatedAt: updatedAt,
|
||||
showClaudeNotes: showClaudeNotes,
|
||||
);
|
||||
if (client != null) {
|
||||
try {
|
||||
@ -89,6 +100,17 @@ class AppSettings {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Persists the per-device Claude-notes choice; nothing leaves the device.
|
||||
Future<AppSettings> withShowClaudeNotes({required bool value}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kShowClaude, value);
|
||||
return AppSettings(
|
||||
advancedMode: advancedMode,
|
||||
advancedModeUpdatedAt: advancedModeUpdatedAt,
|
||||
showClaudeNotes: value,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pulls the remote value from [client] and, if it is newer than the
|
||||
/// local one, applies it locally and returns the reconciled settings.
|
||||
/// Returns this instance unchanged when [client] is null, there is
|
||||
@ -100,9 +122,7 @@ class AppSettings {
|
||||
try {
|
||||
final remote = await client.getStringMap(kAdvancedModeRemotePath);
|
||||
if (!remote.containsKey('value')) return this;
|
||||
final remoteUpdatedMillis = int.tryParse(
|
||||
remote['updatedAtMillis'] ?? '',
|
||||
);
|
||||
final remoteUpdatedMillis = int.tryParse(remote['updatedAtMillis'] ?? '');
|
||||
if (remoteUpdatedMillis == null) return this;
|
||||
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
remoteUpdatedMillis,
|
||||
@ -110,6 +130,7 @@ class AppSettings {
|
||||
final candidate = AppSettings(
|
||||
advancedMode: remote['value'] == 'true',
|
||||
advancedModeUpdatedAt: remoteUpdatedAt,
|
||||
showClaudeNotes: showClaudeNotes,
|
||||
);
|
||||
final adopted = adopt(candidate);
|
||||
if (identical(adopted, this)) return this;
|
||||
|
||||
44
lib/data/created_by_backfill.dart
Normal file
44
lib/data/created_by_backfill.dart
Normal file
@ -0,0 +1,44 @@
|
||||
/// Who filed a note, for notes written before `created_by` existed.
|
||||
///
|
||||
/// The `created_at` field's clock names the node that last wrote it. The MCP
|
||||
/// and the CLI are the only nodes Claude writes through, so a note created
|
||||
/// there carries one of their node ids; anything else came from kuhy's own
|
||||
/// devices.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:todo/data/note_record.dart';
|
||||
|
||||
const _createdAt = 'created_at';
|
||||
|
||||
/// `claude` when [record]'s `created_at` was written by one of [claudeNodes],
|
||||
/// else `kuhy`.
|
||||
String classifyCreatedBy(Record record, Set<String> claudeNodes) {
|
||||
final node = record.fields[_createdAt]?.$2.nodeId;
|
||||
return node != null && claudeNodes.contains(node) ? 'claude' : 'kuhy';
|
||||
}
|
||||
|
||||
/// Live records of [log] with no `created_by` yet, mapped to their value.
|
||||
Map<String, String> planCreatedByBackfill(Log log, Set<String> claudeNodes) => {
|
||||
for (final record in log.values)
|
||||
if (!record.deleted &&
|
||||
((record.fields[kCreatedByField]?.$1 as String?) ?? '').isEmpty)
|
||||
record.id: classifyCreatedBy(record, claudeNodes),
|
||||
};
|
||||
|
||||
/// [incoming], carrying [existing]'s `created_by` when [incoming] has none.
|
||||
///
|
||||
/// A store replaces a record wholesale, so an edit that does not know the
|
||||
/// field (an import of an old backup) would otherwise drop it locally.
|
||||
Record keepCreatedBy(Record incoming, Record? existing) {
|
||||
final kept = existing?.fields[kCreatedByField];
|
||||
if (kept == null || incoming.fields.containsKey(kCreatedByField)) {
|
||||
return incoming;
|
||||
}
|
||||
return Record(
|
||||
id: incoming.id,
|
||||
fields: {...incoming.fields, kCreatedByField: kept},
|
||||
deleted: incoming.deleted,
|
||||
deletedHlc: incoming.deletedHlc,
|
||||
);
|
||||
}
|
||||
@ -76,7 +76,7 @@ enum Status {
|
||||
}
|
||||
}
|
||||
|
||||
/// The values the optional `by` field may hold: who owns a repo task.
|
||||
/// The values the optional `by` and `created_by` fields may hold.
|
||||
const Set<String> kNoteAuthors = {'claude', 'kuhy'};
|
||||
|
||||
/// An immutable idea/note record.
|
||||
@ -99,6 +99,7 @@ class Note {
|
||||
this.doneCheck,
|
||||
this.by,
|
||||
this.evidence,
|
||||
this.createdBy,
|
||||
});
|
||||
|
||||
/// Builds a [Note] from a raw CRDT query row.
|
||||
@ -146,6 +147,12 @@ class Note {
|
||||
/// Proof the task was closed (commit sha + the done check's exit code).
|
||||
final String? evidence;
|
||||
|
||||
/// Who filed the note: one of [kNoteAuthors]; null (older notes) means kuhy.
|
||||
final String? createdBy;
|
||||
|
||||
/// Whether Claude filed this note (hidden by default, sorted last).
|
||||
bool get isClaudeMade => createdBy == 'claude';
|
||||
|
||||
/// Whether this note is a repo task rather than a plain idea.
|
||||
bool get isRepoTask => repo != null;
|
||||
|
||||
@ -162,6 +169,7 @@ class Note {
|
||||
String? doneCheck,
|
||||
String? by,
|
||||
String? evidence,
|
||||
String? createdBy,
|
||||
}) {
|
||||
return Note(
|
||||
id: id,
|
||||
@ -174,6 +182,7 @@ class Note {
|
||||
doneCheck: doneCheck ?? this.doneCheck,
|
||||
by: by ?? this.by,
|
||||
evidence: evidence ?? this.evidence,
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ class NoteFilter {
|
||||
this.statuses = const {},
|
||||
this.repo,
|
||||
this.repoTasksOnly = false,
|
||||
this.hideClaude = false,
|
||||
this.createdFrom,
|
||||
this.createdTo,
|
||||
this.updatedFrom,
|
||||
@ -58,6 +59,10 @@ class NoteFilter {
|
||||
/// Only notes that have a repo (repo tasks). Ideas are excluded.
|
||||
final bool repoTasksOnly;
|
||||
|
||||
/// Leave out notes Claude filed. A view preference rather than a user
|
||||
/// facet, so it never counts toward [isEmpty] or [activeCount].
|
||||
final bool hideClaude;
|
||||
|
||||
/// Inclusive lower bound (by calendar day) on the creation date.
|
||||
final DateTime? createdFrom;
|
||||
|
||||
@ -104,6 +109,7 @@ class NoteFilter {
|
||||
Set<Status>? statuses,
|
||||
String? repo,
|
||||
bool? repoTasksOnly,
|
||||
bool? hideClaude,
|
||||
DateTime? createdFrom,
|
||||
DateTime? createdTo,
|
||||
DateTime? updatedFrom,
|
||||
@ -117,6 +123,7 @@ class NoteFilter {
|
||||
statuses: statuses ?? this.statuses,
|
||||
repo: repo ?? this.repo,
|
||||
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
|
||||
hideClaude: hideClaude ?? this.hideClaude,
|
||||
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
|
||||
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
|
||||
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
|
||||
@ -138,6 +145,7 @@ bool matchesFilter(Note note, NoteFilter filter) {
|
||||
}
|
||||
if (filter.repo != null && note.repo != filter.repo) return false;
|
||||
if (filter.repoTasksOnly && note.repo == null) return false;
|
||||
if (filter.hideClaude && note.isClaudeMade) return false;
|
||||
if (filter.priorities.isNotEmpty &&
|
||||
!filter.priorities.contains(note.priority)) {
|
||||
return false;
|
||||
@ -165,8 +173,17 @@ bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The comparator implementing [sort].
|
||||
/// The comparator implementing [sort]: human-made notes first, Claude-made
|
||||
/// ones after, and [sort] orders each group.
|
||||
Comparator<Note> comparatorFor(NoteSort sort) {
|
||||
final within = _within(sort);
|
||||
return (a, b) {
|
||||
if (a.isClaudeMade != b.isClaudeMade) return a.isClaudeMade ? 1 : -1;
|
||||
return within(a, b);
|
||||
};
|
||||
}
|
||||
|
||||
Comparator<Note> _within(NoteSort sort) {
|
||||
switch (sort) {
|
||||
case NoteSort.createdDesc:
|
||||
return (a, b) => b.createdAt.compareTo(a.createdAt);
|
||||
|
||||
@ -14,6 +14,9 @@ const _doneCheck = 'done_check';
|
||||
const _by = 'by';
|
||||
const _evidence = 'evidence';
|
||||
|
||||
/// Record field naming who filed the note (`kuhy` or `claude`).
|
||||
const kCreatedByField = 'created_by';
|
||||
|
||||
/// Record fields for [note], all at [hlc]. Unset task fields are omitted, so
|
||||
/// an idea's record is exactly the v3 field set (and cannot clear a peer's).
|
||||
Map<String, Field> noteFields(Note note, Hlc hlc) => {
|
||||
@ -26,6 +29,7 @@ Map<String, Field> noteFields(Note note, Hlc hlc) => {
|
||||
if (note.doneCheck case final v?) _doneCheck: (v, hlc),
|
||||
if (note.by case final v?) _by: (v, hlc),
|
||||
if (note.evidence case final v?) _evidence: (v, hlc),
|
||||
if (note.createdBy case final v?) kCreatedByField: (v, hlc),
|
||||
};
|
||||
|
||||
/// Reads [record] as a [Note], ignoring unknown fields.
|
||||
@ -48,6 +52,7 @@ Note noteFromRecord(Record record) {
|
||||
doneCheck: task(_doneCheck),
|
||||
by: task(_by),
|
||||
evidence: task(_evidence),
|
||||
createdBy: task(kCreatedByField),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:todo/data/created_by_backfill.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_import.dart';
|
||||
import 'package:todo/data/note_query.dart';
|
||||
@ -114,9 +115,32 @@ class NoteRepository {
|
||||
/// character-by-character autosave: it is cheap and idempotent. All fields
|
||||
/// share one fresh monotonic clock (they are written together).
|
||||
Future<void> upsert(Note note) async {
|
||||
await _store.upsert(
|
||||
Record(id: note.id, fields: noteFields(note, _store.nextHlc())),
|
||||
await _put(Record(id: note.id, fields: noteFields(note, _store.nextHlc())));
|
||||
}
|
||||
|
||||
Future<void> _put(Record record) =>
|
||||
_store.upsert(keepCreatedBy(record, _store.get(record.id)));
|
||||
|
||||
/// Sets `created_by` on every live note that has none, from the node that
|
||||
/// wrote its `created_at` (see [classifyCreatedBy]); returns id -> value.
|
||||
///
|
||||
/// Writes only that field, at a fresh clock, so no other field's clock
|
||||
/// moves (a rewrite of `created_at` would erase the signal itself). With
|
||||
/// [dryRun] nothing is written.
|
||||
Future<Map<String, String>> backfillCreatedBy(
|
||||
Set<String> claudeNodes, {
|
||||
bool dryRun = false,
|
||||
}) async {
|
||||
final plan = planCreatedByBackfill(_store.snapshot(), claudeNodes);
|
||||
if (dryRun || plan.isEmpty) return plan;
|
||||
final hlc = _store.nextHlc();
|
||||
await _store.replaceAll(
|
||||
mergeLogs(_store.snapshot(), {
|
||||
for (final e in plan.entries)
|
||||
e.key: Record(id: e.key, fields: {kCreatedByField: (e.value, hlc)}),
|
||||
}),
|
||||
);
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// Soft-deletes a note. The log keeps a sticky tombstone so the deletion
|
||||
@ -142,10 +166,10 @@ class NoteRepository {
|
||||
// the import wins locally; the clock decides whether it wins on *other*
|
||||
// devices, and an import must not win there simply by being recent.
|
||||
if (local == null) {
|
||||
await _store.upsert(recordAtNoteTime(note, _nodeId));
|
||||
await _put(recordAtNoteTime(note, _nodeId));
|
||||
added++;
|
||||
} else if (note.updatedAt.isAfter(local.updatedAt)) {
|
||||
await _store.upsert(recordAtNoteTime(note, _nodeId));
|
||||
await _put(recordAtNoteTime(note, _nodeId));
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
|
||||
@ -71,6 +71,7 @@ Future<RepoAdd> addRepoTask(
|
||||
String? doneCheck,
|
||||
Priority priority = Priority.medium,
|
||||
String? by,
|
||||
String? createdBy,
|
||||
}) async {
|
||||
final section = repoSection(title, body);
|
||||
final open = (await repository.listNotes(
|
||||
@ -99,6 +100,7 @@ Future<RepoAdd> addRepoTask(
|
||||
repo: repo,
|
||||
doneCheck: composeDoneCheck(null, doneCheck),
|
||||
by: by,
|
||||
createdBy: createdBy,
|
||||
),
|
||||
appended: false,
|
||||
);
|
||||
|
||||
@ -77,6 +77,7 @@ Future<SplitAdd> addSeparateTask(
|
||||
String? doneCheck,
|
||||
Priority priority = Priority.medium,
|
||||
String? by,
|
||||
String? createdBy,
|
||||
Future<bool> Function(Note note)? busy,
|
||||
}) async {
|
||||
final section = repoSection(title, body);
|
||||
@ -113,6 +114,7 @@ Future<SplitAdd> addSeparateTask(
|
||||
repo: repo,
|
||||
doneCheck: composeDoneCheck(null, doneCheck),
|
||||
by: by,
|
||||
createdBy: createdBy,
|
||||
),
|
||||
kind: why == null ? SplitKind.added : SplitKind.fellBack,
|
||||
why: why,
|
||||
|
||||
@ -76,6 +76,7 @@ class TaskService {
|
||||
doneCheck: a.optional('done_check'),
|
||||
priority: a.priority() ?? Priority.medium,
|
||||
by: 'claude',
|
||||
createdBy: 'claude',
|
||||
);
|
||||
final verb = added.appended ? 'appended to' : 'added';
|
||||
return (reply: await _save(added.note, verb), start: null);
|
||||
|
||||
@ -35,6 +35,7 @@ Future<SplitReply> addSplit(
|
||||
doneCheck: doneCheck,
|
||||
priority: priority,
|
||||
by: 'claude',
|
||||
createdBy: 'claude',
|
||||
busy: board == null ? null : (n) => board.running(n.id),
|
||||
);
|
||||
if (added.kind != SplitKind.merged) {
|
||||
|
||||
@ -116,7 +116,8 @@ McpServer _server(TaskService tasks) {
|
||||
tool(
|
||||
'list_tasks',
|
||||
'List repo tasks, one line each: 8-char id, status, priority, repo, '
|
||||
'title. Default status "open" (todo + in_progress).',
|
||||
'title, created_by (kuhy|claude). Default status "open" '
|
||||
'(todo + in_progress).',
|
||||
{
|
||||
'repo': repo,
|
||||
'status': JsonSchema.string(
|
||||
|
||||
@ -92,6 +92,7 @@ class NotesMarkdown {
|
||||
doneCheck: attrs['done_check'],
|
||||
by: attrs['by'],
|
||||
evidence: attrs['evidence'],
|
||||
createdBy: attrs['created_by'],
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -113,6 +114,7 @@ class NotesMarkdown {
|
||||
('done_check', note.doneCheck),
|
||||
('by', note.by),
|
||||
('evidence', note.evidence),
|
||||
('created_by', note.createdBy),
|
||||
])
|
||||
if (value != null) ' $name="${_escape(value)}"',
|
||||
].join();
|
||||
|
||||
@ -102,6 +102,7 @@ class CaptureDraft {
|
||||
status: status,
|
||||
createdAt: _createdAt!,
|
||||
updatedAt: now,
|
||||
createdBy: 'kuhy',
|
||||
),
|
||||
);
|
||||
if (live()) lastSavedAt.value = now;
|
||||
|
||||
@ -56,7 +56,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
||||
_stream = _watch();
|
||||
}
|
||||
|
||||
@override
|
||||
@ -69,9 +69,26 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
/// Rebuilds the watch stream for the current sort + filter. Call only
|
||||
/// from handlers that change those, never from [build].
|
||||
void _applyState() {
|
||||
setState(() {
|
||||
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
||||
});
|
||||
setState(() => _stream = _watch());
|
||||
}
|
||||
|
||||
/// The query for the current sort + filter, minus Claude-made notes unless
|
||||
/// this device's eye toggle is on.
|
||||
Stream<List<Note>> _watch() => widget.repository.watchNotes(
|
||||
sort: _sort,
|
||||
filter: _filter.copyWith(
|
||||
hideClaude: !widget.appSettings.value.showClaudeNotes,
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _toggleClaudeNotes() async {
|
||||
final settings = widget.appSettings.value;
|
||||
final next = await settings.withShowClaudeNotes(
|
||||
value: !settings.showClaudeNotes,
|
||||
);
|
||||
if (!mounted) return;
|
||||
widget.appSettings.value = next;
|
||||
_applyState();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
@ -110,6 +127,16 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
||||
appBar: AppBar(
|
||||
title: const Text('Notes'),
|
||||
actions: [
|
||||
// Eye, no label: open = Claude-made notes shown, slashed = hidden.
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
widget.appSettings.value.showClaudeNotes
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
semanticLabel: 'Claude notes',
|
||||
),
|
||||
onPressed: _toggleClaudeNotes,
|
||||
),
|
||||
PopupMenuButton<NoteSort>(
|
||||
tooltip: 'Sort',
|
||||
icon: const Icon(Icons.sort),
|
||||
|
||||
@ -10,6 +10,12 @@ The server is one more sync node: it reuses `lib/cli` (`CliSession`,
|
||||
`todo-sync/notes/<node id>.json` plus `todo-sync/revs/<node id>`.
|
||||
Code: `lib/mcp/`, entrypoint `mcp/todo_mcp.dart`, tests `test/mcp/`.
|
||||
|
||||
Output shapes: `list_tasks` and `search_tasks` print one tab-separated line per
|
||||
task (`id8 status priority repo title created_by`); `get_task` prints
|
||||
`key: value` lines (including `created_by`) then `---` and the text.
|
||||
`created_by` is `claude` for notes filed through `add_task` or the CLI and
|
||||
`kuhy` for everything else (a missing value reads as `kuhy`).
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` |
|
||||
|
||||
@ -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.65+1
|
||||
version: 1.0.66+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
21
test/capture_screen_created_by_test.dart
Normal file
21
test/capture_screen_created_by_test.dart
Normal file
@ -0,0 +1,21 @@
|
||||
// A note typed into the app is stamped created_by kuhy.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'capture_screen_harness.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('a captured note is stamped kuhy and keeps it on edit', (
|
||||
tester,
|
||||
) async {
|
||||
final repo = await pumpCapture(tester);
|
||||
await tester.enterText(find.byType(TextField).first, 'First line');
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField).first, 'First line, more');
|
||||
await tester.pump();
|
||||
|
||||
final saved = (await repo.listNotes()).single;
|
||||
expect(saved.text, 'First line, more');
|
||||
expect(saved.createdBy, 'kuhy');
|
||||
});
|
||||
}
|
||||
85
test/cli/cli_created_by_test.dart
Normal file
85
test/cli/cli_created_by_test.dart
Normal file
@ -0,0 +1,85 @@
|
||||
// created_by through the CLI: stamping, output, and the backfill command.
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'cli_harness.dart';
|
||||
|
||||
void main() {
|
||||
late CliHarness h;
|
||||
setUp(() => h = CliHarness());
|
||||
tearDown(() => h.dispose());
|
||||
|
||||
test('a CLI add is stamped claude, repo task or idea', () async {
|
||||
final idea = await h.add('Plain idea');
|
||||
final task = await h.add('Fix gate', ['--repo', 'kuhyx/todo']);
|
||||
for (final id in [idea, task]) {
|
||||
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
|
||||
expect(h.out.toString(), contains('created_by: claude'));
|
||||
}
|
||||
expect(await h.run(['--list']), 0);
|
||||
expect(
|
||||
h.out.toString().trim().split('\n'),
|
||||
everyElement(endsWith('\tclaude')),
|
||||
);
|
||||
});
|
||||
|
||||
test('a --new-note add is stamped claude', () async {
|
||||
final id = await h.add('Own note', ['--repo', 'kuhyx/todo', '--new-note']);
|
||||
expect(await h.run(['--get', id, '--data-dir', h.dir('b')]), 0);
|
||||
expect(h.out.toString(), contains('created_by: claude'));
|
||||
});
|
||||
|
||||
test('a peer note without created_by reads as kuhy', () async {
|
||||
h.seedPeer([fixtureNote('peer-note-1', 'From the phone')]);
|
||||
expect(await h.run(['--get', 'peer-note-1']), 0);
|
||||
expect(h.out.toString(), contains('created_by: kuhy'));
|
||||
expect(await h.run(['--list']), 0);
|
||||
expect(h.out.toString(), endsWith('From the phone\tkuhy\n'));
|
||||
});
|
||||
|
||||
group('--backfill-created-by', () {
|
||||
void seed() {
|
||||
final mcp = Directory('${h.env['XDG_DATA_HOME']}/todo-mcp')
|
||||
..createSync(recursive: true);
|
||||
File('${mcp.path}/node_id').writeAsStringSync('mcp-node\n');
|
||||
h.seedPeer([
|
||||
fixtureNote('mcp-note-0001', 'Make a text-free', repo: 'kuhyx/a'),
|
||||
], node: 'mcp-node');
|
||||
h.seedPeer([fixtureNote('idea-note-0001', 'My idea')], node: 'phone');
|
||||
}
|
||||
|
||||
test('--dry-run lists the plan and writes nothing', () async {
|
||||
seed();
|
||||
expect(await h.run(['--backfill-created-by', '--dry-run']), 0);
|
||||
final lines = h.out.toString().trim().split('\n');
|
||||
expect(lines, contains('mcp-note\tclaude\tMake a text-free'));
|
||||
expect(lines, contains('idea-not\tkuhy\tMy idea'));
|
||||
expect(lines.last, 'dry run: claude 1, kuhy 1');
|
||||
expect(h.db.methods, isNot(contains('PUT')), reason: 'nothing pushed');
|
||||
});
|
||||
|
||||
test('a real run sets it once, pushes it, and is then a no-op', () async {
|
||||
seed();
|
||||
expect(await h.run(['--backfill-created-by']), 0);
|
||||
expect(h.out.toString().trim().split('\n').last, 'set: claude 1, kuhy 1');
|
||||
expect(
|
||||
await h.run(['--get', 'mcp-note-0001', '--data-dir', h.dir('b')]),
|
||||
0,
|
||||
);
|
||||
expect(h.out.toString(), contains('created_by: claude'));
|
||||
expect(
|
||||
await h.run(['--get', 'idea-note-0001', '--data-dir', h.dir('b')]),
|
||||
0,
|
||||
);
|
||||
expect(h.out.toString(), contains('created_by: kuhy'));
|
||||
expect(await h.run(['--backfill-created-by']), 0);
|
||||
expect(h.out.toString().trim(), 'set: claude 0, kuhy 0');
|
||||
});
|
||||
|
||||
test('--dry-run is refused without the backfill flag', () async {
|
||||
expect(await h.run(['--dry-run']), 2);
|
||||
expect(h.err.toString(), contains('--dry-run only applies'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -60,10 +60,10 @@ class CliHarness {
|
||||
return out.toString().trim();
|
||||
}
|
||||
|
||||
/// Publishes [notes] as another device's log.
|
||||
void seedPeer(List<Note> notes) {
|
||||
db.files['todo-sync/notes/peer~2Ejson'] = logToJson({
|
||||
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, 'peer'),
|
||||
/// Publishes [notes] as device [node]'s log.
|
||||
void seedPeer(List<Note> notes, {String node = 'peer'}) {
|
||||
db.files['todo-sync/notes/$node~2Ejson'] = logToJson({
|
||||
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, node),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,7 +121,7 @@ void main() {
|
||||
expect(await run(['--list', '--data-dir', dir('b')]), 0);
|
||||
expect(
|
||||
out.toString().trim(),
|
||||
'${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle',
|
||||
'${id.substring(0, 8)}\tin_progress\thigh\t-\tTitle\tclaude',
|
||||
);
|
||||
|
||||
expect(await run(['--get', id, '--data-dir', dir('b')]), 0);
|
||||
|
||||
@ -41,7 +41,7 @@ void main() {
|
||||
expect(await h.run(['--list', '--repo', 'kuhyx/todo']), 0);
|
||||
expect(
|
||||
h.out.toString(),
|
||||
'${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\n',
|
||||
'${todo.substring(0, 8)}\ttodo\tmedium\tkuhyx/todo\ttodo todos\tclaude\n',
|
||||
);
|
||||
expect(await h.run(['--list', '--status', 'done']), 0);
|
||||
expect(h.out.toString(), isEmpty);
|
||||
|
||||
@ -65,12 +65,15 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('listLine: 8-char id, status, priority, repo, title', () {
|
||||
test('listLine: 8-char id, status, priority, repo, title, author', () {
|
||||
expect(
|
||||
listLine(notes[2].copyWith(status: Status.inProgress)),
|
||||
'12345678\tin_progress\tmedium\t-\tFirst',
|
||||
'12345678\tin_progress\tmedium\t-\tFirst\tkuhy',
|
||||
);
|
||||
expect(
|
||||
listLine(notes[4]),
|
||||
'99999999\ttodo\tmedium\tkuhyx/todo\tOnly\tkuhy',
|
||||
);
|
||||
expect(listLine(notes[4]), '99999999\ttodo\tmedium\tkuhyx/todo\tOnly');
|
||||
expect(listLine(fixtureNote('abc', 'Short')), startsWith('abc\t'));
|
||||
});
|
||||
}
|
||||
|
||||
@ -104,6 +104,12 @@ class FakeNoteRepository implements NoteRepository {
|
||||
@override
|
||||
Stream<int> watchCount() => _snapshots().map((n) => n.length);
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> backfillCreatedBy(
|
||||
Set<String> claudeNodes, {
|
||||
bool dryRun = false,
|
||||
}) async => {};
|
||||
|
||||
@override
|
||||
String get nodeId => 'fake-node';
|
||||
|
||||
|
||||
88
test/mcp/mcp_created_by_test.dart
Normal file
88
test/mcp/mcp_created_by_test.dart
Normal file
@ -0,0 +1,88 @@
|
||||
// add_task stamps created_by claude; every read tool reports it.
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mcp_dart/mcp_dart.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
|
||||
import 'mcp_harness.dart';
|
||||
|
||||
void main() {
|
||||
late McpHarness h;
|
||||
late McpClient c;
|
||||
setUp(() async {
|
||||
h = McpHarness();
|
||||
await h.start();
|
||||
c = await h.connect();
|
||||
});
|
||||
tearDown(() => h.dispose());
|
||||
|
||||
test('add_task is stamped claude and list/get/search show it', () async {
|
||||
final added = await h.call(c, 'add_task', {
|
||||
'repo': 'kuhyx/todo',
|
||||
'title': 'Fix gate',
|
||||
});
|
||||
final id = idIn(added);
|
||||
final stored = (await h.session.repository.listNotes()).single;
|
||||
expect(stored.createdBy, 'claude');
|
||||
|
||||
expect(
|
||||
await h.call(c, 'get_task', {'id': id}),
|
||||
contains('created_by: claude'),
|
||||
);
|
||||
for (final listed in [
|
||||
await h.call(c, 'list_tasks', {}),
|
||||
await h.call(c, 'search_tasks', {'query': 'gate'}),
|
||||
]) {
|
||||
expect(listed.trim(), endsWith('\tclaude'));
|
||||
}
|
||||
});
|
||||
|
||||
test('a separate new_note task is stamped claude too', () async {
|
||||
await h.call(c, 'add_task', {
|
||||
'repo': 'kuhyx/todo',
|
||||
'title': 'Own note',
|
||||
'new_note': true,
|
||||
});
|
||||
expect((await h.session.repository.listNotes()).single.createdBy, 'claude');
|
||||
});
|
||||
|
||||
test('a record without created_by reads as kuhy', () async {
|
||||
final at = DateTime.utc(2026, 11);
|
||||
await h.session.repository.upsert(
|
||||
Note(
|
||||
id: 'human-task-0001',
|
||||
text: 'Human task',
|
||||
priority: Priority.medium,
|
||||
status: Status.todo,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
repo: 'kuhyx/todo',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await h.call(c, 'get_task', {'id': 'human-task-0001'}),
|
||||
contains('created_by: kuhy'),
|
||||
);
|
||||
expect(
|
||||
(await h.call(c, 'list_tasks', {})).trim(),
|
||||
endsWith('Human task\tkuhy'),
|
||||
);
|
||||
});
|
||||
|
||||
test('joining a human-made repo note keeps its author', () async {
|
||||
final at = DateTime.utc(2026, 11);
|
||||
await h.session.repository.upsert(
|
||||
Note(
|
||||
id: 'human-task-0001',
|
||||
text: 'todo todos',
|
||||
priority: Priority.medium,
|
||||
status: Status.todo,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
repo: 'kuhyx/todo',
|
||||
createdBy: 'kuhy',
|
||||
),
|
||||
);
|
||||
await h.call(c, 'add_task', {'repo': 'kuhyx/todo', 'title': 'More'});
|
||||
expect((await h.session.repository.listNotes()).single.createdBy, 'kuhy');
|
||||
});
|
||||
}
|
||||
181
test/note_created_by_test.dart
Normal file
181
test/note_created_by_test.dart
Normal file
@ -0,0 +1,181 @@
|
||||
// created_by: record mapping, merge safety against older builds, list order,
|
||||
// the hide filter and backfill classification.
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:todo/data/created_by_backfill.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/data/note_repository.dart';
|
||||
|
||||
Note _note(
|
||||
String id, {
|
||||
String? createdBy,
|
||||
Priority priority = Priority.medium,
|
||||
DateTime? updated,
|
||||
}) {
|
||||
final at = updated ?? DateTime.utc(2026, 11);
|
||||
return Note(
|
||||
id: id,
|
||||
text: 'note $id',
|
||||
priority: priority,
|
||||
status: Status.todo,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
createdBy: createdBy,
|
||||
);
|
||||
}
|
||||
|
||||
/// What an older build writes: no created_by, replacing the record locally.
|
||||
Map<String, Field> _oldFields(Note n, Hlc hlc) => {
|
||||
'text': (n.text, hlc),
|
||||
'priority': (n.priority.value, hlc),
|
||||
'status': (n.status.value, hlc),
|
||||
'created_at': (n.createdAt.toIso8601String(), hlc),
|
||||
'updated_at': (n.updatedAt.toIso8601String(), hlc),
|
||||
};
|
||||
|
||||
Log _overTheWire(Log log) => logFromJson(logToJson(log));
|
||||
|
||||
void main() {
|
||||
group('record mapping', () {
|
||||
test('created_by is written only when set and read back', () {
|
||||
final hlc = Hlc(wallTimeMs: 1, counter: 0, nodeId: 'n');
|
||||
expect(
|
||||
noteFields(_note('a'), hlc).containsKey('created_by'),
|
||||
isFalse,
|
||||
reason: 'an unset field keeps the record wire-identical',
|
||||
);
|
||||
final fields = noteFields(_note('a', createdBy: 'claude'), hlc);
|
||||
expect(fields['created_by']!.$1, 'claude');
|
||||
final back = noteFromRecord(Record(id: 'a', fields: fields));
|
||||
expect(back.createdBy, 'claude');
|
||||
expect(back.isClaudeMade, isTrue);
|
||||
expect(_note('b', createdBy: 'kuhy').isClaudeMade, isFalse);
|
||||
expect(_note('c').isClaudeMade, isFalse);
|
||||
});
|
||||
|
||||
test('copyWith keeps createdBy unless replaced', () {
|
||||
final n = _note('a', createdBy: 'claude');
|
||||
expect(n.copyWith(text: 'x').createdBy, 'claude');
|
||||
expect(n.copyWith(createdBy: 'kuhy').createdBy, 'kuhy');
|
||||
});
|
||||
});
|
||||
|
||||
group('merge safety', () {
|
||||
test(
|
||||
'an older build upsert (no created_by) does not wipe a peer value',
|
||||
() async {
|
||||
final current = await NoteRepository.openInMemory(nodeId: 'new');
|
||||
await current.upsert(_note('a', createdBy: 'claude'));
|
||||
|
||||
final old = LogStore(persistence: MemoryPersistence(), nodeId: 'old');
|
||||
await old.load();
|
||||
await old.replaceAll(
|
||||
mergeLogs(old.snapshot(), _overTheWire(current.exportLog())),
|
||||
);
|
||||
final edited = _note('a', updated: DateTime.utc(2026, 11, 2));
|
||||
await old.upsert(
|
||||
Record(id: 'a', fields: _oldFields(edited, old.nextHlc())),
|
||||
);
|
||||
expect(old.snapshot()['a']!.fields.containsKey('created_by'), isFalse);
|
||||
|
||||
await current.importLog(_overTheWire(old.snapshot()));
|
||||
expect((await current.listNotes()).single.createdBy, 'claude');
|
||||
|
||||
// Pulling the other way restores it on the older device too.
|
||||
await old.replaceAll(
|
||||
mergeLogs(old.snapshot(), _overTheWire(current.exportLog())),
|
||||
);
|
||||
expect(old.snapshot()['a']!.fields['created_by']!.$1, 'claude');
|
||||
},
|
||||
);
|
||||
|
||||
test('upserting a note without createdBy keeps the stored value', () async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
await repo.upsert(_note('a', createdBy: 'claude'));
|
||||
await repo.upsert(_note('a', updated: DateTime.utc(2026, 11, 3)));
|
||||
expect((await repo.listNotes()).single.createdBy, 'claude');
|
||||
await repo.importNotes([_note('a', updated: DateTime.utc(2027))]);
|
||||
expect((await repo.listNotes()).single.createdBy, 'claude');
|
||||
});
|
||||
});
|
||||
|
||||
group('order and hiding', () {
|
||||
final claudeHigh = _note('c', createdBy: 'claude', priority: Priority.high);
|
||||
final humanLow = _note('h', createdBy: 'kuhy', priority: Priority.low);
|
||||
final legacy = _note('l', priority: Priority.medium);
|
||||
|
||||
test(
|
||||
'human notes come first under every sort, then the old order',
|
||||
() async {
|
||||
final repo = await NoteRepository.openInMemory();
|
||||
for (final n in [claudeHigh, humanLow, legacy]) {
|
||||
await repo.upsert(n);
|
||||
}
|
||||
for (final sort in NoteSort.values) {
|
||||
final ids = [for (final n in await repo.listNotes(sort: sort)) n.id];
|
||||
expect(ids.last, 'c', reason: '$sort');
|
||||
}
|
||||
final byPriority = await repo.listNotes();
|
||||
expect([for (final n in byPriority) n.id], ['l', 'h', 'c']);
|
||||
},
|
||||
);
|
||||
|
||||
test('hideClaude drops Claude-made notes and is not a user facet', () {
|
||||
const filter = NoteFilter(hideClaude: true);
|
||||
expect(matchesFilter(claudeHigh, filter), isFalse);
|
||||
expect(matchesFilter(humanLow, filter), isTrue);
|
||||
expect(matchesFilter(legacy, filter), isTrue);
|
||||
expect(matchesFilter(claudeHigh, const NoteFilter()), isTrue);
|
||||
expect(filter.isEmpty, isTrue);
|
||||
expect(filter.activeCount, 0);
|
||||
expect(filter.copyWith(query: 'x').hideClaude, isTrue);
|
||||
expect(filter.copyWith(hideClaude: false).hideClaude, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('backfill', () {
|
||||
Record rec(String id, String node, {String? createdBy, bool dead = false}) {
|
||||
final hlc = Hlc(wallTimeMs: 5, counter: 0, nodeId: node);
|
||||
return Record(
|
||||
id: id,
|
||||
fields: {...noteFields(_note(id, createdBy: createdBy), hlc)},
|
||||
deleted: dead,
|
||||
deletedHlc: dead ? hlc : null,
|
||||
);
|
||||
}
|
||||
|
||||
test('classifies by the node that wrote created_at', () {
|
||||
const claude = {'mcp', 'cli'};
|
||||
expect(classifyCreatedBy(rec('a', 'mcp'), claude), 'claude');
|
||||
expect(classifyCreatedBy(rec('a', 'cli'), claude), 'claude');
|
||||
expect(classifyCreatedBy(rec('a', 'phone'), claude), 'kuhy');
|
||||
const bare = Record(id: 'x', fields: {});
|
||||
expect(classifyCreatedBy(bare, claude), 'kuhy');
|
||||
});
|
||||
|
||||
test('writes only unset, live records, and only that field', () async {
|
||||
final repo = await NoteRepository.openInMemory(nodeId: 'cli');
|
||||
await repo.importLog({
|
||||
'a': rec('a', 'mcp'),
|
||||
'b': rec('b', 'phone'),
|
||||
'c': rec('c', 'phone', createdBy: 'claude'),
|
||||
'd': rec('d', 'mcp', dead: true),
|
||||
});
|
||||
const claude = {'mcp', 'cli'};
|
||||
final dry = await repo.backfillCreatedBy(claude, dryRun: true);
|
||||
expect(dry, {'a': 'claude', 'b': 'kuhy'});
|
||||
expect(repo.exportLog()['a']!.fields.containsKey('created_by'), isFalse);
|
||||
|
||||
final done = await repo.backfillCreatedBy(claude);
|
||||
expect(done, dry);
|
||||
final log = repo.exportLog();
|
||||
expect(log['a']!.fields['created_by']!.$1, 'claude');
|
||||
expect(log['b']!.fields['created_by']!.$1, 'kuhy');
|
||||
expect(log['c']!.fields['created_by']!.$1, 'claude');
|
||||
expect(log['d']!.fields.containsKey('created_by'), isFalse);
|
||||
// created_at keeps its original clock: the signal is not destroyed.
|
||||
expect(log['b']!.fields['created_at']!.$2.nodeId, 'phone');
|
||||
expect(await repo.backfillCreatedBy(claude), isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
100
test/notes_list_claude_toggle_test.dart
Normal file
100
test/notes_list_claude_toggle_test.dart
Normal file
@ -0,0 +1,100 @@
|
||||
// Claude-made notes: hidden by default, an eye toggle shows them, the choice
|
||||
// is remembered per device, and human notes always sort first.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:todo/data/app_settings.dart';
|
||||
import 'package:todo/data/note.dart';
|
||||
import 'package:todo/ui/notes_list_screen.dart';
|
||||
|
||||
import 'fake_note_repository.dart';
|
||||
import 'notes_list_harness.dart';
|
||||
|
||||
final _seed = [
|
||||
note(
|
||||
'c',
|
||||
'Claude chore',
|
||||
priority: Priority.high,
|
||||
).copyWith(createdBy: 'claude'),
|
||||
note('h', 'Human idea', priority: Priority.low).copyWith(createdBy: 'kuhy'),
|
||||
note('l', 'Legacy idea'),
|
||||
];
|
||||
|
||||
Future<ValueNotifier<AppSettings>> _pump(
|
||||
WidgetTester tester, {
|
||||
AppSettings settings = const AppSettings(advancedMode: true),
|
||||
}) async {
|
||||
final repo = FakeNoteRepository(_seed, true);
|
||||
addTearDown(repo.close);
|
||||
final notifier = ValueNotifier(settings);
|
||||
addTearDown(notifier.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: NotesListScreen(repository: repo, appSettings: notifier),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
return notifier;
|
||||
}
|
||||
|
||||
Future<void> _tap(WidgetTester tester, IconData icon) async {
|
||||
await tester.tap(find.byIcon(icon));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
testWidgets('Claude-made notes are hidden until the eye is tapped', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(tester);
|
||||
expect(find.text('Claude chore'), findsNothing);
|
||||
expect(find.text('Human idea'), findsOneWidget);
|
||||
expect(find.text('Legacy idea'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.visibility_off), findsOneWidget);
|
||||
|
||||
await _tap(tester, Icons.visibility_off);
|
||||
expect(find.text('Claude chore'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.visibility), findsOneWidget);
|
||||
|
||||
await _tap(tester, Icons.visibility);
|
||||
expect(find.text('Claude chore'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shown, Claude notes sort below every human note', (
|
||||
tester,
|
||||
) async {
|
||||
await _pump(
|
||||
tester,
|
||||
settings: const AppSettings(advancedMode: true, showClaudeNotes: true),
|
||||
);
|
||||
double y(String t) => tester.getTopLeft(find.text(t)).dy;
|
||||
// The default 800x600 view lists one note per row, in display order; the
|
||||
// high-priority Claude note still comes last.
|
||||
expect(y('Claude chore'), greaterThan(y('Human idea')));
|
||||
expect(y('Claude chore'), greaterThan(y('Legacy idea')));
|
||||
});
|
||||
|
||||
testWidgets('the choice is persisted per device and reloaded', (
|
||||
tester,
|
||||
) async {
|
||||
final notifier = await _pump(tester);
|
||||
await _tap(tester, Icons.visibility_off);
|
||||
expect(notifier.value.showClaudeNotes, isTrue);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getBool('app.showClaudeNotes'), isTrue);
|
||||
expect((await AppSettings.load()).showClaudeNotes, isTrue);
|
||||
});
|
||||
|
||||
test('advancedMode and the toggle do not overwrite each other', () async {
|
||||
final shown = await const AppSettings(
|
||||
advancedMode: false,
|
||||
).withShowClaudeNotes(value: true);
|
||||
expect(shown.advancedMode, isFalse);
|
||||
final both = await shown.withAdvancedMode(value: true);
|
||||
expect(both.showClaudeNotes, isTrue);
|
||||
expect(both.advancedMode, isTrue);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user