From fa8872226f9e6d7f13f950cba168f0d979117081 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Mon, 4 May 2026 14:23:33 +0400 Subject: [PATCH 01/22] fix: scan child table rows for attachment cleanup on delete Vue reactive() wraps child Doc rows, so row instanceof Doc was false and filesystem refs in table rows were skipped. Unwrap with toRaw before recursing in snapshot and delete cleanup. --- fyo/model/doc.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 57df2cd58..213da68b5 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -14,7 +14,7 @@ import { TargetField, } from 'schemas/types'; import { getIsNullOrUndef, getMapFromList, getRandomString } from 'utils'; -import { markRaw, reactive } from 'vue'; +import { markRaw, reactive, toRaw } from 'vue'; import { isPesa } from '../utils/index'; import { getDbSyncError } from './errorHelpers'; import { @@ -1072,8 +1072,9 @@ export class Doc extends Observable { if (field.fieldtype === FieldTypeEnum.Table) { if (Array.isArray(value)) { for (const row of value) { - if (row instanceof Doc) { - scan(row); + const child = toRaw(row) as Doc; + if (child instanceof Doc) { + scan(child); } } } @@ -1295,8 +1296,9 @@ export class Doc extends Observable { if (fieldtype === FieldTypeEnum.Table) { if (Array.isArray(value)) { for (const row of value) { - if (row instanceof Doc) { - scan(row); + const child = toRaw(row) as Doc; + if (child instanceof Doc) { + scan(child); } } } From bc90af27f58058a51f6e9f88768201c7c6f82d01 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Mon, 4 May 2026 16:28:19 +0400 Subject: [PATCH 02/22] fix: cleanup filesystem attachments on doc delete Handle Attachment values stored as JSON strings after load(), and avoid using a private cleanup method so deletion works on Vue reactive proxies. --- fyo/model/doc.ts | 57 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 213da68b5..2e6fbe56b 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -85,6 +85,35 @@ function dataUrlFromBytes(type: string, bytes: Uint8Array) { return `data:${type || 'application/octet-stream'};base64,${base64}`; } +/** + * After `load()`, Attachment columns are often still JSON strings (see + * `_setValuesWithoutChecks(..., false)`). After `set()` they are `{ path }` objects. + */ +function getFilesystemPathFromAttachmentValue(value: unknown): string | null { + if (value == null) { + return null; + } + if (typeof value === 'object' && !Array.isArray(value)) { + const path = (value as { path?: string }).path; + return typeof path === 'string' && path.length > 0 ? path : null; + } + if (typeof value === 'string') { + const s = value.trim(); + if (!s) { + return null; + } + try { + const parsed = JSON.parse(s) as { path?: string }; + const path = parsed?.path; + return typeof path === 'string' && path.length > 0 ? path : null; + } catch { + // not JSON + return null; + } + } + return null; +} + export class Doc extends Observable { /* eslint-disable @typescript-eslint/no-floating-promises */ name?: string; @@ -1050,9 +1079,9 @@ export class Doc extends Observable { const value = doc.get(field.fieldname) as unknown; if (field.fieldtype === FieldTypeEnum.Attachment) { - const v = value as undefined | null | { path?: string }; - if (v?.path && typeof v.path === 'string') { - refs.add(v.path); + const p = getFilesystemPathFromAttachmentValue(value); + if (p) { + refs.add(p); } continue; } @@ -1082,7 +1111,7 @@ export class Doc extends Observable { } }; - scan(this); + scan(toRaw(this) as Doc); return refs; } @@ -1244,7 +1273,7 @@ export class Doc extends Observable { await this.trigger('beforeDelete'); // Best-effort cleanup for filesystem-backed attachments/images. try { - await this.#cleanupFileBackedFieldsBeforeDelete(); + await this._cleanupFileBackedFieldsBeforeDelete(); } catch {} await this.fyo.db.delete(this.schemaName, this.name!); await this.trigger('afterDelete'); @@ -1253,9 +1282,7 @@ export class Doc extends Observable { this.fyo.doc.observer.trigger(`delete:${this.schemaName}`, this.name); } - static #attachImageFileRefPrefix = 'books-file:'; - - async #cleanupFileBackedFieldsBeforeDelete() { + async _cleanupFileBackedFieldsBeforeDelete() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const ipcApi = (globalThis as any)?.ipc; const dbPath = (this.fyo.db as any)?.dbPath as string | undefined; @@ -1274,9 +1301,9 @@ export class Doc extends Observable { const value = doc.get(fieldname) as unknown; if (fieldtype === FieldTypeEnum.Attachment) { - const v = value as undefined | null | { path?: string }; - if (v?.path && typeof v.path === 'string') { - paths.add(v.path); + const p = getFilesystemPathFromAttachmentValue(value); + if (p) { + paths.add(p); } continue; } @@ -1285,10 +1312,10 @@ export class Doc extends Observable { const v = value as string | null | undefined; if ( typeof v === 'string' && - v.startsWith(Doc.#attachImageFileRefPrefix) && - v.length > Doc.#attachImageFileRefPrefix.length + v.startsWith(ATTACH_IMAGE_FILE_REF_PREFIX) && + v.length > ATTACH_IMAGE_FILE_REF_PREFIX.length ) { - paths.add(v.slice(Doc.#attachImageFileRefPrefix.length)); + paths.add(v.slice(ATTACH_IMAGE_FILE_REF_PREFIX.length)); } continue; } @@ -1306,7 +1333,7 @@ export class Doc extends Observable { } }; - scan(this); + scan(toRaw(this) as Doc); await Promise.all( Array.from(paths).map(async (p) => { From c993622c78d8ee1cf456a7fbf1a7e764deea884d Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Mon, 4 May 2026 16:30:33 +0400 Subject: [PATCH 03/22] chore: bump version to 0.47.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6b295d302..6eaf362e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rukn-books", - "version": "0.47.0", + "version": "0.47.1", "description": "Simple book-keeping app for everyone", "author": { "name": "Rukn Software", From bc9c0bdaf4b5bee648e4ad7cae49fb151635d88b Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Mon, 4 May 2026 19:32:08 +0400 Subject: [PATCH 04/22] refactor: delegate filesystem attachments to AttachmentManager Move attachment normalization, ref scanning, and IPC cleanup out of Doc into a dedicated class while preserving behavior. --- fyo/model/AttachmentManager.ts | 273 ++++++++++++++++++++++++++ fyo/model/attachmentEncoding.ts | 29 +++ fyo/model/doc.ts | 329 +------------------------------- 3 files changed, 311 insertions(+), 320 deletions(-) create mode 100644 fyo/model/AttachmentManager.ts create mode 100644 fyo/model/attachmentEncoding.ts diff --git a/fyo/model/AttachmentManager.ts b/fyo/model/AttachmentManager.ts new file mode 100644 index 000000000..0183ed331 --- /dev/null +++ b/fyo/model/AttachmentManager.ts @@ -0,0 +1,273 @@ +import type { Field } from 'schemas/types'; +import { FieldTypeEnum } from 'schemas/types'; +import { toRaw } from 'vue'; +import { dataUrlFromBytes } from './attachmentEncoding'; + +type AttachmentStorageMode = 'database' | 'filesystem'; + +type DocLike = { + schema: { fields: Array<{ fieldname: string; fieldtype: string; meta?: boolean }> }; + fyo: { + isElectron: boolean; + singles: { SystemSettings?: unknown }; + db: unknown; + }; + get(fieldname: string): unknown; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +}; + +function getStorageMode(doc: DocLike): AttachmentStorageMode { + return ( + ((doc.fyo.singles.SystemSettings as any)?.attachmentStorage as + | AttachmentStorageMode + | undefined) ?? 'database' + ); +} + +/** + * After `load()`, Attachment columns are often still JSON strings (see + * `_setValuesWithoutChecks(..., false)`). After `set()` they are `{ path }` objects. + */ +function getFilesystemPathFromAttachmentValue(value: unknown): string | null { + if (value == null) return null; + if (typeof value === 'object' && !Array.isArray(value)) { + const path = (value as { path?: string }).path; + return typeof path === 'string' && path.length > 0 ? path : null; + } + if (typeof value === 'string') { + const s = value.trim(); + if (!s) return null; + try { + const parsed = JSON.parse(s) as { path?: string }; + const path = parsed?.path; + return typeof path === 'string' && path.length > 0 ? path : null; + } catch { + return null; + } + } + return null; +} + +export class AttachmentManager { + readonly #doc: DocLike; + readonly #attachImagePrefix: string; + + // Snapshot at last successful load/sync. + #fsSnapshot: Set = new Set(); + // Paths to delete after a successful sync. + #pendingDeletes: Set = new Set(); + + constructor(doc: DocLike, attachImagePrefix: string) { + this.#doc = doc; + this.#attachImagePrefix = attachImagePrefix; + } + + snapshotAfterLoadOrSync() { + this.#fsSnapshot = this.collectFilesystemRefs(); + } + + prepareRemovedOnPreSync() { + const current = this.collectFilesystemRefs(); + const removed = new Set(this.#pendingDeletes); + for (const oldRef of this.#fsSnapshot) { + if (!current.has(oldRef)) { + removed.add(oldRef); + } + } + this.#pendingDeletes = removed; + } + + async flushPendingDeletesAfterSync() { + if (!this.#pendingDeletes.size) return; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = (globalThis as any)?.ipc; + const dbPath = (this.#doc.fyo.db as any)?.dbPath as string | undefined; + if (!this.#doc.fyo.isElectron || !dbPath) { + this.#pendingDeletes.clear(); + return; + } + if (!ipcApi?.desktop || typeof ipcApi.attachments?.delete !== 'function') { + this.#pendingDeletes.clear(); + return; + } + + const paths = Array.from(this.#pendingDeletes); + this.#pendingDeletes.clear(); + await Promise.all( + paths.map(async (p) => { + try { + await ipcApi.attachments.delete({ dbPath, path: p }); + } catch { + // best-effort + } + }) + ); + } + + async cleanupBeforeDelete() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = (globalThis as any)?.ipc; + const dbPath = (this.#doc.fyo.db as any)?.dbPath as string | undefined; + if (!this.#doc.fyo.isElectron || !dbPath) return; + if (!ipcApi?.desktop || typeof ipcApi.attachments?.delete !== 'function') { + return; + } + + const paths = this.collectFilesystemRefs(); + await Promise.all( + Array.from(paths).map(async (p) => { + try { + await ipcApi.attachments.delete({ dbPath, path: p }); + } catch { + // best-effort + } + }) + ); + } + + async normalizeBeforeSet(field: Field, value: unknown): Promise { + const storage = getStorageMode(this.#doc); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = (globalThis as any)?.ipc; + const dbPath = (this.#doc.fyo.db as any)?.dbPath as string | undefined; + const canUseFs = + storage === 'filesystem' && + this.#doc.fyo.isElectron && + !!dbPath && + ipcApi?.desktop && + typeof ipcApi.attachments?.save === 'function' && + typeof ipcApi.attachments?.delete === 'function'; + + if (field.fieldtype === FieldTypeEnum.Attachment) { + const v = value as + | null + | undefined + | { + name?: string; + type?: string; + data?: string; + path?: string; + bytes?: Uint8Array; + }; + if (!v) return value; + + const prev = this.#doc.get(field.fieldname) as any; + const prevPath = typeof prev?.path === 'string' ? prev.path : null; + + if (v.bytes instanceof Uint8Array && v.name && v.type) { + if (canUseFs) { + const res = (await ipcApi.attachments.save({ + dbPath, + name: v.name, + type: v.type, + data: v.bytes, + })) as { success?: boolean; attachment?: { path?: string } }; + + const newPath = res?.success ? res?.attachment?.path : undefined; + if (newPath) { + if (prevPath) { + // Defer deletion until after a successful sync(). + this.#pendingDeletes.add(prevPath); + } + return { name: v.name, type: v.type, path: newPath }; + } + } + + // DB fallback (or if filesystem save fails): embed into DB. + return { name: v.name, type: v.type, data: dataUrlFromBytes(v.type, v.bytes) }; + } + + return value; + } + + if (field.fieldtype === FieldTypeEnum.AttachImage) { + if (typeof value === 'string' || value === null) { + return value; + } + + const v = value as { name?: string; type?: string; data?: Uint8Array }; + if (!(v?.data instanceof Uint8Array) || !v.type) { + return value; + } + + const prev = this.#doc.get(field.fieldname) as any; + const prevRef = + typeof prev === 'string' && prev.startsWith(this.#attachImagePrefix) + ? prev.slice(this.#attachImagePrefix.length) + : null; + + if (canUseFs) { + const res = (await ipcApi.attachments.save({ + dbPath, + name: v.name || 'image', + type: v.type, + data: v.data, + })) as { success?: boolean; attachment?: { path?: string } }; + + const newPath = res?.success ? res?.attachment?.path : undefined; + if (newPath) { + if (prevRef) { + // Defer deletion until after a successful sync(). + this.#pendingDeletes.add(prevRef); + } + return `${this.#attachImagePrefix}${newPath}`; + } + } + + return dataUrlFromBytes(v.type, v.data); + } + + return value; + } + + collectFilesystemRefs(): Set { + const refs = new Set(); + + const isDocLike = (v: unknown): v is DocLike => { + if (!v || typeof v !== 'object') return false; + const anyV = v as any; + return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; + }; + + const scan = (d: DocLike) => { + for (const field of d.schema.fields) { + if (field.meta) continue; + const value = d.get(field.fieldname) as unknown; + + if (field.fieldtype === FieldTypeEnum.Attachment) { + const p = getFilesystemPathFromAttachmentValue(value); + if (p) refs.add(p); + continue; + } + + if (field.fieldtype === FieldTypeEnum.AttachImage) { + const v = value as string | null | undefined; + if ( + typeof v === 'string' && + v.startsWith(this.#attachImagePrefix) && + v.length > this.#attachImagePrefix.length + ) { + refs.add(v.slice(this.#attachImagePrefix.length)); + } + continue; + } + + if (field.fieldtype === FieldTypeEnum.Table && Array.isArray(value)) { + for (const row of value) { + const child = toRaw(row) as DocLike; + if (isDocLike(child)) { + scan(child); + } + } + } + } + }; + + scan(toRaw(this.#doc) as DocLike); + return refs; + } +} + diff --git a/fyo/model/attachmentEncoding.ts b/fyo/model/attachmentEncoding.ts new file mode 100644 index 000000000..0f8687800 --- /dev/null +++ b/fyo/model/attachmentEncoding.ts @@ -0,0 +1,29 @@ +function uint8ArrayToBase64(bytes: Uint8Array) { + try { + // Browser/Electron renderer + // eslint-disable-next-line no-undef + if (typeof btoa === 'function') { + let binary = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + // eslint-disable-next-line no-undef + return btoa(binary); + } + } catch {} + + // Fallback (Node-like) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const B = (globalThis as any)?.Buffer; + if (B) { + return B.from(bytes).toString('base64'); + } + return ''; +} + +export function dataUrlFromBytes(type: string, bytes: Uint8Array) { + const base64 = uint8ArrayToBase64(bytes); + return `data:${type || 'application/octet-stream'};base64,${base64}`; +} + diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 2e6fbe56b..40eea7373 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -14,9 +14,10 @@ import { TargetField, } from 'schemas/types'; import { getIsNullOrUndef, getMapFromList, getRandomString } from 'utils'; -import { markRaw, reactive, toRaw } from 'vue'; +import { markRaw, reactive } from 'vue'; import { isPesa } from '../utils/index'; import { getDbSyncError } from './errorHelpers'; +import { AttachmentManager } from './AttachmentManager'; import { areDocValuesEqual, getFormulaSequence, @@ -56,64 +57,6 @@ const DUPLICATE_STRIP_FIELDS = ['isSyncedWithErp', 'datafromErp'] as const; const ATTACH_IMAGE_FILE_REF_PREFIX = 'books-file:'; -function uint8ArrayToBase64(bytes: Uint8Array) { - try { - // Browser/Electron renderer - // eslint-disable-next-line no-undef - if (typeof btoa === 'function') { - let binary = ''; - const chunkSize = 0x8000; - for (let i = 0; i < bytes.length; i += chunkSize) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); - } - // eslint-disable-next-line no-undef - return btoa(binary); - } - } catch {} - - // Fallback (Node-like) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const B = (globalThis as any)?.Buffer; - if (B) { - return B.from(bytes).toString('base64'); - } - return ''; -} - -function dataUrlFromBytes(type: string, bytes: Uint8Array) { - const base64 = uint8ArrayToBase64(bytes); - return `data:${type || 'application/octet-stream'};base64,${base64}`; -} - -/** - * After `load()`, Attachment columns are often still JSON strings (see - * `_setValuesWithoutChecks(..., false)`). After `set()` they are `{ path }` objects. - */ -function getFilesystemPathFromAttachmentValue(value: unknown): string | null { - if (value == null) { - return null; - } - if (typeof value === 'object' && !Array.isArray(value)) { - const path = (value as { path?: string }).path; - return typeof path === 'string' && path.length > 0 ? path : null; - } - if (typeof value === 'string') { - const s = value.trim(); - if (!s) { - return null; - } - try { - const parsed = JSON.parse(s) as { path?: string }; - const path = parsed?.path; - return typeof path === 'string' && path.length > 0 ? path : null; - } catch { - // not JSON - return null; - } - } - return null; -} - export class Doc extends Observable { /* eslint-disable @typescript-eslint/no-floating-promises */ name?: string; @@ -137,13 +80,7 @@ export class Doc extends Observable { _syncing = false; _addDocToSyncQueue = true; - /** - * Snapshot of filesystem-backed file references as of last load/sync. - * Used to delete files only when changes are committed (on sync), - * not when the user clears a field but doesn't save. - */ - _fsFileRefSnapshot: Set = new Set(); - _pendingFsFileDeletes: Set = new Set(); + attachments: AttachmentManager; constructor( schema: Schema, @@ -161,6 +98,7 @@ export class Doc extends Observable { } this._setDefaults(); + this.attachments = markRaw(new AttachmentManager(this, ATTACH_IMAGE_FILE_REF_PREFIX)); this._setValuesWithoutChecks(data, convertToDocValue); return reactive(this) as Doc; } @@ -408,7 +346,7 @@ export class Doc extends Observable { } else { const field = this.fieldMap[fieldname]; await this._validateField(field, value); - value = (await this._normalizeFileFieldValueBeforeSet( + value = (await this.attachments.normalizeBeforeSet( field, value )) as DocValue; @@ -426,106 +364,6 @@ export class Doc extends Observable { return true; } - async _normalizeFileFieldValueBeforeSet(field: Field, value: unknown) { - const storage = - ((this.fyo.singles.SystemSettings as any)?.attachmentStorage as - | 'database' - | 'filesystem' - | undefined) ?? 'database'; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ipcApi = (globalThis as any)?.ipc; - const dbPath = (this.fyo.db as any)?.dbPath as string | undefined; - const canUseFs = - storage === 'filesystem' && - this.fyo.isElectron && - !!dbPath && - ipcApi?.desktop && - typeof ipcApi.attachments?.save === 'function' && - typeof ipcApi.attachments?.delete === 'function'; - - if (field.fieldtype === FieldTypeEnum.Attachment) { - const v = value as - | null - | undefined - | { - name?: string; - type?: string; - data?: string; - path?: string; - bytes?: Uint8Array; - }; - if (!v) return value; - - const prev = this.get(field.fieldname) as any; - const prevPath = typeof prev?.path === 'string' ? prev.path : null; - - if (v.bytes instanceof Uint8Array && v.name && v.type) { - if (canUseFs) { - const res = (await ipcApi.attachments.save({ - dbPath, - name: v.name, - type: v.type, - data: v.bytes, - })) as { success?: boolean; attachment?: { path?: string } }; - - const newPath = res?.success ? res?.attachment?.path : undefined; - if (newPath) { - if (prevPath) { - // Defer deletion until after a successful sync(). - this._pendingFsFileDeletes.add(prevPath); - } - return { name: v.name, type: v.type, path: newPath }; - } - } - - // DB fallback (or if filesystem save fails): embed into DB. - return { name: v.name, type: v.type, data: dataUrlFromBytes(v.type, v.bytes) }; - } - - return value; - } - - if (field.fieldtype === FieldTypeEnum.AttachImage) { - if (typeof value === 'string' || value === null) { - return value; - } - - const v = value as { name?: string; type?: string; data?: Uint8Array }; - if (!(v?.data instanceof Uint8Array) || !v.type) { - return value; - } - - const prev = this.get(field.fieldname) as any; - const prevRef = - typeof prev === 'string' && prev.startsWith(ATTACH_IMAGE_FILE_REF_PREFIX) - ? prev.slice(ATTACH_IMAGE_FILE_REF_PREFIX.length) - : null; - - if (canUseFs) { - const res = (await ipcApi.attachments.save({ - dbPath, - name: v.name || 'image', - type: v.type, - data: v.data, - })) as { success?: boolean; attachment?: { path?: string } }; - - const newPath = res?.success ? res?.attachment?.path : undefined; - if (newPath) { - if (prevRef) { - // Defer deletion until after a successful sync(). - this._pendingFsFileDeletes.add(prevRef); - } - return `${ATTACH_IMAGE_FILE_REF_PREFIX}${newPath}`; - } - } - - return dataUrlFromBytes(v.type, v.data); - } - - return value; - } - async setMultiple(docValueMap: DocValueMap): Promise { let hasSet = false; for (const fieldname in docValueMap) { @@ -891,7 +729,7 @@ export class Doc extends Observable { this._setValuesWithoutChecks(data, false); await this._setComputedValuesFromFormulas(); this._dirty = false; - this._fsFileRefSnapshot = this._collectFilesystemFileRefs(); + this.attachments.snapshotAfterLoadOrSync(); this.trigger('change', { doc: this, }); @@ -1068,92 +906,7 @@ export class Doc extends Observable { // Prepare commit-time cleanup: identify filesystem-backed files that were // removed/replaced since the last successful load/sync. - this._prepareRemovedFilesystemFilesOnSync(); - } - - _collectFilesystemFileRefs(): Set { - const refs = new Set(); - const scan = (doc: Doc) => { - for (const field of doc.schema.fields) { - if (field.meta) continue; - const value = doc.get(field.fieldname) as unknown; - - if (field.fieldtype === FieldTypeEnum.Attachment) { - const p = getFilesystemPathFromAttachmentValue(value); - if (p) { - refs.add(p); - } - continue; - } - - if (field.fieldtype === FieldTypeEnum.AttachImage) { - const v = value as string | null | undefined; - if ( - typeof v === 'string' && - v.startsWith(ATTACH_IMAGE_FILE_REF_PREFIX) && - v.length > ATTACH_IMAGE_FILE_REF_PREFIX.length - ) { - refs.add(v.slice(ATTACH_IMAGE_FILE_REF_PREFIX.length)); - } - continue; - } - - if (field.fieldtype === FieldTypeEnum.Table) { - if (Array.isArray(value)) { - for (const row of value) { - const child = toRaw(row) as Doc; - if (child instanceof Doc) { - scan(child); - } - } - } - } - } - }; - - scan(toRaw(this) as Doc); - return refs; - } - - _prepareRemovedFilesystemFilesOnSync() { - const current = this._collectFilesystemFileRefs(); - const removed = new Set(this._pendingFsFileDeletes); - for (const oldRef of this._fsFileRefSnapshot) { - if (!current.has(oldRef)) { - removed.add(oldRef); - } - } - this._pendingFsFileDeletes = removed; - } - - async _flushPendingFilesystemDeletesAfterSync() { - if (!this._pendingFsFileDeletes.size) { - return; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ipcApi = (globalThis as any)?.ipc; - const dbPath = (this.fyo.db as any)?.dbPath as string | undefined; - if (!this.fyo.isElectron || !dbPath) { - this._pendingFsFileDeletes.clear(); - return; - } - if (!ipcApi?.desktop || typeof ipcApi.attachments?.delete !== 'function') { - this._pendingFsFileDeletes.clear(); - return; - } - - const paths = Array.from(this._pendingFsFileDeletes); - this._pendingFsFileDeletes.clear(); - await Promise.all( - paths.map(async (path) => { - try { - await ipcApi.attachments.delete({ dbPath, path }); - } catch { - // best-effort - } - }) - ); + this.attachments.prepareRemovedOnPreSync(); } async _insert() { @@ -1218,7 +971,7 @@ export class Doc extends Observable { } else { doc = await this._update(); } - await this._flushPendingFilesystemDeletesAfterSync(); + await this.attachments.flushPendingDeletesAfterSync(); this._notInserted = false; await this.trigger('afterSync'); this.fyo.doc.observer.trigger(`sync:${this.schemaName}`, this.name); @@ -1273,7 +1026,7 @@ export class Doc extends Observable { await this.trigger('beforeDelete'); // Best-effort cleanup for filesystem-backed attachments/images. try { - await this._cleanupFileBackedFieldsBeforeDelete(); + await this.attachments.cleanupBeforeDelete(); } catch {} await this.fyo.db.delete(this.schemaName, this.name!); await this.trigger('afterDelete'); @@ -1282,70 +1035,6 @@ export class Doc extends Observable { this.fyo.doc.observer.trigger(`delete:${this.schemaName}`, this.name); } - async _cleanupFileBackedFieldsBeforeDelete() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ipcApi = (globalThis as any)?.ipc; - const dbPath = (this.fyo.db as any)?.dbPath as string | undefined; - if (!this.fyo.isElectron || !dbPath) { - return; - } - if (!ipcApi?.desktop || typeof ipcApi.attachments?.delete !== 'function') { - return; - } - - const paths = new Set(); - const scan = (doc: Doc) => { - for (const field of doc.schema.fields) { - if (field.meta) continue; - const { fieldname, fieldtype } = field; - const value = doc.get(fieldname) as unknown; - - if (fieldtype === FieldTypeEnum.Attachment) { - const p = getFilesystemPathFromAttachmentValue(value); - if (p) { - paths.add(p); - } - continue; - } - - if (fieldtype === FieldTypeEnum.AttachImage) { - const v = value as string | null | undefined; - if ( - typeof v === 'string' && - v.startsWith(ATTACH_IMAGE_FILE_REF_PREFIX) && - v.length > ATTACH_IMAGE_FILE_REF_PREFIX.length - ) { - paths.add(v.slice(ATTACH_IMAGE_FILE_REF_PREFIX.length)); - } - continue; - } - - if (fieldtype === FieldTypeEnum.Table) { - if (Array.isArray(value)) { - for (const row of value) { - const child = toRaw(row) as Doc; - if (child instanceof Doc) { - scan(child); - } - } - } - } - } - }; - - scan(toRaw(this) as Doc); - - await Promise.all( - Array.from(paths).map(async (p) => { - try { - await ipcApi.attachments.delete({ dbPath, path: p }); - } catch { - // best-effort - } - }) - ); - } - async submit() { if (!this.schema.isSubmittable || this.submitted || this.cancelled) { return; From 8ab7f2c1b103c6ccce0ffd0b3fc700590caf5dc6 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Mon, 4 May 2026 19:47:22 +0400 Subject: [PATCH 05/22] feat: stage attachment files in temp until document sync Add IPC stage save/commit/delete under the OS temp directory, use books-staged tokens in the model until save, commit to the DB attachments folder before insert/update, and discard staged files on reload. Update attachment/image resolvers to read staged paths for preview. --- fyo/model/AttachmentManager.ts | 336 +++++++++++++++++++++++-- fyo/model/doc.ts | 5 + main/preload.ts | 54 ++++ main/registerIpcMainActionListeners.ts | 127 ++++++++++ src/utils/attachments.ts | 37 ++- utils/attachmentStagingRef.ts | 50 ++++ utils/messages.ts | 3 + 7 files changed, 586 insertions(+), 26 deletions(-) create mode 100644 utils/attachmentStagingRef.ts diff --git a/fyo/model/AttachmentManager.ts b/fyo/model/AttachmentManager.ts index 0183ed331..18fbb1180 100644 --- a/fyo/model/AttachmentManager.ts +++ b/fyo/model/AttachmentManager.ts @@ -1,12 +1,19 @@ import type { Field } from 'schemas/types'; import { FieldTypeEnum } from 'schemas/types'; import { toRaw } from 'vue'; +import { + decodeBooksStagedPath, + encodeBooksStagedPath, + isBooksStagedRef, +} from 'utils/attachmentStagingRef'; import { dataUrlFromBytes } from './attachmentEncoding'; type AttachmentStorageMode = 'database' | 'filesystem'; type DocLike = { - schema: { fields: Array<{ fieldname: string; fieldtype: string; meta?: boolean }> }; + schema: { + fields: Array<{ fieldname: string; fieldtype: string; meta?: boolean }>; + }; fyo: { isElectron: boolean; singles: { SystemSettings?: unknown }; @@ -33,14 +40,25 @@ function getFilesystemPathFromAttachmentValue(value: unknown): string | null { if (value == null) return null; if (typeof value === 'object' && !Array.isArray(value)) { const path = (value as { path?: string }).path; + if (typeof path === 'string' && isBooksStagedRef(path)) { + return null; + } return typeof path === 'string' && path.length > 0 ? path : null; } if (typeof value === 'string') { const s = value.trim(); - if (!s) return null; + if (!s) { + return null; + } + if (isBooksStagedRef(s)) { + return null; + } try { const parsed = JSON.parse(s) as { path?: string }; const path = parsed?.path; + if (typeof path === 'string' && isBooksStagedRef(path)) { + return null; + } return typeof path === 'string' && path.length > 0 ? path : null; } catch { return null; @@ -53,7 +71,7 @@ export class AttachmentManager { readonly #doc: DocLike; readonly #attachImagePrefix: string; - // Snapshot at last successful load/sync. + // Snapshot at last successful load/sync (committed paths only). #fsSnapshot: Set = new Set(); // Paths to delete after a successful sync. #pendingDeletes: Set = new Set(); @@ -111,20 +129,215 @@ export class AttachmentManager { const ipcApi = (globalThis as any)?.ipc; const dbPath = (this.#doc.fyo.db as any)?.dbPath as string | undefined; if (!this.#doc.fyo.isElectron || !dbPath) return; - if (!ipcApi?.desktop || typeof ipcApi.attachments?.delete !== 'function') { + + const committed = this.collectFilesystemRefs(); + const stagedAbs = this.#collectStagedAbsolutePaths(this.#doc); + + if (ipcApi?.desktop && typeof ipcApi.attachments?.delete === 'function') { + await Promise.all( + Array.from(committed).map(async (p) => { + try { + await ipcApi.attachments.delete({ dbPath, path: p }); + } catch { + // best-effort + } + }) + ); + } + + if ( + ipcApi?.desktop && + typeof ipcApi.attachments?.stageDelete === 'function' + ) { + await Promise.all( + stagedAbs.map(async (abs) => { + try { + await ipcApi.attachments.stageDelete({ stagePath: abs }); + } catch { + // best-effort + } + }) + ); + } + } + + /** + * Before DB insert/update: move staged temp files into final attachments folder + * and replace `books-staged:` tokens with committed paths. + */ + async commitStagedBeforeDbWrite() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = (globalThis as any)?.ipc; + const dbPath = (this.#doc.fyo.db as any)?.dbPath as string | undefined; + if ( + !this.#doc.fyo.isElectron || + !dbPath || + !ipcApi?.desktop || + typeof ipcApi.attachments?.stageCommit !== 'function' + ) { return; } - const paths = this.collectFilesystemRefs(); - await Promise.all( - Array.from(paths).map(async (p) => { - try { - await ipcApi.attachments.delete({ dbPath, path: p }); - } catch { - // best-effort + await this.#commitStagedInDoc(this.#doc, ipcApi, dbPath); + } + + /** + * On reload/discard without syncing: remove staged temp files. + */ + async discardStagedOnReload() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = (globalThis as any)?.ipc; + if ( + !ipcApi?.desktop || + typeof ipcApi.attachments?.stageDelete !== 'function' + ) { + return; + } + + const absPaths = this.#collectStagedAbsolutePaths(this.#doc); + const seen = new Set(); + for (const abs of absPaths) { + if (seen.has(abs)) continue; + seen.add(abs); + try { + await ipcApi.attachments.stageDelete({ stagePath: abs }); + } catch { + // best-effort + } + } + } + + async #commitStagedInDoc( + doc: DocLike, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ipcApi: any, + dbPath: string + ) { + const isDocLike = (v: unknown): v is DocLike => { + if (!v || typeof v !== 'object') return false; + const anyV = v as any; + return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; + }; + + for (const field of doc.schema.fields) { + if (field.meta) continue; + const fieldname = field.fieldname; + const value = doc.get(fieldname) as unknown; + + if (field.fieldtype === FieldTypeEnum.Attachment) { + const v = value as { path?: string; name?: string; type?: string } | null; + const p = v?.path; + if (typeof p === 'string' && isBooksStagedRef(p)) { + const abs = decodeBooksStagedPath(p); + if (abs) { + const res = (await ipcApi.attachments.stageCommit({ + dbPath, + stagePath: abs, + })) as { + success?: boolean; + attachment?: { path?: string; name?: string }; + }; + const newPath = res?.success ? res?.attachment?.path : undefined; + if (newPath) { + doc[fieldname] = { + ...v, + path: newPath, + }; + } + } } - }) - ); + continue; + } + + if (field.fieldtype === FieldTypeEnum.AttachImage) { + if (typeof value === 'string' && isBooksStagedRef(value)) { + const abs = decodeBooksStagedPath(value); + if (abs) { + const res = (await ipcApi.attachments.stageCommit({ + dbPath, + stagePath: abs, + })) as { + success?: boolean; + attachment?: { path?: string }; + }; + const newPath = res?.success ? res?.attachment?.path : undefined; + if (newPath) { + doc[fieldname] = `${this.#attachImagePrefix}${newPath}`; + } + } + } + continue; + } + + if (field.fieldtype === FieldTypeEnum.Table && Array.isArray(value)) { + for (const row of value) { + const child = toRaw(row) as DocLike; + if (isDocLike(child)) { + await this.#commitStagedInDoc(child, ipcApi, dbPath); + } + } + } + } + } + + #collectStagedAbsolutePaths(d: DocLike): string[] { + const out: string[] = []; + const isDocLike = (v: unknown): v is DocLike => { + if (!v || typeof v !== 'object') return false; + const anyV = v as any; + return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; + }; + + const scan = (doc: DocLike) => { + for (const field of doc.schema.fields) { + if (field.meta) continue; + const value = doc.get(field.fieldname) as unknown; + + if (field.fieldtype === FieldTypeEnum.Attachment) { + const v = value as { path?: string } | null; + const p = v?.path; + if (typeof p === 'string' && isBooksStagedRef(p)) { + const abs = decodeBooksStagedPath(p); + if (abs) out.push(abs); + } + continue; + } + + if (field.fieldtype === FieldTypeEnum.AttachImage) { + if (typeof value === 'string' && isBooksStagedRef(value)) { + const abs = decodeBooksStagedPath(value); + if (abs) out.push(abs); + } + continue; + } + + if (field.fieldtype === FieldTypeEnum.Table && Array.isArray(value)) { + for (const row of value) { + const child = toRaw(row) as DocLike; + if (isDocLike(child)) scan(child); + } + } + } + }; + + scan(toRaw(d) as DocLike); + return out; + } + + async #deleteStagedPathIfAny(pathOrRef: string | null) { + if (!pathOrRef || !isBooksStagedRef(pathOrRef)) return; + const abs = decodeBooksStagedPath(pathOrRef); + if (!abs) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = (globalThis as any)?.ipc; + if (!ipcApi?.desktop || typeof ipcApi.attachments?.stageDelete !== 'function') { + return; + } + try { + await ipcApi.attachments.stageDelete({ stagePath: abs }); + } catch { + // best-effort + } } async normalizeBeforeSet(field: Field, value: unknown): Promise { @@ -141,6 +354,12 @@ export class AttachmentManager { typeof ipcApi.attachments?.save === 'function' && typeof ipcApi.attachments?.delete === 'function'; + const canStage = + canUseFs && + typeof ipcApi.attachments?.stageSave === 'function' && + typeof ipcApi.attachments?.stageCommit === 'function' && + typeof ipcApi.attachments?.stageDelete === 'function'; + if (field.fieldtype === FieldTypeEnum.Attachment) { const v = value as | null @@ -155,9 +374,42 @@ export class AttachmentManager { if (!v) return value; const prev = this.#doc.get(field.fieldname) as any; - const prevPath = typeof prev?.path === 'string' ? prev.path : null; + const prevPath = + typeof prev?.path === 'string' ? prev.path : null; if (v.bytes instanceof Uint8Array && v.name && v.type) { + if (canStage) { + const res = (await ipcApi.attachments.stageSave({ + dbPath, + name: v.name, + type: v.type, + data: v.bytes, + })) as { + success?: boolean; + stagePath?: string; + attachment?: { stagePath?: string }; + }; + + const stagePath = res?.success + ? res.stagePath ?? res.attachment?.stagePath + : undefined; + + if (typeof stagePath === 'string' && stagePath.length > 0) { + if (prevPath) { + if (isBooksStagedRef(prevPath)) { + await this.#deleteStagedPathIfAny(prevPath); + } else { + this.#pendingDeletes.add(prevPath); + } + } + return { + name: v.name, + type: v.type, + path: encodeBooksStagedPath(stagePath), + }; + } + } + if (canUseFs) { const res = (await ipcApi.attachments.save({ dbPath, @@ -169,14 +421,16 @@ export class AttachmentManager { const newPath = res?.success ? res?.attachment?.path : undefined; if (newPath) { if (prevPath) { - // Defer deletion until after a successful sync(). - this.#pendingDeletes.add(prevPath); + if (isBooksStagedRef(prevPath)) { + await this.#deleteStagedPathIfAny(prevPath); + } else { + this.#pendingDeletes.add(prevPath); + } } return { name: v.name, type: v.type, path: newPath }; } } - // DB fallback (or if filesystem save fails): embed into DB. return { name: v.name, type: v.type, data: dataUrlFromBytes(v.type, v.bytes) }; } @@ -194,10 +448,37 @@ export class AttachmentManager { } const prev = this.#doc.get(field.fieldname) as any; - const prevRef = - typeof prev === 'string' && prev.startsWith(this.#attachImagePrefix) - ? prev.slice(this.#attachImagePrefix.length) - : null; + const prevStr = typeof prev === 'string' ? prev : null; + + if (canStage) { + const res = (await ipcApi.attachments.stageSave({ + dbPath, + name: v.name || 'image', + type: v.type, + data: v.data, + })) as { + success?: boolean; + stagePath?: string; + attachment?: { stagePath?: string }; + }; + + const stagePath = res?.success + ? res.stagePath ?? res.attachment?.stagePath + : undefined; + + if (typeof stagePath === 'string' && stagePath.length > 0) { + if (prevStr) { + if (isBooksStagedRef(prevStr)) { + await this.#deleteStagedPathIfAny(prevStr); + } else if (prevStr.startsWith(this.#attachImagePrefix)) { + this.#pendingDeletes.add( + prevStr.slice(this.#attachImagePrefix.length) + ); + } + } + return encodeBooksStagedPath(stagePath); + } + } if (canUseFs) { const res = (await ipcApi.attachments.save({ @@ -209,9 +490,14 @@ export class AttachmentManager { const newPath = res?.success ? res?.attachment?.path : undefined; if (newPath) { - if (prevRef) { - // Defer deletion until after a successful sync(). - this.#pendingDeletes.add(prevRef); + if (prevStr) { + if (isBooksStagedRef(prevStr)) { + await this.#deleteStagedPathIfAny(prevStr); + } else if (prevStr.startsWith(this.#attachImagePrefix)) { + this.#pendingDeletes.add( + prevStr.slice(this.#attachImagePrefix.length) + ); + } } return `${this.#attachImagePrefix}${newPath}`; } @@ -247,6 +533,7 @@ export class AttachmentManager { const v = value as string | null | undefined; if ( typeof v === 'string' && + !isBooksStagedRef(v) && v.startsWith(this.#attachImagePrefix) && v.length > this.#attachImagePrefix.length ) { @@ -270,4 +557,3 @@ export class AttachmentManager { return refs; } } - diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 40eea7373..4fc6640ee 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -725,6 +725,9 @@ export class Doc extends Observable { } async _syncValues(data: DocValueMap) { + if (!this._syncing) { + await this.attachments.discardStagedOnReload(); + } this._clearValues(); this._setValuesWithoutChecks(data, false); await this._setComputedValuesFromFormulas(); @@ -911,6 +914,7 @@ export class Doc extends Observable { async _insert() { this._setBaseMetaValues(); + await this.attachments.commitStagedBeforeDbWrite(); await this._preSync(); await setName(this, this.fyo); @@ -930,6 +934,7 @@ export class Doc extends Observable { async _update() { await this._validateDbNotModified(); this._updateModifiedMetaValues(); + await this.attachments.commitStagedBeforeDbWrite(); await this._preSync(); const data = this.getValidDict(false, true); diff --git a/main/preload.ts b/main/preload.ts index a49f5c4de..ee7d215c8 100644 --- a/main/preload.ts +++ b/main/preload.ts @@ -184,6 +184,60 @@ const ipc = { message?: string; }; }, + + async stageSave(params: { + dbPath: string; + name: string; + type: string; + data: Uint8Array; + }) { + const res = (await ipcRenderer.invoke( + IPC_ACTIONS.ATTACHMENT_STAGE_SAVE, + params + )) as BackendResponse; + if (res.error) { + return { success: false, message: res.error.message }; + } + return (res.data ?? { success: false }) as { + success: boolean; + message?: string; + stagePath?: string; + attachment?: { + name: string; + type: string; + stagePath: string; + }; + }; + }, + + async stageCommit(params: { dbPath: string; stagePath: string }) { + const res = (await ipcRenderer.invoke( + IPC_ACTIONS.ATTACHMENT_STAGE_COMMIT, + params + )) as BackendResponse; + if (res.error) { + return { success: false, message: res.error.message }; + } + return (res.data ?? { success: false }) as { + success: boolean; + message?: string; + attachment?: { name: string; path: string }; + }; + }, + + async stageDelete(params: { stagePath: string }) { + const res = (await ipcRenderer.invoke( + IPC_ACTIONS.ATTACHMENT_STAGE_DELETE, + params + )) as BackendResponse; + if (res.error) { + return { success: false, message: res.error.message }; + } + return (res.data ?? { success: false }) as { + success: boolean; + message?: string; + }; + }, }, showItemInFolder(filePath: string) { diff --git a/main/registerIpcMainActionListeners.ts b/main/registerIpcMainActionListeners.ts index 29fc55c6e..453f7bc74 100644 --- a/main/registerIpcMainActionListeners.ts +++ b/main/registerIpcMainActionListeners.ts @@ -10,6 +10,7 @@ import { autoUpdater } from 'electron-updater'; import { constants } from 'fs'; import fs from 'fs-extra'; import path from 'path'; +import { tmpdir } from 'os'; import { SelectFileOptions, SelectFileReturn } from 'utils/types'; import databaseManager from 'backend/database/manager'; import { emitMainProcessError } from 'backend/helpers'; @@ -54,6 +55,20 @@ function getAttachmentRootForDb(dbPath: string) { return path.join(dir, 'attachments', dbBase || 'default'); } +function getAttachmentStageRootForDb(dbPath: string) { + const dbBase = path.basename(dbPath, '.books.db'); + return path.join(tmpdir(), 'rukn-books', 'attachments-stage', dbBase || 'default'); +} + +function isPathInsideStageRoot(stagePath: string): boolean { + const resolved = path.resolve(stagePath); + const prefix = path.join(tmpdir(), 'rukn-books', 'attachments-stage'); + const normalizedPrefix = path.resolve(prefix); + return ( + resolved === normalizedPrefix || resolved.startsWith(normalizedPrefix + path.sep) + ); +} + export default function registerIpcMainActionListeners(main: Main) { ipcMain.handle(IPC_ACTIONS.CHECK_DB_ACCESS, async (_, filePath: string) => { try { @@ -540,4 +555,116 @@ export default function registerIpcMainActionListeners(main: Main) { }); } ); + + /** + * Staging: write bytes to OS temp until document sync commits to attachments folder. + */ + ipcMain.handle( + IPC_ACTIONS.ATTACHMENT_STAGE_SAVE, + async ( + _, + params: { dbPath: string; name: string; type: string; data: unknown } + ) => { + return await getErrorHandledReponse(async () => { + const { dbPath, name, type, data } = params ?? {}; + if (!dbPath || typeof dbPath !== 'string') { + return { success: false, message: 'Missing dbPath' }; + } + if (!name || typeof name !== 'string') { + return { success: false, message: 'Missing file name' }; + } + if (!type || typeof type !== 'string') { + return { success: false, message: 'Missing file type' }; + } + + let bytes: Uint8Array | null = null; + if (data instanceof Uint8Array) { + bytes = data; + } else if (Buffer.isBuffer(data)) { + bytes = new Uint8Array(data); + } else if (data instanceof ArrayBuffer) { + bytes = new Uint8Array(data); + } else if (ArrayBuffer.isView(data)) { + bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + + if (!bytes) { + return { success: false, message: 'Missing file data' }; + } + + const stageRoot = getAttachmentStageRootForDb(dbPath); + await fs.ensureDir(stageRoot); + + const safeName = sanitizeFilename(name) || 'attachment'; + const stamp = new Date().toISOString().replace(/[-T:.Z]/g, ''); + const filename = `${stamp}_${safeName}`; + const fullPath = path.join(stageRoot, filename); + await fs.writeFile(fullPath, Buffer.from(bytes)); + + return { + success: true, + stagePath: fullPath, + attachment: { + name: safeName, + type, + stagePath: fullPath, + }, + }; + }); + } + ); + + ipcMain.handle( + IPC_ACTIONS.ATTACHMENT_STAGE_COMMIT, + async (_, params: { dbPath: string; stagePath: string }) => { + return await getErrorHandledReponse(async () => { + const { dbPath, stagePath } = params ?? {}; + if (!dbPath || typeof dbPath !== 'string') { + return { success: false, message: 'Missing dbPath' }; + } + if (!stagePath || typeof stagePath !== 'string') { + return { success: false, message: 'Missing stage path' }; + } + if (!isPathInsideStageRoot(stagePath)) { + return { success: false, message: 'Invalid stage path' }; + } + if (!(await fs.pathExists(stagePath))) { + return { success: false, message: 'Staged file not found' }; + } + + const finalRoot = getAttachmentRootForDb(dbPath); + await fs.ensureDir(finalRoot); + + const baseName = path.basename(stagePath); + const dest = path.join(finalRoot, baseName); + await fs.move(stagePath, dest, { overwrite: true }); + + const relativePath = path.relative(path.dirname(dbPath), dest); + return { + success: true, + attachment: { + name: baseName, + path: relativePath, + }, + }; + }); + } + ); + + ipcMain.handle( + IPC_ACTIONS.ATTACHMENT_STAGE_DELETE, + async (_, params: { stagePath: string }) => { + return await getErrorHandledReponse(async () => { + const { stagePath } = params ?? {}; + if (!stagePath || typeof stagePath !== 'string') { + return { success: false, message: 'Missing stage path' }; + } + if (!isPathInsideStageRoot(stagePath)) { + return { success: false, message: 'Invalid stage path' }; + } + await fs.remove(stagePath); + return { success: true }; + }); + } + ); } diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index 905cb3dad..386f7a081 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -1,6 +1,10 @@ import { Fyo } from 'fyo'; import { Attachment } from 'fyo/core/types'; import { getDataURL } from 'src/utils/misc'; +import { + decodeBooksStagedPath, + isBooksStagedRef, +} from 'utils/attachmentStagingRef'; const ATTACH_IMAGE_FILE_REF_PREFIX = 'books-file:'; @@ -31,9 +35,13 @@ export async function resolveAttachmentDataUrl( return null; } + const readPath = isBooksStagedRef(attachment.path) + ? decodeBooksStagedPath(attachment.path) ?? attachment.path + : attachment.path; + const res = (await ipcApi.attachments.read({ dbPath, - path: attachment.path, + path: readPath, })) as { success?: boolean; data?: Uint8Array }; if (!res?.success || !res.data) { @@ -82,6 +90,33 @@ export async function resolveAttachImageSrc( } // Legacy / default: already a data URL. + if (isBooksStagedRef(value)) { + const abs = decodeBooksStagedPath(value); + if (!abs) { + return null; + } + const dbPath = fyo.db?.dbPath; + if (!dbPath) { + return null; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = typeof ipc !== 'undefined' ? (ipc as any) : undefined; + if (!ipcApi?.desktop || typeof ipcApi.attachments?.read !== 'function') { + return null; + } + const res = (await ipcApi.attachments.read({ + dbPath, + path: abs, + })) as { success?: boolean; data?: Uint8Array }; + + if (!res?.success || !res.data) { + return null; + } + + const type = typeHint || 'application/octet-stream'; + return getDataURL(type, Uint8Array.from(res.data)); + } + if (!isAttachImageFileRef(value)) { return value; } diff --git a/utils/attachmentStagingRef.ts b/utils/attachmentStagingRef.ts new file mode 100644 index 000000000..902d95a45 --- /dev/null +++ b/utils/attachmentStagingRef.ts @@ -0,0 +1,50 @@ +/** In-memory / pre-save filesystem staging token (not persisted to DB until sync). */ +export const BOOKS_STAGED_PREFIX = 'books-staged:'; + +export function isBooksStagedRef(value: string | null | undefined): boolean { + return ( + typeof value === 'string' && + value.length > BOOKS_STAGED_PREFIX.length && + value.startsWith(BOOKS_STAGED_PREFIX) + ); +} + +export function encodeBooksStagedPath(absolutePath: string): string { + const bin = unescape(encodeURIComponent(absolutePath)); + // eslint-disable-next-line no-undef + if (typeof btoa === 'function') { + return BOOKS_STAGED_PREFIX + btoa(bin); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const B = (globalThis as any)?.Buffer; + if (B) { + return BOOKS_STAGED_PREFIX + B.from(absolutePath, 'utf8').toString('base64'); + } + return BOOKS_STAGED_PREFIX + bin; +} + +export function decodeBooksStagedPath(ref: string): string | null { + if (!isBooksStagedRef(ref)) { + return null; + } + const b64 = ref.slice(BOOKS_STAGED_PREFIX.length); + try { + // eslint-disable-next-line no-undef + if (typeof atob === 'function') { + const bin = atob(b64); + return decodeURIComponent(escape(bin)); + } + } catch { + return null; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const B = (globalThis as any)?.Buffer; + if (B) { + try { + return B.from(b64, 'base64').toString('utf8'); + } catch { + return null; + } + } + return null; +} diff --git a/utils/messages.ts b/utils/messages.ts index 914f69113..a819ef135 100644 --- a/utils/messages.ts +++ b/utils/messages.ts @@ -52,6 +52,9 @@ export enum IPC_ACTIONS { ATTACHMENT_SAVE = 'attachment-save', ATTACHMENT_READ = 'attachment-read', ATTACHMENT_DELETE = 'attachment-delete', + ATTACHMENT_STAGE_SAVE = 'attachment-stage-save', + ATTACHMENT_STAGE_COMMIT = 'attachment-stage-commit', + ATTACHMENT_STAGE_DELETE = 'attachment-stage-delete', } // ipcMain.send(...) From 4c9cb00be81103677a1696527249d7a58e61788d Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Mon, 4 May 2026 20:08:18 +0400 Subject: [PATCH 06/22] refactor: deduplicate attachment save paths in main and AttachmentManager - Main: parseAttachmentParams, normalizeIncomingAttachmentBytes, and writeStampedAttachmentFile shared by ATTACHMENT_SAVE and ATTACHMENT_STAGE_SAVE. - AttachmentManager: ipcStageSave/ipcFinalSave and reconcile helpers for normalizeBeforeSet (Attachment + AttachImage). --- fyo/model/AttachmentManager.ts | 181 ++++++++++++++----------- main/registerIpcMainActionListeners.ts | 137 ++++++++++--------- 2 files changed, 176 insertions(+), 142 deletions(-) diff --git a/fyo/model/AttachmentManager.ts b/fyo/model/AttachmentManager.ts index 18fbb1180..3b8fa94e6 100644 --- a/fyo/model/AttachmentManager.ts +++ b/fyo/model/AttachmentManager.ts @@ -340,6 +340,72 @@ export class AttachmentManager { } } + async #reconcilePreviousAttachmentPath(prevPath: string | null) { + if (!prevPath) return; + if (isBooksStagedRef(prevPath)) { + await this.#deleteStagedPathIfAny(prevPath); + } else { + this.#pendingDeletes.add(prevPath); + } + } + + async #reconcilePreviousAttachImage(prevStr: string | null) { + if (!prevStr) return; + if (isBooksStagedRef(prevStr)) { + await this.#deleteStagedPathIfAny(prevStr); + } else if (prevStr.startsWith(this.#attachImagePrefix)) { + this.#pendingDeletes.add(prevStr.slice(this.#attachImagePrefix.length)); + } + } + + #readStagePathFromResponse(res: { + success?: boolean; + stagePath?: string; + attachment?: { stagePath?: string }; + }): string | null { + if (!res?.success) return null; + const s = res.stagePath ?? res.attachment?.stagePath; + return typeof s === 'string' && s.length > 0 ? s : null; + } + + async #ipcStageSave( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ipcApi: any, + dbPath: string, + name: string, + type: string, + data: Uint8Array + ): Promise { + const res = (await ipcApi.attachments.stageSave({ + dbPath, + name, + type, + data, + })) as { + success?: boolean; + stagePath?: string; + attachment?: { stagePath?: string }; + }; + return this.#readStagePathFromResponse(res); + } + + async #ipcFinalSave( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ipcApi: any, + dbPath: string, + name: string, + type: string, + data: Uint8Array + ): Promise { + const res = (await ipcApi.attachments.save({ + dbPath, + name, + type, + data, + })) as { success?: boolean; attachment?: { path?: string } }; + return res?.success && res.attachment?.path ? res.attachment.path : null; + } + async normalizeBeforeSet(field: Field, value: unknown): Promise { const storage = getStorageMode(this.#doc); @@ -379,29 +445,15 @@ export class AttachmentManager { if (v.bytes instanceof Uint8Array && v.name && v.type) { if (canStage) { - const res = (await ipcApi.attachments.stageSave({ - dbPath, - name: v.name, - type: v.type, - data: v.bytes, - })) as { - success?: boolean; - stagePath?: string; - attachment?: { stagePath?: string }; - }; - - const stagePath = res?.success - ? res.stagePath ?? res.attachment?.stagePath - : undefined; - - if (typeof stagePath === 'string' && stagePath.length > 0) { - if (prevPath) { - if (isBooksStagedRef(prevPath)) { - await this.#deleteStagedPathIfAny(prevPath); - } else { - this.#pendingDeletes.add(prevPath); - } - } + const stagePath = await this.#ipcStageSave( + ipcApi, + dbPath!, + v.name, + v.type, + v.bytes + ); + if (stagePath) { + await this.#reconcilePreviousAttachmentPath(prevPath); return { name: v.name, type: v.type, @@ -411,22 +463,15 @@ export class AttachmentManager { } if (canUseFs) { - const res = (await ipcApi.attachments.save({ - dbPath, - name: v.name, - type: v.type, - data: v.bytes, - })) as { success?: boolean; attachment?: { path?: string } }; - - const newPath = res?.success ? res?.attachment?.path : undefined; + const newPath = await this.#ipcFinalSave( + ipcApi, + dbPath!, + v.name, + v.type, + v.bytes + ); if (newPath) { - if (prevPath) { - if (isBooksStagedRef(prevPath)) { - await this.#deleteStagedPathIfAny(prevPath); - } else { - this.#pendingDeletes.add(prevPath); - } - } + await this.#reconcilePreviousAttachmentPath(prevPath); return { name: v.name, type: v.type, path: newPath }; } } @@ -449,56 +494,32 @@ export class AttachmentManager { const prev = this.#doc.get(field.fieldname) as any; const prevStr = typeof prev === 'string' ? prev : null; + const imageName = v.name || 'image'; if (canStage) { - const res = (await ipcApi.attachments.stageSave({ - dbPath, - name: v.name || 'image', - type: v.type, - data: v.data, - })) as { - success?: boolean; - stagePath?: string; - attachment?: { stagePath?: string }; - }; - - const stagePath = res?.success - ? res.stagePath ?? res.attachment?.stagePath - : undefined; - - if (typeof stagePath === 'string' && stagePath.length > 0) { - if (prevStr) { - if (isBooksStagedRef(prevStr)) { - await this.#deleteStagedPathIfAny(prevStr); - } else if (prevStr.startsWith(this.#attachImagePrefix)) { - this.#pendingDeletes.add( - prevStr.slice(this.#attachImagePrefix.length) - ); - } - } + const stagePath = await this.#ipcStageSave( + ipcApi, + dbPath!, + imageName, + v.type, + v.data + ); + if (stagePath) { + await this.#reconcilePreviousAttachImage(prevStr); return encodeBooksStagedPath(stagePath); } } if (canUseFs) { - const res = (await ipcApi.attachments.save({ - dbPath, - name: v.name || 'image', - type: v.type, - data: v.data, - })) as { success?: boolean; attachment?: { path?: string } }; - - const newPath = res?.success ? res?.attachment?.path : undefined; + const newPath = await this.#ipcFinalSave( + ipcApi, + dbPath!, + imageName, + v.type, + v.data + ); if (newPath) { - if (prevStr) { - if (isBooksStagedRef(prevStr)) { - await this.#deleteStagedPathIfAny(prevStr); - } else if (prevStr.startsWith(this.#attachImagePrefix)) { - this.#pendingDeletes.add( - prevStr.slice(this.#attachImagePrefix.length) - ); - } - } + await this.#reconcilePreviousAttachImage(prevStr); return `${this.#attachImagePrefix}${newPath}`; } } diff --git a/main/registerIpcMainActionListeners.ts b/main/registerIpcMainActionListeners.ts index 453f7bc74..e87923a92 100644 --- a/main/registerIpcMainActionListeners.ts +++ b/main/registerIpcMainActionListeners.ts @@ -69,6 +69,63 @@ function isPathInsideStageRoot(stagePath: string): boolean { ); } +type ParsedAttachmentParams = + | { ok: true; dbPath: string; name: string; type: string; bytes: Uint8Array } + | { ok: false; message: string }; + +function normalizeIncomingAttachmentBytes(data: unknown): Uint8Array | null { + if (data instanceof Uint8Array) { + return data; + } + if (Buffer.isBuffer(data)) { + return new Uint8Array(data); + } + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + return null; +} + +function parseAttachmentParams( + params: + | { dbPath: string; name: string; type: string; data: unknown } + | null + | undefined +): ParsedAttachmentParams { + const { dbPath, name, type, data } = params ?? {}; + if (!dbPath || typeof dbPath !== 'string') { + return { ok: false, message: 'Missing dbPath' }; + } + if (!name || typeof name !== 'string') { + return { ok: false, message: 'Missing file name' }; + } + if (!type || typeof type !== 'string') { + return { ok: false, message: 'Missing file type' }; + } + const bytes = normalizeIncomingAttachmentBytes(data); + if (!bytes) { + return { ok: false, message: 'Missing file data' }; + } + return { ok: true, dbPath, name, type, bytes }; +} + +async function writeStampedAttachmentFile( + rootDir: string, + originalName: string, + bytes: Uint8Array +): Promise<{ fullPath: string; safeName: string }> { + await fs.ensureDir(rootDir); + const safeName = sanitizeFilename(originalName) || 'attachment'; + const stamp = new Date().toISOString().replace(/[-T:.Z]/g, ''); + const filename = `${stamp}_${safeName}`; + const fullPath = path.join(rootDir, filename); + await fs.writeFile(fullPath, Buffer.from(bytes)); + return { fullPath, safeName }; +} + export default function registerIpcMainActionListeners(main: Main) { ipcMain.handle(IPC_ACTIONS.CHECK_DB_ACCESS, async (_, filePath: string) => { try { @@ -465,40 +522,18 @@ export default function registerIpcMainActionListeners(main: Main) { params: { dbPath: string; name: string; type: string; data: unknown } ) => { return await getErrorHandledReponse(async () => { - const { dbPath, name, type, data } = params ?? {}; - if (!dbPath || typeof dbPath !== 'string') { - return { success: false, message: 'Missing dbPath' }; - } - if (!name || typeof name !== 'string') { - return { success: false, message: 'Missing file name' }; - } - if (!type || typeof type !== 'string') { - return { success: false, message: 'Missing file type' }; - } - - let bytes: Uint8Array | null = null; - if (data instanceof Uint8Array) { - bytes = data; - } else if (Buffer.isBuffer(data)) { - bytes = new Uint8Array(data); - } else if (data instanceof ArrayBuffer) { - bytes = new Uint8Array(data); - } else if (ArrayBuffer.isView(data)) { - bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - } - - if (!bytes) { - return { success: false, message: 'Missing file data' }; + const parsed = parseAttachmentParams(params); + if (!parsed.ok) { + return { success: false, message: parsed.message }; } + const { dbPath, name, type, bytes } = parsed; const root = getAttachmentRootForDb(dbPath); - await fs.ensureDir(root); - - const safeName = sanitizeFilename(name) || 'attachment'; - const stamp = new Date().toISOString().replace(/[-T:.Z]/g, ''); - const filename = `${stamp}_${safeName}`; - const fullPath = path.join(root, filename); - await fs.writeFile(fullPath, Buffer.from(bytes)); + const { fullPath, safeName } = await writeStampedAttachmentFile( + root, + name, + bytes + ); const relativePath = path.relative(path.dirname(dbPath), fullPath); return { @@ -566,40 +601,18 @@ export default function registerIpcMainActionListeners(main: Main) { params: { dbPath: string; name: string; type: string; data: unknown } ) => { return await getErrorHandledReponse(async () => { - const { dbPath, name, type, data } = params ?? {}; - if (!dbPath || typeof dbPath !== 'string') { - return { success: false, message: 'Missing dbPath' }; - } - if (!name || typeof name !== 'string') { - return { success: false, message: 'Missing file name' }; - } - if (!type || typeof type !== 'string') { - return { success: false, message: 'Missing file type' }; - } - - let bytes: Uint8Array | null = null; - if (data instanceof Uint8Array) { - bytes = data; - } else if (Buffer.isBuffer(data)) { - bytes = new Uint8Array(data); - } else if (data instanceof ArrayBuffer) { - bytes = new Uint8Array(data); - } else if (ArrayBuffer.isView(data)) { - bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); - } - - if (!bytes) { - return { success: false, message: 'Missing file data' }; + const parsed = parseAttachmentParams(params); + if (!parsed.ok) { + return { success: false, message: parsed.message }; } + const { dbPath, name, type, bytes } = parsed; const stageRoot = getAttachmentStageRootForDb(dbPath); - await fs.ensureDir(stageRoot); - - const safeName = sanitizeFilename(name) || 'attachment'; - const stamp = new Date().toISOString().replace(/[-T:.Z]/g, ''); - const filename = `${stamp}_${safeName}`; - const fullPath = path.join(stageRoot, filename); - await fs.writeFile(fullPath, Buffer.from(bytes)); + const { fullPath, safeName } = await writeStampedAttachmentFile( + stageRoot, + name, + bytes + ); return { success: true, From 2f24fb15be6e15a57ee63d321ceaa47f003805c8 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 08:22:30 +0400 Subject: [PATCH 07/22] feat: stage filesystem attachments on duplicate; add Doc.duplicateForEdit - After duplicate, read committed files and stageSave so the copy uses books-staged refs (child tables included); snapshot refreshed. - Add duplicateForEdit() combining duplicate() + remap; use in Duplicate action and Shipment spec. Keep duplicate() sync for headless use. - JSDoc: mime guess for IPC read+stage (read returns no type). --- fyo/model/AttachmentManager.ts | 198 ++++++++++++++++++ fyo/model/doc.ts | 18 ++ .../inventory/tests/testStockTransfer.spec.ts | 2 +- src/utils/ui.ts | 2 +- 4 files changed, 218 insertions(+), 2 deletions(-) diff --git a/fyo/model/AttachmentManager.ts b/fyo/model/AttachmentManager.ts index 3b8fa94e6..354ac8de4 100644 --- a/fyo/model/AttachmentManager.ts +++ b/fyo/model/AttachmentManager.ts @@ -280,6 +280,204 @@ export class AttachmentManager { } } + /** + * Called from `Doc.duplicateForEdit()` after `duplicate()`. Replaces committed + * filesystem attachment paths with staged copies so the unsaved duplicate does not + * share files with the source (same stage → commit-on-sync lifecycle as a new upload). + */ + async remapCommittedFilesystemAttachmentsToStagedAfterDuplicate() { + if (getStorageMode(this.#doc) !== 'filesystem') { + this.snapshotAfterLoadOrSync(); + return; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = (globalThis as any)?.ipc; + const dbPath = (this.#doc.fyo.db as any)?.dbPath as string | undefined; + if ( + !this.#doc.fyo.isElectron || + !dbPath || + !ipcApi?.desktop || + typeof ipcApi.attachments?.read !== 'function' || + typeof ipcApi.attachments?.stageSave !== 'function' + ) { + this.snapshotAfterLoadOrSync(); + return; + } + + await this.#remapDuplicateRefsInDoc( + toRaw(this.#doc) as DocLike, + ipcApi, + dbPath + ); + this.snapshotAfterLoadOrSync(); + } + + #guessMimeFromFilename(filename: string): string { + const lower = filename.toLowerCase(); + if (lower.endsWith('.png')) return 'image/png'; + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; + if (lower.endsWith('.gif')) return 'image/gif'; + if (lower.endsWith('.webp')) return 'image/webp'; + if (lower.endsWith('.pdf')) return 'application/pdf'; + return 'application/octet-stream'; + } + + #normalizeAttachmentRow(value: unknown): { + name?: string; + type?: string; + path?: string; + data?: string; + } | null { + if (value == null) return null; + if (typeof value === 'object' && !Array.isArray(value)) { + const v = value as { + name?: string; + type?: string; + path?: string; + data?: string; + }; + return { + name: v.name, + type: v.type, + path: v.path, + data: v.data, + }; + } + if (typeof value === 'string') { + const s = value.trim(); + if (!s) return null; + try { + const parsed = JSON.parse(s) as { + name?: string; + type?: string; + path?: string; + data?: string; + }; + if (parsed && typeof parsed === 'object') { + return { + name: parsed.name, + type: parsed.type, + path: parsed.path, + data: parsed.data, + }; + } + } catch { + return null; + } + } + return null; + } + + async #remapDuplicateRefsInDoc( + doc: DocLike, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ipcApi: any, + dbPath: string + ) { + const isDocLike = (v: unknown): v is DocLike => { + if (!v || typeof v !== 'object') return false; + const anyV = v as any; + return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; + }; + + for (const field of doc.schema.fields) { + if (field.meta) continue; + const fieldname = field.fieldname; + const rawValue = doc.get(fieldname) as unknown; + + if (field.fieldtype === FieldTypeEnum.Attachment) { + const v = this.#normalizeAttachmentRow(rawValue); + if (!v) continue; + const p = v.path; + if (typeof p !== 'string' || !p || isBooksStagedRef(p)) continue; + const readRes = (await ipcApi.attachments.read({ + dbPath, + path: p, + })) as { + success?: boolean; + data?: Uint8Array; + name?: string; + }; + if ( + !readRes?.success || + !(readRes.data instanceof Uint8Array) || + readRes.data.length === 0 + ) { + continue; + } + const baseName = + v.name || + readRes.name || + p.split(/[/\\]/).pop() || + 'attachment'; + const mime = + (typeof v.type === 'string' && v.type.length > 0 + ? v.type + : null) ?? this.#guessMimeFromFilename(baseName); + const stagePath = await this.#ipcStageSave( + ipcApi, + dbPath, + baseName, + mime, + readRes.data + ); + if (!stagePath) continue; + doc[fieldname] = { + name: baseName, + type: mime, + path: encodeBooksStagedPath(stagePath), + }; + continue; + } + + if (field.fieldtype === FieldTypeEnum.AttachImage) { + if (typeof rawValue !== 'string') continue; + const s = rawValue; + if (!s || isBooksStagedRef(s)) continue; + if (s.startsWith('data:')) continue; + if (!s.startsWith(this.#attachImagePrefix)) continue; + const relPath = s.slice(this.#attachImagePrefix.length); + if (!relPath) continue; + const readRes = (await ipcApi.attachments.read({ + dbPath, + path: relPath, + })) as { + success?: boolean; + data?: Uint8Array; + name?: string; + }; + if ( + !readRes?.success || + !(readRes.data instanceof Uint8Array) || + readRes.data.length === 0 + ) { + continue; + } + const baseName = readRes.name || relPath.split(/[/\\]/).pop() || 'image'; + const mime = this.#guessMimeFromFilename(baseName); + const stagePath = await this.#ipcStageSave( + ipcApi, + dbPath, + baseName, + mime, + readRes.data + ); + if (!stagePath) continue; + doc[fieldname] = encodeBooksStagedPath(stagePath); + continue; + } + + if (field.fieldtype === FieldTypeEnum.Table && Array.isArray(rawValue)) { + for (const row of rawValue) { + const child = toRaw(row) as DocLike; + if (isDocLike(child)) { + await this.#remapDuplicateRefsInDoc(child, ipcApi, dbPath); + } + } + } + } + } + #collectStagedAbsolutePaths(d: DocLike): string[] { const out: string[] = []; const isDocLike = (v: unknown): v is DocLike => { diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 4fc6640ee..a1ff8e678 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -1119,6 +1119,13 @@ export class Doc extends Observable { return await this.sync(); } + /** + * Clones current field values into a new unsaved document (synchronous). + * For any duplicate that will be edited or saved in the app, prefer + * {@link Doc.duplicateForEdit} so filesystem attachments are remapped to staged + * copies (Electron). Use this only when you need a sync clone without IPC + * (e.g. some tests). + */ duplicate(): Doc { const updateMap = this.getValidDict(true, true); for (const field in updateMap) { @@ -1151,6 +1158,17 @@ export class Doc extends Observable { return this.fyo.doc.getNewDoc(this.schemaName, rawUpdateMap, true); } + /** + * Runs {@link Doc.duplicate} (including subclass overrides), then remaps + * committed filesystem attachment paths to staged temp files so the copy follows + * the same save lifecycle as a new upload. Use for every user-facing duplicate. + */ + async duplicateForEdit(): Promise { + const dupe = this.duplicate(); + await dupe.attachments.remapCommittedFilesystemAttachmentsToStagedAfterDuplicate(); + return dupe; + } + /** * Lifecycle Methods * diff --git a/models/inventory/tests/testStockTransfer.spec.ts b/models/inventory/tests/testStockTransfer.spec.ts index 493cc159e..246d36175 100644 --- a/models/inventory/tests/testStockTransfer.spec.ts +++ b/models/inventory/tests/testStockTransfer.spec.ts @@ -505,7 +505,7 @@ test('Duplicate Shipment, backref unset', async (t) => { t.ok(shpm.backReference, 'SHPM back ref is set'); - const doc = shpm.duplicate(); + const doc = await shpm.duplicateForEdit(); t.notOk(doc.backReference, 'Duplicate SHPM back ref is not set'); }); diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 34db3b896..2e456434f 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -312,7 +312,7 @@ function getDuplicateAction(doc: Doc): Action { ), async action() { try { - const dupe = doc.duplicate(); + const dupe = await doc.duplicateForEdit(); await openEdit(dupe); } catch (err) { await handleErrorWithDialog(err as Error, doc); From 37f0bdae2eb1c406a6aa21eefc97af95813f75cd Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 09:02:57 +0400 Subject: [PATCH 08/22] fix: delete orphan attachment files when DB write fails after stageCommit - commitStagedBeforeDbWrite returns relative paths created by stageCommit. - recoverAfterFailedDbWrite: IPC delete those paths, clear pendingDeletes, reload doc on failed update, clear affected fields + snapshot on failed insert. - Doc.sync uses try/finally so _syncing resets when insert/update throws. --- fyo/model/AttachmentManager.ts | 111 +++++++++++++++++++++++++++++++-- fyo/model/doc.ts | 99 ++++++++++++++++------------- 2 files changed, 161 insertions(+), 49 deletions(-) diff --git a/fyo/model/AttachmentManager.ts b/fyo/model/AttachmentManager.ts index 354ac8de4..3500fac9a 100644 --- a/fyo/model/AttachmentManager.ts +++ b/fyo/model/AttachmentManager.ts @@ -164,8 +164,9 @@ export class AttachmentManager { /** * Before DB insert/update: move staged temp files into final attachments folder * and replace `books-staged:` tokens with committed paths. + * @returns Relative attachment paths created by this pass (for rollback if DB write fails). */ - async commitStagedBeforeDbWrite() { + async commitStagedBeforeDbWrite(): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const ipcApi = (globalThis as any)?.ipc; const dbPath = (this.#doc.fyo.db as any)?.dbPath as string | undefined; @@ -175,10 +176,106 @@ export class AttachmentManager { !ipcApi?.desktop || typeof ipcApi.attachments?.stageCommit !== 'function' ) { + return []; + } + + return await this.#commitStagedInDoc(this.#doc, ipcApi, dbPath); + } + + /** + * If DB insert/update fails after files were moved into `attachments/…`, delete those + * new files and restore in-memory state (reload from DB for updates; clear fields for failed inserts). + */ + async recoverAfterFailedDbWrite( + committedRelativePaths: string[], + opts: { failedDuringInsert: boolean } + ) { + this.#pendingDeletes.clear(); + const unique = [...new Set(committedRelativePaths.filter(Boolean))]; + if (unique.length > 0) { + await this.#deleteCommittedPathsBestEffort(unique); + } + if (opts.failedDuringInsert) { + if (unique.length > 0) { + this.#clearAttachmentFieldsUsingPaths(new Set(unique)); + } + this.snapshotAfterLoadOrSync(); + } else { + const d = this.#doc as DocLike & { load?: () => Promise }; + if (typeof d.load === 'function') { + await d.load(); + } + } + } + + async #deleteCommittedPathsBestEffort(relativePaths: string[]) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = (globalThis as any)?.ipc; + const dbPath = (this.#doc.fyo.db as any)?.dbPath as string | undefined; + if (!this.#doc.fyo.isElectron || !dbPath) return; + if (!ipcApi?.desktop || typeof ipcApi.attachments?.delete !== 'function') { return; } + await Promise.all( + relativePaths.map(async (p) => { + try { + await ipcApi.attachments.delete({ dbPath, path: p }); + } catch { + // best-effort + } + }) + ); + } - await this.#commitStagedInDoc(this.#doc, ipcApi, dbPath); + #clearAttachmentFieldsUsingPaths(paths: Set) { + const isDocLike = (v: unknown): v is DocLike => { + if (!v || typeof v !== 'object') return false; + const anyV = v as any; + return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; + }; + + const clearIn = (d: DocLike) => { + for (const field of d.schema.fields) { + if (field.meta) continue; + const fieldname = field.fieldname; + const value = d.get(fieldname) as unknown; + + if (field.fieldtype === FieldTypeEnum.Attachment) { + const p = getFilesystemPathFromAttachmentValue(value); + if (p && paths.has(p)) { + d[fieldname] = null; + } + continue; + } + + if (field.fieldtype === FieldTypeEnum.AttachImage) { + const v = value as string | null | undefined; + if ( + typeof v === 'string' && + !isBooksStagedRef(v) && + v.startsWith(this.#attachImagePrefix) && + v.length > this.#attachImagePrefix.length + ) { + const rel = v.slice(this.#attachImagePrefix.length); + if (paths.has(rel)) { + d[fieldname] = null; + } + } + continue; + } + + if (field.fieldtype === FieldTypeEnum.Table && Array.isArray(value)) { + for (const row of value) { + const child = toRaw(row) as DocLike; + if (isDocLike(child)) { + clearIn(child); + } + } + } + } + }; + + clearIn(toRaw(this.#doc) as DocLike); } /** @@ -212,7 +309,8 @@ export class AttachmentManager { // eslint-disable-next-line @typescript-eslint/no-explicit-any ipcApi: any, dbPath: string - ) { + ): Promise { + const createdPaths: string[] = []; const isDocLike = (v: unknown): v is DocLike => { if (!v || typeof v !== 'object') return false; const anyV = v as any; @@ -243,6 +341,7 @@ export class AttachmentManager { ...v, path: newPath, }; + createdPaths.push(newPath); } } } @@ -263,6 +362,7 @@ export class AttachmentManager { const newPath = res?.success ? res?.attachment?.path : undefined; if (newPath) { doc[fieldname] = `${this.#attachImagePrefix}${newPath}`; + createdPaths.push(newPath); } } } @@ -273,11 +373,14 @@ export class AttachmentManager { for (const row of value) { const child = toRaw(row) as DocLike; if (isDocLike(child)) { - await this.#commitStagedInDoc(child, ipcApi, dbPath); + createdPaths.push( + ...(await this.#commitStagedInDoc(child, ipcApi, dbPath)) + ); } } } } + return createdPaths; } /** diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index a1ff8e678..93535035b 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -914,7 +914,7 @@ export class Doc extends Observable { async _insert() { this._setBaseMetaValues(); - await this.attachments.commitStagedBeforeDbWrite(); + const pathsCommittedBeforeDb = await this.attachments.commitStagedBeforeDbWrite(); await this._preSync(); await setName(this, this.fyo); @@ -923,6 +923,9 @@ export class Doc extends Observable { try { data = await this.fyo.db.insert(this.schemaName, validDict); } catch (err) { + await this.attachments.recoverAfterFailedDbWrite(pathsCommittedBeforeDb, { + failedDuringInsert: true, + }); throw await getDbSyncError(err as Error, this, this.fyo); } await this._syncValues(data); @@ -934,13 +937,16 @@ export class Doc extends Observable { async _update() { await this._validateDbNotModified(); this._updateModifiedMetaValues(); - await this.attachments.commitStagedBeforeDbWrite(); + const pathsCommittedBeforeDb = await this.attachments.commitStagedBeforeDbWrite(); await this._preSync(); const data = this.getValidDict(false, true); try { await this.fyo.db.update(this.schemaName, data); } catch (err) { + await this.attachments.recoverAfterFailedDbWrite(pathsCommittedBeforeDb, { + failedDuringInsert: false, + }); throw await getDbSyncError(err as Error, this, this.fyo); } await this._syncValues(data); @@ -969,54 +975,57 @@ export class Doc extends Observable { async sync(): Promise { this._syncing = true; - await this.trigger('beforeSync'); - let doc; - if (this.notInserted) { - doc = await this._insert(); - } else { - doc = await this._update(); - } - await this.attachments.flushPendingDeletesAfterSync(); - this._notInserted = false; - await this.trigger('afterSync'); - this.fyo.doc.observer.trigger(`sync:${this.schemaName}`, this.name); - - if (this._addDocToSyncQueue && !!this.shouldDocSyncToERPNext) { - const isSalesInvoice = this.schemaName === ModelNameEnum.SalesInvoice; - const hasERPSyncableItems = await this._hasERPSyncableItems(); - - if ( - hasERPSyncableItems && - (!(isSalesInvoice && this.isSyncedWithErp) || - (isSalesInvoice && !!this.isReturn)) - ) { - if (isSalesInvoice && !this.isReturn) { - await this.setAndSync('isSyncedWithErp', true); - } - - const isDocExistsInQueue = await this.fyo.db.getAll( - ModelNameEnum.ERPNextSyncQueue, - { - filters: { - referenceType: this.schemaName, - documentName: this.name as string, - }, + try { + await this.trigger('beforeSync'); + let doc; + if (this.notInserted) { + doc = await this._insert(); + } else { + doc = await this._update(); + } + await this.attachments.flushPendingDeletesAfterSync(); + this._notInserted = false; + await this.trigger('afterSync'); + this.fyo.doc.observer.trigger(`sync:${this.schemaName}`, this.name); + + if (this._addDocToSyncQueue && !!this.shouldDocSyncToERPNext) { + const isSalesInvoice = this.schemaName === ModelNameEnum.SalesInvoice; + const hasERPSyncableItems = await this._hasERPSyncableItems(); + + if ( + hasERPSyncableItems && + (!(isSalesInvoice && this.isSyncedWithErp) || + (isSalesInvoice && !!this.isReturn)) + ) { + if (isSalesInvoice && !this.isReturn) { + await this.setAndSync('isSyncedWithErp', true); } - ); - if (!isDocExistsInQueue.length) { - await this.fyo.doc - .getNewDoc(ModelNameEnum.ERPNextSyncQueue, { - referenceType: this.schemaName, - documentName: this.name, - }) - .sync(); + const isDocExistsInQueue = await this.fyo.db.getAll( + ModelNameEnum.ERPNextSyncQueue, + { + filters: { + referenceType: this.schemaName, + documentName: this.name as string, + }, + } + ); + + if (!isDocExistsInQueue.length) { + await this.fyo.doc + .getNewDoc(ModelNameEnum.ERPNextSyncQueue, { + referenceType: this.schemaName, + documentName: this.name, + }) + .sync(); + } } } - } - this._syncing = false; - return doc; + return doc; + } finally { + this._syncing = false; + } } async delete() { From 80620be54c20e55211e4626d0336b0be669a7047 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 09:35:43 +0400 Subject: [PATCH 09/22] refactor: return mime type from attachment read; centralize filename inference - Add utils/mimeType.ts with mimeTypeFromFilename. - ATTACHMENT_READ now returns type based on file name. - AttachmentManager duplicate remap prefers readRes.type and drops local guessing. --- fyo/model/AttachmentManager.ts | 25 +++++++++++-------------- main/registerIpcMainActionListeners.ts | 6 ++++-- utils/mimeType.ts | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 16 deletions(-) create mode 100644 utils/mimeType.ts diff --git a/fyo/model/AttachmentManager.ts b/fyo/model/AttachmentManager.ts index 3500fac9a..068fa4c55 100644 --- a/fyo/model/AttachmentManager.ts +++ b/fyo/model/AttachmentManager.ts @@ -6,6 +6,7 @@ import { encodeBooksStagedPath, isBooksStagedRef, } from 'utils/attachmentStagingRef'; +import { mimeTypeFromFilename } from 'utils/mimeType'; import { dataUrlFromBytes } from './attachmentEncoding'; type AttachmentStorageMode = 'database' | 'filesystem'; @@ -415,16 +416,6 @@ export class AttachmentManager { this.snapshotAfterLoadOrSync(); } - #guessMimeFromFilename(filename: string): string { - const lower = filename.toLowerCase(); - if (lower.endsWith('.png')) return 'image/png'; - if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; - if (lower.endsWith('.gif')) return 'image/gif'; - if (lower.endsWith('.webp')) return 'image/webp'; - if (lower.endsWith('.pdf')) return 'application/pdf'; - return 'application/octet-stream'; - } - #normalizeAttachmentRow(value: unknown): { name?: string; type?: string; @@ -500,6 +491,7 @@ export class AttachmentManager { success?: boolean; data?: Uint8Array; name?: string; + type?: string; }; if ( !readRes?.success || @@ -513,10 +505,11 @@ export class AttachmentManager { readRes.name || p.split(/[/\\]/).pop() || 'attachment'; - const mime = - (typeof v.type === 'string' && v.type.length > 0 + const mime = (readRes.type && readRes.type.length > 0 + ? readRes.type + : typeof v.type === 'string' && v.type.length > 0 ? v.type - : null) ?? this.#guessMimeFromFilename(baseName); + : mimeTypeFromFilename(baseName)); const stagePath = await this.#ipcStageSave( ipcApi, dbPath, @@ -548,6 +541,7 @@ export class AttachmentManager { success?: boolean; data?: Uint8Array; name?: string; + type?: string; }; if ( !readRes?.success || @@ -557,7 +551,10 @@ export class AttachmentManager { continue; } const baseName = readRes.name || relPath.split(/[/\\]/).pop() || 'image'; - const mime = this.#guessMimeFromFilename(baseName); + const mime = + (readRes.type && readRes.type.length > 0 + ? readRes.type + : mimeTypeFromFilename(baseName)); const stagePath = await this.#ipcStageSave( ipcApi, dbPath, diff --git a/main/registerIpcMainActionListeners.ts b/main/registerIpcMainActionListeners.ts index e87923a92..86769df5d 100644 --- a/main/registerIpcMainActionListeners.ts +++ b/main/registerIpcMainActionListeners.ts @@ -17,6 +17,7 @@ import { emitMainProcessError } from 'backend/helpers'; import { Main } from 'main'; import { DatabaseMethod } from 'utils/db/types'; import { IPC_ACTIONS, IPC_CHANNELS } from 'utils/messages'; +import { mimeTypeFromFilename } from 'utils/mimeType'; import { getUrlAndTokenString, sendError } from './contactMothership'; import { getLanguageMap } from './getLanguageMap'; import { getTemplates } from './getPrintTemplates'; @@ -560,10 +561,11 @@ export default function registerIpcMainActionListeners(main: Main) { ? relOrAbs : path.join(path.dirname(dbPath), relOrAbs); const buf = await fs.readFile(fullPath); + const name = path.basename(fullPath); return { success: true, - name: path.basename(fullPath), - type: undefined, + name, + type: mimeTypeFromFilename(name), data: new Uint8Array(buf), }; }); diff --git a/utils/mimeType.ts b/utils/mimeType.ts new file mode 100644 index 000000000..6f89597b4 --- /dev/null +++ b/utils/mimeType.ts @@ -0,0 +1,14 @@ +export function mimeTypeFromFilename(filename: string): string { + const lower = filename.toLowerCase(); + if (lower.endsWith('.png')) return 'image/png'; + if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'; + if (lower.endsWith('.gif')) return 'image/gif'; + if (lower.endsWith('.webp')) return 'image/webp'; + if (lower.endsWith('.svg')) return 'image/svg+xml'; + if (lower.endsWith('.pdf')) return 'application/pdf'; + if (lower.endsWith('.txt')) return 'text/plain'; + if (lower.endsWith('.csv')) return 'text/csv'; + if (lower.endsWith('.json')) return 'application/json'; + return 'application/octet-stream'; +} + From 1157b1b3c32f04b7f0f5415bbd347b0dc51a6bc3 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 09:59:25 +0400 Subject: [PATCH 10/22] refactor: rename AttachmentManager to DocAttachmentManager --- .../{AttachmentManager.ts => DocAttachmentManager.ts} | 2 +- fyo/model/doc.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) rename fyo/model/{AttachmentManager.ts => DocAttachmentManager.ts} (99%) diff --git a/fyo/model/AttachmentManager.ts b/fyo/model/DocAttachmentManager.ts similarity index 99% rename from fyo/model/AttachmentManager.ts rename to fyo/model/DocAttachmentManager.ts index 068fa4c55..dacffa1f7 100644 --- a/fyo/model/AttachmentManager.ts +++ b/fyo/model/DocAttachmentManager.ts @@ -68,7 +68,7 @@ function getFilesystemPathFromAttachmentValue(value: unknown): string | null { return null; } -export class AttachmentManager { +export class DocAttachmentManager { readonly #doc: DocLike; readonly #attachImagePrefix: string; diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 93535035b..449dd97d9 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -17,7 +17,7 @@ import { getIsNullOrUndef, getMapFromList, getRandomString } from 'utils'; import { markRaw, reactive } from 'vue'; import { isPesa } from '../utils/index'; import { getDbSyncError } from './errorHelpers'; -import { AttachmentManager } from './AttachmentManager'; +import { DocAttachmentManager } from './DocAttachmentManager'; import { areDocValuesEqual, getFormulaSequence, @@ -80,7 +80,7 @@ export class Doc extends Observable { _syncing = false; _addDocToSyncQueue = true; - attachments: AttachmentManager; + attachments: DocAttachmentManager; constructor( schema: Schema, @@ -98,7 +98,9 @@ export class Doc extends Observable { } this._setDefaults(); - this.attachments = markRaw(new AttachmentManager(this, ATTACH_IMAGE_FILE_REF_PREFIX)); + this.attachments = markRaw( + new DocAttachmentManager(this, ATTACH_IMAGE_FILE_REF_PREFIX) + ); this._setValuesWithoutChecks(data, convertToDocValue); return reactive(this) as Doc; } From fde89a066dd2983e0df5792fa73297a1d536c7d6 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 10:10:02 +0400 Subject: [PATCH 11/22] fix: make books-staged refs always base64 or fail loudly encodeBooksStagedPath now throws when neither btoa nor Buffer is available, preventing non-base64 staged refs that would break decodeBooksStagedPath. Also tighten Buffer typing to satisfy strict lint rules. --- utils/attachmentStagingRef.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/utils/attachmentStagingRef.ts b/utils/attachmentStagingRef.ts index 902d95a45..4c4616d7f 100644 --- a/utils/attachmentStagingRef.ts +++ b/utils/attachmentStagingRef.ts @@ -15,12 +15,15 @@ export function encodeBooksStagedPath(absolutePath: string): string { if (typeof btoa === 'function') { return BOOKS_STAGED_PREFIX + btoa(bin); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const B = (globalThis as any)?.Buffer; + const B = (globalThis as { Buffer?: typeof Buffer | undefined })?.Buffer; if (B) { - return BOOKS_STAGED_PREFIX + B.from(absolutePath, 'utf8').toString('base64'); + return ( + BOOKS_STAGED_PREFIX + B.from(absolutePath, 'utf8').toString('base64') + ); } - return BOOKS_STAGED_PREFIX + bin; + throw new Error( + '[books] encodeBooksStagedPath: no base64 encoder available (missing btoa and Buffer)' + ); } export function decodeBooksStagedPath(ref: string): string | null { @@ -37,8 +40,7 @@ export function decodeBooksStagedPath(ref: string): string | null { } catch { return null; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const B = (globalThis as any)?.Buffer; + const B = (globalThis as { Buffer?: typeof Buffer | undefined })?.Buffer; if (B) { try { return B.from(b64, 'base64').toString('utf8'); From c0aa4b8c8723e9262c3236791e91a5190b683f38 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 10:14:09 +0400 Subject: [PATCH 12/22] refactor: replace escape/unescape in books-staged encoding with TextEncoder/TextDecoder - Encode UTF-8 bytes then base64 (btoa/atob) or Buffer. - Decode base64 back to UTF-8 without deprecated escape/unescape. - Keeps staging refs guaranteed base64 and round-trippable. --- utils/attachmentStagingRef.ts | 55 +++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/utils/attachmentStagingRef.ts b/utils/attachmentStagingRef.ts index 4c4616d7f..20a8f9311 100644 --- a/utils/attachmentStagingRef.ts +++ b/utils/attachmentStagingRef.ts @@ -9,16 +9,61 @@ export function isBooksStagedRef(value: string | null | undefined): boolean { ); } +function textEncodeUtf8(value: string): Uint8Array { + const E = (globalThis as { TextEncoder?: typeof TextEncoder | undefined }) + ?.TextEncoder; + if (E) { + return new E().encode(value); + } + const B = (globalThis as { Buffer?: typeof Buffer | undefined })?.Buffer; + if (B) { + return new Uint8Array(B.from(value, 'utf8')); + } + throw new Error('[books] UTF-8 encoder unavailable (missing TextEncoder and Buffer)'); +} + +function textDecodeUtf8(bytes: Uint8Array): string { + const D = (globalThis as { TextDecoder?: typeof TextDecoder | undefined }) + ?.TextDecoder; + if (D) { + return new D('utf-8').decode(bytes); + } + const B = (globalThis as { Buffer?: typeof Buffer | undefined })?.Buffer; + if (B) { + return B.from(bytes).toString('utf8'); + } + throw new Error('[books] UTF-8 decoder unavailable (missing TextDecoder and Buffer)'); +} + +function bytesToBinaryString(bytes: Uint8Array): string { + let out = ''; + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + out += String.fromCharCode(...chunk); + } + return out; +} + +function binaryStringToBytes(bin: string): Uint8Array { + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { + out[i] = bin.charCodeAt(i) & 0xff; + } + return out; +} + export function encodeBooksStagedPath(absolutePath: string): string { - const bin = unescape(encodeURIComponent(absolutePath)); // eslint-disable-next-line no-undef if (typeof btoa === 'function') { - return BOOKS_STAGED_PREFIX + btoa(bin); + const bytes = textEncodeUtf8(absolutePath); + return BOOKS_STAGED_PREFIX + btoa(bytesToBinaryString(bytes)); } const B = (globalThis as { Buffer?: typeof Buffer | undefined })?.Buffer; if (B) { return ( - BOOKS_STAGED_PREFIX + B.from(absolutePath, 'utf8').toString('base64') + BOOKS_STAGED_PREFIX + + B.from(textEncodeUtf8(absolutePath)).toString('base64') ); } throw new Error( @@ -35,7 +80,7 @@ export function decodeBooksStagedPath(ref: string): string | null { // eslint-disable-next-line no-undef if (typeof atob === 'function') { const bin = atob(b64); - return decodeURIComponent(escape(bin)); + return textDecodeUtf8(binaryStringToBytes(bin)); } } catch { return null; @@ -43,7 +88,7 @@ export function decodeBooksStagedPath(ref: string): string | null { const B = (globalThis as { Buffer?: typeof Buffer | undefined })?.Buffer; if (B) { try { - return B.from(b64, 'base64').toString('utf8'); + return textDecodeUtf8(new Uint8Array(B.from(b64, 'base64'))); } catch { return null; } From 19dc8d822f180653ce823f36b1e4ccc2b9045dff Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 10:17:54 +0400 Subject: [PATCH 13/22] refactor: unify AttachImage src resolver IPC read to data URL - Add readAttachmentAsDataURL helper (ipc.attachments.read -> data URL). - Use it for both books-staged and books-file refs. - Fix misleading comment for staged refs. --- src/utils/attachments.ts | 61 +++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index 386f7a081..ea60f82b2 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -73,6 +73,30 @@ export function makeAttachImageFileRef(path: string) { return `${ATTACH_IMAGE_FILE_REF_PREFIX}${path}`; } +async function readAttachmentAsDataURL(params: { + dbPath: string; + path: string; + typeHint?: string; +}): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = typeof ipc !== 'undefined' ? (ipc as any) : undefined; + if (!ipcApi?.desktop || typeof ipcApi.attachments?.read !== 'function') { + return null; + } + + const res = (await ipcApi.attachments.read({ + dbPath: params.dbPath, + path: params.path, + })) as { success?: boolean; data?: Uint8Array; type?: string }; + + if (!res?.success || !(res.data instanceof Uint8Array)) { + return null; + } + + const type = params.typeHint || res.type || 'application/octet-stream'; + return getDataURL(type, Uint8Array.from(res.data)); +} + /** * Resolve AttachImage stored value to a data URL for . * @@ -89,7 +113,7 @@ export async function resolveAttachImageSrc( return null; } - // Legacy / default: already a data URL. + // Staged pre-save image: `books-staged:`. if (isBooksStagedRef(value)) { const abs = decodeBooksStagedPath(value); if (!abs) { @@ -99,22 +123,7 @@ export async function resolveAttachImageSrc( if (!dbPath) { return null; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ipcApi = typeof ipc !== 'undefined' ? (ipc as any) : undefined; - if (!ipcApi?.desktop || typeof ipcApi.attachments?.read !== 'function') { - return null; - } - const res = (await ipcApi.attachments.read({ - dbPath, - path: abs, - })) as { success?: boolean; data?: Uint8Array }; - - if (!res?.success || !res.data) { - return null; - } - - const type = typeHint || 'application/octet-stream'; - return getDataURL(type, Uint8Array.from(res.data)); + return await readAttachmentAsDataURL({ dbPath, path: abs, typeHint }); } if (!isAttachImageFileRef(value)) { @@ -126,21 +135,9 @@ export async function resolveAttachImageSrc( return null; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ipcApi = typeof ipc !== 'undefined' ? (ipc as any) : undefined; - if (!ipcApi?.desktop || typeof ipcApi.attachments?.read !== 'function') { - return null; - } - - const res = (await ipcApi.attachments.read({ + return await readAttachmentAsDataURL({ dbPath, path: getAttachImageFileRefPath(value), - })) as { success?: boolean; data?: Uint8Array }; - - if (!res?.success || !res.data) { - return null; - } - - const type = typeHint || 'application/octet-stream'; - return getDataURL(type, Uint8Array.from(res.data)); + typeHint, + }); } From dfcc96694ba2745e47dbdffa7903cebd6c05ce58 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 10:23:51 +0400 Subject: [PATCH 14/22] fix: avoid silent attachment data URL encoding failures - attachmentEncoding: return null (not empty base64) when no encoder exists; log btoa and Buffer encoding errors with context. - DocAttachmentManager: handle null dataUrlFromBytes by falling back to the original value/null instead of producing empty data URLs. --- fyo/model/DocAttachmentManager.ts | 8 ++++++-- fyo/model/attachmentEncoding.ts | 27 ++++++++++++++++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/fyo/model/DocAttachmentManager.ts b/fyo/model/DocAttachmentManager.ts index dacffa1f7..ec36ab8fa 100644 --- a/fyo/model/DocAttachmentManager.ts +++ b/fyo/model/DocAttachmentManager.ts @@ -774,7 +774,11 @@ export class DocAttachmentManager { } } - return { name: v.name, type: v.type, data: dataUrlFromBytes(v.type, v.bytes) }; + const dataUrl = dataUrlFromBytes(v.type, v.bytes); + if (!dataUrl) { + return value; + } + return { name: v.name, type: v.type, data: dataUrl }; } return value; @@ -822,7 +826,7 @@ export class DocAttachmentManager { } } - return dataUrlFromBytes(v.type, v.data); + return dataUrlFromBytes(v.type, v.data) ?? null; } return value; diff --git a/fyo/model/attachmentEncoding.ts b/fyo/model/attachmentEncoding.ts index 0f8687800..53da41bf7 100644 --- a/fyo/model/attachmentEncoding.ts +++ b/fyo/model/attachmentEncoding.ts @@ -1,4 +1,4 @@ -function uint8ArrayToBase64(bytes: Uint8Array) { +function uint8ArrayToBase64(bytes: Uint8Array): string | null { try { // Browser/Electron renderer // eslint-disable-next-line no-undef @@ -11,19 +11,32 @@ function uint8ArrayToBase64(bytes: Uint8Array) { // eslint-disable-next-line no-undef return btoa(binary); } - } catch {} + } catch (err) { + // best-effort; fallback to Buffer path below + console.warn( + '[books] attachment encoding failed using btoa/String.fromCharCode', + err + ); + } // Fallback (Node-like) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const B = (globalThis as any)?.Buffer; + const B = (globalThis as { Buffer?: typeof Buffer | undefined })?.Buffer; if (B) { - return B.from(bytes).toString('base64'); + try { + return B.from(bytes).toString('base64'); + } catch (err) { + console.warn('[books] attachment encoding failed using Buffer', err); + return null; + } } - return ''; + return null; } -export function dataUrlFromBytes(type: string, bytes: Uint8Array) { +export function dataUrlFromBytes(type: string, bytes: Uint8Array): string | null { const base64 = uint8ArrayToBase64(bytes); + if (!base64) { + return null; + } return `data:${type || 'application/octet-stream'};base64,${base64}`; } From eb44584d82e0a9e35a1690a02830f24202133d82 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 10:25:56 +0400 Subject: [PATCH 15/22] chore: log attachment cleanup failures on doc delete Keep cleanup best-effort, but record errors from attachments.cleanupBeforeDelete() so failures are visible in logs. --- fyo/model/doc.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 449dd97d9..6e32d66ac 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -1043,7 +1043,12 @@ export class Doc extends Observable { // Best-effort cleanup for filesystem-backed attachments/images. try { await this.attachments.cleanupBeforeDelete(); - } catch {} + } catch (err) { + console.error( + `[books] best-effort attachment cleanup failed before delete (${this.schemaName} ${this.name ?? ''})`, + err + ); + } await this.fyo.db.delete(this.schemaName, this.name!); await this.trigger('afterDelete'); From 5027ff8d894e12f9ba9f28545399e50c74e263f8 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 10:38:20 +0400 Subject: [PATCH 16/22] chore: dedupe isDocLike guard and log stageCommit failures - Reuse a single isDocLike type guard across DocAttachmentManager. - Log stageCommit failures for Attachment/AttachImage to avoid silent partial commit. --- fyo/model/DocAttachmentManager.ts | 44 +++++++++++-------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/fyo/model/DocAttachmentManager.ts b/fyo/model/DocAttachmentManager.ts index ec36ab8fa..5d14fceb0 100644 --- a/fyo/model/DocAttachmentManager.ts +++ b/fyo/model/DocAttachmentManager.ts @@ -25,6 +25,12 @@ type DocLike = { [key: string]: any; }; +function isDocLike(v: unknown): v is DocLike { + if (!v || typeof v !== 'object') return false; + const anyV = v as any; + return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; +} + function getStorageMode(doc: DocLike): AttachmentStorageMode { return ( ((doc.fyo.singles.SystemSettings as any)?.attachmentStorage as @@ -229,12 +235,6 @@ export class DocAttachmentManager { } #clearAttachmentFieldsUsingPaths(paths: Set) { - const isDocLike = (v: unknown): v is DocLike => { - if (!v || typeof v !== 'object') return false; - const anyV = v as any; - return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; - }; - const clearIn = (d: DocLike) => { for (const field of d.schema.fields) { if (field.meta) continue; @@ -312,11 +312,6 @@ export class DocAttachmentManager { dbPath: string ): Promise { const createdPaths: string[] = []; - const isDocLike = (v: unknown): v is DocLike => { - if (!v || typeof v !== 'object') return false; - const anyV = v as any; - return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; - }; for (const field of doc.schema.fields) { if (field.meta) continue; @@ -343,6 +338,11 @@ export class DocAttachmentManager { path: newPath, }; createdPaths.push(newPath); + } else { + console.error( + `[books] stageCommit failed for Attachment field '${fieldname}'`, + { stagePath: abs, response: res } + ); } } } @@ -364,6 +364,11 @@ export class DocAttachmentManager { if (newPath) { doc[fieldname] = `${this.#attachImagePrefix}${newPath}`; createdPaths.push(newPath); + } else { + console.error( + `[books] stageCommit failed for AttachImage field '${fieldname}'`, + { stagePath: abs, response: res } + ); } } } @@ -468,12 +473,6 @@ export class DocAttachmentManager { ipcApi: any, dbPath: string ) { - const isDocLike = (v: unknown): v is DocLike => { - if (!v || typeof v !== 'object') return false; - const anyV = v as any; - return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; - }; - for (const field of doc.schema.fields) { if (field.meta) continue; const fieldname = field.fieldname; @@ -580,11 +579,6 @@ export class DocAttachmentManager { #collectStagedAbsolutePaths(d: DocLike): string[] { const out: string[] = []; - const isDocLike = (v: unknown): v is DocLike => { - if (!v || typeof v !== 'object') return false; - const anyV = v as any; - return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; - }; const scan = (doc: DocLike) => { for (const field of doc.schema.fields) { @@ -835,12 +829,6 @@ export class DocAttachmentManager { collectFilesystemRefs(): Set { const refs = new Set(); - const isDocLike = (v: unknown): v is DocLike => { - if (!v || typeof v !== 'object') return false; - const anyV = v as any; - return typeof anyV.get === 'function' && anyV.schema && anyV.schema.fields; - }; - const scan = (d: DocLike) => { for (const field of d.schema.fields) { if (field.meta) continue; From b0967674e8ce1cb20c6b117469ecf644bde8886c Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 10:40:46 +0400 Subject: [PATCH 17/22] chore: bump version to 0.48.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6eaf362e3..0d498c802 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rukn-books", - "version": "0.47.1", + "version": "0.48.0", "description": "Simple book-keeping app for everyone", "author": { "name": "Rukn Software", From 6e8a52ab2bed65577f97f533c431cd55a0cc2ef9 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 10:59:01 +0400 Subject: [PATCH 18/22] fix: commit staged attachments only immediately before DB write --- fyo/model/doc.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 6e32d66ac..b4a98fe05 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -916,10 +916,12 @@ export class Doc extends Observable { async _insert() { this._setBaseMetaValues(); - const pathsCommittedBeforeDb = await this.attachments.commitStagedBeforeDbWrite(); await this._preSync(); await setName(this, this.fyo); + const pathsCommittedBeforeDb = + await this.attachments.commitStagedBeforeDbWrite(); + const validDict = this.getValidDict(false, true); let data: DocValueMap; try { @@ -939,9 +941,11 @@ export class Doc extends Observable { async _update() { await this._validateDbNotModified(); this._updateModifiedMetaValues(); - const pathsCommittedBeforeDb = await this.attachments.commitStagedBeforeDbWrite(); await this._preSync(); + const pathsCommittedBeforeDb = + await this.attachments.commitStagedBeforeDbWrite(); + const data = this.getValidDict(false, true); try { await this.fyo.db.update(this.schemaName, data); From b264519e4b9ebccdf58358af14da8c161c3a7246 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 11:01:37 +0400 Subject: [PATCH 19/22] fix: delete DB row before best-effort attachment filesystem cleanup - Capture committed/staged attachment refs before DB delete. - Delete DB row first, then cleanup captured refs best-effort (log failures). - Prevent cleanup errors from blocking deletion. --- fyo/model/DocAttachmentManager.ts | 43 +++++++++++++++++++++++++++++++ fyo/model/doc.ts | 9 ++++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/fyo/model/DocAttachmentManager.ts b/fyo/model/DocAttachmentManager.ts index 5d14fceb0..b6449bca4 100644 --- a/fyo/model/DocAttachmentManager.ts +++ b/fyo/model/DocAttachmentManager.ts @@ -168,6 +168,49 @@ export class DocAttachmentManager { } } + extractRefsForDelete(): { committed: string[]; stagedAbs: string[] } { + const committed = Array.from(this.collectFilesystemRefs()); + const stagedAbs = this.#collectStagedAbsolutePaths(this.#doc); + return { committed, stagedAbs }; + } + + async cleanupCapturedRefsAfterDelete(refs: { + committed: string[]; + stagedAbs: string[]; + }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ipcApi = (globalThis as any)?.ipc; + const dbPath = (this.#doc.fyo.db as any)?.dbPath as string | undefined; + if (!this.#doc.fyo.isElectron || !dbPath) return; + + if (ipcApi?.desktop && typeof ipcApi.attachments?.delete === 'function') { + await Promise.all( + Array.from(new Set(refs.committed)).map(async (p) => { + try { + await ipcApi.attachments.delete({ dbPath, path: p }); + } catch { + // best-effort + } + }) + ); + } + + if ( + ipcApi?.desktop && + typeof ipcApi.attachments?.stageDelete === 'function' + ) { + await Promise.all( + Array.from(new Set(refs.stagedAbs)).map(async (abs) => { + try { + await ipcApi.attachments.stageDelete({ stagePath: abs }); + } catch { + // best-effort + } + }) + ); + } + } + /** * Before DB insert/update: move staged temp files into final attachments folder * and replace `books-staged:` tokens with committed paths. diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index b4a98fe05..9bf667644 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -1044,16 +1044,17 @@ export class Doc extends Observable { } await this.trigger('beforeDelete'); - // Best-effort cleanup for filesystem-backed attachments/images. + const refs = this.attachments.extractRefsForDelete(); + await this.fyo.db.delete(this.schemaName, this.name!); + // Best-effort cleanup for filesystem-backed attachments/images (after DB delete). try { - await this.attachments.cleanupBeforeDelete(); + await this.attachments.cleanupCapturedRefsAfterDelete(refs); } catch (err) { console.error( - `[books] best-effort attachment cleanup failed before delete (${this.schemaName} ${this.name ?? ''})`, + `[books] best-effort attachment cleanup failed after delete (${this.schemaName} ${this.name ?? ''})`, err ); } - await this.fyo.db.delete(this.schemaName, this.name!); await this.trigger('afterDelete'); this.fyo.telemetry.log(Verb.Deleted, this.schemaName); From 7de025eb19018d562d6f344935de71d4b712a2c9 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 11:06:52 +0400 Subject: [PATCH 20/22] fix: abort and rollback stageCommit on partial failure - Defer doc field updates until all stageCommit calls succeed. - If any stageCommit fails, delete already-committed paths (best-effort) and throw to abort save, preventing mixed staged/committed refs. --- fyo/model/DocAttachmentManager.ts | 84 ++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/fyo/model/DocAttachmentManager.ts b/fyo/model/DocAttachmentManager.ts index b6449bca4..71676e9ae 100644 --- a/fyo/model/DocAttachmentManager.ts +++ b/fyo/model/DocAttachmentManager.ts @@ -229,7 +229,15 @@ export class DocAttachmentManager { return []; } - return await this.#commitStagedInDoc(this.#doc, ipcApi, dbPath); + const { createdPaths, assignments } = await this.#commitStagedInDoc( + this.#doc, + ipcApi, + dbPath + ); + for (const apply of assignments) { + apply(); + } + return createdPaths; } /** @@ -353,8 +361,40 @@ export class DocAttachmentManager { // eslint-disable-next-line @typescript-eslint/no-explicit-any ipcApi: any, dbPath: string - ): Promise { + ): Promise<{ createdPaths: string[]; assignments: Array<() => void> }> { const createdPaths: string[] = []; + const assignments: Array<() => void> = []; + + const rollbackAndThrow = async (context: { + fieldname: string; + stagePath: string; + response: unknown; + }) => { + console.error('[books] stageCommit failed; rolling back committed files', { + ...context, + createdPaths, + }); + + if (ipcApi?.desktop && typeof ipcApi.attachments?.delete === 'function') { + await Promise.all( + createdPaths.map(async (p) => { + try { + await ipcApi.attachments.delete({ dbPath, path: p }); + } catch (err) { + console.error('[books] rollback delete failed', { path: p, err }); + } + }) + ); + } else { + console.error( + '[books] rollback unavailable (ipc.attachments.delete missing)' + ); + } + + throw new Error( + `[books] stageCommit failed for field '${context.fieldname}', aborting save` + ); + }; for (const field of doc.schema.fields) { if (field.meta) continue; @@ -376,16 +416,19 @@ export class DocAttachmentManager { }; const newPath = res?.success ? res?.attachment?.path : undefined; if (newPath) { - doc[fieldname] = { - ...v, - path: newPath, - }; + assignments.push(() => { + doc[fieldname] = { + ...v, + path: newPath, + }; + }); createdPaths.push(newPath); } else { - console.error( - `[books] stageCommit failed for Attachment field '${fieldname}'`, - { stagePath: abs, response: res } - ); + await rollbackAndThrow({ + fieldname, + stagePath: abs, + response: res, + }); } } } @@ -405,13 +448,16 @@ export class DocAttachmentManager { }; const newPath = res?.success ? res?.attachment?.path : undefined; if (newPath) { - doc[fieldname] = `${this.#attachImagePrefix}${newPath}`; + assignments.push(() => { + doc[fieldname] = `${this.#attachImagePrefix}${newPath}`; + }); createdPaths.push(newPath); } else { - console.error( - `[books] stageCommit failed for AttachImage field '${fieldname}'`, - { stagePath: abs, response: res } - ); + await rollbackAndThrow({ + fieldname, + stagePath: abs, + response: res, + }); } } } @@ -422,14 +468,14 @@ export class DocAttachmentManager { for (const row of value) { const child = toRaw(row) as DocLike; if (isDocLike(child)) { - createdPaths.push( - ...(await this.#commitStagedInDoc(child, ipcApi, dbPath)) - ); + const childRes = await this.#commitStagedInDoc(child, ipcApi, dbPath); + createdPaths.push(...childRes.createdPaths); + assignments.push(...childRes.assignments); } } } } - return createdPaths; + return { createdPaths, assignments }; } /** From cdd04cbc614088ba747b72c7e478512ee77ee504 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 11:11:35 +0400 Subject: [PATCH 21/22] fix: abort duplicateForEdit when attachment remap fails - If reading or stageSave fails during duplicate remap, clear the duplicated field and throw so the copy cannot keep a reference to the source file. --- fyo/model/DocAttachmentManager.ts | 63 ++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/fyo/model/DocAttachmentManager.ts b/fyo/model/DocAttachmentManager.ts index 71676e9ae..d0a61b7a0 100644 --- a/fyo/model/DocAttachmentManager.ts +++ b/fyo/model/DocAttachmentManager.ts @@ -562,6 +562,16 @@ export class DocAttachmentManager { ipcApi: any, dbPath: string ) { + const clearAndThrow = ( + fieldname: string, + message: string, + extra?: unknown + ): never => { + doc[fieldname] = null; + console.error(`[books] duplicate attachment remap failed for '${fieldname}'`, extra); + throw new Error(message); + }; + for (const field of doc.schema.fields) { if (field.meta) continue; const fieldname = field.fieldname; @@ -581,13 +591,15 @@ export class DocAttachmentManager { name?: string; type?: string; }; - if ( - !readRes?.success || - !(readRes.data instanceof Uint8Array) || - readRes.data.length === 0 - ) { - continue; + const data = readRes?.data; + if (!readRes?.success || !(data instanceof Uint8Array) || data.length === 0) { + clearAndThrow( + fieldname, + `[books] Failed to duplicate attachment '${fieldname}': could not read source file`, + { sourcePath: p, response: readRes } + ); } + const bytes = data as Uint8Array; const baseName = v.name || readRes.name || @@ -603,13 +615,20 @@ export class DocAttachmentManager { dbPath, baseName, mime, - readRes.data + bytes ); - if (!stagePath) continue; + if (stagePath == null) { + clearAndThrow( + fieldname, + `[books] Failed to duplicate attachment '${fieldname}': could not stage copied bytes`, + { sourcePath: p, name: baseName, type: mime } + ); + } + const stagePathStr = stagePath as string; doc[fieldname] = { name: baseName, type: mime, - path: encodeBooksStagedPath(stagePath), + path: encodeBooksStagedPath(stagePathStr), }; continue; } @@ -631,13 +650,15 @@ export class DocAttachmentManager { name?: string; type?: string; }; - if ( - !readRes?.success || - !(readRes.data instanceof Uint8Array) || - readRes.data.length === 0 - ) { - continue; + const data = readRes?.data; + if (!readRes?.success || !(data instanceof Uint8Array) || data.length === 0) { + clearAndThrow( + fieldname, + `[books] Failed to duplicate AttachImage '${fieldname}': could not read source file`, + { sourcePath: relPath, response: readRes } + ); } + const bytes = data as Uint8Array; const baseName = readRes.name || relPath.split(/[/\\]/).pop() || 'image'; const mime = (readRes.type && readRes.type.length > 0 @@ -648,10 +669,16 @@ export class DocAttachmentManager { dbPath, baseName, mime, - readRes.data + bytes ); - if (!stagePath) continue; - doc[fieldname] = encodeBooksStagedPath(stagePath); + if (stagePath == null) { + clearAndThrow( + fieldname, + `[books] Failed to duplicate AttachImage '${fieldname}': could not stage copied bytes`, + { sourcePath: relPath, name: baseName, type: mime } + ); + } + doc[fieldname] = encodeBooksStagedPath(stagePath as string); continue; } From a1f167dde095868b57db845ede41fbf663ea6f34 Mon Sep 17 00:00:00 2001 From: Alaa Alsalehi Date: Tue, 5 May 2026 11:14:15 +0400 Subject: [PATCH 22/22] fix: reject stage root path in stageCommit/stageDelete validation --- main/registerIpcMainActionListeners.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/main/registerIpcMainActionListeners.ts b/main/registerIpcMainActionListeners.ts index 86769df5d..47620f23a 100644 --- a/main/registerIpcMainActionListeners.ts +++ b/main/registerIpcMainActionListeners.ts @@ -65,9 +65,7 @@ function isPathInsideStageRoot(stagePath: string): boolean { const resolved = path.resolve(stagePath); const prefix = path.join(tmpdir(), 'rukn-books', 'attachments-stage'); const normalizedPrefix = path.resolve(prefix); - return ( - resolved === normalizedPrefix || resolved.startsWith(normalizedPrefix + path.sep) - ); + return resolved.startsWith(normalizedPrefix + path.sep); } type ParsedAttachmentParams =