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/ 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/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)); 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..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"; /** @@ -44,12 +52,22 @@ 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. */ $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"; /** @@ -60,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"; /** @@ -70,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. 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, 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 */