mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Align vault save path behavior
This commit is contained in:
parent
95496f356f
commit
3c684d239b
9 changed files with 238 additions and 240 deletions
98
src/domain/vault/write-paths.ts
Normal file
98
src/domain/vault/write-paths.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: "" };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())}`;
|
||||
}
|
||||
|
|
|
|||
22
src/shared/obsidian/vault-write-destination.obsidian.ts
Normal file
22
src/shared/obsidian/vault-write-destination.obsidian.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
45
tests/domain/vault/write-paths.test.ts
Normal file
45
tests/domain/vault/write-paths.test.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue