mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +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.
|
/// exact-pinned dependency under the freshness gate.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'package:todo/cli/cli_mode.dart';
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
|
|
||||||
|
export 'package:todo/cli/cli_mode.dart';
|
||||||
export 'package:todo/cli/cli_usage.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.
|
/// The parsed command line.
|
||||||
class CliArgs {
|
class CliArgs {
|
||||||
/// Creates parsed arguments; see [parse].
|
/// Creates parsed arguments; see [parse].
|
||||||
@ -58,6 +21,7 @@ class CliArgs {
|
|||||||
this.status,
|
this.status,
|
||||||
this.dataDir,
|
this.dataDir,
|
||||||
this.newNote = false,
|
this.newNote = false,
|
||||||
|
this.dryRun = false,
|
||||||
this.opts = (
|
this.opts = (
|
||||||
repo: null,
|
repo: null,
|
||||||
query: null,
|
query: null,
|
||||||
@ -89,6 +53,10 @@ class CliArgs {
|
|||||||
opts[arg] = value();
|
opts[arg] = value();
|
||||||
case '--list':
|
case '--list':
|
||||||
modes.add(CliMode.list);
|
modes.add(CliMode.list);
|
||||||
|
case '--backfill-created-by':
|
||||||
|
modes.add(CliMode.backfill);
|
||||||
|
case '--dry-run':
|
||||||
|
opts[arg] = 'true';
|
||||||
case '--search':
|
case '--search':
|
||||||
modes.add(CliMode.search);
|
modes.add(CliMode.search);
|
||||||
opts[arg] = value();
|
opts[arg] = value();
|
||||||
@ -106,8 +74,8 @@ class CliArgs {
|
|||||||
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(
|
throw UsageError(
|
||||||
'--list, --search, --get, --delete, --set-status and --close are '
|
'--list, --search, --get, --delete, --set-status, --close and '
|
||||||
'mutually exclusive',
|
'--backfill-created-by are mutually exclusive',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final mode = modes.isEmpty ? CliMode.add : modes.single;
|
final mode = modes.isEmpty ? CliMode.add : modes.single;
|
||||||
@ -128,6 +96,7 @@ class CliArgs {
|
|||||||
opts.containsKey('--new-note') &&
|
opts.containsKey('--new-note') &&
|
||||||
(opts['--repo'] != null ||
|
(opts['--repo'] != null ||
|
||||||
(throw UsageError('--new-note needs --repo'))),
|
(throw UsageError('--new-note needs --repo'))),
|
||||||
|
dryRun: opts.containsKey('--dry-run'),
|
||||||
opts: (
|
opts: (
|
||||||
repo: _repo(opts['--repo']),
|
repo: _repo(opts['--repo']),
|
||||||
query: _nonBlank(opts['--search'], '--search'),
|
query: _nonBlank(opts['--search'], '--search'),
|
||||||
@ -159,6 +128,9 @@ class CliArgs {
|
|||||||
/// `--new-note`: the `--repo` task gets its own note.
|
/// `--new-note`: the `--repo` task gets its own note.
|
||||||
final bool newNote;
|
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.
|
/// `--repo`, `--search`, `--done-check`, `--by` and `--evidence` values.
|
||||||
final ({
|
final ({
|
||||||
String? repo,
|
String? repo,
|
||||||
@ -183,6 +155,7 @@ class CliArgs {
|
|||||||
),
|
),
|
||||||
'--evidence': ('to --close', {CliMode.close}),
|
'--evidence': ('to --close', {CliMode.close}),
|
||||||
'--new-note': (_adding, {CliMode.add}),
|
'--new-note': (_adding, {CliMode.add}),
|
||||||
|
'--dry-run': ('to --backfill-created-by', {CliMode.backfill}),
|
||||||
};
|
};
|
||||||
|
|
||||||
static void _checkScopes(Iterable<String> flags, CliMode mode) {
|
static void _checkScopes(Iterable<String> flags, CliMode mode) {
|
||||||
|
|||||||
@ -38,6 +38,7 @@ class CliCommands {
|
|||||||
repo: repo,
|
repo: repo,
|
||||||
doneCheck: args.opts.doneCheck,
|
doneCheck: args.opts.doneCheck,
|
||||||
by: args.opts.by,
|
by: args.opts.by,
|
||||||
|
createdBy: 'claude',
|
||||||
);
|
);
|
||||||
await session.repository.upsert(note);
|
await session.repository.upsert(note);
|
||||||
if (!await _push('note')) return 1;
|
if (!await _push('note')) return 1;
|
||||||
@ -57,6 +58,7 @@ class CliCommands {
|
|||||||
doneCheck: args.opts.doneCheck,
|
doneCheck: args.opts.doneCheck,
|
||||||
priority: args.priority,
|
priority: args.priority,
|
||||||
by: args.opts.by,
|
by: args.opts.by,
|
||||||
|
createdBy: 'claude',
|
||||||
);
|
);
|
||||||
await _save(added.note);
|
await _save(added.note);
|
||||||
if (!await _push('note')) return 1;
|
if (!await _push('note')) return 1;
|
||||||
@ -79,6 +81,7 @@ class CliCommands {
|
|||||||
doneCheck: args.opts.doneCheck,
|
doneCheck: args.opts.doneCheck,
|
||||||
priority: args.priority,
|
priority: args.priority,
|
||||||
by: args.opts.by,
|
by: args.opts.by,
|
||||||
|
createdBy: 'claude',
|
||||||
);
|
);
|
||||||
await _save(added.note);
|
await _save(added.note);
|
||||||
if (!await _push('note')) return 1;
|
if (!await _push('note')) return 1;
|
||||||
@ -142,6 +145,35 @@ class CliCommands {
|
|||||||
return 0;
|
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 {
|
Future<Note> _pullAndFind(String ref) async {
|
||||||
_report(await session.sync(push: true));
|
_report(await session.sync(push: true));
|
||||||
return findNote(await session.repository.listNotes(), ref);
|
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.get => await cli.get(args.id!),
|
||||||
CliMode.setStatus => await cli.setStatus(args.id!, args.status!),
|
CliMode.setStatus => await cli.setStatus(args.id!, args.status!),
|
||||||
CliMode.close => await cli.close(args.id!, args.opts.evidence!),
|
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.
|
// Only delete is left: help returned above.
|
||||||
_ => await cli.delete(args.id!),
|
_ => await cli.delete(args.id!),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -29,6 +29,19 @@ String defaultDataDir(Map<String, String> env) {
|
|||||||
return p.join(base, 'todo-cli');
|
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.
|
/// The open state of one CLI invocation. Call [close] when done.
|
||||||
class CliSession {
|
class CliSession {
|
||||||
/// Wraps already-open parts; [open] is the normal way in. Public so a test
|
/// 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>
|
--close <id> --evidence <s>
|
||||||
pull, set status done + evidence (commit sha + done
|
pull, set status done + evidence (commit sha + done
|
||||||
check exit code), push; refused without --evidence
|
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
|
--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
|
||||||
|
|||||||
@ -32,7 +32,7 @@ Note findNote(Iterable<Note> notes, String ref) {
|
|||||||
return matches.single;
|
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) => [
|
String listLine(Note note) => [
|
||||||
if (note.id.length <= _minPrefix)
|
if (note.id.length <= _minPrefix)
|
||||||
note.id
|
note.id
|
||||||
@ -42,6 +42,7 @@ String listLine(Note note) => [
|
|||||||
note.priority.name,
|
note.priority.name,
|
||||||
note.repo ?? '-',
|
note.repo ?? '-',
|
||||||
_title(note),
|
_title(note),
|
||||||
|
note.createdBy ?? 'kuhy',
|
||||||
].join('\t');
|
].join('\t');
|
||||||
|
|
||||||
String _title(Note note) => note.text.split('\n').first;
|
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.doneCheck case final v?) 'done_check: $v',
|
||||||
if (note.by case final v?) 'by: $v',
|
if (note.by case final v?) 'by: $v',
|
||||||
if (note.evidence case final v?) 'evidence: $v',
|
if (note.evidence case final v?) 'evidence: $v',
|
||||||
|
'created_by: ${note.createdBy ?? 'kuhy'}',
|
||||||
'---',
|
'---',
|
||||||
note.text,
|
note.text,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@ -19,10 +19,14 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
/// sibling to `changesets/<nodeId>.json`.
|
/// sibling to `changesets/<nodeId>.json`.
|
||||||
const String kAdvancedModeRemotePath = 'settings/advancedMode';
|
const String kAdvancedModeRemotePath = 'settings/advancedMode';
|
||||||
|
|
||||||
/// App-wide, non-sync preferences (currently just `advancedMode`).
|
/// App-wide, non-sync preferences (`advancedMode`, `showClaudeNotes`).
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
/// Creates an [AppSettings] from its stored fields.
|
/// 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
|
/// Whether the priority/status/template/mode chrome and the routine
|
||||||
/// save/sync status text are shown. Off by default: casual capture needs
|
/// 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.
|
/// a value that has never round-tripped through Firebase.
|
||||||
final DateTime? advancedModeUpdatedAt;
|
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 _kAdvancedMode = 'app.advancedMode';
|
||||||
static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
|
static const _kAdvancedModeUpdatedAt = 'app.advancedMode.updatedAt';
|
||||||
|
|
||||||
@ -44,6 +53,7 @@ class AppSettings {
|
|||||||
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
|
final updatedAtMillis = prefs.getInt(_kAdvancedModeUpdatedAt);
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
|
advancedMode: prefs.getBool(_kAdvancedMode) ?? false,
|
||||||
|
showClaudeNotes: prefs.getBool(_kShowClaude) ?? false,
|
||||||
advancedModeUpdatedAt: updatedAtMillis == null
|
advancedModeUpdatedAt: updatedAtMillis == null
|
||||||
? null
|
? null
|
||||||
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
|
: DateTime.fromMillisecondsSinceEpoch(updatedAtMillis),
|
||||||
@ -71,6 +81,7 @@ class AppSettings {
|
|||||||
final updated = AppSettings(
|
final updated = AppSettings(
|
||||||
advancedMode: value,
|
advancedMode: value,
|
||||||
advancedModeUpdatedAt: updatedAt,
|
advancedModeUpdatedAt: updatedAt,
|
||||||
|
showClaudeNotes: showClaudeNotes,
|
||||||
);
|
);
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
try {
|
try {
|
||||||
@ -89,6 +100,17 @@ class AppSettings {
|
|||||||
return updated;
|
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
|
/// Pulls the remote value from [client] and, if it is newer than the
|
||||||
/// local one, applies it locally and returns the reconciled settings.
|
/// local one, applies it locally and returns the reconciled settings.
|
||||||
/// Returns this instance unchanged when [client] is null, there is
|
/// Returns this instance unchanged when [client] is null, there is
|
||||||
@ -100,9 +122,7 @@ class AppSettings {
|
|||||||
try {
|
try {
|
||||||
final remote = await client.getStringMap(kAdvancedModeRemotePath);
|
final remote = await client.getStringMap(kAdvancedModeRemotePath);
|
||||||
if (!remote.containsKey('value')) return this;
|
if (!remote.containsKey('value')) return this;
|
||||||
final remoteUpdatedMillis = int.tryParse(
|
final remoteUpdatedMillis = int.tryParse(remote['updatedAtMillis'] ?? '');
|
||||||
remote['updatedAtMillis'] ?? '',
|
|
||||||
);
|
|
||||||
if (remoteUpdatedMillis == null) return this;
|
if (remoteUpdatedMillis == null) return this;
|
||||||
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
|
final remoteUpdatedAt = DateTime.fromMillisecondsSinceEpoch(
|
||||||
remoteUpdatedMillis,
|
remoteUpdatedMillis,
|
||||||
@ -110,6 +130,7 @@ class AppSettings {
|
|||||||
final candidate = AppSettings(
|
final candidate = AppSettings(
|
||||||
advancedMode: remote['value'] == 'true',
|
advancedMode: remote['value'] == 'true',
|
||||||
advancedModeUpdatedAt: remoteUpdatedAt,
|
advancedModeUpdatedAt: remoteUpdatedAt,
|
||||||
|
showClaudeNotes: showClaudeNotes,
|
||||||
);
|
);
|
||||||
final adopted = adopt(candidate);
|
final adopted = adopt(candidate);
|
||||||
if (identical(adopted, this)) return this;
|
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'};
|
const Set<String> kNoteAuthors = {'claude', 'kuhy'};
|
||||||
|
|
||||||
/// An immutable idea/note record.
|
/// An immutable idea/note record.
|
||||||
@ -99,6 +99,7 @@ class Note {
|
|||||||
this.doneCheck,
|
this.doneCheck,
|
||||||
this.by,
|
this.by,
|
||||||
this.evidence,
|
this.evidence,
|
||||||
|
this.createdBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Builds a [Note] from a raw CRDT query row.
|
/// 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).
|
/// Proof the task was closed (commit sha + the done check's exit code).
|
||||||
final String? evidence;
|
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.
|
/// Whether this note is a repo task rather than a plain idea.
|
||||||
bool get isRepoTask => repo != null;
|
bool get isRepoTask => repo != null;
|
||||||
|
|
||||||
@ -162,6 +169,7 @@ class Note {
|
|||||||
String? doneCheck,
|
String? doneCheck,
|
||||||
String? by,
|
String? by,
|
||||||
String? evidence,
|
String? evidence,
|
||||||
|
String? createdBy,
|
||||||
}) {
|
}) {
|
||||||
return Note(
|
return Note(
|
||||||
id: id,
|
id: id,
|
||||||
@ -174,6 +182,7 @@ class Note {
|
|||||||
doneCheck: doneCheck ?? this.doneCheck,
|
doneCheck: doneCheck ?? this.doneCheck,
|
||||||
by: by ?? this.by,
|
by: by ?? this.by,
|
||||||
evidence: evidence ?? this.evidence,
|
evidence: evidence ?? this.evidence,
|
||||||
|
createdBy: createdBy ?? this.createdBy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class NoteFilter {
|
|||||||
this.statuses = const {},
|
this.statuses = const {},
|
||||||
this.repo,
|
this.repo,
|
||||||
this.repoTasksOnly = false,
|
this.repoTasksOnly = false,
|
||||||
|
this.hideClaude = false,
|
||||||
this.createdFrom,
|
this.createdFrom,
|
||||||
this.createdTo,
|
this.createdTo,
|
||||||
this.updatedFrom,
|
this.updatedFrom,
|
||||||
@ -58,6 +59,10 @@ class NoteFilter {
|
|||||||
/// Only notes that have a repo (repo tasks). Ideas are excluded.
|
/// Only notes that have a repo (repo tasks). Ideas are excluded.
|
||||||
final bool repoTasksOnly;
|
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.
|
/// Inclusive lower bound (by calendar day) on the creation date.
|
||||||
final DateTime? createdFrom;
|
final DateTime? createdFrom;
|
||||||
|
|
||||||
@ -104,6 +109,7 @@ class NoteFilter {
|
|||||||
Set<Status>? statuses,
|
Set<Status>? statuses,
|
||||||
String? repo,
|
String? repo,
|
||||||
bool? repoTasksOnly,
|
bool? repoTasksOnly,
|
||||||
|
bool? hideClaude,
|
||||||
DateTime? createdFrom,
|
DateTime? createdFrom,
|
||||||
DateTime? createdTo,
|
DateTime? createdTo,
|
||||||
DateTime? updatedFrom,
|
DateTime? updatedFrom,
|
||||||
@ -117,6 +123,7 @@ class NoteFilter {
|
|||||||
statuses: statuses ?? this.statuses,
|
statuses: statuses ?? this.statuses,
|
||||||
repo: repo ?? this.repo,
|
repo: repo ?? this.repo,
|
||||||
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
|
repoTasksOnly: repoTasksOnly ?? this.repoTasksOnly,
|
||||||
|
hideClaude: hideClaude ?? this.hideClaude,
|
||||||
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
|
createdFrom: clearCreated ? null : (createdFrom ?? this.createdFrom),
|
||||||
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
|
createdTo: clearCreated ? null : (createdTo ?? this.createdTo),
|
||||||
updatedFrom: clearUpdated ? null : (updatedFrom ?? this.updatedFrom),
|
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.repo != null && note.repo != filter.repo) return false;
|
||||||
if (filter.repoTasksOnly && note.repo == null) return false;
|
if (filter.repoTasksOnly && note.repo == null) return false;
|
||||||
|
if (filter.hideClaude && note.isClaudeMade) return false;
|
||||||
if (filter.priorities.isNotEmpty &&
|
if (filter.priorities.isNotEmpty &&
|
||||||
!filter.priorities.contains(note.priority)) {
|
!filter.priorities.contains(note.priority)) {
|
||||||
return false;
|
return false;
|
||||||
@ -165,8 +173,17 @@ bool _withinDays(DateTime value, DateTime? from, DateTime? to) {
|
|||||||
return true;
|
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) {
|
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) {
|
switch (sort) {
|
||||||
case NoteSort.createdDesc:
|
case NoteSort.createdDesc:
|
||||||
return (a, b) => b.createdAt.compareTo(a.createdAt);
|
return (a, b) => b.createdAt.compareTo(a.createdAt);
|
||||||
|
|||||||
@ -14,6 +14,9 @@ const _doneCheck = 'done_check';
|
|||||||
const _by = 'by';
|
const _by = 'by';
|
||||||
const _evidence = 'evidence';
|
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
|
/// 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).
|
/// an idea's record is exactly the v3 field set (and cannot clear a peer's).
|
||||||
Map<String, Field> noteFields(Note note, Hlc hlc) => {
|
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.doneCheck case final v?) _doneCheck: (v, hlc),
|
||||||
if (note.by case final v?) _by: (v, hlc),
|
if (note.by case final v?) _by: (v, hlc),
|
||||||
if (note.evidence case final v?) _evidence: (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.
|
/// Reads [record] as a [Note], ignoring unknown fields.
|
||||||
@ -48,6 +52,7 @@ Note noteFromRecord(Record record) {
|
|||||||
doneCheck: task(_doneCheck),
|
doneCheck: task(_doneCheck),
|
||||||
by: task(_by),
|
by: task(_by),
|
||||||
evidence: task(_evidence),
|
evidence: task(_evidence),
|
||||||
|
createdBy: task(kCreatedByField),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
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.dart';
|
||||||
import 'package:todo/data/note_import.dart';
|
import 'package:todo/data/note_import.dart';
|
||||||
import 'package:todo/data/note_query.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
|
/// character-by-character autosave: it is cheap and idempotent. All fields
|
||||||
/// share one fresh monotonic clock (they are written together).
|
/// share one fresh monotonic clock (they are written together).
|
||||||
Future<void> upsert(Note note) async {
|
Future<void> upsert(Note note) async {
|
||||||
await _store.upsert(
|
await _put(Record(id: note.id, fields: noteFields(note, _store.nextHlc())));
|
||||||
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
|
/// 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*
|
// the import wins locally; the clock decides whether it wins on *other*
|
||||||
// devices, and an import must not win there simply by being recent.
|
// devices, and an import must not win there simply by being recent.
|
||||||
if (local == null) {
|
if (local == null) {
|
||||||
await _store.upsert(recordAtNoteTime(note, _nodeId));
|
await _put(recordAtNoteTime(note, _nodeId));
|
||||||
added++;
|
added++;
|
||||||
} else if (note.updatedAt.isAfter(local.updatedAt)) {
|
} else if (note.updatedAt.isAfter(local.updatedAt)) {
|
||||||
await _store.upsert(recordAtNoteTime(note, _nodeId));
|
await _put(recordAtNoteTime(note, _nodeId));
|
||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
|||||||
@ -71,6 +71,7 @@ Future<RepoAdd> addRepoTask(
|
|||||||
String? doneCheck,
|
String? doneCheck,
|
||||||
Priority priority = Priority.medium,
|
Priority priority = Priority.medium,
|
||||||
String? by,
|
String? by,
|
||||||
|
String? createdBy,
|
||||||
}) async {
|
}) async {
|
||||||
final section = repoSection(title, body);
|
final section = repoSection(title, body);
|
||||||
final open = (await repository.listNotes(
|
final open = (await repository.listNotes(
|
||||||
@ -99,6 +100,7 @@ Future<RepoAdd> addRepoTask(
|
|||||||
repo: repo,
|
repo: repo,
|
||||||
doneCheck: composeDoneCheck(null, doneCheck),
|
doneCheck: composeDoneCheck(null, doneCheck),
|
||||||
by: by,
|
by: by,
|
||||||
|
createdBy: createdBy,
|
||||||
),
|
),
|
||||||
appended: false,
|
appended: false,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -77,6 +77,7 @@ Future<SplitAdd> addSeparateTask(
|
|||||||
String? doneCheck,
|
String? doneCheck,
|
||||||
Priority priority = Priority.medium,
|
Priority priority = Priority.medium,
|
||||||
String? by,
|
String? by,
|
||||||
|
String? createdBy,
|
||||||
Future<bool> Function(Note note)? busy,
|
Future<bool> Function(Note note)? busy,
|
||||||
}) async {
|
}) async {
|
||||||
final section = repoSection(title, body);
|
final section = repoSection(title, body);
|
||||||
@ -113,6 +114,7 @@ Future<SplitAdd> addSeparateTask(
|
|||||||
repo: repo,
|
repo: repo,
|
||||||
doneCheck: composeDoneCheck(null, doneCheck),
|
doneCheck: composeDoneCheck(null, doneCheck),
|
||||||
by: by,
|
by: by,
|
||||||
|
createdBy: createdBy,
|
||||||
),
|
),
|
||||||
kind: why == null ? SplitKind.added : SplitKind.fellBack,
|
kind: why == null ? SplitKind.added : SplitKind.fellBack,
|
||||||
why: why,
|
why: why,
|
||||||
|
|||||||
@ -76,6 +76,7 @@ class TaskService {
|
|||||||
doneCheck: a.optional('done_check'),
|
doneCheck: a.optional('done_check'),
|
||||||
priority: a.priority() ?? Priority.medium,
|
priority: a.priority() ?? Priority.medium,
|
||||||
by: 'claude',
|
by: 'claude',
|
||||||
|
createdBy: 'claude',
|
||||||
);
|
);
|
||||||
final verb = added.appended ? 'appended to' : 'added';
|
final verb = added.appended ? 'appended to' : 'added';
|
||||||
return (reply: await _save(added.note, verb), start: null);
|
return (reply: await _save(added.note, verb), start: null);
|
||||||
|
|||||||
@ -35,6 +35,7 @@ Future<SplitReply> addSplit(
|
|||||||
doneCheck: doneCheck,
|
doneCheck: doneCheck,
|
||||||
priority: priority,
|
priority: priority,
|
||||||
by: 'claude',
|
by: 'claude',
|
||||||
|
createdBy: 'claude',
|
||||||
busy: board == null ? null : (n) => board.running(n.id),
|
busy: board == null ? null : (n) => board.running(n.id),
|
||||||
);
|
);
|
||||||
if (added.kind != SplitKind.merged) {
|
if (added.kind != SplitKind.merged) {
|
||||||
|
|||||||
@ -116,7 +116,8 @@ McpServer _server(TaskService tasks) {
|
|||||||
tool(
|
tool(
|
||||||
'list_tasks',
|
'list_tasks',
|
||||||
'List repo tasks, one line each: 8-char id, status, priority, repo, '
|
'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,
|
'repo': repo,
|
||||||
'status': JsonSchema.string(
|
'status': JsonSchema.string(
|
||||||
|
|||||||
@ -92,6 +92,7 @@ class NotesMarkdown {
|
|||||||
doneCheck: attrs['done_check'],
|
doneCheck: attrs['done_check'],
|
||||||
by: attrs['by'],
|
by: attrs['by'],
|
||||||
evidence: attrs['evidence'],
|
evidence: attrs['evidence'],
|
||||||
|
createdBy: attrs['created_by'],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -113,6 +114,7 @@ class NotesMarkdown {
|
|||||||
('done_check', note.doneCheck),
|
('done_check', note.doneCheck),
|
||||||
('by', note.by),
|
('by', note.by),
|
||||||
('evidence', note.evidence),
|
('evidence', note.evidence),
|
||||||
|
('created_by', note.createdBy),
|
||||||
])
|
])
|
||||||
if (value != null) ' $name="${_escape(value)}"',
|
if (value != null) ' $name="${_escape(value)}"',
|
||||||
].join();
|
].join();
|
||||||
|
|||||||
@ -102,6 +102,7 @@ class CaptureDraft {
|
|||||||
status: status,
|
status: status,
|
||||||
createdAt: _createdAt!,
|
createdAt: _createdAt!,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
createdBy: 'kuhy',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (live()) lastSavedAt.value = now;
|
if (live()) lastSavedAt.value = now;
|
||||||
|
|||||||
@ -56,7 +56,7 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
_stream = _watch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -69,9 +69,26 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
/// Rebuilds the watch stream for the current sort + filter. Call only
|
/// Rebuilds the watch stream for the current sort + filter. Call only
|
||||||
/// from handlers that change those, never from [build].
|
/// from handlers that change those, never from [build].
|
||||||
void _applyState() {
|
void _applyState() {
|
||||||
setState(() {
|
setState(() => _stream = _watch());
|
||||||
_stream = widget.repository.watchNotes(sort: _sort, filter: _filter);
|
}
|
||||||
});
|
|
||||||
|
/// 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) {
|
void _onSearchChanged(String value) {
|
||||||
@ -110,6 +127,16 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Notes'),
|
title: const Text('Notes'),
|
||||||
actions: [
|
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>(
|
PopupMenuButton<NoteSort>(
|
||||||
tooltip: 'Sort',
|
tooltip: 'Sort',
|
||||||
icon: const Icon(Icons.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>`.
|
`todo-sync/notes/<node id>.json` plus `todo-sync/revs/<node id>`.
|
||||||
Code: `lib/mcp/`, entrypoint `mcp/todo_mcp.dart`, tests `test/mcp/`.
|
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 |
|
| What | Where |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Firebase seed (read once) | `~/.config/todo-mcp/firebase_auth.json` |
|
| 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
|
# 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.65+1
|
version: 1.0.66+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
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();
|
return out.toString().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes [notes] as another device's log.
|
/// Publishes [notes] as device [node]'s log.
|
||||||
void seedPeer(List<Note> notes) {
|
void seedPeer(List<Note> notes, {String node = 'peer'}) {
|
||||||
db.files['todo-sync/notes/peer~2Ejson'] = logToJson({
|
db.files['todo-sync/notes/$node~2Ejson'] = logToJson({
|
||||||
for (final n in notes) n.id: NoteRepository.recordAtNoteTime(n, 'peer'),
|
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(await run(['--list', '--data-dir', dir('b')]), 0);
|
||||||
expect(
|
expect(
|
||||||
out.toString().trim(),
|
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);
|
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(await h.run(['--list', '--repo', 'kuhyx/todo']), 0);
|
||||||
expect(
|
expect(
|
||||||
h.out.toString(),
|
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(await h.run(['--list', '--status', 'done']), 0);
|
||||||
expect(h.out.toString(), isEmpty);
|
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(
|
expect(
|
||||||
listLine(notes[2].copyWith(status: Status.inProgress)),
|
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'));
|
expect(listLine(fixtureNote('abc', 'Short')), startsWith('abc\t'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,6 +104,12 @@ class FakeNoteRepository implements NoteRepository {
|
|||||||
@override
|
@override
|
||||||
Stream<int> watchCount() => _snapshots().map((n) => n.length);
|
Stream<int> watchCount() => _snapshots().map((n) => n.length);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, String>> backfillCreatedBy(
|
||||||
|
Set<String> claudeNodes, {
|
||||||
|
bool dryRun = false,
|
||||||
|
}) async => {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get nodeId => 'fake-node';
|
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