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

@@ -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,

View File

@@ -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<string> {
// 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<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}`;
try {
@@ -250,7 +235,7 @@ export class NextcloudAPI {
}
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 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<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> {
@@ -361,7 +436,7 @@ export class NextcloudAPI {
}
async fetchNotesWebDAV(): Promise<Note[]> {
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<Note> {
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<Note> {
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<void> {
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<Note> {
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,
};
}
}