diff --git a/electron/main.cjs b/electron/main.cjs index bc52552..454d755 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,6 +1,6 @@ const fs = require('node:fs/promises'); const path = require('node:path'); -const { app, BrowserWindow, dialog, ipcMain } = require('electron'); +const { app, BrowserWindow, dialog, ipcMain, net } = require('electron'); const rendererUrl = process.env.ELECTRON_RENDERER_URL; const isDev = Boolean(rendererUrl); @@ -90,23 +90,55 @@ ipcMain.handle('desktop:http-request', async (_event, payload) => { const body = payload.bodyBase64 != null ? Buffer.from(payload.bodyBase64, 'base64') - : payload.bodyText; + : payload.bodyText != null + ? Buffer.from(payload.bodyText, 'utf8') + : null; - const response = await fetch(payload.url, { - method: payload.method || 'GET', - headers: payload.headers, - body, + return await new Promise((resolve, reject) => { + const request = net.request({ + url: payload.url, + method: payload.method || 'GET', + session: BrowserWindow.getAllWindows()[0]?.webContents.session, + }); + + for (const [name, value] of Object.entries(payload.headers || {})) { + request.setHeader(name, value); + } + + request.on('response', (response) => { + const chunks = []; + + response.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + response.on('end', () => { + const headers = {}; + for (const [name, value] of Object.entries(response.headers)) { + headers[name] = Array.isArray(value) ? value.join(', ') : String(value ?? ''); + } + + const buffer = Buffer.concat(chunks); + resolve({ + ok: response.statusCode >= 200 && response.statusCode < 300, + status: response.statusCode, + statusText: response.statusMessage || '', + headers, + bodyBase64: buffer.toString('base64'), + }); + }); + + response.on('error', reject); + }); + + request.on('error', reject); + + if (body && body.length > 0) { + request.write(body); + } + + request.end(); }); - - const buffer = Buffer.from(await response.arrayBuffer()); - - return { - ok: response.ok, - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - bodyBase64: buffer.toString('base64'), - }; }); ipcMain.handle('desktop:export-pdf', async (event, payload) => { diff --git a/src/App.tsx b/src/App.tsx index e0efcfa..7d9a0c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -510,10 +510,21 @@ function MainApp() { }; } - const remoteCategory = getRemoteCategory(note); + const savedSnapshot = note.draftId ? savedSnapshotsRef.current.get(note.draftId) : null; + const remoteReference = savedSnapshot ?? note; + const remoteCategory = getRemoteCategory(remoteReference); if (remoteCategory !== note.category) { - const movedNote = await syncManager.moveNote(note, note.category); + const movedNote = await syncManager.moveNote( + { + ...note, + id: remoteReference.id, + path: remoteReference.path, + filename: remoteReference.filename, + category: remoteCategory, + }, + note.category, + ); return syncManager.updateNote({ ...movedNote, draftId: note.draftId, diff --git a/src/api/nextcloud.ts b/src/api/nextcloud.ts index 8c07218..e41b768 100644 --- a/src/api/nextcloud.ts +++ b/src/api/nextcloud.ts @@ -127,17 +127,8 @@ export class NextcloudAPI { // Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename} // The path from markdown is like: .attachments.38479/Screenshot.png // We need to construct the full WebDAV URL - - let webdavPath = `/remote.php/dav/files/${this.username}/Notes`; - - // Add category subfolder if present - if (noteCategory) { - webdavPath += `/${noteCategory}`; - } - - // Add the attachment path (already includes .attachments.{id}/filename) - webdavPath += `/${path}`; - + + const webdavPath = this.buildAttachmentWebDAVPath(noteCategory, path); const url = `${this.serverURL}${webdavPath}`; console.log(`[Note ${_noteId}] Fetching attachment via WebDAV:`, url); @@ -167,13 +158,7 @@ export class NextcloudAPI { async uploadAttachment(noteId: number | string, file: File, noteCategory?: string): Promise { // Create .attachments.{noteId} directory path and upload file via WebDAV PUT // Returns the relative path to insert into markdown - - let webdavPath = `/remote.php/dav/files/${this.username}/Notes`; - - if (noteCategory) { - webdavPath += `/${noteCategory}`; - } - + // Sanitize note ID: extract just the filename without extension and remove invalid chars // noteId might be "category/filename.md" or just "filename.md" const noteIdStr = String(noteId); @@ -183,7 +168,7 @@ export class NextcloudAPI { const attachmentDir = `.attachments.${sanitizedNoteId}`; const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename - const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`; + const fullPath = this.buildAttachmentWebDAVPath(noteCategory, attachmentDir, fileName); const url = `${this.serverURL}${fullPath}`; console.log('Uploading attachment via WebDAV:', url); @@ -191,7 +176,7 @@ export class NextcloudAPI { // First, try to create the attachments directory (MKCOL) // This may fail if it already exists, which is fine try { - await runtimeFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, { + await runtimeFetch(`${this.serverURL}${this.buildAttachmentWebDAVPath(noteCategory, attachmentDir)}`, { method: 'MKCOL', headers: { 'Authorization': this.authHeader, @@ -223,7 +208,7 @@ export class NextcloudAPI { } async fetchCategoryColors(): Promise> { - const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`; + const webdavPath = `${this.buildNotesRootWebDAVPath()}/.category-colors.json`; const url = `${this.serverURL}${webdavPath}`; try { @@ -250,7 +235,7 @@ export class NextcloudAPI { } async saveCategoryColors(colors: Record): Promise { - const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`; + const webdavPath = `${this.buildNotesRootWebDAVPath()}/.category-colors.json`; const url = `${this.serverURL}${webdavPath}`; const content = JSON.stringify(colors, null, 2); @@ -294,9 +279,99 @@ export class NextcloudAPI { return note.content; } + private buildNotesRootWebDAVPath(): string { + return `/remote.php/dav/files/${this.username}/Notes`; + } + + private buildEncodedCategoryPath(category: string): string { + if (!category) { + return ''; + } + + const encodedSegments = category + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)); + + return encodedSegments.length ? `/${encodedSegments.join('/')}` : ''; + } + + private buildCategoryWebDAVPath(category: string): string { + return `${this.buildNotesRootWebDAVPath()}${this.buildEncodedCategoryPath(category)}`; + } + + private getRemoteCategoryForNote(note: Note): string { + if (note.path) { + const pathParts = note.path.split('/'); + return pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : ''; + } + + if (typeof note.id === 'string') { + const idParts = note.id.split('/'); + return idParts.length > 1 ? idParts.slice(0, -1).join('/') : ''; + } + + return note.category; + } + + private getRemoteFilenameForNote(note: Note): string { + if (note.filename) { + return note.filename; + } + + if (note.path) { + return note.path.split('/').pop() || 'note.md'; + } + + if (typeof note.id === 'string') { + return note.id.split('/').pop() || 'note.md'; + } + + return 'note.md'; + } + private buildNoteWebDAVPath(category: string, filename: string): string { - const categoryPath = category ? `/${category}` : ''; - return `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`; + return `${this.buildCategoryWebDAVPath(category)}/${encodeURIComponent(filename)}`; + } + + private buildRelativeWebDAVPath(...segments: string[]): string { + return segments + .flatMap((segment) => segment.split('/')) + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/'); + } + + private buildAttachmentWebDAVPath(noteCategory: string | undefined, ...relativeSegments: string[]): string { + const relativePath = this.buildRelativeWebDAVPath(...relativeSegments); + return relativePath + ? `${this.buildCategoryWebDAVPath(noteCategory || '')}/${relativePath}` + : this.buildCategoryWebDAVPath(noteCategory || ''); + } + + private noteHasLocalAttachments(note: Note): boolean { + return note.content.includes('.attachments.'); + } + + private async ensureCategoryDirectoryExists(category: string): Promise { + if (!category) { + return; + } + + const parts = category.split('/').filter(Boolean); + + for (let index = 0; index < parts.length; index += 1) { + const currentCategory = parts.slice(0, index + 1).join('/'); + const categoryUrl = `${this.serverURL}${this.buildCategoryWebDAVPath(currentCategory)}`; + const response = await runtimeFetch(categoryUrl, { + method: 'MKCOL', + headers: { 'Authorization': this.authHeader }, + }); + + if (!response.ok && response.status !== 405) { + throw createHttpStatusError(`Failed to create category folder: ${response.status}`, response.status); + } + } } private async delay(ms: number): Promise { @@ -361,7 +436,7 @@ export class NextcloudAPI { } async fetchNotesWebDAV(): Promise { - const webdavPath = `/remote.php/dav/files/${this.username}/Notes`; + const webdavPath = this.buildNotesRootWebDAVPath(); const url = `${this.serverURL}${webdavPath}`; const response = await runtimeFetch(url, { @@ -439,9 +514,8 @@ export class NextcloudAPI { } async fetchNoteContentWebDAV(note: Note): Promise { - const categoryPath = note.category ? `/${note.category}` : ''; const filename = note.filename || String(note.id).split('/').pop() || 'note.md'; - const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`; + const webdavPath = this.buildNoteWebDAVPath(note.category, filename); const url = `${this.serverURL}${webdavPath}`; const response = await runtimeFetch(url, { @@ -458,21 +532,12 @@ export class NextcloudAPI { async createNoteWebDAV(title: string, content: string, category: string): Promise { const filename = `${title.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`; - const categoryPath = category ? `/${category}` : ''; - const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`; + const webdavPath = this.buildNoteWebDAVPath(category, filename); const url = `${this.serverURL}${webdavPath}`; // Ensure category directory exists if (category) { - try { - const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${category}`; - await runtimeFetch(categoryUrl, { - method: 'MKCOL', - headers: { 'Authorization': this.authHeader }, - }); - } catch (e) { - // Directory might already exist - } + await this.ensureCategoryDirectoryExists(category); } const noteContent = content ? `${title}\n${content}` : title; @@ -616,31 +681,32 @@ export class NextcloudAPI { throw createHttpStatusError(`Failed to rename note: ${response.status}`, response.status); } - // Also rename attachment folder if it exists - const categoryPath = note.category ? `/${note.category}` : ''; - const oldNoteIdStr = String(note.id); - const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr; - const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, ''); - const oldSanitizedNoteId = oldFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); - const oldAttachmentFolder = `.attachments.${oldSanitizedNoteId}`; - - const newFilenameWithoutExt = newFilename.replace(/\.(md|txt)$/, ''); - const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); - const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`; - - const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${oldAttachmentFolder}`; - const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`; - - try { - await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, { - method: 'MOVE', - headers: { - 'Authorization': this.authHeader, - 'Destination': `${this.serverURL}${newAttachmentPath}`, - }, - }); - } catch (e) { - // Attachment folder might not exist, that's ok + if (this.noteHasLocalAttachments(note)) { + // Also rename attachment folder if the note references local attachments + const oldNoteIdStr = String(note.id); + const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr; + const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, ''); + const oldSanitizedNoteId = oldFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); + const oldAttachmentFolder = `.attachments.${oldSanitizedNoteId}`; + + const newFilenameWithoutExt = newFilename.replace(/\.(md|txt)$/, ''); + const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); + const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`; + + const oldAttachmentPath = this.buildAttachmentWebDAVPath(note.category, oldAttachmentFolder); + const newAttachmentPath = this.buildAttachmentWebDAVPath(note.category, newAttachmentFolder); + + try { + await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, { + method: 'MOVE', + headers: { + 'Authorization': this.authHeader, + 'Destination': `${this.serverURL}${newAttachmentPath}`, + }, + }); + } catch (e) { + // Attachment folder might not exist, that's ok + } } const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename); @@ -657,8 +723,7 @@ export class NextcloudAPI { } async deleteNoteWebDAV(note: Note): Promise { - const categoryPath = note.category ? `/${note.category}` : ''; - const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`; + const webdavPath = this.buildNoteWebDAVPath(note.category, note.filename!); const url = `${this.serverURL}${webdavPath}`; const response = await runtimeFetch(url, { @@ -672,28 +737,14 @@ export class NextcloudAPI { } async moveNoteWebDAV(note: Note, newCategory: string): Promise { - const oldCategoryPath = note.category ? `/${note.category}` : ''; - const newCategoryPath = newCategory ? `/${newCategory}` : ''; - const oldPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${encodeURIComponent(note.filename!)}`; - const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${encodeURIComponent(note.filename!)}`; - + const remoteCategory = this.getRemoteCategoryForNote(note); + const remoteFilename = this.getRemoteFilenameForNote(note); + const oldPath = this.buildNoteWebDAVPath(remoteCategory, remoteFilename); + const newPath = this.buildNoteWebDAVPath(newCategory, remoteFilename); + // Ensure new category directory exists (including nested subdirectories) if (newCategory) { - const parts = newCategory.split('/'); - let currentPath = ''; - - for (const part of parts) { - currentPath += (currentPath ? '/' : '') + part; - try { - const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${currentPath}`; - await runtimeFetch(categoryUrl, { - method: 'MKCOL', - headers: { 'Authorization': this.authHeader }, - }); - } catch (e) { - // Directory might already exist, continue - } - } + await this.ensureCategoryDirectoryExists(newCategory); } const response = await runtimeFetch(`${this.serverURL}${oldPath}`, { @@ -705,48 +756,56 @@ export class NextcloudAPI { }); if (!response.ok && response.status !== 201 && response.status !== 204) { - throw new Error(`Failed to move note: ${response.status}`); + const details = await response.text().catch(() => ''); + const detailSuffix = details ? ` - ${details.slice(0, 300)}` : ''; + throw createHttpStatusError( + `Failed to move note: ${response.status}${detailSuffix}. Source: ${oldPath}. Destination: ${newPath}`, + response.status, + ); } - // Move attachment folder if it exists - const noteIdStr = String(note.id); - const justFilename = noteIdStr.split('/').pop() || noteIdStr; - const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, ''); - const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); - const attachmentFolder = `.attachments.${sanitizedNoteId}`; - - const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${attachmentFolder}`; - const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${attachmentFolder}`; - - console.log(`Attempting to move attachment folder:`); - console.log(` From: ${oldAttachmentPath}`); - console.log(` To: ${newAttachmentPath}`); - - try { - const attachmentResponse = await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, { - method: 'MOVE', - headers: { - 'Authorization': this.authHeader, - 'Destination': `${this.serverURL}${newAttachmentPath}`, - }, - }); + if (this.noteHasLocalAttachments(note)) { + // Move attachment folder only when the note references local attachments + const noteIdStr = String(note.id); + const justFilename = noteIdStr.split('/').pop() || noteIdStr; + const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, ''); + const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); + const attachmentFolder = `.attachments.${sanitizedNoteId}`; + + const oldAttachmentPath = this.buildAttachmentWebDAVPath(remoteCategory, attachmentFolder); + const newAttachmentPath = this.buildAttachmentWebDAVPath(newCategory, attachmentFolder); - console.log(`Attachment folder MOVE response status: ${attachmentResponse.status}`); + console.log(`Attempting to move attachment folder:`); + console.log(` From: ${oldAttachmentPath}`); + console.log(` To: ${newAttachmentPath}`); - if (attachmentResponse.ok || attachmentResponse.status === 201 || attachmentResponse.status === 204) { - console.log(`✓ Successfully moved attachment folder: ${attachmentFolder}`); - } else { - console.log(`✗ Failed to move attachment folder (status ${attachmentResponse.status})`); + try { + const attachmentResponse = await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, { + method: 'MOVE', + headers: { + 'Authorization': this.authHeader, + 'Destination': `${this.serverURL}${newAttachmentPath}`, + }, + }); + + console.log(`Attachment folder MOVE response status: ${attachmentResponse.status}`); + + if (attachmentResponse.ok || attachmentResponse.status === 201 || attachmentResponse.status === 204) { + console.log(`✓ Successfully moved attachment folder: ${attachmentFolder}`); + } else { + console.log(`✗ Failed to move attachment folder (status ${attachmentResponse.status})`); + } + } catch (e) { + console.log(`✗ Error moving attachment folder:`, e); } - } catch (e) { - console.log(`✗ Error moving attachment folder:`, e); } return { ...note, category: newCategory, - path: newCategory ? `${newCategory}/${note.filename}` : note.filename || '', - id: newCategory ? `${newCategory}/${note.filename}` : (note.filename || ''), + filename: remoteFilename, + path: newCategory ? `${newCategory}/${remoteFilename}` : remoteFilename, + id: newCategory ? `${newCategory}/${remoteFilename}` : remoteFilename, }; } }