Align vault save path behavior

This commit is contained in:
murashit 2026-07-07 07:45:20 +09:00
parent 95496f356f
commit 3c684d239b
9 changed files with 238 additions and 240 deletions

View file

@ -0,0 +1,98 @@
export interface VaultPathDestination {
normalizePath(path: string): string;
exists(path: string): Promise<boolean>;
createFolder(path: string): Promise<void>;
}
export interface VaultMarkdownDestination extends VaultPathDestination {
createMarkdownFile(path: string, content: string): Promise<void>;
}
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<void> {
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<VaultPathDestination, "normalizePath" | "exists">,
folder: string,
filename: string,
options: UniqueVaultPathOptions = {},
): Promise<string> {
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),
};
}

View file

@ -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<boolean>;
createFolder(path: string): Promise<void>;
createMarkdownFile(path: string, content: string): Promise<void>;
}
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<WebClipSetting
}
function templateContext(page: WebClipPage, now: Date): TemplateContext {
const title = sanitizePathSegment(normalizedDisplayTitle(page.title));
const title = sanitizeVaultPathSegment(normalizedDisplayTitle(page.title));
return {
date: formatDate(now),
time: formatTime(now),
title,
site: sanitizePathSegment(page.site?.trim() || ""),
domain: sanitizePathSegment(page.domain?.trim() || hostnameFromUrl(page.url) || ""),
site: sanitizeVaultPathSegment(page.site?.trim() || ""),
domain: sanitizeVaultPathSegment(page.domain?.trim() || hostnameFromUrl(page.url) || ""),
};
}
@ -86,62 +89,23 @@ function expandTemplate(template: string, context: TemplateContext): string {
}
function folderPath(value: string, normalizePath: (path: string) => 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<void> {
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<string> {
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<string>();
@ -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;

View file

@ -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<void> {
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<string> {
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: "" };

View file

@ -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<boolean> => vault.getAbstractFileByPath(normalizePath(path)) !== null,
createFolder: async (path: string): Promise<void> => {
await vault.createFolder(normalizePath(path));
},
createMarkdownFile: async (path: string, content: string): Promise<void> => {
await vault.create(normalizePath(path), content);
},
};
}
function normalizedHttpUrl(value: string): string | null {
try {
const url = new URL(value);

View file

@ -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<boolean> => vault.getAbstractFileByPath(normalizePath(path)) !== null,
createFolder: async (path: string): Promise<void> => {
await vault.createFolder(normalizePath(path));
},
createMarkdownFile: async (path: string, content: string): Promise<void> => {
await vault.create(normalizePath(path), content);
},
};
export function createObsidianArchiveExportDestination(vault: Vault): ArchiveExportDestination {
return createObsidianVaultMarkdownDestination(vault);
}

View file

@ -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<boolean>;
createFolder(path: string): Promise<void>;
createMarkdownFile(path: string, content: string): Promise<void>;
}
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<void> {
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<string> {
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())}`;
}

View file

@ -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<boolean> => vault.getAbstractFileByPath(normalizePath(path)) !== null,
createFolder: async (path: string): Promise<void> => {
await vault.createFolder(normalizePath(path));
},
};
}
export function createObsidianVaultMarkdownDestination(vault: Vault): VaultMarkdownDestination {
return {
...createObsidianVaultPathDestination(vault),
createMarkdownFile: async (path: string, content: string): Promise<void> => {
await vault.create(normalizePath(path), content);
},
};
}

View file

@ -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<VaultPathDestination, "normalizePath" | "exists"> {
const paths = new Set(existingPaths);
return {
normalizePath: (path) => path.replace(/[\\/]+/g, "/").replace(/^\/+|\/+$/g, ""),
exists: vi.fn(async (path) => paths.has(path)),
};
}

View file

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