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:
15
src/App.tsx
15
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user