mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-04-08 10:05:25 +00:00
Compare commits
50 Commits
0.25.43-pa
...
0.25.52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
584adc9296 | ||
|
|
f7dba6854f | ||
|
|
d0244bd6d0 | ||
|
|
79bb5e1c77 | ||
|
|
3403712e24 | ||
|
|
8faa19629b | ||
|
|
7ff9c666ce | ||
|
|
d8bc2806e0 | ||
|
|
62f78b4028 | ||
|
|
cf9d2720ce | ||
|
|
09115dfe15 | ||
|
|
4cbb833e9d | ||
|
|
7419d0d2a1 | ||
|
|
f3e83d4045 | ||
|
|
28e06a21e4 | ||
|
|
e08fbbd223 | ||
|
|
a1e331d452 | ||
|
|
646f8af680 | ||
|
|
392f76fd36 | ||
|
|
f61a3eb85b | ||
|
|
19c03ec8d8 | ||
|
|
be1642f1c1 | ||
|
|
c9a71e2076 | ||
|
|
2199c1ebd3 | ||
|
|
278935f85d | ||
|
|
010631f553 | ||
|
|
8c0c65307a | ||
|
|
988cb34d7c | ||
|
|
6eec8117f5 | ||
|
|
9f6a909143 | ||
|
|
09f283721a | ||
|
|
235c702223 | ||
|
|
b923b43b6b | ||
|
|
fdcf3be0f9 | ||
|
|
25dd907591 | ||
|
|
80c049d276 | ||
|
|
e961f01187 | ||
|
|
14b4c3cd50 | ||
|
|
4f987e7c2b | ||
|
|
f4d8c0a8db | ||
|
|
556ce471f8 | ||
|
|
32b6717114 | ||
|
|
e0e72fae72 | ||
|
|
203dd17421 | ||
|
|
1bde2b2ff1 | ||
|
|
2bf1c775ee | ||
|
|
4658e3735d | ||
|
|
627edc96bf | ||
|
|
0a1917e83c | ||
|
|
48b0d22da6 |
20
.github/workflows/unit-ci.yml
vendored
20
.github/workflows/unit-ci.yml
vendored
@@ -3,6 +3,10 @@ name: unit-ci
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- beta
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -26,8 +30,16 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install test dependencies (Playwright Chromium)
|
||||
run: npm run test:install-dependencies
|
||||
# unit tests do not require Playwright, so we can skip installing its dependencies to save time
|
||||
# - name: Install test dependencies (Playwright Chromium)
|
||||
# run: npm run test:install-dependencies
|
||||
|
||||
- name: Run unit tests suite
|
||||
run: npm run test:unit
|
||||
- name: Run unit tests suite with coverage
|
||||
run: npm run test:unit:coverage
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/**
|
||||
25
devs.md
25
devs.md
@@ -11,13 +11,28 @@ The plugin uses a dynamic module system to reduce coupling and improve maintaina
|
||||
|
||||
- **Service Hub**: Central registry for services using dependency injection
|
||||
- Services are registered, and accessed via `this.services` (in most modules)
|
||||
- **Module Loading**: All modules extend `AbstractModule` or `AbstractObsidianModule` (which extends `AbstractModule`). These modules are loaded in main.ts and some modules
|
||||
- **Module Loading**: All modules extend `AbstractModule` or `AbstractObsidianModule` (which extends `AbstractModule`). These modules are loaded in main.ts and some modules.
|
||||
- **Module Categories** (by directory):
|
||||
- `core/` - Platform-independent core functionality
|
||||
- `coreObsidian/` - Obsidian-specific core (e.g., `ModuleFileAccessObsidian`)
|
||||
- `essential/` - Required modules (e.g., `ModuleMigration`, `ModuleKeyValueDB`)
|
||||
- `features/` - Optional features (e.g., `ModuleLog`, `ModuleObsidianSettings`)
|
||||
- `extras/` - Development/testing tools (e.g., `ModuleDev`, `ModuleIntegratedTest`)
|
||||
- **Services**: Core services (e.g., `database`, `replicator`, `storageAccess`) are registered in `ServiceHub` and accessed by modules. They provide an extension point for add new behaviour without modifying existing code.
|
||||
- For example, checks before the replication can be added to the `replication.onBeforeReplicate` handler, and the handlers can be return `false` to prevent replication-starting. `vault.isTargetFile` also can be used to prevent processing specific files.
|
||||
- **ServiceModule**: A new type of module that directly depends on services.
|
||||
|
||||
#### Note on Module vs Service
|
||||
|
||||
After v0.25.44 refactoring, the Service will henceforth, as a rule, cease to use setHandler, that is to say, simple lazy binding. - They will be implemented directly in the service. - However, not everything will be middlewarised. Modules that maintain state or make decisions based on the results of multiple handlers are permitted.
|
||||
|
||||
Hence, the new feature should be implemented as follows:
|
||||
|
||||
- If it is a simple extension point (e.g., adding a check before replication), it should be implemented as a handler in the service (e.g., `replication.onBeforeReplicate`).
|
||||
- If it requires maintaining state or making decisions based on multiple handlers, it should be implemented as a serviceModule dependent on the relevant services explicitly.
|
||||
- If you have to implement a new feature without much modification, you can extent existing modules, but it is recommended to implement a new module or serviceModule for better maintainability.
|
||||
- Refactoring existing modules to services is also always welcome!
|
||||
- Please write tests for new features, you will notice that the simple handler approach is quite testable.
|
||||
|
||||
### Key Architectural Components
|
||||
|
||||
@@ -37,6 +52,7 @@ The plugin uses a dynamic module system to reduce coupling and improve maintaina
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
npm run test:unit # Run unit tests with vitest (or `npm run test:unit:coverage` for coverage)
|
||||
npm run check # TypeScript and svelte type checking
|
||||
npm run dev # Development build with auto-rebuild (uses .env for test vault paths)
|
||||
npm run build # Production build
|
||||
@@ -52,8 +68,11 @@ npm test # Run vitest tests (requires Docker services)
|
||||
|
||||
### Testing Infrastructure
|
||||
|
||||
- **Deno Tests**: Unit tests for platform-independent code (e.g., `HashManager.test.ts`)
|
||||
- **Vitest** (`vitest.config.ts`): E2E test by Browser-based-harness using Playwright
|
||||
- ~~**Deno Tests**: Unit tests for platform-independent code (e.g., `HashManager.test.ts`)~~
|
||||
- This is now obsolete, migrated to vitest.
|
||||
- **Vitest** (`vitest.config.ts`): E2E test by Browser-based-harness using Playwright, unit tests.
|
||||
- Unit tests should be `*.unit.spec.ts` and placed alongside the implementation file (e.g., `ChunkFetcher.unit.spec.ts`).
|
||||
|
||||
- **Docker Services**: Tests require CouchDB, MinIO (S3), and P2P services:
|
||||
```bash
|
||||
npm run test:docker-all:start # Start all test services
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.25.43-patched-4",
|
||||
"version": "0.25.52",
|
||||
"minAppVersion": "0.9.12",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
6327
package-lock.json
generated
6327
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
71
package.json
71
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.43-patched-4",
|
||||
"version": "0.25.52",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -16,6 +16,8 @@
|
||||
"dev": "node --env-file=.env esbuild.config.mjs",
|
||||
"prebuild": "npm run bakei18n",
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"buildVite": "npx dotenv-cli -e .env -- vite build --mode production",
|
||||
"buildViteOriginal": "npx dotenv-cli -e .env -- vite build --mode original",
|
||||
"buildDev": "node esbuild.config.mjs dev",
|
||||
"lint": "eslint src",
|
||||
"svelte-check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -46,9 +48,9 @@
|
||||
"test:docker-p2p:start": "npm run test:docker-p2p:up && sleep 3 && npm run test:docker-p2p:init",
|
||||
"test:docker-p2p:down": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/p2p-stop.sh",
|
||||
"test:docker-p2p:stop": "npm run test:docker-p2p:down",
|
||||
"test:docker-all:up": "npm run test:docker-couchdb:up && npm run test:docker-s3:up && npm run test:docker-p2p:up",
|
||||
"test:docker-all:init": "npm run test:docker-couchdb:init && npm run test:docker-s3:init && npm run test:docker-p2p:init",
|
||||
"test:docker-all:down": "npm run test:docker-couchdb:down && npm run test:docker-s3:down && npm run test:docker-p2p:down",
|
||||
"test:docker-all:up": "npm run test:docker-couchdb:up ; npm run test:docker-s3:up ; npm run test:docker-p2p:up",
|
||||
"test:docker-all:init": "npm run test:docker-couchdb:init ; npm run test:docker-s3:init ; npm run test:docker-p2p:init",
|
||||
"test:docker-all:down": "npm run test:docker-couchdb:down ; npm run test:docker-s3:down ; npm run test:docker-p2p:down",
|
||||
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
|
||||
"test:docker-all:stop": "npm run test:docker-all:down",
|
||||
"test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop"
|
||||
@@ -57,15 +59,15 @@
|
||||
"author": "vorotamoroz",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@chialab/esbuild-plugin-worker": "^0.18.1",
|
||||
"@eslint/compat": "^1.2.7",
|
||||
"@eslint/eslintrc": "^3.3.0",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tsconfig/svelte": "^5.0.5",
|
||||
"@types/deno": "^2.3.0",
|
||||
"@chialab/esbuild-plugin-worker": "^0.19.0",
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/deno": "^2.5.0",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/node": "^22.13.8",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/pouchdb": "^6.4.2",
|
||||
"@types/pouchdb-adapter-http": "^6.1.6",
|
||||
"@types/pouchdb-adapter-idb": "^6.1.7",
|
||||
@@ -74,25 +76,25 @@
|
||||
"@types/pouchdb-mapreduce": "^6.1.10",
|
||||
"@types/pouchdb-replication": "^6.4.7",
|
||||
"@types/transform-pouch": "^1.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "8.46.2",
|
||||
"@typescript-eslint/parser": "8.46.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@vitest/browser": "^4.0.16",
|
||||
"@vitest/browser-playwright": "^4.0.16",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"builtin-modules": "5.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.25.0",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
"esbuild-svelte": "^0.9.3",
|
||||
"eslint": "^9.38.0",
|
||||
"esbuild-svelte": "^0.9.4",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-svelte": "^3.12.4",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"events": "^3.3.0",
|
||||
"glob": "^11.0.3",
|
||||
"obsidian": "^1.8.7",
|
||||
"playwright": "^1.57.0",
|
||||
"postcss": "^8.5.3",
|
||||
"glob": "^13.0.6",
|
||||
"obsidian": "^1.12.3",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
@@ -105,31 +107,32 @@
|
||||
"pouchdb-merge": "^9.0.0",
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"prettier": "3.5.2",
|
||||
"prettier": "3.8.1",
|
||||
"rollup-plugin-copy": "^3.5.0",
|
||||
"svelte": "5.41.1",
|
||||
"svelte-check": "^4.3.3",
|
||||
"svelte-check": "^4.4.3",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"terser": "^5.39.0",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"tslib": "^2.8.1",
|
||||
"tsx": "^4.20.6",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.3.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.16",
|
||||
"webdriverio": "^9.23.0",
|
||||
"yaml": "^2.8.0"
|
||||
"webdriverio": "^9.24.0",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
"@smithy/fetch-http-handler": "^5.0.2",
|
||||
"@smithy/md5-js": "^4.0.2",
|
||||
"@smithy/middleware-apply-body-checksum": "^4.1.0",
|
||||
"@smithy/protocol-http": "^5.1.0",
|
||||
"@smithy/querystring-builder": "^4.0.2",
|
||||
"@smithy/fetch-http-handler": "^5.3.10",
|
||||
"@smithy/md5-js": "^4.2.9",
|
||||
"@smithy/middleware-apply-body-checksum": "^4.3.9",
|
||||
"@smithy/protocol-http": "^5.3.9",
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.3",
|
||||
"minimatch": "^10.0.2",
|
||||
"minimatch": "^10.2.2",
|
||||
"octagonal-wheels": "^0.1.45",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"trystero": "^0.22.0",
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"eslint-plugin-svelte": "^3.12.4",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tsconfig/svelte": "^5.0.5",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"svelte": "5.41.1",
|
||||
"svelte-check": "^4.3.3",
|
||||
"svelte-check": "^443.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.3.0"
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"imports": {
|
||||
"../../src/worker/bgWorker.ts": "../../src/worker/bgWorker.mock.ts",
|
||||
|
||||
@@ -34,8 +34,11 @@ import { TrysteroReplicator } from "@lib/replication/trystero/TrysteroReplicator
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types";
|
||||
import { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||
import type { InjectableServiceHub } from "@lib/services/InjectableServices";
|
||||
import { Menu } from "@/lib/src/services/implements/browser/Menu";
|
||||
import type { InjectableVaultServiceCompat } from "@/lib/src/services/implements/injectable/InjectableVaultService";
|
||||
import { Menu } from "@lib/services/implements/browser/Menu";
|
||||
import type { InjectableVaultServiceCompat } from "@lib/services/implements/injectable/InjectableVaultService";
|
||||
import { SimpleStoreIDBv2 } from "octagonal-wheels/databases/SimpleStoreIDBv2";
|
||||
import type { InjectableAPIService } from "@/lib/src/services/implements/injectable/InjectableAPIService";
|
||||
import type { BrowserAPIService } from "@/lib/src/services/implements/browser/BrowserAPIService";
|
||||
|
||||
function addToList(item: string, list: string) {
|
||||
return unique(
|
||||
@@ -80,13 +83,10 @@ export class P2PReplicatorShim implements P2PReplicatorBase, CommandShim {
|
||||
constructor() {
|
||||
const browserServiceHub = new BrowserServiceHub<ServiceContext>();
|
||||
this.services = browserServiceHub;
|
||||
(this.services.vault as InjectableVaultServiceCompat<ServiceContext>).vaultName.setHandler(
|
||||
|
||||
(this.services.API as BrowserAPIService<ServiceContext>).getSystemVaultName.setHandler(
|
||||
() => "p2p-livesync-web-peer"
|
||||
);
|
||||
|
||||
this.services.setting.currentSettings.setHandler(() => {
|
||||
return this.settings as any;
|
||||
});
|
||||
}
|
||||
async init() {
|
||||
// const { simpleStoreAPI } = await getWrappedSynchromesh();
|
||||
@@ -102,11 +102,10 @@ export class P2PReplicatorShim implements P2PReplicatorBase, CommandShim {
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
|
||||
const repStore = this.services.database.openSimpleStore<any>("p2p-livesync-web-peer");
|
||||
const repStore = SimpleStoreIDBv2.open<any>("p2p-livesync-web-peer");
|
||||
this._simpleStore = repStore;
|
||||
let _settings = (await repStore.get("settings")) || ({ ...P2P_DEFAULT_SETTINGS } as P2PSyncSetting);
|
||||
|
||||
this.services.setting.settings = _settings as any;
|
||||
this.plugin = {
|
||||
saveSettings: async () => {
|
||||
await repStore.set("settings", _settings);
|
||||
@@ -148,9 +147,9 @@ export class P2PReplicatorShim implements P2PReplicatorBase, CommandShim {
|
||||
simpleStore(): SimpleStore<any> {
|
||||
return this._simpleStore;
|
||||
}
|
||||
handleReplicatedDocuments(docs: EntryDoc[]): Promise<void> {
|
||||
handleReplicatedDocuments(docs: EntryDoc[]): Promise<boolean> {
|
||||
// No op. This is a client and does not need to process the docs
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
getPluginShim() {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type mount, unmount } from "svelte";
|
||||
export abstract class SvelteItemView extends ItemView {
|
||||
abstract instantiateComponent(target: HTMLElement): ReturnType<typeof mount> | Promise<ReturnType<typeof mount>>;
|
||||
component?: ReturnType<typeof mount>;
|
||||
async onOpen() {
|
||||
override async onOpen() {
|
||||
await super.onOpen();
|
||||
this.contentEl.empty();
|
||||
await this._dismountComponent();
|
||||
@@ -17,7 +17,7 @@ export abstract class SvelteItemView extends ItemView {
|
||||
this.component = undefined;
|
||||
}
|
||||
}
|
||||
async onClose() {
|
||||
override async onClose() {
|
||||
await super.onClose();
|
||||
if (this.component) {
|
||||
await unmount(this.component);
|
||||
|
||||
@@ -24,7 +24,7 @@ export const EVENT_REQUEST_RUN_FIX_INCOMPLETE = "request-run-fix-incomplete";
|
||||
|
||||
export const EVENT_ANALYSE_DB_USAGE = "analyse-db-usage";
|
||||
export const EVENT_REQUEST_PERFORM_GC_V3 = "request-perform-gc-v3";
|
||||
export const EVENT_REQUEST_CHECK_REMOTE_SIZE = "request-check-remote-size";
|
||||
// export const EVENT_REQUEST_CHECK_REMOTE_SIZE = "request-check-remote-size";
|
||||
// export const EVENT_FILE_CHANGED = "file-changed";
|
||||
|
||||
declare global {
|
||||
@@ -44,7 +44,6 @@ declare global {
|
||||
[EVENT_REQUEST_RUN_DOCTOR]: string;
|
||||
[EVENT_REQUEST_RUN_FIX_INCOMPLETE]: undefined;
|
||||
[EVENT_ANALYSE_DB_USAGE]: undefined;
|
||||
[EVENT_REQUEST_CHECK_REMOTE_SIZE]: undefined;
|
||||
[EVENT_REQUEST_PERFORM_GC_V3]: undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type UXFileInfo,
|
||||
type UXFileInfoStub,
|
||||
} from "../lib/src/common/types.ts";
|
||||
import { ICHeader, ICXHeader } from "./types.ts";
|
||||
export { ICHeader, ICXHeader } from "./types.ts";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
import { writeString } from "../lib/src/string_and_binary/convert.ts";
|
||||
import { fireAndForget } from "../lib/src/common/utils.ts";
|
||||
@@ -31,7 +31,6 @@ import { sameChangePairs } from "./stores.ts";
|
||||
|
||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { EVENT_PLUGIN_UNLOADED, eventHub } from "./events.ts";
|
||||
import { promiseWithResolver, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
import { AuthorizationHeaderGenerator } from "../lib/src/replication/httplib.ts";
|
||||
import type { KeyValueDatabase } from "../lib/src/interfaces/KeyValueDatabase.ts";
|
||||
|
||||
@@ -68,21 +67,13 @@ export function getPathFromTFile(file: TAbstractFile) {
|
||||
return file.path as FilePath;
|
||||
}
|
||||
|
||||
import { isInternalFile } from "@lib/common/typeUtils.ts";
|
||||
export function getPathFromUXFileInfo(file: UXFileInfoStub | string | FilePathWithPrefix) {
|
||||
if (typeof file == "string") return file as FilePathWithPrefix;
|
||||
return file.path;
|
||||
}
|
||||
export function getStoragePathFromUXFileInfo(file: UXFileInfoStub | string | FilePathWithPrefix) {
|
||||
if (typeof file == "string") return stripAllPrefixes(file as FilePathWithPrefix);
|
||||
return stripAllPrefixes(file.path);
|
||||
}
|
||||
export function getDatabasePathFromUXFileInfo(file: UXFileInfoStub | string | FilePathWithPrefix) {
|
||||
if (typeof file == "string" && file.startsWith(ICXHeader)) return file as FilePathWithPrefix;
|
||||
const prefix = isInternalFile(file) ? ICHeader : "";
|
||||
if (typeof file == "string") return (prefix + stripAllPrefixes(file as FilePathWithPrefix)) as FilePathWithPrefix;
|
||||
return (prefix + stripAllPrefixes(file.path)) as FilePathWithPrefix;
|
||||
}
|
||||
import {
|
||||
isInternalFile,
|
||||
getPathFromUXFileInfo,
|
||||
getStoragePathFromUXFileInfo,
|
||||
getDatabasePathFromUXFileInfo,
|
||||
} from "@lib/common/typeUtils.ts";
|
||||
export { isInternalFile, getPathFromUXFileInfo, getStoragePathFromUXFileInfo, getDatabasePathFromUXFileInfo };
|
||||
|
||||
const memos: { [key: string]: any } = {};
|
||||
export function memoObject<T>(key: string, obj: T): T {
|
||||
@@ -160,7 +151,7 @@ export class PeriodicProcessor {
|
||||
() =>
|
||||
fireAndForget(async () => {
|
||||
await this.process();
|
||||
if (this._plugin.services?.appLifecycle?.hasUnloaded()) {
|
||||
if (this._plugin.services?.control?.hasUnloaded()) {
|
||||
this.disable();
|
||||
}
|
||||
}),
|
||||
@@ -263,25 +254,11 @@ export function requestToCouchDBWithCredentials(
|
||||
return _requestToCouchDB(baseUri, credentials, origin, uri, body, method, customHeaders);
|
||||
}
|
||||
|
||||
export const BASE_IS_NEW = Symbol("base");
|
||||
export const TARGET_IS_NEW = Symbol("target");
|
||||
export const EVEN = Symbol("even");
|
||||
|
||||
import { BASE_IS_NEW, EVEN, TARGET_IS_NEW } from "@lib/common/models/shared.const.symbols.ts";
|
||||
export { BASE_IS_NEW, EVEN, TARGET_IS_NEW };
|
||||
// Why 2000? : ZIP FILE Does not have enough resolution.
|
||||
const resolution = 2000;
|
||||
export function compareMTime(
|
||||
baseMTime: number,
|
||||
targetMTime: number
|
||||
): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
const truncatedBaseMTime = ~~(baseMTime / resolution) * resolution;
|
||||
const truncatedTargetMTime = ~~(targetMTime / resolution) * resolution;
|
||||
// Logger(`Resolution MTime ${truncatedBaseMTime} and ${truncatedTargetMTime} `, LOG_LEVEL_VERBOSE);
|
||||
if (truncatedBaseMTime == truncatedTargetMTime) return EVEN;
|
||||
if (truncatedBaseMTime > truncatedTargetMTime) return BASE_IS_NEW;
|
||||
if (truncatedBaseMTime < truncatedTargetMTime) return TARGET_IS_NEW;
|
||||
throw new Error("Unexpected error");
|
||||
}
|
||||
|
||||
import { compareMTime } from "@lib/common/utils.ts";
|
||||
export { compareMTime };
|
||||
function getKey(file: AnyEntry | string | UXFileInfoStub) {
|
||||
const key = typeof file == "string" ? file : stripAllPrefixes(file.path);
|
||||
return key;
|
||||
@@ -469,47 +446,3 @@ export function onlyInNTimes(n: number, proc: (progress: number) => any) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const waitingTasks = {} as Record<string, { task?: PromiseWithResolvers<any>; previous: number; leastNext: number }>;
|
||||
|
||||
export function rateLimitedSharedExecution<T>(key: string, interval: number, proc: () => Promise<T>): Promise<T> {
|
||||
if (!(key in waitingTasks)) {
|
||||
waitingTasks[key] = { task: undefined, previous: 0, leastNext: 0 };
|
||||
}
|
||||
if (waitingTasks[key].task) {
|
||||
// Extend the previous execution time.
|
||||
waitingTasks[key].leastNext = Date.now() + interval;
|
||||
return waitingTasks[key].task.promise;
|
||||
}
|
||||
|
||||
const previous = waitingTasks[key].previous;
|
||||
|
||||
const delay = previous == 0 ? 0 : Math.max(interval - (Date.now() - previous), 0);
|
||||
|
||||
const task = promiseWithResolver<T>();
|
||||
void task.promise.finally(() => {
|
||||
if (waitingTasks[key].task === task) {
|
||||
waitingTasks[key].task = undefined;
|
||||
waitingTasks[key].previous = Math.max(Date.now(), waitingTasks[key].leastNext);
|
||||
}
|
||||
});
|
||||
waitingTasks[key] = {
|
||||
task,
|
||||
previous: Date.now(),
|
||||
leastNext: Date.now() + interval,
|
||||
};
|
||||
void scheduleTask("thin-out-" + key, delay, async () => {
|
||||
try {
|
||||
task.resolve(await proc());
|
||||
} catch (ex) {
|
||||
task.reject(ex);
|
||||
}
|
||||
});
|
||||
return task.promise;
|
||||
}
|
||||
export function updatePreviousExecutionTime(key: string, timeDelta: number = 0) {
|
||||
if (!(key in waitingTasks)) {
|
||||
waitingTasks[key] = { task: undefined, previous: 0, leastNext: 0 };
|
||||
}
|
||||
waitingTasks[key].leastNext = Math.max(Date.now() + timeDelta, waitingTasks[key].leastNext);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export type {
|
||||
MarkdownFileInfo,
|
||||
ListedFiles,
|
||||
ValueComponent,
|
||||
Stat,
|
||||
} from "obsidian";
|
||||
import { normalizePath as normalizePath_ } from "obsidian";
|
||||
const normalizePath = normalizePath_ as <T extends string | FilePath>(from: T) => T;
|
||||
|
||||
@@ -53,9 +53,7 @@ import {
|
||||
PeriodicProcessor,
|
||||
disposeMemoObject,
|
||||
isCustomisationSyncMetadata,
|
||||
isMarkedAsSameChanges,
|
||||
isPluginMetadata,
|
||||
markChangesAreSame,
|
||||
memoIfNotExist,
|
||||
memoObject,
|
||||
retrieveMemoObject,
|
||||
@@ -1308,7 +1306,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
eden: {},
|
||||
};
|
||||
} else {
|
||||
if (isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) {
|
||||
if (this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
|
||||
LOG_LEVEL_DEBUG
|
||||
@@ -1328,7 +1326,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
markChangesAreSame(prefixedFileName, old.mtime, mtime + 1);
|
||||
this.services.path.markChangesAreSame(prefixedFileName, old.mtime, mtime + 1);
|
||||
return true;
|
||||
}
|
||||
saveData = {
|
||||
@@ -1802,7 +1800,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
return files;
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
|
||||
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
|
||||
services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this));
|
||||
|
||||
@@ -14,7 +14,7 @@ export class PluginDialogModal extends Modal {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.contentEl.style.overflow = "auto";
|
||||
this.contentEl.style.display = "flex";
|
||||
@@ -28,7 +28,7 @@ export class PluginDialogModal extends Modal {
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
override onClose() {
|
||||
if (this.component) {
|
||||
void unmount(this.component);
|
||||
this.component = undefined;
|
||||
|
||||
@@ -50,7 +50,7 @@ export class JsonResolveModal extends Modal {
|
||||
this.callback = undefined;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
@@ -74,7 +74,7 @@ export class JsonResolveModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose() {
|
||||
override onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
// contentEl.empty();
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
</div>
|
||||
|
||||
{#if selectedObj != false}
|
||||
<div class="op-scrollable json-source">
|
||||
<div class="op-scrollable json-source ls-dialog">
|
||||
{#each diffs as diff}
|
||||
<span class={diff[0] == DIFF_DELETE ? "deleted" : diff[0] == DIFF_INSERT ? "added" : "normal"}
|
||||
>{diff[1]}</span
|
||||
|
||||
@@ -29,9 +29,7 @@ import {
|
||||
} from "../../lib/src/common/utils.ts";
|
||||
import {
|
||||
compareMTime,
|
||||
unmarkChanges,
|
||||
isInternalMetadata,
|
||||
markChangesAreSame,
|
||||
PeriodicProcessor,
|
||||
TARGET_IS_NEW,
|
||||
scheduleTask,
|
||||
@@ -92,7 +90,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
return this.plugin.kvDB;
|
||||
}
|
||||
getConflictedDoc(path: FilePathWithPrefix, rev: string) {
|
||||
return this.plugin.managers.conflictManager.getConflictedDoc(path, rev);
|
||||
return this.localDatabase.managers.conflictManager.getConflictedDoc(path, rev);
|
||||
}
|
||||
onunload() {
|
||||
this.periodicInternalFileScanProcessor?.disable();
|
||||
@@ -244,13 +242,23 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
if (this.isThisModuleEnabled()) {
|
||||
//system file
|
||||
const filename = this.getPath(doc);
|
||||
if (await this.services.vault.isTargetFile(filename)) {
|
||||
// this.procInternalFile(filename);
|
||||
await this.processReplicationResult(doc);
|
||||
const unprefixedPath = stripAllPrefixes(filename);
|
||||
// No need to check via vaultService
|
||||
// if (!await this.services.vault.isTargetFile(unprefixedPath)) {
|
||||
// this._log(`Skipped processing sync file:${unprefixedPath} (Not target)`, LOG_LEVEL_VERBOSE);
|
||||
// return true;
|
||||
// }
|
||||
if (!(await this.isTargetFile(stripAllPrefixes(unprefixedPath)))) {
|
||||
this._log(
|
||||
`Skipped processing sync file:${unprefixedPath} (Not Hidden File Sync target)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
// We should return true, we made sure that document is a internalMetadata.
|
||||
return true;
|
||||
} else {
|
||||
this._log(`Skipped (Not target:${filename})`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (!(await this.processReplicationResult(doc))) {
|
||||
this._log(`Failed to process sync file:${unprefixedPath}`, LOG_LEVEL_NOTICE);
|
||||
// Do not yield false, this file had been processed.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -352,13 +360,13 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
const dbMTime = getComparingMTime(db);
|
||||
const storageMTime = getComparingMTime(stat);
|
||||
if (dbMTime == 0 || storageMTime == 0) {
|
||||
unmarkChanges(path);
|
||||
this.services.path.unmarkChanges(path);
|
||||
} else {
|
||||
markChangesAreSame(path, getComparingMTime(db), getComparingMTime(stat));
|
||||
this.services.path.markChangesAreSame(path, getComparingMTime(db), getComparingMTime(stat));
|
||||
}
|
||||
}
|
||||
updateLastProcessedDeletion(path: FilePath, db: MetaEntry | LoadedEntry | false) {
|
||||
unmarkChanges(path);
|
||||
this.services.path.unmarkChanges(path);
|
||||
if (db) this.updateLastProcessedDatabase(path, db);
|
||||
this.updateLastProcessedFile(path, this.statToKey(null));
|
||||
}
|
||||
@@ -700,7 +708,7 @@ Offline Changed files: ${processFiles.length}`;
|
||||
revFrom._revs_info
|
||||
?.filter((e) => e.status == "available" && Number(e.rev.split("-")[0]) < conflictedRevNo)
|
||||
.first()?.rev ?? "";
|
||||
const result = await this.plugin.managers.conflictManager.mergeObject(
|
||||
const result = await this.localDatabase.managers.conflictManager.mergeObject(
|
||||
doc.path,
|
||||
commonBase,
|
||||
doc._rev,
|
||||
@@ -1934,7 +1942,7 @@ ${messageFetch}${messageOverwrite}${messageMerge}
|
||||
*/
|
||||
// <-- Local Storage SubFunctions
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services) {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services) {
|
||||
// No longer needed on initialisation
|
||||
// services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
|
||||
@@ -40,9 +40,6 @@ export class P2PReplicator extends LiveSyncCommands implements P2PReplicatorBase
|
||||
getSettings(): P2PSyncSetting {
|
||||
return this.plugin.settings;
|
||||
}
|
||||
get settings() {
|
||||
return this.plugin.settings;
|
||||
}
|
||||
getDB() {
|
||||
return this.plugin.localDatabase.localDatabase;
|
||||
}
|
||||
@@ -65,7 +62,7 @@ export class P2PReplicator extends LiveSyncCommands implements P2PReplicatorBase
|
||||
// this.onBindFunction(plugin, plugin.services);
|
||||
}
|
||||
|
||||
async handleReplicatedDocuments(docs: EntryDoc[]): Promise<void> {
|
||||
async handleReplicatedDocuments(docs: EntryDoc[]): Promise<boolean> {
|
||||
// console.log("Processing Replicated Docs", docs);
|
||||
return await this.services.replication.parseSynchroniseResult(
|
||||
docs as PouchDB.Core.ExistingDocument<EntryDoc>[]
|
||||
|
||||
@@ -35,11 +35,11 @@ function removeFromList(item: string, list: string) {
|
||||
|
||||
export class P2PReplicatorPaneView extends SvelteItemView {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon = "waypoints";
|
||||
override icon = "waypoints";
|
||||
title: string = "";
|
||||
navigation = false;
|
||||
override navigation = false;
|
||||
|
||||
getIcon(): string {
|
||||
override getIcon(): string {
|
||||
return "waypoints";
|
||||
}
|
||||
get replicator() {
|
||||
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: 75d46c7163...27d1d4a6e7
392
src/main.ts
392
src/main.ts
@@ -1,9 +1,9 @@
|
||||
import { Plugin, type App, type PluginManifest } from "./deps";
|
||||
import { Notice, Plugin, type App, type PluginManifest } from "./deps";
|
||||
import {
|
||||
type EntryDoc,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type DatabaseConnectingStatus,
|
||||
type HasSettings,
|
||||
LOG_LEVEL_INFO,
|
||||
} from "./lib/src/common/types.ts";
|
||||
import { type SimpleStore } from "./lib/src/common/utils.ts";
|
||||
import { type LiveSyncLocalDBEnv } from "./lib/src/pouchdb/LiveSyncLocalDB.ts";
|
||||
@@ -11,49 +11,39 @@ import { type LiveSyncReplicatorEnv } from "./lib/src/replication/LiveSyncAbstra
|
||||
import { LiveSyncCommands } from "./features/LiveSyncCommands.ts";
|
||||
import { HiddenFileSync } from "./features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { ConfigSync } from "./features/ConfigSync/CmdConfigSync.ts";
|
||||
import { reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive";
|
||||
import { type LiveSyncJournalReplicatorEnv } from "./lib/src/replication/journal/LiveSyncJournalReplicator.js";
|
||||
import { type LiveSyncCouchDBReplicatorEnv } from "./lib/src/replication/couchdb/LiveSyncReplicator.js";
|
||||
import type { CheckPointInfo } from "./lib/src/replication/journal/JournalSyncTypes.js";
|
||||
import type { IObsidianModule } from "./modules/AbstractObsidianModule.ts";
|
||||
|
||||
import { ModuleDev } from "./modules/extras/ModuleDev.ts";
|
||||
import { ModuleFileAccessObsidian } from "./modules/coreObsidian/ModuleFileAccessObsidian.ts";
|
||||
import { ModuleMigration } from "./modules/essential/ModuleMigration.ts";
|
||||
|
||||
import { ModuleCheckRemoteSize } from "./modules/essentialObsidian/ModuleCheckRemoteSize.ts";
|
||||
// import { ModuleCheckRemoteSize } from "./modules/essentialObsidian/ModuleCheckRemoteSize.ts";
|
||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver.ts";
|
||||
import { ModuleInteractiveConflictResolver } from "./modules/features/ModuleInteractiveConflictResolver.ts";
|
||||
import { ModuleLog } from "./modules/features/ModuleLog.ts";
|
||||
import { ModuleObsidianSettings } from "./modules/features/ModuleObsidianSetting.ts";
|
||||
import { ModuleRedFlag } from "./modules/coreFeatures/ModuleRedFlag.ts";
|
||||
// import { ModuleRedFlag } from "./modules/coreFeatures/ModuleRedFlag.ts";
|
||||
import { ModuleObsidianMenu } from "./modules/essentialObsidian/ModuleObsidianMenu.ts";
|
||||
import { ModuleSetupObsidian } from "./modules/features/ModuleSetupObsidian.ts";
|
||||
import { SetupManager } from "./modules/features/SetupManager.ts";
|
||||
import type { StorageAccess } from "./modules/interfaces/StorageAccess.ts";
|
||||
import type { StorageAccess } from "@lib/interfaces/StorageAccess.ts";
|
||||
import type { Confirm } from "./lib/src/interfaces/Confirm.ts";
|
||||
import type { Rebuilder } from "./modules/interfaces/DatabaseRebuilder.ts";
|
||||
import type { DatabaseFileAccess } from "./modules/interfaces/DatabaseFileAccess.ts";
|
||||
import { ModuleDatabaseFileAccess } from "./modules/core/ModuleDatabaseFileAccess.ts";
|
||||
import { ModuleFileHandler } from "./modules/core/ModuleFileHandler.ts";
|
||||
import { ModuleObsidianAPI } from "./modules/essentialObsidian/ModuleObsidianAPI.ts";
|
||||
import type { Rebuilder } from "@lib/interfaces/DatabaseRebuilder.ts";
|
||||
import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess.ts";
|
||||
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
|
||||
import { type AbstractModule } from "./modules/AbstractModule.ts";
|
||||
import { AbstractModule } from "./modules/AbstractModule.ts";
|
||||
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
||||
import { ModuleObsidianDocumentHistory } from "./modules/features/ModuleObsidianDocumentHistory.ts";
|
||||
import { ModuleObsidianGlobalHistory } from "./modules/features/ModuleGlobalHistory.ts";
|
||||
import { ModuleObsidianSettingsAsMarkdown } from "./modules/features/ModuleObsidianSettingAsMarkdown.ts";
|
||||
import { ModuleInitializerFile } from "./modules/essential/ModuleInitializerFile.ts";
|
||||
// import { ModuleInitializerFile } from "./modules/essential/ModuleInitializerFile.ts";
|
||||
import { ModuleReplicator } from "./modules/core/ModuleReplicator.ts";
|
||||
import { ModuleReplicatorCouchDB } from "./modules/core/ModuleReplicatorCouchDB.ts";
|
||||
import { ModuleReplicatorMinIO } from "./modules/core/ModuleReplicatorMinIO.ts";
|
||||
import { ModuleTargetFilter } from "./modules/core/ModuleTargetFilter.ts";
|
||||
import { ModulePeriodicProcess } from "./modules/core/ModulePeriodicProcess.ts";
|
||||
import { ModuleRemoteGovernor } from "./modules/coreFeatures/ModuleRemoteGovernor.ts";
|
||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker.ts";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks.ts";
|
||||
import { ModuleIntegratedTest } from "./modules/extras/ModuleIntegratedTest.ts";
|
||||
import { ModuleRebuilder } from "./modules/core/ModuleRebuilder.ts";
|
||||
import { ModuleReplicateTest } from "./modules/extras/ModuleReplicateTest.ts";
|
||||
import { ModuleLiveSyncMain } from "./modules/main/ModuleLiveSyncMain.ts";
|
||||
import { LocalDatabaseMaintenance } from "./features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts";
|
||||
@@ -61,6 +51,22 @@ import { P2PReplicator } from "./features/P2PSync/CmdP2PReplicator.ts";
|
||||
import type { InjectableServiceHub } from "./lib/src/services/implements/injectable/InjectableServiceHub.ts";
|
||||
import { ObsidianServiceHub } from "./modules/services/ObsidianServiceHub.ts";
|
||||
import type { ServiceContext } from "./lib/src/services/base/ServiceBase.ts";
|
||||
import { ServiceRebuilder } from "@lib/serviceModules/Rebuilder.ts";
|
||||
import type { IFileHandler } from "@lib/interfaces/FileHandler.ts";
|
||||
import { ServiceDatabaseFileAccess } from "@/serviceModules/DatabaseFileAccess.ts";
|
||||
import { ServiceFileAccessObsidian } from "@/serviceModules/ServiceFileAccessImpl.ts";
|
||||
import { StorageAccessManager } from "@lib/managers/StorageProcessingManager.ts";
|
||||
import { __$checkInstanceBinding } from "./lib/src/dev/checks.ts";
|
||||
import { ServiceFileHandler } from "./serviceModules/FileHandler.ts";
|
||||
import { FileAccessObsidian } from "./serviceModules/FileAccessObsidian.ts";
|
||||
import { StorageEventManagerObsidian } from "./managers/StorageEventManagerObsidian.ts";
|
||||
import { onLayoutReadyFeatures } from "./serviceFeatures/onLayoutReady.ts";
|
||||
import type { ServiceModules } from "./types.ts";
|
||||
import { useTargetFilters } from "@lib/serviceFeatures/targetFilter.ts";
|
||||
import { setNoticeClass } from "@lib/mock_and_interop/wrapper.ts";
|
||||
import { useCheckRemoteSize } from "./lib/src/serviceFeatures/checkRemoteSize.ts";
|
||||
import { useRedFlagFeatures } from "./serviceFeatures/redFlag.ts";
|
||||
import { useOfflineScanner } from "./lib/src/serviceFeatures/offlineScanner.ts";
|
||||
|
||||
export default class ObsidianLiveSyncPlugin
|
||||
extends Plugin
|
||||
@@ -83,20 +89,37 @@ export default class ObsidianLiveSyncPlugin
|
||||
return this._services;
|
||||
}
|
||||
|
||||
private initialiseServices() {
|
||||
this._services = new ObsidianServiceHub(this);
|
||||
/**
|
||||
* Service Modules
|
||||
*/
|
||||
protected _serviceModules: ServiceModules;
|
||||
|
||||
get serviceModules() {
|
||||
return this._serviceModules;
|
||||
}
|
||||
|
||||
// Keep order to display the dialogue in order.
|
||||
addOns = [] as LiveSyncCommands[];
|
||||
/**
|
||||
* Bind functions to the service hub (for migration purpose).
|
||||
* addOns: Non-essential and graphically features
|
||||
*/
|
||||
// bindFunctions = (this.serviceHub as ObsidianServiceHub).bindFunctions.bind(this.serviceHub);
|
||||
addOns = [] as LiveSyncCommands[];
|
||||
|
||||
/**
|
||||
* The modules of the plug-in. Modules are responsible for specific features or functionalities of the plug-in, such as file handling, conflict resolution, replication, etc.
|
||||
*/
|
||||
private modules = [
|
||||
// Move to registerModules
|
||||
] as (IObsidianModule | AbstractModule)[];
|
||||
|
||||
/**
|
||||
* register an add-onn to the plug-in.
|
||||
* Add-ons are features that are not essential to the core functionality of the plugin,
|
||||
* @param addOn
|
||||
*/
|
||||
private _registerAddOn(addOn: LiveSyncCommands) {
|
||||
this.addOns.push(addOn);
|
||||
this.services.appLifecycle.onUnload.addHandler(() => Promise.resolve(addOn.onunload()).then(() => true));
|
||||
}
|
||||
|
||||
private registerAddOns() {
|
||||
this._registerAddOn(new ConfigSync(this));
|
||||
this._registerAddOn(new HiddenFileSync(this));
|
||||
@@ -104,6 +127,11 @@ export default class ObsidianLiveSyncPlugin
|
||||
this._registerAddOn(new P2PReplicator(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an add-on by its class name. Returns undefined if not found.
|
||||
* @param cls
|
||||
* @returns
|
||||
*/
|
||||
getAddOn<T extends LiveSyncCommands>(cls: string) {
|
||||
for (const addon of this.addOns) {
|
||||
if (addon.constructor.name == cls) return addon as T;
|
||||
@@ -111,60 +139,48 @@ export default class ObsidianLiveSyncPlugin
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private modules = [
|
||||
// Move to registerModules
|
||||
] as (IObsidianModule | AbstractModule)[];
|
||||
|
||||
/**
|
||||
* Get a module by its class. Throws an error if not found.
|
||||
* Mostly used for getting SetupManager.
|
||||
* @param constructor
|
||||
* @returns
|
||||
*/
|
||||
getModule<T extends IObsidianModule>(constructor: new (...args: any[]) => T): T {
|
||||
for (const module of this.modules) {
|
||||
if (module.constructor === constructor) return module as T;
|
||||
}
|
||||
throw new Error(`Module ${constructor} not found or not loaded.`);
|
||||
}
|
||||
getModulesByType<T extends IObsidianModule>(constructor: new (...args: any[]) => T): T[] {
|
||||
const matchedModules: T[] = [];
|
||||
for (const module of this.modules) {
|
||||
if (module instanceof constructor) matchedModules.push(module);
|
||||
}
|
||||
return matchedModules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a module to the plug-in.
|
||||
* @param module The module to register.
|
||||
*/
|
||||
private _registerModule(module: IObsidianModule) {
|
||||
this.modules.push(module);
|
||||
}
|
||||
private registerModules() {
|
||||
this._registerModule(new ModuleLiveSyncMain(this));
|
||||
// Only on Obsidian
|
||||
this._registerModule(new ModuleDatabaseFileAccess(this));
|
||||
// Common
|
||||
this._registerModule(new ModuleConflictChecker(this));
|
||||
this._registerModule(new ModuleReplicatorMinIO(this));
|
||||
this._registerModule(new ModuleReplicatorCouchDB(this));
|
||||
this._registerModule(new ModuleReplicator(this));
|
||||
this._registerModule(new ModuleFileHandler(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
this._registerModule(new ModuleRemoteGovernor(this));
|
||||
this._registerModule(new ModuleTargetFilter(this));
|
||||
this._registerModule(new ModulePeriodicProcess(this));
|
||||
// Essential Modules
|
||||
this._registerModule(new ModuleInitializerFile(this));
|
||||
this._registerModule(new ModuleObsidianAPI(this, this));
|
||||
// this._registerModule(new ModuleInitializerFile(this));
|
||||
this._registerModule(new ModuleObsidianEvents(this, this));
|
||||
this._registerModule(new ModuleFileAccessObsidian(this, this));
|
||||
this._registerModule(new ModuleObsidianSettings(this));
|
||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||
this._registerModule(new ModuleObsidianSettingsAsMarkdown(this));
|
||||
this._registerModule(new ModuleObsidianSettingDialogue(this, this));
|
||||
this._registerModule(new ModuleLog(this, this));
|
||||
this._registerModule(new ModuleObsidianMenu(this));
|
||||
this._registerModule(new ModuleRebuilder(this));
|
||||
this._registerModule(new ModuleSetupObsidian(this));
|
||||
this._registerModule(new ModuleObsidianDocumentHistory(this, this));
|
||||
this._registerModule(new ModuleMigration(this));
|
||||
this._registerModule(new ModuleRedFlag(this));
|
||||
// this._registerModule(new ModuleRedFlag(this));
|
||||
this._registerModule(new ModuleInteractiveConflictResolver(this, this));
|
||||
this._registerModule(new ModuleObsidianGlobalHistory(this, this));
|
||||
this._registerModule(new ModuleCheckRemoteSize(this));
|
||||
// this._registerModule(new ModuleCheckRemoteSize(this));
|
||||
// Test and Dev Modules
|
||||
this._registerModule(new ModuleDev(this, this));
|
||||
this._registerModule(new ModuleReplicateTest(this, this));
|
||||
@@ -172,96 +188,266 @@ export default class ObsidianLiveSyncPlugin
|
||||
this._registerModule(new SetupManager(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind module functions to services.
|
||||
*/
|
||||
private bindModuleFunctions() {
|
||||
for (const module of this.modules) {
|
||||
if (module instanceof AbstractModule) {
|
||||
module.onBindFunction(this, this.services);
|
||||
__$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not.
|
||||
} else {
|
||||
this.services.API.addLog(
|
||||
`Module ${module.constructor.name} does not have onBindFunction, skipping binding.`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.UI.confirm instead. The confirm function to show a confirmation dialog to the user.
|
||||
*/
|
||||
get confirm(): Confirm {
|
||||
return this.services.UI.confirm;
|
||||
}
|
||||
|
||||
// This property will be changed from outside often, so will be set later.
|
||||
settings!: ObsidianLiveSyncSettings;
|
||||
/**
|
||||
* @obsolete Use services.setting.currentSettings instead. The current settings of the plug-in.
|
||||
*/
|
||||
get settings() {
|
||||
return this.services.setting.settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.setting.settings instead. Set the settings of the plug-in.
|
||||
*/
|
||||
set settings(value: ObsidianLiveSyncSettings) {
|
||||
this.services.setting.settings = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.setting.currentSettings instead. Get the settings of the plug-in.
|
||||
* @returns The current settings of the plug-in.
|
||||
*/
|
||||
getSettings(): ObsidianLiveSyncSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.database.localDatabase instead. The local database instance.
|
||||
*/
|
||||
get localDatabase() {
|
||||
return this.services.database.localDatabase;
|
||||
}
|
||||
|
||||
get managers() {
|
||||
return this.services.database.managers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.database.localDatabase instead. Get the PouchDB database instance. Note that this is not the same as the local database instance, which is a wrapper around the PouchDB database.
|
||||
* @returns The PouchDB database instance.
|
||||
*/
|
||||
getDatabase(): PouchDB.Database<EntryDoc> {
|
||||
return this.localDatabase.localDatabase;
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.keyValueDB.simpleStore instead. A simple key-value store for storing non-file data, such as checkpoints, sync status, etc.
|
||||
*/
|
||||
get simpleStore() {
|
||||
return this.services.keyValueDB.simpleStore as SimpleStore<CheckPointInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.replication.getActiveReplicator instead. Get the active replicator instance. Note that there can be multiple replicators, but only one can be active at a time.
|
||||
*/
|
||||
get replicator() {
|
||||
return this.services.replicator.getActiveReplicator()!;
|
||||
}
|
||||
|
||||
// initialised at ModuleFileAccessObsidian
|
||||
storageAccess!: StorageAccess;
|
||||
// initialised at ModuleDatabaseFileAccess
|
||||
databaseFileAccess!: DatabaseFileAccess;
|
||||
// initialised at ModuleFileHandler
|
||||
fileHandler!: ModuleFileHandler;
|
||||
// initialised at ModuleRebuilder
|
||||
rebuilder!: Rebuilder;
|
||||
|
||||
/**
|
||||
* @obsolete Use services.keyValueDB.kvDB instead. Get the key-value database instance. This is used for storing large data that cannot be stored in the simple store, such as file metadata, etc.
|
||||
*/
|
||||
get kvDB() {
|
||||
return this.services.keyValueDB.kvDB;
|
||||
}
|
||||
|
||||
requestCount = reactiveSource(0);
|
||||
responseCount = reactiveSource(0);
|
||||
totalQueued = reactiveSource(0);
|
||||
batched = reactiveSource(0);
|
||||
processing = reactiveSource(0);
|
||||
databaseQueueCount = reactiveSource(0);
|
||||
storageApplyingCount = reactiveSource(0);
|
||||
replicationResultCount = reactiveSource(0);
|
||||
conflictProcessQueueCount = reactiveSource(0);
|
||||
pendingFileEventCount = reactiveSource(0);
|
||||
processingFileEventCount = reactiveSource(0);
|
||||
|
||||
_totalProcessingCount?: ReactiveValue<number>;
|
||||
|
||||
replicationStat = reactiveSource({
|
||||
sent: 0,
|
||||
arrived: 0,
|
||||
maxPullSeq: 0,
|
||||
maxPushSeq: 0,
|
||||
lastSyncPullSeq: 0,
|
||||
lastSyncPushSeq: 0,
|
||||
syncStatus: "CLOSED" as DatabaseConnectingStatus,
|
||||
});
|
||||
|
||||
constructor(app: App, manifest: PluginManifest) {
|
||||
super(app, manifest);
|
||||
this.initialiseServices();
|
||||
this.registerModules();
|
||||
this.registerAddOns();
|
||||
/// Modules which were relied on services
|
||||
/**
|
||||
* Storage Accessor for handling file operations.
|
||||
* @obsolete Use serviceModules.storageAccess instead.
|
||||
*/
|
||||
get storageAccess(): StorageAccess {
|
||||
return this.serviceModules.storageAccess;
|
||||
}
|
||||
/**
|
||||
* Database File Accessor for handling file operations related to the database, such as exporting the database, importing from a file, etc.
|
||||
* @obsolete Use serviceModules.databaseFileAccess instead.
|
||||
*/
|
||||
get databaseFileAccess(): DatabaseFileAccess {
|
||||
return this.serviceModules.databaseFileAccess;
|
||||
}
|
||||
/**
|
||||
* File Handler for handling file operations related to replication, such as resolving conflicts, applying changes from replication, etc.
|
||||
* @obsolete Use serviceModules.fileHandler instead.
|
||||
*/
|
||||
get fileHandler(): IFileHandler {
|
||||
return this.serviceModules.fileHandler;
|
||||
}
|
||||
/**
|
||||
* Rebuilder for handling database rebuilding operations.
|
||||
* @obsolete Use serviceModules.rebuilder instead.
|
||||
*/
|
||||
get rebuilder(): Rebuilder {
|
||||
return this.serviceModules.rebuilder;
|
||||
}
|
||||
|
||||
private async _startUp() {
|
||||
await this.services.appLifecycle.onLoad();
|
||||
const onReady = this.services.appLifecycle.onReady.bind(this.services.appLifecycle);
|
||||
this.app.workspace.onLayoutReady(onReady);
|
||||
// requestCount = reactiveSource(0);
|
||||
// responseCount = reactiveSource(0);
|
||||
// totalQueued = reactiveSource(0);
|
||||
// batched = reactiveSource(0);
|
||||
// processing = reactiveSource(0);
|
||||
// databaseQueueCount = reactiveSource(0);
|
||||
// storageApplyingCount = reactiveSource(0);
|
||||
// replicationResultCount = reactiveSource(0);
|
||||
|
||||
// pendingFileEventCount = reactiveSource(0);
|
||||
// processingFileEventCount = reactiveSource(0);
|
||||
|
||||
// _totalProcessingCount?: ReactiveValue<number>;
|
||||
|
||||
// replicationStat = reactiveSource({
|
||||
// sent: 0,
|
||||
// arrived: 0,
|
||||
// maxPullSeq: 0,
|
||||
// maxPushSeq: 0,
|
||||
// lastSyncPullSeq: 0,
|
||||
// lastSyncPushSeq: 0,
|
||||
// syncStatus: "CLOSED" as DatabaseConnectingStatus,
|
||||
// });
|
||||
|
||||
private initialiseServices() {
|
||||
this._services = new ObsidianServiceHub(this);
|
||||
}
|
||||
onload() {
|
||||
void this._startUp();
|
||||
/**
|
||||
* Initialise service modules.
|
||||
*/
|
||||
private initialiseServiceModules() {
|
||||
const storageAccessManager = new StorageAccessManager();
|
||||
// If we want to implement to the other platform, implement ObsidianXXXXXService.
|
||||
const vaultAccess = new FileAccessObsidian(this.app, {
|
||||
storageAccessManager: storageAccessManager,
|
||||
vaultService: this.services.vault,
|
||||
settingService: this.services.setting,
|
||||
APIService: this.services.API,
|
||||
pathService: this.services.path,
|
||||
});
|
||||
const storageEventManager = new StorageEventManagerObsidian(this, this, {
|
||||
fileProcessing: this.services.fileProcessing,
|
||||
setting: this.services.setting,
|
||||
vaultService: this.services.vault,
|
||||
storageAccessManager: storageAccessManager,
|
||||
APIService: this.services.API,
|
||||
});
|
||||
const storageAccess = new ServiceFileAccessObsidian({
|
||||
API: this.services.API,
|
||||
setting: this.services.setting,
|
||||
fileProcessing: this.services.fileProcessing,
|
||||
vault: this.services.vault,
|
||||
appLifecycle: this.services.appLifecycle,
|
||||
storageEventManager: storageEventManager,
|
||||
storageAccessManager: storageAccessManager,
|
||||
vaultAccess: vaultAccess,
|
||||
});
|
||||
|
||||
const databaseFileAccess = new ServiceDatabaseFileAccess({
|
||||
API: this.services.API,
|
||||
database: this.services.database,
|
||||
path: this.services.path,
|
||||
storageAccess: storageAccess,
|
||||
vault: this.services.vault,
|
||||
});
|
||||
|
||||
const fileHandler = new ServiceFileHandler({
|
||||
API: this.services.API,
|
||||
databaseFileAccess: databaseFileAccess,
|
||||
conflict: this.services.conflict,
|
||||
setting: this.services.setting,
|
||||
fileProcessing: this.services.fileProcessing,
|
||||
vault: this.services.vault,
|
||||
path: this.services.path,
|
||||
replication: this.services.replication,
|
||||
storageAccess: storageAccess,
|
||||
});
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
API: this.services.API,
|
||||
database: this.services.database,
|
||||
appLifecycle: this.services.appLifecycle,
|
||||
setting: this.services.setting,
|
||||
remote: this.services.remote,
|
||||
databaseEvents: this.services.databaseEvents,
|
||||
replication: this.services.replication,
|
||||
replicator: this.services.replicator,
|
||||
UI: this.services.UI,
|
||||
vault: this.services.vault,
|
||||
fileHandler: fileHandler,
|
||||
storageAccess: storageAccess,
|
||||
control: this.services.control,
|
||||
});
|
||||
return {
|
||||
rebuilder,
|
||||
fileHandler,
|
||||
databaseFileAccess,
|
||||
storageAccess,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @obsolete Use services.setting.saveSettingData instead. Save the settings to the disk. This is usually called after changing the settings in the code, to persist the changes.
|
||||
*/
|
||||
async saveSettings() {
|
||||
await this.services.setting.saveSettingData();
|
||||
}
|
||||
onunload() {
|
||||
return void this.services.appLifecycle.onAppUnload();
|
||||
|
||||
/**
|
||||
* Initialise ServiceFeatures.
|
||||
* (Please refer `serviceFeatures` for more details)
|
||||
*/
|
||||
initialiseServiceFeatures() {
|
||||
for (const feature of onLayoutReadyFeatures) {
|
||||
const curriedFeature = () => feature(this);
|
||||
this.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
|
||||
}
|
||||
useRedFlagFeatures(this);
|
||||
useOfflineScanner(this);
|
||||
|
||||
// enable target filter feature.
|
||||
useTargetFilters(this);
|
||||
useCheckRemoteSize(this);
|
||||
}
|
||||
|
||||
constructor(app: App, manifest: PluginManifest) {
|
||||
super(app, manifest);
|
||||
// Maybe no more need to setNoticeClass, but for safety, set it in the constructor of the main plugin class.
|
||||
// TODO: remove this.
|
||||
setNoticeClass(Notice);
|
||||
this.initialiseServices();
|
||||
this.registerModules();
|
||||
this.registerAddOns();
|
||||
this._serviceModules = this.initialiseServiceModules();
|
||||
this.initialiseServiceFeatures();
|
||||
this.bindModuleFunctions();
|
||||
}
|
||||
|
||||
private async _startUp() {
|
||||
if (!(await this.services.control.onLoad())) return;
|
||||
const onReady = this.services.control.onReady.bind(this.services.control);
|
||||
this.app.workspace.onLayoutReady(onReady);
|
||||
}
|
||||
override onload() {
|
||||
void this._startUp();
|
||||
}
|
||||
override onunload() {
|
||||
return void this.services.control.onUnload();
|
||||
}
|
||||
// <-- Plug-in's overrideable functions
|
||||
}
|
||||
|
||||
// For now,
|
||||
|
||||
137
src/managers/ObsidianStorageEventManagerAdapter.ts
Normal file
137
src/managers/ObsidianStorageEventManagerAdapter.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { TFile, TFolder } from "@/deps";
|
||||
import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@lib/common/types";
|
||||
import type { FileEventItem } from "@lib/common/types";
|
||||
import type { IStorageEventManagerAdapter } from "@lib/managers/adapters";
|
||||
import type {
|
||||
IStorageEventTypeGuardAdapter,
|
||||
IStorageEventPersistenceAdapter,
|
||||
IStorageEventWatchAdapter,
|
||||
IStorageEventStatusAdapter,
|
||||
IStorageEventConverterAdapter,
|
||||
IStorageEventWatchHandlers,
|
||||
} from "@lib/managers/adapters";
|
||||
import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager";
|
||||
import type ObsidianLiveSyncPlugin from "@/main";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import type { FileProcessingService } from "@lib/services/base/FileProcessingService";
|
||||
import { InternalFileToUXFileInfoStub, TFileToUXFileInfoStub } from "@/modules/coreObsidian/storageLib/utilObsidian";
|
||||
|
||||
/**
|
||||
* Obsidian-specific type guard adapter
|
||||
*/
|
||||
class ObsidianTypeGuardAdapter implements IStorageEventTypeGuardAdapter<TFile, TFolder> {
|
||||
isFile(file: any): file is TFile {
|
||||
if (file instanceof TFile) {
|
||||
return true;
|
||||
}
|
||||
if (file && typeof file === "object" && "isFolder" in file) {
|
||||
return !file.isFolder;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isFolder(item: any): item is TFolder {
|
||||
if (item instanceof TFolder) {
|
||||
return true;
|
||||
}
|
||||
if (item && typeof item === "object" && "isFolder" in item) {
|
||||
return !!item.isFolder;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidian-specific persistence adapter
|
||||
*/
|
||||
class ObsidianPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||
constructor(private core: LiveSyncCore) {}
|
||||
|
||||
async saveSnapshot(snapshot: (FileEventItem | FileEventItemSentinel)[]): Promise<void> {
|
||||
await this.core.kvDB.set("storage-event-manager-snapshot", snapshot);
|
||||
}
|
||||
|
||||
async loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null> {
|
||||
const snapShot = await this.core.kvDB.get<(FileEventItem | FileEventItemSentinel)[]>(
|
||||
"storage-event-manager-snapshot"
|
||||
);
|
||||
return snapShot;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidian-specific status adapter
|
||||
*/
|
||||
class ObsidianStatusAdapter implements IStorageEventStatusAdapter {
|
||||
constructor(private fileProcessing: FileProcessingService) {}
|
||||
|
||||
updateStatus(status: { batched: number; processing: number; totalQueued: number }): void {
|
||||
this.fileProcessing.batched.value = status.batched;
|
||||
this.fileProcessing.processing.value = status.processing;
|
||||
this.fileProcessing.totalQueued.value = status.totalQueued;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidian-specific converter adapter
|
||||
*/
|
||||
class ObsidianConverterAdapter implements IStorageEventConverterAdapter<TFile> {
|
||||
toFileInfo(file: TFile, deleted?: boolean): UXFileInfoStub {
|
||||
return TFileToUXFileInfoStub(file, deleted);
|
||||
}
|
||||
|
||||
toInternalFileInfo(path: FilePath): UXInternalFileInfoStub {
|
||||
return InternalFileToUXFileInfoStub(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidian-specific watch adapter
|
||||
*/
|
||||
class ObsidianWatchAdapter implements IStorageEventWatchAdapter {
|
||||
constructor(private plugin: ObsidianLiveSyncPlugin) {}
|
||||
|
||||
beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
||||
const plugin = this.plugin;
|
||||
|
||||
const boundHandlers = {
|
||||
onCreate: handlers.onCreate.bind(handlers),
|
||||
onChange: handlers.onChange.bind(handlers),
|
||||
onDelete: handlers.onDelete.bind(handlers),
|
||||
onRename: handlers.onRename.bind(handlers),
|
||||
onRaw: handlers.onRaw.bind(handlers),
|
||||
onEditorChange: handlers.onEditorChange?.bind(handlers),
|
||||
};
|
||||
|
||||
plugin.registerEvent(plugin.app.vault.on("create", boundHandlers.onCreate));
|
||||
plugin.registerEvent(plugin.app.vault.on("modify", boundHandlers.onChange));
|
||||
plugin.registerEvent(plugin.app.vault.on("delete", boundHandlers.onDelete));
|
||||
plugin.registerEvent(plugin.app.vault.on("rename", boundHandlers.onRename));
|
||||
//@ts-ignore : Internal API
|
||||
plugin.registerEvent(plugin.app.vault.on("raw", boundHandlers.onRaw));
|
||||
if (boundHandlers.onEditorChange) {
|
||||
plugin.registerEvent(plugin.app.workspace.on("editor-change", boundHandlers.onEditorChange));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite adapter for Obsidian StorageEventManager
|
||||
*/
|
||||
export class ObsidianStorageEventManagerAdapter implements IStorageEventManagerAdapter<TFile, TFolder> {
|
||||
readonly typeGuard: ObsidianTypeGuardAdapter;
|
||||
readonly persistence: ObsidianPersistenceAdapter;
|
||||
readonly watch: ObsidianWatchAdapter;
|
||||
readonly status: ObsidianStatusAdapter;
|
||||
readonly converter: ObsidianConverterAdapter;
|
||||
|
||||
constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore, fileProcessing: FileProcessingService) {
|
||||
this.typeGuard = new ObsidianTypeGuardAdapter();
|
||||
this.persistence = new ObsidianPersistenceAdapter(core);
|
||||
this.watch = new ObsidianWatchAdapter(plugin);
|
||||
this.status = new ObsidianStatusAdapter(fileProcessing);
|
||||
this.converter = new ObsidianConverterAdapter();
|
||||
}
|
||||
}
|
||||
48
src/managers/StorageEventManagerObsidian.ts
Normal file
48
src/managers/StorageEventManagerObsidian.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync";
|
||||
import type { FilePath } from "@lib/common/types";
|
||||
import type ObsidianLiveSyncPlugin from "@/main";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } from "@lib/managers/StorageEventManager";
|
||||
import { ObsidianStorageEventManagerAdapter } from "./ObsidianStorageEventManagerAdapter";
|
||||
|
||||
export class StorageEventManagerObsidian extends StorageEventManagerBase<ObsidianStorageEventManagerAdapter> {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
core: LiveSyncCore;
|
||||
|
||||
// Necessary evil.
|
||||
cmdHiddenFileSync: HiddenFileSync;
|
||||
|
||||
constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore, dependencies: StorageEventManagerBaseDependencies) {
|
||||
const adapter = new ObsidianStorageEventManagerAdapter(plugin, core, dependencies.fileProcessing);
|
||||
super(adapter, dependencies);
|
||||
this.plugin = plugin;
|
||||
this.core = core;
|
||||
this.cmdHiddenFileSync = this.plugin.getAddOn(HiddenFileSync.name) as HiddenFileSync;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override _watchVaultRawEvents to add Obsidian-specific logic
|
||||
*/
|
||||
protected override async _watchVaultRawEvents(path: FilePath) {
|
||||
if (!this.settings.syncInternalFiles && !this.settings.usePluginSync) return;
|
||||
if (!this.settings.watchInternalFileChanges) return;
|
||||
if (!path.startsWith(this.plugin.app.vault.configDir)) return;
|
||||
if (path.endsWith("/")) {
|
||||
// Folder
|
||||
return;
|
||||
}
|
||||
const isTargetFile = await this.cmdHiddenFileSync.isTargetFile(path);
|
||||
if (!isTargetFile) return;
|
||||
|
||||
void this.appendQueue(
|
||||
[
|
||||
{
|
||||
type: "INTERNAL",
|
||||
file: this.adapter.converter.toInternalFileInfo(path),
|
||||
skipBatchWait: true, // Internal files should be processed immediately.
|
||||
},
|
||||
],
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
||||
import type { AnyEntry, FilePathWithPrefix } from "@lib/common/types";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import { __$checkInstanceBinding } from "@lib/dev/checks";
|
||||
import { stripAllPrefixes } from "@lib/string_and_binary/path";
|
||||
import { createInstanceLogFunction } from "@/lib/src/services/lib/logUtils";
|
||||
import { createInstanceLogFunction } from "@lib/services/lib/logUtils";
|
||||
|
||||
export abstract class AbstractModule {
|
||||
_log = createInstanceLogFunction(this.constructor.name, this.services.API);
|
||||
@@ -41,9 +40,7 @@ export abstract class AbstractModule {
|
||||
// Override if needed.
|
||||
}
|
||||
constructor(public core: LiveSyncCore) {
|
||||
this.onBindFunction(core, core.services);
|
||||
Logger(`[${this.constructor.name}] Loaded`, LOG_LEVEL_VERBOSE);
|
||||
__$checkInstanceBinding(this);
|
||||
}
|
||||
saveSettings = this.core.saveSettings.bind(this.core);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export abstract class AbstractObsidianModule extends AbstractModule {
|
||||
|
||||
constructor(
|
||||
public plugin: ObsidianLiveSyncPlugin,
|
||||
public core: LiveSyncCore
|
||||
core: LiveSyncCore
|
||||
) {
|
||||
super(core);
|
||||
}
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { EVENT_FILE_SAVED, eventHub } from "../../common/events";
|
||||
import {
|
||||
getDatabasePathFromUXFileInfo,
|
||||
getStoragePathFromUXFileInfo,
|
||||
isInternalMetadata,
|
||||
markChangesAreSame,
|
||||
} from "../../common/utils";
|
||||
import type {
|
||||
UXFileInfoStub,
|
||||
FilePathWithPrefix,
|
||||
UXFileInfo,
|
||||
MetaEntry,
|
||||
LoadedEntry,
|
||||
FilePath,
|
||||
SavingEntry,
|
||||
DocumentID,
|
||||
} from "../../lib/src/common/types";
|
||||
import type { DatabaseFileAccess } from "../interfaces/DatabaseFileAccess";
|
||||
import { isPlainText, shouldBeIgnored, stripAllPrefixes } from "../../lib/src/string_and_binary/path";
|
||||
import {
|
||||
createBlob,
|
||||
createTextBlob,
|
||||
delay,
|
||||
determineTypeFromBlob,
|
||||
isDocContentSame,
|
||||
readContent,
|
||||
} from "../../lib/src/common/utils";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { ICHeader } from "../../common/types.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
|
||||
export class ModuleDatabaseFileAccess extends AbstractModule implements DatabaseFileAccess {
|
||||
private _everyOnload(): Promise<boolean> {
|
||||
this.core.databaseFileAccess = this;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
private async _everyModuleTest(): Promise<boolean> {
|
||||
if (!this.settings.enableDebugTools) return Promise.resolve(true);
|
||||
const testString = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam nec purus nec nunc";
|
||||
// Before test, we need to delete completely.
|
||||
const conflicts = await this.getConflictedRevs("autoTest.md" as FilePathWithPrefix);
|
||||
for (const rev of conflicts) {
|
||||
await this.delete("autoTest.md" as FilePathWithPrefix, rev);
|
||||
}
|
||||
await this.delete("autoTest.md" as FilePathWithPrefix);
|
||||
// OK, begin!
|
||||
|
||||
await this._test(
|
||||
"storeContent",
|
||||
async () => await this.storeContent("autoTest.md" as FilePathWithPrefix, testString)
|
||||
);
|
||||
// For test, we need to clear the caches.
|
||||
this.localDatabase.clearCaches();
|
||||
await this._test("readContent", async () => {
|
||||
const content = await this.fetch("autoTest.md" as FilePathWithPrefix);
|
||||
if (!content) return "File not found";
|
||||
if (content.deleted) return "File is deleted";
|
||||
return (await content.body.text()) == testString
|
||||
? true
|
||||
: `Content is not same ${await content.body.text()}`;
|
||||
});
|
||||
await this._test("delete", async () => await this.delete("autoTest.md" as FilePathWithPrefix));
|
||||
await this._test("read deleted content", async () => {
|
||||
const content = await this.fetch("autoTest.md" as FilePathWithPrefix);
|
||||
if (!content) return true;
|
||||
if (content.deleted) return true;
|
||||
return `Still exist !:${await content.body.text()},${JSON.stringify(content, undefined, 2)}`;
|
||||
});
|
||||
await delay(100);
|
||||
return this.testDone();
|
||||
}
|
||||
|
||||
async checkIsTargetFile(file: UXFileInfoStub | FilePathWithPrefix): Promise<boolean> {
|
||||
const path = getStoragePathFromUXFileInfo(file);
|
||||
if (!(await this.services.vault.isTargetFile(path))) {
|
||||
this._log(`File is not target: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (shouldBeIgnored(path)) {
|
||||
this._log(`File should be ignored: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async delete(file: UXFileInfoStub | FilePathWithPrefix, rev?: string): Promise<boolean> {
|
||||
if (!(await this.checkIsTargetFile(file))) {
|
||||
return true;
|
||||
}
|
||||
const fullPath = getDatabasePathFromUXFileInfo(file);
|
||||
try {
|
||||
this._log(`deleteDB By path:${fullPath}`);
|
||||
return await this.deleteFromDBbyPath(fullPath, rev);
|
||||
} catch (ex) {
|
||||
this._log(`Failed to delete ${fullPath}`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async createChunks(file: UXFileInfo, force: boolean = false, skipCheck?: boolean): Promise<boolean> {
|
||||
return await this.__store(file, force, skipCheck, true);
|
||||
}
|
||||
|
||||
async store(file: UXFileInfo, force: boolean = false, skipCheck?: boolean): Promise<boolean> {
|
||||
return await this.__store(file, force, skipCheck, false);
|
||||
}
|
||||
async storeContent(path: FilePathWithPrefix, content: string): Promise<boolean> {
|
||||
const blob = createTextBlob(content);
|
||||
const bytes = (await blob.arrayBuffer()).byteLength;
|
||||
const isInternal = path.startsWith(".") ? true : undefined;
|
||||
const dummyUXFileInfo: UXFileInfo = {
|
||||
name: path.split("/").pop() as string,
|
||||
path: path,
|
||||
stat: {
|
||||
size: bytes,
|
||||
ctime: Date.now(),
|
||||
mtime: Date.now(),
|
||||
type: "file",
|
||||
},
|
||||
body: blob,
|
||||
isInternal,
|
||||
};
|
||||
return await this.__store(dummyUXFileInfo, true, false, false);
|
||||
}
|
||||
|
||||
private async __store(
|
||||
file: UXFileInfo,
|
||||
force: boolean = false,
|
||||
skipCheck?: boolean,
|
||||
onlyChunks?: boolean
|
||||
): Promise<boolean> {
|
||||
if (!skipCheck) {
|
||||
if (!(await this.checkIsTargetFile(file))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!file) {
|
||||
this._log("File seems bad", LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
// const path = getPathFromUXFileInfo(file);
|
||||
const isPlain = isPlainText(file.name);
|
||||
const possiblyLarge = !isPlain;
|
||||
const content = file.body;
|
||||
|
||||
const datatype = determineTypeFromBlob(content);
|
||||
const idPrefix = file.isInternal ? ICHeader : "";
|
||||
const fullPath = getStoragePathFromUXFileInfo(file);
|
||||
const fullPathOnDB = getDatabasePathFromUXFileInfo(file);
|
||||
|
||||
if (possiblyLarge) this._log(`Processing: ${fullPath}`, LOG_LEVEL_VERBOSE);
|
||||
|
||||
// if (isInternalMetadata(fullPath)) {
|
||||
// this._log(`Internal file: ${fullPath}`, LOG_LEVEL_VERBOSE);
|
||||
// return false;
|
||||
// }
|
||||
if (file.isInternal) {
|
||||
if (file.deleted) {
|
||||
file.stat = {
|
||||
size: 0,
|
||||
ctime: Date.now(),
|
||||
mtime: Date.now(),
|
||||
type: "file",
|
||||
};
|
||||
} else if (file.stat == undefined) {
|
||||
const stat = await this.core.storageAccess.statHidden(file.path);
|
||||
if (!stat) {
|
||||
// We stored actually deleted or not since here, so this is an unexpected case. we should raise an error.
|
||||
this._log(`Internal file not found: ${fullPath}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
file.stat = stat;
|
||||
}
|
||||
}
|
||||
|
||||
const idMain = await this.services.path.path2id(fullPath);
|
||||
|
||||
const id = (idPrefix + idMain) as DocumentID;
|
||||
const d: SavingEntry = {
|
||||
_id: id,
|
||||
path: fullPathOnDB,
|
||||
data: content,
|
||||
ctime: file.stat.ctime,
|
||||
mtime: file.stat.mtime,
|
||||
size: file.stat.size,
|
||||
children: [],
|
||||
datatype: datatype,
|
||||
type: datatype,
|
||||
eden: {},
|
||||
};
|
||||
//upsert should locked
|
||||
const msg = `STORAGE -> DB (${datatype}) `;
|
||||
const isNotChanged = await serialized("file-" + fullPath, async () => {
|
||||
if (force) {
|
||||
this._log(msg + "Force writing " + fullPath, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
// Commented out temporarily: this checks that the file was made ourself.
|
||||
// if (this.core.storageAccess.recentlyTouched(file)) {
|
||||
// return true;
|
||||
// }
|
||||
try {
|
||||
const old = await this.localDatabase.getDBEntry(d.path, undefined, false, true, false);
|
||||
if (old !== false) {
|
||||
const oldData = { data: old.data, deleted: old._deleted || old.deleted };
|
||||
const newData = { data: d.data, deleted: d._deleted || d.deleted };
|
||||
if (oldData.deleted != newData.deleted) return false;
|
||||
if (!(await isDocContentSame(old.data, newData.data))) return false;
|
||||
this._log(
|
||||
msg + "Skipped (not changed) " + fullPath + (d._deleted || d.deleted ? " (deleted)" : ""),
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
markChangesAreSame(old, d.mtime, old.mtime);
|
||||
return true;
|
||||
// d._rev = old._rev;
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log(
|
||||
msg +
|
||||
"Error, Could not check the diff for the old one." +
|
||||
(force ? "force writing." : "") +
|
||||
fullPath +
|
||||
(d._deleted || d.deleted ? " (deleted)" : ""),
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return !force;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (isNotChanged) {
|
||||
this._log(msg + " Skip " + fullPath, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(d, onlyChunks);
|
||||
if (ret !== false) {
|
||||
this._log(msg + fullPath);
|
||||
eventHub.emitEvent(EVENT_FILE_SAVED);
|
||||
}
|
||||
return ret != false;
|
||||
}
|
||||
|
||||
async getConflictedRevs(file: UXFileInfoStub | FilePathWithPrefix): Promise<string[]> {
|
||||
if (!(await this.checkIsTargetFile(file))) {
|
||||
return [];
|
||||
}
|
||||
const filename = getDatabasePathFromUXFileInfo(file);
|
||||
const doc = await this.localDatabase.getDBEntryMeta(filename, { conflicts: true }, true);
|
||||
if (doc === false) {
|
||||
return [];
|
||||
}
|
||||
return doc._conflicts || [];
|
||||
}
|
||||
|
||||
async fetch(
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
waitForReady?: boolean,
|
||||
skipCheck = false
|
||||
): Promise<UXFileInfo | false> {
|
||||
if (skipCheck && !(await this.checkIsTargetFile(file))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry = await this.fetchEntry(file, rev, waitForReady, true);
|
||||
if (entry === false) {
|
||||
return false;
|
||||
}
|
||||
const data = createBlob(readContent(entry));
|
||||
const path = stripAllPrefixes(entry.path);
|
||||
const fileInfo: UXFileInfo = {
|
||||
name: path.split("/").pop() as string,
|
||||
path: path,
|
||||
stat: {
|
||||
size: entry.size,
|
||||
ctime: entry.ctime,
|
||||
mtime: entry.mtime,
|
||||
type: "file",
|
||||
},
|
||||
body: data,
|
||||
deleted: entry.deleted || entry._deleted,
|
||||
};
|
||||
if (isInternalMetadata(entry.path)) {
|
||||
fileInfo.isInternal = true;
|
||||
}
|
||||
return fileInfo;
|
||||
}
|
||||
async fetchEntryMeta(
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
skipCheck = false
|
||||
): Promise<MetaEntry | false> {
|
||||
const dbFileName = getDatabasePathFromUXFileInfo(file);
|
||||
if (skipCheck && !(await this.checkIsTargetFile(file))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const doc = await this.localDatabase.getDBEntryMeta(dbFileName, rev ? { rev: rev } : undefined, true);
|
||||
if (doc === false) {
|
||||
return false;
|
||||
}
|
||||
return doc as MetaEntry;
|
||||
}
|
||||
async fetchEntryFromMeta(
|
||||
meta: MetaEntry,
|
||||
waitForReady: boolean = true,
|
||||
skipCheck = false
|
||||
): Promise<LoadedEntry | false> {
|
||||
if (skipCheck && !(await this.checkIsTargetFile(meta.path))) {
|
||||
return false;
|
||||
}
|
||||
const doc = await this.localDatabase.getDBEntryFromMeta(meta as LoadedEntry, false, waitForReady);
|
||||
if (doc === false) {
|
||||
return false;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
async fetchEntry(
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
waitForReady: boolean = true,
|
||||
skipCheck = false
|
||||
): Promise<LoadedEntry | false> {
|
||||
if (skipCheck && !(await this.checkIsTargetFile(file))) {
|
||||
return false;
|
||||
}
|
||||
const entry = await this.fetchEntryMeta(file, rev, true);
|
||||
if (entry === false) {
|
||||
return false;
|
||||
}
|
||||
const doc = await this.fetchEntryFromMeta(entry, waitForReady, true);
|
||||
return doc;
|
||||
}
|
||||
async deleteFromDBbyPath(fullPath: FilePath | FilePathWithPrefix, rev?: string): Promise<boolean> {
|
||||
if (!(await this.checkIsTargetFile(fullPath))) {
|
||||
this._log(`storeFromStorage: File is not target: ${fullPath}`);
|
||||
return true;
|
||||
}
|
||||
const opt = rev ? { rev: rev } : undefined;
|
||||
const ret = await this.localDatabase.deleteDBEntry(fullPath, opt);
|
||||
eventHub.emitEvent(EVENT_FILE_SAVED);
|
||||
return ret;
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
||||
services.test.test.addHandler(this._everyModuleTest.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import type { FileEventItem } from "../../common/types";
|
||||
import type {
|
||||
FilePath,
|
||||
FilePathWithPrefix,
|
||||
MetaEntry,
|
||||
UXFileInfo,
|
||||
UXFileInfoStub,
|
||||
UXInternalFileInfoStub,
|
||||
} from "../../lib/src/common/types";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { compareFileFreshness, EVEN, getStoragePathFromUXFileInfo, markChangesAreSame } from "../../common/utils";
|
||||
import { getDocDataAsArray, isDocContentSame, readAsBlob, readContent } from "../../lib/src/common/utils";
|
||||
import { shouldBeIgnored } from "../../lib/src/string_and_binary/path";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import { eventHub } from "../../common/events.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
|
||||
export class ModuleFileHandler extends AbstractModule {
|
||||
get db() {
|
||||
return this.core.databaseFileAccess;
|
||||
}
|
||||
get storage() {
|
||||
return this.core.storageAccess;
|
||||
}
|
||||
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.core.fileHandler = this;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async readFileFromStub(file: UXFileInfoStub | UXFileInfo) {
|
||||
if ("body" in file && file.body) {
|
||||
return file;
|
||||
}
|
||||
const readFile = await this.storage.readStubContent(file);
|
||||
if (!readFile) {
|
||||
throw new Error(`File ${file.path} is not exist on the storage`);
|
||||
}
|
||||
return readFile;
|
||||
}
|
||||
|
||||
async storeFileToDB(
|
||||
info: UXFileInfoStub | UXFileInfo | UXInternalFileInfoStub | FilePathWithPrefix,
|
||||
force: boolean = false,
|
||||
onlyChunks: boolean = false
|
||||
): Promise<boolean> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
if (file == null) {
|
||||
this._log(`File ${info} is not exist on the storage`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
// const file = item.args.file;
|
||||
if (file.isInternal) {
|
||||
this._log(
|
||||
`Internal file ${file.path} is not allowed to be processed on processFileEvent`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// First, check the file on the database
|
||||
const entry = await this.db.fetchEntry(file, undefined, true, true);
|
||||
|
||||
if (!entry || entry.deleted || entry._deleted) {
|
||||
// If the file is not exist on the database, then it should be created.
|
||||
const readFile = await this.readFileFromStub(file);
|
||||
if (!onlyChunks) {
|
||||
return await this.db.store(readFile);
|
||||
} else {
|
||||
return await this.db.createChunks(readFile, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
// entry is exist on the database, check the difference between the file and the entry.
|
||||
|
||||
let shouldApplied = false;
|
||||
if (!force && !onlyChunks) {
|
||||
// 1. if the time stamp is far different, then it should be updated.
|
||||
// Note: This checks only the mtime with the resolution reduced to 2 seconds.
|
||||
// 2 seconds it for the ZIP file's mtime. If not, we cannot backup the vault as the ZIP file.
|
||||
// This is hardcoded on `compareMtime` of `src/common/utils.ts`.
|
||||
if (compareFileFreshness(file, entry) !== EVEN) {
|
||||
shouldApplied = true;
|
||||
}
|
||||
// 2. if not, the content should be checked.
|
||||
let readFile: UXFileInfo | undefined = undefined;
|
||||
if (!shouldApplied) {
|
||||
readFile = await this.readFileFromStub(file);
|
||||
if (!readFile) {
|
||||
this._log(`File ${file.path} is not exist on the storage`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (await isDocContentSame(getDocDataAsArray(entry.data), readFile.body)) {
|
||||
// Timestamp is different but the content is same. therefore, two timestamps should be handled as same.
|
||||
// So, mark the changes are same.
|
||||
markChangesAreSame(readFile, readFile.stat.mtime, entry.mtime);
|
||||
} else {
|
||||
shouldApplied = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldApplied) {
|
||||
this._log(`File ${file.path} is not changed`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
if (!readFile) readFile = await this.readFileFromStub(file);
|
||||
// If the file is changed, then the file should be stored.
|
||||
if (onlyChunks) {
|
||||
return await this.db.createChunks(readFile, false, true);
|
||||
} else {
|
||||
return await this.db.store(readFile, false, true);
|
||||
}
|
||||
} else {
|
||||
// If force is true, then it should be updated.
|
||||
const readFile = await this.readFileFromStub(file);
|
||||
if (onlyChunks) {
|
||||
return await this.db.createChunks(readFile, true, true);
|
||||
} else {
|
||||
return await this.db.store(readFile, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFileFromDB(info: UXFileInfoStub | UXInternalFileInfoStub | FilePath): Promise<boolean> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
if (file == null) {
|
||||
this._log(`File ${info} is not exist on the storage`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
// const file = item.args.file;
|
||||
if (file.isInternal) {
|
||||
this._log(
|
||||
`Internal file ${file.path} is not allowed to be processed on processFileEvent`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// First, check the file on the database
|
||||
const entry = await this.db.fetchEntry(file, undefined, true, true);
|
||||
if (!entry || entry.deleted || entry._deleted) {
|
||||
this._log(`File ${file.path} is not exist or already deleted on the database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
// Check the file is already conflicted. if so, only the conflicted one should be deleted.
|
||||
const conflictedRevs = await this.db.getConflictedRevs(file);
|
||||
if (conflictedRevs.length > 0) {
|
||||
// If conflicted, then it should be deleted. entry._rev should be own file's rev.
|
||||
// TODO: I BELIEVED SO. BUT I NOTICED THAT I AN NOT SURE. I SHOULD CHECK THIS.
|
||||
// ANYWAY, I SHOULD DELETE THE FILE. ACTUALLY WE SIMPLY DELETED THE FILE UNTIL PREVIOUS VERSIONS.
|
||||
return await this.db.delete(file, entry._rev);
|
||||
}
|
||||
// Otherwise, the file should be deleted simply. This is the previous behaviour.
|
||||
return await this.db.delete(file);
|
||||
}
|
||||
|
||||
async deleteRevisionFromDB(
|
||||
info: UXFileInfoStub | FilePath | FilePathWithPrefix,
|
||||
rev: string
|
||||
): Promise<boolean | undefined> {
|
||||
//TODO: Possibly check the conflicting.
|
||||
return await this.db.delete(info, rev);
|
||||
}
|
||||
|
||||
async resolveConflictedByDeletingRevision(
|
||||
info: UXFileInfoStub | FilePath,
|
||||
rev: string
|
||||
): Promise<boolean | undefined> {
|
||||
const path = getStoragePathFromUXFileInfo(info);
|
||||
if (!(await this.deleteRevisionFromDB(info, rev))) {
|
||||
this._log(`Failed to delete the conflicted revision ${rev} of ${path}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (!(await this.dbToStorageWithSpecificRev(info, rev, true))) {
|
||||
this._log(`Failed to apply the resolved revision ${rev} of ${path} to the storage`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async dbToStorageWithSpecificRev(
|
||||
info: UXFileInfoStub | UXFileInfo | FilePath | null,
|
||||
rev: string,
|
||||
force?: boolean
|
||||
): Promise<boolean> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
if (file == null) {
|
||||
this._log(`File ${info} is not exist on the storage`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const docEntry = await this.db.fetchEntryMeta(file, rev, true);
|
||||
if (!docEntry) {
|
||||
this._log(`File ${file.path} is not exist on the database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
return await this.dbToStorage(docEntry, file, force);
|
||||
}
|
||||
|
||||
async dbToStorage(
|
||||
entryInfo: MetaEntry | FilePathWithPrefix,
|
||||
info: UXFileInfoStub | UXFileInfo | FilePath | null,
|
||||
force?: boolean
|
||||
): Promise<boolean> {
|
||||
const file = typeof info === "string" ? this.storage.getFileStub(info) : info;
|
||||
const mode = file == null ? "create" : "modify";
|
||||
const pathFromEntryInfo = typeof entryInfo === "string" ? entryInfo : this.getPath(entryInfo);
|
||||
const docEntry = await this.db.fetchEntryMeta(pathFromEntryInfo, undefined, true);
|
||||
if (!docEntry) {
|
||||
this._log(`File ${pathFromEntryInfo} is not exist on the database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const path = this.getPath(docEntry);
|
||||
|
||||
// 1. Check if it already conflicted.
|
||||
const revs = await this.db.getConflictedRevs(path);
|
||||
if (revs.length > 0) {
|
||||
// Some conflicts are exist.
|
||||
if (this.settings.writeDocumentsIfConflicted) {
|
||||
// If configured to write the document even if conflicted, then it should be written.
|
||||
// NO OP
|
||||
} else {
|
||||
// If not, then it should be checked. and will be processed later (i.e., after the conflict is resolved).
|
||||
await this.services.conflict.queueCheckForIfOpen(path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if the file is already exist on the storage.
|
||||
const existDoc = this.storage.getStub(path);
|
||||
if (existDoc && existDoc.isFolder) {
|
||||
this._log(`Folder ${path} is already exist on the storage as a folder`, LOG_LEVEL_VERBOSE);
|
||||
// We can do nothing, and other modules should also nothing to do.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check existence of both file and docEntry.
|
||||
const existOnDB = !(docEntry._deleted || docEntry.deleted || false);
|
||||
const existOnStorage = existDoc != null;
|
||||
if (!existOnDB && !existOnStorage) {
|
||||
this._log(`File ${path} seems to be deleted, but already not on storage`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
if (!existOnDB && existOnStorage) {
|
||||
// Deletion has been Transferred. Storage files will be deleted.
|
||||
// Note: If the folder becomes empty, the folder will be deleted if not configured to keep it.
|
||||
// This behaviour is implemented on the `ModuleFileAccessObsidian`.
|
||||
// And it does not care actually deleted.
|
||||
await this.storage.deleteVaultItem(path);
|
||||
return true;
|
||||
}
|
||||
// Okay, the file is exist on the database. Let's check the file is exist on the storage.
|
||||
const docRead = await this.db.fetchEntryFromMeta(docEntry);
|
||||
if (!docRead) {
|
||||
this._log(`File ${path} is not exist on the database`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we want to process size mismatched files -- in case of having files created by some integrations, enable the toggle.
|
||||
if (!this.settings.processSizeMismatchedFiles) {
|
||||
// Check the file is not corrupted
|
||||
// (Zero is a special case, may be created by some APIs and it might be acceptable).
|
||||
if (docRead.size != 0 && docRead.size !== readAsBlob(docRead).size) {
|
||||
this._log(
|
||||
`File ${path} seems to be corrupted! Writing prevented. (${docRead.size} != ${readAsBlob(docRead).size})`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const docData = readContent(docRead);
|
||||
|
||||
if (existOnStorage && !force) {
|
||||
// The file is exist on the storage. Let's check the difference between the file and the entry.
|
||||
// But, if force is true, then it should be updated.
|
||||
// Ok, we have to compare.
|
||||
let shouldApplied = false;
|
||||
// 1. if the time stamp is far different, then it should be updated.
|
||||
// Note: This checks only the mtime with the resolution reduced to 2 seconds.
|
||||
// 2 seconds it for the ZIP file's mtime. If not, we cannot backup the vault as the ZIP file.
|
||||
// This is hardcoded on `compareMtime` of `src/common/utils.ts`.
|
||||
if (compareFileFreshness(existDoc, docEntry) !== EVEN) {
|
||||
shouldApplied = true;
|
||||
}
|
||||
// 2. if not, the content should be checked.
|
||||
|
||||
if (!shouldApplied) {
|
||||
const readFile = await this.readFileFromStub(existDoc);
|
||||
if (await isDocContentSame(docData, readFile.body)) {
|
||||
// The content is same. So, we do not need to update the file.
|
||||
shouldApplied = false;
|
||||
// Timestamp is different but the content is same. therefore, two timestamps should be handled as same.
|
||||
// So, mark the changes are same.
|
||||
markChangesAreSame(docRead, docRead.mtime, existDoc.stat.mtime);
|
||||
} else {
|
||||
shouldApplied = true;
|
||||
}
|
||||
}
|
||||
if (!shouldApplied) {
|
||||
this._log(`File ${docRead.path} is not changed`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
// Let's apply the changes.
|
||||
} else {
|
||||
this._log(
|
||||
`File ${docRead.path} ${existOnStorage ? "(new) " : ""} ${force ? " (forced)" : ""}`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
await this.storage.ensureDir(path);
|
||||
const ret = await this.storage.writeFileAuto(path, docData, { ctime: docRead.ctime, mtime: docRead.mtime });
|
||||
await this.storage.touched(path);
|
||||
this.storage.triggerFileEvent(mode, path);
|
||||
return ret;
|
||||
}
|
||||
|
||||
private async _anyHandlerProcessesFileEvent(item: FileEventItem): Promise<boolean> {
|
||||
const eventItem = item.args;
|
||||
const type = item.type;
|
||||
const path = eventItem.file.path;
|
||||
if (!(await this.services.vault.isTargetFile(path))) {
|
||||
this._log(`File ${path} is not the target file`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (shouldBeIgnored(path)) {
|
||||
this._log(`File ${path} should be ignored`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const lockKey = `processFileEvent-${path}`;
|
||||
return await serialized(lockKey, async () => {
|
||||
switch (type) {
|
||||
case "CREATE":
|
||||
case "CHANGED":
|
||||
return await this.storeFileToDB(item.args.file);
|
||||
case "DELETE":
|
||||
return await this.deleteFileFromDB(item.args.file);
|
||||
case "INTERNAL":
|
||||
// this should be handled on the other module.
|
||||
return false;
|
||||
default:
|
||||
this._log(`Unsupported event type: ${type}`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _anyProcessReplicatedDoc(entry: MetaEntry): Promise<boolean> {
|
||||
return await serialized(entry.path, async () => {
|
||||
if (!(await this.services.vault.isTargetFile(entry.path))) {
|
||||
this._log(`File ${entry.path} is not the target file`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (this.services.vault.isFileSizeTooLarge(entry.size)) {
|
||||
this._log(`File ${entry.path} is too large (on database) to be processed`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
if (shouldBeIgnored(entry.path)) {
|
||||
this._log(`File ${entry.path} should be ignored`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
const path = this.getPath(entry);
|
||||
|
||||
const targetFile = this.storage.getStub(this.getPathWithoutPrefix(entry));
|
||||
if (targetFile && targetFile.isFolder) {
|
||||
this._log(`${path} is already exist as the folder`);
|
||||
// Nothing to do and other modules should also nothing to do.
|
||||
return true;
|
||||
} else {
|
||||
if (targetFile && this.services.vault.isFileSizeTooLarge(targetFile.stat.size)) {
|
||||
this._log(`File ${targetFile.path} is too large (on storage) to be processed`, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
this._log(
|
||||
`Processing ${path} (${entry._id.substring(0, 8)} :${entry._rev?.substring(0, 5)}) : Started...`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
// Before writing (or skipped ), merging dialogue should be cancelled.
|
||||
eventHub.emitEvent("conflict-cancelled", path);
|
||||
const ret = await this.dbToStorage(entry, targetFile);
|
||||
this._log(`Processing ${path} (${entry._id.substring(0, 8)} :${entry._rev?.substring(0, 5)}) : Done`);
|
||||
return ret;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async createAllChunks(showingNotice?: boolean): Promise<void> {
|
||||
this._log("Collecting local files on the storage", LOG_LEVEL_VERBOSE);
|
||||
const semaphore = Semaphore(10);
|
||||
|
||||
let processed = 0;
|
||||
const filesStorageSrc = this.storage.getFiles();
|
||||
const incProcessed = () => {
|
||||
processed++;
|
||||
if (processed % 25 == 0)
|
||||
this._log(
|
||||
`Creating missing chunks: ${processed} of ${total} files`,
|
||||
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
||||
"chunkCreation"
|
||||
);
|
||||
};
|
||||
const total = filesStorageSrc.length;
|
||||
const procAllChunks = filesStorageSrc.map(async (file) => {
|
||||
if (!(await this.services.vault.isTargetFile(file))) {
|
||||
incProcessed();
|
||||
return true;
|
||||
}
|
||||
if (this.services.vault.isFileSizeTooLarge(file.stat.size)) {
|
||||
incProcessed();
|
||||
return true;
|
||||
}
|
||||
if (shouldBeIgnored(file.path)) {
|
||||
incProcessed();
|
||||
return true;
|
||||
}
|
||||
const release = await semaphore.acquire();
|
||||
incProcessed();
|
||||
try {
|
||||
await this.storeFileToDB(file, false, true);
|
||||
} catch (ex) {
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
await Promise.all(procAllChunks);
|
||||
this._log(
|
||||
`Creating chunks Done: ${processed} of ${total} files`,
|
||||
showingNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO,
|
||||
"chunkCreation"
|
||||
);
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.fileProcessing.processFileEvent.addHandler(this._anyHandlerProcessesFileEvent.bind(this));
|
||||
services.replication.processSynchroniseResult.addHandler(this._anyProcessReplicatedDoc.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export class ModulePeriodicProcess extends AbstractModule {
|
||||
return this.resumePeriodic();
|
||||
}
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onUnload.addHandler(this._allOnUnload.bind(this));
|
||||
services.setting.onBeforeRealiseSetting.addHandler(this._everyBeforeRealizeSetting.bind(this));
|
||||
services.setting.onSettingRealised.addHandler(this._everyAfterRealizeSetting.bind(this));
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
import { delay } from "octagonal-wheels/promises";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
FLAGMD_REDFLAG2_HR,
|
||||
FLAGMD_REDFLAG3_HR,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
} from "../../lib/src/common/types.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { Rebuilder } from "../interfaces/DatabaseRebuilder.ts";
|
||||
import type { LiveSyncCouchDBReplicator } from "../../lib/src/replication/couchdb/LiveSyncReplicator.ts";
|
||||
import { fetchAllUsedChunks } from "@/lib/src/pouchdb/chunks.ts";
|
||||
import { EVENT_DATABASE_REBUILT, eventHub } from "src/common/events.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
|
||||
export class ModuleRebuilder extends AbstractModule implements Rebuilder {
|
||||
private _everyOnload(): Promise<boolean> {
|
||||
this.core.rebuilder = this;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async $performRebuildDB(
|
||||
method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks"
|
||||
): Promise<void> {
|
||||
if (method == "localOnly") {
|
||||
await this.$fetchLocal();
|
||||
}
|
||||
if (method == "localOnlyWithChunks") {
|
||||
await this.$fetchLocal(true);
|
||||
}
|
||||
if (method == "remoteOnly") {
|
||||
await this.$rebuildRemote();
|
||||
}
|
||||
if (method == "rebuildBothByThisDevice") {
|
||||
await this.$rebuildEverything();
|
||||
}
|
||||
}
|
||||
|
||||
async informOptionalFeatures() {
|
||||
await this.core.services.UI.showMarkdownDialog(
|
||||
"All optional features are disabled",
|
||||
`Customisation Sync and Hidden File Sync will all be disabled.
|
||||
Please enable them from the settings screen after setup is complete.`,
|
||||
["OK"]
|
||||
);
|
||||
}
|
||||
async askUsingOptionalFeature(opt: { enableFetch?: boolean; enableOverwrite?: boolean }) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
"Do you want to enable extra features? If you are new to Self-hosted LiveSync, try the core feature first!",
|
||||
{ title: "Enable extra features", defaultOption: "No", timeout: 15 }
|
||||
)) == "yes"
|
||||
) {
|
||||
await this.services.setting.suggestOptionalFeatures(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async rebuildRemote() {
|
||||
await this.services.setting.suspendExtraSync();
|
||||
this.core.settings.isConfigured = true;
|
||||
this.core.settings.notifyThresholdOfRemoteStorageSize = DEFAULT_SETTINGS.notifyThresholdOfRemoteStorageSize;
|
||||
await this.services.setting.realiseSetting();
|
||||
await this.services.remote.markLocked();
|
||||
await this.services.remote.tryResetDatabase();
|
||||
await this.services.remote.markLocked();
|
||||
await delay(500);
|
||||
// await this.askUsingOptionalFeature({ enableOverwrite: true });
|
||||
await delay(1000);
|
||||
await this.services.remote.replicateAllToRemote(true);
|
||||
await delay(1000);
|
||||
await this.services.remote.replicateAllToRemote(true, true);
|
||||
await this.informOptionalFeatures();
|
||||
}
|
||||
$rebuildRemote(): Promise<void> {
|
||||
return this.rebuildRemote();
|
||||
}
|
||||
|
||||
async rebuildEverything() {
|
||||
await this.services.setting.suspendExtraSync();
|
||||
// await this.askUseNewAdapter();
|
||||
this.core.settings.isConfigured = true;
|
||||
this.core.settings.notifyThresholdOfRemoteStorageSize = DEFAULT_SETTINGS.notifyThresholdOfRemoteStorageSize;
|
||||
await this.services.setting.realiseSetting();
|
||||
await this.resetLocalDatabase();
|
||||
await delay(1000);
|
||||
await this.services.databaseEvents.initialiseDatabase(true, true, true);
|
||||
await this.services.remote.markLocked();
|
||||
await this.services.remote.tryResetDatabase();
|
||||
await this.services.remote.markLocked();
|
||||
await delay(500);
|
||||
// We do not have any other devices' data, so we do not need to ask for overwriting.
|
||||
// await this.askUsingOptionalFeature({ enableOverwrite: false });
|
||||
await delay(1000);
|
||||
await this.services.remote.replicateAllToRemote(true);
|
||||
await delay(1000);
|
||||
await this.services.remote.replicateAllToRemote(true, true);
|
||||
await this.informOptionalFeatures();
|
||||
}
|
||||
|
||||
$rebuildEverything(): Promise<void> {
|
||||
return this.rebuildEverything();
|
||||
}
|
||||
|
||||
$fetchLocal(makeLocalChunkBeforeSync?: boolean, preventMakeLocalFilesBeforeSync?: boolean): Promise<void> {
|
||||
return this.fetchLocal(makeLocalChunkBeforeSync, preventMakeLocalFilesBeforeSync);
|
||||
}
|
||||
|
||||
async scheduleRebuild(): Promise<void> {
|
||||
try {
|
||||
await this.core.storageAccess.writeFileAuto(FLAGMD_REDFLAG2_HR, "");
|
||||
} catch (ex) {
|
||||
this._log("Could not create red_flag_rebuild.md", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.services.appLifecycle.performRestart();
|
||||
}
|
||||
async scheduleFetch(): Promise<void> {
|
||||
try {
|
||||
await this.core.storageAccess.writeFileAuto(FLAGMD_REDFLAG3_HR, "");
|
||||
} catch (ex) {
|
||||
this._log("Could not create red_flag_fetch.md", LOG_LEVEL_NOTICE);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.services.appLifecycle.performRestart();
|
||||
}
|
||||
|
||||
private async _tryResetRemoteDatabase(): Promise<void> {
|
||||
await this.core.replicator.tryResetRemoteDatabase(this.settings);
|
||||
}
|
||||
|
||||
private async _tryCreateRemoteDatabase(): Promise<void> {
|
||||
await this.core.replicator.tryCreateRemoteDatabase(this.settings);
|
||||
}
|
||||
|
||||
private _onResetLocalDatabase(): Promise<boolean> {
|
||||
this.core.storageAccess.clearTouched();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async suspendAllSync() {
|
||||
this.core.settings.liveSync = false;
|
||||
this.core.settings.periodicReplication = false;
|
||||
this.core.settings.syncOnSave = false;
|
||||
this.core.settings.syncOnEditorSave = false;
|
||||
this.core.settings.syncOnStart = false;
|
||||
this.core.settings.syncOnFileOpen = false;
|
||||
this.core.settings.syncAfterMerge = false;
|
||||
await this.services.setting.suspendExtraSync();
|
||||
}
|
||||
async suspendReflectingDatabase() {
|
||||
if (this.core.settings.doNotSuspendOnFetching) return;
|
||||
if (this.core.settings.remoteType == REMOTE_MINIO) return;
|
||||
this._log(
|
||||
`Suspending reflection: Database and storage changes will not be reflected in each other until completely finished the fetching.`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.core.settings.suspendParseReplicationResult = true;
|
||||
this.core.settings.suspendFileWatching = true;
|
||||
await this.core.saveSettings();
|
||||
}
|
||||
async resumeReflectingDatabase() {
|
||||
if (this.core.settings.doNotSuspendOnFetching) return;
|
||||
if (this.core.settings.remoteType == REMOTE_MINIO) return;
|
||||
this._log(`Database and storage reflection has been resumed!`, LOG_LEVEL_NOTICE);
|
||||
this.core.settings.suspendParseReplicationResult = false;
|
||||
this.core.settings.suspendFileWatching = false;
|
||||
await this.services.vault.scanVault(true);
|
||||
await this.services.replication.onBeforeReplicate(false); //TODO: Check actual need of this.
|
||||
await this.core.saveSettings();
|
||||
}
|
||||
// No longer needed, both adapters have each advantages and disadvantages.
|
||||
// async askUseNewAdapter() {
|
||||
// if (!this.core.settings.useIndexedDBAdapter) {
|
||||
// const message = `Now this core has been configured to use the old database adapter for keeping compatibility. Do you want to deactivate it?`;
|
||||
// const CHOICE_YES = "Yes, disable and use latest";
|
||||
// const CHOICE_NO = "No, keep compatibility";
|
||||
// const choices = [CHOICE_YES, CHOICE_NO];
|
||||
//
|
||||
// const ret = await this.core.confirm.confirmWithMessage(
|
||||
// "Database adapter",
|
||||
// message,
|
||||
// choices,
|
||||
// CHOICE_YES,
|
||||
// 10
|
||||
// );
|
||||
// if (ret == CHOICE_YES) {
|
||||
// this.core.settings.useIndexedDBAdapter = true;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
async fetchLocal(makeLocalChunkBeforeSync?: boolean, preventMakeLocalFilesBeforeSync?: boolean) {
|
||||
await this.services.setting.suspendExtraSync();
|
||||
// await this.askUseNewAdapter();
|
||||
this.core.settings.isConfigured = true;
|
||||
this.core.settings.notifyThresholdOfRemoteStorageSize = DEFAULT_SETTINGS.notifyThresholdOfRemoteStorageSize;
|
||||
if (this.core.settings.maxMTimeForReflectEvents > 0) {
|
||||
const date = new Date(this.core.settings.maxMTimeForReflectEvents);
|
||||
|
||||
const ask = `Your settings restrict file reflection times to no later than ${date}.
|
||||
|
||||
**This is a recovery configuration.**
|
||||
|
||||
This operation should only be performed on an empty vault.
|
||||
Are you sure you wish to proceed?`;
|
||||
const PROCEED = "I understand, proceed";
|
||||
const CANCEL = "Cancel operation";
|
||||
const CLEARANDPROCEED = "Clear restriction and proceed";
|
||||
const choices = [PROCEED, CLEARANDPROCEED, CANCEL] as const;
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(ask, choices, {
|
||||
title: "Confirm restricted fetch",
|
||||
defaultAction: CANCEL,
|
||||
timeout: 0,
|
||||
});
|
||||
if (ret == CLEARANDPROCEED) {
|
||||
this.core.settings.maxMTimeForReflectEvents = 0;
|
||||
await this.core.saveSettings();
|
||||
}
|
||||
if (ret == CANCEL) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.suspendReflectingDatabase();
|
||||
await this.services.setting.realiseSetting();
|
||||
await this.resetLocalDatabase();
|
||||
await delay(1000);
|
||||
await this.services.database.openDatabase({
|
||||
databaseEvents: this.services.databaseEvents,
|
||||
replicator: this.services.replicator,
|
||||
});
|
||||
// this.core.isReady = true;
|
||||
this.services.appLifecycle.markIsReady();
|
||||
if (makeLocalChunkBeforeSync) {
|
||||
await this.core.fileHandler.createAllChunks(true);
|
||||
} else if (!preventMakeLocalFilesBeforeSync) {
|
||||
await this.services.databaseEvents.initialiseDatabase(true, true, true);
|
||||
} else {
|
||||
// Do not create local file entries before sync (Means use remote information)
|
||||
}
|
||||
await this.services.remote.markResolved();
|
||||
await delay(500);
|
||||
await this.services.remote.replicateAllFromRemote(true);
|
||||
await delay(1000);
|
||||
await this.services.remote.replicateAllFromRemote(true);
|
||||
await this.resumeReflectingDatabase();
|
||||
await this.informOptionalFeatures();
|
||||
// No longer enable
|
||||
// await this.askUsingOptionalFeature({ enableFetch: true });
|
||||
}
|
||||
async fetchLocalWithRebuild() {
|
||||
return await this.fetchLocal(true);
|
||||
}
|
||||
|
||||
private async _allSuspendAllSync(): Promise<boolean> {
|
||||
await this.suspendAllSync();
|
||||
return true;
|
||||
}
|
||||
|
||||
async resetLocalDatabase() {
|
||||
if (this.core.settings.isConfigured && this.core.settings.additionalSuffixOfDatabaseName == "") {
|
||||
// Discard the non-suffixed database
|
||||
await this.services.database.resetDatabase();
|
||||
}
|
||||
const suffix = this.services.API.getAppID() || "";
|
||||
this.core.settings.additionalSuffixOfDatabaseName = suffix;
|
||||
await this.services.database.resetDatabase();
|
||||
eventHub.emitEvent(EVENT_DATABASE_REBUILT);
|
||||
}
|
||||
async fetchRemoteChunks() {
|
||||
if (
|
||||
!this.core.settings.doNotSuspendOnFetching &&
|
||||
!this.core.settings.useOnlyLocalChunk &&
|
||||
this.core.settings.remoteType == REMOTE_COUCHDB
|
||||
) {
|
||||
this._log(`Fetching chunks`, LOG_LEVEL_NOTICE);
|
||||
const replicator = this.services.replicator.getActiveReplicator() as LiveSyncCouchDBReplicator;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||
this.settings,
|
||||
this.services.API.isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof remoteDB == "string") {
|
||||
this._log(remoteDB, LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
await fetchAllUsedChunks(this.localDatabase.localDatabase, remoteDB.db);
|
||||
}
|
||||
this._log(`Fetching chunks done`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
}
|
||||
async resolveAllConflictedFilesByNewerOnes() {
|
||||
this._log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
const files = this.core.storageAccess.getFileNames();
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
if (i++ % 10)
|
||||
this._log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await this.services.conflict.resolveByNewest(file);
|
||||
}
|
||||
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
||||
services.database.onDatabaseReset.addHandler(this._onResetLocalDatabase.bind(this));
|
||||
services.remote.tryResetDatabase.setHandler(this._tryResetRemoteDatabase.bind(this));
|
||||
services.remote.tryCreateDatabase.setHandler(this._tryCreateRemoteDatabase.bind(this));
|
||||
services.setting.suspendAllSync.addHandler(this._allSuspendAllSync.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,60 @@
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO, LEVEL_NOTICE, type LOG_LEVEL } from "octagonal-wheels/common/logger";
|
||||
import { isLockAcquired, shareRunningResult, skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import { balanceChunkPurgedDBs } from "@/lib/src/pouchdb/chunks";
|
||||
import { purgeUnreferencedChunks } from "@/lib/src/pouchdb/chunks";
|
||||
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import { balanceChunkPurgedDBs } from "@lib/pouchdb/chunks";
|
||||
import { purgeUnreferencedChunks } from "@lib/pouchdb/chunks";
|
||||
import { LiveSyncCouchDBReplicator } from "../../lib/src/replication/couchdb/LiveSyncReplicator";
|
||||
import { type EntryDoc, type RemoteType } from "../../lib/src/common/types";
|
||||
import { rateLimitedSharedExecution, scheduleTask, updatePreviousExecutionTime } from "../../common/utils";
|
||||
import { scheduleTask } from "../../common/utils";
|
||||
import { EVENT_FILE_SAVED, EVENT_SETTING_SAVED, eventHub } from "../../common/events";
|
||||
|
||||
import { $msg } from "../../lib/src/common/i18n";
|
||||
import type { LiveSyncCore } from "../../main";
|
||||
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||
import { UnresolvedErrorManager } from "@/lib/src/services/base/UnresolvedErrorManager";
|
||||
import { clearHandlers } from "@/lib/src/replication/SyncParamsHandler";
|
||||
import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager";
|
||||
import { clearHandlers } from "@lib/replication/SyncParamsHandler";
|
||||
import type { NecessaryServices } from "@lib/interfaces/ServiceModule";
|
||||
import { MARK_LOG_NETWORK_ERROR } from "@lib/services/lib/logUtils";
|
||||
|
||||
const KEY_REPLICATION_ON_EVENT = "replicationOnEvent";
|
||||
const REPLICATION_ON_EVENT_FORECASTED_TIME = 5000;
|
||||
function isOnlineAndCanReplicate(
|
||||
errorManager: UnresolvedErrorManager,
|
||||
host: NecessaryServices<"API", any>,
|
||||
showMessage: boolean
|
||||
): Promise<boolean> {
|
||||
const errorMessage = "Network is offline";
|
||||
if (!host.services.API.isOnline) {
|
||||
errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
errorManager.clearError(errorMessage);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async function canReplicateWithPBKDF2(
|
||||
errorManager: UnresolvedErrorManager,
|
||||
host: NecessaryServices<"replicator" | "setting", any>,
|
||||
showMessage: boolean
|
||||
): Promise<boolean> {
|
||||
const currentSettings = host.services.setting.currentSettings();
|
||||
// TODO: check using PBKDF2 salt?
|
||||
const errorMessage = $msg("Replicator.Message.InitialiseFatalError");
|
||||
const replicator = host.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
errorManager.clearError(errorMessage);
|
||||
// Showing message is false: that because be shown here. (And it is a fatal error, no way to hide it).
|
||||
// tagged as network error at beginning for error filtering with NetworkWarningStyles
|
||||
const ensureMessage = `${MARK_LOG_NETWORK_ERROR}Failed to initialise the encryption key, preventing replication.`;
|
||||
const ensureResult = await replicator.ensurePBKDF2Salt(currentSettings, showMessage, true);
|
||||
if (!ensureResult) {
|
||||
errorManager.showError(ensureMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
errorManager.clearError(ensureMessage);
|
||||
return ensureResult; // is true.
|
||||
}
|
||||
|
||||
export class ModuleReplicator extends AbstractModule {
|
||||
_replicatorType?: RemoteType;
|
||||
@@ -26,9 +64,6 @@ export class ModuleReplicator extends AbstractModule {
|
||||
this.core.services.appLifecycle
|
||||
);
|
||||
|
||||
showError(msg: string, max_log_level: LOG_LEVEL = LEVEL_NOTICE) {
|
||||
this._unresolvedErrorManager.showError(msg, max_log_level);
|
||||
}
|
||||
clearErrors() {
|
||||
this._unresolvedErrorManager.clearErrors();
|
||||
}
|
||||
@@ -40,10 +75,6 @@ export class ModuleReplicator extends AbstractModule {
|
||||
}
|
||||
});
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (setting) => {
|
||||
// ReplicatorService responds to `settingService.onRealiseSetting`.
|
||||
// if (this._replicatorType !== setting.remoteType) {
|
||||
// void this.setReplicator();
|
||||
// }
|
||||
if (this.core.settings.suspendParseReplicationResult) {
|
||||
this.processor.suspend();
|
||||
} else {
|
||||
@@ -65,41 +96,12 @@ export class ModuleReplicator extends AbstractModule {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async ensureReplicatorPBKDF2Salt(showMessage: boolean = false): Promise<boolean> {
|
||||
// Checking salt
|
||||
const replicator = this.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
this.showError($msg("Replicator.Message.InitialiseFatalError"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return await replicator.ensurePBKDF2Salt(this.settings, showMessage, true);
|
||||
}
|
||||
|
||||
async _everyBeforeReplicate(showMessage: boolean): Promise<boolean> {
|
||||
// Checking salt
|
||||
if (!this.core.managers.networkManager.isOnline) {
|
||||
this.showError("Network is offline", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
// Showing message is false: that because be shown here. (And it is a fatal error, no way to hide it).
|
||||
if (!(await this.ensureReplicatorPBKDF2Salt(false))) {
|
||||
this.showError("Failed to initialise the encryption key, preventing replication.");
|
||||
return false;
|
||||
}
|
||||
await this.processor.restoreFromSnapshotOnce();
|
||||
this.clearErrors();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _replicate(showMessage: boolean = false): Promise<boolean | void> {
|
||||
try {
|
||||
updatePreviousExecutionTime(KEY_REPLICATION_ON_EVENT, REPLICATION_ON_EVENT_FORECASTED_TIME);
|
||||
return await this.$$_replicate(showMessage);
|
||||
} finally {
|
||||
updatePreviousExecutionTime(KEY_REPLICATION_ON_EVENT);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* obsolete method. No longer maintained and will be removed in the future.
|
||||
* @deprecated v0.24.17
|
||||
@@ -159,149 +161,129 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
});
|
||||
}
|
||||
|
||||
async _canReplicate(showMessage: boolean = false): Promise<boolean> {
|
||||
if (!this.services.appLifecycle.isReady()) {
|
||||
Logger(`Not ready`);
|
||||
private async onReplicationFailed(showMessage: boolean = false): Promise<boolean> {
|
||||
const activeReplicator = this.services.replicator.getActiveReplicator();
|
||||
if (!activeReplicator) {
|
||||
Logger(`No active replicator found`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isLockAcquired("cleanup")) {
|
||||
Logger($msg("Replicator.Message.Cleaned"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.settings.versionUpFlash != "") {
|
||||
Logger($msg("Replicator.Message.VersionUpFlash"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(await this.services.fileProcessing.commitPendingFileEvents())) {
|
||||
this.showError($msg("Replicator.Message.Pending"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.core.managers.networkManager.isOnline) {
|
||||
this.showError("Network is offline", showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (!(await this.services.replication.onBeforeReplicate(showMessage))) {
|
||||
this.showError($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
this.clearErrors();
|
||||
return true;
|
||||
}
|
||||
|
||||
async $$_replicate(showMessage: boolean = false): Promise<boolean | void> {
|
||||
const checkBeforeReplicate = await this.services.replication.isReplicationReady(showMessage);
|
||||
if (!checkBeforeReplicate) return false;
|
||||
|
||||
//<-- Here could be an module.
|
||||
const ret = await this.core.replicator.openReplication(this.settings, false, showMessage, false);
|
||||
if (!ret) {
|
||||
if (this.core.replicator.tweakSettingsMismatched && this.core.replicator.preferredTweakValue) {
|
||||
await this.services.tweakValue.askResolvingMismatched(this.core.replicator.preferredTweakValue);
|
||||
} else {
|
||||
if (this.core.replicator?.remoteLockedAndDeviceNotAccepted) {
|
||||
if (this.core.replicator.remoteCleaned && this.settings.useIndexedDBAdapter) {
|
||||
await this.cleaned(showMessage);
|
||||
} else {
|
||||
const message = $msg("Replicator.Dialogue.Locked.Message");
|
||||
const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch");
|
||||
const CHOICE_DISMISS = $msg("Replicator.Dialogue.Locked.Action.Dismiss");
|
||||
const CHOICE_UNLOCK = $msg("Replicator.Dialogue.Locked.Action.Unlock");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[CHOICE_FETCH, CHOICE_UNLOCK, CHOICE_DISMISS],
|
||||
{
|
||||
title: $msg("Replicator.Dialogue.Locked.Title"),
|
||||
defaultAction: CHOICE_DISMISS,
|
||||
timeout: 60,
|
||||
}
|
||||
);
|
||||
if (ret == CHOICE_FETCH) {
|
||||
this._log($msg("Replicator.Dialogue.Locked.Message.Fetch"), LOG_LEVEL_NOTICE);
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.scheduleRestart();
|
||||
return;
|
||||
} else if (ret == CHOICE_UNLOCK) {
|
||||
await this.core.replicator.markRemoteResolved(this.settings);
|
||||
this._log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
if (activeReplicator.tweakSettingsMismatched && activeReplicator.preferredTweakValue) {
|
||||
await this.services.tweakValue.askResolvingMismatched(activeReplicator.preferredTweakValue);
|
||||
} else {
|
||||
if (activeReplicator.remoteLockedAndDeviceNotAccepted) {
|
||||
if (activeReplicator.remoteCleaned && this.settings.useIndexedDBAdapter) {
|
||||
await this.cleaned(showMessage);
|
||||
} else {
|
||||
const message = $msg("Replicator.Dialogue.Locked.Message");
|
||||
const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch");
|
||||
const CHOICE_DISMISS = $msg("Replicator.Dialogue.Locked.Action.Dismiss");
|
||||
const CHOICE_UNLOCK = $msg("Replicator.Dialogue.Locked.Action.Unlock");
|
||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
||||
message,
|
||||
[CHOICE_FETCH, CHOICE_UNLOCK, CHOICE_DISMISS],
|
||||
{
|
||||
title: $msg("Replicator.Dialogue.Locked.Title"),
|
||||
defaultAction: CHOICE_DISMISS,
|
||||
timeout: 60,
|
||||
}
|
||||
);
|
||||
if (ret == CHOICE_FETCH) {
|
||||
this._log($msg("Replicator.Dialogue.Locked.Message.Fetch"), LOG_LEVEL_NOTICE);
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
this.services.appLifecycle.scheduleRestart();
|
||||
return false;
|
||||
} else if (ret == CHOICE_UNLOCK) {
|
||||
await activeReplicator.markRemoteResolved(this.settings);
|
||||
this._log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
// TODO: Check again and true/false return. This will be the result for performReplication.
|
||||
return false;
|
||||
}
|
||||
|
||||
private async _replicateByEvent(): Promise<boolean | void> {
|
||||
const least = this.settings.syncMinimumInterval;
|
||||
if (least > 0) {
|
||||
return rateLimitedSharedExecution(KEY_REPLICATION_ON_EVENT, least, async () => {
|
||||
return await this.services.replication.replicate();
|
||||
});
|
||||
}
|
||||
return await shareRunningResult(`replication`, () => this.services.replication.replicate());
|
||||
}
|
||||
// private async _replicateByEvent(): Promise<boolean | void> {
|
||||
// const least = this.settings.syncMinimumInterval;
|
||||
// if (least > 0) {
|
||||
// return rateLimitedSharedExecution(KEY_REPLICATION_ON_EVENT, least, async () => {
|
||||
// return await this.services.replication.replicate();
|
||||
// });
|
||||
// }
|
||||
// return await shareRunningResult(`replication`, () => this.services.replication.replicate());
|
||||
// }
|
||||
|
||||
_parseReplicationResult(docs: Array<PouchDB.Core.ExistingDocument<EntryDoc>>): void {
|
||||
_parseReplicationResult(docs: Array<PouchDB.Core.ExistingDocument<EntryDoc>>): Promise<boolean> {
|
||||
this.processor.enqueueAll(docs);
|
||||
}
|
||||
|
||||
_everyBeforeSuspendProcess(): Promise<boolean> {
|
||||
this.core.replicator?.closeReplication();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
private async _replicateAllToServer(
|
||||
showingNotice: boolean = false,
|
||||
sendChunksInBulkDisabled: boolean = false
|
||||
): Promise<boolean> {
|
||||
if (!this.services.appLifecycle.isReady()) return false;
|
||||
if (!(await this.services.replication.onBeforeReplicate(showingNotice))) {
|
||||
Logger($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
if (!sendChunksInBulkDisabled) {
|
||||
if (this.core.replicator instanceof LiveSyncCouchDBReplicator) {
|
||||
if (
|
||||
(await this.core.confirm.askYesNoDialog("Do you want to send all chunks before replication?", {
|
||||
defaultOption: "No",
|
||||
timeout: 20,
|
||||
})) == "yes"
|
||||
) {
|
||||
await this.core.replicator.sendChunks(this.core.settings, undefined, true, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
const ret = await this.core.replicator.replicateAllToServer(this.settings, showingNotice);
|
||||
if (ret) return true;
|
||||
const checkResult = await this.services.replication.checkConnectionFailure();
|
||||
if (checkResult == "CHECKAGAIN") return await this.services.remote.replicateAllToRemote(showingNotice);
|
||||
return !checkResult;
|
||||
}
|
||||
async _replicateAllFromServer(showingNotice: boolean = false): Promise<boolean> {
|
||||
if (!this.services.appLifecycle.isReady()) return false;
|
||||
const ret = await this.core.replicator.replicateAllFromServer(this.settings, showingNotice);
|
||||
if (ret) return true;
|
||||
const checkResult = await this.services.replication.checkConnectionFailure();
|
||||
if (checkResult == "CHECKAGAIN") return await this.services.remote.replicateAllFromRemote(showingNotice);
|
||||
return !checkResult;
|
||||
}
|
||||
// _everyBeforeSuspendProcess(): Promise<boolean> {
|
||||
// this.core.replicator?.closeReplication();
|
||||
// return Promise.resolve(true);
|
||||
// }
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
// private async _replicateAllToServer(
|
||||
// showingNotice: boolean = false,
|
||||
// sendChunksInBulkDisabled: boolean = false
|
||||
// ): Promise<boolean> {
|
||||
// if (!this.services.appLifecycle.isReady()) return false;
|
||||
// if (!(await this.services.replication.onBeforeReplicate(showingNotice))) {
|
||||
// Logger($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
|
||||
// return false;
|
||||
// }
|
||||
// if (!sendChunksInBulkDisabled) {
|
||||
// if (this.core.replicator instanceof LiveSyncCouchDBReplicator) {
|
||||
// if (
|
||||
// (await this.core.confirm.askYesNoDialog("Do you want to send all chunks before replication?", {
|
||||
// defaultOption: "No",
|
||||
// timeout: 20,
|
||||
// })) == "yes"
|
||||
// ) {
|
||||
// await this.core.replicator.sendChunks(this.core.settings, undefined, true, 0);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// const ret = await this.core.replicator.replicateAllToServer(this.settings, showingNotice);
|
||||
// if (ret) return true;
|
||||
// const checkResult = await this.services.replication.checkConnectionFailure();
|
||||
// if (checkResult == "CHECKAGAIN") return await this.services.remote.replicateAllToRemote(showingNotice);
|
||||
// return !checkResult;
|
||||
// }
|
||||
// async _replicateAllFromServer(showingNotice: boolean = false): Promise<boolean> {
|
||||
// if (!this.services.appLifecycle.isReady()) return false;
|
||||
// const ret = await this.core.replicator.replicateAllFromServer(this.settings, showingNotice);
|
||||
// if (ret) return true;
|
||||
// const checkResult = await this.services.replication.checkConnectionFailure();
|
||||
// if (checkResult == "CHECKAGAIN") return await this.services.remote.replicateAllFromRemote(showingNotice);
|
||||
// return !checkResult;
|
||||
// }
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.onReplicatorInitialised.addHandler(this._onReplicatorInitialised.bind(this));
|
||||
services.databaseEvents.onDatabaseInitialised.addHandler(this._everyOnDatabaseInitialized.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
services.replication.parseSynchroniseResult.setHandler(this._parseReplicationResult.bind(this));
|
||||
services.appLifecycle.onSuspending.addHandler(this._everyBeforeSuspendProcess.bind(this));
|
||||
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this));
|
||||
services.replication.isReplicationReady.setHandler(this._canReplicate.bind(this));
|
||||
services.replication.replicate.setHandler(this._replicate.bind(this));
|
||||
services.replication.replicateByEvent.setHandler(this._replicateByEvent.bind(this));
|
||||
services.remote.replicateAllToRemote.setHandler(this._replicateAllToServer.bind(this));
|
||||
services.remote.replicateAllFromRemote.setHandler(this._replicateAllFromServer.bind(this));
|
||||
services.replication.parseSynchroniseResult.addHandler(this._parseReplicationResult.bind(this));
|
||||
|
||||
// --> These handlers can be separated.
|
||||
const isOnlineAndCanReplicateWithHost = isOnlineAndCanReplicate.bind(null, this._unresolvedErrorManager, {
|
||||
services: {
|
||||
API: services.API,
|
||||
},
|
||||
serviceModules: {},
|
||||
});
|
||||
const canReplicateWithPBKDF2WithHost = canReplicateWithPBKDF2.bind(null, this._unresolvedErrorManager, {
|
||||
services: {
|
||||
replicator: services.replicator,
|
||||
setting: services.setting,
|
||||
},
|
||||
serviceModules: {},
|
||||
});
|
||||
services.replication.onBeforeReplicate.addHandler(isOnlineAndCanReplicateWithHost, 10);
|
||||
services.replication.onBeforeReplicate.addHandler(canReplicateWithPBKDF2WithHost, 20);
|
||||
// <-- End of handlers that can be separated.
|
||||
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this), 100);
|
||||
services.replication.onReplicationFailed.addHandler(this.onReplicationFailed.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class ModuleReplicatorCouchDB extends AbstractModule {
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
|
||||
services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export class ModuleReplicatorMinIO extends AbstractModule {
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ModuleReplicatorP2P extends AbstractModule {
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.replicator.getNewReplicator.addHandler(this._anyNewReplicator.bind(this));
|
||||
services.appLifecycle.onResumed.addHandler(this._everyAfterResumeProcess.bind(this));
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { getStoragePathFromUXFileInfo } from "../../common/utils";
|
||||
import { LOG_LEVEL_DEBUG, LOG_LEVEL_VERBOSE, type UXFileInfoStub } from "../../lib/src/common/types";
|
||||
import { isAcceptedAll } from "../../lib/src/string_and_binary/path";
|
||||
import { AbstractModule } from "../AbstractModule";
|
||||
import type { LiveSyncCore } from "../../main";
|
||||
import { Computed } from "octagonal-wheels/dataobject/Computed";
|
||||
export class ModuleTargetFilter extends AbstractModule {
|
||||
ignoreFiles: string[] = [];
|
||||
private refreshSettings() {
|
||||
this.ignoreFiles = this.settings.ignoreFiles.split(",").map((e) => e.trim());
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
private _everyOnload(): Promise<boolean> {
|
||||
void this.refreshSettings();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
_markFileListPossiblyChanged(): void {
|
||||
this.totalFileEventCount++;
|
||||
}
|
||||
|
||||
fileCountMap = new Computed({
|
||||
evaluation: (fileEventCount: number) => {
|
||||
const vaultFiles = this.core.storageAccess.getFileNames().sort();
|
||||
const fileCountMap: Record<string, number> = {};
|
||||
for (const file of vaultFiles) {
|
||||
const lc = file.toLowerCase();
|
||||
if (!fileCountMap[lc]) {
|
||||
fileCountMap[lc] = 1;
|
||||
} else {
|
||||
fileCountMap[lc]++;
|
||||
}
|
||||
}
|
||||
return fileCountMap;
|
||||
},
|
||||
requiresUpdate: (args, previousArgs, previousResult) => {
|
||||
if (!previousResult) return true;
|
||||
if (previousResult instanceof Error) return true;
|
||||
if (!previousArgs) return true;
|
||||
if (args[0] === previousArgs[0]) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
totalFileEventCount = 0;
|
||||
|
||||
private async _isTargetFileByFileNameDuplication(file: string | UXFileInfoStub) {
|
||||
await this.fileCountMap.updateValue(this.totalFileEventCount);
|
||||
const fileCountMap = this.fileCountMap.value;
|
||||
if (!fileCountMap) {
|
||||
this._log("File count map is not ready yet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const filepath = getStoragePathFromUXFileInfo(file);
|
||||
const lc = filepath.toLowerCase();
|
||||
if (this.services.vault.shouldCheckCaseInsensitively()) {
|
||||
if (lc in fileCountMap && fileCountMap[lc] > 1) {
|
||||
this._log("File is duplicated (case-insensitive): " + filepath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this._log("File is not duplicated: " + filepath, LOG_LEVEL_DEBUG);
|
||||
return true;
|
||||
}
|
||||
|
||||
private ignoreFileCacheMap = new Map<string, string[] | undefined | false>();
|
||||
|
||||
private invalidateIgnoreFileCache(path: string) {
|
||||
// This erases `/path/to/.ignorefile` from cache, therefore, next access will reload it.
|
||||
// When detecting edited the ignore file, this method should be called.
|
||||
// Do not check whether it exists in cache or not; just delete it.
|
||||
const key = path.toLowerCase();
|
||||
this.ignoreFileCacheMap.delete(key);
|
||||
}
|
||||
private async getIgnoreFile(path: string): Promise<string[] | false> {
|
||||
const key = path.toLowerCase();
|
||||
const cached = this.ignoreFileCacheMap.get(key);
|
||||
if (cached !== undefined) {
|
||||
// if cached is not undefined, cache hit (neither exists or not exists, string[] or false).
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
// load the ignore file
|
||||
if (!(await this.core.storageAccess.isExistsIncludeHidden(path))) {
|
||||
// file does not exist, cache as not exists
|
||||
this.ignoreFileCacheMap.set(key, false);
|
||||
return false;
|
||||
}
|
||||
const file = await this.core.storageAccess.readHiddenFileText(path);
|
||||
const gitignore = file
|
||||
.split(/\r?\n/g)
|
||||
.map((e) => e.replace(/\r$/, ""))
|
||||
.map((e) => e.trim());
|
||||
this.ignoreFileCacheMap.set(key, gitignore);
|
||||
this._log(`[ignore] Ignore file loaded: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
return gitignore;
|
||||
} catch (ex) {
|
||||
// Failed to read the ignore file, delete cache.
|
||||
this._log(`[ignore] Failed to read ignore file ${path}`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
this.ignoreFileCacheMap.set(key, undefined);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _isTargetFileByLocalDB(file: string | UXFileInfoStub) {
|
||||
const filepath = getStoragePathFromUXFileInfo(file);
|
||||
if (!this.localDatabase?.isTargetFile(filepath)) {
|
||||
this._log("File is not target by local DB: " + filepath);
|
||||
return false;
|
||||
}
|
||||
this._log("File is target by local DB: " + filepath, LOG_LEVEL_DEBUG);
|
||||
return await Promise.resolve(true);
|
||||
}
|
||||
|
||||
private async _isTargetFileFinal(file: string | UXFileInfoStub) {
|
||||
this._log("File is target finally: " + getStoragePathFromUXFileInfo(file), LOG_LEVEL_DEBUG);
|
||||
return await Promise.resolve(true);
|
||||
}
|
||||
|
||||
private async _isTargetIgnoredByIgnoreFiles(file: string | UXFileInfoStub): Promise<boolean> {
|
||||
if (!this.settings.useIgnoreFiles) {
|
||||
return true;
|
||||
}
|
||||
const filepath = getStoragePathFromUXFileInfo(file);
|
||||
this.invalidateIgnoreFileCache(filepath);
|
||||
this._log("Checking ignore files for: " + filepath, LOG_LEVEL_DEBUG);
|
||||
if (!(await isAcceptedAll(filepath, this.ignoreFiles, (filename) => this.getIgnoreFile(filename)))) {
|
||||
this._log("File is ignored by ignore files: " + filepath);
|
||||
return false;
|
||||
}
|
||||
this._log("File is not ignored by ignore files: " + filepath, LOG_LEVEL_DEBUG);
|
||||
return true;
|
||||
}
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.vault.markFileListPossiblyChanged.setHandler(this._markFileListPossiblyChanged.bind(this));
|
||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
||||
services.vault.isIgnoredByIgnoreFile.setHandler(this._isTargetIgnoredByIgnoreFiles.bind(this));
|
||||
services.vault.isTargetFile.addHandler(this._isTargetFileByFileNameDuplication.bind(this));
|
||||
services.vault.isTargetFile.addHandler(this._isTargetIgnoredByIgnoreFiles.bind(this));
|
||||
services.vault.isTargetFile.addHandler(this._isTargetFileByLocalDB.bind(this));
|
||||
services.vault.isTargetFile.addHandler(this._isTargetFileFinal.bind(this));
|
||||
services.setting.onSettingRealised.addHandler(this.refreshSettings.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type EntryLeaf,
|
||||
type LoadedEntry,
|
||||
type MetaEntry,
|
||||
} from "@/lib/src/common/types";
|
||||
} from "@lib/common/types";
|
||||
import type { ModuleReplicator } from "./ModuleReplicator";
|
||||
import { isChunk, isValidPath } from "@/common/utils";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
LOG_LEVEL_VERBOSE,
|
||||
Logger,
|
||||
type LOG_LEVEL,
|
||||
} from "@/lib/src/common/logger";
|
||||
import { fireAndForget, isAnyNote, throttle } from "@/lib/src/common/utils";
|
||||
} from "@lib/common/logger";
|
||||
import { fireAndForget, isAnyNote, throttle } from "@lib/common/utils";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore_v2";
|
||||
import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
|
||||
@@ -162,7 +162,8 @@ export class ReplicateResultProcessor {
|
||||
* Report the current status.
|
||||
*/
|
||||
protected reportStatus() {
|
||||
this.core.replicationResultCount.value = this._queuedChanges.length + this._processingChanges.length;
|
||||
this.services.replication.replicationResultCount.value =
|
||||
this._queuedChanges.length + this._processingChanges.length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,7 +382,7 @@ export class ReplicateResultProcessor {
|
||||
releaser();
|
||||
}
|
||||
}
|
||||
}, this.replicator.core.databaseQueueCount);
|
||||
}, this.services.replication.databaseQueueCount);
|
||||
}
|
||||
// Phase 2.1: process the document and apply to storage
|
||||
// This function is serialized per document to avoid race-condition for the same document.
|
||||
@@ -432,7 +433,7 @@ export class ReplicateResultProcessor {
|
||||
protected applyToStorage(entry: MetaEntry) {
|
||||
return this.withCounting(async () => {
|
||||
await this.services.replication.processSynchroniseResult(entry);
|
||||
}, this.replicator.core.storageApplyingCount);
|
||||
}, this.services.replication.storageApplyingCount);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,10 +71,10 @@ export class ModuleConflictChecker extends AbstractModule {
|
||||
delay: 0,
|
||||
keepResultUntilDownstreamConnected: true,
|
||||
pipeTo: this.conflictResolveQueue,
|
||||
totalRemainingReactiveSource: this.core.conflictProcessQueueCount,
|
||||
totalRemainingReactiveSource: this.services.conflict.conflictProcessQueueCount,
|
||||
}
|
||||
);
|
||||
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.conflict.queueCheckForIfOpen.setHandler(this._queueConflictCheckIfOpen.bind(this));
|
||||
services.conflict.queueCheckFor.setHandler(this._queueConflictCheck.bind(this));
|
||||
services.conflict.ensureAllProcessed.setHandler(this._waitForAllConflictProcessed.bind(this));
|
||||
|
||||
@@ -211,10 +211,30 @@ export class ModuleConflictResolver extends AbstractModule {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
private async _resolveAllConflictedFilesByNewerOnes() {
|
||||
this._log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
const files = this.core.storageAccess.getFileNames();
|
||||
|
||||
let i = 0;
|
||||
for (const file of files) {
|
||||
if (i++ % 10)
|
||||
this._log(
|
||||
`Check and Processing ${i} / ${files.length}`,
|
||||
LOG_LEVEL_NOTICE,
|
||||
"resolveAllConflictedFilesByNewerOnes"
|
||||
);
|
||||
await this.services.conflict.resolveByNewest(file);
|
||||
}
|
||||
this._log(`Done!`, LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.conflict.resolveByDeletingRevision.setHandler(this._resolveConflictByDeletingRev.bind(this));
|
||||
services.conflict.resolve.setHandler(this._resolveConflict.bind(this));
|
||||
services.conflict.resolveByNewest.setHandler(this._anyResolveConflictByNewest.bind(this));
|
||||
services.conflict.resolveAllConflictedFilesByNewerOnes.setHandler(
|
||||
this._resolveAllConflictedFilesByNewerOnes.bind(this)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import type { LiveSyncCore } from "../../main.ts";
|
||||
import FetchEverything from "../features/SetupWizard/dialogs/FetchEverything.svelte";
|
||||
import RebuildEverything from "../features/SetupWizard/dialogs/RebuildEverything.svelte";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { SvelteDialogManagerBase } from "@/lib/src/UI/svelteDialog.ts";
|
||||
import type { ServiceContext } from "@/lib/src/services/base/ServiceBase.ts";
|
||||
import { SvelteDialogManagerBase } from "@lib/UI/svelteDialog.ts";
|
||||
import type { ServiceContext } from "@lib/services/base/ServiceBase.ts";
|
||||
|
||||
export class ModuleRedFlag extends AbstractModule {
|
||||
async isFlagFileExist(path: string) {
|
||||
@@ -324,7 +324,7 @@ export class ModuleRedFlag extends AbstractModule {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
super.onBindFunction(core, services);
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { InjectableServiceHub } from "../../lib/src/services/InjectableServices.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
|
||||
export class ModuleRemoteGovernor extends AbstractModule {
|
||||
private async _markRemoteLocked(lockByClean: boolean = false): Promise<void> {
|
||||
return await this.core.replicator.markRemoteLocked(this.settings, true, lockByClean);
|
||||
}
|
||||
|
||||
private async _markRemoteUnlocked(): Promise<void> {
|
||||
return await this.core.replicator.markRemoteLocked(this.settings, false, false);
|
||||
}
|
||||
|
||||
private async _markRemoteResolved(): Promise<void> {
|
||||
return await this.core.replicator.markRemoteResolved(this.settings);
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.remote.markLocked.setHandler(this._markRemoteLocked.bind(this));
|
||||
services.remote.markUnlocked.setHandler(this._markRemoteUnlocked.bind(this));
|
||||
services.remote.markResolved.setHandler(this._markRemoteResolved.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.tweakValue.fetchRemotePreferred.setHandler(this._fetchRemotePreferredTweakValues.bind(this));
|
||||
services.tweakValue.checkAndAskResolvingMismatched.setHandler(
|
||||
this._checkAndAskResolvingMismatchedTweaks.bind(this)
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
import { TFile, TFolder, type ListedFiles } from "@/deps.ts";
|
||||
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);
|
||||
|
||||
restoreState() {
|
||||
return this.vaultManager.restoreState();
|
||||
}
|
||||
async _everyOnFirstInitialize(): Promise<boolean> {
|
||||
await this.vaultManager.beginWatch();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// $$flushFileEventQueue(): void {
|
||||
// this.vaultManager.flushQueue();
|
||||
// }
|
||||
|
||||
async _everyCommitPendingFileEvent(): Promise<boolean> {
|
||||
await this.vaultManager.waitForIdle();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
_everyOnloadStart(): Promise<boolean> {
|
||||
this.vaultAccess = new SerializedFileAccess(this.app, this.plugin, this);
|
||||
this.core.storageAccess = this;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
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.appLifecycle.onFirstInitialise.addHandler(this._everyOnFirstInitialize.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.fileProcessing.commitPendingFileEvents.addHandler(this._everyCommitPendingFileEvent.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ class AutoClosableModal extends Modal {
|
||||
this._closeByUnload = this._closeByUnload.bind(this);
|
||||
eventHub.once(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
}
|
||||
onClose() {
|
||||
override onClose() {
|
||||
eventHub.off(EVENT_PLUGIN_UNLOADED, this._closeByUnload);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export class InputStringDialog extends AutoClosableModal {
|
||||
this.isPassword = isPassword;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText(this.title);
|
||||
const formEl = contentEl.createDiv();
|
||||
@@ -75,7 +75,7 @@ export class InputStringDialog extends AutoClosableModal {
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
override onClose() {
|
||||
super.onClose();
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
@@ -87,7 +87,7 @@ export class InputStringDialog extends AutoClosableModal {
|
||||
}
|
||||
}
|
||||
export class PopoverSelectString extends FuzzySuggestModal<string> {
|
||||
app: App;
|
||||
_app: App;
|
||||
callback: ((e: string) => void) | undefined = () => {};
|
||||
getItemsFun: () => string[] = () => {
|
||||
return ["yes", "no"];
|
||||
@@ -101,7 +101,7 @@ export class PopoverSelectString extends FuzzySuggestModal<string> {
|
||||
callback: (e: string) => void
|
||||
) {
|
||||
super(app);
|
||||
this.app = app;
|
||||
this._app = app;
|
||||
this.setPlaceholder((placeholder ?? "y/n) ") + note);
|
||||
if (getItemsFun) this.getItemsFun = getItemsFun;
|
||||
this.callback = callback;
|
||||
@@ -120,7 +120,7 @@ export class PopoverSelectString extends FuzzySuggestModal<string> {
|
||||
this.callback?.(item);
|
||||
this.callback = undefined;
|
||||
}
|
||||
onClose(): void {
|
||||
override onClose(): void {
|
||||
setTimeout(() => {
|
||||
if (this.callback) {
|
||||
this.callback("");
|
||||
@@ -184,7 +184,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
|
||||
}
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText(this.title);
|
||||
const div = contentEl.createDiv();
|
||||
@@ -242,7 +242,7 @@ export class MessageBox<T extends readonly string[]> extends AutoClosableModal {
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
override onClose() {
|
||||
super.onClose();
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { type App, TFile, type DataWriteOptions, TFolder, TAbstractFile } from "../../../deps.ts";
|
||||
import { Logger } from "../../../lib/src/common/logger.ts";
|
||||
import { isPlainText } from "../../../lib/src/string_and_binary/path.ts";
|
||||
import type { FilePath, UXFileInfoStub } from "../../../lib/src/common/types.ts";
|
||||
import { createBinaryBlob, isDocContentSame } from "../../../lib/src/common/utils.ts";
|
||||
import type { InternalFileInfo } from "../../../common/types.ts";
|
||||
import { markChangesAreSame } from "../../../common/utils.ts";
|
||||
import type { StorageAccess } from "../../interfaces/StorageAccess.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
function toArrayBuffer(arr: Uint8Array<ArrayBuffer> | ArrayBuffer | DataView<ArrayBuffer>): ArrayBuffer {
|
||||
if (arr instanceof Uint8Array) {
|
||||
return arr.buffer;
|
||||
}
|
||||
if (arr instanceof DataView) {
|
||||
return arr.buffer;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export class SerializedFileAccess {
|
||||
app: App;
|
||||
plugin: LiveSyncCore;
|
||||
storageAccess: StorageAccess;
|
||||
constructor(app: App, plugin: LiveSyncCore, storageAccess: StorageAccess) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.storageAccess = storageAccess;
|
||||
}
|
||||
|
||||
async tryAdapterStat(file: TFile | string) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
return await this.storageAccess.processReadFile(path as FilePath, async () => {
|
||||
if (!(await this.app.vault.adapter.exists(path))) return null;
|
||||
return this.app.vault.adapter.stat(path);
|
||||
});
|
||||
}
|
||||
async adapterStat(file: TFile | string) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.adapter.stat(path));
|
||||
}
|
||||
async adapterExists(file: TFile | string) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.adapter.exists(path));
|
||||
}
|
||||
async adapterRemove(file: TFile | string) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.adapter.remove(path));
|
||||
}
|
||||
|
||||
async adapterRead(file: TFile | string) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.adapter.read(path));
|
||||
}
|
||||
async adapterReadBinary(file: TFile | string) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
return await this.storageAccess.processReadFile(path as FilePath, () =>
|
||||
this.app.vault.adapter.readBinary(path)
|
||||
);
|
||||
}
|
||||
|
||||
async adapterReadAuto(file: TFile | string) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
if (isPlainText(path)) {
|
||||
return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.adapter.read(path));
|
||||
}
|
||||
return await this.storageAccess.processReadFile(path as FilePath, () =>
|
||||
this.app.vault.adapter.readBinary(path)
|
||||
);
|
||||
}
|
||||
|
||||
async adapterWrite(
|
||||
file: TFile | string,
|
||||
data: string | ArrayBuffer | Uint8Array<ArrayBuffer>,
|
||||
options?: DataWriteOptions
|
||||
) {
|
||||
const path = file instanceof TFile ? file.path : file;
|
||||
if (typeof data === "string") {
|
||||
return await this.storageAccess.processWriteFile(path as FilePath, () =>
|
||||
this.app.vault.adapter.write(path, data, options)
|
||||
);
|
||||
} else {
|
||||
return await this.storageAccess.processWriteFile(path as FilePath, () =>
|
||||
this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async vaultCacheRead(file: TFile) {
|
||||
return await this.storageAccess.processReadFile(file.path as FilePath, () => this.app.vault.cachedRead(file));
|
||||
}
|
||||
|
||||
async vaultRead(file: TFile) {
|
||||
return await this.storageAccess.processReadFile(file.path as FilePath, () => this.app.vault.read(file));
|
||||
}
|
||||
|
||||
async vaultReadBinary(file: TFile) {
|
||||
return await this.storageAccess.processReadFile(file.path as FilePath, () => this.app.vault.readBinary(file));
|
||||
}
|
||||
|
||||
async vaultReadAuto(file: TFile) {
|
||||
const path = file.path;
|
||||
if (isPlainText(path)) {
|
||||
return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.read(file));
|
||||
}
|
||||
return await this.storageAccess.processReadFile(path as FilePath, () => this.app.vault.readBinary(file));
|
||||
}
|
||||
|
||||
async vaultModify(file: TFile, data: string | ArrayBuffer | Uint8Array<ArrayBuffer>, options?: DataWriteOptions) {
|
||||
if (typeof data === "string") {
|
||||
return await this.storageAccess.processWriteFile(file.path as FilePath, async () => {
|
||||
const oldData = await this.app.vault.read(file);
|
||||
if (data === oldData) {
|
||||
if (options && options.mtime) markChangesAreSame(file.path, file.stat.mtime, options.mtime);
|
||||
return true;
|
||||
}
|
||||
await this.app.vault.modify(file, data, options);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
return await this.storageAccess.processWriteFile(file.path as FilePath, async () => {
|
||||
const oldData = await this.app.vault.readBinary(file);
|
||||
if (await isDocContentSame(createBinaryBlob(oldData), createBinaryBlob(data))) {
|
||||
if (options && options.mtime) markChangesAreSame(file.path, file.stat.mtime, options.mtime);
|
||||
return true;
|
||||
}
|
||||
await this.app.vault.modifyBinary(file, toArrayBuffer(data), options);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
async vaultCreate(
|
||||
path: string,
|
||||
data: string | ArrayBuffer | Uint8Array<ArrayBuffer>,
|
||||
options?: DataWriteOptions
|
||||
): Promise<TFile> {
|
||||
if (typeof data === "string") {
|
||||
return await this.storageAccess.processWriteFile(path as FilePath, () =>
|
||||
this.app.vault.create(path, data, options)
|
||||
);
|
||||
} else {
|
||||
return await this.storageAccess.processWriteFile(path as FilePath, () =>
|
||||
this.app.vault.createBinary(path, toArrayBuffer(data), options)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
trigger(name: string, ...data: any[]) {
|
||||
return this.app.vault.trigger(name, ...data);
|
||||
}
|
||||
|
||||
async adapterAppend(normalizedPath: string, data: string, options?: DataWriteOptions) {
|
||||
return await this.app.vault.adapter.append(normalizedPath, data, options);
|
||||
}
|
||||
|
||||
async delete(file: TFile | TFolder, force = false) {
|
||||
return await this.storageAccess.processWriteFile(file.path as FilePath, () =>
|
||||
this.app.vault.delete(file, force)
|
||||
);
|
||||
}
|
||||
async trash(file: TFile | TFolder, force = false) {
|
||||
return await this.storageAccess.processWriteFile(file.path as FilePath, () =>
|
||||
this.app.vault.trash(file, force)
|
||||
);
|
||||
}
|
||||
|
||||
isStorageInsensitive(): boolean {
|
||||
return this.plugin.services.vault.isStorageInsensitive();
|
||||
}
|
||||
|
||||
getAbstractFileByPathInsensitive(path: FilePath | string): TAbstractFile | null {
|
||||
//@ts-ignore
|
||||
return this.app.vault.getAbstractFileByPathInsensitive(path);
|
||||
}
|
||||
|
||||
getAbstractFileByPath(path: FilePath | string): TAbstractFile | null {
|
||||
if (!this.plugin.settings.handleFilenameCaseSensitive || this.isStorageInsensitive()) {
|
||||
return this.getAbstractFileByPathInsensitive(path);
|
||||
}
|
||||
return this.app.vault.getAbstractFileByPath(path);
|
||||
}
|
||||
|
||||
getFiles() {
|
||||
return this.app.vault.getFiles();
|
||||
}
|
||||
|
||||
async ensureDirectory(fullPath: string) {
|
||||
const pathElements = fullPath.split("/");
|
||||
pathElements.pop();
|
||||
let c = "";
|
||||
for (const v of pathElements) {
|
||||
c += v;
|
||||
try {
|
||||
await this.app.vault.adapter.mkdir(c);
|
||||
} catch (ex: any) {
|
||||
if (ex?.message == "Folder already exists.") {
|
||||
// Skip if already exists.
|
||||
} else {
|
||||
Logger("Folder Create Error");
|
||||
Logger(ex);
|
||||
}
|
||||
}
|
||||
c += "/";
|
||||
}
|
||||
}
|
||||
|
||||
touchedFiles: string[] = [];
|
||||
|
||||
_statInternal(file: FilePath) {
|
||||
return this.app.vault.adapter.stat(file);
|
||||
}
|
||||
|
||||
async touch(file: TFile | FilePath) {
|
||||
const path = file instanceof TFile ? (file.path as FilePath) : file;
|
||||
const statOrg = file instanceof TFile ? file.stat : await this._statInternal(path);
|
||||
const stat = statOrg || { mtime: 0, size: 0 };
|
||||
const key = `${path}-${stat.mtime}-${stat.size}`;
|
||||
this.touchedFiles.unshift(key);
|
||||
this.touchedFiles = this.touchedFiles.slice(0, 100);
|
||||
}
|
||||
recentlyTouched(file: TFile | InternalFileInfo | UXFileInfoStub) {
|
||||
const key =
|
||||
"stat" in file
|
||||
? `${file.path}-${file.stat.mtime}-${file.stat.size}`
|
||||
: `${file.path}-${file.mtime}-${file.size}`;
|
||||
if (this.touchedFiles.indexOf(key) == -1) return false;
|
||||
return true;
|
||||
}
|
||||
clearTouched() {
|
||||
this.touchedFiles = [];
|
||||
}
|
||||
}
|
||||
@@ -1,650 +0,0 @@
|
||||
import { TAbstractFile, TFile, TFolder } from "../../../deps.ts";
|
||||
import { Logger } from "../../../lib/src/common/logger.ts";
|
||||
import { shouldBeIgnored } from "../../../lib/src/string_and_binary/path.ts";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_DEBUG,
|
||||
LOG_LEVEL_INFO,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type FileEventType,
|
||||
type FilePath,
|
||||
type UXFileInfoStub,
|
||||
type UXInternalFileInfoStub,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import { delay, fireAndForget, throttle } from "../../../lib/src/common/utils.ts";
|
||||
import { type FileEventItem } from "../../../common/types.ts";
|
||||
import { serialized, skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import { isWaitingForTimeout } from "octagonal-wheels/concurrency/task";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import type { LiveSyncCore } from "../../../main.ts";
|
||||
import { InternalFileToUXFileInfoStub, TFileToUXFileInfoStub } from "./utilObsidian.ts";
|
||||
import ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import type { StorageAccess } from "../../interfaces/StorageAccess.ts";
|
||||
import { HiddenFileSync } from "../../../features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
|
||||
// import { InternalFileToUXFileInfo } from "../platforms/obsidian.ts";
|
||||
|
||||
export type FileEvent = {
|
||||
type: FileEventType;
|
||||
file: UXFileInfoStub | UXInternalFileInfoStub;
|
||||
oldPath?: string;
|
||||
cachedData?: string;
|
||||
skipBatchWait?: boolean;
|
||||
cancelled?: boolean;
|
||||
};
|
||||
type WaitInfo = {
|
||||
since: number;
|
||||
type: FileEventType;
|
||||
canProceed: PromiseWithResolvers<boolean>;
|
||||
timerHandler: ReturnType<typeof setTimeout>;
|
||||
event: FileEventItem;
|
||||
};
|
||||
const TYPE_SENTINEL_FLUSH = "SENTINEL_FLUSH";
|
||||
type FileEventItemSentinelFlush = {
|
||||
type: typeof TYPE_SENTINEL_FLUSH;
|
||||
};
|
||||
type FileEventItemSentinel = FileEventItemSentinelFlush;
|
||||
|
||||
export abstract class StorageEventManager {
|
||||
abstract beginWatch(): Promise<void>;
|
||||
|
||||
abstract appendQueue(items: FileEvent[], ctx?: any): Promise<void>;
|
||||
|
||||
abstract isWaiting(filename: FilePath): boolean;
|
||||
abstract waitForIdle(): Promise<void>;
|
||||
abstract restoreState(): Promise<void>;
|
||||
}
|
||||
|
||||
export class StorageEventManagerObsidian extends StorageEventManager {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
core: LiveSyncCore;
|
||||
storageAccess: StorageAccess;
|
||||
get services() {
|
||||
return this.core.services;
|
||||
}
|
||||
|
||||
get shouldBatchSave() {
|
||||
return this.core.settings?.batchSave && this.core.settings?.liveSync != true;
|
||||
}
|
||||
get batchSaveMinimumDelay(): number {
|
||||
return this.core.settings?.batchSaveMinimumDelay ?? DEFAULT_SETTINGS.batchSaveMinimumDelay;
|
||||
}
|
||||
get batchSaveMaximumDelay(): number {
|
||||
return this.core.settings?.batchSaveMaximumDelay ?? DEFAULT_SETTINGS.batchSaveMaximumDelay;
|
||||
}
|
||||
// Necessary evil.
|
||||
cmdHiddenFileSync: HiddenFileSync;
|
||||
|
||||
/**
|
||||
* Snapshot restoration promise.
|
||||
* Snapshot will be restored before starting to watch vault changes.
|
||||
* In designed time, this has been called from Initialisation process, which has been implemented on `ModuleInitializerFile.ts`.
|
||||
*/
|
||||
snapShotRestored: Promise<void> | null = null;
|
||||
|
||||
constructor(plugin: ObsidianLiveSyncPlugin, core: LiveSyncCore, storageAccess: StorageAccess) {
|
||||
super();
|
||||
this.storageAccess = storageAccess;
|
||||
this.plugin = plugin;
|
||||
this.core = core;
|
||||
this.cmdHiddenFileSync = this.plugin.getAddOn(HiddenFileSync.name) as HiddenFileSync;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the previous snapshot if exists.
|
||||
* @returns
|
||||
*/
|
||||
restoreState(): Promise<void> {
|
||||
this.snapShotRestored = this._restoreFromSnapshot();
|
||||
return this.snapShotRestored;
|
||||
}
|
||||
|
||||
async beginWatch() {
|
||||
await this.snapShotRestored;
|
||||
const plugin = this.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);
|
||||
this.watchEditorChange = this.watchEditorChange.bind(this);
|
||||
plugin.registerEvent(plugin.app.vault.on("modify", this.watchVaultChange));
|
||||
plugin.registerEvent(plugin.app.vault.on("delete", this.watchVaultDelete));
|
||||
plugin.registerEvent(plugin.app.vault.on("rename", this.watchVaultRename));
|
||||
plugin.registerEvent(plugin.app.vault.on("create", this.watchVaultCreate));
|
||||
//@ts-ignore : Internal API
|
||||
plugin.registerEvent(plugin.app.vault.on("raw", this.watchVaultRawEvents));
|
||||
plugin.registerEvent(plugin.app.workspace.on("editor-change", this.watchEditorChange));
|
||||
}
|
||||
watchEditorChange(editor: any, info: any) {
|
||||
if (!("path" in info)) {
|
||||
return;
|
||||
}
|
||||
if (!this.shouldBatchSave) {
|
||||
return;
|
||||
}
|
||||
const file = info?.file as TFile;
|
||||
if (!file) return;
|
||||
if (this.storageAccess.isFileProcessing(file.path as FilePath)) {
|
||||
// Logger(`Editor change skipped because the file is being processed: ${file.path}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
if (!this.isWaiting(file.path as FilePath)) {
|
||||
return;
|
||||
}
|
||||
const data = info?.data as string;
|
||||
const fi: FileEvent = {
|
||||
type: "CHANGED",
|
||||
file: TFileToUXFileInfoStub(file),
|
||||
cachedData: data,
|
||||
};
|
||||
void this.appendQueue([fi]);
|
||||
}
|
||||
|
||||
watchVaultCreate(file: TAbstractFile, ctx?: any) {
|
||||
if (file instanceof TFolder) return;
|
||||
if (this.storageAccess.isFileProcessing(file.path as FilePath)) {
|
||||
// Logger(`File create skipped because the file is being processed: ${file.path}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const fileInfo = TFileToUXFileInfoStub(file);
|
||||
void this.appendQueue([{ type: "CREATE", file: fileInfo }], ctx);
|
||||
}
|
||||
|
||||
watchVaultChange(file: TAbstractFile, ctx?: any) {
|
||||
if (file instanceof TFolder) return;
|
||||
if (this.storageAccess.isFileProcessing(file.path as FilePath)) {
|
||||
// Logger(`File change skipped because the file is being processed: ${file.path}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const fileInfo = TFileToUXFileInfoStub(file);
|
||||
void this.appendQueue([{ type: "CHANGED", file: fileInfo }], ctx);
|
||||
}
|
||||
|
||||
watchVaultDelete(file: TAbstractFile, ctx?: any) {
|
||||
if (file instanceof TFolder) return;
|
||||
if (this.storageAccess.isFileProcessing(file.path as FilePath)) {
|
||||
// Logger(`File delete skipped because the file is being processed: ${file.path}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
const fileInfo = TFileToUXFileInfoStub(file, true);
|
||||
void this.appendQueue([{ type: "DELETE", file: fileInfo }], ctx);
|
||||
}
|
||||
watchVaultRename(file: TAbstractFile, oldFile: string, ctx?: any) {
|
||||
// vault Rename will not be raised for self-events (Self-hosted LiveSync will not handle 'rename').
|
||||
if (file instanceof TFile) {
|
||||
const fileInfo = TFileToUXFileInfoStub(file);
|
||||
void this.appendQueue(
|
||||
[
|
||||
{
|
||||
type: "DELETE",
|
||||
file: {
|
||||
path: oldFile as FilePath,
|
||||
name: file.name,
|
||||
stat: {
|
||||
mtime: file.stat.mtime,
|
||||
ctime: file.stat.ctime,
|
||||
size: file.stat.size,
|
||||
type: "file",
|
||||
},
|
||||
deleted: true,
|
||||
},
|
||||
skipBatchWait: true,
|
||||
},
|
||||
{ type: "CREATE", file: fileInfo, skipBatchWait: true },
|
||||
],
|
||||
ctx
|
||||
);
|
||||
}
|
||||
}
|
||||
// Watch raw events (Internal API)
|
||||
watchVaultRawEvents(path: FilePath) {
|
||||
if (this.storageAccess.isFileProcessing(path)) {
|
||||
// Logger(`Raw file event skipped because the file is being processed: ${path}`, LOG_LEVEL_VERBOSE);
|
||||
return;
|
||||
}
|
||||
// Only for internal files.
|
||||
if (!this.plugin.settings) return;
|
||||
// if (this.plugin.settings.useIgnoreFiles && this.plugin.ignoreFiles.some(e => path.endsWith(e.trim()))) {
|
||||
if (this.plugin.settings.useIgnoreFiles) {
|
||||
// If it is one of ignore files, refresh the cached one.
|
||||
// (Calling$$isTargetFile will refresh the cache)
|
||||
void this.services.vault.isTargetFile(path).then(() => this._watchVaultRawEvents(path));
|
||||
} else {
|
||||
void this._watchVaultRawEvents(path);
|
||||
}
|
||||
}
|
||||
|
||||
async _watchVaultRawEvents(path: FilePath) {
|
||||
if (!this.plugin.settings.syncInternalFiles && !this.plugin.settings.usePluginSync) return;
|
||||
if (!this.plugin.settings.watchInternalFileChanges) return;
|
||||
if (!path.startsWith(this.plugin.app.vault.configDir)) return;
|
||||
if (path.endsWith("/")) {
|
||||
// Folder
|
||||
return;
|
||||
}
|
||||
const isTargetFile = await this.cmdHiddenFileSync.isTargetFile(path);
|
||||
if (!isTargetFile) return;
|
||||
|
||||
void this.appendQueue(
|
||||
[
|
||||
{
|
||||
type: "INTERNAL",
|
||||
file: InternalFileToUXFileInfoStub(path),
|
||||
skipBatchWait: true, // Internal files should be processed immediately.
|
||||
},
|
||||
],
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
// Cache file and waiting to can be proceed.
|
||||
async appendQueue(params: FileEvent[], ctx?: any) {
|
||||
if (!this.core.settings.isConfigured) return;
|
||||
if (this.core.settings.suspendFileWatching) return;
|
||||
if (this.core.settings.maxMTimeForReflectEvents > 0) {
|
||||
return;
|
||||
}
|
||||
this.core.services.vault.markFileListPossiblyChanged();
|
||||
// Flag up to be reload
|
||||
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 (type !== "INTERNAL") {
|
||||
const size = (file as UXFileInfoStub).stat.size;
|
||||
if (this.services.vault.isFileSizeTooLarge(size) && (type == "CREATE" || type == "CHANGED")) {
|
||||
Logger(
|
||||
`The storage file has been changed but exceeds the maximum size. Skipping: ${param.file.path}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (file instanceof TFolder) continue;
|
||||
// TODO: Confirm why only the TFolder skipping
|
||||
// Possibly following line is needed...
|
||||
// if (file?.isFolder) continue;
|
||||
if (!(await this.services.vault.isTargetFile(file.path))) continue;
|
||||
|
||||
// Stop cache using to prevent the corruption;
|
||||
// let cache: null | string | ArrayBuffer;
|
||||
// new file or something changed, cache the changes.
|
||||
// if (file instanceof TFile && (type == "CREATE" || type == "CHANGED")) {
|
||||
if (file instanceof TFile || !file.isFolder) {
|
||||
if (type == "CREATE" || type == "CHANGED") {
|
||||
// Wait for a bit while to let the writer has marked `touched` at the file.
|
||||
await delay(10);
|
||||
if (this.core.storageAccess.recentlyTouched(file.path)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cache: string | undefined = undefined;
|
||||
if (param.cachedData) {
|
||||
cache = param.cachedData;
|
||||
}
|
||||
void this.enqueue({
|
||||
type,
|
||||
args: {
|
||||
file: file,
|
||||
oldPath,
|
||||
cache,
|
||||
ctx,
|
||||
},
|
||||
skipBatchWait: param.skipBatchWait,
|
||||
key: atomicKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
private bufferedQueuedItems = [] as (FileEventItem | FileEventItemSentinel)[];
|
||||
|
||||
/**
|
||||
* Immediately take snapshot.
|
||||
*/
|
||||
private _triggerTakeSnapshot() {
|
||||
void this._takeSnapshot();
|
||||
}
|
||||
/**
|
||||
* Trigger taking snapshot after throttled period.
|
||||
*/
|
||||
triggerTakeSnapshot = throttle(() => this._triggerTakeSnapshot(), 100);
|
||||
|
||||
enqueue(newItem: FileEventItem) {
|
||||
if (newItem.type == "DELETE") {
|
||||
// If the sentinel pushed, the runQueuedEvents will wait for idle before processing delete.
|
||||
this.bufferedQueuedItems.push({
|
||||
type: TYPE_SENTINEL_FLUSH,
|
||||
});
|
||||
}
|
||||
this.updateStatus();
|
||||
this.bufferedQueuedItems.push(newItem);
|
||||
|
||||
fireAndForget(() => this._takeSnapshot().then(() => this.runQueuedEvents()));
|
||||
}
|
||||
|
||||
// Limit concurrent processing to reduce the IO load. file-processing + scheduler (1), so file events can be processed in 4 slots.
|
||||
concurrentProcessing = Semaphore(5);
|
||||
|
||||
private _waitingMap = new Map<string, WaitInfo>();
|
||||
private _waitForIdle: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Wait until all queued events are processed.
|
||||
* Subsequent new events will not be waited, but new events will not be added.
|
||||
* @returns
|
||||
*/
|
||||
waitForIdle(): Promise<void> {
|
||||
if (this._waitingMap.size === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this._waitForIdle) {
|
||||
return this._waitForIdle;
|
||||
}
|
||||
const promises = [...this._waitingMap.entries()].map(([key, waitInfo]) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
waitInfo.canProceed.promise
|
||||
.then(() => {
|
||||
Logger(`Processing ${key}: Wait for idle completed`, LOG_LEVEL_DEBUG);
|
||||
// No op
|
||||
})
|
||||
.catch((e) => {
|
||||
Logger(`Processing ${key}: Wait for idle error`, LOG_LEVEL_INFO);
|
||||
Logger(e, LOG_LEVEL_VERBOSE);
|
||||
//no op
|
||||
})
|
||||
.finally(() => {
|
||||
resolve();
|
||||
});
|
||||
this._proceedWaiting(key);
|
||||
});
|
||||
});
|
||||
const waitPromise = Promise.all(promises).then(() => {
|
||||
this._waitForIdle = null;
|
||||
Logger(`All wait for idle completed`, LOG_LEVEL_VERBOSE);
|
||||
});
|
||||
this._waitForIdle = waitPromise;
|
||||
return waitPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proceed waiting for the given key immediately.
|
||||
*/
|
||||
private _proceedWaiting(key: string) {
|
||||
const waitInfo = this._waitingMap.get(key);
|
||||
if (waitInfo) {
|
||||
waitInfo.canProceed.resolve(true);
|
||||
clearTimeout(waitInfo.timerHandler);
|
||||
this._waitingMap.delete(key);
|
||||
}
|
||||
this.triggerTakeSnapshot();
|
||||
}
|
||||
/**
|
||||
* Cancel waiting for the given key.
|
||||
*/
|
||||
private _cancelWaiting(key: string) {
|
||||
const waitInfo = this._waitingMap.get(key);
|
||||
if (waitInfo) {
|
||||
waitInfo.canProceed.resolve(false);
|
||||
clearTimeout(waitInfo.timerHandler);
|
||||
this._waitingMap.delete(key);
|
||||
}
|
||||
this.triggerTakeSnapshot();
|
||||
}
|
||||
/**
|
||||
* Add waiting for the given key.
|
||||
* @param key
|
||||
* @param event
|
||||
* @param waitedSince Optional waited since timestamp to calculate the remaining delay.
|
||||
*/
|
||||
private _addWaiting(key: string, event: FileEventItem, waitedSince?: number): WaitInfo {
|
||||
if (this._waitingMap.has(key)) {
|
||||
// Already waiting
|
||||
throw new Error(`Already waiting for key: ${key}`);
|
||||
}
|
||||
const resolver = promiseWithResolvers<boolean>();
|
||||
const now = Date.now();
|
||||
const since = waitedSince ?? now;
|
||||
const elapsed = now - since;
|
||||
const maxDelay = this.batchSaveMaximumDelay * 1000;
|
||||
const remainingDelay = Math.max(0, maxDelay - elapsed);
|
||||
const nextDelay = Math.min(remainingDelay, this.batchSaveMinimumDelay * 1000);
|
||||
// x*<------- maxDelay --------->*
|
||||
// x*<-- minDelay -->*
|
||||
// x* x<-- nextDelay -->*
|
||||
// x* x<-- Capped-->*
|
||||
// x* x.......*
|
||||
// x: event
|
||||
// *: save
|
||||
// When at event (x) At least, save (*) within maxDelay, but maintain minimum delay between saves.
|
||||
|
||||
if (elapsed >= maxDelay) {
|
||||
// Already exceeded maximum delay, do not wait.
|
||||
Logger(`Processing ${key}: Batch save maximum delay already exceeded: ${event.type}`, LOG_LEVEL_DEBUG);
|
||||
} else {
|
||||
Logger(`Processing ${key}: Adding waiting for batch save: ${event.type} (${nextDelay}ms)`, LOG_LEVEL_DEBUG);
|
||||
}
|
||||
const waitInfo: WaitInfo = {
|
||||
since: since,
|
||||
type: event.type,
|
||||
event: event,
|
||||
canProceed: resolver,
|
||||
timerHandler: setTimeout(() => {
|
||||
Logger(`Processing ${key}: Batch save timeout reached: ${event.type}`, LOG_LEVEL_DEBUG);
|
||||
this._proceedWaiting(key);
|
||||
}, nextDelay),
|
||||
};
|
||||
this._waitingMap.set(key, waitInfo);
|
||||
this.triggerTakeSnapshot();
|
||||
return waitInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the given file event.
|
||||
*/
|
||||
async processFileEvent(fei: FileEventItem) {
|
||||
const releaser = await this.concurrentProcessing.acquire();
|
||||
try {
|
||||
this.updateStatus();
|
||||
const filename = fei.args.file.path;
|
||||
const waitingKey = `${filename}`;
|
||||
const previous = this._waitingMap.get(waitingKey);
|
||||
let isShouldBeCancelled = fei.skipBatchWait || false;
|
||||
let previousPromise: Promise<boolean> = Promise.resolve(true);
|
||||
let waitPromise: Promise<boolean> = Promise.resolve(true);
|
||||
// 1. Check if there is previous waiting for the same file
|
||||
if (previous) {
|
||||
previousPromise = previous.canProceed.promise;
|
||||
if (isShouldBeCancelled) {
|
||||
Logger(
|
||||
`Processing ${filename}: Requested to perform immediately, cancelling previous waiting: ${fei.type}`,
|
||||
LOG_LEVEL_DEBUG
|
||||
);
|
||||
}
|
||||
if (!isShouldBeCancelled && fei.type === "DELETE") {
|
||||
// For DELETE, cancel any previous waiting and proceed immediately
|
||||
// That because when deleting, we cannot read the file anymore.
|
||||
Logger(
|
||||
`Processing ${filename}: DELETE requested, cancelling previous waiting: ${fei.type}`,
|
||||
LOG_LEVEL_DEBUG
|
||||
);
|
||||
isShouldBeCancelled = true;
|
||||
}
|
||||
if (!isShouldBeCancelled && previous.type === fei.type) {
|
||||
// For the same type, we can cancel the previous waiting and proceed immediately.
|
||||
Logger(`Processing ${filename}: Cancelling previous waiting: ${fei.type}`, LOG_LEVEL_DEBUG);
|
||||
isShouldBeCancelled = true;
|
||||
}
|
||||
// 2. wait for the previous to complete
|
||||
if (isShouldBeCancelled) {
|
||||
this._cancelWaiting(waitingKey);
|
||||
Logger(`Processing ${filename}: Previous cancelled: ${fei.type}`, LOG_LEVEL_DEBUG);
|
||||
isShouldBeCancelled = true;
|
||||
}
|
||||
if (!isShouldBeCancelled) {
|
||||
Logger(`Processing ${filename}: Waiting for previous to complete: ${fei.type}`, LOG_LEVEL_DEBUG);
|
||||
this._proceedWaiting(waitingKey);
|
||||
Logger(`Processing ${filename}: Previous completed: ${fei.type}`, LOG_LEVEL_DEBUG);
|
||||
}
|
||||
}
|
||||
await previousPromise;
|
||||
// 3. Check if shouldBatchSave is true
|
||||
if (this.shouldBatchSave && !fei.skipBatchWait) {
|
||||
// if type is CREATE or CHANGED, set waiting
|
||||
if (fei.type == "CREATE" || fei.type == "CHANGED") {
|
||||
// 3.2. If true, set the queue, and wait for the waiting, or until timeout
|
||||
// (since is copied from previous waiting if exists to limit the maximum wait time)
|
||||
// console.warn(`Since:`, previous?.since);
|
||||
const info = this._addWaiting(waitingKey, fei, previous?.since);
|
||||
waitPromise = info.canProceed.promise;
|
||||
} else if (fei.type == "DELETE") {
|
||||
// For DELETE, cancel any previous waiting and proceed immediately
|
||||
}
|
||||
Logger(`Processing ${filename}: Waiting for batch save: ${fei.type}`, LOG_LEVEL_DEBUG);
|
||||
const canProceed = await waitPromise;
|
||||
if (!canProceed) {
|
||||
// 3.2.1. If cancelled by new queue, cancel subsequent process.
|
||||
Logger(`Processing ${filename}: Cancelled by new queue: ${fei.type}`, LOG_LEVEL_DEBUG);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// await this.handleFileEvent(fei);
|
||||
await this.requestProcessQueue(fei);
|
||||
} finally {
|
||||
await this._takeSnapshot();
|
||||
releaser();
|
||||
}
|
||||
}
|
||||
async _takeSnapshot() {
|
||||
const processingEvents = [...this._waitingMap.values()].map((e) => e.event);
|
||||
const waitingEvents = this.bufferedQueuedItems;
|
||||
const snapShot = [...processingEvents, ...waitingEvents];
|
||||
await this.core.kvDB.set("storage-event-manager-snapshot", snapShot);
|
||||
Logger(`Storage operation snapshot taken: ${snapShot.length} items`, LOG_LEVEL_DEBUG);
|
||||
this.updateStatus();
|
||||
}
|
||||
async _restoreFromSnapshot() {
|
||||
const snapShot = await this.core.kvDB.get<(FileEventItem | FileEventItemSentinel)[]>(
|
||||
"storage-event-manager-snapshot"
|
||||
);
|
||||
if (snapShot && Array.isArray(snapShot) && snapShot.length > 0) {
|
||||
// console.warn(`Restoring snapshot: ${snapShot.length} items`);
|
||||
Logger(`Restoring storage operation snapshot: ${snapShot.length} items`, LOG_LEVEL_VERBOSE);
|
||||
// Restore the snapshot
|
||||
// Note: Mark all items as skipBatchWait to prevent apply the off-line batch saving.
|
||||
this.bufferedQueuedItems = snapShot.map((e) => ({ ...e, skipBatchWait: true }));
|
||||
this.updateStatus();
|
||||
await this.runQueuedEvents();
|
||||
} else {
|
||||
Logger(`No snapshot to restore`, LOG_LEVEL_VERBOSE);
|
||||
// console.warn(`No snapshot to restore`);
|
||||
}
|
||||
}
|
||||
runQueuedEvents() {
|
||||
return skipIfDuplicated("storage-event-manager-run-queued-events", async () => {
|
||||
do {
|
||||
if (this.bufferedQueuedItems.length === 0) {
|
||||
break;
|
||||
}
|
||||
// 1. Get the first queued item
|
||||
|
||||
const fei = this.bufferedQueuedItems.shift()!;
|
||||
await this._takeSnapshot();
|
||||
this.updateStatus();
|
||||
// 2. Consume 1 semaphore slot to enqueue processing. Then release immediately.
|
||||
// (Just to limit the total concurrent processing count, because skipping batch handles at processFileEvent).
|
||||
const releaser = await this.concurrentProcessing.acquire();
|
||||
releaser();
|
||||
this.updateStatus();
|
||||
// 3. Check if sentinel flush
|
||||
// If sentinel, wait for idle and continue.
|
||||
if (fei.type === TYPE_SENTINEL_FLUSH) {
|
||||
Logger(`Waiting for idle`, LOG_LEVEL_VERBOSE);
|
||||
// Flush all waiting batch queues
|
||||
await this.waitForIdle();
|
||||
this.updateStatus();
|
||||
continue;
|
||||
}
|
||||
// 4. Process the event, this should be fire-and-forget to not block the queue processing in each file.
|
||||
fireAndForget(() => this.processFileEvent(fei));
|
||||
} while (this.bufferedQueuedItems.length > 0);
|
||||
});
|
||||
}
|
||||
|
||||
processingCount = 0;
|
||||
async requestProcessQueue(fei: FileEventItem) {
|
||||
try {
|
||||
this.processingCount++;
|
||||
// this.bufferedQueuedItems.remove(fei);
|
||||
this.updateStatus();
|
||||
// this.waitedSince.delete(fei.args.file.path);
|
||||
await this.handleFileEvent(fei);
|
||||
await this._takeSnapshot();
|
||||
} finally {
|
||||
this.processingCount--;
|
||||
this.updateStatus();
|
||||
}
|
||||
}
|
||||
isWaiting(filename: FilePath) {
|
||||
return isWaitingForTimeout(`storage-event-manager-batchsave-${filename}`);
|
||||
}
|
||||
|
||||
updateStatus() {
|
||||
const allFileEventItems = this.bufferedQueuedItems.filter((e): e is FileEventItem => "args" in e);
|
||||
const allItems = allFileEventItems.filter((e) => !e.cancelled);
|
||||
const totalItems = allItems.length + this.concurrentProcessing.waiting;
|
||||
const processing = this.processingCount;
|
||||
const batchedCount = this._waitingMap.size;
|
||||
this.core.batched.value = batchedCount;
|
||||
this.core.processing.value = processing;
|
||||
this.core.totalQueued.value = totalItems + batchedCount + processing;
|
||||
}
|
||||
|
||||
async handleFileEvent(queue: FileEventItem): Promise<any> {
|
||||
const file = queue.args.file;
|
||||
const lockKey = `handleFile:${file.path}`;
|
||||
const ret = await serialized(lockKey, async () => {
|
||||
if (queue.cancelled) {
|
||||
Logger(`File event cancelled before processing: ${file.path}`, LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
if (queue.type == "INTERNAL" || file.isInternal) {
|
||||
await this.core.services.fileProcessing.processOptionalFileEvent(file.path as unknown as FilePath);
|
||||
} else {
|
||||
const key = `file-last-proc-${queue.type}-${file.path}`;
|
||||
const last = Number((await this.core.kvDB.get(key)) || 0);
|
||||
if (queue.type == "DELETE") {
|
||||
await this.core.services.fileProcessing.processFileEvent(queue);
|
||||
} else {
|
||||
if (file.stat.mtime == last) {
|
||||
Logger(`File has been already scanned on ${queue.type}, skip: ${file.path}`, LOG_LEVEL_VERBOSE);
|
||||
// Should Cancel the relative operations? (e.g. rename)
|
||||
// this.cancelRelativeEvent(queue);
|
||||
return;
|
||||
}
|
||||
if (!(await this.core.services.fileProcessing.processFileEvent(queue))) {
|
||||
Logger(
|
||||
`STORAGE -> DB: Handler failed, cancel the relative operations: ${file.path}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
// cancel running queues and remove one of atomic operation (e.g. rename)
|
||||
this.cancelRelativeEvent(queue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
this.updateStatus();
|
||||
return ret;
|
||||
}
|
||||
|
||||
cancelRelativeEvent(item: FileEventItem): void {
|
||||
this._cancelWaiting(item.args.file.path);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { TFile, type TAbstractFile, type TFolder } from "../../../deps.ts";
|
||||
import { ICHeader } from "../../../common/types.ts";
|
||||
import type { SerializedFileAccess } from "./SerializedFileAccess.ts";
|
||||
import { addPrefix, isPlainText } from "../../../lib/src/string_and_binary/path.ts";
|
||||
import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { createBlob } from "../../../lib/src/common/utils.ts";
|
||||
@@ -15,6 +14,7 @@ import type {
|
||||
UXInternalFileInfoStub,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import type { LiveSyncCore } from "../../../main.ts";
|
||||
import type { FileAccessObsidian } from "@/serviceModules/FileAccessObsidian.ts";
|
||||
|
||||
export async function TFileToUXFileInfo(
|
||||
core: LiveSyncCore,
|
||||
@@ -51,7 +51,7 @@ export async function TFileToUXFileInfo(
|
||||
|
||||
export async function InternalFileToUXFileInfo(
|
||||
fullPath: string,
|
||||
vaultAccess: SerializedFileAccess,
|
||||
vaultAccess: FileAccessObsidian,
|
||||
prefix: string = ICHeader
|
||||
): Promise<UXFileInfo> {
|
||||
const name = fullPath.split("/").pop() as string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { unique } from "octagonal-wheels/collection";
|
||||
import { throttle } from "octagonal-wheels/function";
|
||||
import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "../../common/events.ts";
|
||||
import { BASE_IS_NEW, compareFileFreshness, EVEN, isValidPath, TARGET_IS_NEW } from "../../common/utils.ts";
|
||||
import { BASE_IS_NEW, EVEN, isValidPath, TARGET_IS_NEW } from "../../common/utils.ts";
|
||||
import {
|
||||
type FilePathWithPrefixLC,
|
||||
type FilePathWithPrefix,
|
||||
@@ -308,7 +308,7 @@ export class ModuleInitializerFile extends AbstractModule {
|
||||
}
|
||||
}
|
||||
|
||||
const compareResult = compareFileFreshness(file, doc);
|
||||
const compareResult = this.services.path.compareFileFreshness(file, doc);
|
||||
switch (compareResult) {
|
||||
case BASE_IS_NEW:
|
||||
if (!this.services.vault.isFileSizeTooLarge(file.stat.size)) {
|
||||
@@ -421,9 +421,9 @@ export class ModuleInitializerFile extends AbstractModule {
|
||||
private _reportDetectedErrors(): Promise<string[]> {
|
||||
return Promise.resolve(Array.from(this._detectedErrors));
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(this._reportDetectedErrors.bind(this));
|
||||
services.databaseEvents.initialiseDatabase.setHandler(this._initializeDatabase.bind(this));
|
||||
services.vault.scanVault.setHandler(this._performFullScan.bind(this));
|
||||
services.databaseEvents.initialiseDatabase.addHandler(this._initializeDatabase.bind(this));
|
||||
services.vault.scanVault.addHandler(this._performFullScan.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,9 +262,7 @@ export class ModuleMigration extends AbstractModule {
|
||||
// Check local database for compromised chunks
|
||||
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
|
||||
const remote = this.services.replicator.getActiveReplicator();
|
||||
const remoteCompromised = this.core.managers.networkManager.isOnline
|
||||
? await remote?.countCompromisedChunks()
|
||||
: 0;
|
||||
const remoteCompromised = this.services.API.isOnline ? await remote?.countCompromisedChunks() : 0;
|
||||
if (localCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
@@ -353,7 +351,7 @@ export class ModuleMigration extends AbstractModule {
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
super.onBindFunction(core, services);
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
services.appLifecycle.onFirstInitialise.addHandler(this._everyOnFirstInitialize.bind(this));
|
||||
|
||||
@@ -28,7 +28,10 @@ export class ObsHttpHandler extends FetchHttpHandler {
|
||||
this.reverseProxyNoSignUrl = reverseProxyNoSignUrl;
|
||||
}
|
||||
// eslint-disable-next-line require-await
|
||||
async handle(request: HttpRequest, { abortSignal }: HttpHandlerOptions = {}): Promise<{ response: HttpResponse }> {
|
||||
override async handle(
|
||||
request: HttpRequest,
|
||||
{ abortSignal }: HttpHandlerOptions = {}
|
||||
): Promise<{ response: HttpResponse }> {
|
||||
if (abortSignal?.aborted) {
|
||||
const abortError = new Error("Request aborted");
|
||||
abortError.name = "AbortError";
|
||||
|
||||
@@ -12,7 +12,7 @@ export class ModuleCheckRemoteSize extends AbstractModule {
|
||||
}
|
||||
|
||||
private async _allScanStat(): Promise<boolean> {
|
||||
if (this.core.managers.networkManager.isOnline === false) {
|
||||
if (this.services.API.isOnline === false) {
|
||||
this._log("Network is offline, skipping remote size check.", LOG_LEVEL_INFO);
|
||||
return true;
|
||||
}
|
||||
@@ -127,7 +127,7 @@ export class ModuleCheckRemoteSize extends AbstractModule {
|
||||
eventHub.onEvent(EVENT_REQUEST_CHECK_REMOTE_SIZE, () => this.checkRemoteSize());
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import { AbstractObsidianModule } from "../AbstractObsidianModule.ts";
|
||||
import {
|
||||
LEVEL_INFO,
|
||||
LEVEL_NOTICE,
|
||||
LOG_LEVEL_DEBUG,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type LOG_LEVEL,
|
||||
} from "octagonal-wheels/common/logger";
|
||||
import { Notice, requestUrl, type RequestUrlParam, type RequestUrlResponse } from "../../deps.ts";
|
||||
import { type CouchDBCredentials, type EntryDoc } from "../../lib/src/common/types.ts";
|
||||
import { isCloudantURI, isValidRemoteCouchDBURI } from "../../lib/src/pouchdb/utils_couchdb.ts";
|
||||
import { replicationFilter } from "@/lib/src/pouchdb/compress.ts";
|
||||
import { disableEncryption } from "@/lib/src/pouchdb/encryption.ts";
|
||||
import { enableEncryption } from "@/lib/src/pouchdb/encryption.ts";
|
||||
import { setNoticeClass } from "../../lib/src/mock_and_interop/wrapper.ts";
|
||||
import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.ts";
|
||||
import { AuthorizationHeaderGenerator } from "../../lib/src/replication/httplib.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "../../common/events.ts";
|
||||
|
||||
setNoticeClass(Notice);
|
||||
|
||||
async function fetchByAPI(request: RequestUrlParam, errorAsResult = false): Promise<RequestUrlResponse> {
|
||||
const ret = await requestUrl({ ...request, throw: !errorAsResult });
|
||||
return ret;
|
||||
}
|
||||
export class ModuleObsidianAPI extends AbstractObsidianModule {
|
||||
_authHeader = new AuthorizationHeaderGenerator();
|
||||
_previousErrors = new Set<string>();
|
||||
|
||||
showError(msg: string, max_log_level: LOG_LEVEL = LEVEL_NOTICE) {
|
||||
const level = this._previousErrors.has(msg) ? LEVEL_INFO : max_log_level;
|
||||
this._log(msg, level);
|
||||
if (!this._previousErrors.has(msg)) {
|
||||
this._previousErrors.add(msg);
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
}
|
||||
}
|
||||
clearErrors() {
|
||||
this._previousErrors.clear();
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
}
|
||||
last_successful_post = false;
|
||||
|
||||
_getLastPostFailedBySize(): boolean {
|
||||
return !this.last_successful_post;
|
||||
}
|
||||
|
||||
async __fetchByAPI(url: string, authHeader: string, opts?: RequestInit): Promise<Response> {
|
||||
const body = opts?.body as string;
|
||||
|
||||
const optHeaders = {} as Record<string, string>;
|
||||
if (opts && "headers" in opts) {
|
||||
if (opts.headers instanceof Headers) {
|
||||
// For Compatibility, mostly headers.entries() is supported, but not all environments.
|
||||
opts.headers.forEach((value, key) => {
|
||||
optHeaders[key] = value;
|
||||
});
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(opts.headers as Record<string, string>)) {
|
||||
optHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
const transformedHeaders = { ...optHeaders };
|
||||
if (authHeader != "") transformedHeaders["authorization"] = authHeader;
|
||||
delete transformedHeaders["host"];
|
||||
delete transformedHeaders["Host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
delete transformedHeaders["Content-Length"];
|
||||
const requestParam: RequestUrlParam = {
|
||||
url,
|
||||
method: opts?.method,
|
||||
body: body,
|
||||
headers: transformedHeaders,
|
||||
contentType:
|
||||
transformedHeaders?.["content-type"] ?? transformedHeaders?.["Content-Type"] ?? "application/json",
|
||||
};
|
||||
const r = await fetchByAPI(requestParam, true);
|
||||
return new Response(r.arrayBuffer, {
|
||||
headers: r.headers,
|
||||
status: r.status,
|
||||
statusText: `${r.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
async fetchByAPI(
|
||||
url: string,
|
||||
localURL: string,
|
||||
method: string,
|
||||
authHeader: string,
|
||||
opts?: RequestInit
|
||||
): Promise<Response> {
|
||||
const body = opts?.body as string;
|
||||
const size = body ? ` (${body.length})` : "";
|
||||
try {
|
||||
const r = await this.__fetchByAPI(url, authHeader, opts);
|
||||
this.plugin.requestCount.value = this.plugin.requestCount.value + 1;
|
||||
if (method == "POST" || method == "PUT") {
|
||||
this.last_successful_post = r.status - (r.status % 100) == 200;
|
||||
} else {
|
||||
this.last_successful_post = true;
|
||||
}
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> ${r.status}`, LOG_LEVEL_DEBUG);
|
||||
return r;
|
||||
} catch (ex) {
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> failed`, LOG_LEVEL_VERBOSE);
|
||||
// limit only in bulk_docs.
|
||||
if (url.toString().indexOf("_bulk_docs") !== -1) {
|
||||
this.last_successful_post = false;
|
||||
}
|
||||
this._log(ex);
|
||||
throw ex;
|
||||
} finally {
|
||||
this.plugin.responseCount.value = this.plugin.responseCount.value + 1;
|
||||
}
|
||||
}
|
||||
|
||||
async _connectRemoteCouchDB(
|
||||
uri: string,
|
||||
auth: CouchDBCredentials,
|
||||
disableRequestURI: boolean,
|
||||
passphrase: string | false,
|
||||
useDynamicIterationCount: boolean,
|
||||
performSetup: boolean,
|
||||
skipInfo: boolean,
|
||||
compression: boolean,
|
||||
customHeaders: Record<string, string>,
|
||||
useRequestAPI: boolean,
|
||||
getPBKDF2Salt: () => Promise<Uint8Array<ArrayBuffer>>
|
||||
): Promise<string | { db: PouchDB.Database<EntryDoc>; info: PouchDB.Core.DatabaseInfo }> {
|
||||
if (!isValidRemoteCouchDBURI(uri)) return "Remote URI is not valid";
|
||||
if (uri.toLowerCase() != uri) return "Remote URI and database name could not contain capital letters.";
|
||||
if (uri.indexOf(" ") !== -1) return "Remote URI and database name could not contain spaces.";
|
||||
if (!this.core.managers.networkManager.isOnline) {
|
||||
return "Network is offline";
|
||||
}
|
||||
// let authHeader = await this._authHeader.getAuthorizationHeader(auth);
|
||||
const conf: PouchDB.HttpAdapter.HttpAdapterConfiguration = {
|
||||
adapter: "http",
|
||||
auth: "username" in auth ? auth : undefined,
|
||||
skip_setup: !performSetup,
|
||||
fetch: async (url: string | Request, opts?: RequestInit) => {
|
||||
const authHeader = await this._authHeader.getAuthorizationHeader(auth);
|
||||
let size = "";
|
||||
const localURL = url.toString().substring(uri.length);
|
||||
const method = opts?.method ?? "GET";
|
||||
if (opts?.body) {
|
||||
const opts_length = opts.body.toString().length;
|
||||
if (opts_length > 1000 * 1000 * 10) {
|
||||
// over 10MB
|
||||
if (isCloudantURI(uri)) {
|
||||
this.last_successful_post = false;
|
||||
this._log("This request should fail on IBM Cloudant.", LOG_LEVEL_VERBOSE);
|
||||
throw new Error("This request should fail on IBM Cloudant.");
|
||||
}
|
||||
}
|
||||
size = ` (${opts_length})`;
|
||||
}
|
||||
try {
|
||||
const headers = new Headers(opts?.headers);
|
||||
if (customHeaders) {
|
||||
for (const [key, value] of Object.entries(customHeaders)) {
|
||||
if (key && value) {
|
||||
headers.append(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!("username" in auth)) {
|
||||
headers.append("authorization", authHeader);
|
||||
}
|
||||
try {
|
||||
this.plugin.requestCount.value = this.plugin.requestCount.value + 1;
|
||||
const response: Response = await (useRequestAPI
|
||||
? this.__fetchByAPI(url.toString(), authHeader, { ...opts, headers })
|
||||
: fetch(url, { ...opts, headers }));
|
||||
if (method == "POST" || method == "PUT") {
|
||||
this.last_successful_post = response.ok;
|
||||
} else {
|
||||
this.last_successful_post = true;
|
||||
}
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> ${response.status}`, LOG_LEVEL_DEBUG);
|
||||
if (Math.floor(response.status / 100) !== 2) {
|
||||
if (response.status == 404) {
|
||||
if (method === "GET" && localURL.indexOf("/_local/") === -1) {
|
||||
this._log(
|
||||
`Just checkpoint or some server information has been missing. The 404 error shown above is not an error.`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const r = response.clone();
|
||||
this._log(
|
||||
`The request may have failed. The reason sent by the server: ${r.status}: ${r.statusText}`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
try {
|
||||
const result = await r.text();
|
||||
this._log(result, LOG_LEVEL_VERBOSE);
|
||||
} catch (_) {
|
||||
this._log("Cloud not fetch response body", LOG_LEVEL_VERBOSE);
|
||||
this._log(_, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.clearErrors();
|
||||
return response;
|
||||
} catch (ex) {
|
||||
if (ex instanceof TypeError) {
|
||||
if (useRequestAPI) {
|
||||
this._log("Failed to request by API.");
|
||||
throw ex;
|
||||
}
|
||||
this._log(
|
||||
"Failed to fetch by native fetch API. Trying to fetch by API to get more information."
|
||||
);
|
||||
const resp2 = await this.fetchByAPI(url.toString(), localURL, method, authHeader, {
|
||||
...opts,
|
||||
headers,
|
||||
});
|
||||
if (resp2.status / 100 == 2) {
|
||||
this.showError(
|
||||
"The request was successful by API. But the native fetch API failed! Please check CORS settings on the remote database!. While this condition, you cannot enable LiveSync",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return resp2;
|
||||
}
|
||||
const r2 = resp2.clone();
|
||||
const msg = await r2.text();
|
||||
this.showError(`Failed to fetch by API. ${resp2.status}: ${msg}`, LOG_LEVEL_NOTICE);
|
||||
return resp2;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
} catch (ex: any) {
|
||||
this._log(`HTTP:${method}${size} to:${localURL} -> failed`, LOG_LEVEL_VERBOSE);
|
||||
const msg = ex instanceof Error ? `${ex?.name}:${ex?.message}` : ex?.toString();
|
||||
this.showError(`Failed to fetch: ${msg}`); // Do not show notice, due to throwing below
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
// limit only in bulk_docs.
|
||||
if (url.toString().indexOf("_bulk_docs") !== -1) {
|
||||
this.last_successful_post = false;
|
||||
}
|
||||
this._log(ex);
|
||||
throw ex;
|
||||
} finally {
|
||||
this.plugin.responseCount.value = this.plugin.responseCount.value + 1;
|
||||
}
|
||||
|
||||
// return await fetch(url, opts);
|
||||
},
|
||||
};
|
||||
|
||||
const db: PouchDB.Database<EntryDoc> = new PouchDB<EntryDoc>(uri, conf);
|
||||
replicationFilter(db, compression);
|
||||
disableEncryption();
|
||||
if (passphrase !== "false" && typeof passphrase === "string") {
|
||||
enableEncryption(
|
||||
db,
|
||||
passphrase,
|
||||
useDynamicIterationCount,
|
||||
false,
|
||||
getPBKDF2Salt,
|
||||
this.settings.E2EEAlgorithm
|
||||
);
|
||||
}
|
||||
if (skipInfo) {
|
||||
return { db: db, info: { db_name: "", doc_count: 0, update_seq: "" } };
|
||||
}
|
||||
try {
|
||||
const info = await db.info();
|
||||
return { db: db, info: info };
|
||||
} catch (ex: any) {
|
||||
const msg = `${ex?.name}:${ex?.message}`;
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
private _reportUnresolvedMessages(): Promise<(string | Error)[]> {
|
||||
return Promise.resolve([...this._previousErrors]);
|
||||
}
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services) {
|
||||
services.API.isLastPostFailedDueToPayloadSize.setHandler(this._getLastPostFailedBySize.bind(this));
|
||||
services.remote.connect.setHandler(this._connectRemoteCouchDB.bind(this));
|
||||
services.appLifecycle.getUnresolvedMessages.addHandler(this._reportUnresolvedMessages.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||
import { type TFile } from "../../deps.ts";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { type FilePathWithPrefix } from "../../lib/src/common/types.ts";
|
||||
import { reactive, reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { reactive, reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import {
|
||||
collectingChunks,
|
||||
pluginScanningCount,
|
||||
@@ -45,7 +45,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
this.initialCallback = save;
|
||||
saveCommandDefinition.callback = () => {
|
||||
scheduleTask("syncOnEditorSave", 250, () => {
|
||||
if (this.services.appLifecycle.hasUnloaded()) {
|
||||
if (this.services.control.hasUnloaded()) {
|
||||
this._log("Unload and remove the handler.", LOG_LEVEL_VERBOSE);
|
||||
saveCommandDefinition.callback = this.initialCallback;
|
||||
this.initialCallback = undefined;
|
||||
@@ -188,20 +188,25 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
}
|
||||
});
|
||||
}
|
||||
// TODO: separate
|
||||
|
||||
// Process counting for app reload scheduling
|
||||
_totalProcessingCount?: ReactiveSource<number> = undefined;
|
||||
private _scheduleAppReload() {
|
||||
if (!this.core._totalProcessingCount) {
|
||||
if (!this._totalProcessingCount) {
|
||||
const __tick = reactiveSource(0);
|
||||
this.core._totalProcessingCount = reactive(() => {
|
||||
const dbCount = this.core.databaseQueueCount.value;
|
||||
const replicationCount = this.core.replicationResultCount.value;
|
||||
const storageApplyingCount = this.core.storageApplyingCount.value;
|
||||
this._totalProcessingCount = reactive(() => {
|
||||
const dbCount = this.services.replication.databaseQueueCount.value;
|
||||
const replicationCount = this.services.replication.replicationResultCount.value;
|
||||
const storageApplyingCount = this.services.replication.storageApplyingCount.value;
|
||||
const chunkCount = collectingChunks.value;
|
||||
const pluginScanCount = pluginScanningCount.value;
|
||||
const hiddenFilesCount = hiddenFilesEventCount.value + hiddenFilesProcessingCount.value;
|
||||
const conflictProcessCount = this.core.conflictProcessQueueCount.value;
|
||||
const e = this.core.pendingFileEventCount.value;
|
||||
const proc = this.core.processingFileEventCount.value;
|
||||
const conflictProcessCount = this.services.conflict.conflictProcessQueueCount.value;
|
||||
// Now no longer `pendingFileEventCount` and `processingFileEventCount` is used
|
||||
// const e = this.core.pendingFileEventCount.value;
|
||||
// const proc = this.core.processingFileEventCount.value;
|
||||
const e = 0;
|
||||
const proc = 0;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const __ = __tick.value;
|
||||
return (
|
||||
@@ -223,7 +228,7 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
);
|
||||
|
||||
let stableCheck = 3;
|
||||
this.core._totalProcessingCount.onChanged((e) => {
|
||||
this._totalProcessingCount.onChanged((e) => {
|
||||
if (e.value == 0) {
|
||||
if (stableCheck-- <= 0) {
|
||||
this.__performAppReload();
|
||||
@@ -239,10 +244,14 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
});
|
||||
}
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
_isReloadingScheduled(): boolean {
|
||||
return this._totalProcessingCount !== undefined;
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.appLifecycle.askRestart.setHandler(this._askReload.bind(this));
|
||||
services.appLifecycle.scheduleRestart.setHandler(this._scheduleAppReload.bind(this));
|
||||
services.appLifecycle.isReloadingScheduled.setHandler(this._isReloadingScheduled.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export class ModuleObsidianMenu extends AbstractModule {
|
||||
this.settings.liveSync = true;
|
||||
this._log("LiveSync Enabled.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.services.setting.realiseSetting();
|
||||
await this.services.control.applySettings();
|
||||
await this.services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
@@ -74,7 +74,7 @@ export class ModuleObsidianMenu extends AbstractModule {
|
||||
this.services.appLifecycle.setSuspended(true);
|
||||
this._log("Self-hosted LiveSync suspended", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
await this.services.setting.realiseSetting();
|
||||
await this.services.control.applySettings();
|
||||
await this.services.setting.saveSettingData();
|
||||
},
|
||||
});
|
||||
@@ -106,7 +106,7 @@ export class ModuleObsidianMenu extends AbstractModule {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ export class ModuleDev extends AbstractObsidianModule {
|
||||
// this.addTestResult("Test of test3", true);
|
||||
return this.testDone();
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
|
||||
@@ -440,7 +440,7 @@ Line4:D`;
|
||||
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
onBindFunction(core: typeof this.core, services: typeof core.services): void {
|
||||
override onBindFunction(core: typeof this.core, services: typeof core.services): void {
|
||||
services.test.testMultiDevice.addHandler(this._everyModuleTestMultiDevice.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,10 +511,11 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZ`;
|
||||
return this.__assertStorageContent((this.testRootPath + "task.md") as FilePath, mergedDoc, false, true);
|
||||
}
|
||||
|
||||
// No longer tested
|
||||
async checkConflictResolution() {
|
||||
this._log("Before testing conflicted files, resolve all once", LOG_LEVEL_NOTICE);
|
||||
await this.core.rebuilder.resolveAllConflictedFilesByNewerOnes();
|
||||
await this.core.rebuilder.resolveAllConflictedFilesByNewerOnes();
|
||||
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
await this.services.replication.replicate();
|
||||
await delay(1000);
|
||||
if (!(await this.testConflictAutomatic())) {
|
||||
@@ -580,7 +581,7 @@ ABCDEFGHIJKLMNOPQRSTUVWXYZ`;
|
||||
await this._test("Conflict resolution", async () => await this.checkConflictResolution());
|
||||
return this.testDone();
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this));
|
||||
services.test.testMultiDevice.addHandler(this._everyModuleTestMultiDevice.bind(this));
|
||||
|
||||
@@ -8,11 +8,11 @@ export class TestPaneView extends ItemView {
|
||||
component?: TestPaneComponent;
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
moduleDev: ModuleDev;
|
||||
icon = "view-log";
|
||||
override icon = "view-log";
|
||||
title: string = "Self-hosted LiveSync Test and Results";
|
||||
navigation = true;
|
||||
override navigation = true;
|
||||
|
||||
getIcon(): string {
|
||||
override getIcon(): string {
|
||||
return "view-log";
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export class TestPaneView extends ItemView {
|
||||
return "Self-hosted LiveSync Test and Results";
|
||||
}
|
||||
|
||||
async onOpen() {
|
||||
override async onOpen() {
|
||||
this.component = new TestPaneComponent({
|
||||
target: this.contentEl,
|
||||
props: {
|
||||
@@ -41,7 +41,7 @@ export class TestPaneView extends ItemView {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
override async onClose() {
|
||||
this.component?.$destroy();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
}
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText("Document History");
|
||||
contentEl.empty();
|
||||
@@ -299,7 +299,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
});
|
||||
});
|
||||
}
|
||||
onClose() {
|
||||
override onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
this.BlobURLs.forEach((value) => {
|
||||
|
||||
@@ -250,7 +250,11 @@
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<span class="filename"><a on:click={() => openFile(entry.path)}>{entry.filename}</a></span>
|
||||
{#if entry.isDeleted}
|
||||
<span class="filename" style="text-decoration: line-through">{entry.filename}</span>
|
||||
{:else}
|
||||
<span class="filename"><a on:click={() => openFile(entry.path)}>{entry.filename}</a></span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -16,11 +16,11 @@ export class GlobalHistoryView extends SvelteItemView {
|
||||
}
|
||||
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon = "clock";
|
||||
override icon = "clock";
|
||||
title: string = "";
|
||||
navigation = true;
|
||||
override navigation = true;
|
||||
|
||||
getIcon(): string {
|
||||
override getIcon(): string {
|
||||
return "clock";
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export class ConflictResolveModal extends Modal {
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
// if not there, simply be ignored.
|
||||
@@ -63,6 +63,7 @@ export class ConflictResolveModal extends Modal {
|
||||
contentEl.createEl("span", { text: this.filename });
|
||||
const div = contentEl.createDiv("");
|
||||
div.addClass("op-scrollable");
|
||||
div.addClass("ls-dialog");
|
||||
let diff = "";
|
||||
for (const v of this.result.diff) {
|
||||
const x1 = v[0];
|
||||
@@ -86,6 +87,7 @@ export class ConflictResolveModal extends Modal {
|
||||
}
|
||||
|
||||
const div2 = contentEl.createDiv("");
|
||||
div2.addClass("ls-dialog");
|
||||
const date1 =
|
||||
new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
|
||||
const date2 =
|
||||
@@ -119,7 +121,7 @@ export class ConflictResolveModal extends Modal {
|
||||
this.close();
|
||||
}
|
||||
|
||||
onClose() {
|
||||
override onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.offEvent) {
|
||||
|
||||
@@ -19,11 +19,11 @@ export class LogPaneView extends SvelteItemView {
|
||||
}
|
||||
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
icon = "view-log";
|
||||
override icon = "view-log";
|
||||
title: string = "";
|
||||
navigation = false;
|
||||
override navigation = false;
|
||||
|
||||
getIcon(): string {
|
||||
override getIcon(): string {
|
||||
return "view-log";
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export class ModuleObsidianGlobalHistory extends AbstractObsidianModule {
|
||||
showGlobalHistory() {
|
||||
void this.services.API.showWindow(VIEW_TYPE_GLOBAL_HISTORY);
|
||||
}
|
||||
onBindFunction(core: typeof this.core, services: typeof core.services): void {
|
||||
override onBindFunction(core: typeof this.core, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export class ModuleInteractiveConflictResolver extends AbstractObsidianModule {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onScanningStartupIssues.addHandler(this._allScanStat.bind(this));
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.conflict.resolveByUserInteraction.addHandler(this._anyResolveConflictByUI.bind(this));
|
||||
|
||||
@@ -32,14 +32,15 @@ import { serialized } from "octagonal-wheels/concurrency/lock";
|
||||
import { $msg } from "src/lib/src/common/i18n.ts";
|
||||
import { P2PLogCollector } from "../../lib/src/replication/trystero/P2PReplicatorCore.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { LiveSyncError } from "@/lib/src/common/LSError.ts";
|
||||
import { LiveSyncError } from "@lib/common/LSError.ts";
|
||||
import { isValidPath } from "@/common/utils.ts";
|
||||
import {
|
||||
isValidFilenameInAndroid,
|
||||
isValidFilenameInDarwin,
|
||||
isValidFilenameInWidows,
|
||||
} from "@/lib/src/string_and_binary/path.ts";
|
||||
import { MARK_LOG_SEPARATOR } from "@/lib/src/services/lib/logUtils.ts";
|
||||
} from "@lib/string_and_binary/path.ts";
|
||||
import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@lib/services/lib/logUtils.ts";
|
||||
import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts";
|
||||
|
||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||
|
||||
@@ -102,12 +103,12 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
});
|
||||
return computed(() => formatted.value);
|
||||
}
|
||||
const labelReplication = padLeftSpComputed(this.core.replicationResultCount, `📥`);
|
||||
const labelDBCount = padLeftSpComputed(this.core.databaseQueueCount, `📄`);
|
||||
const labelStorageCount = padLeftSpComputed(this.core.storageApplyingCount, `💾`);
|
||||
const labelReplication = padLeftSpComputed(this.services.replication.replicationResultCount, `📥`);
|
||||
const labelDBCount = padLeftSpComputed(this.services.replication.databaseQueueCount, `📄`);
|
||||
const labelStorageCount = padLeftSpComputed(this.services.replication.storageApplyingCount, `💾`);
|
||||
const labelChunkCount = padLeftSpComputed(collectingChunks, `🧩`);
|
||||
const labelPluginScanCount = padLeftSpComputed(pluginScanningCount, `🔌`);
|
||||
const labelConflictProcessCount = padLeftSpComputed(this.core.conflictProcessQueueCount, `🔩`);
|
||||
const labelConflictProcessCount = padLeftSpComputed(this.services.conflict.conflictProcessQueueCount, `🔩`);
|
||||
const hiddenFilesCount = reactive(() => hiddenFilesEventCount.value - hiddenFilesProcessingCount.value);
|
||||
const labelHiddenFilesCount = padLeftSpComputed(hiddenFilesCount, `⚙️`);
|
||||
const queueCountLabelX = reactive(() => {
|
||||
@@ -116,12 +117,12 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const queueCountLabel = () => queueCountLabelX.value;
|
||||
|
||||
const requestingStatLabel = computed(() => {
|
||||
const diff = this.core.requestCount.value - this.core.responseCount.value;
|
||||
const diff = this.services.API.requestCount.value - this.services.API.responseCount.value;
|
||||
return diff != 0 ? "📲 " : "";
|
||||
});
|
||||
|
||||
const replicationStatLabel = computed(() => {
|
||||
const e = this.core.replicationStat.value;
|
||||
const e = this.services.replicator.replicationStatics.value;
|
||||
const sent = e.sent;
|
||||
const arrived = e.arrived;
|
||||
const maxPullSeq = e.maxPullSeq;
|
||||
@@ -173,9 +174,9 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
}
|
||||
return { w, sent, pushLast, arrived, pullLast };
|
||||
});
|
||||
const labelProc = padLeftSpComputed(this.core.processing, `⏳`);
|
||||
const labelPend = padLeftSpComputed(this.core.totalQueued, `🛫`);
|
||||
const labelInBatchDelay = padLeftSpComputed(this.core.batched, `📬`);
|
||||
const labelProc = padLeftSpComputed(this.services.fileProcessing.processing, `⏳`);
|
||||
const labelPend = padLeftSpComputed(this.services.fileProcessing.totalQueued, `🛫`);
|
||||
const labelInBatchDelay = padLeftSpComputed(this.services.fileProcessing.batched, `📬`);
|
||||
const waitingLabel = computed(() => {
|
||||
return `${labelProc()}${labelPend()}${labelInBatchDelay()}`;
|
||||
});
|
||||
@@ -282,7 +283,20 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
const fileStatus = this.activeFileStatus.value;
|
||||
if (fileStatus && !this.settings.hideFileWarningNotice) messageLines.push(fileStatus);
|
||||
const messages = (await this.services.appLifecycle.getUnresolvedMessages()).flat().filter((e) => e);
|
||||
messageLines.push(...messages);
|
||||
const stringMessages = messages.filter((m): m is string => typeof m === "string"); // for 'startsWith'
|
||||
const networkMessages = stringMessages.filter((m) => m.startsWith(MARK_LOG_NETWORK_ERROR));
|
||||
const otherMessages = stringMessages.filter((m) => !m.startsWith(MARK_LOG_NETWORK_ERROR));
|
||||
|
||||
messageLines.push(...otherMessages);
|
||||
|
||||
if (
|
||||
this.settings.networkWarningStyle !== NetworkWarningStyles.ICON &&
|
||||
this.settings.networkWarningStyle !== NetworkWarningStyles.HIDDEN
|
||||
) {
|
||||
messageLines.push(...networkMessages);
|
||||
} else if (this.settings.networkWarningStyle === NetworkWarningStyles.ICON) {
|
||||
if (networkMessages.length > 0) messageLines.push("🔗❌");
|
||||
}
|
||||
this.messageArea.innerText = messageLines.map((e) => `⚠️ ${e}`).join("\n");
|
||||
}
|
||||
}
|
||||
@@ -495,7 +509,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
}
|
||||
}
|
||||
}
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.API.addLog.setHandler(globalLogFunction);
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
|
||||
@@ -50,7 +50,7 @@ export class ModuleObsidianDocumentHistory extends AbstractObsidianModule {
|
||||
this.showHistory(targetId.path, targetId.id);
|
||||
}
|
||||
}
|
||||
onBindFunction(core: typeof this.core, services: typeof core.services): void {
|
||||
override onBindFunction(core: typeof this.core, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED, eventHub } from "../../common/events.ts";
|
||||
import {
|
||||
type BucketSyncSetting,
|
||||
ChunkAlgorithmNames,
|
||||
type ConfigPassphraseStore,
|
||||
type CouchDBConnection,
|
||||
DEFAULT_SETTINGS,
|
||||
type ObsidianLiveSyncSettings,
|
||||
SALT_OF_PASSPHRASE,
|
||||
SETTING_KEY_P2P_DEVICE_NAME,
|
||||
} from "../../lib/src/common/types";
|
||||
import { LOG_LEVEL_NOTICE, LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
|
||||
import { $msg, setLang } from "../../lib/src/common/i18n.ts";
|
||||
import { isCloudantURI } from "../../lib/src/pouchdb/utils_couchdb.ts";
|
||||
import { getLanguage } from "@/deps.ts";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "../../lib/src/common/rosetta.ts";
|
||||
import { decryptString, encryptString } from "@/lib/src/encryption/stringEncryption.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
export class ModuleObsidianSettings extends AbstractModule {
|
||||
async _everyOnLayoutReady(): Promise<boolean> {
|
||||
let isChanged = false;
|
||||
if (this.settings.displayLanguage == "") {
|
||||
const obsidianLanguage = getLanguage();
|
||||
if (
|
||||
SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported
|
||||
obsidianLanguage != this.settings.displayLanguage // Check if the language is different from the current setting
|
||||
) {
|
||||
// Check if the current setting is not empty (Means migrated or installed).
|
||||
this.settings.displayLanguage = obsidianLanguage as I18N_LANGS;
|
||||
isChanged = true;
|
||||
setLang(this.settings.displayLanguage);
|
||||
} else if (this.settings.displayLanguage == "") {
|
||||
this.settings.displayLanguage = "def";
|
||||
setLang(this.settings.displayLanguage);
|
||||
await this.services.setting.saveSettingData();
|
||||
}
|
||||
}
|
||||
if (isChanged) {
|
||||
const revert = $msg("dialog.yourLanguageAvailable.btnRevertToDefault");
|
||||
if (
|
||||
(await this.core.confirm.askSelectStringDialogue($msg(`dialog.yourLanguageAvailable`), ["OK", revert], {
|
||||
defaultAction: "OK",
|
||||
title: $msg(`dialog.yourLanguageAvailable.Title`),
|
||||
})) == revert
|
||||
) {
|
||||
this.settings.displayLanguage = "def";
|
||||
setLang(this.settings.displayLanguage);
|
||||
}
|
||||
await this.services.setting.saveSettingData();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
getPassphrase(settings: ObsidianLiveSyncSettings) {
|
||||
const methods: Record<ConfigPassphraseStore, () => Promise<string | false>> = {
|
||||
"": () => Promise.resolve("*"),
|
||||
LOCALSTORAGE: () => Promise.resolve(localStorage.getItem("ls-setting-passphrase") ?? false),
|
||||
ASK_AT_LAUNCH: () => this.core.confirm.askString("Passphrase", "passphrase", ""),
|
||||
};
|
||||
const method = settings.configPassphraseStore;
|
||||
const methodFunc = method in methods ? methods[method] : methods[""];
|
||||
return methodFunc();
|
||||
}
|
||||
|
||||
_saveDeviceAndVaultName(): void {
|
||||
const lsKey = "obsidian-live-sync-vaultanddevicename-" + this.services.vault.getVaultName();
|
||||
localStorage.setItem(lsKey, this.services.setting.getDeviceAndVaultName() || "");
|
||||
}
|
||||
|
||||
usedPassphrase = "";
|
||||
private _clearUsedPassphrase(): void {
|
||||
this.usedPassphrase = "";
|
||||
}
|
||||
|
||||
async decryptConfigurationItem(encrypted: string, passphrase: string) {
|
||||
const dec = await decryptString(encrypted, passphrase + SALT_OF_PASSPHRASE);
|
||||
if (dec) {
|
||||
this.usedPassphrase = passphrase;
|
||||
return dec;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async encryptConfigurationItem(src: string, settings: ObsidianLiveSyncSettings) {
|
||||
if (this.usedPassphrase != "") {
|
||||
return await encryptString(src, this.usedPassphrase + SALT_OF_PASSPHRASE);
|
||||
}
|
||||
|
||||
const passphrase = await this.getPassphrase(settings);
|
||||
if (passphrase === false) {
|
||||
this._log(
|
||||
"Failed to obtain passphrase when saving data.json! Please verify the configuration.",
|
||||
LOG_LEVEL_URGENT
|
||||
);
|
||||
return "";
|
||||
}
|
||||
const dec = await encryptString(src, passphrase + SALT_OF_PASSPHRASE);
|
||||
if (dec) {
|
||||
this.usedPassphrase = passphrase;
|
||||
return dec;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
get appId() {
|
||||
return this.services.API.getAppID();
|
||||
}
|
||||
|
||||
async _saveSettingData() {
|
||||
this.services.setting.saveDeviceAndVaultName();
|
||||
const settings = { ...this.settings };
|
||||
settings.deviceAndVaultName = "";
|
||||
if (settings.P2P_DevicePeerName && settings.P2P_DevicePeerName.trim() !== "") {
|
||||
console.log("Saving device peer name to small config");
|
||||
this.services.config.setSmallConfig(SETTING_KEY_P2P_DEVICE_NAME, settings.P2P_DevicePeerName.trim());
|
||||
settings.P2P_DevicePeerName = "";
|
||||
}
|
||||
if (this.usedPassphrase == "" && !(await this.getPassphrase(settings))) {
|
||||
this._log("Failed to retrieve passphrase. data.json contains unencrypted items!", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
if (
|
||||
settings.couchDB_PASSWORD != "" ||
|
||||
settings.couchDB_URI != "" ||
|
||||
settings.couchDB_USER != "" ||
|
||||
settings.couchDB_DBNAME
|
||||
) {
|
||||
const connectionSetting: CouchDBConnection & BucketSyncSetting = {
|
||||
couchDB_DBNAME: settings.couchDB_DBNAME,
|
||||
couchDB_PASSWORD: settings.couchDB_PASSWORD,
|
||||
couchDB_URI: settings.couchDB_URI,
|
||||
couchDB_USER: settings.couchDB_USER,
|
||||
accessKey: settings.accessKey,
|
||||
bucket: settings.bucket,
|
||||
endpoint: settings.endpoint,
|
||||
region: settings.region,
|
||||
secretKey: settings.secretKey,
|
||||
useCustomRequestHandler: settings.useCustomRequestHandler,
|
||||
bucketCustomHeaders: settings.bucketCustomHeaders,
|
||||
couchDB_CustomHeaders: settings.couchDB_CustomHeaders,
|
||||
useJWT: settings.useJWT,
|
||||
jwtKey: settings.jwtKey,
|
||||
jwtAlgorithm: settings.jwtAlgorithm,
|
||||
jwtKid: settings.jwtKid,
|
||||
jwtExpDuration: settings.jwtExpDuration,
|
||||
jwtSub: settings.jwtSub,
|
||||
useRequestAPI: settings.useRequestAPI,
|
||||
bucketPrefix: settings.bucketPrefix,
|
||||
forcePathStyle: settings.forcePathStyle,
|
||||
};
|
||||
settings.encryptedCouchDBConnection = await this.encryptConfigurationItem(
|
||||
JSON.stringify(connectionSetting),
|
||||
settings
|
||||
);
|
||||
settings.couchDB_PASSWORD = "";
|
||||
settings.couchDB_DBNAME = "";
|
||||
settings.couchDB_URI = "";
|
||||
settings.couchDB_USER = "";
|
||||
settings.accessKey = "";
|
||||
settings.bucket = "";
|
||||
settings.region = "";
|
||||
settings.secretKey = "";
|
||||
settings.endpoint = "";
|
||||
}
|
||||
if (settings.encrypt && settings.passphrase != "") {
|
||||
settings.encryptedPassphrase = await this.encryptConfigurationItem(settings.passphrase, settings);
|
||||
settings.passphrase = "";
|
||||
}
|
||||
}
|
||||
await this.core.saveData(settings);
|
||||
eventHub.emitEvent(EVENT_SETTING_SAVED, settings);
|
||||
}
|
||||
|
||||
tryDecodeJson(encoded: string | false): object | false {
|
||||
try {
|
||||
if (!encoded) return false;
|
||||
return JSON.parse(encoded);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async _decryptSettings(settings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings> {
|
||||
const passphrase = await this.getPassphrase(settings);
|
||||
if (passphrase === false) {
|
||||
this._log("No passphrase found for data.json! Verify configuration before syncing.", LOG_LEVEL_URGENT);
|
||||
} else {
|
||||
if (settings.encryptedCouchDBConnection) {
|
||||
const keys = [
|
||||
"couchDB_URI",
|
||||
"couchDB_USER",
|
||||
"couchDB_PASSWORD",
|
||||
"couchDB_DBNAME",
|
||||
"accessKey",
|
||||
"bucket",
|
||||
"endpoint",
|
||||
"region",
|
||||
"secretKey",
|
||||
] as (keyof CouchDBConnection | keyof BucketSyncSetting)[];
|
||||
const decrypted = this.tryDecodeJson(
|
||||
await this.decryptConfigurationItem(settings.encryptedCouchDBConnection, passphrase)
|
||||
) as CouchDBConnection & BucketSyncSetting;
|
||||
if (decrypted) {
|
||||
for (const key of keys) {
|
||||
if (key in decrypted) {
|
||||
//@ts-ignore
|
||||
settings[key] = decrypted[key];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._log(
|
||||
"Failed to decrypt passphrase from data.json! Ensure configuration is correct before syncing with remote.",
|
||||
LOG_LEVEL_URGENT
|
||||
);
|
||||
for (const key of keys) {
|
||||
//@ts-ignore
|
||||
settings[key] = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (settings.encrypt && settings.encryptedPassphrase) {
|
||||
const encrypted = settings.encryptedPassphrase;
|
||||
const decrypted = await this.decryptConfigurationItem(encrypted, passphrase);
|
||||
if (decrypted) {
|
||||
settings.passphrase = decrypted;
|
||||
} else {
|
||||
this._log(
|
||||
"Failed to decrypt passphrase from data.json! Ensure configuration is correct before syncing with remote.",
|
||||
LOG_LEVEL_URGENT
|
||||
);
|
||||
settings.passphrase = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method mutates the settings object.
|
||||
* @param settings
|
||||
* @returns
|
||||
*/
|
||||
_adjustSettings(settings: ObsidianLiveSyncSettings): Promise<ObsidianLiveSyncSettings> {
|
||||
// Adjust settings as needed
|
||||
|
||||
// Delete this feature to avoid problems on mobile.
|
||||
settings.disableRequestURI = true;
|
||||
|
||||
// GC is disabled.
|
||||
settings.gcDelay = 0;
|
||||
// So, use history is always enabled.
|
||||
settings.useHistory = true;
|
||||
|
||||
if ("workingEncrypt" in settings) delete settings.workingEncrypt;
|
||||
if ("workingPassphrase" in settings) delete settings.workingPassphrase;
|
||||
// Splitter configurations have been replaced with chunkSplitterVersion.
|
||||
if (settings.chunkSplitterVersion == "") {
|
||||
if (settings.enableChunkSplitterV2) {
|
||||
if (settings.useSegmenter) {
|
||||
settings.chunkSplitterVersion = "v2-segmenter";
|
||||
} else {
|
||||
settings.chunkSplitterVersion = "v2";
|
||||
}
|
||||
} else {
|
||||
settings.chunkSplitterVersion = "";
|
||||
}
|
||||
} else if (!(settings.chunkSplitterVersion in ChunkAlgorithmNames)) {
|
||||
settings.chunkSplitterVersion = "";
|
||||
}
|
||||
return Promise.resolve(settings);
|
||||
}
|
||||
|
||||
async _loadSettings(): Promise<void> {
|
||||
const settings = Object.assign({}, DEFAULT_SETTINGS, await this.core.loadData()) as ObsidianLiveSyncSettings;
|
||||
|
||||
if (typeof settings.isConfigured == "undefined") {
|
||||
// If migrated, mark true
|
||||
if (JSON.stringify(settings) !== JSON.stringify(DEFAULT_SETTINGS)) {
|
||||
settings.isConfigured = true;
|
||||
} else {
|
||||
settings.additionalSuffixOfDatabaseName = this.appId;
|
||||
settings.isConfigured = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.settings = await this.services.setting.decryptSettings(settings);
|
||||
|
||||
setLang(this.settings.displayLanguage);
|
||||
|
||||
await this.services.setting.adjustSettings(this.settings);
|
||||
|
||||
const lsKey = "obsidian-live-sync-vaultanddevicename-" + this.services.vault.getVaultName();
|
||||
if (this.settings.deviceAndVaultName != "") {
|
||||
if (!localStorage.getItem(lsKey)) {
|
||||
this.services.setting.setDeviceAndVaultName(this.settings.deviceAndVaultName);
|
||||
this.services.setting.saveDeviceAndVaultName();
|
||||
this.settings.deviceAndVaultName = "";
|
||||
}
|
||||
}
|
||||
if (isCloudantURI(this.settings.couchDB_URI) && this.settings.customChunkSize != 0) {
|
||||
this._log(
|
||||
"Configuration issues detected and automatically resolved. However, unsynchronized data may exist. Consider rebuilding if necessary.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this.settings.customChunkSize = 0;
|
||||
}
|
||||
this.services.setting.setDeviceAndVaultName(localStorage.getItem(lsKey) || "");
|
||||
if (this.services.setting.getDeviceAndVaultName() == "") {
|
||||
if (this.settings.usePluginSync) {
|
||||
this._log("Device name missing. Disabling plug-in sync.", LOG_LEVEL_NOTICE);
|
||||
this.settings.usePluginSync = false;
|
||||
}
|
||||
}
|
||||
|
||||
// this.core.ignoreFiles = this.settings.ignoreFiles.split(",").map(e => e.trim());
|
||||
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||
}
|
||||
|
||||
private _currentSettings(): ObsidianLiveSyncSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
super.onBindFunction(core, services);
|
||||
services.appLifecycle.onLayoutReady.addHandler(this._everyOnLayoutReady.bind(this));
|
||||
services.setting.clearUsedPassphrase.setHandler(this._clearUsedPassphrase.bind(this));
|
||||
services.setting.decryptSettings.setHandler(this._decryptSettings.bind(this));
|
||||
services.setting.adjustSettings.setHandler(this._adjustSettings.bind(this));
|
||||
services.setting.loadSettings.setHandler(this._loadSettings.bind(this));
|
||||
services.setting.currentSettings.setHandler(this._currentSettings.bind(this));
|
||||
services.setting.saveDeviceAndVaultName.setHandler(this._saveDeviceAndVaultName.bind(this));
|
||||
services.setting.saveSettingData.setHandler(this._saveSettingData.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { DEFAULT_SETTINGS, type FilePathWithPrefix, type ObsidianLiveSyncSetting
|
||||
import { parseYaml, stringifyYaml } from "../../deps";
|
||||
import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import type { ServiceContext } from "@/lib/src/services/base/ServiceBase.ts";
|
||||
import type { InjectableServiceHub } from "@/lib/src/services/InjectableServices.ts";
|
||||
import type { ServiceContext } from "@lib/services/base/ServiceBase.ts";
|
||||
import type { InjectableServiceHub } from "@lib/services/InjectableServices.ts";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
const SETTING_HEADER = "````yaml:livesync-setting\n";
|
||||
const SETTING_FOOTER = "\n````";
|
||||
@@ -246,7 +246,7 @@ We can perform a command in this file.
|
||||
}
|
||||
}
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub<ServiceContext>): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub<ServiceContext>): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
|
||||
get appId() {
|
||||
return `${"appId" in this.app ? this.app.appId : ""}`;
|
||||
}
|
||||
onBindFunction(core: typeof this.plugin, services: typeof core.services): void {
|
||||
override onBindFunction(core: typeof this.plugin, services: typeof core.services): void {
|
||||
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE } from "../../lib/src/common/types.ts";
|
||||
import { type ObsidianLiveSyncSettings, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "../../lib/src/common/types.ts";
|
||||
import { configURIBase } from "../../common/types.ts";
|
||||
// import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.js";
|
||||
import { fireAndForget } from "../../lib/src/common/utils.ts";
|
||||
@@ -25,16 +25,24 @@ export class ModuleSetupObsidian extends AbstractModule {
|
||||
private _setupManager!: SetupManager;
|
||||
private _everyOnload(): Promise<boolean> {
|
||||
this._setupManager = this.core.getModule(SetupManager);
|
||||
this.registerObsidianProtocolHandler("setuplivesync", async (conf: any) => {
|
||||
if (conf.settings) {
|
||||
await this._setupManager.onUseSetupURI(
|
||||
UserMode.Unknown,
|
||||
`${configURIBase}${encodeURIComponent(conf.settings)}`
|
||||
);
|
||||
} else if (conf.settingsQR) {
|
||||
await this._setupManager.decodeQR(conf.settingsQR);
|
||||
}
|
||||
});
|
||||
try {
|
||||
this.registerObsidianProtocolHandler("setuplivesync", async (conf: any) => {
|
||||
if (conf.settings) {
|
||||
await this._setupManager.onUseSetupURI(
|
||||
UserMode.Unknown,
|
||||
`${configURIBase}${encodeURIComponent(conf.settings)}`
|
||||
);
|
||||
} else if (conf.settingsQR) {
|
||||
await this._setupManager.decodeQR(conf.settingsQR);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
this._log(
|
||||
"Failed to register protocol handler. This feature may not work in some environments.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
this._log(e, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.addCommand({
|
||||
id: "livesync-setting-qr",
|
||||
name: "Show settings as a QR code",
|
||||
@@ -194,7 +202,7 @@ export class ModuleSetupObsidian extends AbstractModule {
|
||||
// }
|
||||
// }
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
||||
services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export class LiveSyncSetting extends Setting {
|
||||
}
|
||||
}
|
||||
|
||||
setDesc(desc: string | DocumentFragment): this {
|
||||
override setDesc(desc: string | DocumentFragment): this {
|
||||
this.descBuf = desc;
|
||||
DEV: {
|
||||
this._createDocStub("desc", desc);
|
||||
@@ -66,7 +66,7 @@ export class LiveSyncSetting extends Setting {
|
||||
super.setDesc(desc);
|
||||
return this;
|
||||
}
|
||||
setName(name: string | DocumentFragment): this {
|
||||
override setName(name: string | DocumentFragment): this {
|
||||
this.nameBuf = name;
|
||||
DEV: {
|
||||
this._createDocStub("name", name);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { delay, isObjectDifferent, sizeToHumanReadable } from "../../../lib/src/common/utils.ts";
|
||||
import { versionNumberString2Number } from "../../../lib/src/string_and_binary/convert.ts";
|
||||
import { Logger } from "../../../lib/src/common/logger.ts";
|
||||
import { checkSyncInfo } from "@/lib/src/pouchdb/negotiation.ts";
|
||||
import { checkSyncInfo } from "@lib/pouchdb/negotiation.ts";
|
||||
import { testCrypt } from "octagonal-wheels/encryption/encryption";
|
||||
import ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import { scheduleTask } from "../../../common/utils.ts";
|
||||
@@ -374,7 +374,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
|
||||
this.initialSettings = undefined;
|
||||
}
|
||||
|
||||
hide() {
|
||||
override hide() {
|
||||
this.isShown = false;
|
||||
}
|
||||
isShown: boolean = false;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
import type { PageFunctions } from "./SettingPane.ts";
|
||||
import { visibleOnly } from "./SettingPane.ts";
|
||||
import { EVENT_ON_UNRESOLVED_ERROR, eventHub } from "@/common/events.ts";
|
||||
import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts";
|
||||
export function paneGeneral(
|
||||
this: ObsidianLiveSyncSettingTab,
|
||||
paneEl: HTMLElement,
|
||||
@@ -24,6 +26,16 @@ export function paneGeneral(
|
||||
});
|
||||
new Setting(paneEl).autoWireToggle("showStatusOnStatusbar");
|
||||
new Setting(paneEl).autoWireToggle("hideFileWarningNotice");
|
||||
new Setting(paneEl).autoWireDropDown("networkWarningStyle", {
|
||||
options: {
|
||||
[NetworkWarningStyles.BANNER]: "Show full banner",
|
||||
[NetworkWarningStyles.ICON]: "Show icon only",
|
||||
[NetworkWarningStyles.HIDDEN]: "Hide completely",
|
||||
},
|
||||
});
|
||||
this.addOnSaved("networkWarningStyle", () => {
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
});
|
||||
});
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleLogging")).then((paneEl) => {
|
||||
paneEl.addClass("wizardHidden");
|
||||
|
||||
@@ -361,7 +361,7 @@ ${stringifyYaml({
|
||||
.setButtonText("Resolve All")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
await this.plugin.rebuilder.resolveAllConflictedFilesByNewerOnes();
|
||||
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export function paneMaintenance(
|
||||
(e) => {
|
||||
e.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
await this.services.remote.markResolved();
|
||||
await this.services.replication.markResolved();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,7 @@ export function paneMaintenance(
|
||||
(e) => {
|
||||
e.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
await this.services.remote.markUnlocked();
|
||||
await this.services.replication.markUnlocked();
|
||||
this.display();
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ export function paneMaintenance(
|
||||
.setDisabled(false)
|
||||
.setWarning()
|
||||
.onClick(async () => {
|
||||
await this.services.remote.markLocked();
|
||||
await this.services.replication.markLocked();
|
||||
})
|
||||
)
|
||||
.addOnUpdate(this.onlyOnCouchDBOrMinIO);
|
||||
|
||||
@@ -105,7 +105,7 @@ export function paneSyncSettings(
|
||||
if (!this.editingSettings.isConfigured) {
|
||||
this.editingSettings.isConfigured = true;
|
||||
await this.saveAllDirtySettings();
|
||||
await this.services.setting.realiseSetting();
|
||||
await this.services.control.applySettings();
|
||||
await this.rebuildDB("localOnly");
|
||||
// this.resetEditingSettings();
|
||||
if (
|
||||
@@ -124,13 +124,13 @@ export function paneSyncSettings(
|
||||
await this.confirmRebuild();
|
||||
} else {
|
||||
await this.saveAllDirtySettings();
|
||||
await this.services.setting.realiseSetting();
|
||||
await this.services.control.applySettings();
|
||||
this.services.appLifecycle.askRestart();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await this.saveAllDirtySettings();
|
||||
await this.services.setting.realiseSetting();
|
||||
await this.services.control.applySettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -169,7 +169,7 @@ export function paneSyncSettings(
|
||||
}
|
||||
await this.saveSettings(["liveSync", "periodicReplication"]);
|
||||
|
||||
await this.services.setting.realiseSetting();
|
||||
await this.services.control.applySettings();
|
||||
});
|
||||
|
||||
new Setting(paneEl)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type {
|
||||
FilePathWithPrefix,
|
||||
LoadedEntry,
|
||||
MetaEntry,
|
||||
UXFileInfo,
|
||||
UXFileInfoStub,
|
||||
} from "../../lib/src/common/types";
|
||||
|
||||
export interface DatabaseFileAccess {
|
||||
delete: (file: UXFileInfoStub | FilePathWithPrefix, rev?: string) => Promise<boolean>;
|
||||
store: (file: UXFileInfo, force?: boolean, skipCheck?: boolean) => Promise<boolean>;
|
||||
storeContent(path: FilePathWithPrefix, content: string): Promise<boolean>;
|
||||
createChunks: (file: UXFileInfo, force?: boolean, skipCheck?: boolean) => Promise<boolean>;
|
||||
fetch: (
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
waitForReady?: boolean,
|
||||
skipCheck?: boolean
|
||||
) => Promise<UXFileInfo | false>;
|
||||
fetchEntryFromMeta: (meta: MetaEntry, waitForReady?: boolean, skipCheck?: boolean) => Promise<LoadedEntry | false>;
|
||||
fetchEntryMeta: (
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
skipCheck?: boolean
|
||||
) => Promise<MetaEntry | false>;
|
||||
fetchEntry: (
|
||||
file: UXFileInfoStub | FilePathWithPrefix,
|
||||
rev?: string,
|
||||
waitForReady?: boolean,
|
||||
skipCheck?: boolean
|
||||
) => Promise<LoadedEntry | false>;
|
||||
getConflictedRevs: (file: UXFileInfoStub | FilePathWithPrefix) => Promise<string[]>;
|
||||
// storeFromStorage: (file: UXFileInfoStub | FilePathWithPrefix, force?: boolean) => Promise<boolean>;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export interface Rebuilder {
|
||||
$performRebuildDB(
|
||||
method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks"
|
||||
): Promise<void>;
|
||||
$rebuildRemote(): Promise<void>;
|
||||
$rebuildEverything(): Promise<void>;
|
||||
$fetchLocal(makeLocalChunkBeforeSync?: boolean, preventMakeLocalFilesBeforeSync?: boolean): Promise<void>;
|
||||
|
||||
scheduleRebuild(): Promise<void>;
|
||||
scheduleFetch(): Promise<void>;
|
||||
resolveAllConflictedFilesByNewerOnes(): Promise<void>;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type {
|
||||
FilePath,
|
||||
FilePathWithPrefix,
|
||||
UXDataWriteOptions,
|
||||
UXFileInfo,
|
||||
UXFileInfoStub,
|
||||
UXFolderInfo,
|
||||
UXStat,
|
||||
} from "../../lib/src/common/types";
|
||||
import type { CustomRegExp } from "../../lib/src/common/utils";
|
||||
|
||||
export interface StorageAccess {
|
||||
restoreState(): Promise<void>;
|
||||
processWriteFile<T>(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise<T>): Promise<T>;
|
||||
processReadFile<T>(file: UXFileInfoStub | FilePathWithPrefix, proc: () => Promise<T>): Promise<T>;
|
||||
isFileProcessing(file: UXFileInfoStub | FilePathWithPrefix): boolean;
|
||||
|
||||
deleteVaultItem(file: FilePathWithPrefix | UXFileInfoStub | UXFolderInfo): Promise<void>;
|
||||
|
||||
writeFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise<boolean>;
|
||||
|
||||
readFileAuto(path: string): Promise<string | ArrayBuffer>;
|
||||
readFileText(path: string): Promise<string>;
|
||||
isExists(path: string): Promise<boolean>;
|
||||
writeHiddenFileAuto(path: string, data: string | ArrayBuffer, opt?: UXDataWriteOptions): Promise<boolean>;
|
||||
appendHiddenFile(path: string, data: string, opt?: UXDataWriteOptions): Promise<boolean>;
|
||||
|
||||
stat(path: string): Promise<UXStat | null>;
|
||||
statHidden(path: string): Promise<UXStat | null>;
|
||||
removeHidden(path: string): Promise<boolean>;
|
||||
readHiddenFileAuto(path: string): Promise<string | ArrayBuffer>;
|
||||
readHiddenFileBinary(path: string): Promise<ArrayBuffer>;
|
||||
readHiddenFileText(path: string): Promise<string>;
|
||||
isExistsIncludeHidden(path: string): Promise<boolean>;
|
||||
// This could be work also for the hidden files.
|
||||
ensureDir(path: string): Promise<boolean>;
|
||||
triggerFileEvent(event: string, path: string): void;
|
||||
triggerHiddenFile(path: string): Promise<void>;
|
||||
|
||||
getFileStub(path: string): UXFileInfoStub | null;
|
||||
readStubContent(stub: UXFileInfoStub): Promise<UXFileInfo | false>;
|
||||
getStub(path: string): UXFileInfoStub | UXFolderInfo | null;
|
||||
|
||||
getFiles(): UXFileInfoStub[];
|
||||
getFileNames(): FilePathWithPrefix[];
|
||||
|
||||
touched(file: UXFileInfoStub | FilePathWithPrefix): Promise<void>;
|
||||
recentlyTouched(file: UXFileInfoStub | FilePathWithPrefix): boolean;
|
||||
clearTouched(): void;
|
||||
|
||||
// -- Low-Level
|
||||
delete(file: FilePathWithPrefix | UXFileInfoStub | string, force: boolean): Promise<void>;
|
||||
trash(file: FilePathWithPrefix | UXFileInfoStub | string, system: boolean): Promise<void>;
|
||||
|
||||
getFilesIncludeHidden(
|
||||
basePath: string,
|
||||
includeFilter?: CustomRegExp[],
|
||||
excludeFilter?: CustomRegExp[],
|
||||
skipFolder?: string[]
|
||||
): Promise<FilePath[]>;
|
||||
}
|
||||
@@ -3,17 +3,13 @@ import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, VER, type ObsidianLiveSyncSettings
|
||||
import {
|
||||
EVENT_LAYOUT_READY,
|
||||
EVENT_PLUGIN_LOADED,
|
||||
EVENT_PLUGIN_UNLOADED,
|
||||
EVENT_REQUEST_RELOAD_SETTING_TAB,
|
||||
EVENT_SETTING_SAVED,
|
||||
eventHub,
|
||||
} from "../../common/events.ts";
|
||||
import { $msg, setLang } from "../../lib/src/common/i18n.ts";
|
||||
import { versionNumberString2Number } from "../../lib/src/string_and_binary/convert.ts";
|
||||
import { cancelAllPeriodicTask, cancelAllTasks } from "octagonal-wheels/concurrency/task";
|
||||
import { stopAllRunningProcessors } from "octagonal-wheels/concurrency/processor";
|
||||
import { AbstractModule } from "../AbstractModule.ts";
|
||||
import { EVENT_PLATFORM_UNLOADED } from "@lib/events/coreEvents";
|
||||
import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub.ts";
|
||||
import type { LiveSyncCore } from "../../main.ts";
|
||||
import { initialiseWorkerModule } from "@lib/worker/bgWorker.ts";
|
||||
@@ -49,7 +45,7 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
}
|
||||
if (!(await this.core.services.appLifecycle.onFirstInitialise())) return false;
|
||||
// await this.core.$$realizeSettingSyncMode();
|
||||
await this.services.setting.realiseSetting();
|
||||
await this.services.control.applySettings();
|
||||
fireAndForget(async () => {
|
||||
this._log($msg("moduleLiveSyncMain.logAdditionalSafetyScan"), LOG_LEVEL_VERBOSE);
|
||||
if (!(await this.services.appLifecycle.onScanningStartupIssues())) {
|
||||
@@ -65,7 +61,7 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (settings: ObsidianLiveSyncSettings) => {
|
||||
fireAndForget(async () => {
|
||||
try {
|
||||
await this.core.services.setting.realiseSetting();
|
||||
await this.core.services.control.applySettings();
|
||||
const lang = this.core.services.setting.currentSettings()?.displayLanguage ?? undefined;
|
||||
if (lang !== undefined) {
|
||||
setLang(this.core.services.setting.currentSettings()?.displayLanguage);
|
||||
@@ -139,89 +135,82 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
return true;
|
||||
}
|
||||
|
||||
async _onLiveSyncUnload(): Promise<void> {
|
||||
eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
await this.services.appLifecycle.onBeforeUnload();
|
||||
cancelAllPeriodicTask();
|
||||
cancelAllTasks();
|
||||
stopAllRunningProcessors();
|
||||
await this.services.appLifecycle.onUnload();
|
||||
this._unloaded = true;
|
||||
for (const addOn of this.core.addOns) {
|
||||
addOn.onunload();
|
||||
}
|
||||
if (this.localDatabase != null) {
|
||||
this.localDatabase.onunload();
|
||||
if (this.core.replicator) {
|
||||
this.core.replicator?.closeReplication();
|
||||
}
|
||||
await this.localDatabase.close();
|
||||
}
|
||||
eventHub.emitEvent(EVENT_PLATFORM_UNLOADED);
|
||||
eventHub.offAll();
|
||||
this._log($msg("moduleLiveSyncMain.logUnloadingPlugin"));
|
||||
return;
|
||||
}
|
||||
// async _onLiveSyncUnload(): Promise<void> {
|
||||
// eventHub.emitEvent(EVENT_PLUGIN_UNLOADED);
|
||||
// await this.services.appLifecycle.onBeforeUnload();
|
||||
// cancelAllPeriodicTask();
|
||||
// cancelAllTasks();
|
||||
// stopAllRunningProcessors();
|
||||
// await this.services.appLifecycle.onUnload();
|
||||
// this._unloaded = true;
|
||||
// for (const addOn of this.core.addOns) {
|
||||
// addOn.onunload();
|
||||
// }
|
||||
// if (this.localDatabase != null) {
|
||||
// this.localDatabase.onunload();
|
||||
// if (this.core.replicator) {
|
||||
// this.core.replicator?.closeReplication();
|
||||
// }
|
||||
// await this.localDatabase.close();
|
||||
// }
|
||||
// eventHub.emitEvent(EVENT_PLATFORM_UNLOADED);
|
||||
// eventHub.offAll();
|
||||
// this._log($msg("moduleLiveSyncMain.logUnloadingPlugin"));
|
||||
// return;
|
||||
// }
|
||||
|
||||
private async _realizeSettingSyncMode(): Promise<void> {
|
||||
await this.services.appLifecycle.onSuspending();
|
||||
await this.services.setting.onBeforeRealiseSetting();
|
||||
this.localDatabase.refreshSettings();
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
await this.services.setting.onRealiseSetting();
|
||||
// disable all sync temporary.
|
||||
if (this.services.appLifecycle.isSuspended()) return;
|
||||
await this.services.appLifecycle.onResuming();
|
||||
await this.services.appLifecycle.onResumed();
|
||||
await this.services.setting.onSettingRealised();
|
||||
return;
|
||||
}
|
||||
// private async _realizeSettingSyncMode(): Promise<void> {
|
||||
// await this.services.appLifecycle.onSuspending();
|
||||
// await this.services.setting.onBeforeRealiseSetting();
|
||||
// this.localDatabase.refreshSettings();
|
||||
// await this.services.fileProcessing.commitPendingFileEvents();
|
||||
// await this.services.setting.onRealiseSetting();
|
||||
// // disable all sync temporary.
|
||||
// if (this.services.appLifecycle.isSuspended()) return;
|
||||
// await this.services.appLifecycle.onResuming();
|
||||
// await this.services.appLifecycle.onResumed();
|
||||
// await this.services.setting.onSettingRealised();
|
||||
// return;
|
||||
// }
|
||||
|
||||
_isReloadingScheduled(): boolean {
|
||||
return this.core._totalProcessingCount !== undefined;
|
||||
}
|
||||
// isReady = false;
|
||||
|
||||
isReady = false;
|
||||
// _isReady(): boolean {
|
||||
// return this.isReady;
|
||||
// }
|
||||
|
||||
_isReady(): boolean {
|
||||
return this.isReady;
|
||||
}
|
||||
// _markIsReady(): void {
|
||||
// this.isReady = true;
|
||||
// }
|
||||
|
||||
_markIsReady(): void {
|
||||
this.isReady = true;
|
||||
}
|
||||
// _resetIsReady(): void {
|
||||
// this.isReady = false;
|
||||
// }
|
||||
|
||||
_resetIsReady(): void {
|
||||
this.isReady = false;
|
||||
}
|
||||
// _suspended = false;
|
||||
// _isSuspended(): boolean {
|
||||
// return this._suspended || !this.settings?.isConfigured;
|
||||
// }
|
||||
|
||||
_suspended = false;
|
||||
_isSuspended(): boolean {
|
||||
return this._suspended || !this.settings?.isConfigured;
|
||||
}
|
||||
// _setSuspended(value: boolean) {
|
||||
// this._suspended = value;
|
||||
// }
|
||||
|
||||
_setSuspended(value: boolean) {
|
||||
this._suspended = value;
|
||||
}
|
||||
// _unloaded = false;
|
||||
// _isUnloaded(): boolean {
|
||||
// return this._unloaded;
|
||||
// }
|
||||
|
||||
_unloaded = false;
|
||||
_isUnloaded(): boolean {
|
||||
return this._unloaded;
|
||||
}
|
||||
|
||||
onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
super.onBindFunction(core, services);
|
||||
services.appLifecycle.isSuspended.setHandler(this._isSuspended.bind(this));
|
||||
services.appLifecycle.setSuspended.setHandler(this._setSuspended.bind(this));
|
||||
services.appLifecycle.isReady.setHandler(this._isReady.bind(this));
|
||||
services.appLifecycle.markIsReady.setHandler(this._markIsReady.bind(this));
|
||||
services.appLifecycle.resetIsReady.setHandler(this._resetIsReady.bind(this));
|
||||
services.appLifecycle.hasUnloaded.setHandler(this._isUnloaded.bind(this));
|
||||
services.appLifecycle.isReloadingScheduled.setHandler(this._isReloadingScheduled.bind(this));
|
||||
// services.appLifecycle.isSuspended.setHandler(this._isSuspended.bind(this));
|
||||
// services.appLifecycle.setSuspended.setHandler(this._setSuspended.bind(this));
|
||||
// services.appLifecycle.isReady.setHandler(this._isReady.bind(this));
|
||||
// services.appLifecycle.markIsReady.setHandler(this._markIsReady.bind(this));
|
||||
// services.appLifecycle.resetIsReady.setHandler(this._resetIsReady.bind(this));
|
||||
// services.appLifecycle.hasUnloaded.setHandler(this._isUnloaded.bind(this));
|
||||
services.appLifecycle.onReady.addHandler(this._onLiveSyncReady.bind(this));
|
||||
services.appLifecycle.onWireUpEvents.addHandler(this._wireUpEvents.bind(this));
|
||||
services.appLifecycle.onLoad.addHandler(this._onLiveSyncLoad.bind(this));
|
||||
services.appLifecycle.onAppUnload.addHandler(this._onLiveSyncUnload.bind(this));
|
||||
services.setting.realiseSetting.setHandler(this._realizeSettingSyncMode.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { InjectableAPIService } from "@/lib/src/services/implements/injectable/InjectableAPIService";
|
||||
import type { ObsidianServiceContext } from "@/lib/src/services/implements/obsidian/ObsidianServiceContext";
|
||||
import { InjectableAPIService } from "@lib/services/implements/injectable/InjectableAPIService";
|
||||
import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext";
|
||||
import { Platform, type Command, type ViewCreator } from "obsidian";
|
||||
import { ObsHttpHandler } from "../essentialObsidian/APILib/ObsHttpHandler";
|
||||
|
||||
import { ObsidianConfirm } from "./ObsidianConfirm";
|
||||
import type { Confirm } from "@lib/interfaces/Confirm";
|
||||
import { requestUrl, type RequestUrlParam } from "@/deps";
|
||||
// All Services will be migrated to be based on Plain Services, not Injectable Services.
|
||||
// This is a migration step.
|
||||
|
||||
export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceContext> {
|
||||
_customHandler: ObsHttpHandler | undefined;
|
||||
_confirmInstance: Confirm;
|
||||
constructor(context: ObsidianServiceContext) {
|
||||
super(context);
|
||||
this._confirmInstance = new ObsidianConfirm(context);
|
||||
}
|
||||
getCustomFetchHandler(): ObsHttpHandler {
|
||||
if (!this._customHandler) this._customHandler = new ObsHttpHandler(undefined, undefined);
|
||||
return this._customHandler;
|
||||
@@ -35,7 +42,7 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
return this.context.app;
|
||||
}
|
||||
|
||||
getPlatform(): string {
|
||||
override getPlatform(): string {
|
||||
if (Platform.isAndroidApp) {
|
||||
return "android-app";
|
||||
} else if (Platform.isIosApp) {
|
||||
@@ -63,6 +70,11 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
override getAppID(): string {
|
||||
return `${"appId" in this.app ? this.app.appId : ""}`;
|
||||
}
|
||||
|
||||
override getSystemVaultName(): string {
|
||||
return this.app.vault.getName();
|
||||
}
|
||||
|
||||
override getAppVersion(): string {
|
||||
const navigatorString = globalThis.navigator?.userAgent ?? "";
|
||||
const match = navigatorString.match(/obsidian\/([0-9]+\.[0-9]+\.[0-9]+)/);
|
||||
@@ -76,6 +88,10 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
return this.context.plugin.manifest.version;
|
||||
}
|
||||
|
||||
get confirm(): Confirm {
|
||||
return this._confirmInstance;
|
||||
}
|
||||
|
||||
addCommand<TCommand extends Command>(command: TCommand): TCommand {
|
||||
return this.context.plugin.addCommand(command) as TCommand;
|
||||
}
|
||||
@@ -86,7 +102,73 @@ export class ObsidianAPIService extends InjectableAPIService<ObsidianServiceCont
|
||||
addRibbonIcon(icon: string, title: string, callback: (evt: MouseEvent) => any): HTMLElement {
|
||||
return this.context.plugin.addRibbonIcon(icon, title, callback);
|
||||
}
|
||||
|
||||
registerProtocolHandler(action: string, handler: (params: Record<string, string>) => any): void {
|
||||
return this.context.plugin.registerObsidianProtocolHandler(action, handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* In Obsidian, we will use the native `requestUrl` function as the default fetch handler,
|
||||
* to address unavoidable CORS issues.
|
||||
*/
|
||||
override async nativeFetch(req: string | Request, opts?: RequestInit): Promise<Response> {
|
||||
const url = typeof req === "string" ? req : req.url;
|
||||
let body: string | ArrayBuffer | undefined = undefined;
|
||||
const method =
|
||||
typeof opts?.method === "string"
|
||||
? opts.method
|
||||
: req instanceof Request && typeof req.method === "string"
|
||||
? req.method
|
||||
: "GET";
|
||||
if (typeof req !== "string") {
|
||||
if (opts?.body) {
|
||||
body = typeof opts.body === "string" ? opts.body : await new Response(opts.body).arrayBuffer();
|
||||
} else if (req.body) {
|
||||
body = await new Response(req.body).arrayBuffer();
|
||||
}
|
||||
} else {
|
||||
body = opts?.body as string;
|
||||
}
|
||||
const reqHeaders = new Headers(req instanceof Request ? req.headers : {});
|
||||
|
||||
const optHeaders = {} as Record<string, string>;
|
||||
// Merge headers from the Request object and the options, with options taking precedence
|
||||
reqHeaders.forEach((value, key) => {
|
||||
optHeaders[key] = value;
|
||||
});
|
||||
if (opts && "headers" in opts) {
|
||||
if (opts.headers instanceof Headers) {
|
||||
// For Compatibility, mostly headers.entries() is supported, but not all environments.
|
||||
opts.headers.forEach((value, key) => {
|
||||
optHeaders[key] = value;
|
||||
});
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(opts.headers as Record<string, string>)) {
|
||||
optHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
const transformedHeaders = { ...optHeaders };
|
||||
// Delete headers that should not be sent by native fetch,
|
||||
// they are controlled by the browser and may cause CORS preflight failure if sent manually.
|
||||
delete transformedHeaders["host"];
|
||||
delete transformedHeaders["Host"];
|
||||
delete transformedHeaders["content-length"];
|
||||
delete transformedHeaders["Content-Length"];
|
||||
const contentType =
|
||||
transformedHeaders["content-type"] ?? transformedHeaders["Content-Type"] ?? "application/json";
|
||||
const requestParam: RequestUrlParam = {
|
||||
url,
|
||||
method: method,
|
||||
body: body,
|
||||
headers: transformedHeaders,
|
||||
contentType: contentType,
|
||||
};
|
||||
const r = await requestUrl({ ...requestParam, throw: false });
|
||||
return new Response(r.arrayBuffer, {
|
||||
headers: r.headers,
|
||||
status: r.status,
|
||||
statusText: `${r.status}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext";
|
||||
import { normalizePath } from "@/deps";
|
||||
import { PathService } from "@/lib/src/services/base/PathService";
|
||||
|
||||
import {
|
||||
type BASE_IS_NEW,
|
||||
type TARGET_IS_NEW,
|
||||
type EVEN,
|
||||
markChangesAreSame,
|
||||
unmarkChanges,
|
||||
compareFileFreshness,
|
||||
isMarkedAsSameChanges,
|
||||
} from "@/common/utils";
|
||||
import type { UXFileInfo, AnyEntry, UXFileInfoStub, FilePathWithPrefix } from "@/lib/src/common/types";
|
||||
export class ObsidianPathService extends PathService<ObsidianServiceContext> {
|
||||
override markChangesAreSame(
|
||||
old: UXFileInfo | AnyEntry | FilePathWithPrefix,
|
||||
newMtime: number,
|
||||
oldMtime: number
|
||||
): boolean | undefined {
|
||||
return markChangesAreSame(old, newMtime, oldMtime);
|
||||
}
|
||||
override unmarkChanges(file: AnyEntry | FilePathWithPrefix | UXFileInfoStub): void {
|
||||
return unmarkChanges(file);
|
||||
}
|
||||
override compareFileFreshness(
|
||||
baseFile: UXFileInfoStub | AnyEntry | undefined,
|
||||
checkTarget: UXFileInfo | AnyEntry | undefined
|
||||
): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
return compareFileFreshness(baseFile, checkTarget);
|
||||
}
|
||||
override isMarkedAsSameChanges(
|
||||
file: UXFileInfoStub | AnyEntry | FilePathWithPrefix,
|
||||
mtimes: number[]
|
||||
): undefined | typeof EVEN {
|
||||
return isMarkedAsSameChanges(file, mtimes);
|
||||
}
|
||||
protected normalizePath(path: string): string {
|
||||
return normalizePath(path);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InjectableServiceHub } from "@/lib/src/services/implements/injectable/InjectableServiceHub";
|
||||
import { ObsidianServiceContext } from "@/lib/src/services/implements/obsidian/ObsidianServiceContext";
|
||||
import type { ServiceInstances } from "@/lib/src/services/ServiceHub";
|
||||
import { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub";
|
||||
import { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext";
|
||||
import type { ServiceInstances } from "@lib/services/ServiceHub";
|
||||
import type ObsidianLiveSyncPlugin from "@/main";
|
||||
import {
|
||||
ObsidianConflictService,
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
ObsidianReplicationService,
|
||||
ObsidianReplicatorService,
|
||||
ObsidianRemoteService,
|
||||
ObsidianSettingService,
|
||||
ObsidianTweakValueService,
|
||||
ObsidianTestService,
|
||||
ObsidianDatabaseEventService,
|
||||
ObsidianConfigService,
|
||||
ObsidianKeyValueDBService,
|
||||
ObsidianControlService,
|
||||
} from "./ObsidianServices";
|
||||
import { ObsidianSettingService } from "./ObsidianSettingService";
|
||||
import { ObsidianDatabaseService } from "./ObsidianDatabaseService";
|
||||
import { ObsidianAPIService } from "./ObsidianAPIService";
|
||||
import { ObsidianAppLifecycleService } from "./ObsidianAppLifecycleService";
|
||||
@@ -29,16 +30,25 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
const context = new ObsidianServiceContext(plugin.app, plugin, plugin);
|
||||
|
||||
const API = new ObsidianAPIService(context);
|
||||
const appLifecycle = new ObsidianAppLifecycleService(context);
|
||||
const conflict = new ObsidianConflictService(context);
|
||||
const fileProcessing = new ObsidianFileProcessingService(context);
|
||||
const replication = new ObsidianReplicationService(context);
|
||||
|
||||
const remote = new ObsidianRemoteService(context);
|
||||
const setting = new ObsidianSettingService(context);
|
||||
const tweakValue = new ObsidianTweakValueService(context);
|
||||
|
||||
const setting = new ObsidianSettingService(context, {
|
||||
APIService: API,
|
||||
});
|
||||
const appLifecycle = new ObsidianAppLifecycleService(context, {
|
||||
settingService: setting,
|
||||
});
|
||||
const remote = new ObsidianRemoteService(context, {
|
||||
APIService: API,
|
||||
appLifecycle: appLifecycle,
|
||||
setting: setting,
|
||||
});
|
||||
const vault = new ObsidianVaultService(context, {
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
});
|
||||
const test = new ObsidianTestService(context);
|
||||
const databaseEvents = new ObsidianDatabaseEventService(context);
|
||||
@@ -49,24 +59,46 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
path: path,
|
||||
vault: vault,
|
||||
setting: setting,
|
||||
API: API,
|
||||
});
|
||||
const keyValueDB = new ObsidianKeyValueDBService(context, {
|
||||
appLifecycle: appLifecycle,
|
||||
databaseEvents: databaseEvents,
|
||||
vault: vault,
|
||||
});
|
||||
const config = new ObsidianConfigService(context, vault);
|
||||
const config = new ObsidianConfigService(context, {
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
});
|
||||
const replicator = new ObsidianReplicatorService(context, {
|
||||
settingService: setting,
|
||||
appLifecycleService: appLifecycle,
|
||||
databaseEventService: databaseEvents,
|
||||
});
|
||||
const replication = new ObsidianReplicationService(context, {
|
||||
APIService: API,
|
||||
appLifecycleService: appLifecycle,
|
||||
replicatorService: replicator,
|
||||
settingService: setting,
|
||||
fileProcessingService: fileProcessing,
|
||||
databaseService: database,
|
||||
});
|
||||
|
||||
const control = new ObsidianControlService(context, {
|
||||
appLifecycleService: appLifecycle,
|
||||
databaseService: database,
|
||||
fileProcessingService: fileProcessing,
|
||||
settingService: setting,
|
||||
APIService: API,
|
||||
replicatorService: replicator,
|
||||
});
|
||||
const ui = new ObsidianUIService(context, {
|
||||
appLifecycle,
|
||||
config,
|
||||
replicator,
|
||||
APIService: API,
|
||||
control: control,
|
||||
});
|
||||
|
||||
// Using 'satisfies' to ensure all services are provided
|
||||
const serviceInstancesToInit = {
|
||||
appLifecycle: appLifecycle,
|
||||
@@ -86,6 +118,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
API: API,
|
||||
config: config,
|
||||
keyValueDB: keyValueDB,
|
||||
control: control,
|
||||
} satisfies Required<ServiceInstances<ObsidianServiceContext>>;
|
||||
|
||||
super(context, serviceInstancesToInit);
|
||||
|
||||
@@ -4,12 +4,12 @@ import { InjectableFileProcessingService } from "@lib/services/implements/inject
|
||||
import { InjectableRemoteService } from "@lib/services/implements/injectable/InjectableRemoteService";
|
||||
import { InjectableReplicationService } from "@lib/services/implements/injectable/InjectableReplicationService";
|
||||
import { InjectableReplicatorService } from "@lib/services/implements/injectable/InjectableReplicatorService";
|
||||
import { InjectableSettingService } from "@lib/services/implements/injectable/InjectableSettingService";
|
||||
import { InjectableTestService } from "@lib/services/implements/injectable/InjectableTestService";
|
||||
import { InjectableTweakValueService } from "@lib/services/implements/injectable/InjectableTweakValueService";
|
||||
import { ConfigServiceBrowserCompat } from "@lib/services/implements/browser/ConfigServiceBrowserCompat";
|
||||
import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext.ts";
|
||||
import { KeyValueDBService } from "@/lib/src/services/base/KeyValueDBService";
|
||||
import { KeyValueDBService } from "@lib/services/base/KeyValueDBService";
|
||||
import { ControlService } from "@lib/services/base/ControlService";
|
||||
|
||||
export class ObsidianDatabaseEventService extends InjectableDatabaseEventService<ObsidianServiceContext> {}
|
||||
|
||||
@@ -23,8 +23,6 @@ export class ObsidianReplicationService extends InjectableReplicationService<Obs
|
||||
export class ObsidianRemoteService extends InjectableRemoteService<ObsidianServiceContext> {}
|
||||
// InjectableConflictService
|
||||
export class ObsidianConflictService extends InjectableConflictService<ObsidianServiceContext> {}
|
||||
// InjectableSettingService
|
||||
export class ObsidianSettingService extends InjectableSettingService<ObsidianServiceContext> {}
|
||||
// InjectableTweakValueService
|
||||
export class ObsidianTweakValueService extends InjectableTweakValueService<ObsidianServiceContext> {}
|
||||
// InjectableTestService
|
||||
@@ -32,3 +30,5 @@ export class ObsidianTestService extends InjectableTestService<ObsidianServiceCo
|
||||
export class ObsidianConfigService extends ConfigServiceBrowserCompat<ObsidianServiceContext> {}
|
||||
|
||||
export class ObsidianKeyValueDBService extends KeyValueDBService<ObsidianServiceContext> {}
|
||||
|
||||
export class ObsidianControlService extends ControlService<ObsidianServiceContext> {}
|
||||
|
||||
35
src/modules/services/ObsidianSettingService.ts
Normal file
35
src/modules/services/ObsidianSettingService.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED } from "@lib/events/coreEvents";
|
||||
import { eventHub } from "@lib/hub/hub";
|
||||
import { SettingService, type SettingServiceDependencies } from "@lib/services/base/SettingService";
|
||||
import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext";
|
||||
|
||||
export class ObsidianSettingService<T extends ObsidianServiceContext> extends SettingService<T> {
|
||||
constructor(context: T, dependencies: SettingServiceDependencies) {
|
||||
super(context, dependencies);
|
||||
this.onSettingSaved.addHandler((settings) => {
|
||||
eventHub.emitEvent(EVENT_SETTING_SAVED, settings);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
this.onSettingLoaded.addHandler((settings) => {
|
||||
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||
return Promise.resolve(true);
|
||||
});
|
||||
}
|
||||
protected setItem(key: string, value: string) {
|
||||
return localStorage.setItem(key, value);
|
||||
}
|
||||
protected getItem(key: string): string {
|
||||
return localStorage.getItem(key) ?? "";
|
||||
}
|
||||
protected deleteItem(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
protected override async saveData(data: ObsidianLiveSyncSettings): Promise<void> {
|
||||
return await this.context.liveSyncPlugin.saveData(data);
|
||||
}
|
||||
protected override async loadData(): Promise<ObsidianLiveSyncSettings | undefined> {
|
||||
return await this.context.liveSyncPlugin.loadData();
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,16 @@ import type { ConfigService } from "@lib/services/base/ConfigService";
|
||||
import type { AppLifecycleService } from "@lib/services/base/AppLifecycleService";
|
||||
import type { ReplicatorService } from "@lib/services/base/ReplicatorService";
|
||||
import { UIService } from "@lib/services//implements/base/UIService";
|
||||
import { ObsidianServiceContext } from "@/lib/src/services/implements/obsidian/ObsidianServiceContext";
|
||||
import { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext";
|
||||
import { ObsidianSvelteDialogManager } from "./SvelteDialogObsidian";
|
||||
import { ObsidianConfirm } from "./ObsidianConfirm";
|
||||
import DialogToCopy from "@/lib/src/UI/dialogues/DialogueToCopy.svelte";
|
||||
import DialogToCopy from "@lib/UI/dialogues/DialogueToCopy.svelte";
|
||||
import type { IAPIService, IControlService } from "@lib/services/base/IService";
|
||||
export type ObsidianUIServiceDependencies<T extends ObsidianServiceContext = ObsidianServiceContext> = {
|
||||
appLifecycle: AppLifecycleService<T>;
|
||||
config: ConfigService<T>;
|
||||
replicator: ReplicatorService<T>;
|
||||
APIService: IAPIService;
|
||||
control: IControlService;
|
||||
};
|
||||
|
||||
export class ObsidianUIService extends UIService<ObsidianServiceContext> {
|
||||
@@ -17,17 +19,18 @@ export class ObsidianUIService extends UIService<ObsidianServiceContext> {
|
||||
return DialogToCopy;
|
||||
}
|
||||
constructor(context: ObsidianServiceContext, dependents: ObsidianUIServiceDependencies<ObsidianServiceContext>) {
|
||||
const obsidianConfirm = new ObsidianConfirm(context);
|
||||
const obsidianConfirm = dependents.APIService.confirm;
|
||||
const obsidianSvelteDialogManager = new ObsidianSvelteDialogManager<ObsidianServiceContext>(context, {
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
config: dependents.config,
|
||||
replicator: dependents.replicator,
|
||||
confirm: obsidianConfirm,
|
||||
control: dependents.control,
|
||||
});
|
||||
super(context, {
|
||||
appLifecycle: dependents.appLifecycle,
|
||||
dialogManager: obsidianSvelteDialogManager,
|
||||
confirm: obsidianConfirm,
|
||||
APIService: dependents.APIService,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getPathFromTFile } from "@/common/utils";
|
||||
import { getPathFromTFile, isValidPath } from "@/common/utils";
|
||||
import { InjectableVaultService } from "@/lib/src/services/implements/injectable/InjectableVaultService";
|
||||
import type { ObsidianServiceContext } from "@/lib/src/services/implements/obsidian/ObsidianServiceContext";
|
||||
import type { FilePath } from "@/lib/src/common/types";
|
||||
@@ -11,7 +11,7 @@ declare module "obsidian" {
|
||||
|
||||
// InjectableVaultService
|
||||
export class ObsidianVaultService extends InjectableVaultService<ObsidianServiceContext> {
|
||||
vaultName(): string {
|
||||
override vaultName(): string {
|
||||
return this.context.app.vault.getName();
|
||||
}
|
||||
getActiveFilePath(): FilePath | undefined {
|
||||
@@ -30,4 +30,7 @@ export class ObsidianVaultService extends InjectableVaultService<ObsidianService
|
||||
if (this.isStorageInsensitive()) return false;
|
||||
return super.shouldCheckCaseInsensitively(); // Check the setting
|
||||
}
|
||||
override isValidPath(path: string): boolean {
|
||||
return isValidPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
3
src/serviceFeatures/onLayoutReady.ts
Normal file
3
src/serviceFeatures/onLayoutReady.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { enableI18nFeature } from "./onLayoutReady/enablei18n";
|
||||
|
||||
export const onLayoutReadyFeatures = [enableI18nFeature];
|
||||
41
src/serviceFeatures/onLayoutReady/enablei18n.ts
Normal file
41
src/serviceFeatures/onLayoutReady/enablei18n.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getLanguage } from "@/deps";
|
||||
import { createServiceFeature } from "@lib/interfaces/ServiceModule";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta";
|
||||
import { $msg, setLang } from "@lib/common/i18n";
|
||||
|
||||
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API } }) => {
|
||||
let isChanged = false;
|
||||
const settings = setting.currentSettings();
|
||||
if (settings.displayLanguage == "") {
|
||||
const obsidianLanguage = getLanguage();
|
||||
if (
|
||||
SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported
|
||||
obsidianLanguage != settings.displayLanguage // Check if the language is different from the current setting
|
||||
) {
|
||||
// Check if the current setting is not empty (Means migrated or installed).
|
||||
// settings.displayLanguage = obsidianLanguage as I18N_LANGS;
|
||||
await setting.applyPartial({ displayLanguage: obsidianLanguage as I18N_LANGS });
|
||||
isChanged = true;
|
||||
setLang(settings.displayLanguage);
|
||||
} else if (settings.displayLanguage == "") {
|
||||
// settings.displayLanguage = "def";
|
||||
await setting.applyPartial({ displayLanguage: "def" });
|
||||
setLang(settings.displayLanguage);
|
||||
await setting.saveSettingData();
|
||||
}
|
||||
}
|
||||
if (isChanged) {
|
||||
const revert = $msg("dialog.yourLanguageAvailable.btnRevertToDefault");
|
||||
if (
|
||||
(await API.confirm.askSelectStringDialogue($msg(`dialog.yourLanguageAvailable`), ["OK", revert], {
|
||||
defaultAction: "OK",
|
||||
title: $msg(`dialog.yourLanguageAvailable.Title`),
|
||||
})) == revert
|
||||
) {
|
||||
await setting.applyPartial({ displayLanguage: "def" });
|
||||
setLang(settings.displayLanguage);
|
||||
}
|
||||
await setting.saveSettingData();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
387
src/serviceFeatures/redFlag.ts
Normal file
387
src/serviceFeatures/redFlag.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import type { NecessaryServices } from "@lib/interfaces/ServiceModule";
|
||||
import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils";
|
||||
import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/redflag.const";
|
||||
import FetchEverything from "../modules/features/SetupWizard/dialogs/FetchEverything.svelte";
|
||||
import RebuildEverything from "../modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { REMOTE_MINIO } from "@lib/common/models/setting.const";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type";
|
||||
import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition";
|
||||
|
||||
/**
|
||||
* Flag file handler interface, similar to target filter pattern.
|
||||
*/
|
||||
interface FlagFileHandler {
|
||||
priority: number;
|
||||
check: () => Promise<boolean>;
|
||||
handle: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export async function isFlagFileExist(host: NecessaryServices<never, "storageAccess">, path: string) {
|
||||
const redFlagExist = await host.serviceModules.storageAccess.isExists(
|
||||
host.serviceModules.storageAccess.normalisePath(path)
|
||||
);
|
||||
if (redFlagExist) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function deleteFlagFile(host: NecessaryServices<never, "storageAccess">, log: LogFunction, path: string) {
|
||||
try {
|
||||
const isFlagged = await host.serviceModules.storageAccess.isExists(
|
||||
host.serviceModules.storageAccess.normalisePath(path)
|
||||
);
|
||||
if (isFlagged) {
|
||||
await host.serviceModules.storageAccess.delete(path, true);
|
||||
}
|
||||
} catch (ex) {
|
||||
log(`Could not delete ${path}`);
|
||||
log(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Factory function to create a fetch all flag handler.
|
||||
* All logic related to fetch all flag is encapsulated here.
|
||||
*/
|
||||
export function createFetchAllFlagHandler(
|
||||
host: NecessaryServices<
|
||||
"vault" | "fileProcessing" | "tweakValue" | "UI" | "setting" | "appLifecycle",
|
||||
"storageAccess" | "rebuilder"
|
||||
>,
|
||||
log: LogFunction
|
||||
): FlagFileHandler {
|
||||
// Check if fetch all flag is active
|
||||
const isFlagActive = async () =>
|
||||
(await isFlagFileExist(host, FlagFilesOriginal.FETCH_ALL)) ||
|
||||
(await isFlagFileExist(host, FlagFilesHumanReadable.FETCH_ALL));
|
||||
|
||||
// Cleanup fetch all flag files
|
||||
const cleanupFlag = async () => {
|
||||
await deleteFlagFile(host, log, FlagFilesOriginal.FETCH_ALL);
|
||||
await deleteFlagFile(host, log, FlagFilesHumanReadable.FETCH_ALL);
|
||||
};
|
||||
|
||||
// Handle the fetch all scheduled operation
|
||||
const onScheduled = async () => {
|
||||
const method = await host.services.UI.dialogManager.openWithExplicitCancel(FetchEverything);
|
||||
if (method === "cancelled") {
|
||||
log("Fetch everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
await cleanupFlag();
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
const { vault, extra } = method;
|
||||
const settings = await host.services.setting.currentSettings();
|
||||
// If remote is MinIO, makeLocalChunkBeforeSync is not available. (because no-deduplication on sending).
|
||||
const makeLocalChunkBeforeSyncAvailable = settings.remoteType !== REMOTE_MINIO;
|
||||
const mapVaultStateToAction = {
|
||||
identical: {
|
||||
makeLocalChunkBeforeSync: makeLocalChunkBeforeSyncAvailable,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
independent: {
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
unbalanced: {
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: true,
|
||||
},
|
||||
cancelled: {
|
||||
makeLocalChunkBeforeSync: false,
|
||||
makeLocalFilesBeforeSync: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
return await processVaultInitialisation(host, log, async () => {
|
||||
const settings = host.services.setting.currentSettings();
|
||||
await adjustSettingToRemoteIfNeeded(host, log, extra, settings);
|
||||
const vaultStateToAction = mapVaultStateToAction[vault];
|
||||
const { makeLocalChunkBeforeSync, makeLocalFilesBeforeSync } = vaultStateToAction;
|
||||
log(
|
||||
`Fetching everything with settings: makeLocalChunkBeforeSync=${makeLocalChunkBeforeSync}, makeLocalFilesBeforeSync=${makeLocalFilesBeforeSync}`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
await host.serviceModules.rebuilder.$fetchLocal(makeLocalChunkBeforeSync, !makeLocalFilesBeforeSync);
|
||||
await cleanupFlag();
|
||||
log("Fetch everything operation completed. Vault files will be gradually synced.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
priority: 10,
|
||||
check: () => isFlagActive(),
|
||||
handle: async () => {
|
||||
const res = await onScheduled();
|
||||
if (res) {
|
||||
return await verifyAndUnlockSuspension(host, log);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust setting to remote configuration.
|
||||
* @param config current configuration to retrieve remote preferred config
|
||||
* @returns updated configuration if applied, otherwise null.
|
||||
*/
|
||||
export async function adjustSettingToRemote(
|
||||
host: NecessaryServices<"tweakValue" | "UI" | "setting", any>,
|
||||
log: LogFunction,
|
||||
config: ObsidianLiveSyncSettings
|
||||
) {
|
||||
// Fetch remote configuration unless prevented.
|
||||
const SKIP_FETCH = "Skip and proceed";
|
||||
const RETRY_FETCH = "Retry (recommended)";
|
||||
let canProceed = false;
|
||||
do {
|
||||
const remoteTweaks = await host.services.tweakValue.fetchRemotePreferred(config);
|
||||
if (!remoteTweaks) {
|
||||
const choice = await host.services.UI.confirm.askSelectStringDialogue(
|
||||
"Could not fetch configuration from remote. If you are new to the Self-hosted LiveSync, this might be expected. If not, you should check your network or server settings.",
|
||||
[SKIP_FETCH, RETRY_FETCH] as const,
|
||||
{
|
||||
defaultAction: RETRY_FETCH,
|
||||
timeout: 0,
|
||||
title: "Fetch Remote Configuration Failed",
|
||||
}
|
||||
);
|
||||
if (choice === SKIP_FETCH) {
|
||||
canProceed = true;
|
||||
}
|
||||
} else {
|
||||
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
||||
// Check if any necessary tweak value is different from current config.
|
||||
const differentItems = Object.entries(necessary).filter(([key, value]) => {
|
||||
return (config as any)[key] !== value;
|
||||
});
|
||||
if (differentItems.length === 0) {
|
||||
log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
await host.services.UI.confirm.askSelectStringDialogue(
|
||||
"Your settings differed slightly from the server's. The plug-in has supplemented the incompatible parts with the server settings!",
|
||||
["OK"] as const,
|
||||
{
|
||||
defaultAction: "OK",
|
||||
timeout: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
config = {
|
||||
...config,
|
||||
...Object.fromEntries(differentItems),
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
await host.services.setting.applyPartial(config, true);
|
||||
log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
||||
canProceed = true;
|
||||
const updatedConfig = host.services.setting.currentSettings();
|
||||
return updatedConfig;
|
||||
}
|
||||
} while (!canProceed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust setting to remote if needed.
|
||||
* @param extra result of dialogues that may contain preventFetchingConfig flag (e.g, from FetchEverything or RebuildEverything)
|
||||
* @param config current configuration to retrieve remote preferred config
|
||||
*/
|
||||
export async function adjustSettingToRemoteIfNeeded(
|
||||
host: NecessaryServices<"tweakValue" | "UI" | "setting", any>,
|
||||
log: LogFunction,
|
||||
extra: { preventFetchingConfig: boolean },
|
||||
config: ObsidianLiveSyncSettings
|
||||
) {
|
||||
if (extra && extra.preventFetchingConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remote configuration fetched and applied.
|
||||
if (await adjustSettingToRemote(host, log, config)) {
|
||||
config = host.services.setting.currentSettings();
|
||||
} else {
|
||||
log("Remote configuration not applied.", LOG_LEVEL_NOTICE);
|
||||
}
|
||||
// log(JSON.stringify(config), LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process vault initialisation with suspending file watching and sync.
|
||||
* @param proc process to be executed during initialisation, should return true if can be continued, false if app is unable to continue the process.
|
||||
* @param keepSuspending whether to keep suspending file watching after the process.
|
||||
* @returns result of the process, or false if error occurs.
|
||||
*/
|
||||
export async function processVaultInitialisation(
|
||||
host: NecessaryServices<"setting", any>,
|
||||
log: LogFunction,
|
||||
proc: () => Promise<boolean>,
|
||||
keepSuspending = false
|
||||
) {
|
||||
try {
|
||||
// Disable batch saving and file watching during initialisation.
|
||||
await host.services.setting.applyPartial({ batchSave: false }, false);
|
||||
await host.services.setting.suspendAllSync();
|
||||
await host.services.setting.suspendExtraSync();
|
||||
await host.services.setting.applyPartial({ suspendFileWatching: true }, true);
|
||||
try {
|
||||
const result = await proc();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
log("Error during vault initialisation process.", LOG_LEVEL_NOTICE);
|
||||
log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
log("Error during vault initialisation.", LOG_LEVEL_NOTICE);
|
||||
log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
} finally {
|
||||
if (!keepSuspending) {
|
||||
// Re-enable file watching after initialisation.
|
||||
await host.services.setting.applyPartial({ suspendFileWatching: false }, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyAndUnlockSuspension(
|
||||
host: NecessaryServices<"setting" | "appLifecycle" | "UI", any>,
|
||||
log: LogFunction
|
||||
) {
|
||||
if (!host.services.setting.currentSettings().suspendFileWatching) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(await host.services.UI.confirm.askYesNoDialog(
|
||||
"Do you want to resume file and database processing, and restart obsidian now?",
|
||||
{ defaultOption: "Yes", timeout: 15 }
|
||||
)) != "yes"
|
||||
) {
|
||||
// TODO: Confirm actually proceed to next process.
|
||||
return true;
|
||||
}
|
||||
await host.services.setting.applyPartial({ suspendFileWatching: false }, true);
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a rebuild flag handler.
|
||||
* All logic related to rebuild flag is encapsulated here.
|
||||
*/
|
||||
export function createRebuildFlagHandler(
|
||||
host: NecessaryServices<"setting" | "appLifecycle" | "UI" | "tweakValue", "storageAccess" | "rebuilder">,
|
||||
log: LogFunction
|
||||
) {
|
||||
// Check if rebuild flag is active
|
||||
const isFlagActive = async () =>
|
||||
(await isFlagFileExist(host, FlagFilesOriginal.REBUILD_ALL)) ||
|
||||
(await isFlagFileExist(host, FlagFilesHumanReadable.REBUILD_ALL));
|
||||
|
||||
// Cleanup rebuild flag files
|
||||
const cleanupFlag = async () => {
|
||||
await deleteFlagFile(host, log, FlagFilesOriginal.REBUILD_ALL);
|
||||
await deleteFlagFile(host, log, FlagFilesHumanReadable.REBUILD_ALL);
|
||||
};
|
||||
|
||||
// Handle the rebuild everything scheduled operation
|
||||
const onScheduled = async () => {
|
||||
const method = await host.services.UI.dialogManager.openWithExplicitCancel(RebuildEverything);
|
||||
if (method === "cancelled") {
|
||||
log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE);
|
||||
await cleanupFlag();
|
||||
host.services.appLifecycle.performRestart();
|
||||
return false;
|
||||
}
|
||||
const { extra } = method;
|
||||
const settings = host.services.setting.currentSettings();
|
||||
await adjustSettingToRemoteIfNeeded(host, log, extra, settings);
|
||||
return await processVaultInitialisation(host, log, async () => {
|
||||
await host.serviceModules.rebuilder.$rebuildEverything();
|
||||
await cleanupFlag();
|
||||
log("Rebuild everything operation completed.", LOG_LEVEL_NOTICE);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
priority: 20,
|
||||
check: () => isFlagActive(),
|
||||
handle: async () => {
|
||||
const res = await onScheduled();
|
||||
if (res) {
|
||||
return await verifyAndUnlockSuspension(host, log);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a suspend all flag handler.
|
||||
* All logic related to suspend flag is encapsulated here.
|
||||
*/
|
||||
export function createSuspendFlagHandler(
|
||||
host: NecessaryServices<"setting", "storageAccess">,
|
||||
log: LogFunction
|
||||
): FlagFileHandler {
|
||||
// Check if suspend flag is active
|
||||
const isFlagActive = async () => await isFlagFileExist(host, FlagFilesOriginal.SUSPEND_ALL);
|
||||
|
||||
// Handle the suspend all scheduled operation
|
||||
const onScheduled = async () => {
|
||||
log("SCRAM is detected. All operations are suspended.", LOG_LEVEL_NOTICE);
|
||||
return await processVaultInitialisation(
|
||||
host,
|
||||
log,
|
||||
async () => {
|
||||
log(
|
||||
"All operations are suspended as per SCRAM.\nLogs will be written to the file. This might be a performance impact.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await host.services.setting.applyPartial({ writeLogToTheFile: true }, true);
|
||||
return Promise.resolve(false);
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
priority: 5,
|
||||
check: () => isFlagActive(),
|
||||
handle: () => onScheduled(),
|
||||
};
|
||||
}
|
||||
|
||||
export function flagHandlerToEventHandler(flagHandler: FlagFileHandler) {
|
||||
return async () => {
|
||||
if (await flagHandler.check()) {
|
||||
return await flagHandler.handle();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export function useRedFlagFeatures(
|
||||
host: NecessaryServices<
|
||||
"API" | "appLifecycle" | "UI" | "setting" | "tweakValue" | "fileProcessing" | "vault",
|
||||
"storageAccess" | "rebuilder"
|
||||
>
|
||||
) {
|
||||
const log = createInstanceLogFunction("SF:RedFlag", host.services.API);
|
||||
const handlerFetch = createFetchAllFlagHandler(host, log);
|
||||
const handlerRebuild = createRebuildFlagHandler(host, log);
|
||||
const handlerSuspend = createSuspendFlagHandler(host, log);
|
||||
host.services.appLifecycle.onLayoutReady.addHandler(flagHandlerToEventHandler(handlerFetch), handlerFetch.priority);
|
||||
host.services.appLifecycle.onLayoutReady.addHandler(
|
||||
flagHandlerToEventHandler(handlerRebuild),
|
||||
handlerRebuild.priority
|
||||
);
|
||||
host.services.appLifecycle.onLayoutReady.addHandler(
|
||||
flagHandlerToEventHandler(handlerSuspend),
|
||||
handlerSuspend.priority
|
||||
);
|
||||
}
|
||||
1140
src/serviceFeatures/redFlag.unit.spec.ts
Normal file
1140
src/serviceFeatures/redFlag.unit.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
8
src/serviceModules/DatabaseFileAccess.ts
Normal file
8
src/serviceModules/DatabaseFileAccess.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess.ts";
|
||||
import { ServiceDatabaseFileAccessBase } from "@lib/serviceModules/ServiceDatabaseFileAccessBase";
|
||||
|
||||
// markChangesAreSame uses persistent data implicitly, we should refactor it too.
|
||||
// For now, to make the refactoring done once, we just use them directly.
|
||||
// Hence it is not on /src/lib/src/serviceModules. (markChangesAreSame is using indexedDB).
|
||||
// Refactored, now migrating...
|
||||
export class ServiceDatabaseFileAccess extends ServiceDatabaseFileAccessBase implements DatabaseFileAccess {}
|
||||
14
src/serviceModules/FileAccessObsidian.ts
Normal file
14
src/serviceModules/FileAccessObsidian.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { type App } from "@/deps";
|
||||
import { FileAccessBase, type FileAccessBaseDependencies } from "@lib/serviceModules/FileAccessBase.ts";
|
||||
import { ObsidianFileSystemAdapter } from "./FileSystemAdapters/ObsidianFileSystemAdapter";
|
||||
|
||||
/**
|
||||
* Obsidian-specific implementation of FileAccessBase
|
||||
* Uses ObsidianFileSystemAdapter for platform-specific operations
|
||||
*/
|
||||
export class FileAccessObsidian extends FileAccessBase<ObsidianFileSystemAdapter> {
|
||||
constructor(app: App, dependencies: FileAccessBaseDependencies) {
|
||||
const adapter = new ObsidianFileSystemAdapter(app);
|
||||
super(adapter, dependencies);
|
||||
}
|
||||
}
|
||||
7
src/serviceModules/FileHandler.ts
Normal file
7
src/serviceModules/FileHandler.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ServiceFileHandlerBase } from "@lib/serviceModules/ServiceFileHandlerBase";
|
||||
|
||||
// markChangesAreSame uses persistent data implicitly, we should refactor it too.
|
||||
// also, compareFileFreshness depends on marked changes, so we should refactor it as well. For now, to make the refactoring done once, we just use them directly.
|
||||
// Hence it is not on /src/lib/src/serviceModules. (markChangesAreSame is using indexedDB).
|
||||
// Refactored: markChangesAreSame, unmarkChanges, compareFileFreshness, isMarkedAsSameChanges are now moved to PathService
|
||||
export class ServiceFileHandler extends ServiceFileHandlerBase {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { UXFileInfoStub, UXFolderInfo } from "@/lib/src/common/types";
|
||||
import type { IConversionAdapter } from "@/lib/src/serviceModules/adapters";
|
||||
import { TFileToUXFileInfoStub, TFolderToUXFileInfoStub } from "@/modules/coreObsidian/storageLib/utilObsidian";
|
||||
import type { TFile, TFolder } from "obsidian";
|
||||
|
||||
/**
|
||||
* Conversion adapter implementation for Obsidian
|
||||
*/
|
||||
|
||||
export class ObsidianConversionAdapter implements IConversionAdapter<TFile, TFolder> {
|
||||
nativeFileToUXFileInfoStub(file: TFile): UXFileInfoStub {
|
||||
return TFileToUXFileInfoStub(file);
|
||||
}
|
||||
|
||||
nativeFolderToUXFolder(folder: TFolder): UXFolderInfo {
|
||||
return TFolderToUXFileInfoStub(folder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FilePath, UXStat } from "@/lib/src/common/types";
|
||||
import type {
|
||||
IFileSystemAdapter,
|
||||
IPathAdapter,
|
||||
ITypeGuardAdapter,
|
||||
IConversionAdapter,
|
||||
IStorageAdapter,
|
||||
IVaultAdapter,
|
||||
} from "@/lib/src/serviceModules/adapters";
|
||||
import type { TAbstractFile, TFile, TFolder, Stat, App } from "obsidian";
|
||||
import { ObsidianConversionAdapter } from "./ObsidianConversionAdapter";
|
||||
import { ObsidianPathAdapter } from "./ObsidianPathAdapter";
|
||||
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
|
||||
import { ObsidianTypeGuardAdapter } from "./ObsidianTypeGuardAdapter";
|
||||
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
|
||||
|
||||
declare module "obsidian" {
|
||||
interface Vault {
|
||||
getAbstractFileByPathInsensitive(path: string): TAbstractFile | null;
|
||||
}
|
||||
interface DataAdapter {
|
||||
reconcileInternalFile?(path: string): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete file system adapter implementation for Obsidian
|
||||
*/
|
||||
|
||||
export class ObsidianFileSystemAdapter implements IFileSystemAdapter<TAbstractFile, TFile, TFolder, Stat> {
|
||||
readonly path: IPathAdapter<TAbstractFile>;
|
||||
readonly typeGuard: ITypeGuardAdapter<TFile, TFolder>;
|
||||
readonly conversion: IConversionAdapter<TFile, TFolder>;
|
||||
readonly storage: IStorageAdapter<Stat>;
|
||||
readonly vault: IVaultAdapter<TFile>;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.path = new ObsidianPathAdapter();
|
||||
this.typeGuard = new ObsidianTypeGuardAdapter();
|
||||
this.conversion = new ObsidianConversionAdapter();
|
||||
this.storage = new ObsidianStorageAdapter(app);
|
||||
this.vault = new ObsidianVaultAdapter(app);
|
||||
}
|
||||
|
||||
getAbstractFileByPath(path: FilePath | string): TAbstractFile | null {
|
||||
return this.app.vault.getAbstractFileByPath(path);
|
||||
}
|
||||
|
||||
getAbstractFileByPathInsensitive(path: FilePath | string): TAbstractFile | null {
|
||||
return this.app.vault.getAbstractFileByPathInsensitive(path);
|
||||
}
|
||||
|
||||
getFiles(): TFile[] {
|
||||
return this.app.vault.getFiles();
|
||||
}
|
||||
|
||||
statFromNative(file: TFile): Promise<UXStat> {
|
||||
return Promise.resolve({ ...file.stat, type: "file" });
|
||||
}
|
||||
|
||||
async reconcileInternalFile(path: string): Promise<void> {
|
||||
return await Promise.resolve(this.app.vault.adapter.reconcileInternalFile?.(path));
|
||||
}
|
||||
}
|
||||
16
src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.ts
Normal file
16
src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type TAbstractFile, normalizePath } from "@/deps";
|
||||
import type { FilePath } from "@lib/common/types";
|
||||
import type { IPathAdapter } from "@lib/serviceModules/adapters";
|
||||
|
||||
/**
|
||||
* Path adapter implementation for Obsidian
|
||||
*/
|
||||
export class ObsidianPathAdapter implements IPathAdapter<TAbstractFile> {
|
||||
getPath(file: string | TAbstractFile): FilePath {
|
||||
return (typeof file === "string" ? file : file.path) as FilePath;
|
||||
}
|
||||
|
||||
normalisePath(path: string): string {
|
||||
return normalizePath(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { UXDataWriteOptions } from "@/lib/src/common/types";
|
||||
import type { IStorageAdapter } from "@/lib/src/serviceModules/adapters";
|
||||
import { toArrayBuffer } from "@/lib/src/serviceModules/FileAccessBase";
|
||||
import type { Stat, App } from "obsidian";
|
||||
|
||||
/**
|
||||
* Storage adapter implementation for Obsidian
|
||||
*/
|
||||
|
||||
export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
|
||||
constructor(private app: App) {}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return await this.app.vault.adapter.exists(path);
|
||||
}
|
||||
|
||||
async trystat(path: string): Promise<Stat | null> {
|
||||
if (!(await this.app.vault.adapter.exists(path))) return null;
|
||||
return await this.app.vault.adapter.stat(path);
|
||||
}
|
||||
|
||||
async stat(path: string): Promise<Stat | null> {
|
||||
return await this.app.vault.adapter.stat(path);
|
||||
}
|
||||
|
||||
async mkdir(path: string): Promise<void> {
|
||||
await this.app.vault.adapter.mkdir(path);
|
||||
}
|
||||
|
||||
async remove(path: string): Promise<void> {
|
||||
await this.app.vault.adapter.remove(path);
|
||||
}
|
||||
|
||||
async read(path: string): Promise<string> {
|
||||
return await this.app.vault.adapter.read(path);
|
||||
}
|
||||
|
||||
async readBinary(path: string): Promise<ArrayBuffer> {
|
||||
return await this.app.vault.adapter.readBinary(path);
|
||||
}
|
||||
|
||||
async write(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.write(path, data, options);
|
||||
}
|
||||
|
||||
async writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options);
|
||||
}
|
||||
|
||||
async append(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.append(path, data, options);
|
||||
}
|
||||
|
||||
list(basePath: string): Promise<{ files: string[]; folders: string[] }> {
|
||||
return Promise.resolve(this.app.vault.adapter.list(basePath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ITypeGuardAdapter } from "@/lib/src/serviceModules/adapters";
|
||||
import { TFile, TFolder } from "obsidian";
|
||||
|
||||
/**
|
||||
* Type guard adapter implementation for Obsidian
|
||||
*/
|
||||
|
||||
export class ObsidianTypeGuardAdapter implements ITypeGuardAdapter<TFile, TFolder> {
|
||||
isFile(file: any): file is TFile {
|
||||
return file instanceof TFile;
|
||||
}
|
||||
|
||||
isFolder(item: any): item is TFolder {
|
||||
return item instanceof TFolder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { UXDataWriteOptions } from "@/lib/src/common/types";
|
||||
import type { IVaultAdapter } from "@/lib/src/serviceModules/adapters";
|
||||
import { toArrayBuffer } from "@/lib/src/serviceModules/FileAccessBase";
|
||||
import type { TFile, App, TFolder } from "obsidian";
|
||||
|
||||
/**
|
||||
* Vault adapter implementation for Obsidian
|
||||
*/
|
||||
export class ObsidianVaultAdapter implements IVaultAdapter<TFile> {
|
||||
constructor(private app: App) {}
|
||||
|
||||
async read(file: TFile): Promise<string> {
|
||||
return await this.app.vault.read(file);
|
||||
}
|
||||
|
||||
async cachedRead(file: TFile): Promise<string> {
|
||||
return await this.app.vault.cachedRead(file);
|
||||
}
|
||||
|
||||
async readBinary(file: TFile): Promise<ArrayBuffer> {
|
||||
return await this.app.vault.readBinary(file);
|
||||
}
|
||||
|
||||
async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.modify(file, data, options);
|
||||
}
|
||||
|
||||
async modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), options);
|
||||
}
|
||||
|
||||
async create(path: string, data: string, options?: UXDataWriteOptions): Promise<TFile> {
|
||||
return await this.app.vault.create(path, data, options);
|
||||
}
|
||||
|
||||
async createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<TFile> {
|
||||
return await this.app.vault.createBinary(path, toArrayBuffer(data), options);
|
||||
}
|
||||
|
||||
async delete(file: TFile | TFolder, force = false): Promise<void> {
|
||||
return await this.app.vault.delete(file, force);
|
||||
}
|
||||
|
||||
async trash(file: TFile | TFolder, force = false): Promise<void> {
|
||||
return await this.app.vault.trash(file, force);
|
||||
}
|
||||
|
||||
trigger(name: string, ...data: any[]): any {
|
||||
return this.app.vault.trigger(name, ...data);
|
||||
}
|
||||
}
|
||||
5
src/serviceModules/ServiceFileAccessImpl.ts
Normal file
5
src/serviceModules/ServiceFileAccessImpl.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ServiceFileAccessBase } from "@lib/serviceModules/ServiceFileAccessBase";
|
||||
import type { ObsidianFileSystemAdapter } from "./FileSystemAdapters/ObsidianFileSystemAdapter";
|
||||
|
||||
// For now, this is just a re-export of ServiceFileAccess with the Obsidian-specific adapter type.
|
||||
export class ServiceFileAccessObsidian extends ServiceFileAccessBase<ObsidianFileSystemAdapter> {}
|
||||
27
src/types.ts
Normal file
27
src/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { DatabaseFileAccess } from "@/lib/src/interfaces/DatabaseFileAccess";
|
||||
import type { Rebuilder } from "@/lib/src/interfaces/DatabaseRebuilder";
|
||||
import type { IFileHandler } from "@/lib/src/interfaces/FileHandler";
|
||||
import type { StorageAccess } from "@/lib/src/interfaces/StorageAccess";
|
||||
import type { IServiceHub } from "./lib/src/services/base/IService";
|
||||
|
||||
export interface ServiceModules {
|
||||
storageAccess: StorageAccess;
|
||||
/**
|
||||
* Database File Accessor for handling file operations related to the database, such as exporting the database, importing from a file, etc.
|
||||
*/
|
||||
databaseFileAccess: DatabaseFileAccess;
|
||||
|
||||
/**
|
||||
* File Handler for handling file operations related to replication, such as resolving conflicts, applying changes from replication, etc.
|
||||
*/
|
||||
fileHandler: IFileHandler;
|
||||
/**
|
||||
* Rebuilder for handling database rebuilding operations.
|
||||
*/
|
||||
rebuilder: Rebuilder;
|
||||
}
|
||||
|
||||
export interface LiveSyncHost {
|
||||
services: IServiceHub;
|
||||
serviceModules: ServiceModules;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user