diff --git a/src/lib b/src/lib index 1c176da..4af350b 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 1c176da469c811cf00a7cd5b16946011b942c8a4 +Subproject commit 4af350bb67a6033018c6a61845bccf9ed887a927 diff --git a/src/main.ts b/src/main.ts index 4f335df..53d62a7 100644 --- a/src/main.ts +++ b/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(); diff --git a/src/modules/core/ModuleReplicator.ts b/src/modules/core/ModuleReplicator.ts index e059b72..0150fbe 100644 --- a/src/modules/core/ModuleReplicator.ts +++ b/src/modules/core/ModuleReplicator.ts @@ -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 { 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: {}, }); diff --git a/src/modules/core/ModuleTargetFilter.ts b/src/modules/core/ModuleTargetFilter.ts deleted file mode 100644 index f4db428..0000000 --- a/src/modules/core/ModuleTargetFilter.ts +++ /dev/null @@ -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 { - 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 = {}; - 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(); - - 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 { - 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 { - 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)); - } -} diff --git a/src/modules/essential/ModuleMigration.ts b/src/modules/essential/ModuleMigration.ts index 8ff2227..edd172c 100644 --- a/src/modules/essential/ModuleMigration.ts +++ b/src/modules/essential/ModuleMigration.ts @@ -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; diff --git a/src/modules/essentialObsidian/ModuleCheckRemoteSize.ts b/src/modules/essentialObsidian/ModuleCheckRemoteSize.ts index 3dedf56..b27c286 100644 --- a/src/modules/essentialObsidian/ModuleCheckRemoteSize.ts +++ b/src/modules/essentialObsidian/ModuleCheckRemoteSize.ts @@ -12,7 +12,7 @@ export class ModuleCheckRemoteSize extends AbstractModule { } private async _allScanStat(): Promise { - 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; } diff --git a/src/modules/essentialObsidian/ModuleObsidianAPI.ts b/src/modules/essentialObsidian/ModuleObsidianAPI.ts deleted file mode 100644 index 49e81a1..0000000 --- a/src/modules/essentialObsidian/ModuleObsidianAPI.ts +++ /dev/null @@ -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 { - const ret = await requestUrl({ ...request, throw: !errorAsResult }); - return ret; -} -export class ModuleObsidianAPI extends AbstractObsidianModule { - _authHeader = new AuthorizationHeaderGenerator(); - _previousErrors = new Set(); - - 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 { - const body = opts?.body as string; - - const optHeaders = {} as Record; - 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)) { - 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 { - 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, - useRequestAPI: boolean, - getPBKDF2Salt: () => Promise> - ): Promise; 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 = new PouchDB(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)); - } -} diff --git a/src/modules/services/ObsidianAPIService.ts b/src/modules/services/ObsidianAPIService.ts index 5e5e580..6ec05c0 100644 --- a/src/modules/services/ObsidianAPIService.ts +++ b/src/modules/services/ObsidianAPIService.ts @@ -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 any): HTMLElement { return this.context.plugin.addRibbonIcon(icon, title, callback); } + registerProtocolHandler(action: string, handler: (params: Record) => 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 { + 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; + // 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)) { + 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}`, + }); + } } diff --git a/src/modules/services/ObsidianServiceHub.ts b/src/modules/services/ObsidianServiceHub.ts index 9ed3663..99cb133 100644 --- a/src/modules/services/ObsidianServiceHub.ts +++ b/src/modules/services/ObsidianServiceHub.ts @@ -33,7 +33,6 @@ export class ObsidianServiceHub extends InjectableServiceHub = Pick; -export type RequiredServiceModules = Pick; - -export type NecessaryServices = { - services: RequiredServices; - serviceModules: RequiredServiceModules; -}; - -export type ServiceFeatureFunction = ( - host: NecessaryServices -) => TR; -type ServiceFeatureContext = T & { - _log: LogFunction; -}; -export type ServiceFeatureFunctionWithContext = ( - host: NecessaryServices, - context: ServiceFeatureContext -) => 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( - featureFunction: ServiceFeatureFunction -): ServiceFeatureFunction { - return featureFunction; -} - -type ContextFactory = ( - host: NecessaryServices -) => ServiceFeatureContext; - -export function serviceFeature() { - return { - create(featureFunction: ServiceFeatureFunction) { - return featureFunction; - }, - withContext(ContextFactory: ContextFactory) { - return { - create: - (featureFunction: ServiceFeatureFunctionWithContext) => - (host: NecessaryServices, context: ServiceFeatureContext) => - featureFunction(host, ContextFactory(host)), - }; - }, - }; -} diff --git a/test/harness/obsidian-mock.ts b/test/harness/obsidian-mock.ts index b7fde70..a07f902 100644 --- a/test/harness/obsidian-mock.ts +++ b/test/harness/obsidian-mock.ts @@ -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++; diff --git a/tsconfig.json b/tsconfig.json index 5e8bd6c..49caa41 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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/**"] } diff --git a/vitest.config.unit.ts b/vitest.config.unit.ts index 119cb33..778e46d 100644 --- a/vitest.config.unit.ts +++ b/vitest.config.unit.ts @@ -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"],