Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338// src/services/metadata/metadata-cache.tsimport type { AutomergeUrl } from '../../storage/document-runtime-contract'import { Events } from '../../plugin/events'import { getLinkpath } from '../../plugin/obsidian-stubs'import { extractMetadata, parseFrontmatterTags } from './extract-metadata'import type { CachedMetadata, LinkCache, EmbedCache } from './types'import type { TFile } from '../../plugin/files'import type { Vault } from '../../plugin/vault'import type { EventRef } from '../../plugin/events'import type { Heads } from '../../sync/heads'import { LinkgraphWorkerClient } from './linkgraph-worker-client'import type { LinkgraphFile } from './linkgraph'
const CONTEXT_RADIUS = 30
function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === 'object' && value !== null && !Array.isArray(value)}
function normalizeLinkTarget(target: string): string { return target.toLowerCase().replace(/\.md$/, '')}
function recordLinkpath(reference: string): string { const inner = reference.startsWith('[[') && reference.endsWith(']]') ? reference.slice(2, -2) : reference return getLinkpath(inner.split('|', 1)[0])}
/** * Build a deduplicated Set of ALL tags (with `#` prefix) declared by * a document — both inline body tags and frontmatter `tags:` entries. * * Used by the inverted-index updaters (`addToInvertedIndexes` / * `removeFromInvertedIndexes`) so the index reflects every tag the * doc participates in, regardless of where it was declared. Without * this, frontmatter-only tags would be invisible to any future * "what docs use this tag?" lookup against `tagSources`. */function collectAllTagsForDoc(metadata: CachedMetadata): Set<string> { const tags = new Set<string>() for (const tag of metadata.tags ?? []) { tags.add(tag.tag) } for (const tag of parseFrontmatterTags(metadata.frontmatter)) { tags.add(tag) } return tags}
/** * A backlink result showing which document links to a target. * * @internal Host-internal type. Exported via 'reams' module only, not part * of the Obsidian-compatible 'obsidian' module contract. */export interface BacklinkResult { /** Automerge document URL of the linking document */ url: AutomergeUrl /** Document filename */ name: string /** Context snippet around the link */ context: string /** Record fields matching the queried target; Markdown links remain in context. */ recordReferences?: readonly RecordBacklinkReference[]}
/** @internal Host-only Record backlink provenance, not an Obsidian LinkCache. */export interface RecordBacklinkReference { link: string subject: { kind: 'root' } | { kind: 'block'; nodeId: string } typeName: string fieldId: string fieldName: string sourceText?: string line?: number}
/** * Serialized metadata cache format for the hidden derived-index checkpoint. * * @internal Host-internal persistence format. Used by DerivedIndexPersistence * to save/restore the cache across sessions. Plugins must not rely on this * shape — it may change without notice. */export interface SerializedMetadataCache { /** Per-document metadata entries */ documents: Record<string, { name: string metadata: CachedMetadata hash: string | null /** Optional for v1 entries persisted before heads-backed freshness. */ heads?: Heads | null /** Per-link context snippets keyed by normalized raw link text */ contexts: Record<string, string> }>}
/** * Internal entry for a cached document. */interface CachedDocument { name: string metadata: CachedMetadata hash: string | null heads: Heads | null /** Per-link context snippets keyed by normalized raw link text */ contexts: Map<string, string>}
/** * MetadataCache service providing Obsidian-compatible metadata API. * * Maintains forward and inverted indexes for links, tags, headings. * Extends Events for `changed`, `resolve`, `resolved` event support. * React integration via `useMetadataCacheVersion` hook (internal). */export class MetadataCache extends Events { // ============================================================================ // Obsidian-compatible typed event overloads // ============================================================================
/** * Subscribe to metadata cache events. * * Event types: * - 'changed': Fired when a file's metadata is updated. Callback receives * (file: TFile, data: string, cache: CachedMetadata). * - 'deleted': Fired when a file is deleted. Callback receives * (file: TFile, prevCache: CachedMetadata | null). * - 'resolve': Fired when a file's links are resolved. Callback receives * (file: TFile). * - 'resolved': Fired when all files have been resolved. Callback receives * no arguments. * * @public */ // eslint-disable-next-line @typescript-eslint/no-explicit-any on(name: 'changed', callback: (file: TFile, data: string, cache: CachedMetadata) => any, ctx?: any): EventRef // eslint-disable-next-line @typescript-eslint/no-explicit-any on(name: 'deleted', callback: (file: TFile, prevCache: CachedMetadata | null) => any, ctx?: any): EventRef // eslint-disable-next-line @typescript-eslint/no-explicit-any on(name: 'resolve', callback: (file: TFile) => any, ctx?: any): EventRef // eslint-disable-next-line @typescript-eslint/no-explicit-any on(name: 'resolved', callback: () => any, ctx?: any): EventRef // eslint-disable-next-line @typescript-eslint/no-explicit-any on(name: string, callback: (...data: any) => any, ctx?: any): EventRef { return super.on(name, callback, ctx) } /** Forward index: url → cached document data */ private documents: Map<AutomergeUrl, CachedDocument> = new Map()
/** Inverted tag index: tag (with #) → Set of source urls */ private tagSources: Map<string, Set<AutomergeUrl>> = new Map()
/** Version counter for useSyncExternalStore */ private version = 0
/** External subscribers for useSyncExternalStore */ private subscribers: Set<() => void> = new Set() private readonly displayTitleSubscribers = new Set<() => void>()
/** Optional Vault reference for link resolution and path lookups */ private vault: Vault | null = null private backgroundGraph: LinkgraphWorkerClient | null = null private graphGeneration = 0 private graphHasTable = false private backgroundGraphError: Error | null = null private readonly pendingChanged = new Map<AutomergeUrl, { file: TFile; content: string; metadata: CachedMetadata }>() private readonly pendingDeleted = new Map<TFile, CachedMetadata | null>() private readonly pendingResolved = new Set<AutomergeUrl>() private pendingResolvedEvent = false private readonly filesByPath = new Map<string, TFile>() private readonly filesByName = new Map<string, TFile[]>() private readonly recordReferences = new Map<AutomergeUrl, readonly RecordBacklinkReference[]>()
/** * Unresolved links map: sourcePath → { rawLinktext → count }. * * Counts outgoing wikilinks/embeds whose linkpath does not resolve to any * current vault file for that source path. * * Note: This is not marked `readonly` to match upstream Obsidian API, * but the value should not be mutated by plugins. */ unresolvedLinks: Record<string, Record<string, number>> = {}
/** Event refs for vault subscriptions (for cleanup) */ private vaultEventRefs: EventRef[] = []
/** Cached `getAllTags()` result. The `version` field captures the * cache's epoch — when the live `version` no longer matches, the * cached `value` is stale and a fresh result is computed. The * cache itself is NOT proactively cleared on mutations; the * version mismatch causes the next read to bypass the stale entry * and recompute. */ private _allTagsCache: { version: number; value: Record<string, number> } | null = null
/** Whether display title feature is enabled. * Defaults to false — the Frontmatter Titles plugin enables it in onload() * and disables it in onunload(). This ensures the feature is off when the * plugin is disabled and on when it's enabled, with a single control point. */ private displayTitleEnabled = false
/** * Update metadata for a file. Extracts metadata, updates all indexes. * Fires public 'changed' only when a real TFile can be resolved. * * @internal Host-internal. Called by SearchProvider when document content * changes. Plugins must not call this — metadata is indexed automatically. */ updateFile(url: AutomergeUrl, name: string, content: string, contentHash?: string, heads?: Heads): void { this.updateParsedFile(url, name, content, extractMetadata(content), contentHash, heads) }
/** @internal Replace non-Markdown record references for one known source document. */ updateRecordReferences(url: AutomergeUrl, references: readonly RecordBacklinkReference[]): void { const previous = this.recordReferences.get(url) ?? [] if (previous.length === references.length && previous.every((reference, index) => { const next = references[index]! return reference.link === next.link && reference.typeName === next.typeName && reference.fieldId === next.fieldId && reference.fieldName === next.fieldName && reference.sourceText === next.sourceText && reference.line === next.line && reference.subject.kind === next.subject.kind && (reference.subject.kind === 'root' || next.subject.kind === 'block' && reference.subject.nodeId === next.subject.nodeId) })) return if (references.length) this.recordReferences.set(url, [...references]) else this.recordReferences.delete(url) this.version++ if (this.backgroundGraph) void this.scheduleGraph(url) else { this.computeResolvedLinks(); this.notifySubscribers() } }
/** @internal Withdraw every record reference, e.g. when its catalog is disabled. */ clearRecordReferences(): void { if (this.recordReferences.size === 0) return this.recordReferences.clear() this.version++ if (this.backgroundGraph) void this.scheduleGraph(undefined, true) else { this.computeResolvedLinks(); this.notifySubscribers() } }
/** @internal Enable eventually-consistent graph publication. */ enableBackgroundDerivation(client = new LinkgraphWorkerClient()): void { this.backgroundGraph?.stop() this.backgroundGraph = client this.graphHasTable = false this.backgroundGraphError = null void this.scheduleGraph(undefined, true) }
/** @internal Stop background graph work and restore the synchronous path. */ disableBackgroundDerivation(): void { this.backgroundGraph?.stop() this.backgroundGraph = null this.graphGeneration++ this.graphHasTable = false this.backgroundGraphError = null this.pendingChanged.clear() this.pendingDeleted.clear() this.pendingResolved.clear() this.pendingResolvedEvent = false this.computeResolvedLinks() }
/** @internal Update the cache with metadata extracted by the background parser. */ updateParsedFile( url: AutomergeUrl, name: string, content: string, metadata: CachedMetadata, contentHash?: string, heads?: Heads, contextsSnapshot?: Readonly<Record<string, string>>, ): void {
// Build per-link context snippets keyed by raw link text so truthful // backlink queries can still reuse the original surrounding snippet. const contexts = new Map<string, string>(Object.entries(contextsSnapshot ?? {})) if (!contextsSnapshot) { for (const reference of [...(metadata.links ?? []), ...(metadata.embeds ?? [])]) { const key = normalizeLinkTarget(reference.link) if (!contexts.has(key)) contexts.set(key, this.extractContext(content, reference.position.start.offset, reference.original.length)) } }
const previous = this.documents.get(url) const previousTags = previous ? collectAllTagsForDoc(previous.metadata) : new Set<string>() const nextTags = collectAllTagsForDoc(metadata) const nextDocument: CachedDocument = { name, metadata, hash: contentHash ?? null, heads: heads === undefined || heads.length === 0 ? null : [...heads], contexts, }
const removedTags = [...previousTags].filter(tag => !nextTags.has(tag)) const addedTags = [...nextTags].filter(tag => !previousTags.has(tag)) const touchedTags = new Map<string, { sources: Set<AutomergeUrl> | undefined hadDocument: boolean }>() for (const tag of [...removedTags, ...addedTags]) { const sources = this.tagSources.get(tag) touchedTags.set(tag, { sources, hadDocument: sources?.has(url) ?? false }) }
try { for (const tag of removedTags) { const sources = this.tagSources.get(tag) if (!sources) continue sources.delete(url) if (sources.size === 0) this.tagSources.delete(tag) } for (const tag of addedTags) { let sources = this.tagSources.get(tag) if (!sources) { sources = new Set() this.tagSources.set(tag, sources) } sources.add(url) } this.documents.set(url, nextDocument) } catch (error) { for (const [tag, { sources, hadDocument }] of touchedTags) { if (sources === undefined) { this.tagSources.delete(tag) continue } this.tagSources.set(tag, sources) if (hadDocument) sources.add(url) else sources.delete(url) } if (previous === undefined) this.documents.delete(url) else this.documents.set(url, previous) throw error }
// Notify this.version++ if (this.effectiveDisplayTitle(previous) !== this.effectiveDisplayTitle(nextDocument)) this.notifyDisplayTitleSubscribers() if (this.backgroundGraph) { const file = this.vault?.getFileByUrl(url) if (file) this.pendingChanged.set(url, { file, content, metadata }) void this.scheduleGraph(url) } else { this.computeResolvedLinks() this.notifySubscribers() const file = this.vault?.getFileByUrl(url) if (file) this.triggerIsolated('changed', file, content, metadata) }
// Background mode publishes the event from applyGraph(), after resolution. }
/** @internal Host-side indexing notification. */ notifyFileResolved(url: AutomergeUrl): void { const file = this.vault?.getFileByUrl(url) if (file) { if (this.backgroundGraph) this.pendingResolved.add(url) else this.triggerIsolated('resolve', file) } }
/** * Update canonical display name visibility without re-parsing content. * Path lookup remains owned by Vault/FileIndex; this cache only mirrors the * name it stores for compatibility fallbacks and backlink result labels. * * @internal Host-internal derived-index rename wiring. Plugins must use * FileManager.renameFile instead. */ renameFile(url: AutomergeUrl, name: string): void { const existing = this.documents.get(url) if (!existing || existing.name === name) return
const previousTitle = this.effectiveDisplayTitle(existing) existing.name = name this.version++ if (previousTitle !== this.effectiveDisplayTitle(existing)) this.notifyDisplayTitleSubscribers() if (this.backgroundGraph) void this.scheduleGraph(undefined, true) else { this.computeResolvedLinks(); this.notifySubscribers() } }
/** * Remove a file from all indexes. * * @internal Host-internal. Called by SearchProvider when a document is * deleted. Plugins must not call this — metadata is cleaned up automatically. */ removeFile(url: AutomergeUrl, deletedFile?: TFile): void { const hadRecordReferences = this.recordReferences.delete(url) const existing = this.documents.get(url) if (!existing) { if (hadRecordReferences) { this.version++ if (this.backgroundGraph) void this.scheduleGraph(undefined, true, [url]) else { this.computeResolvedLinks(); this.notifySubscribers() } } return }
const prevCache = existing.metadata
this.removeFromInvertedIndexes(url) this.documents.delete(url)
this.version++ this.notifyDisplayTitleSubscribers() if (this.backgroundGraph) { if (deletedFile) this.pendingDeleted.set(deletedFile, prevCache) void this.scheduleGraph(undefined, true, [url]) } else { this.computeResolvedLinks() this.notifySubscribers() const resolvedDeletedFile = this.vault?.getFileByUrl(url) if (deletedFile && resolvedDeletedFile === deletedFile) this.triggerIsolated('deleted', deletedFile, prevCache) }
}
/** * Get cached metadata for a document by path. * Obsidian-compatible API. Plugins call this as `app.metadataCache.getCache(path)`. * * @param path - The vault path of the file (e.g., "folder/note.md") * @returns The cached metadata, or null if not found */ getCache(path: string): CachedMetadata | null { if (!this.vault) { // Without vault, try to match by document name (basename only) const basename = path.split('/').pop()! for (const doc of this.documents.values()) { if (doc.name === basename) return doc.metadata } return null } const url = this.vault.getUrlForPath(path) if (!url) return null const doc = this.documents.get(url) return doc?.metadata ?? null }
/** * Get cached metadata for a document by AutomergeUrl. * * @internal Host-internal. Built-in components and plugins use this URL-keyed * accessor for direct document lookups. External Obsidian plugins should use * `getFileCache(file: TFile)` instead. */ getFileCacheByUrl(url: AutomergeUrl): CachedMetadata | null { const doc = this.documents.get(url) return doc?.metadata ?? null }
/** * Get cached metadata for a file. * Obsidian-compatible API. Plugins call this as `app.metadataCache.getFileCache(file)`. * * @param file - The TFile to get metadata for * @returns The cached metadata, or null if not found */ getFileCache(file: TFile): CachedMetadata | null { if (this.vault) { const url = this.vault.getUrlForFile?.(file) ?? this.vault.getUrlForPath(file.path) if (!url) return null const doc = this.documents.get(url) return doc?.metadata ?? null } // No vault reference — fall back to scanning by name for (const doc of this.documents.values()) { if (doc.name === file.name) return doc.metadata } return null }
/** * Get backlinks for a link target resolved through the current vault model. * Includes effective Record reference fields as well as Markdown links; * returns one row per source document, not one row per occurrence. * Queries are normalized through `getLinkpath()`, then resolved the same way * as outgoing links: path-qualified targets are exact, bare targets prefer * root-level matches and otherwise fall back to the first basename match. * * @internal Built-in plugins use this URL-keyed accessor; external plugins * should use `getBacklinksForFile(file)` (Obsidian-shape). */ getBacklinks(target: string): BacklinkResult[] { const targetPath = this.getBacklinkTargetPath(target)
const results: BacklinkResult[] = [] for (const [sourceUrl, doc] of this.documents.entries()) { // Exclude self-references const sourceFile = this.vault?.getFileByUrl(sourceUrl) const sourceTarget = sourceFile ? normalizeLinkTarget(sourceFile.path) : normalizeLinkTarget(doc.name) if (sourceTarget === targetPath) continue
const matchingLinks = this.getMatchingBacklinkReferences(sourceUrl, doc, targetPath) const recordReferences = (this.recordReferences.get(sourceUrl) ?? []).filter(reference => this.recordReferenceMatches(sourceUrl, reference.link, targetPath)) if (matchingLinks.length === 0 && recordReferences.length === 0) continue
results.push({ url: sourceUrl, name: doc.name, context: matchingLinks.length ? doc.contexts.get(normalizeLinkTarget(matchingLinks[0].link)) ?? '' : '', ...(recordReferences.length ? { recordReferences } : {}), }) } return results }
/** * Get backlinks for a file (Obsidian-compatible API). * Returns positioned Markdown LinkCache entries only: Record fields have no * Markdown position and cannot be represented by this API. * * @internal Built-in plugins (backlinks-panel) use this; not part of * upstream Obsidian's documented MetadataCache API. */ getBacklinksForFile(file: TFile): Map<string, LinkCache[]> { const result = new Map<string, LinkCache[]>() const targetPath = normalizeLinkTarget(file.path)
for (const [sourceUrl, doc] of this.documents.entries()) { // Exclude self-references const sourceFile = this.vault?.getFileByUrl(sourceUrl) const sourceTarget = sourceFile ? normalizeLinkTarget(sourceFile.path) : normalizeLinkTarget(doc.name) if (sourceTarget === targetPath) continue
const matchingLinks = this.getMatchingBacklinkReferences(sourceUrl, doc, targetPath) if (matchingLinks.length > 0) { result.set(sourceFile?.path ?? doc.name, matchingLinks) } } return result }
/** * Get first link target matching a path. Obsidian-compatible. * * Resolution order (matches Obsidian's behavior): * 1. If `linkpath` includes `/`: vault-absolute lookup. Returns a file * whose normalized path matches; otherwise null. * 2. With non-empty `sourcePath`: sibling-relative — search the source * file's directory for a basename match first. * 3. Root-level basename match (back-compat: bare links to root files). * 4. First basename match anywhere in the vault (Obsidian's last * resort for ambiguous bare links). * * The `sourcePath` is the path of the note containing the link. Empty * string means "no source context" — common for cross-cutting callers * like backlinks panels and the daily-notes plugin. */ getFirstLinkpathDest(linkpath: string, sourcePath: string): TFile | null { if (!this.vault) return null if (typeof this.vault.getMarkdownFiles !== 'function') return null const normalized = normalizeLinkTarget(linkpath) const files = this.vault.getMarkdownFiles() // Step 1: vault-absolute (linkpath contains '/') if (normalized.includes('/')) { return files.find(f => normalizeLinkTarget(f.path) === normalized) ?? null }
// Step 2: sibling-relative — search the source file's directory. // The source's directory is everything up to the last '/' in sourcePath. // sourcePath may be 'folder/source.md' (dir = 'folder'), 'source.md' (dir = ''), // or '' (no source context). const lastSlash = sourcePath.lastIndexOf('/') const sourceDir = lastSlash >= 0 ? sourcePath.slice(0, lastSlash) : '' if (sourceDir) { const sibling = files.find(f => f.path.startsWith(`${sourceDir}/`) && !f.path.slice(sourceDir.length + 1).includes('/') && f.basename.toLowerCase() === normalized) if (sibling) return sibling }
// Step 3: root-level basename match (preserves prior bare-link semantics). const rootMatch = files.find(f => !f.path.includes('/') && f.basename.toLowerCase() === normalized) if (rootMatch) return rootMatch
// Step 4: first basename match anywhere (Obsidian's last resort). return files.find(f => f.basename.toLowerCase() === normalized) ?? null }
/** * Get all tags with their occurrence counts across all documents. * * Counting rules: * - Inline body tags count by occurrence: a doc with `#todo` * written twice contributes 2 to `#todo` (matches Obsidian). * - Frontmatter tags count by doc-membership: a doc with * `tags: [foo, foo]` in YAML contributes 1 (not 2) to `#foo`, * because YAML tag lists are conceptually unordered sets and * duplicate entries are typos rather than meaningful. * * Frontmatter parsing is delegated to `parseFrontmatterTags`, * which validates each entry against the tag grammar * (`TAG_NAME_RE`) and silently drops malformed entries like * `tags: ["@invalid", "with space", 123]`. * * Cached per-version: same input → same Record reference without * re-walking the document map. The `version` field bumps on every * `updateFile` / `removeFile` / `clear` / vault `create` / etc., * so the next call after any mutation returns a fresh result. * * **Caller must NOT retain the returned Record across mutations.** * The Record is a snapshot at one version; reading from it after * `updateFile()` returns stale data (the cache field gets replaced, * but the previously-returned reference still points at the old * snapshot). Always re-call `getAllTags()` for current state. * * @internal Built-in plugins (tags-panel) use this; external plugins should * use `getFileCache(file)?.tags` plus `parseFrontMatterTags` for tag access. */ getAllTags(): Record<string, number> { if (this._allTagsCache && this._allTagsCache.version === this.version) { return this._allTagsCache.value }
const result: Record<string, number> = {} for (const doc of this.documents.values()) { // Inline body tags — one count per occurrence. for (const tag of doc.metadata.tags ?? []) { result[tag.tag] = (result[tag.tag] ?? 0) + 1 }
// Frontmatter tags — one count per doc per UNIQUE tag in the // list (within-doc dedup is handled by parseFrontmatterTags // which returns a Set). for (const tag of parseFrontmatterTags(doc.metadata.frontmatter)) { result[tag] = (result[tag] ?? 0) + 1 } }
this._allTagsCache = { version: this.version, value: result } return result }
/** * Get all tags in the vault (Obsidian-compatible alias). * Obsidian exposes this as `getTags()`. Reams' primary method is * `getAllTags()` — this is an alias for plugins that call `getTags()`. * QuickAdd calls this in refreshTagIndex() and getMarkdownFilesWithTag(). * * @internal Shim for QuickAdd/third-party plugins. Not part of upstream * Obsidian's MetadataCache contract — external plugins that need tags * should use `getFileCache(file)?.tags` plus `parseFrontMatterTags`. */ getTags(): Record<string, number> { return this.getAllTags() }
/** * Whether a path is in the user's "Excluded files" list (Obsidian setting). * Plugins call this to skip indexing/processing of paths the user opted * out of — Strange New Worlds passes `oe.app.metadataCache.isUserIgnored(file?.path)` * before doing any reference work. * * Reams has no excluded-files concept yet, so this always returns `false`. * Accepts `undefined`/`null` paths without throwing — SNW's call site is * `isUserIgnored(t == null ? void 0 : t.path)` and crashing there would * tear down the plugin's view extension. * * @internal Stub shim for Strange New Worlds plugin. Not part of upstream * Obsidian's documented MetadataCache API. */ isUserIgnored(_path: string | undefined | null): boolean { return false }
/** * Get the derived content hash for a document (for integrity metadata). * * @internal Host-internal indexing primitive. Used by SearchProvider for * incremental re-indexing. Plugins should not need content hashes. */ getDocumentHash(url: AutomergeUrl): string | null { return this.documents.get(url)?.hash ?? null }
/** @internal Host-internal derived-index freshness helper. */ getDocumentHeads(url: AutomergeUrl): Heads | undefined { return this.documents.get(url)?.heads ?? undefined }
/** * Get all indexed document URLs. * * @internal Host-internal indexing primitive. Used by SearchProvider for * cache warming. Plugins should use `vault.getMarkdownFiles()` instead. */ getIndexedDocuments(): AutomergeUrl[] { return [...this.documents.keys()] }
/** @internal Iterate indexed URLs without allocating a vault-sized snapshot. */ iterateIndexedDocuments(): IterableIterator<AutomergeUrl> { return this.documents.keys() }
/** * Subscribe to changes (for useSyncExternalStore). * Returns unsubscribe function. * * @internal Built-in React UI uses this via useMetadataCacheVersion hook. * External plugins should use `on('changed', callback)` (Obsidian-shape). */ subscribe(callback: () => void): () => void { this.subscribers.add(callback) return () => { this.subscribers.delete(callback) } }
/** * Get current version (snapshot for useSyncExternalStore). * * @internal Built-in React UI uses this via useMetadataCacheVersion hook. * External plugins should use `on('changed', callback)` (Obsidian-shape). */ getVersion(): number { return this.version }
/** * Set vault reference for link resolution. * Subscribes to vault create/delete/rename events. * * @internal Host-internal wiring. Plugins must not call this method — * it's used by the Reams host (SearchProvider) to wire up cache invalidation * when vault files are created, deleted, or renamed. */ setVault(vault: Vault): void { // Clean up old subscriptions for (const ref of this.vaultEventRefs) { this.vault?.offref(ref) } this.vaultEventRefs = []
this.vault = vault this.rebuildFileLookup()
// Subscribe to vault events for link resolution updates this.vaultEventRefs.push(vault.on('create', () => { this.rebuildFileLookup() if (this.backgroundGraph) void this.scheduleGraph(undefined, true) this.version++ if (!this.backgroundGraph) { this.computeResolvedLinks(); this.notifySubscribers() } })) this.vaultEventRefs.push(vault.on('delete', () => { this.rebuildFileLookup() if (this.backgroundGraph) void this.scheduleGraph(undefined, true) this.version++ if (!this.backgroundGraph) { this.computeResolvedLinks(); this.notifySubscribers() } })) this.vaultEventRefs.push(vault.on('rename', () => { this.rebuildFileLookup() if (this.backgroundGraph) void this.scheduleGraph(undefined, true) this.version++ if (!this.backgroundGraph) { this.computeResolvedLinks(); this.notifySubscribers() } }))
// Pre-vault indexing may have cached link maps against document names and // markdown-only resolution. Recompute once the real vault is wired in. this.version++ if (this.backgroundGraph) void this.scheduleGraph(undefined, true) else { this.computeResolvedLinks(); this.notifySubscribers() } }
/** * Clear all indexed data. * * @internal Host-internal. Used during vault re-initialization or testing. * Plugins must not call this — it would break the metadata cache for the * entire application. */ clear(): void { const hadDocuments = this.documents.size > 0 this.documents.clear() this.recordReferences.clear() this.tagSources.clear() this.resolvedLinks = {} this.unresolvedLinks = {} this.graphGeneration++ this.graphHasTable = false this.pendingChanged.clear() this.pendingDeleted.clear() this.pendingResolved.clear() this.backgroundGraph?.reset() this.version++ if (!this.backgroundGraph) this.computeResolvedLinks() this.notifySubscribers() if (hadDocuments) this.notifyDisplayTitleSubscribers() }
/** * Serialize the cache for persistence. * * @internal Host-internal persistence. Called by derived-index persistence. * to save metadata cache across sessions. Plugins must not call this. */ serialize(): SerializedMetadataCache { const documents: SerializedMetadataCache['documents'] = {} for (const [url, doc] of this.documents) { documents[url] = { name: doc.name, metadata: doc.metadata, hash: doc.hash, heads: doc.heads, contexts: Object.fromEntries(doc.contexts), } } return { documents } }
/** * Deserialize a cache from persistence. * Replaces all current data. Notifies subscribers once. * * @internal Host-internal persistence. Called by derived-index persistence. * on startup. Plugins must not call this — it would overwrite all metadata. */ deserialize(data: SerializedMetadataCache): void { // Clear without notifying this.documents.clear() this.recordReferences.clear() this.tagSources.clear()
// Rebuild from serialized data const documents = isRecord(data) && isRecord(data.documents) ? data.documents : {} for (const [url, entry] of Object.entries(documents)) { if (!isRecord(entry) || typeof entry.name !== 'string' || !isRecord(entry.metadata)) continue const amUrl = url as AutomergeUrl const heads = Array.isArray(entry.heads) && entry.heads.every((head): head is string => typeof head === 'string') ? entry.heads : null const contexts = isRecord(entry.contexts) ? Object.entries(entry.contexts).filter((context): context is [string, string] => typeof context[1] === 'string') : [] const doc: CachedDocument = { name: entry.name, metadata: entry.metadata as CachedMetadata, hash: typeof entry.hash === 'string' ? entry.hash : null, heads: heads === null ? null : [...heads], contexts: new Map(contexts), } this.documents.set(amUrl, doc) this.addToInvertedIndexes(amUrl, doc.metadata) }
// Notify once this.version++ this.notifyDisplayTitleSubscribers() if (this.backgroundGraph) { this.pendingResolvedEvent = true void this.scheduleGraph(undefined, true) } else { this.computeResolvedLinks() this.notifySubscribers() this.triggerIsolated('resolved') } }
/** * Resolved links map: sourcePath → { destinationPath → count }. * Obsidian-compatible API used by Calendar plugin. * * This property is updated synchronously by the compatibility path, or * eventually by the opt-in background graph. * Plugins should treat this as read-only even though it's not marked * `readonly` (to match upstream Obsidian API). */ resolvedLinks: Record<string, Record<string, number>> = {}
/** @internal Last background graph failure, retained for host diagnostics and retry UI. */ get lastGraphError(): Error | null { return this.backgroundGraphError }
/** * Get resolved links, computing if needed. * This is the internal accessor that rebuilds the cache when stale. * The `resolvedLinks` property is updated to the computed value. * * @internal Synchronous compatibility resolver. */ private computeResolvedLinks(): Record<string, Record<string, number>> { const resolved: Record<string, Record<string, number>> = {} const unresolved: Record<string, Record<string, number>> = {}
for (const [url, doc] of this.documents.entries()) { const sourceFile = this.vault?.getFileByUrl(url as AutomergeUrl) if (!sourceFile) continue
const sourcePath = sourceFile.path const resolvedTargets: Record<string, number> = {} const unresolvedTargets: Record<string, number> = {} const outgoing: (LinkCache | EmbedCache)[] = [ ...(doc.metadata.links ?? []), ...(doc.metadata.embeds ?? []), ]
for (const reference of outgoing) { const target = this.resolveReferenceTarget(reference, sourcePath) if (target) { resolvedTargets[target.path] = (resolvedTargets[target.path] ?? 0) + 1 } else { unresolvedTargets[reference.link] = (unresolvedTargets[reference.link] ?? 0) + 1 } } for (const reference of this.recordReferences.get(url) ?? []) { const target = this.resolveLinkpathTarget(recordLinkpath(reference.link), sourcePath) if (target) resolvedTargets[target.path] = (resolvedTargets[target.path] ?? 0) + 1 else { const linkpath = recordLinkpath(reference.link) unresolvedTargets[linkpath] = (unresolvedTargets[linkpath] ?? 0) + 1 } }
// Always include the document, even with zero outgoing links. // Calendar checks `resolvedLinks[path]` and expects `{}`, not `undefined`. resolved[sourcePath] = resolvedTargets unresolved[sourcePath] = unresolvedTargets }
this.resolvedLinks = resolved this.unresolvedLinks = unresolved return resolved }
/** * Get the link text for a file relative to a source path. * Obsidian-compatible API used by Periodic Notes. * * Returns basename for .md files, full name otherwise. * In Obsidian this resolves shortest-unique-path, but basename * is sufficient for our single-folder-depth usage. * * @param file - The file to generate link text for * @param sourcePath - The path of the note containing the link (unused in Reams) * @param omitMdExtension - If true, omit the .md extension from the link text. * Defaults to true for .md files. If false, includes the full filename. * @returns The link text for the file */ fileToLinktext(file: TFile, _sourcePath: string, omitMdExtension?: boolean): string { // Default behavior: omit .md extension for markdown files const shouldOmitExtension = omitMdExtension ?? true if (file.extension !== 'md') return file.name if (!shouldOmitExtension) return file.name return file.path.includes('/') ? file.path.replace(/\.md$/i, '') : file.basename }
/** * Get the display title for a document. * Returns the frontmatter `title` field if it's a non-empty string, * otherwise falls back to the file basename (name without .md). * * Accepts AutomergeUrl (internal) or TFile (Obsidian-compatible). * Returns null if the document is not indexed. * * **Reams extension** — This method is not part of Obsidian's MetadataCache API. * It is a deliberate Reams-only addition that powers frontmatter title display * across tabs, file tree, backlinks, and search results. The Frontmatter Titles * plugin controls whether frontmatter titles are enabled globally. When disabled, * this method returns basenames. * * @internal Not part of upstream Obsidian's MetadataCache API. Built-in * Reams plugins and host code use this method directly on MetadataCache. */ getDisplayTitle(urlOrFile: AutomergeUrl | TFile): string | null { // Resolve TFile to URL if needed let url: AutomergeUrl | null = null let fallbackName: string | null = null
if (typeof urlOrFile === 'object' && urlOrFile !== null && 'path' in urlOrFile) { const file = urlOrFile as TFile fallbackName = file.basename if (this.vault) { url = this.vault.getUrlForFile?.(file) ?? this.vault.getUrlForPath(file.path) ?? null } else { // No vault — scan by name for (const [docUrl, doc] of this.documents) { if (doc.name === file.name) { url = docUrl break } } } } else { url = urlOrFile as AutomergeUrl // For AutomergeUrl path, try to get fallback name from vault if (this.vault) { const file = this.vault.getFileByUrl(url) if (file) fallbackName = file.basename } }
if (!url) return fallbackName
const doc = this.documents.get(url) if (!doc) return fallbackName return this.effectiveDisplayTitle(doc) }
/** * Get all tags for a document (inline body tags + frontmatter tags). * Frontmatter tags are normalized to include `#` prefix; entries that * don't match the tag grammar (TAG_NAME_RE) are silently dropped via * `parseFrontmatterTags`. * Returns a deduplicated, sorted array. Empty array if document not * found. * * @internal Built-in plugins (tags-panel) use this URL-keyed accessor; * external plugins should use `getFileCache(file)?.tags` plus * `parseFrontmatterTags` for frontmatter tags (Obsidian-shape). */ getDocumentTags(url: AutomergeUrl): string[] { const doc = this.documents.get(url) if (!doc) return []
const tagSet = new Set<string>()
// Inline body tags for (const tag of doc.metadata.tags ?? []) { tagSet.add(tag.tag) }
// Frontmatter tags for (const tag of parseFrontmatterTags(doc.metadata.frontmatter)) { tagSet.add(tag) }
return [...tagSet].sort() }
/** * Get all outgoing links (wikilinks + embeds) from a document. * Returns combined array. Empty if document not found. * * @internal No production callers; retained for symmetry with * `getResolvedOutgoingLinks`. External plugins should use * `getFileCache(file)?.links` and `getFileCache(file)?.embeds` * (Obsidian-shape). */ getOutgoingLinks(url: AutomergeUrl): (LinkCache | EmbedCache)[] { const doc = this.documents.get(url) if (!doc) return [] return [...(doc.metadata.links ?? []), ...(doc.metadata.embeds ?? [])] }
/** * Get outgoing links with resolved display titles. * * For each link, resolves the target file via `getFirstLinkpathDest` * and looks up the display title from the forward index. This keeps * the O(n × m) file lookup in the service layer (computed once per * version bump) instead of in the React render path. * * @internal Built-in plugins (outgoing-links-panel) use this URL-keyed * accessor; external plugins should use `resolvedLinks[file.path]` for * the raw counts, or build their own display-title resolution from * `getFileCache(file)?.links` + `getDisplayTitle` (Obsidian-shape). */ getResolvedOutgoingLinks(url: AutomergeUrl): Array<{ link: LinkCache | EmbedCache displayTitle: string targetUrl: AutomergeUrl | null }> { const doc = this.documents.get(url) if (!doc) return []
const rawLinks: (LinkCache | EmbedCache)[] = [ ...(doc.metadata.links ?? []), ...(doc.metadata.embeds ?? []), ]
return rawLinks.map(link => { const sourceFile = this.vault?.getFileByUrl(url) const file = this.resolveReferenceTarget(link, sourceFile?.path ?? '') // Use displayText if available, otherwise fall back to the link target let displayTitle = link.displayText ?? link.link let targetUrl: AutomergeUrl | null = null
if (file) { const resolved = this.getDisplayTitle(file) if (resolved) displayTitle = resolved // file is non-null only when vault resolved it, so this.vault is set. targetUrl = this.vault!.getUrlForPath(file.path) ?? null }
return { link, displayTitle, targetUrl } }) }
/** * Check whether the display title feature is currently enabled. * * @internal Host-internal state query. Plugins should not need to check this — * they should simply call `getDisplayTitle()` which returns the appropriate * value based on the current feature state. */ isDisplayTitleEnabled(): boolean { return this.displayTitleEnabled }
/** * Enable or disable the display title feature globally. * * @internal Host-internal wiring. Plugins must not call this method — the * Frontmatter Titles plugin is the single control point for enabling/disabling * display titles. When enabled, `getDisplayTitle()` returns frontmatter `title:` * fields; when disabled, it returns basenames. */ setDisplayTitleEnabled(enabled: boolean): void { if (this.displayTitleEnabled === enabled) return this.displayTitleEnabled = enabled // No localStorage persistence — the Frontmatter Titles plugin's // enabled state (tracked by EnabledStateStore) controls this. this.version++ this.notifySubscribers() this.notifyDisplayTitleSubscribers() }
/** @internal Subscribe only to effective file-title changes, not every Markdown edit. */ subscribeDisplayTitles(callback: () => void): () => void { this.displayTitleSubscribers.add(callback) return () => { this.displayTitleSubscribers.delete(callback) } }
private effectiveDisplayTitle(doc: CachedDocument | undefined): string | null { if (!doc) return null const title = doc.metadata.frontmatter?.['title'] return this.displayTitleEnabled && typeof title === 'string' && title.trim() ? title.trim() : doc.name.replace(/\.md$/i, '') }
private notifyDisplayTitleSubscribers(): void { for (const callback of [...this.displayTitleSubscribers]) { try { callback() } catch { /* Isolate observers from metadata publication. */ } } }
// --- Private helpers ---
private addToInvertedIndexes(url: AutomergeUrl, metadata: CachedMetadata): void { // Tags — include BOTH inline body tags and frontmatter `tags:` // entries, so docs that only declare tags in frontmatter still // appear in the inverted index. Frontmatter tags are deduped // within the doc by parseFrontmatterTags's Set return type. const tagsForDoc = collectAllTagsForDoc(metadata) for (const tag of tagsForDoc) { if (!this.tagSources.has(tag)) { this.tagSources.set(tag, new Set()) } this.tagSources.get(tag)!.add(url) } }
private removeFromInvertedIndexes(url: AutomergeUrl): void { const doc = this.documents.get(url) if (!doc) return
// Remove from tags — same combined set as `addToInvertedIndexes`. const tagsForDoc = collectAllTagsForDoc(doc.metadata) for (const tag of tagsForDoc) { const sources = this.tagSources.get(tag) if (sources) { sources.delete(url) if (sources.size === 0) { this.tagSources.delete(tag) } } } }
/** * Extract ~CONTEXT_RADIUS chars of context around a match position. */ private extractContext(content: string, matchOffset: number, matchLength: number): string { const start = Math.max(0, matchOffset - CONTEXT_RADIUS) const end = Math.min(content.length, matchOffset + matchLength + CONTEXT_RADIUS) let snippet = content.slice(start, end) if (start > 0) snippet = '...' + snippet if (end < content.length) snippet = snippet + '...' return snippet }
private getBacklinkTargetPath(target: string): string { const linkpath = getLinkpath(target) const resolvedTarget = this.resolveLinkpathTarget(linkpath, '') return normalizeLinkTarget(resolvedTarget?.path ?? linkpath) }
private getMatchingBacklinkReferences( sourceUrl: AutomergeUrl, doc: CachedDocument, targetPath: string, ): LinkCache[] { const outgoing: LinkCache[] = [ ...(doc.metadata.links ?? []), ...(doc.metadata.embeds ?? []), ] const rawMatches = outgoing.filter( reference => normalizeLinkTarget(getLinkpath(reference.link)) === targetPath, )
if (!this.vault) { return rawMatches }
const sourceFile = this.vault.getFileByUrl(sourceUrl) if (!sourceFile) return [] if (typeof this.vault.getMarkdownFiles !== 'function' && typeof this.vault.getFiles !== 'function') { return rawMatches }
return outgoing.filter(reference => { const resolvedTarget = this.resolveReferenceTarget(reference, sourceFile.path) return normalizeLinkTarget(resolvedTarget?.path ?? '') === targetPath }) }
private recordReferenceMatches(sourceUrl: AutomergeUrl, reference: string, targetPath: string): boolean { if (!this.vault) return normalizeLinkTarget(recordLinkpath(reference)) === targetPath const sourcePath = this.vault?.getFileByUrl(sourceUrl)?.path ?? this.documents.get(sourceUrl)?.name ?? '' return normalizeLinkTarget(this.resolveLinkpathTarget(recordLinkpath(reference), sourcePath)?.path ?? '') === targetPath }
private resolveReferenceTarget(reference: LinkCache | EmbedCache, sourcePath: string): TFile | null { return this.resolveLinkpathTarget(getLinkpath(reference.link), sourcePath) }
private resolveLinkpathTarget(linkpath: string, sourcePath: string): TFile | null { const markdownTarget = this.getFirstLinkpathDest(linkpath, sourcePath) if (markdownTarget) return markdownTarget if (!this.vault || typeof this.vault.getFiles !== 'function') return null
const normalizedLinkpath = linkpath.toLowerCase() if (normalizedLinkpath.includes('/')) { return this.filesByPath.get(normalizeLinkTarget(normalizedLinkpath)) ?? null }
const lastSlash = sourcePath.lastIndexOf('/') const sourceDir = lastSlash >= 0 ? sourcePath.slice(0, lastSlash) : '' if (sourceDir) { const sibling = this.filesByName.get(normalizeLinkTarget(normalizedLinkpath))?.find(file => file.path.startsWith(`${sourceDir}/`) && !file.path.slice(sourceDir.length + 1).includes('/') && file.name.toLowerCase() === normalizedLinkpath) if (sibling) return sibling }
const rootMatch = this.filesByName.get(normalizeLinkTarget(normalizedLinkpath))?.find(file => !file.path.includes('/') && file.name.toLowerCase() === normalizedLinkpath) if (rootMatch) return rootMatch
return this.filesByName.get(normalizeLinkTarget(normalizedLinkpath))?.find(file => file.name.toLowerCase() === normalizedLinkpath) ?? null }
private notifySubscribers(): void { for (const cb of this.subscribers) { try { cb() } catch { // Host subscribers must not interrupt committed metadata publication. } } }
private rebuildFileLookup(): void { this.filesByPath.clear() this.filesByName.clear() const files = this.vault?.getFiles?.() ?? this.vault?.getMarkdownFiles?.() ?? [] for (const file of files) { this.filesByPath.set(normalizeLinkTarget(file.path), file) const key = file.basename.toLowerCase() const files = this.filesByName.get(key) ?? [] files.push(file) this.filesByName.set(key, files) const fullNameKey = normalizeLinkTarget(file.name) if (fullNameKey !== key) { const named = this.filesByName.get(fullNameKey) ?? [] named.push(file) this.filesByName.set(fullNameKey, named) } } }
private scheduleGraph(dirtyUrl?: AutomergeUrl, membershipChanged = false, removedUrls: string[] = []): Promise<void> { const graph = this.backgroundGraph if (!graph) return Promise.resolve() const dirty: LinkgraphFile[] = [] if (dirtyUrl) { const doc = this.documents.get(dirtyUrl) const file = this.vault?.getFileByUrl(dirtyUrl) if (doc && file) dirty.push({ url: dirtyUrl, path: file.path, metadata: doc.metadata, recordReferences: this.recordReferences.get(dirtyUrl)?.map(reference => reference.link) }) } if (dirty.length === 0 && dirtyUrl && !this.vault) { const doc = this.documents.get(dirtyUrl) if (doc) dirty.push({ url: dirtyUrl, path: doc.name, metadata: doc.metadata, recordReferences: this.recordReferences.get(dirtyUrl)?.map(reference => reference.link) }) } let table: LinkgraphFile[] | undefined if (!this.graphHasTable || membershipChanged) { table = [] for (const file of this.vault?.getFiles?.() ?? this.vault?.getMarkdownFiles?.() ?? []) { const url = this.vault?.getUrlForFile?.(file) ?? this.vault?.getUrlForPath(file.path) if (url) table.push({ url, path: file.path, metadata: this.documents.get(url)?.metadata ?? {}, recordReferences: this.recordReferences.get(url)?.map(reference => reference.link) }) } this.graphHasTable = true } const generation = ++this.graphGeneration return graph.update(dirty, generation, table, table ? generation : undefined, removedUrls) .then(result => this.applyGraph(result, generation)) .catch(error => { this.graphHasTable = false this.backgroundGraphError = error instanceof Error ? error : new Error('Linkgraph derivation failed') }) }
private applyGraph(result: { generation: number; resolvedLinks: Record<string, Record<string, number>>; unresolvedLinks: Record<string, Record<string, number>> }, generation: number): void { if (generation !== this.graphGeneration || result.generation !== generation) return this.resolvedLinks = result.resolvedLinks this.unresolvedLinks = result.unresolvedLinks this.backgroundGraphError = null this.version++ this.notifySubscribers() for (const { file, content, metadata } of this.pendingChanged.values()) this.triggerIsolated('changed', file, content, metadata) this.pendingChanged.clear() for (const [file, metadata] of this.pendingDeleted) this.triggerIsolated('deleted', file, metadata) this.pendingDeleted.clear() for (const url of this.pendingResolved) { const file = this.vault?.getFileByUrl(url) if (file) this.triggerIsolated('resolve', file) } this.pendingResolved.clear() if (this.pendingResolvedEvent) { this.pendingResolvedEvent = false this.triggerIsolated('resolved') } }}