feat: add image attachment backend — batch fetch, API responses, optimization, thumbnails

- Add fetchAttachmentsForNotes() batch helper mirroring fetchTagsForNotes pattern
- Include attachments in all note API responses (list, single, sync)
- Fix ImageUpload.svelte URL bug (path-based → query param)
- Add client-side image optimization (resize, compress, WebP conversion)
- Add thumbnailPath column to attachments schema with migration
- Update saveAttachment/deleteAttachment for thumbnail file handling
- Update attachment API: accept thumbnail in POST, serve via &thumb=1 in GET
- Add Cache-Control immutable header for served attachments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-08 22:37:50 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent a279b2f406
commit 854725c7ba
12 changed files with 776 additions and 18 deletions
+67
View File
@@ -0,0 +1,67 @@
CREATE TABLE `attachments` (
`id` text PRIMARY KEY NOT NULL,
`note_id` text NOT NULL,
`filename` text NOT NULL,
`mime_type` text NOT NULL,
`size` integer NOT NULL,
`path` text NOT NULL,
`thumbnail_path` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `note_tags` (
`note_id` text NOT NULL,
`tag_id` integer NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `note_tags_note_id_idx` ON `note_tags` (`note_id`);--> statement-breakpoint
CREATE INDEX `note_tags_tag_id_idx` ON `note_tags` (`tag_id`);--> statement-breakpoint
CREATE TABLE `notes` (
`id` text PRIMARY KEY NOT NULL,
`title` text DEFAULT '' NOT NULL,
`content` text DEFAULT '' NOT NULL,
`color` text DEFAULT 'default' NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`archived` integer DEFAULT false NOT NULL,
`trashed` integer DEFAULT false NOT NULL,
`trashed_at` integer,
`checklist_mode` integer DEFAULT false NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`version` integer DEFAULT 1 NOT NULL
);
--> statement-breakpoint
CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint
CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` integer NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `sync_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`note_id` text NOT NULL,
`operation` text NOT NULL,
`timestamp` integer NOT NULL,
`client_id` text NOT NULL,
FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint
CREATE TABLE `tags` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`);--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`password_hash` text NOT NULL,
`created_at` integer NOT NULL
);
+465
View File
@@ -0,0 +1,465 @@
{
"version": "6",
"dialect": "sqlite",
"id": "682b02aa-a28d-4904-8996-cbc4d740f626",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"attachments": {
"name": "attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"attachments_note_id_notes_id_fk": {
"name": "attachments_note_id_notes_id_fk",
"tableFrom": "attachments",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_tags": {
"name": "note_tags",
"columns": {
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"note_tags_note_id_idx": {
"name": "note_tags_note_id_idx",
"columns": [
"note_id"
],
"isUnique": false
},
"note_tags_tag_id_idx": {
"name": "note_tags_tag_id_idx",
"columns": [
"tag_id"
],
"isUnique": false
}
},
"foreignKeys": {
"note_tags_note_id_notes_id_fk": {
"name": "note_tags_note_id_notes_id_fk",
"tableFrom": "note_tags",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"note_tags_tag_id_tags_id_fk": {
"name": "note_tags_tag_id_tags_id_fk",
"tableFrom": "note_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"archived": {
"name": "archived",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed": {
"name": "trashed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed_at": {
"name": "trashed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checklist_mode": {
"name": "checklist_mode",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {
"notes_trashed_archived_idx": {
"name": "notes_trashed_archived_idx",
"columns": [
"trashed",
"archived"
],
"isUnique": false
},
"notes_updated_at_idx": {
"name": "notes_updated_at_idx",
"columns": [
"updated_at"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_log": {
"name": "sync_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sync_log_timestamp_idx": {
"name": "sync_log_timestamp_idx",
"columns": [
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {
"sync_log_note_id_notes_id_fk": {
"name": "sync_log_note_id_notes_id_fk",
"tableFrom": "sync_log",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_name_unique": {
"name": "tags_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1773000424830,
"tag": "0000_wooden_vargas",
"breakpoints": true
}
]
}