diff --git a/src/ConflictResolveModal.ts b/src/ConflictResolveModal.ts index 4742808..ca56eb3 100644 --- a/src/ConflictResolveModal.ts +++ b/src/ConflictResolveModal.ts @@ -1,4 +1,4 @@ -import { App, Modal } from "obsidian"; +import { App, Modal } from "./deps"; import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch"; import { diff_result } from "./lib/src/types"; import { escapeStringToHTML } from "./lib/src/strbin"; diff --git a/src/DocumentHistoryModal.ts b/src/DocumentHistoryModal.ts index 97abf5d..c8099a6 100644 --- a/src/DocumentHistoryModal.ts +++ b/src/DocumentHistoryModal.ts @@ -1,4 +1,4 @@ -import { TFile, Modal, App } from "obsidian"; +import { TFile, Modal, App } from "./deps"; import { isValidPath, path2id } from "./utils"; import { base64ToArrayBuffer, base64ToString, escapeStringToHTML } from "./lib/src/strbin"; import ObsidianLiveSyncPlugin from "./main"; diff --git a/src/JsonResolveModal.ts b/src/JsonResolveModal.ts index be1f0d5..5e3189e 100644 --- a/src/JsonResolveModal.ts +++ b/src/JsonResolveModal.ts @@ -1,4 +1,4 @@ -import { App, Modal } from "obsidian"; +import { App, Modal } from "./deps"; import { LoadedEntry } from "./lib/src/types"; import JsonResolvePane from "./JsonResolvePane.svelte"; diff --git a/src/LocalPouchDB.ts b/src/LocalPouchDB.ts index 5d65e30..b6a547c 100644 --- a/src/LocalPouchDB.ts +++ b/src/LocalPouchDB.ts @@ -1,4 +1,4 @@ -import { requestUrl, RequestUrlParam, RequestUrlResponse } from "obsidian"; +import { requestUrl, RequestUrlParam, RequestUrlResponse } from "./deps"; import { KeyValueDatabase, OpenKeyValueDatabase } from "./KeyValueDB.js"; import { LocalPouchDBBase } from "./lib/src/LocalPouchDBBase.js"; import { Logger } from "./lib/src/logger.js"; diff --git a/src/LogDisplayModal.ts b/src/LogDisplayModal.ts index 8dcb1b2..2d24e18 100644 --- a/src/LogDisplayModal.ts +++ b/src/LogDisplayModal.ts @@ -1,4 +1,4 @@ -import { App, Modal } from "obsidian"; +import { App, Modal } from "./deps"; import { logMessageStore } from "./lib/src/stores"; import { escapeStringToHTML } from "./lib/src/strbin"; import ObsidianLiveSyncPlugin from "./main"; diff --git a/src/ObsidianLiveSyncSettingTab.ts b/src/ObsidianLiveSyncSettingTab.ts index 09ef3b5..541fdeb 100644 --- a/src/ObsidianLiveSyncSettingTab.ts +++ b/src/ObsidianLiveSyncSettingTab.ts @@ -1,4 +1,4 @@ -import { App, PluginSettingTab, Setting, sanitizeHTMLToDom, RequestUrlParam, requestUrl, TextAreaComponent, MarkdownRenderer, stringifyYaml } from "obsidian"; +import { App, PluginSettingTab, Setting, sanitizeHTMLToDom, RequestUrlParam, requestUrl, TextAreaComponent, MarkdownRenderer, stringifyYaml } from "./deps"; import { DEFAULT_SETTINGS, LOG_LEVEL, ObsidianLiveSyncSettings, ConfigPassphraseStore, RemoteDBSettings } from "./lib/src/types"; import { path2id, id2path } from "./utils"; import { delay } from "./lib/src/utils"; @@ -28,6 +28,7 @@ const requestToCouchDB = async (baseUri: string, username: string, password: str }; export class ObsidianLiveSyncSettingTab extends PluginSettingTab { plugin: ObsidianLiveSyncPlugin; + selectedScreen = ""; constructor(app: App, plugin: ObsidianLiveSyncPlugin) { super(app, plugin); @@ -93,6 +94,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { element.addClass("selected"); (element.querySelector("input[type=radio]") as HTMLInputElement).checked = true; }); + this.selectedScreen = screen; }; menuTabs.forEach((element) => { const e = element.querySelector(".sls-setting-tab"); @@ -1392,29 +1394,51 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { Logger("Select any preset.", LOG_LEVEL.NOTICE); return; } - this.plugin.settings.batchSave = false; - this.plugin.settings.liveSync = false; - this.plugin.settings.periodicReplication = false; - this.plugin.settings.syncOnSave = false; - this.plugin.settings.syncOnStart = false; - this.plugin.settings.syncOnFileOpen = false; - this.plugin.settings.syncAfterMerge = false; + const presetAllDisabled = { + batchSave: false, + liveSync: false, + periodicReplication: false, + syncOnSave: false, + syncOnStart: false, + syncOnFileOpen: false, + syncAfterMerge: false, + } as Partial; + const presetLiveSync = { + ...presetAllDisabled, + liveSync: true + } as Partial; + const presetPeriodic = { + ...presetAllDisabled, + batchSave: true, + periodicReplication: true, + syncOnSave: false, + syncOnStart: true, + syncOnFileOpen: true, + syncAfterMerge: true, + } as Partial; + if (currentPreset == "LIVESYNC") { - this.plugin.settings.liveSync = true; + this.plugin.settings = { + ...this.plugin.settings, + ...presetLiveSync + } Logger("Synchronization setting configured as LiveSync.", LOG_LEVEL.NOTICE); } else if (currentPreset == "PERIODIC") { - this.plugin.settings.batchSave = true; - this.plugin.settings.periodicReplication = true; - this.plugin.settings.syncOnSave = false; - this.plugin.settings.syncOnStart = true; - this.plugin.settings.syncOnFileOpen = true; - this.plugin.settings.syncAfterMerge = true; + this.plugin.settings = { + ...this.plugin.settings, + ...presetPeriodic + } Logger("Synchronization setting configured as Periodic sync with batch database update.", LOG_LEVEL.NOTICE); } else { Logger("All synchronization disabled.", LOG_LEVEL.NOTICE); + this.plugin.settings = { + ...this.plugin.settings, + ...presetAllDisabled + } } this.plugin.saveSettings(); await this.plugin.realizeSettingSyncMode(); + this.display(); if (inWizard) { // @ts-ignore this.plugin.app.setting.close() @@ -1432,8 +1456,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab { // @ts-ignore this.plugin.app.commands.executeCommandById("obsidian-livesync:livesync-copysetupuri") } - - }) ); @@ -1769,18 +1791,22 @@ ${stringifyYaml(pluginConfig)}`; } applyDisplayEnabled(); addScreenElement("70", containerCorruptedDataEl); - if (lastVersion != this.plugin.settings.lastReadUpdates) { - if (JSON.stringify(this.plugin.settings) != JSON.stringify(DEFAULT_SETTINGS)) { - changeDisplay("100"); + if (this.selectedScreen == "") { + if (lastVersion != this.plugin.settings.lastReadUpdates) { + if (JSON.stringify(this.plugin.settings) != JSON.stringify(DEFAULT_SETTINGS)) { + changeDisplay("100"); + } else { + changeDisplay("110") + } } else { - changeDisplay("110") + if (isAnySyncEnabled()) { + changeDisplay("0"); + } else { + changeDisplay("110") + } } } else { - if (isAnySyncEnabled()) { - changeDisplay("0"); - } else { - changeDisplay("110") - } + changeDisplay(this.selectedScreen); } } } diff --git a/src/StorageEventManager.ts b/src/StorageEventManager.ts new file mode 100644 index 0000000..08a40f4 --- /dev/null +++ b/src/StorageEventManager.ts @@ -0,0 +1,172 @@ +import { Plugin_2, TAbstractFile, TFile, TFolder } from "./deps"; +import { isPlainText, shouldBeIgnored } from "./lib/src/path"; +import { getGlobalStore } from "./lib/src/store"; +import { ObsidianLiveSyncSettings } from "./lib/src/types"; +import { FileEventItem, FileEventType, FileInfo, InternalFileInfo, queueItem } from "./types"; +import { recentlyTouched } from "./utils"; + + +export abstract class StorageEventManager { + abstract fetchEvent(): FileEventItem | false; + abstract cancelRelativeEvent(item: FileEventItem): void; + abstract getQueueLength(): number; +} + +type LiveSyncForStorageEventManager = Plugin_2 & +{ + settings: ObsidianLiveSyncSettings +} & { + isTargetFile: (file: string | TAbstractFile) => boolean, + procFileEvent: (applyBatch?: boolean) => Promise +}; + + +export class StorageEventManagerObsidian extends StorageEventManager { + plugin: LiveSyncForStorageEventManager; + queuedFilesStore = getGlobalStore("queuedFiles", { queuedItems: [] as queueItem[], fileEventItems: [] as FileEventItem[] }); + + watchedFileEventQueue = [] as FileEventItem[]; + + constructor(plugin: LiveSyncForStorageEventManager) { + super(); + this.plugin = plugin; + this.watchVaultChange = this.watchVaultChange.bind(this); + this.watchVaultCreate = this.watchVaultCreate.bind(this); + this.watchVaultDelete = this.watchVaultDelete.bind(this); + this.watchVaultRename = this.watchVaultRename.bind(this); + this.watchVaultRawEvents = this.watchVaultRawEvents.bind(this); + plugin.registerEvent(app.vault.on("modify", this.watchVaultChange)); + plugin.registerEvent(app.vault.on("delete", this.watchVaultDelete)); + plugin.registerEvent(app.vault.on("rename", this.watchVaultRename)); + plugin.registerEvent(app.vault.on("create", this.watchVaultCreate)); + //@ts-ignore : Internal API + plugin.registerEvent(app.vault.on("raw", this.watchVaultRawEvents)); + } + + watchVaultCreate(file: TAbstractFile, ctx?: any) { + this.appendWatchEvent([{ type: "CREATE", file }], ctx); + } + + watchVaultChange(file: TAbstractFile, ctx?: any) { + this.appendWatchEvent([{ type: "CHANGED", file }], ctx); + } + + watchVaultDelete(file: TAbstractFile, ctx?: any) { + this.appendWatchEvent([{ type: "DELETE", file }], ctx); + } + watchVaultRename(file: TAbstractFile, oldFile: string, ctx?: any) { + if (file instanceof TFile) { + this.appendWatchEvent([ + { type: "CREATE", file }, + { type: "DELETE", file: { path: oldFile, mtime: file.stat.mtime, ctime: file.stat.ctime, size: file.stat.size, deleted: true } } + ], ctx); + } + } + // Watch raw events (Internal API) + watchVaultRawEvents(path: string) { + if (!this.plugin.settings.syncInternalFiles) return; + if (!this.plugin.settings.watchInternalFileChanges) return; + if (!path.startsWith(app.vault.configDir)) return; + const ignorePatterns = this.plugin.settings.syncInternalFilesIgnorePatterns + .replace(/\n| /g, "") + .split(",").filter(e => e).map(e => new RegExp(e, "i")); + if (ignorePatterns.some(e => path.match(e))) return; + this.appendWatchEvent( + [{ + type: "INTERNAL", + file: { path, mtime: 0, ctime: 0, size: 0 } + }], null); + } + + // Cache file and waiting to can be proceed. + async appendWatchEvent(params: { type: FileEventType, file: TAbstractFile | InternalFileInfo, oldPath?: string }[], ctx?: any) { + let forcePerform = false; + for (const param of params) { + if (shouldBeIgnored(param.file.path)) { + continue; + } + const atomicKey = [0, 0, 0, 0, 0, 0].map(e => `${Math.floor(Math.random() * 100000)}`).join("-"); + const type = param.type; + const file = param.file; + const oldPath = param.oldPath; + if (file instanceof TFolder) continue; + if (!this.plugin.isTargetFile(file.path)) continue; + if (this.plugin.settings.suspendFileWatching) continue; + + let cache: null | string | ArrayBuffer; + // new file or something changed, cache the changes. + if (file instanceof TFile && (type == "CREATE" || type == "CHANGED")) { + if (recentlyTouched(file)) { + continue; + } + if (!isPlainText(file.name)) { + cache = await app.vault.readBinary(file); + } else { + // cache = await this.app.vault.read(file); + cache = await app.vault.cachedRead(file); + if (!cache) cache = await app.vault.read(file); + } + } + if (type == "DELETE" || type == "RENAME") { + forcePerform = true; + } + + + if (this.plugin.settings.batchSave) { + // if the latest event is the same type, omit that + // a.md MODIFY <- this should be cancelled when a.md MODIFIED + // b.md MODIFY <- this should be cancelled when b.md MODIFIED + // a.md MODIFY + // a.md CREATE + // : + let i = this.watchedFileEventQueue.length; + L1: + while (i >= 0) { + i--; + if (i < 0) break L1; + if (this.watchedFileEventQueue[i].args.file.path != file.path) { + continue L1; + } + if (this.watchedFileEventQueue[i].type != type) break L1; + this.watchedFileEventQueue.remove(this.watchedFileEventQueue[i]); + //this.queuedFilesStore.set({ queuedItems: this.queuedFiles, fileEventItems: this.watchedFileEventQueue }); + this.queuedFilesStore.apply((value) => ({ ...value, fileEventItems: this.watchedFileEventQueue })); + } + } + + const fileInfo = file instanceof TFile ? { + ctime: file.stat.ctime, + mtime: file.stat.mtime, + file: file, + path: file.path, + size: file.stat.size + } as FileInfo : file as InternalFileInfo; + this.watchedFileEventQueue.push({ + type, + args: { + file: fileInfo, + oldPath, + cache, + ctx + }, + key: atomicKey + }) + } + // this.queuedFilesStore.set({ queuedItems: this.queuedFiles, fileEventItems: this.watchedFileEventQueue }); + this.queuedFilesStore.apply((value) => ({ ...value, fileEventItems: this.watchedFileEventQueue })); + this.plugin.procFileEvent(forcePerform); + } + fetchEvent(): FileEventItem | false { + if (this.watchedFileEventQueue.length == 0) return false; + const item = this.watchedFileEventQueue.shift(); + this.queuedFilesStore.apply((value) => ({ ...value, fileEventItems: this.watchedFileEventQueue })); + return item; + } + cancelRelativeEvent(item: FileEventItem) { + this.watchedFileEventQueue = [...this.watchedFileEventQueue].filter(e => e.key != item.key); + this.queuedFilesStore.apply((value) => ({ ...value, fileEventItems: this.watchedFileEventQueue })); + } + getQueueLength() { + return this.watchedFileEventQueue.length; + } +} \ No newline at end of file diff --git a/src/deps.ts b/src/deps.ts new file mode 100644 index 0000000..41822fb --- /dev/null +++ b/src/deps.ts @@ -0,0 +1,4 @@ +export { + addIcon, App, DataWriteOptions, debounce, Editor, FuzzySuggestModal, MarkdownRenderer, MarkdownView, Modal, normalizePath, Notice, Platform, Plugin, PluginManifest, + PluginSettingTab, Plugin_2, requestUrl, RequestUrlParam, RequestUrlResponse, sanitizeHTMLToDom, Setting, stringifyYaml, TAbstractFile, TextAreaComponent, TFile, TFolder +} from "obsidian"; diff --git a/src/dialogs.ts b/src/dialogs.ts index 39dede3..4cba839 100644 --- a/src/dialogs.ts +++ b/src/dialogs.ts @@ -1,4 +1,4 @@ -import { App, FuzzySuggestModal, Modal, Setting } from "obsidian"; +import { App, FuzzySuggestModal, Modal, Setting } from "./deps"; import ObsidianLiveSyncPlugin from "./main"; //@ts-ignore diff --git a/src/main.ts b/src/main.ts index d105527..335dee7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,8 @@ -import { debounce, Notice, Plugin, TFile, addIcon, TFolder, normalizePath, TAbstractFile, Editor, MarkdownView, PluginManifest, App } from "obsidian"; +const isDebug = false; import { Diff, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "diff-match-patch"; +import { debounce, Notice, Plugin, TFile, addIcon, TFolder, normalizePath, TAbstractFile, Editor, MarkdownView, PluginManifest } from "./deps"; import { EntryDoc, LoadedEntry, ObsidianLiveSyncSettings, diff_check_result, diff_result_leaf, EntryBody, LOG_LEVEL, VER, DEFAULT_SETTINGS, diff_result, FLAGMD_REDFLAG, SYNCINFO_ID, InternalFileEntry, SALT_OF_PASSPHRASE, ConfigPassphraseStore, CouchDBConnection, FLAGMD_REDFLAG2, FLAGMD_REDFLAG3, PREFIXMD_LOGFILE } from "./lib/src/types"; -import { PluginDataEntry, PERIODIC_PLUGIN_SWEEP, PluginList, DevicePluginList, InternalFileInfo, queueItem, FileInfo } from "./types"; +import { PluginDataEntry, PERIODIC_PLUGIN_SWEEP, PluginList, DevicePluginList, InternalFileInfo, queueItem, CacheData, FileEventItem, configURIBase, FileWatchEventQueueMax, PSCHeader, PSCHeaderEnd, ICHeader, ICHeaderEnd } from "./types"; import { delay, getDocData, isDocContentSame } from "./lib/src/utils"; import { Logger } from "./lib/src/logger"; import { LocalPouchDB } from "./LocalPouchDB"; @@ -9,12 +10,9 @@ import { LogDisplayModal } from "./LogDisplayModal"; import { ConflictResolveModal } from "./ConflictResolveModal"; import { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab"; import { DocumentHistoryModal } from "./DocumentHistoryModal"; -import { applyPatch, clearAllPeriodic, clearAllTriggers, clearTrigger, disposeMemoObject, generatePatchObj, id2path, isObjectMargeApplicable, isSensibleMargeApplicable, memoIfNotExist, memoObject, flattenObject, path2id, retrieveMemoObject, setTrigger, tryParseJSON, createFile, modifyFile, isValidPath } from "./utils"; +import { applyPatch, cancelAllPeriodicTask, cancelAllTasks, cancelTask, disposeMemoObject, generatePatchObj, id2path, isObjectMargeApplicable, isSensibleMargeApplicable, memoIfNotExist, memoObject, flattenObject, path2id, retrieveMemoObject, scheduleTask, tryParseJSON, createFile, modifyFile, isValidPath, getAbstractFileByPath, trimPrefix, touch, recentlyTouched, isInternalMetadata, isPluginMetadata, filename2idInternalMetadata, id2filenameInternalMetadata, isChunk, askSelectString, askYesNo, askString, PeriodicProcessor, clearTouched } from "./utils"; import { decrypt, encrypt, tryDecrypt } from "./lib/src/e2ee_v2"; - -const isDebug = false; - -import { InputStringDialog, PluginDialogModal, PopoverSelectString } from "./dialogs"; +import { PluginDialogModal } from "./dialogs"; import { isCloudantURI, isErrorOfMissingDoc } from "./lib/src/utils_couchdb"; import { getGlobalStore, observeStores } from "./lib/src/store"; import { lockStore, logMessageStore, logStore } from "./lib/src/stores"; @@ -24,110 +22,10 @@ import { isPlainText, shouldBeIgnored } from "./lib/src/path"; import { runWithLock } from "./lib/src/lock"; import { Semaphore } from "./lib/src/semaphore"; import { JsonResolveModal } from "./JsonResolveModal"; +import { StorageEventManager, StorageEventManagerObsidian } from "./StorageEventManager"; setNoticeClass(Notice); -const ICHeader = "i:"; -const ICHeaderEnd = "i;"; -const ICHeaderLength = ICHeader.length; -const FileWatchEventQueueMax = 10; - -const configURIBase = "obsidian://setuplivesync?settings="; - -function getAbstractFileByPath(path: string): TAbstractFile | null { - // Hidden API but so useful. - // @ts-ignore - if ("getAbstractFileByPathInsensitive" in app.vault && (app.vault.adapter?.insensitive ?? false)) { - // @ts-ignore - return app.vault.getAbstractFileByPathInsensitive(path); - } else { - return app.vault.getAbstractFileByPath(path); - } -} -function trimPrefix(target: string, prefix: string) { - return target.startsWith(prefix) ? target.substring(prefix.length) : target; -} - -/** - * returns is internal chunk of file - * @param str ID - * @returns - */ -function isInternalMetadata(str: string): boolean { - return str.startsWith(ICHeader); -} -function id2filenameInternalMetadata(str: string): string { - return str.substring(ICHeaderLength); -} -function filename2idInternalMetadata(str: string): string { - return ICHeader + str; -} - -const CHeader = "h:"; -// const CHeaderLength = CHeader.length; -function isChunk(str: string): boolean { - return str.startsWith(CHeader); -} - -const PSCHeader = "ps:"; -const PSCHeaderEnd = "ps;"; -function isPluginMetadata(str: string): boolean { - return str.startsWith(PSCHeader); -} - - -const askYesNo = (app: App, message: string): Promise<"yes" | "no"> => { - return new Promise((res) => { - const popover = new PopoverSelectString(app, message, null, null, (result) => res(result as "yes" | "no")); - popover.open(); - }); -}; - -const askSelectString = (app: App, message: string, items: string[]): Promise => { - const getItemsFun = () => items; - return new Promise((res) => { - const popover = new PopoverSelectString(app, message, "", getItemsFun, (result) => res(result)); - popover.open(); - }); -}; - - -const askString = (app: App, title: string, key: string, placeholder: string): Promise => { - return new Promise((res) => { - const dialog = new InputStringDialog(app, title, key, placeholder, (result) => res(result)); - dialog.open(); - }); -}; -let touchedFiles: string[] = []; -function touch(file: TFile | string) { - const f = file instanceof TFile ? file : getAbstractFileByPath(file) as TFile; - const key = `${f.path}-${f.stat.mtime}-${f.stat.size}`; - touchedFiles.unshift(key); - touchedFiles = touchedFiles.slice(0, 100); -} -function recentlyTouched(file: TFile) { - const key = `${file.path}-${file.stat.mtime}-${file.stat.size}`; - if (touchedFiles.indexOf(key) == -1) return false; - return true; -} -function clearTouched() { - touchedFiles = []; -} - -type CacheData = string | ArrayBuffer; -type FileEventType = "CREATE" | "DELETE" | "CHANGED" | "RENAME" | "INTERNAL"; -type FileEventArgs = { - file: FileInfo | InternalFileInfo; - cache?: CacheData; - oldPath?: string; - ctx?: any; -} -type FileEventItem = { - type: FileEventType, - args: FileEventArgs, - key: string, -} - export default class ObsidianLiveSyncPlugin extends Plugin { settings: ObsidianLiveSyncSettings; localDatabase: LocalPouchDB; @@ -140,7 +38,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin { packageVersion = ""; manifestVersion = ""; - watchedFileEventQueue = [] as FileEventItem[]; + periodicSyncProcessor = new PeriodicProcessor(this, async () => await this.replicate()); + periodicPluginSweepProcessor = new PeriodicProcessor(this, async () => await this.sweepPlugin(false)); + periodicInternalFileScanProcessor = new PeriodicProcessor(this, async () => await this.syncInternalFilesAndDatabase("push", false)); getVaultName(): string { return this.app.vault.getName() + (this.settings?.additionalSuffixOfDatabaseName ? ("-" + this.settings.additionalSuffixOfDatabaseName) : ""); @@ -544,21 +444,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin { } localStorage.setItem(lsKey, `${VER}`); await this.openDatabase(); - - this.watchVaultChange = this.watchVaultChange.bind(this); - this.watchVaultCreate = this.watchVaultCreate.bind(this); - this.watchVaultDelete = this.watchVaultDelete.bind(this); - this.watchVaultRename = this.watchVaultRename.bind(this); - this.watchVaultRawEvents = this.watchVaultRawEvents.bind(this); this.watchWorkspaceOpen = debounce(this.watchWorkspaceOpen.bind(this), 1000, false); this.watchWindowVisibility = debounce(this.watchWindowVisibility.bind(this), 1000, false); this.watchOnline = debounce(this.watchOnline.bind(this), 500, false); this.parseReplicationResult = this.parseReplicationResult.bind(this); - this.setPeriodicSync = this.setPeriodicSync.bind(this); - this.clearPeriodicSync = this.clearPeriodicSync.bind(this); - this.periodicSync = this.periodicSync.bind(this); this.loadQueuedFiles = this.loadQueuedFiles.bind(this); this.getPluginList = this.getPluginList.bind(this); @@ -764,21 +655,16 @@ export default class ObsidianLiveSyncPlugin extends Plugin { if (this.localDatabase != null) { this.localDatabase.onunload(); } - if (this.gcTimerHandler != null) { - clearTimeout(this.gcTimerHandler); - this.gcTimerHandler = null; - } - this.clearPeriodicSync(); - this.clearPluginSweep(); - this.clearInternalFileScan(); + + this.periodicSyncProcessor?.disable(); + this.periodicPluginSweepProcessor?.disable(); + this.periodicInternalFileScanProcessor?.disable(); if (this.localDatabase != null) { this.localDatabase.closeReplication(); this.localDatabase.close(); } - clearAllPeriodic(); - clearAllTriggers(); - window.removeEventListener("visibilitychange", this.watchWindowVisibility); - window.removeEventListener("online", this.watchOnline); + cancelAllPeriodicTask(); + cancelAllTasks(); Logger("unloading plugin"); } @@ -940,21 +826,16 @@ export default class ObsidianLiveSyncPlugin extends Plugin { this.triggerRealizeSettingSyncMode(); } - gcTimerHandler: any = null; - + vaultManager: StorageEventManager; registerFileWatchEvents() { - this.registerEvent(this.app.vault.on("modify", this.watchVaultChange)); - this.registerEvent(this.app.vault.on("delete", this.watchVaultDelete)); - this.registerEvent(this.app.vault.on("rename", this.watchVaultRename)); - this.registerEvent(this.app.vault.on("create", this.watchVaultCreate)); - //@ts-ignore : Internal API - this.registerEvent(this.app.vault.on("raw", this.watchVaultRawEvents)); + this.vaultManager = new StorageEventManagerObsidian(this) } registerWatchEvents() { this.registerEvent(this.app.workspace.on("file-open", this.watchWorkspaceOpen)); - window.addEventListener("visibilitychange", this.watchWindowVisibility); - window.addEventListener("online", this.watchOnline); + this.registerDomEvent(document, "visibilitychange", this.watchWindowVisibility); + this.registerDomEvent(window, "online", this.watchOnline); + this.registerDomEvent(window, "offline", this.watchOnline); } @@ -963,6 +844,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { } async watchOnlineAsync() { // If some files were failed to retrieve, scan files again. + // TODO:FIXME AT V0.17.31, this logic has been disabled. if (navigator.onLine && this.localDatabase.needScanning) { this.localDatabase.needScanning = false; await this.syncAllFiles(); @@ -980,7 +862,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { await this.applyBatchChange(); if (isHidden) { this.localDatabase.closeReplication(); - this.clearPeriodicSync(); + this.periodicSyncProcessor?.disable(); } else { // suspend all temporary. if (this.suspended) return; @@ -993,175 +875,68 @@ export default class ObsidianLiveSyncPlugin extends Plugin { if (this.settings.syncOnStart) { this.localDatabase.openReplication(this.settings, false, false, this.parseReplicationResult); } - if (this.settings.periodicReplication) { - this.setPeriodicSync(); - } + this.periodicSyncProcessor.enable(this.settings.periodicReplication ? this.settings.periodicReplicationInterval * 1000 : 0); } } - // Cache file and waiting to can be proceed. - async appendWatchEvent(params: { type: FileEventType, file: TAbstractFile | InternalFileInfo, oldPath?: string }[], ctx?: any) { - let forcePerform = false; - for (const param of params) { - if (shouldBeIgnored(param.file.path)) { - continue; - } - const atomicKey = [0, 0, 0, 0, 0, 0].map(e => `${Math.floor(Math.random() * 100000)}`).join("-"); - const type = param.type; - const file = param.file; - const oldPath = param.oldPath; - if (file instanceof TFolder) continue; - if (!this.isTargetFile(file.path)) continue; - if (this.settings.suspendFileWatching) continue; - - let cache: null | string | ArrayBuffer; - // new file or something changed, cache the changes. - if (file instanceof TFile && (type == "CREATE" || type == "CHANGED")) { - if (recentlyTouched(file)) { - continue; - } - if (!isPlainText(file.name)) { - cache = await this.app.vault.readBinary(file); - } else { - // cache = await this.app.vault.read(file); - cache = await this.app.vault.cachedRead(file); - if (!cache) cache = await this.app.vault.read(file); - } - } - if (type == "DELETE" || type == "RENAME") { - forcePerform = true; - } - - - if (this.settings.batchSave) { - // if the latest event is the same type, omit that - // a.md MODIFY <- this should be cancelled when a.md MODIFIED - // b.md MODIFY <- this should be cancelled when b.md MODIFIED - // a.md MODIFY - // a.md CREATE - // : - let i = this.watchedFileEventQueue.length; - L1: - while (i >= 0) { - i--; - if (i < 0) break L1; - if (this.watchedFileEventQueue[i].args.file.path != file.path) { - continue L1; - } - if (this.watchedFileEventQueue[i].type != type) break L1; - this.watchedFileEventQueue.remove(this.watchedFileEventQueue[i]); - this.queuedFilesStore.set({ queuedItems: this.queuedFiles, fileEventItems: this.watchedFileEventQueue }); - } - } - - const fileInfo = file instanceof TFile ? { - ctime: file.stat.ctime, - mtime: file.stat.mtime, - file: file, - path: file.path, - size: file.stat.size - } as FileInfo : file as InternalFileInfo; - this.watchedFileEventQueue.push({ - type, - args: { - file: fileInfo, - oldPath, - cache, - ctx - }, - key: atomicKey - }) - } - this.queuedFilesStore.set({ queuedItems: this.queuedFiles, fileEventItems: this.watchedFileEventQueue }); - if (this.isReady) { - await this.procFileEvent(forcePerform); - } - - } async procFileEvent(applyBatch?: boolean) { if (!this.isReady) return; if (this.settings.batchSave) { - if (!applyBatch && this.watchedFileEventQueue.length < FileWatchEventQueueMax) { + if (!applyBatch && this.vaultManager.getQueueLength() < FileWatchEventQueueMax) { // Defer till applying batch save or queue has been grown enough. - // or 120 seconds after. - setTrigger("applyBatchAuto", 30000, () => { + // or 30 seconds after. + scheduleTask("applyBatchAuto", 30000, () => { this.procFileEvent(true); }) return; } } - clearTrigger("applyBatchAuto"); + cancelTask("applyBatchAuto"); const ret = await runWithLock("procFiles", true, async () => { - L2: do { - const procs = [...this.watchedFileEventQueue]; - this.watchedFileEventQueue = []; + const queue = this.vaultManager.fetchEvent(); + if (queue === false) break; + if (queue === undefined) break; + const file = queue.args.file; + const key = `file-last-proc-${queue.type}-${file.path}`; + const last = Number(await this.localDatabase.kvDB.get(key) || 0); + if (queue.type == "DELETE") { + await this.deleteFromDBbyPath(file.path); + } else if (queue.type == "INTERNAL") { + await this.watchVaultRawEventsAsync(file.path); + } else { + const targetFile = this.app.vault.getAbstractFileByPath(file.path); + if (!(targetFile instanceof TFile)) { + Logger(`Target file was not found: ${file.path}`, LOG_LEVEL.INFO); + continue; + } + //TODO: check from cache time. + if (file.mtime == last) { + Logger(`File has been already scanned on ${queue.type}, skip: ${file.path}`, LOG_LEVEL.VERBOSE); + continue; + } - L1: - do { - const queue = procs.shift(); - if (queue === undefined) break L1; - - const file = queue.args.file; - const key = `file-last-proc-${queue.type}-${file.path}`; - const last = Number(await this.localDatabase.kvDB.get(key) || 0); - if (queue.type == "DELETE") { - await this.deleteFromDBbyPath(file.path); - } else if (queue.type == "INTERNAL") { - await this.watchVaultRawEventsAsync(file.path); - } else { - const targetFile = this.app.vault.getAbstractFileByPath(file.path); - if (!(targetFile instanceof TFile)) { - Logger(`Target file was not found: ${file.path}`, LOG_LEVEL.INFO); - continue L1; - } - //TODO: check from cache time. - if (file.mtime == last) { - Logger(`File has been already scanned on ${queue.type}, skip: ${file.path}`, LOG_LEVEL.VERBOSE); - continue L1; - } - - const cache = queue.args.cache; - if (queue.type == "CREATE" || queue.type == "CHANGED") { - if (!await this.updateIntoDB(targetFile, false, cache)) { - Logger(`DB -> STORAGE: failed, cancel the relative operations: ${targetFile.path}`, LOG_LEVEL.INFO); - // cancel running queues and remove one of atomic operation - this.watchedFileEventQueue = [...procs, ...this.watchedFileEventQueue].filter(e => e.key != queue.key); - continue L2; - } - } - if (queue.type == "RENAME") { - // Obsolete - await this.watchVaultRenameAsync(targetFile, queue.args.oldPath); + const cache = queue.args.cache; + if (queue.type == "CREATE" || queue.type == "CHANGED") { + if (!await this.updateIntoDB(targetFile, false, cache)) { + Logger(`DB -> STORAGE: failed, cancel the relative operations: ${targetFile.path}`, LOG_LEVEL.INFO); + // cancel running queues and remove one of atomic operation + this.vaultManager.cancelRelativeEvent(queue); + continue; } } - await this.localDatabase.kvDB.set(key, file.mtime); - } while (procs.length > 0); - } while (this.watchedFileEventQueue.length != 0); + if (queue.type == "RENAME") { + // Obsolete + await this.watchVaultRenameAsync(targetFile, queue.args.oldPath); + } + } + await this.localDatabase.kvDB.set(key, file.mtime); + } while (this.vaultManager.getQueueLength() > 0); return true; }) return ret; } - watchVaultCreate(file: TAbstractFile, ctx?: any) { - this.appendWatchEvent([{ type: "CREATE", file }], ctx); - } - - watchVaultChange(file: TAbstractFile, ctx?: any) { - this.appendWatchEvent([{ type: "CHANGED", file }], ctx); - } - - watchVaultDelete(file: TAbstractFile, ctx?: any) { - this.appendWatchEvent([{ type: "DELETE", file }], ctx); - } - watchVaultRename(file: TAbstractFile, oldFile: string, ctx?: any) { - if (file instanceof TFile) { - this.appendWatchEvent([ - { type: "CREATE", file }, - { type: "DELETE", file: { path: oldFile, mtime: file.stat.mtime, ctime: file.stat.ctime, size: file.stat.size, deleted: true } } - ], ctx); - } - } watchWorkspaceOpen(file: TFile) { if (this.settings.suspendFileWatching) return; @@ -1188,21 +963,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin { } } - // Watch raw events (Internal API) - watchVaultRawEvents(path: string) { - if (!this.settings.syncInternalFiles) return; - if (!this.settings.watchInternalFileChanges) return; - if (!path.startsWith(this.app.vault.configDir)) return; - const ignorePatterns = this.settings.syncInternalFilesIgnorePatterns - .replace(/\n| /g, "") - .split(",").filter(e => e).map(e => new RegExp(e, "i")); - if (ignorePatterns.some(e => path.match(e))) return; - this.appendWatchEvent( - [{ - type: "INTERNAL", - file: { path, mtime: 0, ctime: 0, size: 0 } - }], null); - } recentProcessedInternalFiles = [] as string[]; async watchVaultRawEventsAsync(path: string) { const stat = await this.app.vault.adapter.stat(path); @@ -1571,7 +1331,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { } procInternalFile(filename: string) { this.procInternalFiles.push(filename); - setTrigger("procInternal", 500, async () => { + scheduleTask("procInternal", 500, async () => { await this.execInternalFile(); }); } @@ -1600,7 +1360,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { } } this.queuedFiles = this.queuedFiles.filter((e) => !e.done); - this.queuedFilesStore.set({ queuedItems: this.queuedFiles, fileEventItems: this.watchedFileEventQueue }); + this.queuedFilesStore.apply((value) => ({ ...value, queuedItems: this.queuedFiles })); this.saveQueuedFiles(); } parseIncomingChunk(chunk: PouchDB.Core.ExistingDocument) { @@ -1673,7 +1433,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin { this.saveQueuedFiles(); this.procQueuedFiles(); } - periodicSyncHandler: number = null; //---> Sync async parseReplicationResult(docs: Array>): Promise { @@ -1739,50 +1498,15 @@ export default class ObsidianLiveSyncPlugin extends Plugin { Logger("Everything is up to date.", LOG_LEVEL.NOTICE); } } - - clearPeriodicSync() { - if (this.periodicSyncHandler != null) { - clearInterval(this.periodicSyncHandler); - this.periodicSyncHandler = null; - } - } - - setPeriodicSync() { - this.clearPeriodicSync(); - if (this.settings.periodicReplication && this.settings.periodicReplicationInterval > 0) { - this.periodicSyncHandler = this.setInterval(async () => await this.periodicSync(), Math.max(this.settings.periodicReplicationInterval, 30) * 1000); - } - } - - async periodicSync() { - await this.replicate(); - } - - periodicPluginSweepHandler: number = null; - - clearPluginSweep() { - if (this.periodicPluginSweepHandler != null) { - clearInterval(this.periodicPluginSweepHandler); - this.periodicPluginSweepHandler = null; - } - } - - setPluginSweep() { - if (this.settings.autoSweepPluginsPeriodic && !this.settings.watchInternalFileChanges) { - this.clearPluginSweep(); - this.periodicPluginSweepHandler = this.setInterval(async () => await this.periodicPluginSweep(), PERIODIC_PLUGIN_SWEEP * 1000); - } - } - async periodicPluginSweep() { await this.sweepPlugin(false); } async realizeSettingSyncMode() { this.localDatabase.closeReplication(); - this.clearPeriodicSync(); - this.clearPluginSweep(); - this.clearInternalFileScan(); + this.periodicSyncProcessor?.disable(); + this.periodicPluginSweepProcessor?.disable(); + this.periodicInternalFileScanProcessor?.disable(); await this.applyBatchChange(); // disable all sync temporary. if (this.suspended) return; @@ -1795,9 +1519,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin { if (this.settings.syncInternalFiles) { await this.syncInternalFilesAndDatabase("safe", false); } - this.setPeriodicSync(); - this.setPluginSweep(); - this.setPeriodicInternalFileScan(); + this.periodicSyncProcessor.enable(this.settings.periodicReplication ? this.settings.periodicReplicationInterval * 1000 : 0); + this.periodicPluginSweepProcessor.enable(this.settings.autoSweepPluginsPeriodic && !this.settings.watchInternalFileChanges ? (PERIODIC_PLUGIN_SWEEP * 1000) : 0) + this.periodicInternalFileScanProcessor.enable(this.settings.syncInternalFiles && this.settings.syncInternalFilesInterval ? (this.settings.syncInternalFilesInterval * 1000) : 0) } lastMessage = ""; @@ -1842,7 +1566,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin { this.statusBar.title = e.syncStatus; let waiting = ""; if (this.settings.batchSave) { - waiting = " " + this.watchedFileEventQueue.map((e) => "🛫").join(""); + waiting = " " + "🛫".repeat(this.vaultManager.getQueueLength()); waiting = waiting.replace(/(🛫){10}/g, "🚀"); } let queued = ""; @@ -3011,27 +2735,6 @@ export default class ObsidianLiveSyncPlugin extends Plugin { } - periodicInternalFileScanHandler: number = null; - - clearInternalFileScan() { - if (this.periodicInternalFileScanHandler != null) { - clearInterval(this.periodicInternalFileScanHandler); - this.periodicInternalFileScanHandler = null; - } - } - - setPeriodicInternalFileScan() { - if (this.periodicInternalFileScanHandler != null) { - this.clearInternalFileScan(); - } - if (this.settings.syncInternalFiles && this.settings.syncInternalFilesInterval > 0 && !this.settings.watchInternalFileChanges) { - this.periodicPluginSweepHandler = this.setInterval(async () => await this.periodicInternalFileScan(), this.settings.syncInternalFilesInterval * 1000); - } - } - - async periodicInternalFileScan() { - await this.syncInternalFilesAndDatabase("push", false); - } async getFiles( path: string, @@ -3539,14 +3242,14 @@ export default class ObsidianLiveSyncPlugin extends Plugin { }); const updatedPluginKey = "popupUpdated-" + updatePluginId; - setTrigger(updatedPluginKey, 1000, async () => { + scheduleTask(updatedPluginKey, 1000, async () => { const popup = await memoIfNotExist(updatedPluginKey, () => new Notice(fragment, 0)); //@ts-ignore const isShown = popup?.noticeEl?.isShown(); if (!isShown) { memoObject(updatedPluginKey, new Notice(fragment, 0)) } - setTrigger(updatedPluginKey + "-close", 20000, () => { + scheduleTask(updatedPluginKey + "-close", 20000, () => { const popup = retrieveMemoObject(updatedPluginKey) if (!popup) return; //@ts-ignore @@ -3581,13 +3284,13 @@ export default class ObsidianLiveSyncPlugin extends Plugin { }); }); - setTrigger("popupUpdated-" + configDir, 1000, () => { + scheduleTask("popupUpdated-" + configDir, 1000, () => { //@ts-ignore const isShown = this.confirmPopup?.noticeEl?.isShown(); if (!isShown) { this.confirmPopup = new Notice(fragment, 0); } - setTrigger("popupClose" + configDir, 20000, () => { + scheduleTask("popupClose" + configDir, 20000, () => { this.confirmPopup?.hide(); this.confirmPopup = null; }) @@ -3608,3 +3311,4 @@ export default class ObsidianLiveSyncPlugin extends Plugin { } } + diff --git a/src/types.ts b/src/types.ts index 4cd9609..5debcfa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import { PluginManifest, TFile } from "obsidian"; +import { PluginManifest, TFile } from "./deps"; import { DatabaseEntry, EntryBody } from "./lib/src/types"; export interface PluginDataEntry extends DatabaseEntry { @@ -46,4 +46,28 @@ export type queueItem = { timeout?: number; done?: boolean; warned?: boolean; -}; \ No newline at end of file +}; + +export type CacheData = string | ArrayBuffer; +export type FileEventType = "CREATE" | "DELETE" | "CHANGED" | "RENAME" | "INTERNAL"; +export type FileEventArgs = { + file: FileInfo | InternalFileInfo; + cache?: CacheData; + oldPath?: string; + ctx?: any; +} +export type FileEventItem = { + type: FileEventType, + args: FileEventArgs, + key: string, +} + +export const CHeader = "h:"; +export const PSCHeader = "ps:"; +export const PSCHeaderEnd = "ps;"; +export const ICHeader = "i:"; +export const ICHeaderEnd = "i;"; +export const ICHeaderLength = ICHeader.length; + +export const FileWatchEventQueueMax = 10; +export const configURIBase = "obsidian://setuplivesync?settings="; \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index e8dd109..df13a82 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,10 @@ -import { DataWriteOptions, normalizePath, TFile, Platform } from "obsidian"; +import { DataWriteOptions, normalizePath, TFile, Platform, TAbstractFile, App, Plugin_2 } from "./deps"; import { path2id_base, id2path_base, isValidFilenameInLinux, isValidFilenameInDarwin, isValidFilenameInWidows, isValidFilenameInAndroid } from "./lib/src/path"; import { Logger } from "./lib/src/logger"; import { LOG_LEVEL } from "./lib/src/types"; +import { CHeader, ICHeader, ICHeaderLength, PSCHeader } from "./types"; +import { InputStringDialog, PopoverSelectString } from "./dialogs"; // For backward compatibility, using the path for determining id. // Only CouchDB unacceptable ID (that starts with an underscore) has been prefixed with "/". @@ -15,40 +17,44 @@ export function id2path(filename: string): string { return id2path_base(normalizePath(filename)); } -const triggers: { [key: string]: ReturnType } = {}; -export function setTrigger(key: string, timeout: number, proc: (() => Promise | void)) { - clearTrigger(key); - triggers[key] = setTimeout(async () => { - delete triggers[key]; +const tasks: { [key: string]: ReturnType } = {}; +export function scheduleTask(key: string, timeout: number, proc: (() => Promise | void)) { + cancelTask(key); + tasks[key] = setTimeout(async () => { + delete tasks[key]; await proc(); }, timeout); } -export function clearTrigger(key: string) { - if (key in triggers) { - clearTimeout(triggers[key]); +export function cancelTask(key: string) { + if (key in tasks) { + clearTimeout(tasks[key]); + delete tasks[key]; } } -export function clearAllTriggers() { - for (const v in triggers) { - clearTimeout(triggers[v]); +export function cancelAllTasks() { + for (const v in tasks) { + clearTimeout(tasks[v]); + delete tasks[v]; } } const intervals: { [key: string]: ReturnType } = {}; -export function setPeriodic(key: string, timeout: number, proc: (() => Promise | void)) { - clearPeriodic(key); +export function setPeriodicTask(key: string, timeout: number, proc: (() => Promise | void)) { + cancelPeriodicTask(key); intervals[key] = setInterval(async () => { delete intervals[key]; await proc(); }, timeout); } -export function clearPeriodic(key: string) { +export function cancelPeriodicTask(key: string) { if (key in intervals) { clearInterval(intervals[key]); + delete intervals[key]; } } -export function clearAllPeriodic() { +export function cancelAllPeriodicTask() { for (const v in intervals) { clearInterval(intervals[v]); + delete intervals[v]; } } @@ -290,4 +296,109 @@ export function isValidPath(filename: string) { //Fallback Logger("Could not determine platform for checking filename", LOG_LEVEL.VERBOSE); return isValidFilenameInWidows(filename); -} \ No newline at end of file +} + +let touchedFiles: string[] = []; + +export function getAbstractFileByPath(path: string): TAbstractFile | null { + // Hidden API but so useful. + // @ts-ignore + if ("getAbstractFileByPathInsensitive" in app.vault && (app.vault.adapter?.insensitive ?? false)) { + // @ts-ignore + return app.vault.getAbstractFileByPathInsensitive(path); + } else { + return app.vault.getAbstractFileByPath(path); + } +} +export function trimPrefix(target: string, prefix: string) { + return target.startsWith(prefix) ? target.substring(prefix.length) : target; +} + +export function touch(file: TFile | string) { + const f = file instanceof TFile ? file : getAbstractFileByPath(file) as TFile; + const key = `${f.path}-${f.stat.mtime}-${f.stat.size}`; + touchedFiles.unshift(key); + touchedFiles = touchedFiles.slice(0, 100); +} +export function recentlyTouched(file: TFile) { + const key = `${file.path}-${file.stat.mtime}-${file.stat.size}`; + if (touchedFiles.indexOf(key) == -1) return false; + return true; +} +export function clearTouched() { + touchedFiles = []; +} + +/** + * returns is internal chunk of file + * @param str ID + * @returns + */ +export function isInternalMetadata(str: string): boolean { + return str.startsWith(ICHeader); +} +export function id2filenameInternalMetadata(str: string): string { + return str.substring(ICHeaderLength); +} +export function filename2idInternalMetadata(str: string): string { + return ICHeader + str; +} + +// const CHeaderLength = CHeader.length; +export function isChunk(str: string): boolean { + return str.startsWith(CHeader); +} + +export function isPluginMetadata(str: string): boolean { + return str.startsWith(PSCHeader); +} + +export const askYesNo = (app: App, message: string): Promise<"yes" | "no"> => { + return new Promise((res) => { + const popover = new PopoverSelectString(app, message, null, null, (result) => res(result as "yes" | "no")); + popover.open(); + }); +}; + +export const askSelectString = (app: App, message: string, items: string[]): Promise => { + const getItemsFun = () => items; + return new Promise((res) => { + const popover = new PopoverSelectString(app, message, "", getItemsFun, (result) => res(result)); + popover.open(); + }); +}; + + +export const askString = (app: App, title: string, key: string, placeholder: string): Promise => { + return new Promise((res) => { + const dialog = new InputStringDialog(app, title, key, placeholder, (result) => res(result)); + dialog.open(); + }); +}; + + +export class PeriodicProcessor { + _process: () => Promise; + _timer?: number; + _plugin: Plugin_2; + constructor(plugin: Plugin_2, process: () => Promise) { + this._plugin = plugin; + this._process = process; + } + async process() { + try { + await this._process(); + } catch (ex) { + Logger(ex); + } + } + enable(interval: number) { + this.disable(); + if (interval == 0) return; + this._timer = window.setInterval(() => this._process().then(() => { }), interval); + this._plugin.registerInterval(this._timer); + } + disable() { + if (this._timer) clearInterval(this._timer); + } +}