mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-02-25 13:38:49 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61a3eb85b | ||
|
|
19c03ec8d8 | ||
|
|
be1642f1c1 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.25.44",
|
||||
"version": "0.25.45",
|
||||
"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",
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.44",
|
||||
"version": "0.25.45",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.44",
|
||||
"version": "0.25.45",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.44",
|
||||
"version": "0.25.45",
|
||||
"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",
|
||||
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: 1c176da469...4af350bb67
13
src/main.ts
13
src/main.ts
@@ -1,4 +1,4 @@
|
||||
import { Plugin, type App, type PluginManifest } from "./deps";
|
||||
import { Notice, Plugin, type App, type PluginManifest } from "./deps";
|
||||
import {
|
||||
type EntryDoc,
|
||||
type ObsidianLiveSyncSettings,
|
||||
@@ -30,7 +30,6 @@ import type { StorageAccess } from "@lib/interfaces/StorageAccess.ts";
|
||||
import type { Confirm } from "./lib/src/interfaces/Confirm.ts";
|
||||
import type { Rebuilder } from "@lib/interfaces/DatabaseRebuilder.ts";
|
||||
import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess.ts";
|
||||
import { ModuleObsidianAPI } from "./modules/essentialObsidian/ModuleObsidianAPI.ts";
|
||||
import { ModuleObsidianEvents } from "./modules/essentialObsidian/ModuleObsidianEvents.ts";
|
||||
import { AbstractModule } from "./modules/AbstractModule.ts";
|
||||
import { ModuleObsidianSettingDialogue } from "./modules/features/ModuleObsidianSettingTab.ts";
|
||||
@@ -41,7 +40,6 @@ import { ModuleInitializerFile } from "./modules/essential/ModuleInitializerFile
|
||||
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 { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker.ts";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks.ts";
|
||||
@@ -64,6 +62,8 @@ 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";
|
||||
|
||||
export default class ObsidianLiveSyncPlugin
|
||||
extends Plugin
|
||||
@@ -163,10 +163,8 @@ export default class ObsidianLiveSyncPlugin
|
||||
this._registerModule(new ModuleReplicatorCouchDB(this));
|
||||
this._registerModule(new ModuleReplicator(this));
|
||||
this._registerModule(new ModuleConflictResolver(this));
|
||||
this._registerModule(new ModuleTargetFilter(this));
|
||||
this._registerModule(new ModulePeriodicProcess(this));
|
||||
this._registerModule(new ModuleInitializerFile(this));
|
||||
this._registerModule(new ModuleObsidianAPI(this, this));
|
||||
this._registerModule(new ModuleObsidianEvents(this, this));
|
||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||
this._registerModule(new ModuleObsidianSettingsAsMarkdown(this));
|
||||
@@ -421,10 +419,15 @@ export default class ObsidianLiveSyncPlugin
|
||||
const curriedFeature = () => feature(this);
|
||||
this.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
|
||||
}
|
||||
// enable target filter feature.
|
||||
useTargetFilters(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();
|
||||
|
||||
@@ -14,17 +14,16 @@ import type { LiveSyncCore } from "../../main";
|
||||
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||
import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager";
|
||||
import { clearHandlers } from "@lib/replication/SyncParamsHandler";
|
||||
import type { NecessaryServices } from "@/serviceFeatures/types";
|
||||
import type { NecessaryServices } from "@lib/interfaces/ServiceModule";
|
||||
import { MARK_LOG_NETWORK_ERROR } from "@lib/services/lib/logUtils";
|
||||
|
||||
function isOnlineAndCanReplicate(
|
||||
errorManager: UnresolvedErrorManager,
|
||||
host: NecessaryServices<"database", any>,
|
||||
host: NecessaryServices<"API", any>,
|
||||
showMessage: boolean
|
||||
): Promise<boolean> {
|
||||
const errorMessage = "Network is offline";
|
||||
const manager = host.services.database.managers.networkManager;
|
||||
if (!manager.isOnline) {
|
||||
if (!host.services.API.isOnline) {
|
||||
errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
@@ -270,7 +269,7 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
// --> These handlers can be separated.
|
||||
const isOnlineAndCanReplicateWithHost = isOnlineAndCanReplicate.bind(null, this._unresolvedErrorManager, {
|
||||
services: {
|
||||
database: services.database,
|
||||
API: services.API,
|
||||
},
|
||||
serviceModules: {},
|
||||
});
|
||||
|
||||
@@ -1,155 +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 _isTargetAcceptedByFileNameDuplication(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 _isTargetAcceptedByLocalDB(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 _isTargetAcceptedFinally(file: string | UXFileInfoStub) {
|
||||
this._log("File is target finally: " + getStoragePathFromUXFileInfo(file), LOG_LEVEL_DEBUG);
|
||||
return await Promise.resolve(true);
|
||||
}
|
||||
|
||||
private async _isTargetAcceptedByIgnoreFiles(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;
|
||||
}
|
||||
|
||||
private async _isTargetIgnoredByIgnoreFiles(file: string | UXFileInfoStub) {
|
||||
const result = await this._isTargetAcceptedByIgnoreFiles(file);
|
||||
return !result;
|
||||
}
|
||||
|
||||
override 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._isTargetAcceptedByFileNameDuplication.bind(this), 10);
|
||||
services.vault.isTargetFile.addHandler(this._isTargetAcceptedByIgnoreFiles.bind(this), 20);
|
||||
services.vault.isTargetFile.addHandler(this._isTargetAcceptedByLocalDB.bind(this), 30);
|
||||
services.vault.isTargetFile.addHandler(this._isTargetAcceptedFinally.bind(this), 100);
|
||||
services.setting.onSettingRealised.addHandler(this.refreshSettings.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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,291 +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/pouchdb/compress.ts";
|
||||
import { disableEncryption } from "@lib/pouchdb/encryption.ts";
|
||||
import { enableEncryption } from "@lib/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";
|
||||
import { MARK_LOG_NETWORK_ERROR } from "@lib/services/lib/logUtils.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.services.API.requestCount.value = this.services.API.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.services.API.responseCount.value = this.services.API.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.services.API.requestCount.value = this.services.API.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(`${MARK_LOG_NETWORK_ERROR}Network Error: 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.services.API.responseCount.value = this.services.API.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]);
|
||||
}
|
||||
|
||||
override 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));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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.
|
||||
|
||||
@@ -102,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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
const conflict = new ObsidianConflictService(context);
|
||||
const fileProcessing = new ObsidianFileProcessingService(context);
|
||||
|
||||
const remote = new ObsidianRemoteService(context);
|
||||
const tweakValue = new ObsidianTweakValueService(context);
|
||||
|
||||
const setting = new ObsidianSettingService(context, {
|
||||
@@ -42,6 +41,11 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getLanguage } from "@/deps";
|
||||
import { createServiceFeature } from "../types.ts";
|
||||
import { createServiceFeature } from "@lib/interfaces/ServiceModule";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta";
|
||||
import { $msg, setLang } from "@lib/common/i18n";
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { IServiceHub } from "@lib/services/base/IService";
|
||||
import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess";
|
||||
import type { Rebuilder } from "@lib/interfaces/DatabaseRebuilder";
|
||||
import type { IFileHandler } from "@lib/interfaces/FileHandler";
|
||||
import type { StorageAccess } from "@lib/interfaces/StorageAccess";
|
||||
import type { LogFunction } from "@/lib/src/services/lib/logUtils";
|
||||
|
||||
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 type RequiredServices<T extends keyof IServiceHub> = Pick<IServiceHub, T>;
|
||||
export type RequiredServiceModules<T extends keyof ServiceModules> = Pick<ServiceModules, T>;
|
||||
|
||||
export type NecessaryServices<T extends keyof IServiceHub, U extends keyof ServiceModules> = {
|
||||
services: RequiredServices<T>;
|
||||
serviceModules: RequiredServiceModules<U>;
|
||||
};
|
||||
|
||||
export type ServiceFeatureFunction<T extends keyof IServiceHub, U extends keyof ServiceModules, TR> = (
|
||||
host: NecessaryServices<T, U>
|
||||
) => TR;
|
||||
type ServiceFeatureContext<T> = T & {
|
||||
_log: LogFunction;
|
||||
};
|
||||
export type ServiceFeatureFunctionWithContext<T extends keyof IServiceHub, U extends keyof ServiceModules, C, TR> = (
|
||||
host: NecessaryServices<T, U>,
|
||||
context: ServiceFeatureContext<C>
|
||||
) => TR;
|
||||
|
||||
/**
|
||||
* Helper function to create a service feature with proper typing.
|
||||
* @param featureFunction The feature function to be wrapped.
|
||||
* @returns The same feature function with proper typing.
|
||||
* @example
|
||||
* const myFeatureDef = createServiceFeature(({ services: { API }, serviceModules: { storageAccess } }) => {
|
||||
* // ...
|
||||
* });
|
||||
* const myFeature = myFeatureDef.bind(null, this); // <- `this` may `ObsidianLiveSyncPlugin` or a custom context object
|
||||
* appLifecycle.onLayoutReady(myFeature);
|
||||
*/
|
||||
export function createServiceFeature<T extends keyof IServiceHub, U extends keyof ServiceModules, TR>(
|
||||
featureFunction: ServiceFeatureFunction<T, U, TR>
|
||||
): ServiceFeatureFunction<T, U, TR> {
|
||||
return featureFunction;
|
||||
}
|
||||
|
||||
type ContextFactory<T extends keyof IServiceHub, U extends keyof ServiceModules, C> = (
|
||||
host: NecessaryServices<T, U>
|
||||
) => ServiceFeatureContext<C>;
|
||||
|
||||
export function serviceFeature<T extends keyof IServiceHub, U extends keyof ServiceModules>() {
|
||||
return {
|
||||
create<TR>(featureFunction: ServiceFeatureFunction<T, U, TR>) {
|
||||
return featureFunction;
|
||||
},
|
||||
withContext<C extends object = object>(ContextFactory: ContextFactory<T, U, C>) {
|
||||
return {
|
||||
create:
|
||||
<TR>(featureFunction: ServiceFeatureFunctionWithContext<T, U, C, TR>) =>
|
||||
(host: NecessaryServices<T, U>, context: ServiceFeatureContext<C>) =>
|
||||
featureFunction(host, ContextFactory(host)),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -481,7 +481,7 @@ export class Plugin {
|
||||
}
|
||||
|
||||
export class Notice {
|
||||
private _key:number;
|
||||
private _key: number;
|
||||
private static _counter = 0;
|
||||
constructor(message: string) {
|
||||
this._key = Notice._counter++;
|
||||
|
||||
@@ -24,6 +24,6 @@
|
||||
"@lib/*": ["src/lib/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "test/**/*.test.ts"],
|
||||
"exclude": ["pouchdb-browser-webpack", "utils", "src/apps", "src/**/*.test.ts", "**/_test/**", "src/**/*.test.ts"]
|
||||
"include": ["**/*.ts", "test/**/*.test.ts", "**/*.unit.spec.ts"],
|
||||
"exclude": ["pouchdb-browser-webpack", "utils", "src/apps", "src/**/*.test.ts", "**/_test/**"]
|
||||
}
|
||||
|
||||
18
updates.md
18
updates.md
@@ -3,6 +3,20 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
||||
|
||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||
|
||||
## 0.25.45
|
||||
|
||||
25th February, 2026
|
||||
|
||||
As a result of recent refactoring, we are able to write tests more easily now!
|
||||
|
||||
### Refactored
|
||||
|
||||
- `ModuleTargetFilter`, which was responsible for checking if a file is a target file, has been ported to a serviceFeature.
|
||||
- And also tests have been added. The middleware-style-power.
|
||||
- `ModuleObsidianAPI` has been removed and implemented in `APIService` and `RemoteService`.
|
||||
- Now `APIService` is responsible for the network-online-status, not `databaseService.managers.networkManager`.
|
||||
|
||||
|
||||
## 0.25.44
|
||||
|
||||
24th February, 2026
|
||||
@@ -57,8 +71,8 @@ However, as this update is very substantial, please do feel free to let me know
|
||||
#### Dependencies:
|
||||
|
||||
- Bumped dependencies simply to a point where they can be considered problem-free (by human-powered-artefacts-diff).
|
||||
- Svelte, terser, and more something will be bumped later. they have a significant impact on diff and paints it totally.
|
||||
- You may be surprised but, when bumping the library, I am actually checking for any unintended code.
|
||||
- Svelte, terser, and more something will be bumped later. They have a significant impact on the diff and paint it totally.
|
||||
- You may be surprised, but when I bump the library, I am actually checking for any unintended code.
|
||||
|
||||
## 0.25.43
|
||||
|
||||
|
||||
@@ -3,6 +3,76 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
||||
|
||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||
|
||||
## 0.25.45
|
||||
|
||||
25th February, 2026
|
||||
|
||||
As a result of recent refactoring, we are able to write tests more easily now!
|
||||
|
||||
### Refactored
|
||||
|
||||
- `ModuleTargetFilter`, which was responsible for checking if a file is a target file, has been ported to a serviceFeature.
|
||||
- And also tests have been added. The middleware-style-power.
|
||||
- `ModuleObsidianAPI` has been removed and implemented in `APIService` and `RemoteService`.
|
||||
- Now `APIService` is responsible for the network-online-status, not `databaseService.managers.networkManager`.
|
||||
|
||||
## 0.25.44
|
||||
|
||||
24th February, 2026
|
||||
|
||||
This release represents a significant architectural overhaul of the plug-in, focusing on modularity, testability, and stability. While many changes are internal, they pave the way for more robust features and easier maintenance.
|
||||
However, as this update is very substantial, please do feel free to let me know if you encounter any issues.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ignore files (e.g., `.ignore`) are now handled efficiently.
|
||||
- Replication & Database:
|
||||
- Replication statistics are now correctly reset after switching replicators.
|
||||
- Fixed `File already exists` for .md files has been merged (PR #802) So thanks @waspeer for the contribution!
|
||||
|
||||
### Improved
|
||||
|
||||
- Now we can configure network-error banners as icons, or hide them completely with the new `Network Warning Style` setting in the `General` pane of the settings dialogue. (#770, PR #804)
|
||||
- Thanks so much to @A-wry!
|
||||
|
||||
### Refactored
|
||||
|
||||
#### Architectural Overhaul:
|
||||
|
||||
- A major transition from Class-based Modules to a Service/Middleware architecture has begun.
|
||||
- Many modules (for example, `ModulePouchDB`, `ModuleLocalDatabaseObsidian`, `ModuleKeyValueDB`) have been removed or integrated into specific Services (`database`, `keyValueDB`, etc.).
|
||||
- Reduced reliance on dynamic binding and inverted dependencies; dependencies are now explicit.
|
||||
- `ObsidianLiveSyncPlugin` properties (`replicator`, `localDatabase`, `storageAccess`, etc.) have been moved to their respective services for better separation of concerns.
|
||||
- In this 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.
|
||||
- Lifecycle:
|
||||
- Application LifeCycle now starts in `Main` rather than `ServiceHub` or `ObsidianMenuModule`, ensuring smoother startup coordination.
|
||||
|
||||
#### New Services & Utilities:
|
||||
|
||||
- Added a `control` service to orchestrate other services (for example, handling stop/start logic during settings realisation).
|
||||
- Added `UnresolvedErrorManager` to handle and display unresolved errors in a unified way.
|
||||
- Added `logUtils` to unify logging injection and formatting.
|
||||
- `VaultService.isTargetFile` now uses multiple, distinct checkers for better extensibility.
|
||||
|
||||
#### Code Separation:
|
||||
|
||||
- Separated Obsidian-specific logic from base logic for `StorageEventManager` and `FileAccess` modules.
|
||||
- Moved reactive state values and statistics from the main plug-in instance to the services responsible for them.
|
||||
|
||||
#### Internal Cleanups:
|
||||
|
||||
- Many functions have been renamed for clarity (for example, `_isTargetFileByLocalDB` is now `_isTargetAcceptedByLocalDB`).
|
||||
- Added `override` keywords to overridden items and removed dynamic binding for clearer code inheritance.
|
||||
- Moved common functions to the common library.
|
||||
|
||||
#### Dependencies:
|
||||
|
||||
- Bumped dependencies simply to a point where they can be considered problem-free (by human-powered-artefacts-diff).
|
||||
- Svelte, terser, and more something will be bumped later. They have a significant impact on the diff and paint it totally.
|
||||
- You may be surprised, but when I bump the library, I am actually checking for any unintended code.
|
||||
|
||||
## 0.25.43-patched-9 a.k.a. 0.25.44-rc1
|
||||
|
||||
We are finally ready for release. I think I will go ahead and release it after using it for a few days.
|
||||
|
||||
@@ -6,7 +6,7 @@ export default mergeConfig(
|
||||
defineConfig({
|
||||
test: {
|
||||
name: "unit-tests",
|
||||
include: ["**/*unit.test.ts"],
|
||||
include: ["**/*unit.test.ts", "**/*.unit.spec.ts"],
|
||||
exclude: ["test/**"],
|
||||
coverage: {
|
||||
include: ["src/**/*.ts"],
|
||||
|
||||
Reference in New Issue
Block a user