todo-app/lib/data/repo_note_split.dart
Krzysztof kuhy Rudnicki ba25e8f176 Wait for the board before a merged Start
The board's note cache lags ~10 s after a merge, so Start found
"no startable item" (T1863); it now waits up to 30 s for the merged
section's card. A Start POST slower than its timeout that did take
effect was reported "board unreachable" (T1864); it now re-reads the
board and reports ok when the card left Start.

Todo: 64d39ab5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 18:18:30 +01:00

121 lines
3.8 KiB
Dart

/// `new_note`: a repo task in its own open note, `<name> todos: <title>`,
/// so it can close on its own done_check. A title that already heads an open
/// section of another separate note of the repo merges into that note
/// instead (it keeps its id); the shared `<name> todos` note never counts.
library;
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repo_note.dart';
import 'package:uuid/uuid.dart';
/// How a separate add landed.
enum SplitKind {
/// A new separate note.
added,
/// Appended to the separate note that already had the title.
merged,
/// A new separate note because the duplicate's note was busy.
fellBack,
}
/// The note to save, how it landed, and why it fell back.
typedef SplitAdd = ({Note note, SplitKind kind, String? why});
final _heading = RegExp(r'^##\s+(\[x\]\s+)?(.*)$');
/// Every `## title` heading of [text] outside code fences, open or done.
Iterable<RegExpMatch> _sections(String text) sync* {
var fenced = false;
for (final line in text.split('\n')) {
if (line.trimLeft().startsWith('```')) fenced = !fenced;
final m = fenced ? null : _heading.firstMatch(line);
if (m != null) yield m;
}
}
/// The open `## title` headings of [text], outside code fences.
Iterable<String> openSections(String text) => _sections(
text,
).where((m) => m.group(1) == null).map((m) => m.group(2)!.trim());
/// How many board items [text] splits into: one per section, done or not.
int sectionCount(String text) => _sections(text).length;
/// The separate open note of [repo] with an open section titled [title].
Note? duplicateTarget(Iterable<Note> open, String repo, String title) {
final hits =
open
.where((n) => n.repo == repo && isSeparateNote(n))
.where((n) => openSections(n.text).contains(title.trim()))
.toList()
..sort(byAge);
return hits.isEmpty ? null : hits.first;
}
/// Why a merged task must not be started, or null when it may be.
String? startBlock(String? doneCheck, String body) {
final check = (doneCheck ?? '').trim().toLowerCase();
if (check.isEmpty || check == 'none') return 'done_check is none';
final first = body
.split('\n')
.firstWhere((l) => l.trim().isNotEmpty, orElse: () => '');
return first.trim().toLowerCase().startsWith('low priority')
? 'marked low priority'
: null;
}
/// Adds the task as its own note, or merges it into the duplicate's note
/// unless [busy] says a board session is working on it.
Future<SplitAdd> addSeparateTask(
NoteRepository repository, {
required String repo,
required String title,
String body = '',
String? doneCheck,
Priority priority = Priority.medium,
String? by,
Future<bool> Function(Note note)? busy,
}) async {
final section = repoSection(title, body);
final open = await repository.listNotes(
filter: NoteFilter(statuses: kOpenStatuses, repo: repo),
);
final target = duplicateTarget(open, repo, title);
String? why;
if (target != null) {
if (busy == null || !await busy(target)) {
return (
note: target.copyWith(
text: '${target.text}\n\n$section',
doneCheck: composeDoneCheck(target.doneCheck, doneCheck),
priority: maxPriority(target.priority, priority),
),
kind: SplitKind.merged,
why: null,
);
}
why =
'not merged into ${target.id.substring(0, 8)}: '
'a board session is running on it';
}
final now = DateTime.now();
return (
note: Note(
id: const Uuid().v4(),
text: '${separateNoteTitle(repo, title)}\n\n$section',
priority: priority,
status: Status.todo,
createdAt: now,
updatedAt: now,
repo: repo,
doneCheck: composeDoneCheck(null, doneCheck),
by: by,
),
kind: why == null ? SplitKind.added : SplitKind.fellBack,
why: why,
);
}