M6 1900: any-file attachments + audio memos (broaden beyond images)
A note can now carry any file, not just images — PDFs, documents, audio memos, etc. "Dump anything" capture. Backend: - note_attachments.filename (migration 0019) records the original name for download + display. - Upload drops the image-only mime gate: accepts any type, derives the storage extension from the filename, and enforces a DB-backed per-file cap — new setting max_attachment_mb (default 25, rule 25). App body ceiling raised 12→64 MB (also lifts the import-zip / sync-push limits); the per-file cap is the effective attachment limit. - Serve sets Content-Disposition: images inline, everything else downloads with its original (header-sanitized) filename. - Import (native + Keep Takeout) now brings in ANY attachment, not just images — completing the Keep audio-memo gap; preserves filename + sha256. - Attachment metadata (delta feed + REST) carries filename. Frontend: - Editor renders attachments by kind: images inline (thumbnail), audio via an inline <audio> player, any other file as a download chip (paperclip + filename + size). File picker accepts any type; "Attach a file". - Card previews the first image; non-image files show as compact chips. Tests (DB-free): _safe_filename, _attachment_ext, _header_filename. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -423,9 +423,20 @@ async function toggleKind() {
|
||||
}
|
||||
|
||||
// ---- attachments ----
|
||||
function pickImage() {
|
||||
function pickFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
function attKind(mime: string): "image" | "audio" | "file" {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
return "file";
|
||||
}
|
||||
function fmtSize(bytes?: number): string {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
async function uploadFile(file: File) {
|
||||
const id = await ensureDraft();
|
||||
if (!id) return;
|
||||
@@ -433,7 +444,7 @@ async function uploadFile(file: File) {
|
||||
try {
|
||||
await notes.uploadAttachment(id, file);
|
||||
} catch (e) {
|
||||
uploadError.value = (e as { error?: string }).error ?? "Could not upload image.";
|
||||
uploadError.value = (e as { error?: string }).error ?? "Could not upload file.";
|
||||
}
|
||||
}
|
||||
async function onFileChange(e: Event) {
|
||||
@@ -541,18 +552,55 @@ defineExpose({ open });
|
||||
@paste="onPaste"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
<div v-if="liveNote.attachments.length" class="flex flex-wrap gap-2">
|
||||
<div v-for="att in liveNote.attachments" :key="att.id" class="group/att relative">
|
||||
<img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
|
||||
aria-label="Remove image"
|
||||
@click="notes.deleteAttachment(liveNote.id, att.id)"
|
||||
<div v-if="liveNote.attachments.length" class="flex flex-wrap items-center gap-2">
|
||||
<template v-for="att in liveNote.attachments" :key="att.id">
|
||||
<!-- Image → inline thumbnail -->
|
||||
<div v-if="attKind(att.mime) === 'image'" class="group/att relative">
|
||||
<img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1 top-1 rounded-full bg-black/50 px-1.5 text-white opacity-0 transition group-hover/att:opacity-100"
|
||||
aria-label="Remove attachment"
|
||||
@click="notes.deleteAttachment(liveNote.id, att.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<!-- Audio → inline player -->
|
||||
<div
|
||||
v-else-if="attKind(att.mime) === 'audio'"
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-200 px-2 py-1 dark:border-neutral-700"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<audio controls :src="att.url" class="h-8 max-w-[220px]"></audio>
|
||||
<button
|
||||
type="button"
|
||||
class="text-neutral-400 hover:text-red-500"
|
||||
aria-label="Remove attachment"
|
||||
@click="notes.deleteAttachment(liveNote.id, att.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<!-- Any other file → download chip -->
|
||||
<a
|
||||
v-else
|
||||
:href="att.url"
|
||||
download
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-200 px-3 py-2 text-xs hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Icon name="paperclip" />
|
||||
<span class="max-w-[160px] truncate">{{ att.filename || "file" }}</span>
|
||||
<span v-if="att.size" class="text-neutral-400">{{ fmtSize(att.size) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 text-neutral-400 hover:text-red-500"
|
||||
aria-label="Remove attachment"
|
||||
@click.prevent.stop="notes.deleteAttachment(liveNote.id, att.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="uploadError" class="text-xs text-red-600 dark:text-red-400">{{ uploadError }}</p>
|
||||
|
||||
@@ -710,19 +758,13 @@ defineExpose({ open });
|
||||
v-if="richEnabled && !liveNote.trashed"
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
title="Add image"
|
||||
aria-label="Add image"
|
||||
@click="pickImage"
|
||||
title="Attach a file"
|
||||
aria-label="Attach a file"
|
||||
@click="pickFile"
|
||||
>
|
||||
<Icon name="image" />
|
||||
<Icon name="paperclip" />
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="hidden"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
|
||||
<button
|
||||
v-if="!liveNote.trashed"
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user