mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
Some checks failed
📎 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>
203 lines
7.8 KiB
Dart
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');
|
|
}
|