todo-app/lib/data/note_template_parser.dart
Krzysztof kuhy Rudnicki 9f2ffdb8a9
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Attach any file to a note, of any size, not only images
📎 now picks any file (Android: Camera / Gallery / File; desktop: an
unfiltered dialog) and the desktop page accepts any dropped file. Photo and
screenshot formats up to 64 MB are still re-encoded and shown inline;
everything else is stored byte for byte on dufs beside the images and linked
as [name](url), shown as a row with its size that opens in the platform's
app (Android) or a new browser tab (desktop). BACKLOG.md rewrites file links
to ~/data/cloud/todo-images/ like image links.

There is no size cap, so no layer holds a file in memory: uploads and
downloads stream through DufsImageClient, writes land in a .tmp renamed when
whole (an interrupted download no longer leaves a truncated file that looks
done), the desktop page POSTs the browser's own Blob with fetch, and Android's
"File" uses file_picker, because file_selector's Android side reads the whole
file into memory on the UI thread. Verified with a 1 GB file end to end on
desktop (peak RSS +36 MB) and on an API 36 emulator.

The wrapper serves attachments from the origin that holds the GitHub token
and the notes, so only script-free types open inline (nosniff), the rest
download; writes carrying another site's Origin are refused (this also
closes cross-site overwrites of the backup files); and attachment routes run
concurrently so a long upload or a playing video cannot stall the page.

Also: cupertino_icons 2.0.0 (unused, but the freshness gate blocks the
pubspec otherwise), open_file 4.0.0 and file_picker 13.1.0 pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:17:06 +02:00

203 lines
7.8 KiB
Dart

