diff --git a/src/domain/vault/write-paths.ts b/src/domain/vault/write-paths.ts new file mode 100644 index 00000000..10e82dcb --- /dev/null +++ b/src/domain/vault/write-paths.ts @@ -0,0 +1,98 @@ +export interface VaultPathDestination { + normalizePath(path: string): string; + exists(path: string): Promise; + createFolder(path: string): Promise; +} + +export interface VaultMarkdownDestination extends VaultPathDestination { + createMarkdownFile(path: string, content: string): Promise; +} + +export interface VaultRelativeFolderPathOptions { + normalizePath(path: string): string; + emptyPathMessage: string; + absolutePathMessage: string; + relativeSegmentMessage: string; + emptyFallback?: string; +} + +export interface UniqueVaultPathOptions { + firstCollisionSuffix?: number; +} + +const DEFAULT_FIRST_COLLISION_SUFFIX = 2; +const UNSAFE_VAULT_PATH_CHARS = '<>:"/\\|?*[]#^'; + +export function vaultRelativeFolderPath(value: string, options: VaultRelativeFolderPathOptions): string { + const trimmed = value.trim(); + if (!trimmed) return vaultFolderFallback(options); + + const raw = trimmed.replaceAll("\\", "/"); + if (raw.startsWith("/") || /^[A-Za-z]:\//.test(raw)) throw new Error(options.absolutePathMessage); + + const rawSegments = raw + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + if (rawSegments.length === 0) return vaultFolderFallback(options); + if (rawSegments.some((segment) => segment === "." || segment === "..")) throw new Error(options.relativeSegmentMessage); + + const folder = options.normalizePath(rawSegments.map(sanitizeVaultPathSegment).filter(Boolean).join("/")); + if (!folder) return vaultFolderFallback(options); + if (folder.split("/").some((segment) => segment === "." || segment === "..")) throw new Error(options.relativeSegmentMessage); + return folder; +} + +export async function ensureVaultFolder(destination: VaultPathDestination, folder: string): Promise { + const segments = folder.split("/"); + for (let index = 0; index < segments.length; index += 1) { + const path = segments.slice(0, index + 1).join("/"); + if (!(await destination.exists(path))) await destination.createFolder(path); + } +} + +export async function uniqueVaultPath( + destination: Pick, + folder: string, + filename: string, + options: UniqueVaultPathOptions = {}, +): Promise { + const { stem, extension } = splitVaultFilename(filename); + let candidate = destination.normalizePath(`${folder}/${filename}`); + let suffix = options.firstCollisionSuffix ?? DEFAULT_FIRST_COLLISION_SUFFIX; + while (await destination.exists(candidate)) { + candidate = destination.normalizePath(`${folder}/${stem} ${String(suffix)}${extension}`); + suffix += 1; + } + return candidate; +} + +export function sanitizeVaultPathSegment(value: string): string { + return value + .split("") + .map((char) => (isUnsafeVaultPathChar(char) ? "-" : char)) + .join("") + .replace(/\s+/g, " ") + .trim() + .replace(/^\.+$/, "") + .slice(0, 120) + .trim(); +} + +function vaultFolderFallback(options: VaultRelativeFolderPathOptions): string { + if (options.emptyFallback === undefined) throw new Error(options.emptyPathMessage); + return options.normalizePath(options.emptyFallback); +} + +function isUnsafeVaultPathChar(char: string): boolean { + return char.charCodeAt(0) < 32 || UNSAFE_VAULT_PATH_CHARS.includes(char); +} + +function splitVaultFilename(filename: string): { stem: string; extension: string } { + const dotIndex = filename.lastIndexOf("."); + if (dotIndex <= 0) return { stem: filename, extension: "" }; + return { + stem: filename.slice(0, dotIndex), + extension: filename.slice(dotIndex), + }; +} diff --git a/src/features/chat/application/web-clipping/web-clip.ts b/src/features/chat/application/web-clipping/web-clip.ts index d09ad978..428a5115 100644 --- a/src/features/chat/application/web-clipping/web-clip.ts +++ b/src/features/chat/application/web-clipping/web-clip.ts @@ -1,3 +1,11 @@ +import { + ensureVaultFolder, + sanitizeVaultPathSegment, + uniqueVaultPath, + type VaultMarkdownDestination, + vaultRelativeFolderPath, +} from "../../../../domain/vault/write-paths"; + export interface WebClipSettings { clipFolder: string; clipFilenameTemplate: string; @@ -12,12 +20,7 @@ export interface WebClipPage { domain?: string | null; } -export interface WebClipDestination { - normalizePath(path: string): string; - exists(path: string): Promise; - createFolder(path: string): Promise; - createMarkdownFile(path: string, content: string): Promise; -} +export type WebClipDestination = VaultMarkdownDestination; export interface WebClipResult { path: string; @@ -44,9 +47,9 @@ export async function saveWebClipMarkdown( const normalizePath = (path: string): string => destination.normalizePath(path); const folder = folderPath(settings.clipFolder, normalizePath); const filename = filenameFromTemplate(settings.clipFilenameTemplate, context, normalizePath); - await ensureFolder(destination, folder); + await ensureVaultFolder(destination, folder); - const path = await uniqueMarkdownPath(destination, folder, filename, normalizePath); + const path = await uniqueVaultPath(destination, folder, filename); await destination.createMarkdownFile(path, webClipMarkdown(page, settings, context.title, now)); return { path, wikilink: `[[${path}]]` }; } @@ -67,13 +70,13 @@ export function webClipMarkdown(page: WebClipPage, settings: Pick string): string { - const raw = value.trim().replaceAll("\\", "/"); - if (!raw) throw new Error("Clip folder produced an empty path."); - if (raw.startsWith("/") || /^[A-Za-z]:\//.test(raw)) throw new Error("Clip folder must be relative to the vault."); - - const rawSegments = raw - .split("/") - .map((segment) => segment.trim()) - .filter(Boolean); - if (rawSegments.length === 0) throw new Error("Clip folder produced an empty path."); - if (rawSegments.some((segment) => segment === "." || segment === "..")) { - throw new Error("Clip folder cannot contain relative path segments."); - } - - const folder = normalizePath(rawSegments.map(sanitizePathSegment).filter(Boolean).join("/")); - if (!folder) throw new Error("Clip folder produced an empty path."); - if (folder.split("/").some((segment) => segment === "." || segment === "..")) { - throw new Error("Clip folder cannot contain relative path segments."); - } - return folder; + return vaultRelativeFolderPath(value, { + normalizePath, + emptyPathMessage: "Clip folder produced an empty path.", + absolutePathMessage: "Clip folder must be relative to the vault.", + relativeSegmentMessage: "Clip folder cannot contain relative path segments.", + }); } function filenameFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string { const expanded = expandTemplate(template, context) .trim() .replace(/[\\/]+/g, "-"); - const filename = normalizePath(sanitizePathSegment(expanded)); + const filename = normalizePath(sanitizeVaultPathSegment(expanded)); if (!filename || filename === "." || filename === "..") throw new Error("Clip filename template produced an empty filename."); return filename.toLowerCase().endsWith(".md") ? filename : `${filename}.md`; } -async function ensureFolder(destination: WebClipDestination, folder: string): Promise { - const segments = folder.split("/"); - for (let index = 0; index < segments.length; index += 1) { - const path = segments.slice(0, index + 1).join("/"); - if (!(await destination.exists(path))) await destination.createFolder(path); - } -} - -async function uniqueMarkdownPath( - destination: WebClipDestination, - folder: string, - filename: string, - normalizePath: (path: string) => string, -): Promise { - const dotIndex = filename.toLowerCase().endsWith(".md") ? filename.length - 3 : filename.length; - const stem = filename.slice(0, dotIndex); - const extension = filename.slice(dotIndex); - let candidate = normalizePath(`${folder}/${filename}`); - let suffix = 2; - while (await destination.exists(candidate)) { - candidate = normalizePath(`${folder}/${stem} ${String(suffix)}${extension}`); - suffix += 1; - } - return candidate; -} - function normalizedClipTags(input: string): string[] { const tags: string[] = []; const seen = new Set(); @@ -166,22 +130,6 @@ function yamlString(value: string): string { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } -function sanitizePathSegment(value: string): string { - return value - .split("") - .map((char) => (isUnsafePathChar(char) ? "-" : char)) - .join("") - .replace(/\s+/g, " ") - .trim() - .replace(/^\.+$/, "") - .slice(0, 120) - .trim(); -} - -function isUnsafePathChar(char: string): boolean { - return char.charCodeAt(0) < 32 || '<>:"/\\|?*[]#^'.includes(char); -} - function hostnameFromUrl(url: string): string { try { return new URL(url).hostname; diff --git a/src/features/chat/host/obsidian/composer-attachments.obsidian.ts b/src/features/chat/host/obsidian/composer-attachments.obsidian.ts index d6dbeb9e..c81eb859 100644 --- a/src/features/chat/host/obsidian/composer-attachments.obsidian.ts +++ b/src/features/chat/host/obsidian/composer-attachments.obsidian.ts @@ -1,6 +1,13 @@ -import { type App, normalizePath, type Vault } from "obsidian"; +import type { App } from "obsidian"; +import { + ensureVaultFolder, + sanitizeVaultPathSegment, + uniqueVaultPath, + vaultRelativeFolderPath, +} from "../../../../domain/vault/write-paths"; import { DEFAULT_ATTACHMENT_FOLDER } from "../../../../settings/model"; +import { createObsidianVaultPathDestination } from "../../../../shared/obsidian/vault-write-destination.obsidian"; import type { ComposerAttachment, ComposerAttachmentHandler } from "../../application/composer/attachments"; interface VaultComposerAttachmentHandlerOptions { @@ -41,13 +48,14 @@ async function saveComposerAttachmentFiles( if (files.length === 0) return []; const vault = options.app.vault; - const folder = attachmentFolderPath(options.attachmentFolder()); - await ensureFolder(vault, folder); + const destination = createObsidianVaultPathDestination(vault); + const folder = attachmentFolderPath(options.attachmentFolder(), (path) => destination.normalizePath(path)); + await ensureVaultFolder(destination, folder); const attachments: ComposerAttachment[] = []; for (const file of files) { const filename = attachmentFilename(file, options.now?.() ?? new Date()); - const path = await uniqueAttachmentPath(vault, folder, filename); + const path = await uniqueVaultPath(destination, folder, filename); await vault.createBinary(path, await file.arrayBuffer()); const kind = isImageFile(file, path) ? "image" : "file"; attachments.push({ @@ -60,47 +68,14 @@ async function saveComposerAttachmentFiles( return attachments; } -function attachmentFolderPath(value: string): string { - const raw = (value.trim() || DEFAULT_ATTACHMENT_FOLDER).replaceAll("\\", "/"); - if (raw.startsWith("/") || /^[A-Za-z]:\//.test(raw)) throw new Error("Attachment folder must be relative to the vault."); - - const rawSegments = raw - .split("/") - .map((segment) => segment.trim()) - .filter(Boolean); - if (rawSegments.length === 0) return DEFAULT_ATTACHMENT_FOLDER; - if (rawSegments.some((segment) => segment === "." || segment === "..")) { - throw new Error("Attachment folder cannot contain relative path segments."); - } - - const segments = rawSegments.map((segment) => sanitizePathSegment(segment.trim())).filter(Boolean); - if (segments.length === 0) return DEFAULT_ATTACHMENT_FOLDER; - - const folder = normalizePath(segments.join("/")); - if (!folder) return DEFAULT_ATTACHMENT_FOLDER; - if (folder.split("/").some((segment) => segment === "." || segment === "..")) { - throw new Error("Attachment folder cannot contain relative path segments."); - } - return folder; -} - -async function ensureFolder(vault: Vault, folder: string): Promise { - const segments = folder.split("/"); - for (let index = 0; index < segments.length; index += 1) { - const path = segments.slice(0, index + 1).join("/"); - if (!vault.getAbstractFileByPath(path)) await vault.createFolder(path); - } -} - -async function uniqueAttachmentPath(vault: Vault, folder: string, filename: string): Promise { - const { stem, extension } = splitFilename(filename); - let candidate = normalizePath(`${folder}/${filename}`); - let suffix = 1; - while (vault.getAbstractFileByPath(candidate)) { - candidate = normalizePath(`${folder}/${stem} ${String(suffix)}${extension}`); - suffix += 1; - } - return candidate; +function attachmentFolderPath(value: string, normalizePath: (path: string) => string): string { + return vaultRelativeFolderPath(value, { + normalizePath, + emptyFallback: DEFAULT_ATTACHMENT_FOLDER, + emptyPathMessage: "Attachment folder produced an empty path.", + absolutePathMessage: "Attachment folder must be relative to the vault.", + relativeSegmentMessage: "Attachment folder cannot contain relative path segments.", + }); } function attachmentFilename(file: File, now: Date): string { @@ -116,27 +91,11 @@ function generatedAttachmentFilename(file: File, now: Date): string { function sanitizeFilename(value: string): string { const normalized = value.replace(/[\\/]+/g, "-").trim(); - const sanitized = sanitizePathSegment(normalized); + const sanitized = sanitizeVaultPathSegment(normalized); if (sanitized && sanitized !== "." && sanitized !== "..") return sanitized; return ""; } -function sanitizePathSegment(value: string): string { - return value - .split("") - .map((char) => (isUnsafePathChar(char) ? "-" : char)) - .join("") - .replace(/\s+/g, " ") - .trim() - .replace(/^\.+$/, "") - .slice(0, 120) - .trim(); -} - -function isUnsafePathChar(char: string): boolean { - return char.charCodeAt(0) < 32 || '<>:"/\\|?*[]#^'.includes(char); -} - function splitFilename(filename: string): { stem: string; extension: string } { const dotIndex = filename.lastIndexOf("."); if (dotIndex <= 0) return { stem: filename, extension: "" }; diff --git a/src/features/chat/host/obsidian/web-clipper.obsidian.ts b/src/features/chat/host/obsidian/web-clipper.obsidian.ts index b61444f2..1d9be35b 100644 --- a/src/features/chat/host/obsidian/web-clipper.obsidian.ts +++ b/src/features/chat/host/obsidian/web-clipper.obsidian.ts @@ -1,11 +1,12 @@ import Defuddle from "defuddle/full"; -import { normalizePath, requestUrl, TFile, type Vault } from "obsidian"; +import { requestUrl, TFile, type Vault } from "obsidian"; import type { CodexInput } from "../../../../domain/chat/input"; import { codexTextInputWithAttachments } from "../../../../domain/chat/input"; import type { CodexPanelSettings } from "../../../../settings/model"; +import { createObsidianVaultMarkdownDestination } from "../../../../shared/obsidian/vault-write-destination.obsidian"; import type { ComposerInputSnapshot } from "../../application/composer/input-snapshot"; -import { saveWebClipMarkdown, type WebClipDestination } from "../../application/web-clipping/web-clip"; +import { saveWebClipMarkdown } from "../../application/web-clipping/web-clip"; import { displayNameForFile } from "./vault-note-links.obsidian"; export interface WebClipInput { @@ -50,7 +51,7 @@ async function clipUrlToInput( const content = result.content.trim(); if (!content) throw new Error(`No readable content found for ${parsedUrl}`); - const destination = obsidianWebClipDestination(options.vault); + const destination = createObsidianVaultMarkdownDestination(options.vault); const page = { url: parsedUrl, title: result.title, @@ -84,19 +85,6 @@ async function defuddleUrl(options: VaultWebClipperOptions, url: string): Promis }; } -function obsidianWebClipDestination(vault: Vault): WebClipDestination { - return { - normalizePath, - exists: async (path: string): Promise => vault.getAbstractFileByPath(normalizePath(path)) !== null, - createFolder: async (path: string): Promise => { - await vault.createFolder(normalizePath(path)); - }, - createMarkdownFile: async (path: string, content: string): Promise => { - await vault.create(normalizePath(path), content); - }, - }; -} - function normalizedHttpUrl(value: string): string | null { try { const url = new URL(value); diff --git a/src/features/threads/obsidian/archive-export-destination.obsidian.ts b/src/features/threads/obsidian/archive-export-destination.obsidian.ts index cac5516e..a229ae3a 100644 --- a/src/features/threads/obsidian/archive-export-destination.obsidian.ts +++ b/src/features/threads/obsidian/archive-export-destination.obsidian.ts @@ -1,14 +1,7 @@ -import { normalizePath, type Vault } from "obsidian"; +import type { Vault } from "obsidian"; +import { createObsidianVaultMarkdownDestination } from "../../../shared/obsidian/vault-write-destination.obsidian"; +import type { ArchiveExportDestination } from "../workflows/archive-export"; -export function createObsidianArchiveExportDestination(vault: Vault) { - return { - normalizePath, - exists: async (path: string): Promise => vault.getAbstractFileByPath(normalizePath(path)) !== null, - createFolder: async (path: string): Promise => { - await vault.createFolder(normalizePath(path)); - }, - createMarkdownFile: async (path: string, content: string): Promise => { - await vault.create(normalizePath(path), content); - }, - }; +export function createObsidianArchiveExportDestination(vault: Vault): ArchiveExportDestination { + return createObsidianVaultMarkdownDestination(vault); } diff --git a/src/features/threads/workflows/archive-export.ts b/src/features/threads/workflows/archive-export.ts index d7d3887e..3b1f1867 100644 --- a/src/features/threads/workflows/archive-export.ts +++ b/src/features/threads/workflows/archive-export.ts @@ -1,17 +1,19 @@ import { type ArchiveExportSettings, type ArchiveThreadInput, archivedThreadMarkdown } from "../../../domain/threads/archive-markdown"; import { shortThreadId } from "../../../domain/threads/id"; import { threadArchiveTitle } from "../../../domain/threads/title"; +import { + ensureVaultFolder, + sanitizeVaultPathSegment, + uniqueVaultPath, + type VaultMarkdownDestination, + vaultRelativeFolderPath, +} from "../../../domain/vault/write-paths"; export interface ArchiveExportResult { path: string; } -export interface ArchiveExportDestination { - normalizePath(path: string): string; - exists(path: string): Promise; - createFolder(path: string): Promise; - createMarkdownFile(path: string, content: string): Promise; -} +export type ArchiveExportDestination = VaultMarkdownDestination; interface TemplateContext { date: string; @@ -31,21 +33,21 @@ export async function exportArchivedThreadMarkdown( const normalizePath = (path: string): string => destination.normalizePath(path); const folder = folderPathFromTemplate(settings.archiveExportFolderTemplate, context, normalizePath); const filename = filenameFromTemplate(settings.archiveExportFilenameTemplate, context, normalizePath); - await ensureFolder(destination, folder); + await ensureVaultFolder(destination, folder); - const path = await uniqueMarkdownPath(destination, folder, filename, normalizePath); + const path = await uniqueVaultPath(destination, folder, filename); await destination.createMarkdownFile(path, archivedThreadMarkdown(thread, now, settings)); return { path }; } function templateContext(thread: ArchiveThreadInput, now: Date): TemplateContext { - const title = sanitizePathSegment(threadArchiveTitle(thread)); + const title = sanitizeVaultPathSegment(threadArchiveTitle(thread)); return { date: formatDate(now), time: formatTime(now), title, - id: sanitizePathSegment(thread.id), - shortId: sanitizePathSegment(shortThreadId(thread.id)), + id: sanitizeVaultPathSegment(thread.id), + shortId: sanitizeVaultPathSegment(shortThreadId(thread.id)), }; } @@ -55,82 +57,25 @@ function expandTemplate(template: string, context: TemplateContext): string { function folderPathFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string { const expanded = expandTemplate(template, context).trim().replaceAll("\\", "/"); - if (!expanded) throw new Error("Archive export folder template produced an empty path."); - if (expanded.startsWith("/") || /^[A-Za-z]:\//.test(expanded)) { - throw new Error("Archive export folder must be relative to the vault."); - } - - const segments = expanded - .split("/") - .map((segment) => segment.trim()) - .filter(Boolean); - if (segments.length === 0) throw new Error("Archive export folder template produced an empty path."); - if (segments.some((segment) => segment === "." || segment === "..")) { - throw new Error("Archive export folder cannot contain relative path segments."); - } - const folder = normalizePath(segments.map(sanitizePathSegment).join("/")); - if (!folder) throw new Error("Archive export folder template produced an empty path."); - if (folder.split("/").some((segment) => segment === "." || segment === "..")) { - throw new Error("Archive export folder cannot contain relative path segments."); - } - return folder; + return vaultRelativeFolderPath(expanded, { + normalizePath, + emptyPathMessage: "Archive export folder template produced an empty path.", + absolutePathMessage: "Archive export folder must be relative to the vault.", + relativeSegmentMessage: "Archive export folder cannot contain relative path segments.", + }); } function filenameFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string { const expanded = expandTemplate(template, context) .trim() .replace(/[\\/]+/g, "-"); - const filename = normalizePath(sanitizePathSegment(expanded)); + const filename = normalizePath(sanitizeVaultPathSegment(expanded)); if (!filename || filename === "." || filename === "..") { throw new Error("Archive export filename template produced an empty filename."); } return filename.toLowerCase().endsWith(".md") ? filename : `${filename}.md`; } -async function ensureFolder(destination: ArchiveExportDestination, folder: string): Promise { - const segments = folder.split("/"); - for (let index = 0; index < segments.length; index += 1) { - const path = segments.slice(0, index + 1).join("/"); - if (!(await destination.exists(path))) { - await destination.createFolder(path); - } - } -} - -async function uniqueMarkdownPath( - destination: ArchiveExportDestination, - folder: string, - filename: string, - normalizePath: (path: string) => string, -): Promise { - const dotIndex = filename.toLowerCase().endsWith(".md") ? filename.length - 3 : filename.length; - const stem = filename.slice(0, dotIndex); - const extension = filename.slice(dotIndex); - let candidate = normalizePath(`${folder}/${filename}`); - let suffix = 2; - while (await destination.exists(candidate)) { - candidate = normalizePath(`${folder}/${stem} ${String(suffix)}${extension}`); - suffix += 1; - } - return candidate; -} - -function sanitizePathSegment(value: string): string { - return value - .split("") - .map((char) => (isUnsafePathChar(char) ? "-" : char)) - .join("") - .replace(/\s+/g, " ") - .trim() - .replace(/^\.+$/, "") - .slice(0, 120) - .trim(); -} - -function isUnsafePathChar(char: string): boolean { - return char.charCodeAt(0) < 32 || '<>:"/\\|?*'.includes(char); -} - function formatDate(date: Date): string { return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; } diff --git a/src/shared/obsidian/vault-write-destination.obsidian.ts b/src/shared/obsidian/vault-write-destination.obsidian.ts new file mode 100644 index 00000000..5e555cf5 --- /dev/null +++ b/src/shared/obsidian/vault-write-destination.obsidian.ts @@ -0,0 +1,22 @@ +import { normalizePath, type Vault } from "obsidian"; + +import type { VaultMarkdownDestination, VaultPathDestination } from "../../domain/vault/write-paths"; + +export function createObsidianVaultPathDestination(vault: Vault): VaultPathDestination { + return { + normalizePath, + exists: async (path: string): Promise => vault.getAbstractFileByPath(normalizePath(path)) !== null, + createFolder: async (path: string): Promise => { + await vault.createFolder(normalizePath(path)); + }, + }; +} + +export function createObsidianVaultMarkdownDestination(vault: Vault): VaultMarkdownDestination { + return { + ...createObsidianVaultPathDestination(vault), + createMarkdownFile: async (path: string, content: string): Promise => { + await vault.create(normalizePath(path), content); + }, + }; +} diff --git a/tests/domain/vault/write-paths.test.ts b/tests/domain/vault/write-paths.test.ts new file mode 100644 index 00000000..9cf25e89 --- /dev/null +++ b/tests/domain/vault/write-paths.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + sanitizeVaultPathSegment, + uniqueVaultPath, + type VaultPathDestination, + vaultRelativeFolderPath, +} from "../../../src/domain/vault/write-paths"; + +describe("vault write paths", () => { + it("sanitizes Obsidian path and subpath marker characters", () => { + expect(sanitizeVaultPathSegment("Topic/[draft]#section^block?")).toBe("Topic--draft--section-block-"); + }); + + it("validates vault-relative folders before sanitizing relative segments", () => { + expect(() => + vaultRelativeFolderPath("../outside", { + normalizePath: (path) => path, + emptyPathMessage: "empty", + absolutePathMessage: "absolute", + relativeSegmentMessage: "relative", + }), + ).toThrow("relative"); + }); + + it("starts collision suffixes at 2 for generated vault paths", async () => { + const destination = memoryDestination(["Files/Paper.pdf"]); + + await expect(uniqueVaultPath(destination, "Files", "Paper.pdf")).resolves.toBe("Files/Paper 2.pdf"); + }); + + it("increments collision suffixes until an unused vault path is found", async () => { + const destination = memoryDestination(["Files/Paper.pdf", "Files/Paper 2.pdf"]); + + await expect(uniqueVaultPath(destination, "Files", "Paper.pdf")).resolves.toBe("Files/Paper 3.pdf"); + }); +}); + +function memoryDestination(existingPaths: readonly string[]): Pick { + const paths = new Set(existingPaths); + return { + normalizePath: (path) => path.replace(/[\\/]+/g, "/").replace(/^\/+|\/+$/g, ""), + exists: vi.fn(async (path) => paths.has(path)), + }; +} diff --git a/tests/features/chat/host/composer-attachments.test.ts b/tests/features/chat/host/composer-attachments.test.ts index 16a7851a..444a2114 100644 --- a/tests/features/chat/host/composer-attachments.test.ts +++ b/tests/features/chat/host/composer-attachments.test.ts @@ -27,8 +27,8 @@ describe("vault composer attachments", () => { ]); }); - it("keeps known filenames and appends a numeric suffix when the target exists", async () => { - const vault = vaultFixture(["Files/Paper.pdf", "Files/Paper 1.pdf"]); + it("keeps known filenames and starts numeric collision suffixes at 2", async () => { + const vault = vaultFixture(["Files/Paper.pdf"]); const handler = createVaultComposerAttachmentHandler({ app: { vault } as never, attachmentFolder: () => "Files",