silverbullet/common/spaces/evented_space_primitives.ts

242 lines
7.3 KiB
TypeScript
Raw Normal View History

import type { FileMeta } from "@silverbulletmd/silverbullet/types";
2024-07-30 23:33:33 +08:00
import type { EventHook } from "$common/hooks/event.ts";
2024-05-31 01:55:35 +08:00
import { plugPrefix } from "$common/spaces/constants.ts";
2022-04-27 02:31:31 +08:00
import type { SpacePrimitives } from "./space_primitives.ts";
2023-08-27 17:02:24 +08:00
/**
* Events exposed:
2023-08-27 20:13:18 +08:00
* - file:changed (string, localUpdate: boolean)
2023-08-27 17:02:24 +08:00
* - file:deleted (string)
* - file:listed (FileMeta[])
* - page:saved (string, FileMeta)
* - page:deleted (string)
*/
export class EventedSpacePrimitives implements SpacePrimitives {
// Various operations may be going on at the same time, and we don't want to trigger events unnessarily.
// Therefore we use this variable to track if any operation is in flight, and if so, we skip event triggering.
// This is ok, because any event will be picked up in a following iteration.
2024-03-14 20:02:34 +08:00
operationInProgress = false;
2024-07-10 17:32:07 +08:00
initialFileListLoad: boolean;
public enabled = true;
2023-08-27 17:02:24 +08:00
constructor(
private wrapped: SpacePrimitives,
private eventHook: EventHook,
2024-07-10 17:32:07 +08:00
private spaceSnapshot: Record<string, number> = {},
) {
// Translate file change events for attachments into attachment:index events
this.eventHook.addLocalListener(
"file:changed",
async (
name: string,
) => {
if (!name.endsWith(".md") && !name.startsWith(plugPrefix)) {
// Not a page nor plug, so an attachment!
await this.dispatchEvent("attachment:index", name);
}
},
);
2024-07-10 17:32:07 +08:00
this.initialFileListLoad = Object.keys(this.spaceSnapshot).length === 0;
}
2023-08-27 17:02:24 +08:00
dispatchEvent(name: string, ...args: any[]): Promise<any[]> {
2023-08-27 20:13:18 +08:00
return this.eventHook.dispatchEvent(name, ...args);
2023-08-27 17:02:24 +08:00
}
async fetchFileList(): Promise<FileMeta[]> {
2024-07-10 17:32:07 +08:00
if (!this.enabled) {
return this.wrapped.fetchFileList();
}
2024-03-14 20:02:34 +08:00
if (this.operationInProgress) {
// Some other operation (read, write, list, meta) is already going on
// this will likely trigger events, so let's not worry about any of that and avoid race condition and inconsistent data.
console.info(
"alreadyFetching is on, skipping even triggering for fetchFileList.",
);
return this.wrapped.fetchFileList();
2023-08-27 20:13:18 +08:00
}
2024-07-10 17:32:07 +08:00
// console.log("Fetching file list");
// Fetching mutex
2024-03-14 20:02:34 +08:00
this.operationInProgress = true;
try {
2024-03-14 20:02:34 +08:00
// Fetch the list
const newFileList = await this.wrapped.fetchFileList();
// Now we have the list, let's compare it to the snapshot and trigger events appropriately
const deletedFiles = new Set<string>(Object.keys(this.spaceSnapshot));
for (const meta of newFileList) {
const oldHash = this.spaceSnapshot[meta.name];
const newHash = meta.lastModified;
// Update in snapshot
this.spaceSnapshot[meta.name] = newHash;
// Check what happened to the file
if (
(
// New file scenario
!oldHash && !this.initialFileListLoad
) || (
// Changed file scenario
oldHash &&
oldHash !== newHash
)
) {
2024-07-10 17:32:07 +08:00
console.log("Detected file change", meta.name, oldHash, newHash);
await this.dispatchEvent(
"file:changed",
meta.name,
false,
oldHash,
newHash,
);
}
// Page found, not deleted
deletedFiles.delete(meta.name);
2023-08-27 17:02:24 +08:00
}
for (const deletedFile of deletedFiles) {
delete this.spaceSnapshot[deletedFile];
await this.dispatchEvent("file:deleted", deletedFile);
if (deletedFile.endsWith(".md")) {
const pageName = deletedFile.substring(0, deletedFile.length - 3);
await this.dispatchEvent("page:deleted", pageName);
}
2023-08-27 17:02:24 +08:00
}
await this.dispatchEvent("file:listed", newFileList);
this.initialFileListLoad = false;
return newFileList;
} finally {
2024-03-14 20:02:34 +08:00
this.operationInProgress = false;
}
}
2023-08-27 17:02:24 +08:00
async readFile(
2022-09-12 20:50:37 +08:00
name: string,
): Promise<{ data: Uint8Array; meta: FileMeta }> {
2024-07-10 17:32:07 +08:00
if (!this.enabled) {
return this.wrapped.readFile(name);
}
try {
// Fetching mutex
2024-03-14 20:02:34 +08:00
const wasFetching = this.operationInProgress;
this.operationInProgress = true;
// Fetch file
const data = await this.wrapped.readFile(name);
if (!wasFetching) {
this.triggerEventsAndCache(name, data.meta.lastModified);
}
return data;
} finally {
2024-03-14 20:02:34 +08:00
this.operationInProgress = false;
}
}
2022-09-12 20:50:37 +08:00
async writeFile(
name: string,
data: Uint8Array,
// TODO: Is self update still used or can it now be removed?
2023-01-13 22:41:29 +08:00
selfUpdate?: boolean,
meta?: FileMeta,
2022-09-12 20:50:37 +08:00
): Promise<FileMeta> {
2024-07-10 17:32:07 +08:00
if (!this.enabled) {
return this.wrapped.writeFile(name, data, selfUpdate, meta);
}
try {
2024-03-14 20:02:34 +08:00
this.operationInProgress = true;
const newMeta = await this.wrapped.writeFile(
name,
data,
selfUpdate,
meta,
);
await this.dispatchEvent(
"file:changed",
name,
true,
undefined,
newMeta.lastModified,
);
2024-03-14 20:02:34 +08:00
this.spaceSnapshot[name] = newMeta.lastModified;
if (name.endsWith(".md")) {
// Let's trigger some page-specific events
const pageName = name.substring(0, name.length - 3);
let text = "";
const decoder = new TextDecoder("utf-8");
text = decoder.decode(data);
await this.dispatchEvent("page:saved", pageName, newMeta);
await this.dispatchEvent("page:index_text", {
name: pageName,
text,
});
}
return newMeta;
} finally {
2024-03-14 20:02:34 +08:00
this.operationInProgress = false;
}
}
2023-08-27 20:13:18 +08:00
triggerEventsAndCache(name: string, newHash: number) {
const oldHash = this.spaceSnapshot[name];
2023-11-12 17:33:27 +08:00
if (oldHash && newHash && oldHash !== newHash) {
2023-08-27 20:13:18 +08:00
// Page changed since last cached metadata, trigger event
2023-11-12 17:33:27 +08:00
this.dispatchEvent("file:changed", name, false, oldHash, newHash);
2023-08-27 20:13:18 +08:00
}
this.spaceSnapshot[name] = newHash;
return;
}
2023-08-27 17:02:24 +08:00
async getFileMeta(name: string): Promise<FileMeta> {
2024-07-10 17:32:07 +08:00
if (!this.enabled) {
return this.wrapped.getFileMeta(name);
}
2023-08-27 17:02:24 +08:00
try {
2024-03-14 20:02:34 +08:00
const wasFetching = this.operationInProgress;
this.operationInProgress = true;
2023-08-27 17:02:24 +08:00
const newMeta = await this.wrapped.getFileMeta(name);
if (!wasFetching) {
this.triggerEventsAndCache(name, newMeta.lastModified);
}
2023-08-27 20:13:18 +08:00
return newMeta;
2023-08-27 17:02:24 +08:00
} catch (e: any) {
// console.log("Checking error", e, name);
2023-08-27 17:02:24 +08:00
if (e.message === "Not found") {
await this.dispatchEvent("file:deleted", name);
2023-08-27 17:02:24 +08:00
if (name.endsWith(".md")) {
const pageName = name.substring(0, name.length - 3);
await this.dispatchEvent("page:deleted", pageName);
}
}
throw e;
} finally {
2024-03-14 20:02:34 +08:00
this.operationInProgress = false;
2023-08-27 17:02:24 +08:00
}
}
2022-09-12 20:50:37 +08:00
async deleteFile(name: string): Promise<void> {
2024-07-10 17:32:07 +08:00
if (!this.enabled) {
return this.wrapped.deleteFile(name);
}
try {
2024-03-14 20:02:34 +08:00
this.operationInProgress = true;
if (name.endsWith(".md")) {
const pageName = name.substring(0, name.length - 3);
await this.dispatchEvent("page:deleted", pageName);
}
// await this.getPageMeta(name); // Check if page exists, if not throws Error
await this.wrapped.deleteFile(name);
delete this.spaceSnapshot[name];
await this.dispatchEvent("file:deleted", name);
} finally {
2024-03-14 20:02:34 +08:00
this.operationInProgress = false;
2023-08-27 17:02:24 +08:00
}
}
}