From d1e1c091e6e6d92c5783a398deed00dca1377a9e Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Thu, 20 Feb 2025 11:17:18 -0600 Subject: [PATCH 1/5] feat: Add `$atime` intrinsic to `File`s This field will track the last access time of the file in the stored metadata. It's added as a part of the `File` interface definition, and implemented for `MarkdownPage`, `Canvas`, and `GenericFile`, as well as their JSON representations. --- src/index/types/canvas.ts | 3 +++ src/index/types/files.ts | 5 ++++- src/index/types/indexable.ts | 2 ++ src/index/types/json/canvas.ts | 2 ++ src/index/types/json/markdown.ts | 2 ++ src/index/types/markdown.ts | 4 ++++ 6 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/index/types/canvas.ts b/src/index/types/canvas.ts index d770f7aa5..2e8a1f198 100644 --- a/src/index/types/canvas.ts +++ b/src/index/types/canvas.ts @@ -34,6 +34,7 @@ export class Canvas implements Linkable, File, Linkbearing, Taggable, Indexable, $types: string[] = Canvas.TYPES; $typename: string = "Canvas"; + $atime?: DateTime; $ctime: DateTime; $mtime: DateTime; @@ -75,6 +76,7 @@ export class Canvas implements Linkable, File, Linkbearing, Taggable, Indexable, $cards: this.$cards.map((x) => x.json()) as JsonCanvasCard[], $ctime: this.$ctime.toMillis(), $mtime: this.$mtime.toMillis(), + $atime: this.$atime?.toMillis(), $size: this.$size, $links: this.$links, $path: this.$path, @@ -101,6 +103,7 @@ export class Canvas implements Linkable, File, Linkbearing, Taggable, Indexable, $cards: cards, $ctime: DateTime.fromMillis(raw.$ctime), $mtime: DateTime.fromMillis(raw.$mtime), + $atime: raw.$atime ? DateTime.fromMillis(raw.$atime) : undefined, $size: raw.$size, $extension: "canvas", $path: raw.$path, diff --git a/src/index/types/files.ts b/src/index/types/files.ts index c429d7137..edc2401e4 100644 --- a/src/index/types/files.ts +++ b/src/index/types/files.ts @@ -19,15 +19,18 @@ export class GenericFile implements File, Indexable, Fieldbearing, Linkable { $ctime: DateTime; /** Obsidian-provided date this page was modified. */ $mtime: DateTime; + /** Timestamp of last file access, as determined by inspecting `file-open` workspace events */ + $atime?: DateTime; /** Obsidian-provided size of this page in bytes. */ $size: number; /** The extension of the file. */ $extension: string; - public constructor(path: string, ctime: DateTime, mtime: DateTime, size: number) { + public constructor(path: string, ctime: DateTime, mtime: DateTime, size: number, atime?: DateTime) { this.$path = path; this.$ctime = ctime; this.$mtime = mtime; + this.$atime = atime; this.$size = size; const lastDot = path.lastIndexOf("."); diff --git a/src/index/types/indexable.ts b/src/index/types/indexable.ts index 0ad00450d..84c613ba7 100644 --- a/src/index/types/indexable.ts +++ b/src/index/types/indexable.ts @@ -44,6 +44,8 @@ export interface File extends Linkable { $ctime: DateTime; /** Obsidian-provided date this page was modified. */ $mtime: DateTime; + /** Timestamp of last file access, as determined by inspecting `file-open` workspace events */ + $atime?: DateTime; /** Obsidian-provided size of this page in bytes. */ $size: number; /** The extension of the file. */ diff --git a/src/index/types/json/canvas.ts b/src/index/types/json/canvas.ts index 7228f2c34..621ccd5c5 100644 --- a/src/index/types/json/canvas.ts +++ b/src/index/types/json/canvas.ts @@ -36,6 +36,8 @@ export interface JsonCanvas { $ctime: number; /** Last modified time as a UNIX epoch time in milliseconds. */ $mtime: number; + /** Last access time as a UNIX epoch time in milliseconds. */ + $atime?: number; /** All tags in the canvas. */ $tags: string[]; /** All links in the canvas. */ diff --git a/src/index/types/json/markdown.ts b/src/index/types/json/markdown.ts index 255037fc4..77e53a3e0 100644 --- a/src/index/types/json/markdown.ts +++ b/src/index/types/json/markdown.ts @@ -33,6 +33,8 @@ export interface JsonMarkdownPage { $ctime: number; /** Obsidian-provided date this page was modified. */ $mtime: number; + /** Timestamp of last file access, as determined by inspecting `file-open` workspace events */ + $atime?: number; /** The extension; for markdown files, almost always '.md'. */ $extension: string; /** Obsidian-provided size of this page in bytes. */ diff --git a/src/index/types/markdown.ts b/src/index/types/markdown.ts index 2270d0a0c..f7488484d 100644 --- a/src/index/types/markdown.ts +++ b/src/index/types/markdown.ts @@ -61,6 +61,8 @@ export class MarkdownPage implements File, Linkbearing, Taggable, Indexable, Fie $ctime: DateTime; /** Obsidian-provided date this page was modified. */ $mtime: DateTime; + /** Timestamp of last file access, as determined by inspecting `file-open` workspace events */ + $atime?: DateTime; /** The extension; for markdown files, almost always '.md'. */ $extension: string; /** Obsidian-provided size of this page in bytes. */ @@ -89,6 +91,7 @@ export class MarkdownPage implements File, Linkbearing, Taggable, Indexable, Fie $infields: mapObjectValues(raw.$infields, (field) => normalizeLinks(valueInlineField(field), normalizer)), $ctime: DateTime.fromMillis(raw.$ctime), $mtime: DateTime.fromMillis(raw.$mtime), + $atime: raw.$atime ? DateTime.fromMillis(raw.$atime) : undefined, $extension: raw.$extension, $size: raw.$size, $position: raw.$position, @@ -140,6 +143,7 @@ export class MarkdownPage implements File, Linkbearing, Taggable, Indexable, Fie $infields: mapObjectValues(this.$infields, jsonInlineField), $ctime: this.$ctime.toMillis(), $mtime: this.$mtime.toMillis(), + $atime: this.$atime?.toMillis(), $extension: this.$extension, $size: this.$size, $position: this.$position, From 68a044cf553a6e1a35940dceaffde134f00b98c7 Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Thu, 20 Feb 2025 11:20:08 -0600 Subject: [PATCH 2/5] feat: Type guard narrowing fn's for Indexables Because `File`, `Taggable`, `Linkbearing`, and `Fieldbearing` don't have any type overlap with `Indexable` itself, direct casting via `as` isn't possible. We introduce user-defined type-guards to get around this. This adds type narrowing guards for the above mentioned four interfaces. --- src/index/types/indexable.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/index/types/indexable.ts b/src/index/types/indexable.ts index 84c613ba7..c8cafb080 100644 --- a/src/index/types/indexable.ts +++ b/src/index/types/indexable.ts @@ -32,6 +32,14 @@ export interface Linkable { $link: Link; } +export function isLinkable(obj: any): obj is Linkable { + if (obj && obj.$types !== undefined && Array.isArray(obj.$types) && obj.$types.contains(LINKABLE_TYPE)) { + return true; + } + + return false; +} + /** General metadata for any file. */ export const FILE_TYPE = "file"; /** @@ -52,6 +60,14 @@ export interface File extends Linkable { $extension: string; } +export function isFile(obj: any): obj is File { + if (obj && obj.$types !== undefined && Array.isArray(obj.$types) && obj.$types.contains(FILE_TYPE)) { + return true; + } + + return false; +} + /** Metadata for taggable objects. */ export const TAGGABLE_TYPE = "taggable"; /** @@ -62,6 +78,14 @@ export interface Taggable { $tags: string[]; } +export function isTaggable(obj: any): obj is Taggable { + if (obj && obj.$types !== undefined && Array.isArray(obj.$types) && obj.$types.contains(TAGGABLE_TYPE)) { + return true; + } + + return false; +} + /** Metadata for objects which can link to other things. */ export const LINKBEARING_TYPE = "links"; /** @@ -72,6 +96,14 @@ export interface Linkbearing { $links: Link[]; } +export function isLinkbearing(obj: any): obj is Linkbearing { + if (obj && obj.$types !== undefined && Array.isArray(obj.$types) && obj.$types.contains(LINKBEARING_TYPE)) { + return true; + } + + return false; +} + /** * All supported extensions. This should probably become a dynamic lookup table and not just * a fixed list at some point, especially if we add the ability to turn indexing on/off. From a7ada90c73cabb3bc5aec912d0a826c123b4f18d Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Thu, 20 Feb 2025 11:24:57 -0600 Subject: [PATCH 3/5] feat: Track file metadata `$atime` file access We utilize the `Workspace`'s `file-open` event for this. We register for the event, then update the `$atime` if we already have existing metadata for the file. Because we know that file access is non-mutating, we don't perform a full reload when this happens. We manually set the `$atime` intrinsic and then call `store` on the file object without recursive sub-storers, as we don't need to re-parse the file contents or other fields in this case. This happens for any `TFile` that has a corresponding datastore entry for its `path` and whose metadata has the `FILE_TYPE` in its `$types` array. If the file is a Markdown or Canvas file, we trigger the persister as well. We then trigger an `update` for the new index revision. --- src/index/datacore.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/index/datacore.ts b/src/index/datacore.ts index caa054573..7566991b3 100644 --- a/src/index/datacore.ts +++ b/src/index/datacore.ts @@ -1,7 +1,7 @@ import { deferred, Deferred } from "utils/deferred"; import { Datastore, Substorer } from "index/datastore"; import { LocalStorageCache } from "index/persister"; -import { Indexable, INDEXABLE_EXTENSIONS } from "index/types/indexable"; +import { Indexable, INDEXABLE_EXTENSIONS, isFile } from "index/types/indexable"; import { FileImporter, ImportThrottle } from "index/web-worker/importer"; import { ImportResult } from "index/web-worker/message"; import { App, Component, EventRef, Events, MetadataCache, TAbstractFile, TFile, Vault } from "obsidian"; @@ -69,6 +69,23 @@ export class Datacore extends Component { // Metadata cache handles markdown file updates. this.registerEvent(this.metadataCache.on("resolve", (file) => this.reload(file))); + this.registerEvent( + this.app.workspace.on("file-open", (file) => { + if (file instanceof TFile) { + const maybeMetadata: Indexable | undefined = this.datastore.load(file.path); + if (isFile(maybeMetadata)) { + maybeMetadata.$atime = DateTime.now(); + // we just update the top-level store, we don't need to recursively re-store everything. + this.datastore.store(maybeMetadata); + if (maybeMetadata instanceof MarkdownPage || maybeMetadata instanceof Canvas) { + this.persister.storeFile(maybeMetadata.$path, maybeMetadata.json()); + } + this.trigger("update", this.revision); + } + } + }) + ); + // Renames do not set off the metadata cache; catch these explicitly. this.registerEvent(this.vault.on("rename", this.rename, this)); From 40f4f2c951177247695f8d84be653c42e82dcf78 Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Thu, 20 Feb 2025 11:31:34 -0600 Subject: [PATCH 4/5] feat: `useLastOpenedFiles` hook. Adds a new Hook to the API that returns a list of files, sorted from most recent to least recent access time. The hook is backed by a query for all `@file` metadata objects that have a populated `$atime` intrinsic. If the query returns no results, we fall back to the Obsidian `Workspace`'s `getLastOpenFiles()` function, which returns the last 10 opened file paths without additional any additional information, and we map those paths to their datastore metadata objects. Because Obsidian itself doesn't track access times, we aren't really able to use the fallback list to update our metadata. We just use it to improve the UX around this new hook. --- src/api/local-api.tsx | 9 ++++++++- src/ui/hooks.ts | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/api/local-api.tsx b/src/api/local-api.tsx index bf9c92e04..2df3fcff7 100644 --- a/src/api/local-api.tsx +++ b/src/api/local-api.tsx @@ -9,7 +9,7 @@ import { IndexQuery } from "index/types/index-query"; import { Indexable } from "index/types/indexable"; import { MarkdownPage } from "index/types/markdown"; import { App } from "obsidian"; -import { useFileMetadata, useFullQuery, useIndexUpdates, useInterning, useQuery } from "ui/hooks"; +import { useFileMetadata, useFullQuery, useIndexUpdates, useInterning, useQuery, useLastOpenedFiles } from "ui/hooks"; import * as luxon from "luxon"; import * as preact from "preact"; import * as hooks from "preact/hooks"; @@ -182,6 +182,13 @@ export class DatacoreLocalApi { return useFileMetadata(this.core, path, settings)!; } + /** Get the list of opened files. Defaults the 10 most recent files. Can be overridden by setting `settings.limit` to + * the desired amount. + */ + public useLastOpenedFiles(settings?: { limit?: number; debounce?: number }): Indexable[] { + return useLastOpenedFiles(this.core, settings); + } + /** Automatically refresh the view whenever the index updates; returns the latest index revision ID. */ public useIndexUpdates(settings?: { debounce?: number }): number { return useIndexUpdates(this.core, settings); diff --git a/src/ui/hooks.ts b/src/ui/hooks.ts index 7ed9a20ae..63b68a4a9 100644 --- a/src/ui/hooks.ts +++ b/src/ui/hooks.ts @@ -2,11 +2,12 @@ import { Datacore } from "index/datacore"; import { debounce } from "obsidian"; import { IndexQuery } from "index/types/index-query"; -import { Indexable } from "index/types/indexable"; +import { Indexable, File, isFile } from "index/types/indexable"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { SearchResult } from "index/datastore"; import { Literals } from "expression/literal"; import { Result } from "api/result"; +import { QUERY } from "expression/parser"; /** Hook that updates the view whenever the revision updates, returning the newest revision. * @group Hooks @@ -26,6 +27,37 @@ export function useIndexUpdates(datacore: Datacore, settings?: { debounce?: numb return revision; } +export function useLastOpenedFiles(datacore: Datacore, settings?: { limit?: number; debounce?: number }): Indexable[] { + const limit = settings?.limit ?? 10; + const indexRevision = useIndexUpdates(datacore, settings); + const query = useRef(QUERY.query.tryParse("@file AND exists($atime)")); + + return useMemo(() => { + return datacore.datastore + .search(query.current) + .map( + (result) => + (result.results.filter((file) => isFile(file) && file.$atime !== undefined) as unknown as File[]) + .sort((left, right) => { + return right.$atime!.toMillis() - left.$atime!.toMillis(); + }) + .slice(0, limit !== 0 ? limit : undefined) as unknown as Indexable[] + ) + .orElse( + datacore.app.workspace + .getLastOpenFiles() + .reduce((indexables: Indexable[], path: string) => { + const file = datacore.datastore.load(path); + if (file) { + indexables.push(file); + } + return indexables; + }, []) + .slice(0, limit !== 0 ? limit : undefined) + ); + }, [settings?.limit, indexRevision]); +} + /** A hook which updates whenever file metadata for a specific file updates. * @group Hooks */ From bf41fe12a21ab97d06c5a87a8a80bdfed46e5073 Mon Sep 17 00:00:00 2001 From: Doug Stephen Date: Thu, 20 Feb 2025 11:34:46 -0600 Subject: [PATCH 5/5] chore: update gitignore + fix typo --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f670e29db..444780aef 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ # Build artifacts. build/ +dist/ # Various editor preferences. .vscode @@ -18,4 +19,4 @@ build/ docs/root/api/**/*.* !docs/root/api/index.md -.docuaurus/ \ No newline at end of file +.docusaurus/