// src/plugin/file-manager.ts /** * FileManager — Obsidian-compatible shim over Vault. * * Most methods delegate to existing Reams services. * `renameFile()` also rewrites known cached inbound links on a best-effort basis. */ import yaml from 'js-yaml' import type { Vault } from './vault' import { TFile } from './files' import type { TFolder } from './files' import type { TAbstractFile } from './files' import type { DataWriteOptions } from './obsidian-stubs' import { getLinkpath, normalizePath, parseLinktext } from './obsidian-stubs' import { extractMetadata, parseFrontmatter } from '../services/metadata/extract-metadata' import type { CachedMetadata, MetadataCache, EmbedCache, LinkCache } from '../services/metadata' import { contentTypeForFileSync } from './files/content-type-for-file' import { conformsTo, KnownContentTypes } from '../types/content-types' function getParentPath(path: string): string { const normalized = normalizePath(path) const lastSlash = normalized.lastIndexOf('/') return lastSlash >= 0 ? normalized.slice(0, lastSlash) : '' } function isFolder(file: TAbstractFile | null | undefined): file is TFolder { return Boolean(file && 'children' in file) } function getPathQualifiedLinkTarget(file: TFile): string { return file.extension === 'md' ? file.path.replace(/\.md$/i, '') : file.path } function getDefaultLinkTarget(file: TFile): string { return file.extension === 'md' ? file.basename : file.name } function normalizeMarkdownLinkTarget(target: string): string { return target.toLowerCase().replace(/\.md$/i, '') } function getRetryReferenceKey(reference: CachedReference): string { const { path, subpath } = parseLinktext(reference.link) return `${normalizeMarkdownLinkTarget(path)}${subpath}` } type CachedReference = LinkCache | EmbedCache interface ResolvedRenameTarget { oldTargetPath: string targetFile: TFile } interface PendingLinkRewrite { sourcePath: string sourceFile: TFile references: Array<{ oldTargetPath: string targetFile: TFile reference: CachedReference }> } interface RenameTargetMaps { targetsByOldPath: Map pathQualifiedTargets: Map } interface RenameFileFailure { sourcePath: string phase: 'planning' | 'rewrite' detail: string } const metadataCacheByVault = new WeakMap() class RenameFileBestEffortError extends Error { readonly failures: RenameFileFailure[] constructor(originalPath: string, newPath: string, failures: RenameFileFailure[]) { const summary = failures .map(({ sourcePath, phase, detail }) => `- ${sourcePath} (${phase}): ${detail}`) .join('\n') super( `FileManager.renameFile() renamed "${originalPath}" to "${newPath}" but encountered ${failures.length} source failure(s):\n${summary}` ) this.name = 'RenameFileBestEffortError' this.failures = failures } } function getErrorDetail(error: unknown): string { if (error instanceof Error && error.message) { return error.message } return String(error) } function buildUpdatedReference( fileManager: FileManager, targetFile: TFile, sourceFile: TFile, reference: CachedReference ): string { const aliasSeparator = reference.original.indexOf('|') const alias = aliasSeparator === -1 ? undefined : reference.original.slice(aliasSeparator + 1, -2) const { subpath } = parseLinktext(reference.link) const rewritten = fileManager.generateMarkdownLink( targetFile, sourceFile.path, subpath || undefined, alias ) return reference.original.startsWith('![[') ? `!${rewritten}` : rewritten } function describeReferenceLocation(sourceFile: TFile, reference: CachedReference): string { const start = reference.position.start.offset const end = reference.position.end.offset return `${sourceFile.path}:${start}-${end}` } function createStaleReferenceError(sourceFile: TFile, reference: CachedReference): Error { return new Error( `FileManager.renameFile() skipped rewriting ${describeReferenceLocation(sourceFile, reference)} because cached metadata is stale` ) } function createUnrecoverableCandidateError(sourcePath: string): Error { return new Error( `cached backlink candidate could not be reconciled with current content for "${sourcePath}"` ) } function createRewriteRefreshError(sourcePath: string): Error { return new Error( `cached backlink candidate could not be refreshed during rewrite for "${sourcePath}"` ) } function isStaleReferenceError(error: unknown): boolean { return error instanceof Error && error.message.startsWith('FileManager.renameFile() skipped rewriting ') } function rewriteResolvedReferences( fileManager: FileManager, content: string, sourceFile: TFile, references: PendingLinkRewrite['references'] ): string { let nextContent = content for (const { targetFile, reference } of [...references].sort( (a, b) => b.reference.position.start.offset - a.reference.position.start.offset, )) { const start = reference.position.start.offset const end = reference.position.end.offset if (nextContent.slice(start, end) !== reference.original) { throw createStaleReferenceError(sourceFile, reference) } nextContent = nextContent.slice(0, start) + buildUpdatedReference(fileManager, targetFile, sourceFile, reference) + nextContent.slice(end) } return nextContent } function fileManagerUnsupported(methodName: string, detail: string): never { throw new Error(`FileManager.${methodName}() is not supported in Reams: ${detail}`) } export class FileManager { /** @internal - not part of Obsidian's public API */ #vault: Vault /** * @internal - FileManager instances are created internally by the App. * Plugins should access FileManager via `app.fileManager`, not by constructing directly. */ protected constructor(vault: Vault) { this.#vault = vault } /** * Gets the folder that new files should be saved to, given the user's preferences. * @param sourcePath - The path to the current open/focused file, * used when the user wants new files to be created 'in the same folder'. * Use an empty string if there is no active file. * @param newFilePath - The path to the file that will be newly created, * used to infer what settings to use based on the path's extension. * @public * @since 1.1.13 */ getNewFileParent(sourcePath: string, newFilePath?: string): TFolder { const explicitParent = newFilePath ? this.#vault.getAbstractFileByPath(getParentPath(newFilePath)) : null if (isFolder(explicitParent)) { return explicitParent } const source = this.#vault.getAbstractFileByPath(normalizePath(sourcePath)) if (isFolder(source)) { return source } if (source?.parent) { return source.parent } const inferredParent = this.#vault.getAbstractFileByPath(getParentPath(sourcePath)) if (isFolder(inferredParent)) { return inferredParent } return this.#vault.getRoot() } /** * Resolves a unique path for the attachment file being saved. * Ensures that the parent directory exists and dedupes the * filename if the destination filename already exists. * * @param filename Name of the attachment being saved * @param sourcePath The path to the note associated with this attachment, defaults to the workspace's active file. * @returns Full path for where the attachment should be saved, according to the user's settings * @public * @since 1.5.7 */ async getAvailablePathForAttachment(filename: string, sourcePath?: string): Promise { fileManagerUnsupported( 'getAvailablePathForAttachment', `attachment folder preferences are not implemented yet, so Reams cannot honestly choose a destination for "${filename}"${sourcePath ? ` from source "${sourcePath}"` : ''}` ) } /** * Rename or move a file, then best-effort rewrite inbound links from known metadata-cache candidates. * Rejects with a summary error after rename completes if any candidate sources could not be read or rewritten. * @param file - the file to rename * @param newPath - the new path for the file * @public * @since 0.11.0 */ async renameFile(file: TAbstractFile, newPath: string): Promise { const originalPath = file.path const metadataCache = this.#getMetadataCacheOrThrow(file) const renameTargets = this.#getRenameTargets(file) const renameTargetMaps = this.#createRenameTargetMaps(renameTargets) const failures: RenameFileFailure[] = [] const plannedRewrites = await this.#planLinkRewrites(renameTargets, renameTargetMaps, metadataCache, failures) await this.#vault.rename(file, newPath) for (const rewrite of plannedRewrites) { try { await this.#applyPlannedRewrite(rewrite) } catch (error) { failures.push({ sourcePath: rewrite.sourcePath, phase: 'rewrite', detail: getErrorDetail(error), }) } } if (failures.length > 0) { throw new RenameFileBestEffortError(originalPath, newPath, failures) } } async #planLinkRewrites( renameTargets: Array<{ file: TFile; oldPath: string }>, renameTargetMaps: RenameTargetMaps, metadataCache: MetadataCache, failures: RenameFileFailure[], ): Promise { const planned: PendingLinkRewrite[] = [] for (const candidate of this.#getRenameRewriteCandidates(renameTargets, metadataCache)) { try { const originalContent = await this.#vault.read(candidate.sourceFile) const references = this.#getUpToDateCandidateReferences( candidate.sourceFile, originalContent, renameTargetMaps.targetsByOldPath, renameTargetMaps.pathQualifiedTargets, metadataCache, ) if (references.length === 0) { continue } planned.push({ sourcePath: candidate.sourcePath, sourceFile: candidate.sourceFile, references }) } catch (error) { failures.push({ sourcePath: candidate.sourcePath, phase: 'planning', detail: getErrorDetail(error), }) } } return planned } #getRenameRewriteCandidates( renameTargets: Array<{ file: TFile; oldPath: string }>, metadataCache: MetadataCache, ): PendingLinkRewrite[] { const candidates = new Map() for (const [sourcePath, resolvedTargets] of Object.entries(metadataCache.resolvedLinks)) { const sourceFile = this.#vault.getFileByPath(sourcePath) if (!sourceFile) { continue } // #406: link-rewrite operates on text content. Skip non-text source // files so a stray entry in resolvedLinks cannot trigger a string read // against a binary blob. if (!conformsTo(contentTypeForFileSync(sourceFile), KnownContentTypes.PlainText)) { continue } if (!renameTargets.some(({ oldPath }) => resolvedTargets[oldPath] !== undefined)) { continue } candidates.set(sourcePath, { sourcePath, sourceFile, references: [], }) } return [...candidates.values()] } #getMetadataCacheOrThrow(file: TAbstractFile): MetadataCache { const metadataCache = metadataCacheByVault.get(this.#vault) if (!metadataCache) { throw new Error( `FileManager.renameFile() requires a wired MetadataCache to rewrite inbound links for "${file.path}"` ) } return metadataCache } #getRenameTargets(file: TAbstractFile): Array<{ file: TFile; oldPath: string }> { if (file instanceof TFile) { return [{ file, oldPath: file.path }] } if (!isFolder(file)) { return [] } const targets: Array<{ file: TFile; oldPath: string }> = [] const visit = (folder: TFolder): void => { for (const child of folder.children) { if (child instanceof TFile) { targets.push({ file: child, oldPath: child.path }) } else if (isFolder(child)) { visit(child) } } } visit(file) return targets } #createRenameTargetMaps(renameTargets: Array<{ file: TFile; oldPath: string }>): RenameTargetMaps { return { targetsByOldPath: new Map(renameTargets.map(({ file, oldPath }) => [oldPath, file])), pathQualifiedTargets: new Map( renameTargets.map(({ file, oldPath }) => [ normalizeMarkdownLinkTarget(oldPath), { oldTargetPath: oldPath, targetFile: file }, ]) ), } } async #applyResolvedReferenceRewrite( sourceFile: TFile, content: string, references: PendingLinkRewrite['references'], ): Promise { try { const updated = rewriteResolvedReferences(this, content, sourceFile, references) if (updated !== content) { await this.#vault.modify(sourceFile, updated) } return true } catch (error) { if (isStaleReferenceError(error)) { return false } throw error } } async #applyPlannedRewrite(rewrite: PendingLinkRewrite): Promise { const currentContent = await this.#vault.read(rewrite.sourceFile) const appliedPlannedRewrite = await this.#applyResolvedReferenceRewrite( rewrite.sourceFile, currentContent, rewrite.references, ) if (appliedPlannedRewrite) { return } const refreshedMetadata = this.#refreshSourceMetadata(currentContent) const refreshedReferences = this.#rebasePlannedReferences( rewrite.references, refreshedMetadata, ) if (refreshedReferences.length === 0) { return } const reappliedRefreshedRewrite = await this.#applyResolvedReferenceRewrite( rewrite.sourceFile, currentContent, refreshedReferences, ) if (!reappliedRefreshedRewrite) { throw createRewriteRefreshError(rewrite.sourcePath) } } #refreshSourceMetadata(content: string): CachedMetadata { return extractMetadata(content) } #sourceReferencesLookCurrent( content: string, references: PendingLinkRewrite['references'], ): boolean { return references.every(({ reference }) => { const start = reference.position.start.offset const end = reference.position.end.offset return content.slice(start, end) === reference.original }) } #getSourceCachedReferences(sourceCache: CachedMetadata): CachedReference[] { return [...(sourceCache.links ?? []), ...(sourceCache.embeds ?? [])] } #rebasePlannedReferences( plannedReferences: PendingLinkRewrite['references'], sourceCache: CachedMetadata | null, ): PendingLinkRewrite['references'] { if (!sourceCache) { return [] } const refreshedReferencesByKey = new Map() for (const reference of this.#getSourceCachedReferences(sourceCache)) { const key = getRetryReferenceKey(reference) const existing = refreshedReferencesByKey.get(key) if (existing) { existing.push(reference) } else { refreshedReferencesByKey.set(key, [reference]) } } const rebound: PendingLinkRewrite['references'] = [] for (const plannedReference of plannedReferences) { const refreshedCandidates = refreshedReferencesByKey.get(getRetryReferenceKey(plannedReference.reference)) const refreshedReference = refreshedCandidates?.shift() if (!refreshedReference) { continue } rebound.push({ ...plannedReference, reference: refreshedReference, }) } return rebound } #matchReferenceTarget( sourceFile: TFile, reference: CachedReference, targetsByOldPath: Map, pathQualifiedTargets: Map, metadataCache: MetadataCache, ): ResolvedRenameTarget | null { const linkpath = getLinkpath(reference.link) const markdownTarget = metadataCache.getFirstLinkpathDest(linkpath, sourceFile.path) if (markdownTarget) { const targetFile = targetsByOldPath.get(markdownTarget.path) return targetFile ? { oldTargetPath: markdownTarget.path, targetFile } : null } const exactPathTarget = pathQualifiedTargets.get(normalizeMarkdownLinkTarget(linkpath)) if (exactPathTarget) { return exactPathTarget } if (linkpath.includes('/')) { return null } const sourceDir = getParentPath(sourceFile.path).toLowerCase() const normalizedLinkpath = normalizeMarkdownLinkTarget(linkpath) const matchesBareLinkFallback = (oldPath: string, targetFile: TFile): boolean => { const oldName = oldPath.split('/').pop()?.toLowerCase() ?? '' const bareTarget = targetFile.extension === 'md' ? oldName.replace(/\.md$/i, '') : oldName return bareTarget === normalizedLinkpath } const siblingTarget = [...targetsByOldPath.entries()].find(([oldPath, targetFile]) => { return getParentPath(oldPath).toLowerCase() === sourceDir && matchesBareLinkFallback(oldPath, targetFile) }) if (siblingTarget) { return { oldTargetPath: siblingTarget[0], targetFile: siblingTarget[1] } } const rootTarget = [...targetsByOldPath.entries()].find(([oldPath, targetFile]) => { return !oldPath.includes('/') && matchesBareLinkFallback(oldPath, targetFile) }) if (rootTarget) { return { oldTargetPath: rootTarget[0], targetFile: rootTarget[1] } } const anywhereTarget = [...targetsByOldPath.entries()].find(([oldPath, targetFile]) => { return matchesBareLinkFallback(oldPath, targetFile) }) return anywhereTarget ? { oldTargetPath: anywhereTarget[0], targetFile: anywhereTarget[1] } : null } #collectDirectLinkRewritesFromCache( sourceFile: TFile, sourceCache: CachedMetadata, targetsByOldPath: Map, pathQualifiedTargets: Map, metadataCache: MetadataCache, ): PendingLinkRewrite['references'] { const references: PendingLinkRewrite['references'] = [] for (const reference of [...(sourceCache.links ?? []), ...(sourceCache.embeds ?? [])]) { const target = this.#matchReferenceTarget( sourceFile, reference, targetsByOldPath, pathQualifiedTargets, metadataCache, ) if (target) { references.push({ oldTargetPath: target.oldTargetPath, targetFile: target.targetFile, reference, }) } } return references } #getUpToDateCandidateReferences( sourceFile: TFile, content: string, targetsByOldPath: Map, pathQualifiedTargets: Map, metadataCache: MetadataCache, ): PendingLinkRewrite['references'] { const sourceCache = metadataCache.getFileCache(sourceFile) const cachedReferences = sourceCache ? this.#collectDirectLinkRewritesFromCache( sourceFile, sourceCache, targetsByOldPath, pathQualifiedTargets, metadataCache, ) : [] if (cachedReferences.length > 0 && this.#sourceReferencesLookCurrent(content, cachedReferences)) { return cachedReferences } const refreshedSourceCache = this.#refreshSourceMetadata(content) const refreshedReferences = this.#collectDirectLinkRewritesFromCache( sourceFile, refreshedSourceCache, targetsByOldPath, pathQualifiedTargets, metadataCache, ) if (this.#sourceReferencesLookCurrent(content, refreshedReferences) && refreshedReferences.length > 0) { return refreshedReferences } if (cachedReferences.length > 0 && refreshedReferences.length === 0) { return [] } // The graph also counts Record fields, which have no Markdown span to // rewrite. A Record-only candidate is not a failed text reconciliation. if (cachedReferences.length === 0 && refreshedReferences.length === 0) return [] throw createUnrecoverableCandidateError(sourceFile.path) } /** * Prompt the user to confirm they want to delete the specified file or folder * @param file - the file or folder to delete * @returns A promise that resolves to true if the prompt was confirmed or false if it was canceled * @public * @since 0.15.0 */ async promptForDeletion(file: TAbstractFile): Promise { fileManagerUnsupported( 'promptForDeletion', `interactive deletion confirmation is not implemented for "${file.path}"` ) } /** * Remove a file or a folder from the vault according the user's preferred 'trash' * options (either moving the file to .trash/ or the OS trash bin). * @param file * @public * @since 1.6.6 */ async trashFile(file: TAbstractFile): Promise { fileManagerUnsupported( 'trashFile', `trash preferences are not implemented, so Reams cannot honestly decide how to trash "${file.path}"` ) } /** * Generate a markdown wikilink to a file. * Produces [[basename]], [[basename#subpath]], [[basename|alias]], * or [[basename#subpath|alias]]. * * Matches the wikilink format used by src/editor/markdown/transformers.ts. */ generateMarkdownLink( file: TFile, sourcePath: string, subpath?: string, alias?: string ): string { const normalizedSourcePath = normalizePath(sourcePath) const source = this.#vault.getAbstractFileByPath(normalizedSourcePath.replace(/\/$/, '')) const sourceFolderPath = isFolder(source) ? source.path : getParentPath(normalizedSourcePath) const sameFile = normalizedSourcePath === file.path const sameFolder = sourceFolderPath === getParentPath(file.path) let target = sameFile && subpath ? '' : sameFolder ? getDefaultLinkTarget(file) : getPathQualifiedLinkTarget(file) let link = target if (subpath) { const cleanSubpath = subpath.startsWith('#') ? subpath.slice(1) : subpath link += `#${cleanSubpath}` } if (alias) { link += `|${alias}` } return `[[${link}]]` } /** * Read, modify, and save the frontmatter of a note. * The frontmatter is passed in as a JS object, and should be mutated directly to achieve the desired result. * * Remember to handle errors thrown by this method. * * @param file - the file to be modified. Must be a Markdown file. * @param fn - a callback function which mutates the frontmatter object synchronously. * @param options - write options. * @throws YAMLParseError if the YAML parsing fails * @throws any errors that your callback function throws * @example * ```ts * app.fileManager.processFrontMatter(file, (frontmatter) => { * frontmatter['key1'] = value; * delete frontmatter['key2']; * }); * ``` * @public * @since 1.4.4 */ async processFrontMatter( file: TFile, fn: (frontmatter: any) => void, options?: DataWriteOptions ): Promise { const content = await this.#vault.read(file) const parsed = parseFrontmatter(content) const newline = parsed?.newline || (content.includes('\r\n') ? '\r\n' : content.includes('\r') ? '\r' : '\n') let data: Record let bodyContent: string const hadFrontmatter = parsed !== null if (parsed) { if (!parsed.isValidYaml) { throw new Error(`Cannot process malformed frontmatter in ${file.path}`) } if (!parsed.isMapping) { throw new Error(`Cannot process non-object frontmatter in ${file.path}`) } data = { ...parsed.data } bodyContent = content.slice(parsed.endOffset) } else { data = {} bodyContent = content } // Let the callback mutate the data fn(data) // Serialize back const yamlStr = Object.keys(data).length > 0 ? yaml.dump(data, { lineWidth: -1, quotingType: '"', forceQuotes: false }) : '' // Reconstruct the file content let newContent: string if (yamlStr) { newContent = `---${newline}${yamlStr.replace(/\n/g, newline)}---${newline}${bodyContent}` } else if (hadFrontmatter) { // File originally had frontmatter — preserve empty delimiters (Obsidian behavior) newContent = `---${newline}---${newline}${bodyContent}` } else { // No frontmatter keys and file never had frontmatter — body only newContent = bodyContent } await this.#vault.modify(file, newContent, options) } } /** * Internal subclass to access protected constructor. * Defined at module scope to avoid class recreation on every createFileManager() call. * @internal */ class FileManagerFactory extends FileManager { constructor(vault: Vault) { super(vault) } } /** * Internal factory function to create FileManager instances. * This is NOT part of the public Obsidian API. * * Exists to allow internal Reams code (App, tests) to create FileManager instances * while keeping the constructor protected to match Obsidian's public API contract. * * @internal - Not exported from the 'obsidian' module. */ export function createFileManager(vault: Vault): FileManager { // FileManagerFactory extends FileManager, so instanceof FileManager will work. return new FileManagerFactory(vault) } /** @internal Host wiring for rename-time link updates. */ export function setFileManagerMetadataCache(vault: Vault, metadataCache: MetadataCache): void { metadataCacheByVault.set(vault, metadataCache) }