From 9d4ff506dc2f9327d66bc548ee32568d7c6d4777 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 12 Apr 2026 18:17:26 +0300 Subject: [PATCH] feat(standalone): start working on an image service --- .../src/local-server-worker.ts | 1 + .../src/services/image_provider.ts | 96 +++++++ apps/client-standalone/src/vite-env.d.ts | 31 --- apps/server/src/main.ts | 1 + apps/server/src/services/image.ts | 257 ++---------------- apps/server/src/services/image_provider.ts | 114 ++++++++ packages/trilium-core/src/index.ts | 7 +- packages/trilium-core/src/services/image.ts | 168 ++++++++++-- .../src/services/image_provider.ts | 44 +++ 9 files changed, 442 insertions(+), 277 deletions(-) create mode 100644 apps/client-standalone/src/services/image_provider.ts delete mode 100644 apps/client-standalone/src/vite-env.d.ts create mode 100644 apps/server/src/services/image_provider.ts create mode 100644 packages/trilium-core/src/services/image_provider.ts diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index bea560bd1c..08fd86bb7b 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -184,6 +184,7 @@ async function initialize(): Promise { if (!response.ok) return null; return new Uint8Array(await response.arrayBuffer()); }, + image: (await import("./services/image_provider.js")).standaloneImageProvider, dbConfig: { provider: sqlProvider!, isReadOnly: false, diff --git a/apps/client-standalone/src/services/image_provider.ts b/apps/client-standalone/src/services/image_provider.ts new file mode 100644 index 0000000000..d5c2ca949e --- /dev/null +++ b/apps/client-standalone/src/services/image_provider.ts @@ -0,0 +1,96 @@ +/** + * Standalone image provider implementation. + * Uses pure JavaScript for format detection without compression. + * Images are saved as-is without resizing. + */ + +import type { ImageProvider, ImageFormat, ProcessedImage } from "@triliumnext/core"; + +/** + * Detect image type from buffer using magic bytes. + */ +function getImageTypeFromBuffer(buffer: Uint8Array): ImageFormat | null { + if (buffer.length < 12) { + return null; + } + + // Check for SVG (text-based) + if (isSvg(buffer)) { + return { ext: "svg", mime: "image/svg+xml" }; + } + + // JPEG: FF D8 FF + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return { ext: "jpg", mime: "image/jpeg" }; + } + + // PNG: 89 50 4E 47 0D 0A 1A 0A + if ( + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + ) { + return { ext: "png", mime: "image/png" }; + } + + // GIF: "GIF" + if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { + return { ext: "gif", mime: "image/gif" }; + } + + // WebP: RIFF....WEBP + if ( + buffer[0] === 0x52 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + buffer[3] === 0x46 && + buffer[8] === 0x57 && + buffer[9] === 0x45 && + buffer[10] === 0x42 && + buffer[11] === 0x50 + ) { + return { ext: "webp", mime: "image/webp" }; + } + + // BMP: "BM" + if (buffer[0] === 0x42 && buffer[1] === 0x4d) { + return { ext: "bmp", mime: "image/bmp" }; + } + + return null; +} + +/** + * Check if buffer contains SVG content. + */ +function isSvg(buffer: Uint8Array): boolean { + const maxBytes = Math.min(buffer.length, 1000); + let str = ""; + for (let i = 0; i < maxBytes; i++) { + str += String.fromCharCode(buffer[i]); + } + + const trimmed = str.trim().toLowerCase(); + return trimmed.startsWith(" { + // Standalone doesn't do compression - just detect format and return original + const format = getImageTypeFromBuffer(buffer) || { ext: "dat", mime: "application/octet-stream" }; + + return { + buffer, + format + }; + } +}; diff --git a/apps/client-standalone/src/vite-env.d.ts b/apps/client-standalone/src/vite-env.d.ts deleted file mode 100644 index 3e6ffaec19..0000000000 --- a/apps/client-standalone/src/vite-env.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -/// - -interface ImportMetaEnv { - readonly VITE_APP_TITLE: string -} - -interface ImportMeta { - readonly env: ImportMetaEnv -} - -interface Window { - glob: { - assetPath: string; - themeCssUrl?: string; - themeUseNextAsBase?: string; - iconPackCss: string; - device: string; - headingStyle: string; - layoutOrientation: string; - platform: string; - isElectron: boolean; - hasNativeTitleBar: boolean; - hasBackgroundEffects: boolean; - currentLocale: { - id: string; - rtl: boolean; - }; - activeDialog: any; - }; - global: typeof globalThis; -} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 8854218a1a..ac3b1624fc 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -82,6 +82,7 @@ async function startApplication() { getDemoArchive: async () => fs.readFileSync(path.join(RESOURCE_DIR, "db", "demo.zip")), inAppHelp: new NodejsInAppHelpProvider(), backup: new ServerBackupService(), + image: (await import("./services/image_provider.js")).serverImageProvider, extraAppInfo: { nodeVersion: process.version, dataDirectory: path.resolve(dataDirs.TRILIUM_DATA_DIR) diff --git a/apps/server/src/services/image.ts b/apps/server/src/services/image.ts index 743d40acdc..717f88fcab 100644 --- a/apps/server/src/services/image.ts +++ b/apps/server/src/services/image.ts @@ -1,191 +1,12 @@ -import { sanitize } from "@triliumnext/core"; -import imageType from "image-type"; -import isAnimated from "is-animated"; -import isSvg from "is-svg"; -import { Jimp } from "jimp"; -import sanitizeFilename from "sanitize-filename"; +/** + * Server-side image service. + * Re-exports core image service and adds OCR scheduling. + */ -import becca from "../becca/becca.js"; +import { imageService } from "@triliumnext/core"; import log from "./log.js"; -import noteService from "./notes.js"; import ocrService from "./ocr/ocr_service.js"; import optionService from "./options.js"; -import protectedSessionService from "./protected_session.js"; -import sql from "./sql.js"; - -async function processImage(uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean) { - const compressImages = optionService.getOptionBool("compressImages"); - const origImageFormat = await getImageType(uploadBuffer); - - if (!origImageFormat || !["jpg", "png"].includes(origImageFormat.ext)) { - shrinkImageSwitch = false; - } else if (isAnimated(uploadBuffer)) { - // recompression of animated images will make them static - shrinkImageSwitch = false; - } - - let finalImageBuffer; - let imageFormat; - - if (compressImages && shrinkImageSwitch) { - finalImageBuffer = await shrinkImage(uploadBuffer, originalName); - imageFormat = await getImageType(finalImageBuffer); - } else { - finalImageBuffer = uploadBuffer; - imageFormat = origImageFormat || { - ext: "dat" - }; - } - - return { - buffer: finalImageBuffer, - imageFormat - }; -} - -async function getImageType(buffer: Buffer) { - if (isSvg(buffer.toString())) { - return { ext: "svg" }; - } - return (await imageType(buffer)) || { ext: "jpg" }; // optimistic JPG default -} - -function getImageMimeFromExtension(ext: string) { - ext = ext.toLowerCase(); - - return `image/${ext === "svg" ? "svg+xml" : ext}`; -} - -function updateImage(noteId: string, uploadBuffer: Buffer, originalName: string) { - log.info(`Updating image ${noteId}: ${originalName}`); - - originalName = sanitize.sanitizeHtml(originalName); - - const note = becca.getNote(noteId); - if (!note) { - throw new Error("Unable to find note."); - } - - note.saveRevision(); - - note.setLabel("originalFileName", originalName); - - // resizing images asynchronously since JIMP does not support sync operation - processImage(uploadBuffer, originalName, true).then(({ buffer, imageFormat }) => { - sql.transactional(() => { - note.mime = getImageMimeFromExtension(imageFormat.ext); - note.save(); - - note.setContent(buffer); - }); - - scheduleOcrForNote(noteId); - }); -} - -function saveImage(parentNoteId: string, uploadBuffer: Buffer, originalName: string, shrinkImageSwitch: boolean, trimFilename = false) { - log.info(`Saving image ${originalName} into parent ${parentNoteId}`); - - if (trimFilename && originalName.length > 40) { - // https://github.com/zadam/trilium/issues/2307 - originalName = "image"; - } - - const fileName = sanitizeFilename(originalName); - const parentNote = becca.getNote(parentNoteId); - if (!parentNote) { - throw new Error("Unable to find parent note."); - } - - const { note } = noteService.createNewNote({ - parentNoteId, - title: fileName, - type: "image", - mime: "unknown", - content: "", - isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable() - }); - - note.addLabel("originalFileName", originalName); - - // resizing images asynchronously since JIMP does not support sync operation - processImage(uploadBuffer, originalName, shrinkImageSwitch).then(({ buffer, imageFormat }) => { - sql.transactional(() => { - note.mime = getImageMimeFromExtension(imageFormat.ext); - - if (!originalName.includes(".")) { - originalName += `.${imageFormat.ext}`; - - note.setLabel("originalFileName", originalName); - note.title = sanitizeFilename(originalName); - } - - note.setContent(buffer, { forceSave: true }); - }); - - scheduleOcrForNote(note.noteId); - }); - - return { - fileName, - note, - noteId: note.noteId, - url: `api/images/${note.noteId}/${encodeURIComponent(fileName)}` - }; -} - -function saveImageToAttachment(noteId: string, uploadBuffer: Buffer, originalName: string, shrinkImageSwitch?: boolean, trimFilename = false) { - log.info(`Saving image '${originalName}' as attachment into note '${noteId}'`); - - if (trimFilename && originalName.length > 40) { - // https://github.com/zadam/trilium/issues/2307 - originalName = "image"; - } - - const fileName = sanitizeFilename(originalName); - const note = becca.getNoteOrThrow(noteId); - - let attachment = note.saveAttachment({ - role: "image", - mime: "unknown", - title: fileName - }); - - // TODO: this is a quick-fix solution of a recursive bug - this is called from asyncPostProcessContent() - // find some async way to do this - perhaps some global timeout with a Set of noteIds needing one more - // run of asyncPostProcessContent - setTimeout(() => { - sql.transactional(() => { - const note = becca.getNoteOrThrow(noteId); - noteService.asyncPostProcessContent(note, note.getContent()); // to mark an unused attachment for deletion - }); - }, 5000); - - // resizing images asynchronously since JIMP does not support sync operation - const attachmentId = attachment.attachmentId; - processImage(uploadBuffer, originalName, !!shrinkImageSwitch).then(({ buffer, imageFormat }) => { - sql.transactional(() => { - // re-read, might be changed in the meantime - if (!attachmentId) { - throw new Error("Missing attachment ID."); - } - attachment = becca.getAttachmentOrThrow(attachmentId); - - attachment.mime = getImageMimeFromExtension(imageFormat.ext); - - if (!originalName.includes(".")) { - originalName += `.${imageFormat.ext}`; - attachment.title = sanitizeFilename(originalName); - } - - attachment.setContent(buffer, { forceSave: true }); - }); - - scheduleOcrForAttachment(attachmentId); - }); - - return attachment; -} function scheduleOcrForNote(noteId: string) { if (optionService.getOptionBool("ocrAutoProcessImages")) { @@ -211,52 +32,34 @@ function scheduleOcrForAttachment(attachmentId: string | undefined) { } } -async function shrinkImage(buffer: Buffer, originalName: string) { - let jpegQuality = optionService.getOptionInt("imageJpegQuality", 0); - - if (jpegQuality < 10 || jpegQuality > 100) { - jpegQuality = 75; - } - - let finalImageBuffer; - try { - finalImageBuffer = await resize(buffer, jpegQuality); - } catch (e: any) { - log.error(`Failed to resize image '${originalName}', stack: ${e.stack}`); - - finalImageBuffer = buffer; - } - - // if resizing did not help with size, then save the original - // (can happen when e.g., resizing PNG into JPEG) - if (finalImageBuffer.byteLength >= buffer.byteLength) { - finalImageBuffer = buffer; - } - - return finalImageBuffer; +// Re-export core functions with OCR scheduling wrappers +function saveImage( + parentNoteId: string, + uploadBuffer: Uint8Array, + originalName: string, + shrinkImageSwitch: boolean, + trimFilename = false +) { + const result = imageService.saveImage(parentNoteId, uploadBuffer, originalName, shrinkImageSwitch, trimFilename); + scheduleOcrForNote(result.noteId); + return result; } -async function resize(buffer: Buffer, quality: number) { - const imageMaxWidthHeight = optionService.getOptionInt("imageMaxWidthHeight"); +function saveImageToAttachment( + noteId: string, + uploadBuffer: Uint8Array, + originalName: string, + shrinkImageSwitch?: boolean, + trimFilename = false +) { + const result = imageService.saveImageToAttachment(noteId, uploadBuffer, originalName, shrinkImageSwitch, trimFilename); + scheduleOcrForAttachment(result.attachmentId); + return result; +} - const start = Date.now(); - - const image = await Jimp.read(buffer); - - if (image.bitmap.width > image.bitmap.height && image.bitmap.width > imageMaxWidthHeight) { - image.resize({ w: imageMaxWidthHeight }); - } else if (image.bitmap.height > imageMaxWidthHeight) { - image.resize({ h: imageMaxWidthHeight }); - } - - // when converting PNG to JPG, we lose the alpha channel, this is replaced by white to match Trilium white background - image.background = 0xffffffff; - - const resultBuffer = await image.getBuffer("image/jpeg", { quality }); - - log.info(`Resizing image of ${resultBuffer.byteLength} took ${Date.now() - start}ms`); - - return resultBuffer; +function updateImage(noteId: string, uploadBuffer: Uint8Array, originalName: string) { + imageService.updateImage(noteId, uploadBuffer, originalName); + scheduleOcrForNote(noteId); } export default { diff --git a/apps/server/src/services/image_provider.ts b/apps/server/src/services/image_provider.ts new file mode 100644 index 0000000000..58bb1bc1bd --- /dev/null +++ b/apps/server/src/services/image_provider.ts @@ -0,0 +1,114 @@ +/** + * Server-side image provider implementation. + * Uses JIMP for image processing with full compression support. + */ + +import imageType from "image-type"; +import isAnimated from "is-animated"; +import isSvg from "is-svg"; +import { Jimp } from "jimp"; + +import type { ImageProvider, ImageFormat, ProcessedImage } from "@triliumnext/core/src/services/image_provider.js"; +import log from "./log.js"; +import optionService from "./options.js"; + +async function getImageTypeFromBuffer(buffer: Uint8Array): Promise { + // Check for SVG first (text-based) + if (isSvg(Buffer.from(buffer).toString())) { + return { ext: "svg", mime: "image/svg+xml" }; + } + + const detected = await imageType(buffer); + if (detected) { + return { ext: detected.ext, mime: detected.mime }; + } + + return null; +} + +async function shrinkImage(buffer: Uint8Array, originalName: string): Promise { + let jpegQuality = optionService.getOptionInt("imageJpegQuality", 0); + + if (jpegQuality < 10 || jpegQuality > 100) { + jpegQuality = 75; + } + + let finalImageBuffer: Uint8Array; + try { + finalImageBuffer = await resize(buffer, jpegQuality); + } catch (e: unknown) { + const error = e as Error; + log.error(`Failed to resize image '${originalName}', stack: ${error.stack}`); + finalImageBuffer = buffer; + } + + // If resizing did not help with size, then save the original + if (finalImageBuffer.byteLength >= buffer.byteLength) { + finalImageBuffer = buffer; + } + + return finalImageBuffer; +} + +async function resize(buffer: Uint8Array, quality: number): Promise { + const imageMaxWidthHeight = optionService.getOptionInt("imageMaxWidthHeight"); + + const start = Date.now(); + + const image = await Jimp.read(buffer); + + if (image.bitmap.width > image.bitmap.height && image.bitmap.width > imageMaxWidthHeight) { + image.resize({ w: imageMaxWidthHeight }); + } else if (image.bitmap.height > imageMaxWidthHeight) { + image.resize({ h: imageMaxWidthHeight }); + } + + // When converting PNG to JPG, we lose the alpha channel - replace with white + image.background = 0xffffffff; + + const resultBuffer = await image.getBuffer("image/jpeg", { quality }); + + log.info(`Resizing image of ${resultBuffer.byteLength} took ${Date.now() - start}ms`); + + return resultBuffer; +} + +export const serverImageProvider: ImageProvider = { + getImageType(buffer: Uint8Array): ImageFormat | null { + // Synchronous check for SVG + if (isSvg(Buffer.from(buffer).toString())) { + return { ext: "svg", mime: "image/svg+xml" }; + } + + // For other formats, we need async detection but interface is sync + // Return null and let processImage handle the async detection + return null; + }, + + async processImage(buffer: Uint8Array, originalName: string, shrink: boolean): Promise { + const compressImages = optionService.getOptionBool("compressImages"); + const origImageFormat = await getImageTypeFromBuffer(buffer); + + let shouldShrink = shrink; + + if (!origImageFormat || !["jpg", "png"].includes(origImageFormat.ext)) { + shouldShrink = false; + } else if (isAnimated(Buffer.from(buffer))) { + // Recompression of animated images will make them static + shouldShrink = false; + } + + let finalBuffer: Uint8Array; + let format: ImageFormat; + + if (compressImages && shouldShrink) { + finalBuffer = await shrinkImage(buffer, originalName); + format = (await getImageTypeFromBuffer(finalBuffer)) || { ext: "jpg", mime: "image/jpeg" }; + } else { + finalBuffer = buffer; + format = origImageFormat || { ext: "dat", mime: "application/octet-stream" }; + } + + return { buffer: finalBuffer, format }; + } +}; diff --git a/packages/trilium-core/src/index.ts b/packages/trilium-core/src/index.ts index 7753281615..dfb4bf4bf3 100644 --- a/packages/trilium-core/src/index.ts +++ b/packages/trilium-core/src/index.ts @@ -13,6 +13,7 @@ import { type PlatformProvider, initPlatform } from "./services/platform"; import { type ZipProvider, initZipProvider } from "./services/zip_provider"; import { type ZipExportProviderFactory, initZipExportProviderFactory } from "./services/export/zip_export_provider_factory"; import { type InAppHelpProvider, initInAppHelp } from "./services/in_app_help"; +import { type ImageProvider, initImageProvider } from "./services/image_provider"; export { default as LogService, getLog } from "./services/log"; export { default as FileBasedLogService, type LogFileInfo } from "./services/file_based_log"; @@ -101,6 +102,8 @@ export { default as sync_mutex } from "./services/sync_mutex"; export { default as setup } from "./services/setup"; export { getPlatform, type PlatformProvider } from "./services/platform"; export type { InAppHelpProvider } from "./services/in_app_help"; +export { type ImageProvider, type ImageFormat, type ProcessedImage, getImageProvider } from "./services/image_provider"; +export { default as imageService } from "./services/image"; export { t } from "i18next"; export type { RequestProvider, ExecOpts, CookieJar } from "./services/request"; export type * from "./meta"; @@ -124,7 +127,7 @@ export { default as scriptService } from "./services/script"; export { default as BackendScriptApi, type Api as BackendScriptApiInterface } from "./services/backend_script_api"; export * as scheduler from "./services/scheduler"; -export async function initializeCore({ dbConfig, executionContext, crypto, zip, zipExportProviderFactory, translations, messaging, request, schema, extraAppInfo, platform, getDemoArchive, inAppHelp, log, backup }: { +export async function initializeCore({ dbConfig, executionContext, crypto, zip, zipExportProviderFactory, translations, messaging, request, schema, extraAppInfo, platform, getDemoArchive, inAppHelp, log, backup, image }: { dbConfig: SqlServiceParams, executionContext: ExecutionContext, crypto: CryptoProvider, @@ -143,6 +146,7 @@ export async function initializeCore({ dbConfig, executionContext, crypto, zip, }; log?: LogService; backup: BackupService; + image: ImageProvider; }) { initPlatform(platform); initLog(log); @@ -154,6 +158,7 @@ export async function initializeCore({ dbConfig, executionContext, crypto, zip, initContext(executionContext); initSql(new SqlService(dbConfig, getLog())); initSchema(schema); + initImageProvider(image); if (getDemoArchive) { initDemoArchive(getDemoArchive); } diff --git a/packages/trilium-core/src/services/image.ts b/packages/trilium-core/src/services/image.ts index 7ccd1ff5ad..98ff4f6170 100644 --- a/packages/trilium-core/src/services/image.ts +++ b/packages/trilium-core/src/services/image.ts @@ -1,22 +1,154 @@ -export default { - saveImageToAttachment(noteId: string, imageBuffer: Uint8Array, title: string, b1?: boolean, b2?: boolean) { - console.warn("Image save ignored", noteId, title); +/** + * Image service for saving and updating images. + * Uses ImageProvider for platform-specific processing (compression, format detection). + */ - return { - attachmentId: null, - title: "" - }; - }, +import sanitizeFilename from "sanitize-filename"; - updateImage(noteId: string, imageBuffer: Uint8Array, title: string) { - console.warn("Image update ignored", noteId, title); - }, +import becca from "../becca/becca.js"; +import { getLog } from "./log.js"; +import { getImageProvider } from "./image_provider.js"; +import noteService from "./notes.js"; +import protectedSessionService from "./protected_session.js"; +import { getSql } from "./sql/index.js"; +import { sanitizeHtml } from "./sanitizer.js"; - saveImage(noteId: string, imageBuffer: Uint8Array, title: string, b1?: boolean, b2?: boolean) { - console.warn("Image save ignored", noteId, title); - - return { - note: null - }; - } +function getImageMimeFromExtension(ext: string): string { + ext = ext.toLowerCase(); + return `image/${ext === "svg" ? "svg+xml" : ext}`; } + +function updateImage(noteId: string, uploadBuffer: Uint8Array, originalName: string): void { + getLog().info(`Updating image ${noteId}: ${originalName}`); + + originalName = sanitizeHtml(originalName); + + const note = becca.getNote(noteId); + if (!note) { + throw new Error("Unable to find note."); + } + + note.saveRevision(); + note.setLabel("originalFileName", originalName); + + // Process image asynchronously + getImageProvider().processImage(uploadBuffer, originalName, true).then(({ buffer, format }) => { + getSql().transactional(() => { + note.mime = getImageMimeFromExtension(format.ext); + note.save(); + note.setContent(buffer); + }); + }); +} + +function saveImage( + parentNoteId: string, + uploadBuffer: Uint8Array, + originalName: string, + shrinkImageSwitch: boolean, + trimFilename = false +): { fileName: string; note: ReturnType["note"]; noteId: string; url: string } { + getLog().info(`Saving image ${originalName} into parent ${parentNoteId}`); + + if (trimFilename && originalName.length > 40) { + originalName = "image"; + } + + const fileName = sanitizeFilename(originalName); + const parentNote = becca.getNote(parentNoteId); + if (!parentNote) { + throw new Error("Unable to find parent note."); + } + + const { note } = noteService.createNewNote({ + parentNoteId, + title: fileName, + type: "image", + mime: "unknown", + content: "", + isProtected: parentNote.isProtected && protectedSessionService.isProtectedSessionAvailable() + }); + + note.addLabel("originalFileName", originalName); + + // Process image asynchronously + getImageProvider().processImage(uploadBuffer, originalName, shrinkImageSwitch).then(({ buffer, format }) => { + getSql().transactional(() => { + note.mime = getImageMimeFromExtension(format.ext); + + if (!originalName.includes(".")) { + originalName += `.${format.ext}`; + note.setLabel("originalFileName", originalName); + note.title = sanitizeFilename(originalName); + } + + note.setContent(buffer, { forceSave: true }); + }); + }); + + return { + fileName, + note, + noteId: note.noteId, + url: `api/images/${note.noteId}/${encodeURIComponent(fileName)}` + }; +} + +function saveImageToAttachment( + noteId: string, + uploadBuffer: Uint8Array, + originalName: string, + shrinkImageSwitch?: boolean, + trimFilename = false +): { attachmentId: string | undefined; title: string } { + getLog().info(`Saving image '${originalName}' as attachment into note '${noteId}'`); + + if (trimFilename && originalName.length > 40) { + originalName = "image"; + } + + const fileName = sanitizeFilename(originalName); + const note = becca.getNoteOrThrow(noteId); + + let attachment = note.saveAttachment({ + role: "image", + mime: "unknown", + title: fileName + }); + + // Schedule post-processing to mark unused attachments + setTimeout(() => { + getSql().transactional(() => { + const note = becca.getNoteOrThrow(noteId); + noteService.asyncPostProcessContent(note, note.getContent()); + }); + }, 5000); + + // Process image asynchronously + const attachmentId = attachment.attachmentId; + getImageProvider().processImage(uploadBuffer, originalName, !!shrinkImageSwitch).then(({ buffer, format }) => { + getSql().transactional(() => { + if (!attachmentId) { + throw new Error("Missing attachment ID."); + } + attachment = becca.getAttachmentOrThrow(attachmentId); + + attachment.mime = getImageMimeFromExtension(format.ext); + + if (!originalName.includes(".")) { + originalName += `.${format.ext}`; + attachment.title = sanitizeFilename(originalName); + } + + attachment.setContent(buffer, { forceSave: true }); + }); + }); + + return attachment; +} + +export default { + saveImage, + saveImageToAttachment, + updateImage +}; diff --git a/packages/trilium-core/src/services/image_provider.ts b/packages/trilium-core/src/services/image_provider.ts new file mode 100644 index 0000000000..10649c7615 --- /dev/null +++ b/packages/trilium-core/src/services/image_provider.ts @@ -0,0 +1,44 @@ +/** + * Interface for platform-specific image processing. + * Server uses JIMP with full compression support. + * Standalone uses simple format detection without compression. + */ + +export interface ImageFormat { + ext: string; + mime: string; +} + +export interface ProcessedImage { + buffer: Uint8Array; + format: ImageFormat; +} + +export interface ImageProvider { + /** + * Detect image format from buffer. + */ + getImageType(buffer: Uint8Array): ImageFormat | null; + + /** + * Process image - may resize/compress depending on implementation. + * @param buffer - Raw image data + * @param originalName - Original filename for logging + * @param shrink - Whether to attempt shrinking the image + * @returns Processed image buffer and detected format + */ + processImage(buffer: Uint8Array, originalName: string, shrink: boolean): Promise; +} + +let imageProvider: ImageProvider | null = null; + +export function initImageProvider(provider: ImageProvider) { + imageProvider = provider; +} + +export function getImageProvider(): ImageProvider { + if (!imageProvider) { + throw new Error("Image provider not initialized"); + } + return imageProvider; +}