mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-03-05 01:18:47 +00:00
- Fixed wrong event type bindings (which caused some events not to be handled correctly).
- Fixed detected a timing issue in StorageEventManager
- When multiple events for the same file are fired in quick succession, metadata has been kept older information. This induces unexpected wrong notifications and write prevention.
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
import { TFile, TFolder, type ListedFiles } from "obsidian";
|
|
import { SerializedFileAccess } from "./storageLib/SerializedFileAccess";
|
|
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
|
|
import { LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
|
import type {
|
|
FilePath,
|
|
FilePathWithPrefix,
|
|
UXDataWriteOptions,
|
|
UXFileInfo,
|
|
UXFileInfoStub,
|
|
UXFolderInfo,
|
|
UXStat,
|
|
} from "../../lib/src/common/types";
|
|
import { TFileToUXFileInfoStub, TFolderToUXFileInfoStub } from "./storageLib/utilObsidian.ts";
|
|
import { StorageEventManagerObsidian, type StorageEventManager } from "./storageLib/StorageEventManager";
|
|
import type { StorageAccess } from "../interfaces/StorageAccess";
|
|
import { createBlob, type CustomRegExp } from "../../lib/src/common/utils";
|
|
import { serialized } from "octagonal-wheels/concurrency/lock_v2";
|
|
import type { LiveSyncCore } from "../../main.ts";
|
|
import type ObsidianLiveSyncPlugin from "../../main.ts";
|
|
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
|
|
|
|
const fileLockPrefix = "file-lock:";
|
|
|
|
export class ModuleFileAccessObsidian extends AbstractObsidianModule implements StorageAccess {
|
|
processingFiles: Set<FilePathWithPrefix> = new Set();
|
|
processWriteFile<T>(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise<T>): Promise<T> {
|
|
const path = typeof file === "string" ? file : file.path;
|
|
return serialized(`${fileLockPrefix}${path}`, async () => {
|
|
try {
|
|
this.processingFiles.add(path);
|
|
return await proc();
|
|
} finally {
|
|
this.processingFiles.delete(path);
|
|
}
|
|
});
|
|
}
|
|
processReadFile<T>(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise<T>): Promise<T> {
|
|
const path = typeof file === "string" ? file : file.path;
|
|
return serialized(`${fileLockPrefix}${path}`, async () => {
|
|
try {
|
|
this.processingFiles.add(path);
|
|
return await proc();
|
|
} finally {
|
|
this.processingFiles.delete(path);
|
|
}
|
|
});
|
|
}
|
|
isFileProcessing(file: UXFileInfoStub | FilePathWithPrefix): boolean {
|
|
const path = typeof file === "string" ? file : file.path;
|
|
return this.processingFiles.has(path);
|
|
}
|
|
vaultAccess!: SerializedFileAccess;
|
|
vaultManager: StorageEventManager = new StorageEventManagerObsidian(this.plugin, this.core, this);
|
|
private _everyOnload(): Promise<boolean> {
|
|
this.core.storageAccess = this;
|
|
return Promise.resolve(true);
|
|
}
|
|
_everyOnFirstInitialize(): Promise<boolean> {
|
|
this.vaultManager.beginWatch();
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
// $$flushFileEventQueue(): void {
|
|
// this.vaultManager.flushQueue();
|
|
// }
|
|
|
|
_everyCommitPendingFileEvent(): Promise<boolean> {
|
|
this.vaultManager.flushQueue();
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
_everyOnloadStart(): Promise<boolean> {
|
|
this.vaultAccess = new SerializedFileAccess(this.app, this.plugin, this);
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
_isStorageInsensitive(): boolean {
|
|
return this.vaultAccess.isStorageInsensitive();
|
|
}
|
|
|
|
_shouldCheckCaseInsensitive(): boolean {
|
|
if (this.services.vault.isStorageInsensitive()) return false;
|
|
return !this.settings.handleFilenameCaseSensitive;
|
|
}
|
|
|
|
async writeFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise<boolean> {
|
|
const file = this.vaultAccess.getAbstractFileByPath(path);
|
|
if (file instanceof TFile) {
|
|
return this.vaultAccess.vaultModify(file, data, opt);
|
|
} else if (file === null) {
|
|
if (!path.endsWith(".md")) {
|
|
// Very rare case, we encountered this case with `writing-goals-history.csv` file.
|
|
// Indeed, that file not appears in the File Explorer, but it exists in the vault.
|
|
// Hence, we cannot retrieve the file from the vault by getAbstractFileByPath, and we cannot write it via vaultModify.
|
|
// It makes `File already exists` error.
|
|
// Therefore, we need to write it via adapterWrite.
|
|
// Maybe there are others like this, so I will write it via adapterWrite.
|
|
// This is a workaround for the issue, but I don't know if this is the right solution.
|
|
// (So limits to non-md files).
|
|
// Has Obsidian been patched?, anyway, writing directly might be a safer approach.
|
|
// However, does changes of that file trigger file-change event?
|
|
await this.vaultAccess.adapterWrite(path, data, opt);
|
|
// For safety, check existence
|
|
return await this.vaultAccess.adapterExists(path);
|
|
} else {
|
|
return (await this.vaultAccess.vaultCreate(path, data, opt)) instanceof TFile;
|
|
}
|
|
} else {
|
|
this._log(`Could not write file (Possibly already exists as a folder): ${path}`, LOG_LEVEL_VERBOSE);
|
|
return false;
|
|
}
|
|
}
|
|
readFileAuto(path: string): Promise<string | ArrayBuffer> {
|
|
const file = this.vaultAccess.getAbstractFileByPath(path);
|
|
if (file instanceof TFile) {
|
|
return this.vaultAccess.vaultRead(file);
|
|
} else {
|
|
throw new Error(`Could not read file (Possibly does not exist): ${path}`);
|
|
}
|
|
}
|
|
readFileText(path: string): Promise<string> {
|
|
const file = this.vaultAccess.getAbstractFileByPath(path);
|
|
if (file instanceof TFile) {
|
|
return this.vaultAccess.vaultRead(file);
|
|
} else {
|
|
throw new Error(`Could not read file (Possibly does not exist): ${path}`);
|
|
}
|
|
}
|
|
isExists(path: string): Promise<boolean> {
|
|
return Promise.resolve(this.vaultAccess.getAbstractFileByPath(path) instanceof TFile);
|
|
}
|
|
async writeHiddenFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise<boolean> {
|
|
try {
|
|
await this.vaultAccess.adapterWrite(path, data, opt);
|
|
return true;
|
|
} catch (e) {
|
|
this._log(`Could not write hidden file: ${path}`, LOG_LEVEL_VERBOSE);
|
|
this._log(e, LOG_LEVEL_VERBOSE);
|
|
return false;
|
|
}
|
|
}
|
|
async appendHiddenFile(path: string, data: string, opt?: UXDataWriteOptions): Promise<boolean> {
|
|
try {
|
|
await this.vaultAccess.adapterAppend(path, data, opt);
|
|
return true;
|
|
} catch (e) {
|
|
this._log(`Could not append hidden file: ${path}`, LOG_LEVEL_VERBOSE);
|
|
this._log(e, LOG_LEVEL_VERBOSE);
|
|
return false;
|
|
}
|
|
}
|
|
stat(path: string): Promise<UXStat | null> {
|
|
const file = this.vaultAccess.getAbstractFileByPath(path);
|
|
if (file === null) return Promise.resolve(null);
|
|
if (file instanceof TFile) {
|
|
return Promise.resolve({
|
|
ctime: file.stat.ctime,
|
|
mtime: file.stat.mtime,
|
|
size: file.stat.size,
|
|
type: "file",
|
|
});
|
|
} else {
|
|
throw new Error(`Could not stat file (Possibly does not exist): ${path}`);
|
|
}
|
|
}
|
|
statHidden(path: string): Promise<UXStat | null> {
|
|
return this.vaultAccess.tryAdapterStat(path);
|
|
}
|
|
async removeHidden(path: string): Promise<boolean> {
|
|
try {
|
|
await this.vaultAccess.adapterRemove(path);
|
|
if (this.vaultAccess.tryAdapterStat(path) !== null) {
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (e) {
|
|
this._log(`Could not remove hidden file: ${path}`, LOG_LEVEL_VERBOSE);
|
|
this._log(e, LOG_LEVEL_VERBOSE);
|
|
return false;
|
|
}
|
|
}
|
|
async readHiddenFileAuto(path: string): Promise<string | ArrayBuffer> {
|
|
return await this.vaultAccess.adapterReadAuto(path);
|
|
}
|
|
async readHiddenFileText(path: string): Promise<string> {
|
|
return await this.vaultAccess.adapterRead(path);
|
|
}
|
|
async readHiddenFileBinary(path: string): Promise<ArrayBuffer> {
|
|
return await this.vaultAccess.adapterReadBinary(path);
|
|
}
|
|
async isExistsIncludeHidden(path: string): Promise<boolean> {
|
|
return (await this.vaultAccess.tryAdapterStat(path)) !== null;
|
|
}
|
|
async ensureDir(path: string): Promise<boolean> {
|
|
try {
|
|
await this.vaultAccess.ensureDirectory(path);
|
|
return true;
|
|
} catch (e) {
|
|
this._log(`Could not ensure directory: ${path}`, LOG_LEVEL_VERBOSE);
|
|
this._log(e, LOG_LEVEL_VERBOSE);
|
|
return false;
|
|
}
|
|
}
|
|
triggerFileEvent(event: string, path: string): void {
|
|
const file = this.vaultAccess.getAbstractFileByPath(path);
|
|
if (file === null) return;
|
|
this.vaultAccess.trigger(event, file);
|
|
}
|
|
async triggerHiddenFile(path: string): Promise<void> {
|
|
//@ts-ignore internal function
|
|
await this.app.vault.adapter.reconcileInternalFile(path);
|
|
}
|
|
// getFileStub(file: TFile): UXFileInfoStub {
|
|
// return TFileToUXFileInfoStub(file);
|
|
// }
|
|
getFileStub(path: string): UXFileInfoStub | null {
|
|
const file = this.vaultAccess.getAbstractFileByPath(path);
|
|
if (file instanceof TFile) {
|
|
return TFileToUXFileInfoStub(file);
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async readStubContent(stub: UXFileInfoStub): Promise<UXFileInfo | false> {
|
|
const file = this.vaultAccess.getAbstractFileByPath(stub.path);
|
|
if (!(file instanceof TFile)) {
|
|
this._log(`Could not read file (Possibly does not exist or a folder): ${stub.path}`, LOG_LEVEL_VERBOSE);
|
|
return false;
|
|
}
|
|
const data = await this.vaultAccess.vaultReadAuto(file);
|
|
return {
|
|
...stub,
|
|
...TFileToUXFileInfoStub(file),
|
|
body: createBlob(data),
|
|
};
|
|
}
|
|
getStub(path: string): UXFileInfoStub | UXFolderInfo | null {
|
|
const file = this.vaultAccess.getAbstractFileByPath(path);
|
|
if (file instanceof TFile) {
|
|
return TFileToUXFileInfoStub(file);
|
|
} else if (file instanceof TFolder) {
|
|
return TFolderToUXFileInfoStub(file);
|
|
}
|
|
return null;
|
|
}
|
|
getFiles(): UXFileInfoStub[] {
|
|
return this.vaultAccess.getFiles().map((f) => TFileToUXFileInfoStub(f));
|
|
}
|
|
getFileNames(): FilePath[] {
|
|
return this.vaultAccess.getFiles().map((f) => f.path as FilePath);
|
|
}
|
|
|
|
async getFilesIncludeHidden(
|
|
basePath: string,
|
|
includeFilter?: CustomRegExp[],
|
|
excludeFilter?: CustomRegExp[],
|
|
skipFolder: string[] = [".git", ".trash", "node_modules"]
|
|
): Promise<FilePath[]> {
|
|
let w: ListedFiles;
|
|
try {
|
|
w = await this.app.vault.adapter.list(basePath);
|
|
} catch (ex) {
|
|
this._log(`Could not traverse(getFilesIncludeHidden):${basePath}`, LOG_LEVEL_INFO);
|
|
this._log(ex, LOG_LEVEL_VERBOSE);
|
|
return [];
|
|
}
|
|
skipFolder = skipFolder.map((e) => e.toLowerCase());
|
|
|
|
let files = [] as string[];
|
|
for (const file of w.files) {
|
|
if (includeFilter && includeFilter.length > 0) {
|
|
if (!includeFilter.some((e) => e.test(file))) continue;
|
|
}
|
|
if (excludeFilter && excludeFilter.some((ee) => ee.test(file))) {
|
|
continue;
|
|
}
|
|
if (await this.services.vault.isIgnoredByIgnoreFile(file)) continue;
|
|
files.push(file);
|
|
}
|
|
|
|
for (const v of w.folders) {
|
|
const folderName = (v.split("/").pop() ?? "").toLowerCase();
|
|
if (skipFolder.some((e) => folderName === e)) {
|
|
continue;
|
|
}
|
|
|
|
if (excludeFilter && excludeFilter.some((e) => e.test(v))) {
|
|
continue;
|
|
}
|
|
if (await this.services.vault.isIgnoredByIgnoreFile(v)) {
|
|
continue;
|
|
}
|
|
// OK, deep dive!
|
|
files = files.concat(await this.getFilesIncludeHidden(v, includeFilter, excludeFilter, skipFolder));
|
|
}
|
|
return files as FilePath[];
|
|
}
|
|
async touched(file: UXFileInfoStub | FilePathWithPrefix): Promise<void> {
|
|
const path = typeof file === "string" ? file : file.path;
|
|
await this.vaultAccess.touch(path as FilePath);
|
|
}
|
|
recentlyTouched(file: UXFileInfoStub | FilePathWithPrefix): boolean {
|
|
const xFile = typeof file === "string" ? (this.vaultAccess.getAbstractFileByPath(file) as TFile) : file;
|
|
if (xFile === null) return false;
|
|
if (xFile instanceof TFolder) return false;
|
|
return this.vaultAccess.recentlyTouched(xFile);
|
|
}
|
|
clearTouched(): void {
|
|
this.vaultAccess.clearTouched();
|
|
}
|
|
|
|
delete(file: FilePathWithPrefix | UXFileInfoStub | string, force: boolean): Promise<void> {
|
|
const xPath = typeof file === "string" ? file : file.path;
|
|
const xFile = this.vaultAccess.getAbstractFileByPath(xPath);
|
|
if (xFile === null) return Promise.resolve();
|
|
if (!(xFile instanceof TFile) && !(xFile instanceof TFolder)) return Promise.resolve();
|
|
return this.vaultAccess.delete(xFile, force);
|
|
}
|
|
trash(file: FilePathWithPrefix | UXFileInfoStub | string, system: boolean): Promise<void> {
|
|
const xPath = typeof file === "string" ? file : file.path;
|
|
const xFile = this.vaultAccess.getAbstractFileByPath(xPath);
|
|
if (xFile === null) return Promise.resolve();
|
|
if (!(xFile instanceof TFile) && !(xFile instanceof TFolder)) return Promise.resolve();
|
|
return this.vaultAccess.trash(xFile, system);
|
|
}
|
|
// $readFileBinary(path: string): Promise<ArrayBuffer> {
|
|
// const file = this.vaultAccess.getAbstractFileByPath(path);
|
|
// if (file instanceof TFile) {
|
|
// return this.vaultAccess.vaultReadBinary(file);
|
|
// } else {
|
|
// throw new Error(`Could not read file (Possibly does not exist): ${path}`);
|
|
// }
|
|
// }
|
|
// async $appendFileAuto(path: string, data: string | ArrayBuffer, opt?: DataWriteOptions): Promise<boolean> {
|
|
// const file = this.vaultAccess.getAbstractFileByPath(path);
|
|
// if (file instanceof TFile) {
|
|
// return this.vaultAccess.a(file, data, opt);
|
|
// } else if (file !== null) {
|
|
// return await this.vaultAccess.vaultCreate(path, data, opt) instanceof TFile;
|
|
// } else {
|
|
// this._log(`Could not append file (Possibly already exists as a folder): ${path}`, LOG_LEVEL_VERBOSE);
|
|
// return false;
|
|
// }
|
|
// }
|
|
|
|
async __deleteVaultItem(file: TFile | TFolder) {
|
|
if (file instanceof TFile) {
|
|
if (!(await this.services.vault.isTargetFile(file.path))) return;
|
|
}
|
|
const dir = file.parent;
|
|
if (this.settings.trashInsteadDelete) {
|
|
await this.vaultAccess.trash(file, false);
|
|
} else {
|
|
await this.vaultAccess.delete(file, true);
|
|
}
|
|
this._log(`xxx <- STORAGE (deleted) ${file.path}`);
|
|
if (dir) {
|
|
this._log(`files: ${dir.children.length}`);
|
|
if (dir.children.length == 0) {
|
|
if (!this.settings.doNotDeleteFolder) {
|
|
this._log(
|
|
`All files under the parent directory (${dir.path}) have been deleted, so delete this one.`
|
|
);
|
|
await this.__deleteVaultItem(dir);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async deleteVaultItem(fileSrc: FilePathWithPrefix | UXFileInfoStub | UXFolderInfo): Promise<void> {
|
|
const path = typeof fileSrc === "string" ? fileSrc : fileSrc.path;
|
|
const file = this.vaultAccess.getAbstractFileByPath(path);
|
|
if (file === null) return;
|
|
if (file instanceof TFile || file instanceof TFolder) {
|
|
return await this.__deleteVaultItem(file);
|
|
}
|
|
}
|
|
|
|
constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore) {
|
|
super(plugin, core);
|
|
}
|
|
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
|
services.vault.handleIsStorageInsensitive(this._isStorageInsensitive.bind(this));
|
|
services.setting.handleShouldCheckCaseInsensitively(this._shouldCheckCaseInsensitive.bind(this));
|
|
services.appLifecycle.handleFirstInitialise(this._everyOnFirstInitialize.bind(this));
|
|
services.appLifecycle.handleOnInitialise(this._everyOnloadStart.bind(this));
|
|
services.appLifecycle.handleOnLoaded(this._everyOnload.bind(this));
|
|
services.fileProcessing.handleCommitPendingFileEvents(this._everyCommitPendingFileEvent.bind(this));
|
|
}
|
|
}
|