/// The pure text <-> sections conversion for [NoteTemplate].
///
/// Split out of `note_template.dart` (which now owns only the template types
/// and the template data) purely for file size; `note_template.dart` re-exports
/// everything here, so callers import that one file as before.
///
/// Design invariants (see the round-trip tests):
/// * The **template** defines which sections exist; the stored text only
/// carries values. Parsing an unknown/legacy note never invents sections.
/// * `assemble(parse(text))` is idempotent for any text that *conforms* to a
/// template, so opening and closing a structured note never mutates it.
/// * Text that does **not** conform (freeform notes, the old `what —` format,
/// the pre-2026-07 twelve-section spec, hand-mangled raw edits) is reported
/// as such so the UI can fall back to a raw editor and show it verbatim —
/// we never force non-conforming text into the guided stepper and never
/// drop a line we couldn't place.
library;
import 'package:todo/data/note_template.dart';
import 'package:todo/images/attachment_links.dart';
import 'package:todo/images/attachment_ref.dart';
/// Matches a single `http(s)://…` token.
final RegExp _linkPattern = RegExp(r'^https?://\S+$');
/// Whether [text] is nothing but one or more links.
///
/// Deliberately strict: one word of context ("read this https://…") means the
/// note has prose in it and is no longer a bare link.
bool isBareLink(String text) {
final tokens = text.trim().split(RegExp(r'\s+'))
..removeWhere((t) => t.isEmpty);
if (tokens.isEmpty) return false;
return tokens.every(_linkPattern.hasMatch);
}
/// Result of parsing stored text against a template.
class ParsedNote {
/// Creates a [ParsedNote] from its parsed [values] and [conforms] flag.
const ParsedNote({required this.values, required this.conforms});
/// Section-key -> value (trimmed). Sections absent from the text map to ''.
final Map<String, String> values;
/// Whether the text cleanly matched the template. When false the UI must
/// show the text in a raw editor rather than the guided stepper.
final bool conforms;
}
/// Builds the stored note text from section [values], dropping empty sections
/// so the pasted note carries no blank scaffold for the LLM to wade through.
///
/// [template] fixes the section order; [values] is keyed by section key. Each
/// present section becomes a `## label` heading, an italic guidance line, and
/// the value.
String assemble(NoteTemplate template, Map<String, String> values) {
if (template.isFreeform) return (values['body'] ?? '').trimRight();
final blocks = <String>[];
final title = (values['title'] ?? '').trim();
if (title.isNotEmpty) blocks.add('# $title');
for (final section in template.sections) {
if (section.isTitle) continue;
final value = (values[section.key] ?? '').trim();
if (value.isEmpty) continue; // drop empty sections
blocks.add('## ${section.label}\n_${section.helper}_\n\n$value');
}
return blocks.join('\n\n');
}
/// Parses stored [text] into section values for the given [template].
///
/// Freeform templates always conform: the whole text is the `body` value.
/// For structured templates the text conforms only if it uses the Markdown
/// section headings, those headings are known and in template order without
/// repeats, and nothing but the `#` title sits before the first section.
/// Anything else (freeform, the old `what —` format) is reported as
/// non-conforming so the caller can show it raw, untouched.
ParsedNote parse(NoteTemplate template, String text) {
if (template.isFreeform) {
return ParsedNote(values: {'body': text}, conforms: true);
}
// Index of each non-title section in template order, used to detect
// out-of-order or duplicate headings.
final order = <String, int>{};
var oi = 0;
for (final s in template.sections) {
if (!s.isTitle) order[s.key] = oi++;
}
final values = <String, String>{for (final s in template.sections) s.key: ''};
final lines = text.split('\n');
final titleLines = <String>[];
String? currentKey; // open section accumulating its block lines
final blocks = <String, List<String>>{};
var lastOrder = -1;
var sawSection = false;
var conforms = true;
for (final line in lines) {
final heading = _matchHeading(line, template);
if (heading != null) {
sawSection = true;
final idx = order[heading.key]!;
if (idx <= lastOrder) conforms = false; // out of order / duplicate
lastOrder = idx;
currentKey = heading.key;
blocks[heading.key] = <String>[];
continue;
}
// A heading the template retired means the note predates the current
// section set. Keep the line as content (we never drop one) but refuse to
// call the note conforming, so the UI shows it raw instead of letting the
// stepper fold the retired section into its neighbour on the next save.
if (_isRetiredHeading(line)) conforms = false;
if (currentKey != null) {
blocks[currentKey]!.add(line);
continue;
}
// Still before the first section: only the `#` title and blanks belong.
final trimmed = line.trim();
if (trimmed.isEmpty) continue;
if (trimmed.startsWith('# ') && !trimmed.startsWith('## ')) {
titleLines.add(trimmed.substring(2).trim());
} else {
conforms = false; // stray content before the first section
}
}
values['title'] = titleLines.join('\n').trim();
for (final entry in blocks.entries) {
values[entry.key] = _stripGuidance(entry.value).trim();
}
// A structured note with no recognised section headings is really freeform.
if (!sawSection) conforms = false;
return ParsedNote(values: values, conforms: conforms);
}
/// The note's display title: the `#` heading text without its `#` marker, or
/// the first non-empty line for freeform notes. Attachment lines are skipped
/// (a 120-char dufs URL is no title); a note of nothing but attachments is
/// titled by their count, or by the file's name when it holds just one.
String noteTitle(String text) {
final attachments = <Attachment>[];
for (final line in text.split('\n')) {
final trimmed = line.trim();
if (trimmed.isEmpty) continue;
if (attachmentOfLine(trimmed) case final attachment?) {
attachments.add(attachment);
continue;
}
return trimmed.replaceFirst(RegExp(r'^#+\s*'), '');
}
final images = attachments.where((a) => a.isImage).length;
return switch ((attachments.length, images)) {
(0, _) => '',
(1, 1) => 'Image',
(1, _) => attachments.single.name,
(final n, final i) when i == n => '$n images',
(final n, _) => '$n attachments',
};
}
/// Returns the section whose `## label` heading is on [line], or null. Only
/// known section labels are treated as headings, so a `## subheading` the user
/// wrote inside a value is preserved as content rather than splitting the note.
TemplateSection? _matchHeading(String line, NoteTemplate template) {
if (!line.startsWith('## ')) return null;
final label = line.substring(3).trim();
for (final s in template.sections) {
if (!s.isTitle && s.label == label) return s;
}
return null;
}
/// Whether [line] is a `## heading` naming a section the template retired.
bool _isRetiredHeading(String line) {
if (!line.startsWith('## ')) return false;
return NoteTemplate.retiredLabels.contains(line.substring(3).trim());
}
/// Drops the leading blank lines and the single italic guidance line (if any)
/// from a section's block, leaving just the user's value.
String _stripGuidance(List<String> lines) {
final out = List<String>.from(lines);
var i = 0;
while (i < out.length && out[i].trim().isEmpty) {
i++;
}
if (i < out.length) {
final t = out[i].trim();
if (t.length >= 2 && t.startsWith('_') && t.endsWith('_')) {
out.removeAt(i);
}
}
return out.join('\n');
}