Refactor WebDAV path construction and fix note move operations

- Replace Node.js fetch with Electron net.request for better session handling
- Extract WebDAV path building into reusable private methods with proper URL encoding
- Add helper methods for category path encoding and attachment path construction
- Fix note move operations to use remote category/filename from saved snapshots
- Add ensureCategoryDirectoryExists to handle nested category creation
- Only move/rename attachment folders when note has any
This commit is contained in:
drelich
2026-04-06 17:40:57 +02:00
parent 6bc67a3118
commit 995696fea3
3 changed files with 237 additions and 135 deletions

View File

@@ -1,6 +1,6 @@
const fs = require('node:fs/promises'); const fs = require('node:fs/promises');
const path = require('node:path'); 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 rendererUrl = process.env.ELECTRON_RENDERER_URL;
const isDev = Boolean(rendererUrl); const isDev = Boolean(rendererUrl);
@@ -90,23 +90,55 @@ ipcMain.handle('desktop:http-request', async (_event, payload) => {
const body = const body =
payload.bodyBase64 != null payload.bodyBase64 != null
? Buffer.from(payload.bodyBase64, 'base64') ? Buffer.from(payload.bodyBase64, 'base64')
: payload.bodyText; : payload.bodyText != null
? Buffer.from(payload.bodyText, 'utf8')
: null;
const response = await fetch(payload.url, { return await new Promise((resolve, reject) => {
method: payload.method || 'GET', const request = net.request({
headers: payload.headers, url: payload.url,
body, 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) => { ipcMain.handle('desktop:export-pdf', async (event, payload) => {

View File

@@ -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) { 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({ return syncManager.updateNote({
...movedNote, ...movedNote,
draftId: note.draftId, draftId: note.draftId,

View File

@@ -127,17 +127,8 @@ export class NextcloudAPI {
// Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename} // Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename}
// The path from markdown is like: .attachments.38479/Screenshot.png // The path from markdown is like: .attachments.38479/Screenshot.png
// We need to construct the full WebDAV URL // We need to construct the full WebDAV URL
let webdavPath = `/remote.php/dav/files/${this.username}/Notes`; const webdavPath = this.buildAttachmentWebDAVPath(noteCategory, path);
// Add category subfolder if present
if (noteCategory) {
webdavPath += `/${noteCategory}`;
}
// Add the attachment path (already includes .attachments.{id}/filename)
webdavPath += `/${path}`;
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
console.log(`[Note ${_noteId}] Fetching attachment via WebDAV:`, url); 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<string> { async uploadAttachment(noteId: number | string, file: File, noteCategory?: string): Promise<string> {
// Create .attachments.{noteId} directory path and upload file via WebDAV PUT // Create .attachments.{noteId} directory path and upload file via WebDAV PUT
// Returns the relative path to insert into markdown // 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 // Sanitize note ID: extract just the filename without extension and remove invalid chars
// noteId might be "category/filename.md" or just "filename.md" // noteId might be "category/filename.md" or just "filename.md"
const noteIdStr = String(noteId); const noteIdStr = String(noteId);
@@ -183,7 +168,7 @@ export class NextcloudAPI {
const attachmentDir = `.attachments.${sanitizedNoteId}`; const attachmentDir = `.attachments.${sanitizedNoteId}`;
const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename 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}`; const url = `${this.serverURL}${fullPath}`;
console.log('Uploading attachment via WebDAV:', url); console.log('Uploading attachment via WebDAV:', url);
@@ -191,7 +176,7 @@ export class NextcloudAPI {
// First, try to create the attachments directory (MKCOL) // First, try to create the attachments directory (MKCOL)
// This may fail if it already exists, which is fine // This may fail if it already exists, which is fine
try { try {
await runtimeFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, { await runtimeFetch(`${this.serverURL}${this.buildAttachmentWebDAVPath(noteCategory, attachmentDir)}`, {
method: 'MKCOL', method: 'MKCOL',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -223,7 +208,7 @@ export class NextcloudAPI {
} }
async fetchCategoryColors(): Promise<Record<string, number>> { async fetchCategoryColors(): Promise<Record<string, number>> {
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 url = `${this.serverURL}${webdavPath}`;
try { try {
@@ -250,7 +235,7 @@ export class NextcloudAPI {
} }
async saveCategoryColors(colors: Record<string, number>): Promise<void> { async saveCategoryColors(colors: Record<string, number>): Promise<void> {
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 url = `${this.serverURL}${webdavPath}`;
const content = JSON.stringify(colors, null, 2); const content = JSON.stringify(colors, null, 2);
@@ -294,9 +279,99 @@ export class NextcloudAPI {
return note.content; 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 { private buildNoteWebDAVPath(category: string, filename: string): string {
const categoryPath = category ? `/${category}` : ''; return `${this.buildCategoryWebDAVPath(category)}/${encodeURIComponent(filename)}`;
return `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${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<void> {
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<void> { private async delay(ms: number): Promise<void> {
@@ -361,7 +436,7 @@ export class NextcloudAPI {
} }
async fetchNotesWebDAV(): Promise<Note[]> { async fetchNotesWebDAV(): Promise<Note[]> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`; const webdavPath = this.buildNotesRootWebDAVPath();
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
const response = await runtimeFetch(url, { const response = await runtimeFetch(url, {
@@ -439,9 +514,8 @@ export class NextcloudAPI {
} }
async fetchNoteContentWebDAV(note: Note): Promise<Note> { async fetchNoteContentWebDAV(note: Note): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : '';
const filename = note.filename || String(note.id).split('/').pop() || 'note.md'; 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 url = `${this.serverURL}${webdavPath}`;
const response = await runtimeFetch(url, { const response = await runtimeFetch(url, {
@@ -458,21 +532,12 @@ export class NextcloudAPI {
async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> { async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> {
const filename = `${title.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`; const filename = `${title.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`;
const categoryPath = category ? `/${category}` : ''; const webdavPath = this.buildNoteWebDAVPath(category, filename);
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
// Ensure category directory exists // Ensure category directory exists
if (category) { if (category) {
try { await this.ensureCategoryDirectoryExists(category);
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
}
} }
const noteContent = content ? `${title}\n${content}` : title; const noteContent = content ? `${title}\n${content}` : title;
@@ -616,31 +681,32 @@ export class NextcloudAPI {
throw createHttpStatusError(`Failed to rename note: ${response.status}`, response.status); throw createHttpStatusError(`Failed to rename note: ${response.status}`, response.status);
} }
// Also rename attachment folder if it exists if (this.noteHasLocalAttachments(note)) {
const categoryPath = note.category ? `/${note.category}` : ''; // Also rename attachment folder if the note references local attachments
const oldNoteIdStr = String(note.id); const oldNoteIdStr = String(note.id);
const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr; const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr;
const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, ''); const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, '');
const oldSanitizedNoteId = oldFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); const oldSanitizedNoteId = oldFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
const oldAttachmentFolder = `.attachments.${oldSanitizedNoteId}`; const oldAttachmentFolder = `.attachments.${oldSanitizedNoteId}`;
const newFilenameWithoutExt = newFilename.replace(/\.(md|txt)$/, ''); const newFilenameWithoutExt = newFilename.replace(/\.(md|txt)$/, '');
const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`; const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`;
const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${oldAttachmentFolder}`; const oldAttachmentPath = this.buildAttachmentWebDAVPath(note.category, oldAttachmentFolder);
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`; const newAttachmentPath = this.buildAttachmentWebDAVPath(note.category, newAttachmentFolder);
try { try {
await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, { await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
method: 'MOVE', method: 'MOVE',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
'Destination': `${this.serverURL}${newAttachmentPath}`, 'Destination': `${this.serverURL}${newAttachmentPath}`,
}, },
}); });
} catch (e) { } catch (e) {
// Attachment folder might not exist, that's ok // Attachment folder might not exist, that's ok
}
} }
const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename); const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
@@ -657,8 +723,7 @@ export class NextcloudAPI {
} }
async deleteNoteWebDAV(note: Note): Promise<void> { async deleteNoteWebDAV(note: Note): Promise<void> {
const categoryPath = note.category ? `/${note.category}` : ''; const webdavPath = this.buildNoteWebDAVPath(note.category, note.filename!);
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
const response = await runtimeFetch(url, { const response = await runtimeFetch(url, {
@@ -672,28 +737,14 @@ export class NextcloudAPI {
} }
async moveNoteWebDAV(note: Note, newCategory: string): Promise<Note> { async moveNoteWebDAV(note: Note, newCategory: string): Promise<Note> {
const oldCategoryPath = note.category ? `/${note.category}` : ''; const remoteCategory = this.getRemoteCategoryForNote(note);
const newCategoryPath = newCategory ? `/${newCategory}` : ''; const remoteFilename = this.getRemoteFilenameForNote(note);
const oldPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${encodeURIComponent(note.filename!)}`; const oldPath = this.buildNoteWebDAVPath(remoteCategory, remoteFilename);
const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${encodeURIComponent(note.filename!)}`; const newPath = this.buildNoteWebDAVPath(newCategory, remoteFilename);
// Ensure new category directory exists (including nested subdirectories) // Ensure new category directory exists (including nested subdirectories)
if (newCategory) { if (newCategory) {
const parts = newCategory.split('/'); await this.ensureCategoryDirectoryExists(newCategory);
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
}
}
} }
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, { const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
@@ -705,48 +756,56 @@ export class NextcloudAPI {
}); });
if (!response.ok && response.status !== 201 && response.status !== 204) { 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 if (this.noteHasLocalAttachments(note)) {
const noteIdStr = String(note.id); // Move attachment folder only when the note references local attachments
const justFilename = noteIdStr.split('/').pop() || noteIdStr; const noteIdStr = String(note.id);
const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, ''); const justFilename = noteIdStr.split('/').pop() || noteIdStr;
const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, '');
const attachmentFolder = `.attachments.${sanitizedNoteId}`; 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}`; const oldAttachmentPath = this.buildAttachmentWebDAVPath(remoteCategory, attachmentFolder);
const newAttachmentPath = this.buildAttachmentWebDAVPath(newCategory, 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}`,
},
});
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) { try {
console.log(`✓ Successfully moved attachment folder: ${attachmentFolder}`); const attachmentResponse = await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
} else { method: 'MOVE',
console.log(`✗ Failed to move attachment folder (status ${attachmentResponse.status})`); 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 { return {
...note, ...note,
category: newCategory, category: newCategory,
path: newCategory ? `${newCategory}/${note.filename}` : note.filename || '', filename: remoteFilename,
id: newCategory ? `${newCategory}/${note.filename}` : (note.filename || ''), path: newCategory ? `${newCategory}/${remoteFilename}` : remoteFilename,
id: newCategory ? `${newCategory}/${remoteFilename}` : remoteFilename,
}; };
} }
} }