Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802// 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<string, TFile> pathQualifiedTargets: Map<string, ResolvedRenameTarget>}
interface RenameFileFailure { sourcePath: string phase: 'planning' | 'rewrite' detail: string}
const metadataCacheByVault = new WeakMap<Vault, MetadataCache>()
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<string> { 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<void> { 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<PendingLinkRewrite[]> { 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<string, PendingLinkRewrite>()
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<boolean> { 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<void> { 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<string, CachedReference[]>() 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<string, TFile>, pathQualifiedTargets: Map<string, ResolvedRenameTarget>, 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<string, TFile>, pathQualifiedTargets: Map<string, ResolvedRenameTarget>, 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<string, TFile>, pathQualifiedTargets: Map<string, ResolvedRenameTarget>, 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<boolean> { 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<void> { 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<void> { 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<string, unknown> 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)}