Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 113 additions & 8 deletions apps/desktop/src/main/ipc/notes-handlers-extra.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { NotesChannels } from '@memry/contracts/notes-api'
import { PropertyTypes } from '@memry/contracts/property-types'
Expand All @@ -10,6 +13,7 @@ const mocks = vi.hoisted(() => {
}
const windowInstance = {
loadURL: vi.fn().mockResolvedValue(undefined),
loadFile: vi.fn().mockResolvedValue(undefined),
webContents,
destroy: vi.fn(),
isDestroyed: vi.fn(() => false)
Expand All @@ -34,6 +38,8 @@ const mocks = vi.hoisted(() => {
windowInstance,
webContents,
fsWriteFile: vi.fn(),
fsRm: vi.fn(),
appGetPath: vi.fn(() => '/tmp'),
resolveNoteByTitle: vi.fn(),
resolveNotesByTitles: vi.fn(),
getNoteTags: vi.fn(),
Expand Down Expand Up @@ -63,6 +69,8 @@ const mocks = vi.hoisted(() => {
countLocalOnlyNoteMetadata: vi.fn(),
listPropertyDefinitions: vi.fn(),
emitNoteAttachmentSaved: vi.fn(),
getVaultStatus: vi.fn(() => ({ path: null }) as { path: string | null }),
renderNoteAsHtml: vi.fn(() => '<html><body>note</body></html>'),
service: {
get: vi.fn(),
upsert: vi.fn(),
Expand All @@ -84,12 +92,14 @@ vi.mock('electron', () => ({
removeHandler: mocks.removeHandler
},
dialog: mocks.dialog,
BrowserWindow: mocks.BrowserWindow
BrowserWindow: mocks.BrowserWindow,
app: { getPath: mocks.appGetPath }
}))

vi.mock('fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof import('fs/promises')>()),
writeFile: mocks.fsWriteFile
writeFile: mocks.fsWriteFile,
rm: mocks.fsRm
}))

vi.mock('../database', () => ({
Expand Down Expand Up @@ -173,10 +183,15 @@ vi.mock('../vault/property-definitions', () => ({
}))

vi.mock('../lib/export-utils', () => ({
renderNoteAsHtml: vi.fn(() => '<html><body>note</body></html>'),
renderNoteAsHtml: mocks.renderNoteAsHtml,
sanitizeFilename: vi.fn((value: string) => value.replace(/\W+/g, '_'))
}))

vi.mock('../vault/index', async (importOriginal) => ({
...(await importOriginal<typeof import('../vault/index')>()),
getStatus: mocks.getVaultStatus
}))

vi.mock('../lib/main-i18n', () => ({
getMainI18n: () => ({
t: (key: string) => key,
Expand All @@ -197,7 +212,8 @@ vi.mock('@memry/storage-data', () => ({
listPropertyDefinitions: mocks.listPropertyDefinitions
}))

vi.mock('@memry/shared/file-types', () => ({
vi.mock('@memry/shared/file-types', async (importOriginal) => ({
...(await importOriginal<typeof import('@memry/shared/file-types')>()),
getAllSupportedExtensions: vi.fn(() => ['md', 'pdf', 'png'])
}))

Expand All @@ -216,21 +232,38 @@ const successful = (result: unknown): unknown => {
return result
}

const PNG_BYTES = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
'base64'
)
const PNG_BASE64 = PNG_BYTES.toString('base64')

describe('notes-handlers extra coverage', () => {
let vaultPath: string

beforeEach(() => {
vaultPath = mkdtempSync(path.join(tmpdir(), 'memry-export-handler-'))
mkdirSync(path.join(vaultPath, 'attachments', 'note-a'), { recursive: true })
writeFileSync(path.join(vaultPath, 'attachments', 'note-a', 'photo.png'), PNG_BYTES)
vi.clearAllMocks()
mocks.handlers.clear()
mocks.webContents.printToPDF.mockResolvedValue(Buffer.from('pdf'))
mocks.windowInstance.loadURL.mockResolvedValue(undefined)
mocks.windowInstance.loadFile.mockResolvedValue(undefined)
mocks.windowInstance.isDestroyed.mockReturnValue(false)
mocks.fsWriteFile.mockResolvedValue(undefined)
mocks.fsRm.mockResolvedValue(undefined)
mocks.appGetPath.mockReturnValue('/tmp')
mocks.service.get.mockReturnValue(null)
mocks.getVaultStatus.mockReturnValue({ path: null })
mocks.renderNoteAsHtml.mockReturnValue('<html><body>note</body></html>')
registerNotesHandlers()
})

afterEach(() => {
unregisterNotesHandlers()
mocks.handlers.clear()
rmSync(vaultPath, { recursive: true, force: true })
})

it('resolves a batch of titles into a plain record over one channel', async () => {
Expand Down Expand Up @@ -531,6 +564,7 @@ describe('notes-handlers extra coverage', () => {

const note = {
id: 'note-a',
path: 'Note.md',
title: 'Daily note',
content: '# Today',
emoji: null,
Expand All @@ -539,6 +573,10 @@ describe('notes-handlers extra coverage', () => {
modified: new Date('2026-05-10T00:00:00.000Z')
}
mocks.getNoteById.mockResolvedValue(note)
mocks.getVaultStatus.mockReturnValue({ path: vaultPath })
mocks.renderNoteAsHtml.mockReturnValue(
'<html><body><img src="attachments/note-a/photo.png"></body></html>'
)
mocks.dialog.showSaveDialog.mockResolvedValueOnce({
canceled: false,
filePath: '/tmp/Daily_note.pdf'
Expand All @@ -559,6 +597,17 @@ describe('notes-handlers extra coverage', () => {
)
expect(mocks.fsWriteFile).toHaveBeenCalledWith('/tmp/Daily_note.pdf', Buffer.from('pdf'))

// Staged to a real file rather than a `data:` URL, which Chromium rejects
// past its length ceiling once an image is inlined.
const staged = mocks.windowInstance.loadFile.mock.calls.at(-1)?.[0] as string
expect(staged).toMatch(/^\/tmp\/memry-export-.+\.html$/)
expect(mocks.fsWriteFile).toHaveBeenCalledWith(
staged,
`<html><body><img src="data:image/png;base64,${PNG_BASE64}"></body></html>`,
'utf-8'
)
expect(mocks.fsRm).toHaveBeenCalledWith(staged, { force: true })

mocks.dialog.showSaveDialog.mockResolvedValueOnce({ canceled: true })
await expect(
invoke(NotesChannels.invoke.EXPORT_HTML, {
Expand All @@ -579,13 +628,66 @@ describe('notes-handlers extra coverage', () => {
pageSize: 'A4'
})
).resolves.toEqual({ success: true, path: '/tmp/Daily_note.html' })
// Self-contained, so the file keeps its images once the user moves it.
expect(mocks.fsWriteFile).toHaveBeenCalledWith(
'/tmp/Daily_note.html',
`<html><body><img src="data:image/png;base64,${PNG_BASE64}"></body></html>`,
'utf-8'
)
})

it('leaves an image it cannot read as written', async () => {
mocks.getNoteById.mockResolvedValue({
id: 'note-a',
path: 'Note.md',
title: 'Daily note',
content: '# Today',
emoji: null,
tags: [],
created: new Date('2026-05-10T00:00:00.000Z'),
modified: new Date('2026-05-10T00:00:00.000Z')
})
mocks.getVaultStatus.mockReturnValue({ path: vaultPath })
mocks.renderNoteAsHtml.mockReturnValue('<img src="attachments/note-a/missing.png">')

await expect(
invoke(NotesChannels.invoke.EXPORT_HTML, {
noteId: 'note-a',
outputPath: '/tmp/Daily_note.html',
includeMetadata: false,
pageSize: 'A4'
})
).resolves.toEqual({ success: true, path: '/tmp/Daily_note.html' })
expect(mocks.fsWriteFile).toHaveBeenCalledWith(
'/tmp/Daily_note.html',
'<html><body>note</body></html>',
'<img src="attachments/note-a/missing.png">',
'utf-8'
)
})

it('still exports when the staged HTML cannot be removed', async () => {
mocks.getNoteById.mockResolvedValue({
id: 'note-a',
path: 'Note.md',
title: 'Daily note',
content: '# Today',
emoji: null,
tags: [],
created: new Date('2026-05-10T00:00:00.000Z'),
modified: new Date('2026-05-10T00:00:00.000Z')
})
mocks.fsRm.mockRejectedValueOnce(new Error('EBUSY'))

await expect(
invoke(NotesChannels.invoke.EXPORT_PDF, {
noteId: 'note-a',
outputPath: '/tmp/Daily_note.pdf',
includeMetadata: false,
pageSize: 'A4'
})
).resolves.toEqual({ success: true, path: '/tmp/Daily_note.pdf' })
})

it('destroys the hidden PDF window when rendering fails', async () => {
const note = {
id: 'note-a',
Expand All @@ -608,18 +710,21 @@ describe('notes-handlers extra coverage', () => {
})
).toEqual({ success: false, error: 'printToPDF crashed' })
expect(mocks.windowInstance.destroy).toHaveBeenCalledTimes(1)
expect(mocks.fsWriteFile).not.toHaveBeenCalled()
expect(mocks.fsWriteFile).not.toHaveBeenCalledWith('/tmp/Daily_note.pdf', expect.anything())
// The staged HTML is removed even when the print fails.
const staged = mocks.windowInstance.loadFile.mock.calls.at(-1)?.[0] as string
expect(mocks.fsRm).toHaveBeenCalledWith(staged, { force: true })

mocks.windowInstance.destroy.mockClear()
mocks.windowInstance.loadURL.mockRejectedValueOnce(new Error('loadURL crashed'))
mocks.windowInstance.loadFile.mockRejectedValueOnce(new Error('loadFile crashed'))
expect(
await invoke(NotesChannels.invoke.EXPORT_PDF, {
noteId: 'note-a',
outputPath: '/tmp/Daily_note.pdf',
includeMetadata: false,
pageSize: 'A4'
})
).toEqual({ success: false, error: 'loadURL crashed' })
).toEqual({ success: false, error: 'loadFile crashed' })
expect(mocks.windowInstance.destroy).toHaveBeenCalledTimes(1)

// An already-destroyed window is never destroyed twice.
Expand Down
68 changes: 42 additions & 26 deletions apps/desktop/src/main/ipc/notes-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
* @module ipc/notes-handlers
*/

import { ipcMain, dialog, BrowserWindow } from 'electron'
import { ipcMain, dialog, BrowserWindow, app } from 'electron'
import * as fs from 'fs/promises'
import * as path from 'path'
import { randomUUID } from 'crypto'
import { z } from 'zod'
import {
NotesChannels,
Expand Down Expand Up @@ -46,6 +48,7 @@ import {
withErrorHandler
} from './validate'
import { registerCommand } from './lib/register-command'
import type { Note } from '../vault/notes'
import {
getNoteById,
getNoteByPath,
Expand Down Expand Up @@ -78,6 +81,8 @@ import {
import { applyTemplateToNote } from '../notes/apply-template'
import { getAllSupportedExtensions } from '@memry/shared/file-types'
import { saveAttachment, deleteAttachment, listNoteAttachments } from '../vault/attachments'
import { getStatus as getVaultStatus } from '../vault/index'
import { inlineExportImages } from '../lib/export-image-inliner'
import { readFolderConfig, writeFolderConfig, getFolderTemplate } from '../vault/folders'
import {
syncFolderConfigSet,
Expand Down Expand Up @@ -193,6 +198,29 @@ const ExportNoteSchema = z.object({
outputPath: z.string().min(1).optional()
})

/**
* Render a note for export with its images carried inside the document.
*
* Both export paths go through here so the two stay in step. The PDF path has
* no base URL to resolve a relative `<img src>` against, and an exported
* `.html` only kept its images while it sat next to the attachments (#1935).
*/
async function renderNoteForExport(note: Note, includeMetadata: boolean): Promise<string> {
const html = renderNoteAsHtml(
{
id: note.id,
title: note.title,
content: note.content,
emoji: note.emoji,
tags: note.tags,
created: note.created,
modified: note.modified
},
{ includeMetadata }
)
return inlineExportImages(html, { notePath: note.path, vaultPath: getVaultStatus().path })
}

/**
* Register all note-related IPC handlers.
* Call this once during app initialization.
Expand Down Expand Up @@ -893,18 +921,7 @@ export function registerNotesHandlers(): void {
targetPath = result.filePath
}

const html = renderNoteAsHtml(
{
id: note.id,
title: note.title,
content: note.content,
emoji: note.emoji,
tags: note.tags,
created: note.created,
modified: note.modified
},
{ includeMetadata: input.includeMetadata }
)
const html = await renderNoteForExport(note, input.includeMetadata)

const win = new BrowserWindow({
show: false,
Expand All @@ -915,9 +932,16 @@ export function registerNotesHandlers(): void {
}
})

// A `data:` URL would be simpler, but Chromium rejects one past its URL
// length ceiling with ERR_INVALID_URL, and a note holding one phone
// photograph clears that ceiling once its images are inlined. A real file
// has no such limit.
const stagedHtmlPath = path.join(app.getPath('temp'), `memry-export-${randomUUID()}.html`)

let pdfData: Buffer
try {
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
await fs.writeFile(stagedHtmlPath, html, 'utf-8')
await win.loadFile(stagedHtmlPath)
await new Promise((resolve) => setTimeout(resolve, 100))

const pageSizeMap: Record<string, Electron.PrintToPDFOptions['pageSize']> = {
Expand All @@ -938,6 +962,9 @@ export function registerNotesHandlers(): void {
})
} finally {
if (!win.isDestroyed()) win.destroy()
await fs.rm(stagedHtmlPath, { force: true }).catch((error) => {
logger.warn('Failed to remove the staged export HTML', { stagedHtmlPath, error })
})
}

await fs.writeFile(targetPath, pdfData)
Expand Down Expand Up @@ -982,18 +1009,7 @@ export function registerNotesHandlers(): void {
targetPath = result.filePath
}

const html = renderNoteAsHtml(
{
id: note.id,
title: note.title,
content: note.content,
emoji: note.emoji,
tags: note.tags,
created: note.created,
modified: note.modified
},
{ includeMetadata: input.includeMetadata }
)
const html = await renderNoteForExport(note, input.includeMetadata)

await fs.writeFile(targetPath, html, 'utf-8')

Expand Down
Loading
Loading