diff --git a/fyo/model/DocAttachmentManager.ts b/fyo/model/DocAttachmentManager.ts new file mode 100644 index 000000000..d0a61b7a0 --- /dev/null +++ b/fyo/model/DocAttachmentManager.ts @@ -0,0 +1,986 @@ +import type { Field } from 'schemas/types'; +import { FieldTypeEnum } from 'schemas/types'; +import { toRaw } from 'vue'; +import { + decodeBooksStagedPath, + encodeBooksStagedPath, + isBooksStagedRef, +} from 'utils/attachmentStagingRef'; +import { mimeTypeFromFilename } from 'utils/mimeType'; +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 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 + | 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; + 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 (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; + } + } + return null; +} + +export class DocAttachmentManager { + readonly #doc: DocLike; + readonly #attachImagePrefix: string; + + // Snapshot at last successful load/sync (committed paths only). + #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; + + 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 + } + }) + ); + } + } + + 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. + * @returns Relative attachment paths created by this pass (for rollback if DB write fails). + */ + 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; + if ( + !this.#doc.fyo.isElectron || + !dbPath || + !ipcApi?.desktop || + typeof ipcApi.attachments?.stageCommit !== 'function' + ) { + return []; + } + + const { createdPaths, assignments } = await this.#commitStagedInDoc( + this.#doc, + ipcApi, + dbPath + ); + for (const apply of assignments) { + apply(); + } + return createdPaths; + } + + /** + * 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 + } + }) + ); + } + + #clearAttachmentFieldsUsingPaths(paths: Set) { + 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); + } + + /** + * 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 + ): 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; + 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) { + assignments.push(() => { + doc[fieldname] = { + ...v, + path: newPath, + }; + }); + createdPaths.push(newPath); + } else { + await rollbackAndThrow({ + fieldname, + stagePath: abs, + response: res, + }); + } + } + } + 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) { + assignments.push(() => { + doc[fieldname] = `${this.#attachImagePrefix}${newPath}`; + }); + createdPaths.push(newPath); + } else { + await rollbackAndThrow({ + fieldname, + stagePath: abs, + response: res, + }); + } + } + } + continue; + } + + if (field.fieldtype === FieldTypeEnum.Table && Array.isArray(value)) { + for (const row of value) { + const child = toRaw(row) as DocLike; + if (isDocLike(child)) { + const childRes = await this.#commitStagedInDoc(child, ipcApi, dbPath); + createdPaths.push(...childRes.createdPaths); + assignments.push(...childRes.assignments); + } + } + } + } + return { createdPaths, assignments }; + } + + /** + * 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(); + } + + #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 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; + 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; + type?: string; + }; + 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 || + p.split(/[/\\]/).pop() || + 'attachment'; + const mime = (readRes.type && readRes.type.length > 0 + ? readRes.type + : typeof v.type === 'string' && v.type.length > 0 + ? v.type + : mimeTypeFromFilename(baseName)); + const stagePath = await this.#ipcStageSave( + ipcApi, + dbPath, + baseName, + mime, + bytes + ); + 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(stagePathStr), + }; + 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; + type?: string; + }; + 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 + ? readRes.type + : mimeTypeFromFilename(baseName)); + const stagePath = await this.#ipcStageSave( + ipcApi, + dbPath, + baseName, + mime, + bytes + ); + 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; + } + + 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 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 #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); + + // 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'; + + 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 + | 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 (canStage) { + 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, + path: encodeBooksStagedPath(stagePath), + }; + } + } + + if (canUseFs) { + const newPath = await this.#ipcFinalSave( + ipcApi, + dbPath!, + v.name, + v.type, + v.bytes + ); + if (newPath) { + await this.#reconcilePreviousAttachmentPath(prevPath); + return { name: v.name, type: v.type, path: newPath }; + } + } + + const dataUrl = dataUrlFromBytes(v.type, v.bytes); + if (!dataUrl) { + return value; + } + return { name: v.name, type: v.type, data: dataUrl }; + } + + 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 prevStr = typeof prev === 'string' ? prev : null; + const imageName = v.name || 'image'; + + if (canStage) { + const stagePath = await this.#ipcStageSave( + ipcApi, + dbPath!, + imageName, + v.type, + v.data + ); + if (stagePath) { + await this.#reconcilePreviousAttachImage(prevStr); + return encodeBooksStagedPath(stagePath); + } + } + + if (canUseFs) { + const newPath = await this.#ipcFinalSave( + ipcApi, + dbPath!, + imageName, + v.type, + v.data + ); + if (newPath) { + await this.#reconcilePreviousAttachImage(prevStr); + return `${this.#attachImagePrefix}${newPath}`; + } + } + + return dataUrlFromBytes(v.type, v.data) ?? null; + } + + return value; + } + + collectFilesystemRefs(): Set { + const refs = new Set(); + + 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' && + !isBooksStagedRef(v) && + 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..53da41bf7 --- /dev/null +++ b/fyo/model/attachmentEncoding.ts @@ -0,0 +1,42 @@ +function uint8ArrayToBase64(bytes: Uint8Array): string | null { + 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 (err) { + // best-effort; fallback to Buffer path below + console.warn( + '[books] attachment encoding failed using btoa/String.fromCharCode', + err + ); + } + + // Fallback (Node-like) + const B = (globalThis as { Buffer?: typeof Buffer | undefined })?.Buffer; + if (B) { + try { + return B.from(bytes).toString('base64'); + } catch (err) { + console.warn('[books] attachment encoding failed using Buffer', err); + return null; + } + } + return null; +} + +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}`; +} + diff --git a/fyo/model/doc.ts b/fyo/model/doc.ts index 57df2cd58..9bf667644 100644 --- a/fyo/model/doc.ts +++ b/fyo/model/doc.ts @@ -17,6 +17,7 @@ import { getIsNullOrUndef, getMapFromList, getRandomString } from 'utils'; import { markRaw, reactive } from 'vue'; import { isPesa } from '../utils/index'; import { getDbSyncError } from './errorHelpers'; +import { DocAttachmentManager } from './DocAttachmentManager'; import { areDocValuesEqual, getFormulaSequence, @@ -56,35 +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}`; -} - export class Doc extends Observable { /* eslint-disable @typescript-eslint/no-floating-promises */ name?: string; @@ -108,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: DocAttachmentManager; constructor( schema: Schema, @@ -132,6 +98,9 @@ export class Doc extends Observable { } this._setDefaults(); + this.attachments = markRaw( + new DocAttachmentManager(this, ATTACH_IMAGE_FILE_REF_PREFIX) + ); this._setValuesWithoutChecks(data, convertToDocValue); return reactive(this) as Doc; } @@ -379,7 +348,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; @@ -397,106 +366,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) { @@ -858,11 +727,14 @@ 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(); this._dirty = false; - this._fsFileRefSnapshot = this._collectFilesystemFileRefs(); + this.attachments.snapshotAfterLoadOrSync(); this.trigger('change', { doc: this, }); @@ -1039,91 +911,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 v = value as undefined | null | { path?: string }; - if (v?.path && typeof v.path === 'string') { - refs.add(v.path); - } - 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) { - if (row instanceof Doc) { - scan(row); - } - } - } - } - } - }; - - scan(this); - 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() { @@ -1131,11 +919,17 @@ export class Doc extends Observable { await this._preSync(); await setName(this, this.fyo); + const pathsCommittedBeforeDb = + await this.attachments.commitStagedBeforeDbWrite(); + const validDict = this.getValidDict(false, true); let data: DocValueMap; 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); @@ -1149,10 +943,16 @@ export class Doc extends Observable { this._updateModifiedMetaValues(); await this._preSync(); + const pathsCommittedBeforeDb = + await this.attachments.commitStagedBeforeDbWrite(); + 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); @@ -1181,54 +981,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._flushPendingFilesystemDeletesAfterSync(); - 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() { @@ -1241,82 +1044,23 @@ export class Doc extends Observable { } await this.trigger('beforeDelete'); - // Best-effort cleanup for filesystem-backed attachments/images. - try { - await this.#cleanupFileBackedFieldsBeforeDelete(); - } catch {} + 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.cleanupCapturedRefsAfterDelete(refs); + } catch (err) { + console.error( + `[books] best-effort attachment cleanup failed after delete (${this.schemaName} ${this.name ?? ''})`, + err + ); + } await this.trigger('afterDelete'); this.fyo.telemetry.log(Verb.Deleted, this.schemaName); this.fyo.doc.observer.trigger(`delete:${this.schemaName}`, this.name); } - static #attachImageFileRefPrefix = 'books-file:'; - - 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 v = value as undefined | null | { path?: string }; - if (v?.path && typeof v.path === 'string') { - paths.add(v.path); - } - continue; - } - - if (fieldtype === FieldTypeEnum.AttachImage) { - const v = value as string | null | undefined; - if ( - typeof v === 'string' && - v.startsWith(Doc.#attachImageFileRefPrefix) && - v.length > Doc.#attachImageFileRefPrefix.length - ) { - paths.add(v.slice(Doc.#attachImageFileRefPrefix.length)); - } - continue; - } - - if (fieldtype === FieldTypeEnum.Table) { - if (Array.isArray(value)) { - for (const row of value) { - if (row instanceof Doc) { - scan(row); - } - } - } - } - } - }; - - scan(this); - - 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; @@ -1396,6 +1140,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) { @@ -1428,6 +1179,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/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..47620f23a 100644 --- a/main/registerIpcMainActionListeners.ts +++ b/main/registerIpcMainActionListeners.ts @@ -10,12 +10,14 @@ 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'; 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'; @@ -54,6 +56,75 @@ 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.startsWith(normalizedPrefix + path.sep); +} + +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 { @@ -450,40 +521,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 { @@ -510,10 +559,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), }; }); @@ -540,4 +590,94 @@ 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 parsed = parseAttachmentParams(params); + if (!parsed.ok) { + return { success: false, message: parsed.message }; + } + const { dbPath, name, type, bytes } = parsed; + + const stageRoot = getAttachmentStageRootForDb(dbPath); + const { fullPath, safeName } = await writeStampedAttachmentFile( + stageRoot, + name, + 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/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/package.json b/package.json index 6b295d302..0d498c802 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rukn-books", - "version": "0.47.0", + "version": "0.48.0", "description": "Simple book-keeping app for everyone", "author": { "name": "Rukn Software", diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index 905cb3dad..ea60f82b2 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) { @@ -65,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 . * @@ -81,7 +113,19 @@ 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) { + return null; + } + const dbPath = fyo.db?.dbPath; + if (!dbPath) { + return null; + } + return await readAttachmentAsDataURL({ dbPath, path: abs, typeHint }); + } + if (!isAttachImageFileRef(value)) { return value; } @@ -91,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, + }); } 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); diff --git a/utils/attachmentStagingRef.ts b/utils/attachmentStagingRef.ts new file mode 100644 index 000000000..20a8f9311 --- /dev/null +++ b/utils/attachmentStagingRef.ts @@ -0,0 +1,97 @@ +/** 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) + ); +} + +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 { + // eslint-disable-next-line no-undef + if (typeof btoa === 'function') { + 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(textEncodeUtf8(absolutePath)).toString('base64') + ); + } + throw new Error( + '[books] encodeBooksStagedPath: no base64 encoder available (missing btoa and Buffer)' + ); +} + +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 textDecodeUtf8(binaryStringToBytes(bin)); + } + } catch { + return null; + } + const B = (globalThis as { Buffer?: typeof Buffer | undefined })?.Buffer; + if (B) { + try { + return textDecodeUtf8(new Uint8Array(B.from(b64, 'base64'))); + } 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(...) 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'; +} +