Rename ImageRef to AttachmentRef ahead of non-image attachments

A ref is about to name any file stored under todo-images/, not only a
re-encoded JPEG. Mechanical rename of the type and its file; no behaviour
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-01 10:59:34 +02:00
parent 2bbfe0b663
commit 05056cf8c2
24 changed files with 100 additions and 109 deletions

View File

@ -103,7 +103,7 @@ migrate → Settings → Import).
A note never holds image bytes, only a line
`![](https://kuhy-cloud.duckdns.org/todo-images/<noteId>/<uuid>.jpg)`
(`lib/images/image_ref.dart` owns that shape), so sync and older builds are
(`lib/images/attachment_ref.dart` owns that shape), so sync and older builds are
untouched. Every image is re-encoded before it leaves the device: ≤2560 px,
JPEG q85, EXIF stripped after applying its rotation (`image_processing.dart`).
`ImageCacheStore` keeps every linked image on-device (offline) plus a

View File

@ -8,7 +8,7 @@ library;
import 'package:flutter/foundation.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
/// Where to draw an image from right now.
sealed class ImageSource {
@ -70,24 +70,24 @@ abstract class ImageStore extends ChangeNotifier {
///
/// Returns the new ref, or null when [bytes] is not an image. Throws
/// [ImageStoreException] when it cannot be stored.
Future<ImageRef?> attach(String noteId, Uint8List bytes);
Future<AttachmentRef?> attach(String noteId, Uint8List bytes);
/// Uploads the queue, fetches every image [noteTexts] link to that this
/// device lacks, and evicts cached images no note links to.
Future<void> reconcile(Iterable<String> noteTexts);
/// Where to draw [ref] from.
ImageSource sourceFor(ImageRef ref);
ImageSource sourceFor(AttachmentRef ref);
/// Whether [ref] is still waiting to upload (always false when unknown).
bool isPending(ImageRef ref);
bool isPending(AttachmentRef ref);
/// Login and queue summary.
Future<ImageStoreStatus> status();
}
/// Every image ref across [noteTexts].
Set<ImageRef> imageRefsAcross(Iterable<String> noteTexts) => {
Set<AttachmentRef> imageRefsAcross(Iterable<String> noteTexts) => {
for (final text in noteTexts) ...imageRefsIn(text),
};
@ -95,17 +95,17 @@ Set<ImageRef> imageRefsAcross(Iterable<String> noteTexts) => {
/// outside the app root): attaching is refused, nothing is ever available.
class NoImageStore extends ImageStore {
@override
Future<ImageRef?> attach(String noteId, Uint8List bytes) async =>
Future<AttachmentRef?> attach(String noteId, Uint8List bytes) async =>
throw const ImageStoreException('Images are not available here');
@override
Future<void> reconcile(Iterable<String> noteTexts) async {}
@override
ImageSource sourceFor(ImageRef ref) => const MissingImageSource();
ImageSource sourceFor(AttachmentRef ref) => const MissingImageSource();
@override
bool isPending(ImageRef ref) => false;
bool isPending(AttachmentRef ref) => false;
@override
Future<ImageStoreStatus> status() async =>

View File

@ -8,13 +8,12 @@ import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:todo/attachments/image_password_store.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/images/image_cache_store.dart';
import 'package:todo/images/image_processing.dart';
import 'package:todo/images/image_ref.dart';
/// Opens the store under the app-support directory's `images/`.
// coverage:ignore-start
@ -47,10 +46,10 @@ class IoImageStore extends ImageStore {
final Future<Uint8List?> Function(Uint8List) _prepare;
@override
Future<ImageRef?> attach(String noteId, Uint8List bytes) async {
Future<AttachmentRef?> attach(String noteId, Uint8List bytes) async {
final jpeg = await _prepare(bytes);
if (jpeg == null) return null;
final ref = ImageRef.create(noteId);
final ref = AttachmentRef.create(noteId);
try {
await cache.put(ref, jpeg);
} on FileSystemException catch (e) {
@ -67,7 +66,7 @@ class IoImageStore extends ImageStore {
Future<void> reconcile(Iterable<String> noteTexts) =>
_sync(imageRefsAcross(noteTexts));
Future<void> _sync(Set<ImageRef> refs) async {
Future<void> _sync(Set<AttachmentRef> refs) async {
final login = await _passwords.login();
final client = login == null
? null
@ -83,7 +82,7 @@ class IoImageStore extends ImageStore {
}
@override
ImageSource sourceFor(ImageRef ref) {
ImageSource sourceFor(AttachmentRef ref) {
final file = cache.fileFor(ref);
return file.existsSync()
? LocalImageSource(file.path)
@ -91,7 +90,7 @@ class IoImageStore extends ImageStore {
}
@override
bool isPending(ImageRef ref) => cache.isPending(ref);
bool isPending(AttachmentRef ref) => cache.isPending(ref);
@override
Future<ImageStoreStatus> status() async => ImageStoreStatus(

View File

@ -9,7 +9,7 @@ import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/sync/desktop_wrapper.dart';
/// Opens the store against the wrapper that served this page.
@ -34,8 +34,8 @@ class WebImageStore extends ImageStore {
Uri _images(String rest) => Uri.parse('$origin/images/$rest');
@override
Future<ImageRef?> attach(String noteId, Uint8List bytes) async {
final ref = ImageRef.create(noteId);
Future<AttachmentRef?> attach(String noteId, Uint8List bytes) async {
final ref = AttachmentRef.create(noteId);
final http.Response response;
try {
response = await _http.post(_images(ref.relativePath), body: bytes);
@ -60,7 +60,7 @@ class WebImageStore extends ImageStore {
Future<void> reconcile(Iterable<String> noteTexts) =>
_reconcile(imageRefsAcross(noteTexts));
Future<void> _reconcile(Set<ImageRef> refs) async {
Future<void> _reconcile(Set<AttachmentRef> refs) async {
try {
final response = await _http.post(
_images('reconcile'),
@ -81,11 +81,11 @@ class WebImageStore extends ImageStore {
}
@override
ImageSource sourceFor(ImageRef ref) =>
ImageSource sourceFor(AttachmentRef ref) =>
NetworkImageSource('$origin/images/${ref.relativePath}?r=$_revision');
@override
bool isPending(ImageRef ref) => false;
bool isPending(AttachmentRef ref) => false;
@override
Future<ImageStoreStatus> status() async {

View File

@ -17,7 +17,7 @@
library;
import 'package:todo/data/note_template.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
/// Matches a single `http(s)://…` token.
final RegExp _linkPattern = RegExp(r'^https?://\S+$');

View File

@ -21,13 +21,12 @@ import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:todo/desktop/dufs_login_env.dart';
import 'package:todo/desktop/wrapper_paths.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/images/image_cache_store.dart';
import 'package:todo/images/image_processing.dart';
import 'package:todo/images/image_ref.dart';
/// Largest upload accepted from the page, before re-encoding (a raw 50 MP
/// phone photo is ~25 MB).
@ -77,7 +76,7 @@ class WrapperImageRoutes {
'configured': _login() != null,
'pending': store.pending().length,
});
} else if (ImageRef.tryParse(rest) case final ref?) {
} else if (AttachmentRef.tryParse(rest) case final ref?) {
if (method == 'GET') {
await _serve(response, ref);
} else if (method == 'POST') {
@ -93,7 +92,7 @@ class WrapperImageRoutes {
DufsLogin? _login() => readDufsLoginEnv(File(loginEnvPath));
Future<void> _serve(HttpResponse response, ImageRef ref) async {
Future<void> _serve(HttpResponse response, AttachmentRef ref) async {
final file = store.fileFor(ref);
if (!file.existsSync()) {
response.statusCode = HttpStatus.notFound;
@ -106,7 +105,7 @@ class WrapperImageRoutes {
await response.addStream(file.openRead());
}
Future<void> _put(HttpRequest request, ImageRef ref) async {
Future<void> _put(HttpRequest request, AttachmentRef ref) async {
final builder = BytesBuilder(copy: false);
var tooLarge = false;
// Read to the end even once over the cap: answering while the browser
@ -130,12 +129,12 @@ class WrapperImageRoutes {
}
Future<void> _reconcile(HttpRequest request) async {
final Set<ImageRef> refs;
final Set<AttachmentRef> refs;
try {
final body = jsonDecode(await utf8.decodeStream(request));
refs = {
for (final raw in (body as Map<String, dynamic>)['refs'] as List)
?ImageRef.tryParse(raw as String),
?AttachmentRef.tryParse(raw as String),
};
} on Object {
request.response.statusCode = HttpStatus.badRequest;

View File

@ -40,17 +40,17 @@ final _wholeLine = RegExp('^\\s*${_urlLink.pattern}\\s*\$');
///
/// An extension type over that relative path: equality and hashing are the
/// string's, so refs drop straight into sets.
extension type const ImageRef._(String relativePath) {
extension type const AttachmentRef._(String relativePath) {
/// A new, unique ref for an image being attached to [noteId].
factory ImageRef.create(String noteId) {
final ref = ImageRef.tryParse('$noteId/${const Uuid().v4()}.jpg');
factory AttachmentRef.create(String noteId) {
final ref = AttachmentRef.tryParse('$noteId/${const Uuid().v4()}.jpg');
if (ref == null) throw ArgumentError.value(noteId, 'noteId');
return ref;
}
/// Parses `<noteId>/<file>.jpg`, or null when it is not a safe image path.
static ImageRef? tryParse(String relativePath) =>
_refPath.hasMatch(relativePath) ? ImageRef._(relativePath) : null;
static AttachmentRef? tryParse(String relativePath) =>
_refPath.hasMatch(relativePath) ? AttachmentRef._(relativePath) : null;
/// The note the image was attached to (it names the folder only).
String get noteId => relativePath.substring(0, relativePath.indexOf('/'));
@ -66,15 +66,15 @@ extension type const ImageRef._(String relativePath) {
}
/// Every image [text] links to, in order, without duplicates.
Set<ImageRef> imageRefsIn(String text) => {
Set<AttachmentRef> imageRefsIn(String text) => {
for (final match in _urlLink.allMatches(text))
ImageRef.tryParse(match.group(1)!)!,
AttachmentRef.tryParse(match.group(1)!)!,
};
/// The image when [line] is nothing but one image link, else null.
ImageRef? imageRefOfLine(String line) {
AttachmentRef? imageRefOfLine(String line) {
final match = _wholeLine.firstMatch(line);
return match == null ? null : ImageRef.tryParse(match.group(1)!);
return match == null ? null : AttachmentRef.tryParse(match.group(1)!);
}
/// Rewrites image links to their on-disk path under [kBacklogImagesRoot].

View File

@ -13,7 +13,7 @@ import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
/// A scoped dufs login, e.g. `todo` → `/todo-images`.
class DufsLogin {
@ -66,7 +66,7 @@ class DufsImageClient {
Uri.parse('${login.baseUrl}/$kImagesDir/$relative');
/// PUTs [bytes] as [ref]; true on a 2xx.
Future<bool> upload(ImageRef ref, Uint8List bytes) async {
Future<bool> upload(AttachmentRef ref, Uint8List bytes) async {
try {
final response = await _http.put(
_uri(ref.relativePath),
@ -80,12 +80,9 @@ class DufsImageClient {
}
/// GETs [ref]'s bytes, or null when it is missing or unreachable.
Future<Uint8List?> download(ImageRef ref) async {
Future<Uint8List?> download(AttachmentRef ref) async {
try {
final response = await _http.get(
_uri(ref.relativePath),
headers: _auth,
);
final response = await _http.get(_uri(ref.relativePath), headers: _auth);
return response.statusCode == 200 ? response.bodyBytes : null;
} on Exception {
return null;

View File

@ -14,9 +14,8 @@ import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/images/image_ref.dart';
/// How long a cached, unreferenced image is kept before eviction.
///
@ -79,15 +78,16 @@ class ImageCacheStore {
_Pass? _scheduledPass;
/// The cached file for [ref] (it may not exist).
File fileFor(ImageRef ref) => File(p.join(root.path, ref.noteId, ref.file));
File fileFor(AttachmentRef ref) =>
File(p.join(root.path, ref.noteId, ref.file));
File _marker(ImageRef ref) => File('${fileFor(ref).path}.pending');
File _marker(AttachmentRef ref) => File('${fileFor(ref).path}.pending');
/// Whether [ref] is waiting to upload.
bool isPending(ImageRef ref) => _marker(ref).existsSync();
bool isPending(AttachmentRef ref) => _marker(ref).existsSync();
/// Stores [bytes] as [ref] and queues it for upload.
Future<void> put(ImageRef ref, Uint8List bytes) async {
Future<void> put(AttachmentRef ref, Uint8List bytes) async {
final file = fileFor(ref);
await file.parent.create(recursive: true);
// Marker first: a crash between the two writes then leaves a marker with
@ -99,9 +99,9 @@ class ImageCacheStore {
}
/// Every image waiting to upload.
List<ImageRef> pending() => [
List<AttachmentRef> pending() => [
for (final marker in _files('.pending'))
?ImageRef.tryParse(_relative(marker).replaceFirst('.pending', '')),
?AttachmentRef.tryParse(_relative(marker).replaceFirst('.pending', '')),
];
/// Uploads the queue, fetches linked images this device lacks, and evicts
@ -115,7 +115,7 @@ class ImageCacheStore {
/// Overlapping calls do not run in parallel: calls made before the next
/// pass starts share it, with their refs unioned and the newest client.
Future<ReconcileReport> reconcile(
Set<ImageRef> referenced,
Set<AttachmentRef> referenced,
DufsImageClient? client,
) {
// Merge into a pass that has not started yet: union the refs (an
@ -174,11 +174,11 @@ class ImageCacheStore {
);
}
int _evict(Set<ImageRef> referenced) {
int _evict(Set<AttachmentRef> referenced) {
final cutoff = _now().subtract(kEvictionGrace);
var evicted = 0;
for (final file in _files('.jpg')) {
final ref = ImageRef.tryParse(_relative(file));
final ref = AttachmentRef.tryParse(_relative(file));
if (ref == null || referenced.contains(ref) || isPending(ref)) continue;
if (file.lastModifiedSync().isAfter(cutoff)) continue;
file.deleteSync();
@ -202,6 +202,6 @@ class ImageCacheStore {
class _Pass {
const _Pass(this.referenced, this.client);
final Set<ImageRef> referenced;
final Set<AttachmentRef> referenced;
final DufsImageClient? client;
}

View File

@ -1,5 +1,5 @@
import 'package:todo/data/note.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:uuid/uuid.dart';
/// Serialises notes to (and parses them back from) a single Markdown file.

View File

@ -12,7 +12,7 @@ import 'package:flutter/widgets.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
/// Lets a screen insert lines into whichever editor is currently mounted.
class ImageAttachController {
@ -52,7 +52,7 @@ Future<void> attachImages(
int rejected = 0,
}) async {
final store = ImageStoreScope.read(context);
final refs = <ImageRef>[];
final refs = <AttachmentRef>[];
var notImages = rejected;
String? failure;
for (final bytes in images) {

View File

@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/ui/images/image_viewer_screen.dart';
import 'package:todo/ui/images/local_image.dart';
@ -19,14 +19,10 @@ const kImageWaitingToUpload = 'Waiting to upload';
/// Renders [ref] from the nearest [ImageStoreScope].
class ImageEmbed extends StatelessWidget {
/// Creates an embed; [fit] is `cover` for thumbnails, `contain` inline.
const ImageEmbed({
required this.ref,
this.fit = BoxFit.cover,
super.key,
});
const ImageEmbed({required this.ref, this.fit = BoxFit.cover, super.key});
/// The image to show.
final ImageRef ref;
final AttachmentRef ref;
/// How the picture fills the embed's box.
final BoxFit fit;

View File

@ -1,7 +1,7 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/ui/images/image_embed.dart';
/// Height of the thumbnail row under the editor.

View File

@ -1,7 +1,7 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/ui/images/image_embed.dart';
/// Tallest an image is drawn inside a rendered note.
@ -11,10 +11,10 @@ const kInlineImageHeight = 320.0;
///
/// The app's notes use a small, known Markdown subset — an `#` title, `##`
/// section headings, italic `_guidance_` lines, `-` bullet lists and plain
/// paragraphs, plus one attached image per line (see `image_ref.dart`) — so a
/// tailored line-based renderer covers everything we emit
/// without pulling in a full Markdown engine. It keeps the quick-capture app
/// lean and is trivial to test.
/// paragraphs, plus one attached image per line (see `attachment_ref.dart`)
/// — so a tailored line-based renderer covers everything we emit without
/// pulling in a full Markdown engine. It keeps the quick-capture app lean and
/// is trivial to test.
///
/// The whole rendered note is wrapped in a [SelectionArea] so it can be copied
/// out in one go (the note is meant to be pasted into an LLM).

View File

@ -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
# 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.
version: 1.0.39+1
version: 1.0.40+1
environment:
sdk: ^3.12.2

View File

@ -4,10 +4,10 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
void main() {
final ref = ImageRef.create('n1');
final ref = AttachmentRef.create('n1');
test('NoImageStore refuses to attach and has nothing', () async {
final store = NoImageStore();
@ -27,7 +27,7 @@ void main() {
});
test('imageRefsAcross unions every note', () {
final other = ImageRef.create('n2');
final other = AttachmentRef.create('n2');
expect(
imageRefsAcross([ref.markdown, '${other.markdown}\n${ref.markdown}']),
{ref, other},

View File

@ -7,7 +7,7 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_web.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
void main() {
final requests = <http.Request>[];
@ -84,7 +84,7 @@ void main() {
);
test('reconcile sends every linked ref; a download bumps the url', () async {
final ref = ImageRef.create('n1');
final ref = AttachmentRef.create('n1');
final store = make((_) async => report(downloaded: 1));
await store.reconcile(['a ${ref.markdown}', 'none']);
expect(jsonDecode(requests.single.body), {

View File

@ -8,7 +8,7 @@ import 'package:path/path.dart' as p;
import 'package:todo/desktop/wrapper_image_routes.dart';
import 'package:todo/desktop/wrapper_server.dart';
import 'package:todo/images/image_cache_store.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
import '../images/fake_dufs.dart';
@ -71,7 +71,7 @@ void main() {
body: Uint8List.fromList([0xFF, 1, 2]),
);
expect(post.statusCode, 204);
expect(store.isPending(ImageRef.tryParse(rel)!), isTrue);
expect(store.isPending(AttachmentRef.tryParse(rel)!), isTrue);
final get = await http.get(Uri.parse('$base/images/$rel'));
expect(get.statusCode, 200);
expect(get.headers['content-type'], 'image/jpeg');

View File

@ -7,22 +7,22 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
/// Records attaches and reconciles; every image is "local" at a fake path
/// unless listed in [missing].
class FakeImageStore extends ImageStore {
/// Bytes accepted per ref, in attach order.
final Map<ImageRef, Uint8List> attached = {};
final Map<AttachmentRef, Uint8List> attached = {};
/// Note texts passed to each reconcile.
final List<List<String>> reconciles = [];
/// Refs reported as not on this device.
final Set<ImageRef> missing = {};
final Set<AttachmentRef> missing = {};
/// Refs reported as waiting to upload.
final Set<ImageRef> pending = {};
final Set<AttachmentRef> pending = {};
/// Inputs whose first byte is this are "not an image".
int notImageMarker = 0;
@ -40,12 +40,12 @@ class FakeImageStore extends ImageStore {
Future<void>? gate;
@override
Future<ImageRef?> attach(String noteId, Uint8List bytes) async {
Future<AttachmentRef?> attach(String noteId, Uint8List bytes) async {
await gate;
final failure = this.failure;
if (failure != null) throw failure;
if (bytes.isNotEmpty && bytes.first == notImageMarker) return null;
final ref = ImageRef.create(noteId);
final ref = AttachmentRef.create(noteId);
attached[ref] = bytes;
return ref;
}
@ -55,7 +55,7 @@ class FakeImageStore extends ImageStore {
reconciles.add(noteTexts.toList());
@override
ImageSource sourceFor(ImageRef ref) {
ImageSource sourceFor(AttachmentRef ref) {
if (missing.contains(ref)) return const MissingImageSource();
return network
? NetworkImageSource('http://localhost/images/$ref')
@ -63,7 +63,7 @@ class FakeImageStore extends ImageStore {
}
@override
bool isPending(ImageRef ref) => pending.contains(ref);
bool isPending(AttachmentRef ref) => pending.contains(ref);
@override
Future<ImageStoreStatus> status() async =>

View File

@ -1,9 +1,9 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
void main() {
const rel = 'note-1/0815ff8a-203e-4f54-9cc5-8a58b27982ec.jpg';
final ref = ImageRef.tryParse(rel)!;
final ref = AttachmentRef.tryParse(rel)!;
test('a ref exposes its parts, url and markdown', () {
expect(ref.noteId, 'note-1');
@ -14,15 +14,15 @@ void main() {
});
test('create files a fresh uuid jpg under the note', () {
final a = ImageRef.create('n1');
final b = ImageRef.create('n1');
final a = AttachmentRef.create('n1');
final b = AttachmentRef.create('n1');
expect(a.noteId, 'n1');
expect(a.file, endsWith('.jpg'));
expect(a, isNot(b));
});
test('create refuses an unsafe note id', () {
expect(() => ImageRef.create('../x'), throwsArgumentError);
expect(() => AttachmentRef.create('../x'), throwsArgumentError);
});
test('tryParse rejects traversal, nesting and other extensions', () {
@ -35,16 +35,16 @@ void main() {
'/a/b.jpg',
'a b/c.jpg',
]) {
expect(ImageRef.tryParse(bad), isNull, reason: bad);
expect(AttachmentRef.tryParse(bad), isNull, reason: bad);
}
});
test('refs compare by path, so sets dedupe', () {
expect({ref, ImageRef.tryParse(rel)!}, hasLength(1));
expect({ref, AttachmentRef.tryParse(rel)!}, hasLength(1));
});
test('imageRefsIn finds every dufs image link, in order, once', () {
final other = ImageRef.create('n2');
final other = AttachmentRef.create('n2');
final text =
'hi ${ref.markdown} and\n${other.markdown}\n${ref.markdown}\n'
'![x](https://example.com/a.jpg) ![]($kDufsBaseUrl/Keepass/a.jpg)';

View File

@ -6,10 +6,10 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
void main() {
final ref = ImageRef.tryParse('n1/a.jpg')!;
final ref = AttachmentRef.tryParse('n1/a.jpg')!;
const login = DufsLogin(user: 'todo', password: 'pw', baseUrl: 'https://d');
final auth = 'Basic ${base64.encode(utf8.encode('todo:pw'))}';

View File

@ -5,7 +5,7 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:todo/images/image_cache_store.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
import 'fake_dufs.dart';
@ -15,8 +15,8 @@ void main() {
late FakeDufs dufs;
var now = DateTime(2026, 9, 25, 12);
final a = ImageRef.tryParse('n1/a.jpg')!;
final b = ImageRef.tryParse('n2/b.jpg')!;
final a = AttachmentRef.tryParse('n1/a.jpg')!;
final b = AttachmentRef.tryParse('n2/b.jpg')!;
final bytes = Uint8List.fromList([1, 2, 3]);
setUp(() {
@ -27,7 +27,7 @@ void main() {
});
tearDown(() => root.deleteSync(recursive: true));
void age(ImageRef ref, Duration by) =>
void age(AttachmentRef ref, Duration by) =>
store.fileFor(ref).setLastModifiedSync(now.subtract(by));
test('put stores the file and queues it', () async {

View File

@ -6,7 +6,7 @@ import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template_parser.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/sync/desktop_wrapper.dart';
import 'package:todo/sync/notes_markdown.dart';
import 'package:todo/ui/capture_draft.dart';
@ -30,7 +30,7 @@ Note note(String text) => Note(
);
void main() {
final ref = ImageRef.create('n1');
final ref = AttachmentRef.create('n1');
testWidgets('detail: 📎 inserts at the cursor and persists', (tester) async {
tester.view.physicalSize = const Size(1200, 2400);

View File

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/images/attachment_ref.dart';
import 'package:todo/ui/images/image_embed.dart';
import 'package:todo/ui/images/image_strip.dart';
import 'package:todo/ui/images/image_viewer_screen.dart';
@ -12,7 +12,7 @@ import 'package:todo/ui/images/image_viewer_screen.dart';
import '../fake_image_store.dart';
void main() {
final ref = ImageRef.create('n1');
final ref = AttachmentRef.create('n1');
late FakeImageStore store;
late Directory dir;
@ -82,7 +82,7 @@ void main() {
});
testWidgets('the strip shows one thumbnail per linked image', (tester) async {
final other = ImageRef.create('n2');
final other = AttachmentRef.create('n2');
await pump(
tester,
ImageStrip(text: 'x\n${ref.markdown}\n${other.markdown}'),