( root: string, topFolder: NoteFolder, oldSubpath: string, newSubpath: string )
| 3373 | * descendant, and refuses to touch the top-level folder. |
| 3374 | */ |
| 3375 | export async function renameFolder( |
| 3376 | root: string, |
| 3377 | topFolder: NoteFolder, |
| 3378 | oldSubpath: string, |
| 3379 | newSubpath: string |
| 3380 | ): Promise<string> { |
| 3381 | const oldClean = oldSubpath.replace(/^\/+|\/+$/g, '') |
| 3382 | const newClean = newSubpath.replace(/^\/+|\/+$/g, '') |
| 3383 | if (!oldClean) throw new Error('Cannot rename the top-level folder') |
| 3384 | if (!newClean) throw new Error('Target folder name is required') |
| 3385 | |
| 3386 | const topRoot = await folderRoot(root, topFolder) |
| 3387 | const oldAbs = resolveSafe(topRoot, oldClean) |
| 3388 | const newAbs = resolveSafe(topRoot, newClean) |
| 3389 | if (newAbs === oldAbs) return newClean |
| 3390 | |
| 3391 | const sep = path.sep |
| 3392 | if ((newAbs + sep).startsWith(oldAbs + sep)) { |
| 3393 | throw new Error('Cannot move a folder into itself') |
| 3394 | } |
| 3395 | |
| 3396 | // Refuse to overwrite a different existing folder. |
| 3397 | // On case-insensitive filesystems (macOS), a case-only rename |
| 3398 | // (e.g. "Work" → "work") is fine — same underlying directory. |
| 3399 | try { |
| 3400 | await fs.access(newAbs) |
| 3401 | // Check if old and new are the same file (case-only rename) |
| 3402 | const [oldStat, newStat] = await Promise.all([fs.stat(oldAbs), fs.stat(newAbs)]) |
| 3403 | if (oldStat.ino !== newStat.ino) { |
| 3404 | throw new Error(`A folder already exists at "${newClean}"`) |
| 3405 | } |
| 3406 | } catch (e) { |
| 3407 | if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e |
| 3408 | } |
| 3409 | |
| 3410 | await fs.mkdir(path.dirname(newAbs), { recursive: true }) |
| 3411 | // On case-insensitive filesystems, a direct rename('AI','ai') may |
| 3412 | // not change the case. Use a two-step rename via a temp name. |
| 3413 | if (oldAbs.toLowerCase() === newAbs.toLowerCase() && oldAbs !== newAbs) { |
| 3414 | const tmpAbs = oldAbs + '_rename_tmp_' + Date.now() |
| 3415 | await fs.rename(oldAbs, tmpAbs) |
| 3416 | await fs.rename(tmpAbs, newAbs) |
| 3417 | } else { |
| 3418 | await fs.rename(oldAbs, newAbs) |
| 3419 | } |
| 3420 | const settings = await getVaultSettings(root) |
| 3421 | const nextSettings: VaultSettings = { |
| 3422 | ...settings, |
| 3423 | folderIcons: rewriteFolderIconsForRename( |
| 3424 | settings.folderIcons, |
| 3425 | topFolder, |
| 3426 | oldClean, |
| 3427 | newClean |
| 3428 | ), |
| 3429 | folderColors: rewriteFolderColorsForRename( |
| 3430 | settings.folderColors, |
| 3431 | topFolder, |
| 3432 | oldClean, |
no test coverage detected