todo-app/lib/data/repo_note.dart
Krzysztof kuhy Rudnicki 57019eec30
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
Track who filed a note (created_by)
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>
2026-11-10 14:18:19 +01:00

108 lines
3.3 KiB
Dart

/// The one-open-note-per-repo invariant. Single add path for the MCP
/// `add_task` and the CLI `--repo` add; callers persist and push.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:uuid/uuid.dart';
/// Statuses that count as open.
const Set<Status> kOpenStatuses = {Status.todo, Status.inProgress};
/// The repo note's title.
String repoNoteTitle(String repo) => '${repo.split('/').last} todos';
/// A `new_note` task's own note title: `<name> todos: <title>`.
String separateNoteTitle(String repo, String title) =>
'${repoNoteTitle(repo)}: ${title.trim()}';
/// Whether [note] is a `new_note` note, which the shared add path and the
/// one-time merge leave alone.
bool isSeparateNote(Note note) {
final repo = note.repo;
return repo != null &&
splitNote(note.text).title.startsWith('${repoNoteTitle(repo)}: ');
}
/// Joins two checks with `&&`; either may be absent.
String? composeDoneCheck(String? older, String? newer) {
final a = (older ?? '').trim();
final b = (newer ?? '').trim();
if (a.isEmpty) return b.isEmpty ? null : b;
return b.isEmpty ? a : '( $a ) && ( $b )';
}
/// A `## title` section.
String repoSection(String title, String body) {
final text = body.trim();
return text.isEmpty ? '## $title' : '## $title\n$text';
}
/// The higher priority.
Priority maxPriority(Priority a, Priority b) => a.value >= b.value ? a : b;
/// Title line and rest of a note's text.
({String title, String body}) splitNote(String text) {
final lines = text.split('\n');
final first = lines.indexWhere((l) => l.trim().isNotEmpty);
if (first < 0) return (title: '', body: '');
return (
title: lines[first].trim().replaceFirst(RegExp(r'^#+\s*'), ''),
body: lines.skip(first + 1).join('\n').trim(),
);
}
/// Oldest first; ties broken by id so the choice is deterministic.
int byAge(Note a, Note b) {
final c = a.createdAt.compareTo(b.createdAt);
return c != 0 ? c : a.id.compareTo(b.id);
}
/// The note to save and whether it was appended.
typedef RepoAdd = ({Note note, bool appended});
/// Appends the task to [repo]'s oldest open shared note, or builds that
/// note; separate (`new_note`) notes are never joined.
Future<RepoAdd> addRepoTask(
NoteRepository repository, {
required String repo,
required String title,
String body = '',
String? doneCheck,
Priority priority = Priority.medium,
String? by,
String? createdBy,
}) async {
final section = repoSection(title, body);
final open = (await repository.listNotes(
filter: NoteFilter(statuses: kOpenStatuses, repo: repo),
)).where((n) => !isSeparateNote(n)).toList();
if (open.isNotEmpty) {
final existing = (open..sort(byAge)).first;
return (
note: existing.copyWith(
text: '${existing.text}\n\n$section',
doneCheck: composeDoneCheck(existing.doneCheck, doneCheck),
priority: maxPriority(existing.priority, priority),
),
appended: true,
);
}
final now = DateTime.now();
return (
note: Note(
id: const Uuid().v4(),
text: '${repoNoteTitle(repo)}\n\n$section',
priority: priority,
status: Status.todo,
createdAt: now,
updatedAt: now,
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
createdBy: createdBy,
),
appended: false,
);
}