Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913// src/plugin/vault.tsimport type { AutomergeUrl, DocHandle } from '../storage/document-runtime-contract'import { Events } from './events'import type { EventRef } from './events'import { TFile, TFolder, TAbstractFile, type FileStats } from './files'import { DocumentManagerExistingEntryError, type DocumentManager, type FixedWidthTextEditHint, type TextWriteResult } from '../services/document-manager'import type { Heads } from '../sync/heads'import type { FileIndex } from '../services/file-index'import { VaultUserEntries, REAMS_STATE_FOLDER_NAME } from '../services/vault-user-entries'import { ImageResolver } from '../services/image-resolver'import { isFileEntry, isFolderEntry, isPushworkDirectory, isPushworkFile, REAMS_STATE_MARKER, mimeTypeFromExtension,} from '../types/pushwork'import { conformsTo, KnownContentTypes } from '../types/content-types'import type { DataAdapter, DataWriteOptions } from './obsidian-stubs'
export function validateCanonicalLeafName(fileName: string): string { // eslint-disable-next-line no-control-regex if (typeof fileName !== 'string' || fileName.trim() === '' || fileName === '.' || fileName === '..' || /[\/\\\u0000-\u001f\u007f-\u009f]/.test(fileName)) { throw new Error(`Invalid file name: '${fileName}'`) } return fileName}
function isCanonicalAdoptionSegment(value: string): boolean { // eslint-disable-next-line no-control-regex return value.length > 0 && value !== '.' && value !== '..' && !/[\/\\\u0000-\u001f\u007f-\u009f]/.test(value)}
const folderPathMutationTails = new WeakMap<object, Map<string, Promise<void>>>()
async function withFolderPathMutationLock<T>(owner: object, path: string, operation: () => Promise<T>): Promise<T> { let locks = folderPathMutationTails.get(owner) if (!locks) { locks = new Map() folderPathMutationTails.set(owner, locks) } const previous = locks.get(path) ?? Promise.resolve() let release!: () => void const current = new Promise<void>(resolve => { release = resolve }) locks.set(path, current) await previous try { return await operation() } finally { release() if (locks.get(path) === current) locks.delete(path) }}
/** * Local extension of Obsidian's {@link DataWriteOptions} adding an optional * `contentType` that Reams callers can use to pin the new file's UTI-style * content type (e.g. `"public.png"`). When omitted, the content type is * inferred from the file name / mime type. Forwarded through to * {@link DocumentManager.createBinaryFileInFolder}. * * @internal Reams-internal — not part of Obsidian's documented Vault API. */export interface ReamsDataWriteOptions extends DataWriteOptions { /** * UTI-style content type to pin on the underlying file document. When * absent, Reams infers a value from the file name and mime type. */ contentType?: string}
/** * Discriminator for {@link AdoptFolderError}. UI callers (CreateItemModal) * use this to render localized copy without string-matching on the message. * * @internal Reams-internal — not part of Obsidian's Vault API. */export type AdoptFolderErrorCode = | 'unreachable' | 'wrong-type' | 'collision' | 'anchor-missing' | 'already-adopted' | 'internal-state'
/** * Typed error surface for {@link Vault.adoptFolderByUrl}. The error class * lives here (vault-level concern) co-located with the orchestrator that * throws it. * * @internal Reams-internal — not part of Obsidian's Vault API. */export class AdoptFolderError extends Error { constructor(public readonly code: AdoptFolderErrorCode, message: string) { super(message) this.name = 'AdoptFolderError' }}
/** * Discriminator for {@link AdoptFileError}. UI callers (CreateItemModal, * the UrlRouter for foreign-URL adoption) use this to render localized * copy without string-matching on the message. * * @internal Reams-internal — not part of Obsidian's Vault API. */export type AdoptFileErrorCode = | 'unreachable' // repo.find rejected OR doc is not a Pushwork file | 'wrong-type' // doc is a folder, or otherwise not a recognizable file | 'invalid-name' // doc.name is empty/all-whitespace after sanitisation | 'already-adopted' // URL already in anchor's docs[] | 'anchor-missing' // anchorUrl is not the vault root and not in FileIndex | 'reconcile-timeout' // FileIndex did not surface the new file within timeout | 'internal-state' // URL belongs to the internal vault state subtree
/** * Timeout for FileIndex reconciliation after `addFileToFolder` in * {@link Vault.adoptFileByUrl}. Named const so the unit test and the * production code agree on a single value. * * @internal Reams-internal — not part of Obsidian's Vault API. */export const ADOPT_FILE_RECONCILE_TIMEOUT_MS = 5000
/** * Typed error surface for {@link Vault.adoptFileByUrl}. Sibling of * {@link AdoptFolderError} — co-located here for the same reason: this * is a vault-level concern owned by the orchestrator that throws it. * * @internal Reams-internal — not part of Obsidian's Vault API. */export class AdoptFileError extends Error { constructor(public readonly code: AdoptFileErrorCode, message: string) { super(message) this.name = 'AdoptFileError' }}
/** * URL-derived fallback name for an adopted folder when the document * carries no name on the wire (Reams-created folders, or pushwork * versions < 1.0.20 that predate the name/title schema). * * Format: `adopted-<last8>` where `last8` is the last 8 characters of * the URL's identifier (everything after `automerge:`). The parser * (`parseCreateItemInput`) enforces a minimum identifier length of 8 * chars, so in normal flow the suffix is ALWAYS exactly 8 chars. The * defensive branch for shorter identifiers exists for callers that * bypass the parser; it returns the entire identifier verbatim. * * The user can rename via the existing rename flow after adoption. */export function defaultAdoptedFolderName(url: AutomergeUrl): string { const identifier = url.replace(/^automerge:/, '') const suffix = identifier.length <= 8 ? identifier : identifier.slice(-8) return `adopted-${suffix}`}
/** * Resolve the user-facing name for a folder being adopted by URL. * * Precedence (highest first): * 1. `doc.name` — set by pushwork >= 1.0.20 from the local directory's * basename. Source of truth when present. * 2. `doc.title` — set by pushwork >= 1.0.20 to the same basename. Kept * as a separate field by pushwork for forward-compat; we read it as * a fallback in case some pushwork-adjacent tool sets `title` but * not `name`. * 3. {@link defaultAdoptedFolderName} — synthesized from the URL. * * The name is normalised: trimmed, control characters and path * separators (`/`, `\`) stripped, NUL bytes removed. If normalisation * produces an empty string we fall through to the URL-derived default * — a folder named "" would be unrepresentable in the file tree. * * @internal Reams-internal helper for {@link Vault.adoptFolderByUrl}. */export function resolveAdoptedFolderName( url: AutomergeUrl, doc: { name?: unknown; title?: unknown } | null | undefined,): string { const candidates: unknown[] = [doc?.name, doc?.title] for (const candidate of candidates) { if (typeof candidate !== 'string') continue // Strip path separators, control chars, and NULs. Path separators // would let a malicious or buggy peer inject an entry that the // file-tree interprets as a nested path; control chars confuse // both rendering and downstream string handling. const sanitised = candidate // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u001f\u007f-\u009f/\\]/g, '') .trim() if (isCanonicalAdoptionSegment(sanitised)) return sanitised } return defaultAdoptedFolderName(url)}
/** Vault event types for typed subscriptions */export interface VaultEvents { create: [file: TAbstractFile] modify: [file: TAbstractFile] delete: [file: TAbstractFile] rename: [file: TAbstractFile, oldPath: string]}
/** * Vault class providing file system operations. * Thin Obsidian-compatible wrapper delegating lookups and events to FileIndex, * and orchestrating mutations via DocumentManager + FileIndex. * * @public * @since 0.9.7 */export class Vault extends Events { /** * @internal Upstream Natural Language Dates reads this undocumented preference * when inserting links. Reams' link insertion surface uses wikilinks. */ getConfig(key: string): unknown { return key === 'useMarkdownLinks' ? false : undefined } /** * Recursively traverse a folder and its children. * Static method matching Obsidian's Vault.recurseChildren(). * @public * @since 0.9.7 */ static recurseChildren(root: TFolder, cb: (file: TAbstractFile) => any): void { const visited = new Set<TAbstractFile>() function walk(node: TAbstractFile) { if (visited.has(node)) return visited.add(node) cb(node) if (node instanceof TFolder && node.children) { for (const child of node.children) { walk(child) } } } walk(root) }
private name: string private readonly fileIndex: FileIndex private readonly documentManager: DocumentManager private readonly userEntries: VaultUserEntries /** * Per-path guard so a downstream regression that calls `vault.read()` on * a binary file logs at most once per path. Without this, a busy plugin * could flood the console; with it, the first occurrence surfaces the bug * and subsequent ones stay silent. * * @internal */ private readonly _readNonTextWarned = new Set<string>()
/** * Root URL used for app boot/traversal anchoring only. * This is current bootstrap policy, not the deeper document identity contract. * @internal Reams-internal — not part of Obsidian's Vault API. */ get rootUrl(): AutomergeUrl { return this.fileIndex.getVaultRootUrl() }
/** @internal Resume deferred file stats after the initial editor is ready. */ startInitialStats(): void { this.fileIndex.startInitialStats() }
/** Config directory path (typically .obsidian) */ configDir: string = '.obsidian'
/** * Low-level filesystem adapter (Obsidian-compatible). * * Implements the subset of Obsidian's DataAdapter that makes sense in a * browser PWA context. Methods that require filesystem access not available * in browsers throw with clear error messages. */ adapter: DataAdapter
// Typed event overloads on(name: 'create', callback: (file: TAbstractFile) => any, ctx?: any): EventRef /** @internal Reams only emits TFile for modify events today. */ on(name: 'modify', callback: (file: TFile) => any, ctx?: any): EventRef on(name: 'modify', callback: (file: TAbstractFile) => any, ctx?: any): EventRef on(name: 'delete', callback: (file: TAbstractFile) => any, ctx?: any): EventRef on(name: 'rename', callback: (file: TAbstractFile, oldPath: string) => any, ctx?: any): EventRef /** @internal Carries the exact committed text frontier to derived indexes. */ on(name: 'reams-internal-semantic-modify', callback: (file: TFile, contribution: TextWriteResult) => any, ctx?: any): EventRef /** @internal UI-only invalidation when deferred initial file stats arrive. */ on(name: 'reams-internal-stats-hydrated', callback: () => any, ctx?: any): EventRef on(name: string, callback: (...data: any[]) => any, ctx?: any): EventRef { return super.on(name, callback, ctx) }
/** * @internal Reams-internal — Obsidian's Vault does not expose a public constructor. */ constructor(name: string, fileIndex: FileIndex, documentManager: DocumentManager, userEntries?: VaultUserEntries) { super() this.name = name this.fileIndex = fileIndex this.documentManager = documentManager this.userEntries = userEntries ?? new VaultUserEntries(fileIndex.getVaultRootUrl(), documentManager)
// Inject vault reference into FileIndex so it can set vault property // on files created during hydration. Must be called before init(). fileIndex.setVault(this)
// Forward FileIndex events to Vault subscribers fileIndex.on('create', (...args: unknown[]) => this.triggerIsolated('create', ...args)) fileIndex.on('modify', (...args: unknown[]) => this.trigger('modify', ...args)) fileIndex.on('delete', (...args: unknown[]) => this.triggerIsolated('delete', ...args)) fileIndex.on('rename', (...args: unknown[]) => this.triggerIsolated('rename', ...args)) fileIndex.on('stats-hydrated', () => this.trigger('reams-internal-stats-hydrated'))
// Initialize adapter with closures over private fields const self = this const normalizeAdapterPath = (path: string): string => self.normalizePath(path)
const adapter: DataAdapter & { getBasePath(): string } = { getName() { return 'reams' }, async stat(path: string) { const normalizedPath = normalizeAdapterPath(path) const file = normalizedPath === '' ? self.fileIndex.getRoot() : self.fileIndex.getAbstractFileByPath(normalizedPath) if (!file) return null if (file instanceof TFile) { return { type: 'file' as const, ctime: file.stat.ctime, mtime: file.stat.mtime, size: file.stat.size } } return { type: 'folder' as const, ctime: 0, mtime: 0, size: 0 } }, async exists(path: string) { const normalizedPath = normalizeAdapterPath(path) return normalizedPath === '' ? true : self.fileIndex.hasPath(normalizedPath) }, async read(path: string) { const normalizedPath = normalizeAdapterPath(path) const url = self.fileIndex.getUrlForPath(normalizedPath) if (url === undefined) throw new Error(`File not found: ${path}`) const entry = self.fileIndex.getByUrl(url) if (!entry || entry.type !== 'file') throw new Error(`File not found: ${path}`) const result = await self.documentManager.readText(url) if (result.status !== 'available') throw new Error(`File content is null: ${path}`) return result.content }, async write(path: string, data: string, _options?: DataWriteOptions) { const normalizedPath = normalizeAdapterPath(path) const url = self.fileIndex.getUrlForPath(normalizedPath) if (url !== undefined) { const file = self.fileIndex.getFileByPath(normalizedPath) if (file) { await self.documentManager.updateContent(url, data) file.stat.size = data.length self.fileIndex.notifyModified(file) return } } await self.create(normalizedPath, data) },
async list(path: string): Promise<{ files: string[]; folders: string[] }> { // Normalize path: empty string or "/" means root const normalizedPath = normalizeAdapterPath(path) const folder = normalizedPath === '' ? self.fileIndex.getRoot() : self.fileIndex.getFolderByPath(normalizedPath)
if (!folder) { // Path doesn't exist - return empty arrays (matches Obsidian behavior) return { files: [], folders: [] } }
const files: string[] = [] const folders: string[] = []
for (const child of folder.children) { if (child instanceof TFile) { files.push(child.name) } else if (child instanceof TFolder) { folders.push(child.name) } }
return { files, folders } },
async mkdir(path: string): Promise<void> { const normalizedPath = normalizeAdapterPath(path) if (normalizedPath === '') { throw new Error('Cannot create root folder') } await self.createFolder(normalizedPath) },
async trashSystem(_path: string): Promise<boolean> { return false },
async trashLocal(path: string): Promise<void> { const normalizedPath = normalizeAdapterPath(path) const file = normalizedPath === '' ? self.fileIndex.getRoot() : self.fileIndex.getAbstractFileByPath(normalizedPath) if (!file) { throw new Error(`File or folder not found: ${path}`) } await self.delete(file, true) },
async rmdir(path: string, recursive: boolean = false): Promise<void> { const normalizedPath = normalizeAdapterPath(path) const folder = normalizedPath === '' ? self.fileIndex.getRoot() : self.fileIndex.getFolderByPath(normalizedPath) if (!folder) { throw new Error(`Folder not found: ${path}`) } if (normalizedPath === '' || folder.isRoot()) { throw new Error('Cannot delete vault root') } if (!recursive) { const folderUrl = self.fileIndex.getUrlForFile(folder) if (!folderUrl) { throw new Error(`Folder not found: ${path}`) } const contents = await self.userEntries.listUserEntries(folderUrl) if (contents.length > 0) { throw new Error(`Folder is not empty: ${path}. Use recursive=true to delete non-empty folders.`) } } await self.delete(folder, recursive) },
async remove(path: string): Promise<void> { const normalizedPath = normalizeAdapterPath(path) const file = normalizedPath === '' ? self.fileIndex.getRoot() : self.fileIndex.getAbstractFileByPath(normalizedPath) if (!file) { throw new Error(`File or folder not found: ${path}`) } await self.delete(file, true) },
async rename(oldPath: string, newPath: string): Promise<void> { const normalizedOldPath = normalizeAdapterPath(oldPath) const file = normalizedOldPath === '' ? self.fileIndex.getRoot() : self.fileIndex.getAbstractFileByPath(normalizedOldPath) if (!file) { throw new Error(`File or folder not found: ${oldPath}`) } await self.rename(file, normalizeAdapterPath(newPath)) },
async copy(_src: string, _dst: string): Promise<void> { throw new Error( 'Vault.adapter.copy is not implemented in Reams. ' + 'To copy files, use app.vault.copy(source, destination) or file an issue if you need adapter-level support.' ) },
async readBinary(path: string): Promise<ArrayBuffer> { const normalizedPath = normalizeAdapterPath(path) const file = self.fileIndex.getFileByPath(normalizedPath) if (!file) { throw new Error(`File not found: ${path}`) } // Vault.readBinary now returns ArrayBuffer and throws if not found return self.readBinary(file) },
async writeBinary(path: string, data: ArrayBuffer, options?: DataWriteOptions): Promise<void> { const normalizedPath = normalizeAdapterPath(path) const existingFile = self.fileIndex.getFileByPath(normalizedPath) if (existingFile) { await self.modifyBinary(existingFile, data, options) return } await self.createBinary(normalizedPath, data, options) },
async append(path: string, data: string, options?: DataWriteOptions): Promise<void> { const normalizedPath = normalizeAdapterPath(path) const existingFile = self.fileIndex.getFileByPath(normalizedPath) if (existingFile) { await self.append(existingFile, data, options) } else { await self.create(normalizedPath, data, options) } },
async appendBinary(path: string, data: ArrayBuffer, options?: DataWriteOptions): Promise<void> { const normalizedPath = normalizeAdapterPath(path) const existingFile = self.fileIndex.getFileByPath(normalizedPath) if (existingFile) { await self.appendBinary(existingFile, data, options) } else { await self.createBinary(normalizedPath, data, options) } },
async process(path: string, fn: (data: string) => string, options?: DataWriteOptions): Promise<string> { const normalizedPath = normalizeAdapterPath(path) const file = self.fileIndex.getFileByPath(normalizedPath) if (!file) { throw new Error(`File not found: ${path}`) } return self.process(file, fn, options) },
getBasePath(): string { throw new Error( 'Vault.adapter.getBasePath is not available in Reams. ' + 'Reams is a browser-based PWA without filesystem access. ' + 'Files are stored in IndexedDB via Automerge, not on a filesystem path.' ) },
getResourcePath(path: string): string { const normalizedPath = normalizeAdapterPath(path) const file = self.fileIndex.getFileByPath(normalizedPath) if (!file) { throw new Error(`File not found: ${path}`) } const url = self.fileIndex.getUrlForPath(normalizedPath) if (!url) { throw new Error(`File not found: ${path}`) } return self.getResolvedResourcePath(file, url) }, } this.adapter = adapter }
/** * Initialize the vault by delegating to FileIndex.init(). * * @internal Host-internal lifecycle. Called by App during startup * to load files from Automerge into the file index. Plugins must not * call this — the vault is already initialized by the time plugins load. */ async init(): Promise<void> { await this.fileIndex.init() }
/** * Get vault name. */ getName(): string { return this.name }
// --- Lookup methods (delegate to FileIndex) ---
getRoot(): TFolder { return this.fileIndex.getRoot() }
getFileByPath(path: string): TFile | null { return this.fileIndex.getFileByPath(path) }
getFolderByPath(path: string): TFolder | null { return this.fileIndex.getFolderByPath(path) }
getAbstractFileByPath(path: string): TAbstractFile | null { return this.fileIndex.getAbstractFileByPath(path) }
getMarkdownFiles(): TFile[] { return this.fileIndex.getMarkdownFiles() }
getFiles(): TFile[] { return this.fileIndex.getFiles() }
getAllLoadedFiles(): TAbstractFile[] { return this.fileIndex.getAllLoadedFiles() }
/** * Get all folders in the vault. * @param includeRoot - Should the root folder (`/`) be returned * @public * @since 1.6.6 */ getAllFolders(includeRoot?: boolean): TFolder[] { const folders: TFolder[] = [] const root = this.fileIndex.getRoot() function collectFolders(folder: TFolder) { folders.push(folder) for (const child of folder.children) { if (child instanceof TFolder) { collectFolders(child) } } } if (includeRoot) { collectFolders(root) } else { // Only collect children, not root itself for (const child of root.children) { if (child instanceof TFolder) { collectFolders(child) } } } return folders }
/** * Get current backing URL for a vault path. * Path lookup is for path-oriented UI/cache flows; object-bearing active-set * events should use {@link getUrlForFile} instead. * @internal Reams-internal — not part of Obsidian's Vault API. */ getUrlForPath(path: string): AutomergeUrl | undefined { return this.fileIndex.getUrlForPath(path) }
/** * Get file by Automerge URL. * @internal Reams-internal — not part of Obsidian's Vault API. */ getFileByUrl(url: AutomergeUrl): TFile | null { return this.fileIndex.getFileByUrl(url) }
/** * Object-authoritative active-set identity lookup for an exact file object. * The returned AutomergeUrl is the current backing implementation of stable * document identity, not a path-derived authority. * * BY-IDENTITY, not by-path. A stale TFile whose `path` happens to match * a live entry will still return `undefined` here — desired behavior * when two leaves share a path (e.g. after foreign-doc adoption lands * at a colliding name). Use this when URL uniqueness matters more than * path resolution. * * @internal Reams-internal — not part of Obsidian's Vault API. */ getUrlForFile(file: TAbstractFile): AutomergeUrl | undefined { return this.fileIndex.getUrlForFile(file) }
/** * Get abstract file by Automerge URL. * @internal Reams-internal — not part of Obsidian's Vault API. */ getAbstractFileByUrl(url: AutomergeUrl): TAbstractFile | null { return this.fileIndex.getAbstractFileByUrl(url) }
/** * Get vault path for an Automerge URL. * @internal Reams-internal — not part of Obsidian's Vault API. */ getPathForUrl(url: AutomergeUrl): string | undefined { return this.fileIndex.getPathForUrl(url) }
/** * Notify that a file was modified externally (e.g. debounced saver). * Delegates to FileIndex which updates mtime and fires the event. * * @internal Host-internal. Called by the debounced saver when Automerge * changes are persisted. Plugins must not call this — modify events are * fired automatically when the vault content changes. */ notifyModified(file: TFile): void { this.fileIndex.notifyModified(file) }
/** * Get a resource path/URL for a file that can be used in <img src> etc. * Obsidian-shape public API that resolves by file object identity. * * For images: returns blob URL if cached, otherwise throws with guidance. * For non-images: returns automerge:// URL. */ getResourcePath(file: TFile): string { const { url } = this.resolveIndexedFile(file) return this.getResolvedResourcePath(file, url) }
// --- Mutation methods ---
/** * Create a new text file. * Throws if a file already exists at the given path (matches Obsidian API). * Use getFileByPath() to check existence first, or modify() to update content. * @param path - Vault absolute path for the new file, with extension. * @param data - text content for the new file. * @param options - (Optional) write options including ctime/mtime overrides. */ async create(path: string, data: string, options?: DataWriteOptions): Promise<TFile> { options = options ? { ...options } : undefined const { parentPath, fileName } = this.parsePath(path)
if (!fileName) { throw new Error(`Invalid path: '${path}' — empty filename`) }
const normalizedPath = parentPath ? `${parentPath}/${fileName}` : fileName
// Throw if path already exists as file or folder (Obsidian API contract) const existing = this.fileIndex.getAbstractFileByPath(normalizedPath) if (existing) { throw new Error(`File already exists: ${normalizedPath}`) }
// Ensure parent folder exists const parentUrl = await this.ensureFolderPath(parentPath) await this.assertStorageChildVacant(parentUrl, fileName, () => `File already exists: ${normalizedPath}`)
// Create file in the parent folder // Publish the file only after its initial content is durable. Plugins that // index the create event must never observe a transient empty shell. const parent = this.fileIndex.getFolderByPath(parentPath) ?? this.fileIndex.getRoot() return this.createProjectedTextFile(parentUrl, parent, fileName, data, options) }
/** @internal Create a leaf anchored to a stable folder URL. */ async createInFolder(folderUrl: AutomergeUrl, fileName: string, data: string, options?: DataWriteOptions): Promise<TFile> { options = options ? { ...options } : undefined validateCanonicalLeafName(fileName) const parent = folderUrl === this.rootUrl ? this.fileIndex.getRoot() : this.fileIndex.getAbstractFileByUrl(folderUrl) if (!(parent instanceof TFolder) || !this.fileIndex.getUrlForFile(parent)) { throw new Error(`Destination folder is no longer available: ${folderUrl}`) } await this.assertStorageChildVacant(folderUrl, fileName, () => `File already exists: ${parent.path ? `${parent.path}/` : ''}${fileName}`, undefined, true) const receipt = await this.documentManager.getFolderHandle(folderUrl) if (!receipt) throw new Error(`Destination folder is no longer available: ${folderUrl}`) for (const child of [...parent.children]) { if (child.name !== fileName) continue const childUrl = this.fileIndex.getUrlForFile(child) if (!childUrl || receipt.doc().docs.some(entry => entry.url === childUrl)) throw new Error(`File already exists: ${fileName}`) const reconciled = await this.fileIndex.reconcileLinkedEntry(folderUrl, childUrl, parent, receipt, receipt) if (reconciled.status !== 'unlinked') throw new Error(`Destination contains a live entry named ${fileName}`) } return this.createProjectedTextFile(folderUrl, parent, fileName, data, options) }
private async createProjectedTextFile( parentUrl: AutomergeUrl, parent: TFolder, fileName: string, data: string, options?: DataWriteOptions, ): Promise<TFile> { if (this.fileIndex.getUrlForFile(parent) !== parentUrl) throw new Error(`Destination folder is no longer available: ${parentUrl}`) const now = Date.now() const stat: FileStats = { ctime: options?.ctime ?? now, mtime: options?.mtime ?? now, size: data.length, } let preparedUrl: AutomergeUrl | undefined let url: AutomergeUrl try { url = await this.documentManager.createFileInFolder(parentUrl, fileName, undefined, data, child => { if (this.fileIndex.getUrlForFile(parent) !== parentUrl) throw new Error(`Destination folder is no longer available: ${parentUrl}`) preparedUrl = child this.fileIndex.stageFileCreation(child, parentUrl, stat) }) } catch (error) { // The parent may have emitted its link before durability failed. Reconcile // that evidence (also without a live subscription), preserving the error. // Unavailable evidence retains its metadata hint for a later valid pass. if (preparedUrl) await this.fileIndex.reconcileLinkedEntry(parentUrl, preparedUrl, parent).catch(() => {}) throw error } const result = await this.fileIndex.reconcileLinkedEntry(parentUrl, url, parent) if (result.status !== 'materialized' || result.kind !== 'file') throw new Error(`Created file could not be projected in destination folder (${result.status}): ${parentUrl}`) return result.file }
/** @internal Look up a direct child by its stable parent URL. */ async getFileInFolder(folderUrl: AutomergeUrl, fileName: string): Promise<TFile | null> { validateCanonicalLeafName(fileName) const folder = folderUrl === this.rootUrl ? this.fileIndex.getRoot() : this.fileIndex.getAbstractFileByUrl(folderUrl) if (!(folder instanceof TFolder)) { throw new Error(`Destination folder is no longer available: ${folderUrl}`) } const entry = (await this.userEntries.listUserEntries(folderUrl)) .find(candidate => candidate.name === fileName && isFileEntry(candidate)) const currentParent = folderUrl === this.rootUrl ? this.fileIndex.getRoot() : this.fileIndex.getAbstractFileByUrl(folderUrl) if (currentParent !== folder || !this.fileIndex.getUrlForFile(folder)) { throw new Error(`Destination folder is no longer available: ${folderUrl}`) } if (!entry) return null const cached = this.fileIndex.getFileByUrl(entry.url) const cachedEntry = this.fileIndex.getByUrl(entry.url) if (cached?.parent === folder && cached.name === fileName && cachedEntry?.type === 'file' && this.fileIndex.getAbstractFileByUrl(folderUrl) === folder && this.fileIndex.getCanonicalPathForUrl(folderUrl) !== undefined) return cached const result = await this.fileIndex.reconcileLinkedEntry(folderUrl, entry.url, folder) return result.status === 'materialized' && result.kind === 'file' && result.file.name === fileName ? result.file : null }
/** * Create a new binary file. * Throws if a file already exists at the given path (matches Obsidian API). */ /** @internal Reams accepts Uint8Array + explicit MIME type for app-side callers. */ createBinary(path: string, data: Uint8Array, mimeType: string): Promise<TFile> createBinary(path: string, data: ArrayBuffer, options?: ReamsDataWriteOptions): Promise<TFile> async createBinary( path: string, data: ArrayBuffer | Uint8Array, optionsOrMimeType?: ReamsDataWriteOptions | string, ): Promise<TFile> { const options = typeof optionsOrMimeType === 'string' || optionsOrMimeType === undefined ? undefined : { ...optionsOrMimeType } const binary = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0)) const { parentPath, fileName } = this.parsePath(path)
if (!fileName) { throw new Error(`Invalid path: '${path}' — empty filename`) }
const normalizedPath = parentPath ? `${parentPath}/${fileName}` : fileName
// Throw if path already exists as file or folder (Obsidian API contract) const existing = this.fileIndex.getAbstractFileByPath(normalizedPath) if (existing) { throw new Error(`File already exists: ${normalizedPath}`) }
const parentUrl = await this.ensureFolderPath(parentPath) await this.assertStorageChildVacant(parentUrl, fileName, () => `File already exists: ${normalizedPath}`)
const mimeType = typeof optionsOrMimeType === 'string' ? optionsOrMimeType : mimeTypeFromExtension(fileName.split('.').pop()?.toLowerCase() ?? '') // Preserve explicit routing metadata and local creation stats before linking. const parent = this.fileIndex.getFolderByPath(parentPath) ?? this.fileIndex.getRoot() const now = Date.now() const stat: FileStats = { ctime: options?.ctime ?? now, mtime: options?.mtime ?? now, size: binary.byteLength } let preparedUrl: AutomergeUrl | undefined let url: AutomergeUrl try { url = await this.documentManager.createBinaryFileInFolder( parentUrl, fileName, binary, mimeType, options?.contentType === undefined ? undefined : { contentType: options.contentType }, child => { if (this.fileIndex.getUrlForFile(parent) !== parentUrl) throw new Error(`Destination folder is no longer available: ${parentUrl}`) preparedUrl = child this.fileIndex.stageFileCreation(child, parentUrl, stat) }, ) } catch (error) { if (preparedUrl) await this.fileIndex.reconcileLinkedEntry(parentUrl, preparedUrl, parent).catch(() => {}) throw error } const result = await this.fileIndex.reconcileLinkedEntry(parentUrl, url, parent) if (result.status !== 'materialized' || result.kind !== 'file') throw new Error(`Created file could not be projected in destination folder (${result.status}): ${parentUrl}`) return result.file }
/** * Read binary content of a file. * @returns ArrayBuffer containing the file's binary content * @throws Error if file not found or has no binary content */ async readBinary(file: TFile): Promise<ArrayBuffer> { const { url } = this.resolveIndexedFile(file) const data = await this.documentManager.readBinaryContent(url) if (!data) throw new Error(`File has no binary content: ${file.path}`) // Convert Uint8Array to ArrayBuffer (Obsidian returns ArrayBuffer) // Use slice() for defensive copy - Automerge may return a view into its internal buffer return data.slice().buffer }
/** * Create a new folder. */ async createFolder(path: string): Promise<TFolder> { const { parentPath, fileName } = this.parsePath(path) const normalizedPath = fileName ? (parentPath ? `${parentPath}/${fileName}` : fileName) : ''
if (normalizedPath === '') { throw new Error('Cannot create root folder') }
await this.ensureFolderPath(path)
const folder = this.fileIndex.getFolderByPath(normalizedPath) if (!folder) { throw new Error(`Failed to create folder: ${path}`) }
return folder }
/** * Adopt a remote Automerge folder document into this vault, anchored * under the given folder URL with zero-or-more intermediate path * segments. * * The intermediate segments are folder names relative to the anchor; any * that don't already exist are created. The adopted folder is registered * in the deepest intermediate folder's `docs[]` under a name resolved * from the folder document itself when possible — pushwork >= 1.0.20 * writes the original directory basename as `doc.name` / `doc.title`, * which Reams reads via {@link resolveAdoptedFolderName}. When neither * field is present (older pushwork; Reams-created folders), Reams falls * back to a URL-derived default (see {@link defaultAdoptedFolderName}). * * The live folder subscription in FileIndex picks up the new entry, * hydrates the subtree recursively, and fires create events; we * explicitly do NOT register the adopted folder ourselves to avoid * racing the reconciler. * * Validation order (front-loaded; no writes before async reachability * is known): anchor-resolution → getFolderHandle → handle.doc() → * isPushworkDirectory → derive name → ensure intermediates (only when * supplied) → collision check → addFolderToFolder. This protects * against the "bad URL leaves a stranded empty intermediate folder" * failure mode. * * @throws {@link AdoptFolderError} with code 'unreachable', 'wrong-type', * 'collision', or 'anchor-missing'. * @internal Reams-internal — not part of Obsidian's Vault API. */ async adoptFolderByUrl( anchorUrl: AutomergeUrl, intermediateSegments: readonly string[], url: AutomergeUrl, ): Promise<void> { // 1. Resolve anchor path. Vault root always maps to ''. Any other // anchor MUST be currently indexed; if not, the anchor folder // was deleted/renamed between modal-open and submit. const anchor = anchorUrl === this.rootUrl ? this.fileIndex.getRoot() : this.fileIndex.getAbstractFileByUrl(anchorUrl) if (!(anchor instanceof TFolder) || this.fileIndex.getUrlForFile(anchor) !== anchorUrl || this.fileIndex.getCanonicalPathForUrl(anchorUrl) === undefined) { throw new AdoptFolderError('anchor-missing', 'The anchor folder no longer exists. Close and reopen the modal.') }
// 2. Reachability check. getFolderHandle wraps repo.find in a // try/catch and returns null on rejection. const handle = await this.documentManager.getFolderHandle(url) if (!handle) { throw new AdoptFolderError( 'unreachable', 'Could not load folder from sync server. Check that the sync server is reachable and the URL is correct.', ) }
let doc: unknown try { doc = handle.doc() } catch { throw new AdoptFolderError( 'unreachable', 'Could not load folder from sync server. Check that the sync server is reachable and the URL is correct.', ) }
// 3. Type check. doc() returned a value but it may not match the // Pushwork directory schema (e.g. it's a file doc). if (!isPushworkDirectory(doc)) { throw new AdoptFolderError('wrong-type', 'This URL is not a folder.') } if (url === this.rootUrl || url === anchorUrl) throw new AdoptFolderError('wrong-type', 'Cannot adopt the vault root or anchor folder into itself.') if (await this.fileIndex.isActuallyRootedFolder(url)) throw new AdoptFolderError('already-adopted', 'This folder is already active in the vault.') if (await this.fileIndex.isActuallyRootedFolder(anchorUrl)) { let cursor: TFolder | null = anchor const visited = new Set<TFolder>() while (cursor) { if (this.fileIndex.getUrlForFile(cursor) === url) throw new AdoptFolderError('wrong-type', 'Cannot adopt an ancestor folder into its descendant.') if (visited.has(cursor)) throw new AdoptFolderError('wrong-type', 'Cannot create a folder cycle.') visited.add(cursor) cursor = cursor.parent } }
try { await this.userEntries.assertAdoptable(url, doc) } catch (error) { throw new AdoptFolderError('internal-state', error instanceof Error ? error.message : 'Cannot adopt Reams state') }
// 4. Derive the adopted name. Prefer the on-the-wire `name` / `title` // fields populated by pushwork >= 1.0.20 (which carry the original // folder's basename). Fall back to a URL-derived default for docs // that omit those fields (older pushwork; Reams-created folders). const name = resolveAdoptedFolderName(url, doc) if (this.fileIndex.getUrlForFile(anchor) !== anchorUrl || this.fileIndex.getCanonicalPathForUrl(anchorUrl) === undefined) throw new AdoptFolderError('anchor-missing', 'The anchor folder no longer exists.') if (anchorUrl === this.rootUrl && intermediateSegments.length === 0 && name === REAMS_STATE_FOLDER_NAME) { throw new AdoptFolderError('collision', `The ${REAMS_STATE_FOLDER_NAME} vault path is reserved for Reams state.`) }
// 5. Resolve the deepest folder URL. If no intermediates were // supplied, the deepest folder IS the anchor — we MUST NOT // round-trip through ensureFolderPath in that case. let deepestFolderUrl: AutomergeUrl if (intermediateSegments.length === 0) { deepestFolderUrl = anchorUrl } else { if (intermediateSegments.some(segment => !isCanonicalAdoptionSegment(segment))) throw new AdoptFolderError('collision', 'Invalid intermediate folder name.') deepestFolderUrl = await this.ensureFolderPathFromFolder(anchor, intermediateSegments) }
// 6. Storage-level collision checks at the deepest folder. Best- // effort UX guards — addFolderToFolder dedupes by URL, not by // name, so a concurrent peer write may still produce two // same-named entries; this is consistent with how Reams treats // concurrent peer writes (LWW on names). // // URL-collision check fires BEFORE name-collision: if the URL // is already adopted under a different (renamed) name, the user // needs to know the URL is already here, not that the default // name happens to be taken. const entries = await this.documentManager.listFolder(deepestFolderUrl) const existing = entries.find(e => e.url === url) if (existing) { throw new AdoptFolderError( 'already-adopted', `This folder is already adopted here as ${existing.name}.`, ) } if (entries.some(e => e.name === name)) { throw new AdoptFolderError( 'collision', `A folder named ${name} already exists here. Rename it first or pick a different anchor.`, ) }
// 7. Write the adopt entry. The live reconciler in FileIndex will // observe the docs[] mutation, hydrate the subtree, and fire // 'create' events. Do NOT call fileIndex.registerFolder here. const parent = this.fileIndex.getAbstractFileByUrl(deepestFolderUrl) if (!(parent instanceof TFolder)) throw new AdoptFolderError('anchor-missing', 'The destination folder no longer exists.') let receipt: DocHandle<import('../types/pushwork').PushworkDirectoryDocument> | undefined try { await this.documentManager.addFolderToFolder(deepestFolderUrl, url, name, parentHandle => { receipt = parentHandle }, () => { if (!this.fileIndex.isObservedRootedFolder(anchorUrl, anchor) || !this.fileIndex.isObservedRootedFolder(deepestFolderUrl, parent) || !isPushworkDirectory(receipt?.doc())) { throw new AdoptFolderError('anchor-missing', 'The adoption anchor changed during preparation.') } const current = handle.doc() if (!isPushworkDirectory(current)) throw new AdoptFolderError('wrong-type', 'The target is no longer a folder.') if (current[REAMS_STATE_MARKER] !== undefined) throw new AdoptFolderError('internal-state', 'Cannot adopt Reams state.') }) } catch (error) { if (receipt) await this.fileIndex.reconcileLinkedEntry(deepestFolderUrl, url, parent, receipt, receipt).catch(() => {}) throw error } receipt ??= await this.documentManager.getFolderHandle(deepestFolderUrl) ?? undefined if (!receipt) throw new AdoptFolderError('unreachable', 'Destination folder could not be confirmed.') const result = await this.fileIndex.reconcileLinkedEntry(deepestFolderUrl, url, parent, receipt, receipt) if (result.status !== 'materialized' || result.kind !== 'folder') throw new AdoptFolderError('unreachable', `Folder adoption could not be reconciled (${result.status}).`) }
/** * Adopt a single file document by URL. * * Sibling of {@link adoptFolderByUrl} that handles file docs. Registers * the `url` into the `anchorUrl` folder's `docs[]` (the same Automerge * mutation pushwork uses) with a sanitised version of `doc.name`. The * live FileIndex subscription hydrates the new entry; this method * awaits the matching `create` event before resolving with the * registered {@link TFile}. * * Validation order (front-loaded; no writes before async reachability * is known): anchor-resolution → findFileHandle → derive/sanitise name * → collision check → addFileToFolder → wait for FileIndex. * * No intermediate folder segments are accepted — foreign file adoption * always lands at a single anchor (typically the vault root, used by * `UrlRouter` for inbound URLs). * * @throws {@link AdoptFileError} with code `unreachable`, `wrong-type`, * `invalid-name`, `already-adopted`, `anchor-missing`, or * `reconcile-timeout`. * @internal Reams-internal — not part of Obsidian's Vault API. */ async adoptFileByUrl(anchorUrl: AutomergeUrl, url: AutomergeUrl): Promise<TFile> { // 1. Validate anchor. Vault root is always valid. Anything else MUST // be currently indexed; if not, the anchor was deleted/renamed // between caller-decision and now. const anchor = anchorUrl === this.rootUrl ? this.fileIndex.getRoot() : this.fileIndex.getAbstractFileByUrl(anchorUrl) if (!(anchor instanceof TFolder) || this.fileIndex.getUrlForFile(anchor) !== anchorUrl || this.fileIndex.getCanonicalPathForUrl(anchorUrl) === undefined) { throw new AdoptFileError( 'anchor-missing', 'The anchor folder no longer exists.', ) }
// 2. Reachability check. findFileHandle REJECTS on unreachable URLs // (see Critical fact-check in 2026-05-20-url-addressability-411.md). // Wrap explicitly — do NOT modify findFileHandle's contract. let handle: DocHandle< import('../types/pushwork').PushworkFileDocument > | undefined try { handle = await this.documentManager.findFileHandle(url) } catch (err) { throw new AdoptFileError( 'unreachable', `Could not load file from sync server: ${err instanceof Error ? err.message : String(err)}`, ) } if (!handle) { // The doc resolved but did not match the Pushwork file schema. Could // be a folder doc or a malformed doc — neither can be opened as a // file leaf, so both surface here. throw new AdoptFileError( 'wrong-type', 'This URL is not a recognizable Pushwork file.', ) } const doc = handle.doc() if (this.fileIndex.getUrlForFile(anchor) !== anchorUrl || this.fileIndex.getCanonicalPathForUrl(anchorUrl) === undefined) throw new AdoptFileError('anchor-missing', 'The anchor folder no longer exists.')
try { await this.userEntries.assertAdoptable(url, doc) } catch (error) { throw new AdoptFileError('internal-state', error instanceof Error ? error.message : 'Cannot adopt Reams state') }
// 3. Derive + sanitise the filename. `doc.name` is canonical for // Pushwork files; still strip path separators / control chars / // NULs (defence in depth, identical to resolveAdoptedFolderName). const rawName = typeof doc.name === 'string' ? doc.name : '' const sanitised = rawName // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u001f\u007f-\u009f/\\]/g, '') .trim() if (!isCanonicalAdoptionSegment(sanitised)) { throw new AdoptFileError( 'invalid-name', 'File document has no usable name. Adopting would orphan it in the tree.', ) } if (anchorUrl === this.rootUrl && sanitised === REAMS_STATE_FOLDER_NAME) { throw new AdoptFileError('invalid-name', `The ${REAMS_STATE_FOLDER_NAME} vault path is reserved for Reams state.`) } // NOTE: Files without an extension (e.g. `README`) are still adopted. // The content-type registry's `inferContentType` falls back to // `public.data` for unknown extensions; routing then lands on // `UnsupportedFileView`. Documented in plan Risk #6.
// 4. URL-collision check. Consistent with adoptFolderByUrl: a name- // collision is NOT checked — duplicate sibling names are tolerated. const entries = await this.documentManager.listFolder(anchorUrl) if (this.fileIndex.getUrlForFile(anchor) !== anchorUrl || this.fileIndex.getCanonicalPathForUrl(anchorUrl) === undefined) throw new AdoptFileError('anchor-missing', 'The anchor folder no longer exists.') const existing = entries.find(e => e.url === url) if (existing) { throw new AdoptFileError('already-adopted', `This file is already adopted here as ${existing.name}.`) } const active = this.fileIndex.getAbstractFileByUrl(url) if (active && this.fileIndex.getUrlForFile(active) === url) throw new AdoptFileError('already-adopted', 'This file is already active in the vault.')
// 5. Set up the reconciliation listener BEFORE the mutation so we // don't miss a fast-firing create event. The comparison uses // `fileIndex.getUrlForFile(file) === url` (the by-identity reverse // map), NOT a path-based comparison — paths can collide during // concurrent reconciliation but URLs are authoritative. const parent = anchor if (this.fileIndex.getUrlForFile(parent) !== anchorUrl || this.fileIndex.getCanonicalPathForUrl(anchorUrl) === undefined) { throw new AdoptFileError('anchor-missing', 'The anchor folder no longer exists.') } let parentHandle: DocHandle<import('../types/pushwork').PushworkDirectoryDocument> | undefined try { await this.documentManager.addFileToFolder(anchorUrl, url, sanitised, receipt => { parentHandle = receipt }, () => { if (!this.fileIndex.isObservedRootedFolder(anchorUrl, anchor) || !isPushworkDirectory(parentHandle?.doc())) { throw new AdoptFileError('anchor-missing', 'The adoption anchor changed during preparation.') } const current = handle!.doc() if (!isPushworkFile(current)) throw new AdoptFileError('wrong-type', 'The target is no longer a file.') if (current[REAMS_STATE_MARKER] !== undefined) throw new AdoptFileError('internal-state', 'Cannot adopt Reams state.') }) } catch (error) { if (parentHandle) await this.fileIndex.reconcileLinkedEntry(anchorUrl, url, parent, parentHandle, parentHandle).catch(() => {}) throw error } // A concurrent adopter may have linked the same URL first; in that case // DocumentManager correctly no-ops and supplies no mutation receipt. Read // the current parent as the explicit confirmation for this raced call. parentHandle ??= await this.documentManager.getFolderHandle(anchorUrl) ?? undefined if (!parentHandle) throw new AdoptFileError('reconcile-timeout', `FileIndex did not receive a parent receipt for ${url}.`) const result = await this.fileIndex.reconcileLinkedEntry(anchorUrl, url, parent, parentHandle, parentHandle) if (result.status !== 'materialized' || result.kind !== 'file') { throw new AdoptFileError('reconcile-timeout', `FileIndex could not reconcile the adopted file (${result.status}).`) } return result.file }
/** * Read file content. * * For text-content files (those whose `contentType` conforms to * `public.plain-text`) this returns the doc string. For binary or * unknown content types the underlying `readContent()` returns null and * we surface an empty string — a deliberate Obsidian-compat behavior. * * **Regression detection (#406):** when called for a file whose * stored `contentType` is known and does NOT conform to `public.plain-text`, * we emit a one-time `console.warn` per file path so a future regression * — e.g. a plugin calling `vault.read()` on an image — is visible during * development. The warn is deduplicated via a `Set<path>` so a busy plugin * cannot flood the console. */ async read(file: TFile): Promise<string> { const { url } = this.resolveIndexedFile(file)
// Best-effort content-type check. We only warn when the doc exposes a // concrete non-text contentType; an absent/malformed contentType falls // through silently because the metadata read heals legacy content types. try { const ct = await this.documentManager.getFileContentType(url) if ( typeof ct === 'string' && ct.length > 0 && !conformsTo(ct, KnownContentTypes.PlainText) && !this._readNonTextWarned.has(file.path) ) { this._readNonTextWarned.add(file.path) console.warn( `Vault.read('${file.path}'): file's contentType '${ct}' is not text — ` + 'use readBinary() instead. This message logs once per file path.', ) } } catch { // Best-effort: a getFile failure should not prevent the read itself. }
const result = await this.documentManager.readText(url) return result.status === 'available' ? result.content : '' }
/** * Read file content from cache (same as read for Reams). */ async cachedRead(file: TFile): Promise<string> { return this.read(file) }
/** * Modify file content. * @param file - The file to modify. * @param data - The new file content. * @param options - (Optional) write options including ctime/mtime overrides. */ async modify(file: TFile, data: string, options?: DataWriteOptions): Promise<void> { return this.modifyWithTextEditHint(file, data, undefined, undefined, options) }
/** @internal Benchmark/editor seam for an exact CM6-derived text range. */ async modifyWithTextEditHint(file: TFile, data: string, hint?: FixedWidthTextEditHint, baseHeads?: Heads, options?: DataWriteOptions): Promise<void> { const { url } = this.resolveIndexedFile(file)
const contribution = hint && baseHeads ? await this.documentManager.updateContentAt(url, baseHeads, data, undefined, hint) : await this.documentManager.updateContent(url, data) this.trigger('reams-internal-semantic-modify', file, contribution)
// The per-file change listener (when attached, i.e. production) // already fired `modify` with the doc's history-derived stat // synchronously inside `updateContent`. We don't redundantly // assign `file.stat.size` here — the listener wrote it from // `doc.content.length`, which equals `data.length` for the text // path (no normalization in `updateText`). See applyWriteOptions // for the override-and-fire path. this.applyWriteOptions(file, options) }
/** * Read a file, apply a transform function, and write the result back. * @param file - The file to be read and modified. * @param fn - A callback function which returns the new content of the note synchronously. * @param options - (Optional) write options including ctime/mtime overrides. * @returns The text value of the note that was written. */ async process(file: TFile, fn: (data: string) => string, options?: DataWriteOptions): Promise<string> { const data = await this.read(file) const result = fn(data) await this.modify(file, result, options) return result }
/** * Append text to the end of a file. * Throws if file doesn't exist (unlike adapter.append which creates). * @param file - The file to append to. * @param data - The text to append. * @param options - (Optional) write options including ctime/mtime overrides. */ async append(file: TFile, data: string, options?: DataWriteOptions): Promise<void> { await this.process(file, prev => prev + data, options) }
async modifyBinary(file: TFile, data: ArrayBuffer, options?: DataWriteOptions): Promise<void> { const { url } = this.resolveIndexedFile(file)
const binary = new Uint8Array(data) await this.documentManager.updateBinaryContent(url, binary)
// The per-file change listener (when attached) wrote size from // `doc.content.byteLength`, which equals `binary.byteLength`. // See modify() for the dedup + override-event rationale. this.applyWriteOptions(file, options) }
/** * Apply post-write stat handling for {@link modify} and {@link modifyBinary}. * * When a per-file Automerge `change` listener is attached (production * via `findFileHandle`), the listener has already fired `modify` with * the doc's history-derived stat synchronously inside the * `updateContent`/`updateBinaryContent` call. `notifyModified` is a * no-op on that path. * * When `options.mtime` or `options.ctime` is supplied, apply the * override and fire a second `modify` via `notifyModifiedForce` so * subscribers re-read the final state. Obsidian's documented * DataWriteOptions contract requires the override to win; without * the second event, the listener-fired `modify` carries history- * derived values that don't reflect the caller's intent. * * @internal */ private applyWriteOptions(file: TFile, options?: DataWriteOptions): void { this.fileIndex.notifyModified(file) if (options?.mtime !== undefined || options?.ctime !== undefined) { this.fileIndex.notifyModifiedForce(file, { mtime: options.mtime, ctime: options.ctime, }) } }
async appendBinary(file: TFile, data: ArrayBuffer, options?: DataWriteOptions): Promise<void> { const existing = new Uint8Array(await this.readBinary(file)) const incoming = new Uint8Array(data) const combined = new Uint8Array(existing.byteLength + incoming.byteLength) combined.set(existing) combined.set(incoming, existing.byteLength) await this.modifyBinary(file, combined.buffer, options) }
private isRootFolderLike(file: TAbstractFile): boolean { return file instanceof TFolder && (file.isRoot() || file.path === '') }
/** * Refuse a create/rename when storage already has a sibling with the same * exact name in the parent folder. Pre-write check against the local * Automerge replica (which is the source of truth for this client). */ private async assertStorageChildVacant( parentUrl: AutomergeUrl, name: string, errorMessage: () => string, allowUrl?: AutomergeUrl, typedCollision = false, ): Promise<void> { if (parentUrl === this.rootUrl && name === REAMS_STATE_FOLDER_NAME) { throw new Error(`The ${REAMS_STATE_FOLDER_NAME} vault path is reserved for Reams state`) } const entries = await this.documentManager.listFolder(parentUrl) const storageEntry = entries.find(entry => entry.name === name) if (!storageEntry) { return }
if (allowUrl && storageEntry.url === allowUrl) { return }
if (typedCollision) throw new DocumentManagerExistingEntryError(name) throw new Error(errorMessage()) }
/** * Resolve a TFile to its live URL by object identity. * * Rejects: * - Stale objects not registered in the index (object identity fails) * - Objects whose live entry is actually a folder (type confusion) * * Does NOT do a path-ownership check: the identity check above is already * authoritative, and a redundant path check would produce false negatives * in the legitimate name-collision case where two FileEntries share a path * (the second registration is the live one; the first is being phased out). */ private resolveIndexedFile(file: TFile): { url: AutomergeUrl } { const url = this.fileIndex.getUrlForFile(file) if (!url) { throw new Error(`File not found: ${file.path}`) }
const entry = this.fileIndex.getByUrl(url) if (!entry || entry.file !== file) { throw new Error(`File not found: ${file.path}`) } if (entry.type !== 'file') { // Type confusion: caller passed a folder where a file is expected. // Distinct error so debugging doesn't go down the "not found" rabbit hole. throw new Error(`Expected file, got folder: ${file.path}`) }
return { url } }
/** As {@link resolveIndexedFile}, but accepts either a file or folder. */ private resolveIndexedAbstractFile(file: TAbstractFile): { url: AutomergeUrl; entry: NonNullable<ReturnType<FileIndex['getByUrl']>> } { const url = this.fileIndex.getUrlForFile(file) if (!url) { throw new Error(`File not found: ${file.path}`) }
const entry = this.fileIndex.getByUrl(url) if (!entry || entry.file !== file) { throw new Error(`File not found: ${file.path}`) }
return { url, entry } }
private getResolvedResourcePath(file: TFile, url: AutomergeUrl): string { // For images, we need blob URLs which require async resolution. // Check ImageResolver cache via window.app (runtime coupling is unavoidable // due to circular dependency: ImageResolver already depends on Vault). if (ImageResolver.isImageExtension(file.extension)) { // Safe access to window.app.reamsImageResolver with proper type narrowing if (typeof window !== 'undefined' && window.app) { const imageResolver = (window.app as { reamsImageResolver?: ImageResolver }).reamsImageResolver if (imageResolver && typeof imageResolver.getCachedUrl === 'function') { const cachedUrl = imageResolver.getCachedUrl(file.path) if (cachedUrl) { return cachedUrl } } } // No cached URL available - provide clear guidance throw new Error( `Image not yet resolved: ${file.path}. ` + 'In Reams, image URLs are blob URLs that must be resolved asynchronously. ' + 'Call await app.reamsImageResolver.resolveImageUrl(path) first, then getResourcePath() will return the cached blob URL.' ) }
// For non-images, return an automerge URL that uniquely identifies the file. // The fragment contains the filename for debugging/display purposes. return url }
private async collectStorageBackedDescendants( folderUrl: AutomergeUrl, ): Promise<Array<{ url: AutomergeUrl; parentUrl: AutomergeUrl; name: string; type: 'file' | 'folder' }>> { const result: Array<{ url: AutomergeUrl; parentUrl: AutomergeUrl; name: string; type: 'file' | 'folder' }> = [] const visited = new Set<AutomergeUrl>([folderUrl]) const visit = async (parentUrl: AutomergeUrl): Promise<void> => { const parentHandle = await this.documentManager.getFolderDeletionHandle(parentUrl) if (!parentHandle) return // A tombstoned folder has no live descendants. const entries = await this.userEntries.listUserEntries(parentUrl) for (const entry of entries) { const type = isFolderEntry(entry) ? 'folder' : 'file' if (visited.has(entry.url)) throw new Error(`Corrupt folder ancestry cycle at ${entry.url}; refusing destructive delete`) if (type === 'folder') { visited.add(entry.url) await visit(entry.url) } result.push({ url: entry.url, parentUrl, name: entry.name, type }) } } await visit(folderUrl) return result }
private async collectReadOnlyAncestry( parentUrl: AutomergeUrl, ): Promise<Array<{ url: AutomergeUrl; parentUrl: AutomergeUrl; name: string; type: 'folder' }>> { const rootUrl = this.rootUrl const reversed: Array<{ url: AutomergeUrl; parentUrl: AutomergeUrl; name: string; type: 'folder' }> = [] const seen = new Set<AutomergeUrl>() let childUrl = parentUrl let currentParentUrl: AutomergeUrl | undefined = this.fileIndex.getByUrl(parentUrl)?.parentUrl ?? undefined while (currentParentUrl) { if (seen.has(currentParentUrl)) throw new Error(`Corrupt folder ancestry cycle at ${currentParentUrl}; refusing destructive delete`) seen.add(currentParentUrl) if (currentParentUrl === rootUrl) break const parent = await this.documentManager.getFolderHandle(currentParentUrl) if (!parent) throw new Error(`Folder is unavailable: ${currentParentUrl}`) const link = (await this.userEntries.listUserEntries(currentParentUrl)) .find(candidate => candidate.url === childUrl && isFolderEntry(candidate)) if (!link) throw new Error(`Entry ${childUrl} changed placement while preparing deletion`) reversed.push({ url: childUrl, parentUrl: currentParentUrl, name: link.name, type: 'folder' }) childUrl = currentParentUrl const indexEntry = this.fileIndex.getByUrl(currentParentUrl) currentParentUrl = indexEntry?.parentUrl ?? undefined } if (parentUrl !== rootUrl && currentParentUrl !== rootUrl) throw new Error(`Entry ${parentUrl} is not connected to the current vault root`) const rootHandle = await this.documentManager.getFolderHandle(rootUrl) if (!rootHandle) throw new Error(`Folder is unavailable: ${rootUrl}`) if (parentUrl !== rootUrl) { const rootEntry = (await this.userEntries.listUserEntries(rootUrl)) .find(candidate => candidate.url === childUrl && isFolderEntry(candidate)) if (!rootEntry) throw new Error(`Entry ${childUrl} changed placement while preparing deletion`) reversed.push({ url: childUrl, parentUrl: rootUrl, name: rootEntry.name, type: 'folder' }) } return reversed }
private async prepareDeleteGuard( targetUrl: AutomergeUrl, targetParentUrl: AutomergeUrl, ancestry: readonly { url: AutomergeUrl; parentUrl: AutomergeUrl; name: string; type: 'folder' }[], targetName: string, targetType: 'file' | 'folder', requireEmpty: boolean, ): Promise<() => void> { const checks = [...ancestry, { url: targetUrl, parentUrl: targetParentUrl, name: targetName, type: targetType }] const handles = new Map<AutomergeUrl, DocHandle<import('../types/pushwork').PushworkDirectoryDocument>>() for (const check of checks) { if (handles.has(check.parentUrl)) continue const handle = await this.documentManager.getFolderHandle(check.parentUrl) if (!handle) throw new Error(`Folder is unavailable: ${check.parentUrl}`) handles.set(check.parentUrl, handle) } if (requireEmpty && !handles.has(targetUrl)) { const targetHandle = await this.documentManager.getFolderDeletionHandle(targetUrl) if (targetHandle) handles.set(targetUrl, targetHandle) } const rootObject = this.fileIndex.getRoot() return () => { if (this.fileIndex.getRoot() !== rootObject || this.fileIndex.getUrlForFile(rootObject) !== this.rootUrl) { throw new Error('Vault root changed placement while preparing deletion') } for (const check of checks) { const handle = handles.get(check.parentUrl)! const found = handle.doc().docs.find(candidate => candidate.url === check.url && candidate.name === check.name && (check.type === 'folder' ? isFolderEntry(candidate) : isFileEntry(candidate))) if (!found) throw new Error(`Entry ${check.url} changed placement while preparing deletion`) } if (requireEmpty && handles.get(targetUrl) && handles.get(targetUrl)!.doc().docs.length !== 0) { throw new Error('Folder is not empty: a child was added while preparing deletion') } } }
/** * Delete a file or folder. * * Matches Obsidian's contract: `force=false` (default) refuses to delete * a non-empty folder. `force=true` deletes recursively. Files are unaffected * by `force` (always deleted). */ async delete(file: TAbstractFile, force: boolean = false): Promise<void> { if (this.isRootFolderLike(file)) { throw new Error('Cannot delete vault root') }
const { url: fileUrl, entry } = this.resolveIndexedAbstractFile(file) const deletedFolder = entry.type === 'folder' && file instanceof TFolder ? file : null const originalParentUrl = entry.parentUrl const originalParent = originalParentUrl ? this.fileIndex.getAbstractFileByUrl(originalParentUrl) : null const ancestry = originalParentUrl ? await this.collectReadOnlyAncestry(originalParentUrl) : [] if (originalParentUrl && !(originalParent instanceof TFolder)) throw new Error(`Parent folder is no longer available: ${originalParentUrl}`) // For folders, validate empty/force semantics BEFORE doing any state- // mutating work. The empty check must be read-only so that a rejected // delete() does not have side effects (no adoption, no rename event noise, // no index mutations). Once the check passes (or force is set), the rest // of delete() may freely adopt and mutate. if (deletedFolder && !force) { const handle = await this.documentManager.getFolderDeletionHandle(fileUrl) // A confirmed tombstone's old projection is not evidence of live children. // This read-only preflight must still reject unavailable bodies. if (handle && ((this.userEntries.projectFolderSnapshot(fileUrl, handle.doc())?.length ?? 0) > 0 || deletedFolder.children.length > 0)) { throw new Error(`Folder is not empty: ${file.path}. Pass force=true to delete recursively.`) } }
// force=false never enters descendant deletion; its final guard below // repeats the empty test after all asynchronous parent-handle lookups. if (deletedFolder && force) { const descendants = await this.collectStorageBackedDescendants(fileUrl) // Preflight above is read-only. Once recursive deletion is accepted, use // the existing projection to observe current storage placements so delete // callbacks resolve the canonical objects at their actual paths. await this.fileIndex.refreshExistingFolder(deletedFolder) const descendantFolders = new Map(descendants.filter(item => item.type === 'folder').map(item => [item.url, item])) for (const descendant of descendants) { let receipt: DocHandle<import('../types/pushwork').PushworkDirectoryDocument> | undefined try { const pathFromTarget: typeof ancestry = [] let pathChild = descendant.parentUrl while (pathChild !== fileUrl) { const edge = descendantFolders.get(pathChild) if (!edge) throw new Error(`Corrupt folder ancestry at ${pathChild}; refusing destructive delete`) pathFromTarget.unshift({ url: edge.url, parentUrl: edge.parentUrl, name: edge.name, type: 'folder' }) pathChild = edge.parentUrl } const operationAncestry = [...ancestry, { url: fileUrl, parentUrl: originalParentUrl!, name: file.name, type: 'folder' as const }, ...pathFromTarget] const beforeMutation = async () => this.prepareDeleteGuard(fileUrl, originalParentUrl!, ancestry, file.name, entry.type, false) .then(rootGuard => this.prepareDeleteGuard(descendant.url, descendant.parentUrl, operationAncestry, descendant.name, descendant.type, descendant.type === 'folder') .then(edgeGuard => () => { rootGuard(); edgeGuard() })) if (descendant.type === 'file') await this.documentManager.deleteFile(descendant.url, descendant.parentUrl, handle => { receipt = handle }, beforeMutation) else await this.documentManager.deleteFolder(descendant.url, descendant.parentUrl, handle => { receipt = handle }, beforeMutation) } catch (error) { const currentParent = this.fileIndex.getAbstractFileByUrl(descendant.parentUrl) if (receipt && currentParent instanceof TFolder) await this.fileIndex.reconcileLinkedEntry(descendant.parentUrl, descendant.url, currentParent, receipt, receipt).catch(() => {}) throw error } const currentParent = this.fileIndex.getAbstractFileByUrl(descendant.parentUrl) if (receipt && currentParent instanceof TFolder) await this.fileIndex.reconcileLinkedEntry(descendant.parentUrl, descendant.url, currentParent, receipt, receipt) } }
// Delete the item itself. if (entry.parentUrl) { const parent = this.fileIndex.getAbstractFileByUrl(entry.parentUrl) if (!(parent instanceof TFolder)) throw new Error(`Parent folder is no longer available: ${entry.parentUrl}`) let receipt: DocHandle<import('../types/pushwork').PushworkDirectoryDocument> | undefined try { const beforeMutation = async () => this.prepareDeleteGuard(fileUrl, entry.parentUrl!, ancestry, file.name, entry.type, deletedFolder !== null) if (entry.type === 'file') await this.documentManager.deleteFile(fileUrl, entry.parentUrl, handle => { receipt = handle }, beforeMutation) else await this.documentManager.deleteFolder(fileUrl, entry.parentUrl, handle => { receipt = handle }, beforeMutation) } catch (error) { if (receipt) await this.fileIndex.reconcileLinkedEntry(entry.parentUrl, fileUrl, parent, receipt, receipt).catch(() => {}) throw error } if (receipt) await this.fileIndex.reconcileLinkedEntry(entry.parentUrl, fileUrl, parent, receipt, receipt) } }
/** * Rename or move a file/folder. */ async rename(file: TAbstractFile, newPath: string): Promise<void> { if (this.isRootFolderLike(file)) { throw new Error('Cannot rename root folder') }
const { url, entry } = this.resolveIndexedAbstractFile(file)
// Use the canonical indexed path, not the wrapper's potentially-mutated path. // This prevents corruption when a caller has directly mutated file.path. const oldPath = this.fileIndex.getCanonicalPathForUrl(url) ?? file.path const { parentPath: newParentPath, fileName: newName } = this.parsePath(newPath) const normalizedNewPath = newParentPath ? `${newParentPath}/${newName}` : newName
// Validate before ensureFolderPath can create any missing ancestors. this.userEntries.assertPublicPathAvailable(normalizedNewPath)
// Check for conflict const conflicting = this.fileIndex.getAbstractFileByPath(normalizedNewPath) if (conflicting && conflicting !== file) { throw new Error(`Cannot rename to "${normalizedNewPath}": path already exists`) }
const oldParentPath = file.parent ? (file.parent.isRoot() ? '' : file.parent.path) : ''
if (file instanceof TFolder) { if (newParentPath === oldPath || newParentPath.startsWith(`${oldPath}/`)) { throw new Error(`Cannot move folder into itself: ${oldPath} -> ${normalizedNewPath}`) } }
const parentChanged = newParentPath !== oldParentPath const nameChanged = newName !== file.name
if (!parentChanged && !nameChanged) { this.trigger('rename', file, oldPath) return }
// --- Phase 1: DocumentManager operations ---
if (parentChanged) { const newParentUrl = await this.ensureFolderPath(newParentPath) const resolvedDestination = this.fileIndex.getAbstractFileByUrl(newParentUrl) if (!(resolvedDestination instanceof TFolder)) throw new Error(`Destination folder is no longer available: ${newParentUrl}`) if (file instanceof TFolder) { const visited = new Set<TFolder>() let cursor: TFolder | null = resolvedDestination while (cursor) { if (cursor === file || visited.has(cursor)) throw new Error(`Cannot move folder into itself: ${oldPath} -> ${normalizedNewPath}`) visited.add(cursor) cursor = cursor.parent } } await this.assertStorageChildVacant( newParentUrl, newName, () => `Cannot rename to "${normalizedNewPath}": path already exists`, url, )
const oldParentUrl = entry.parentUrl ?? this.rootUrl const oldParent = file.parent ?? this.fileIndex.getRoot()
let sourceHandle: DocHandle<import('../types/pushwork').PushworkDirectoryDocument> | undefined let destinationHandle: DocHandle<import('../types/pushwork').PushworkDirectoryDocument> | undefined try { await this.documentManager.moveFile(url, oldParentUrl, newParentUrl, newName, (source, destination) => { sourceHandle = source destinationHandle = destination }) } catch (error) { if (sourceHandle && destinationHandle) { await Promise.all([ this.fileIndex.reconcileLinkedEntry(oldParentUrl, url, oldParent, sourceHandle, destinationHandle).catch(() => {}), this.fileIndex.reconcileLinkedEntry(newParentUrl, url, resolvedDestination, sourceHandle, destinationHandle).catch(() => {}), ]) } throw error } if (!sourceHandle || !destinationHandle) throw new Error(`Move receipt missing directory handles for ${url}`) const newParent = resolvedDestination const [sourceResult, result] = await Promise.all([ this.fileIndex.reconcileLinkedEntry(oldParentUrl, url, oldParent, sourceHandle, destinationHandle), this.fileIndex.reconcileLinkedEntry(newParentUrl, url, newParent, sourceHandle, destinationHandle), ]) void sourceResult if (result.status !== 'materialized' || result.file !== file || file.parent !== newParent || file.name !== newName || file.path !== normalizedNewPath || this.fileIndex.getCanonicalPathForUrl(url) !== normalizedNewPath) { throw new Error(`Moved entry could not be reconciled in destination folder (${result.status}): ${newParentUrl}`) } }
if (nameChanged && !parentChanged) { const currentParentUrl = entry.parentUrl ?? this.rootUrl await this.assertStorageChildVacant( currentParentUrl, newName, () => `Cannot rename to "${normalizedNewPath}": path already exists`, url, )
let parentHandle: DocHandle<import('../types/pushwork').PushworkDirectoryDocument> | undefined const receipt = (handle: DocHandle<import('../types/pushwork').PushworkDirectoryDocument>) => { parentHandle = handle } if (entry.type === 'file') await this.documentManager.renameFile(url, currentParentUrl, newName, file.name, receipt) else await this.documentManager.renameEntry(url, currentParentUrl, newName, file.name, receipt) if (!parentHandle) throw new Error(`Rename receipt missing parent handle for ${url}`) const parent = file.parent ?? this.fileIndex.getRoot() const result = await this.fileIndex.reconcileLinkedEntry(currentParentUrl, url, parent, parentHandle, parentHandle) if (result.status !== 'materialized' || result.file !== file || file.name !== newName || file.path !== normalizedNewPath || this.fileIndex.getCanonicalPathForUrl(url) !== normalizedNewPath) { throw new Error(`Renamed entry could not be reconciled (${result.status}): ${currentParentUrl}`) } } }
async trash(file: TAbstractFile, _system: boolean): Promise<void> { await this.delete(file, true) }
async copy<T extends TAbstractFile>(file: T, newPath: string): Promise<T> { const { url } = this.resolveIndexedAbstractFile(file)
if (file instanceof TFolder) { const folder = await this.createFolder(newPath) for (const child of file.children) { await this.copy(child, `${newPath}/${child.name}`) } return folder as unknown as T }
const binary = await this.documentManager.readBinaryContent(url) if (binary) { return this.createBinary( newPath, binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength), ) as unknown as Promise<T> }
return this.create(newPath, await this.read(file as unknown as TFile)) as unknown as Promise<T> }
/** * Ensure all folders in a path exist, creating them if necessary. */ private async ensureFolderPath(folderPath: string): Promise<AutomergeUrl> { const normalizedFolderPath = this.normalizePath(folderPath) this.userEntries.assertPublicPathAvailable(normalizedFolderPath) if (normalizedFolderPath === '') { return this.rootUrl }
return withFolderPathMutationLock(this.documentManager, normalizedFolderPath, () => this.ensureFolderPathUnlocked(normalizedFolderPath)) }
private async ensureFolderPathFromFolder(start: TFolder, segments: readonly string[]): Promise<AutomergeUrl> { if (segments.length === 0) { const url = this.fileIndex.getUrlForFile(start) if (!url || this.fileIndex.getCanonicalPathForUrl(url) === undefined) throw new Error('Starting folder is no longer reachable') return url } const startUrl = this.fileIndex.getUrlForFile(start) if (!startUrl || this.fileIndex.getCanonicalPathForUrl(startUrl) === undefined) throw new Error('Starting folder is no longer reachable') const path = `${start.path ? `${start.path}/` : ''}${segments.join('/')}` return withFolderPathMutationLock(this.documentManager, path, () => this.ensureFolderPathUnlocked(path, start, segments)) }
private async ensureFolderPathUnlocked(normalizedFolderPath: string, startFolder: TFolder = this.fileIndex.getRoot(), requestedSegments?: readonly string[]): Promise<AutomergeUrl> {
const segments = requestedSegments ?? normalizedFolderPath.split('/')
let currentPath = startFolder.path let currentFolder = startFolder let currentUrl = this.fileIndex.getUrlForFile(startFolder) if (!currentUrl) throw new Error('Starting folder is no longer reachable')
for (const segment of segments) { const nextPath = currentPath ? `${currentPath}/${segment}` : segment const parent = currentFolder const parentHandle = await this.documentManager.getFolderHandle(currentUrl) const parentContents = this.userEntries.projectFolderHandle(currentUrl, parentHandle) if (!parentHandle || !parentContents) throw new Error(`Destination folder is unavailable or invalid: ${currentUrl}`) // The wire-level `type` may be the file's extension under // pushwork-2 (`'md'`, `'png'`, …) instead of literal `'file'`, // so we discriminate via `isFolderEntry` — anything that is not // a folder is a leaf we'd collide with. A literal // `e.type === 'file'` check used to silently miss pushwork-2 // files, letting Reams push a same-named folder entry next to // them; both then round-tripped to Automerge as siblings. const existingFile = parentContents.find( e => isFileEntry(e) && e.name === segment ) if (existingFile) { throw new Error(`File already exists: ${nextPath}`) }
const existingFolder = parentContents.find( e => isFolderEntry(e) && e.name === segment )
if (existingFolder) { const result = await this.fileIndex.reconcileLinkedEntry(currentUrl, existingFolder.url, parent, parentHandle, parentHandle) if (result.status !== 'materialized' || result.kind !== 'folder') throw new Error(`Existing folder could not be reconciled (${result.status}): ${existingFolder.url}`) await this.fileIndex.refreshExistingFolder(result.file)
currentPath = nextPath currentUrl = existingFolder.url currentFolder = result.file continue }
// Create new folder let preparedUrl: AutomergeUrl | undefined let newUrl: AutomergeUrl try { newUrl = await this.documentManager.createSubfolder(currentUrl, segment, undefined, child => { if (this.fileIndex.getUrlForFile(parent) !== currentUrl) throw new Error(`Destination folder is no longer available: ${currentUrl}`) preparedUrl = child }) } catch (error) { if (preparedUrl) await this.fileIndex.reconcileLinkedEntry(currentUrl, preparedUrl, parent).catch(() => {}) throw error } const result = await this.fileIndex.reconcileLinkedEntry(currentUrl, newUrl, parent) if (result.status !== 'materialized' || result.kind !== 'folder') throw new Error(`Created folder could not be projected in destination folder (${result.status}): ${currentUrl}`)
currentPath = nextPath currentUrl = newUrl const current = this.fileIndex.getAbstractFileByUrl(newUrl) if (!(current instanceof TFolder)) throw new Error(`Created folder is no longer available: ${newUrl}`) currentFolder = current }
return currentUrl }
/** * Parse path into parent path and file name. */ private parsePath(path: string): { parentPath: string; fileName: string } { const segments = this.normalizePath(path).split('/').filter(s => s.length > 0)
if (segments.length === 0) { return { parentPath: '', fileName: '' } }
if (segments.length === 1) { return { parentPath: '', fileName: segments[0] } }
const fileName = segments[segments.length - 1] const parentPath = segments.slice(0, -1).join('/') return { parentPath, fileName } }
private normalizePath(path: string): string { const segments = path.split('/').filter(segment => segment.length > 0) for (const segment of segments) validateCanonicalLeafName(segment) return segments.join('/') }}