mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-01-07 13:59:17 +00:00
Refactor:
- Files have been categorised for clarity. The deliverables are not affected.
This commit is contained in:
51
src/common/KeyValueDB.ts
Normal file
51
src/common/KeyValueDB.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { deleteDB, type IDBPDatabase, openDB } from "idb";
|
||||
export interface KeyValueDatabase {
|
||||
get<T>(key: IDBValidKey): Promise<T>;
|
||||
set<T>(key: IDBValidKey, value: T): Promise<IDBValidKey>;
|
||||
del(key: IDBValidKey): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
keys(query?: IDBValidKey | IDBKeyRange, count?: number): Promise<IDBValidKey[]>;
|
||||
close(): void;
|
||||
destroy(): Promise<void>;
|
||||
}
|
||||
const databaseCache: { [key: string]: IDBPDatabase<any> } = {};
|
||||
export const OpenKeyValueDatabase = async (dbKey: string): Promise<KeyValueDatabase> => {
|
||||
if (dbKey in databaseCache) {
|
||||
databaseCache[dbKey].close();
|
||||
delete databaseCache[dbKey];
|
||||
}
|
||||
const storeKey = dbKey;
|
||||
const dbPromise = openDB(dbKey, 1, {
|
||||
upgrade(db) {
|
||||
db.createObjectStore(storeKey);
|
||||
},
|
||||
});
|
||||
const db = await dbPromise;
|
||||
databaseCache[dbKey] = db;
|
||||
return {
|
||||
async get<T>(key: IDBValidKey): Promise<T> {
|
||||
return await db.get(storeKey, key);
|
||||
},
|
||||
async set<T>(key: IDBValidKey, value: T) {
|
||||
return await db.put(storeKey, value, key);
|
||||
},
|
||||
async del(key: IDBValidKey) {
|
||||
return await db.delete(storeKey, key);
|
||||
},
|
||||
async clear() {
|
||||
return await db.clear(storeKey);
|
||||
},
|
||||
async keys(query?: IDBValidKey | IDBKeyRange, count?: number) {
|
||||
return await db.getAllKeys(storeKey, query, count);
|
||||
},
|
||||
close() {
|
||||
delete databaseCache[dbKey];
|
||||
return db.close();
|
||||
},
|
||||
async destroy() {
|
||||
delete databaseCache[dbKey];
|
||||
db.close();
|
||||
await deleteDB(dbKey);
|
||||
},
|
||||
};
|
||||
};
|
||||
133
src/common/ObsHttpHandler.ts
Normal file
133
src/common/ObsHttpHandler.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// This file is based on a file that was published by the @remotely-save, under the Apache 2 License.
|
||||
// I would love to express my deepest gratitude to the original authors for their hard work and dedication. Without their contributions, this project would not have been possible.
|
||||
//
|
||||
// Original Implementation is here: https://github.com/remotely-save/remotely-save/blob/28b99557a864ef59c19d2ad96101196e401718f0/src/remoteForS3.ts
|
||||
|
||||
import {
|
||||
FetchHttpHandler,
|
||||
type FetchHttpHandlerOptions,
|
||||
} from "@smithy/fetch-http-handler";
|
||||
import { HttpRequest, HttpResponse, type HttpHandlerOptions } from "@smithy/protocol-http";
|
||||
//@ts-ignore
|
||||
import { requestTimeout } from "@smithy/fetch-http-handler/dist-es/request-timeout";
|
||||
import { buildQueryString } from "@smithy/querystring-builder";
|
||||
import { requestUrl, type RequestUrlParam } from "../deps.ts";
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// special handler using Obsidian requestUrl
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* This is close to origin implementation of FetchHttpHandler
|
||||
* https://github.com/aws/aws-sdk-js-v3/blob/main/packages/fetch-http-handler/src/fetch-http-handler.ts
|
||||
* that is released under Apache 2 License.
|
||||
* But this uses Obsidian requestUrl instead.
|
||||
*/
|
||||
export class ObsHttpHandler extends FetchHttpHandler {
|
||||
requestTimeoutInMs: number | undefined;
|
||||
reverseProxyNoSignUrl: string | undefined;
|
||||
constructor(
|
||||
options?: FetchHttpHandlerOptions,
|
||||
reverseProxyNoSignUrl?: string
|
||||
) {
|
||||
super(options);
|
||||
this.requestTimeoutInMs =
|
||||
options === undefined ? undefined : options.requestTimeout;
|
||||
this.reverseProxyNoSignUrl = reverseProxyNoSignUrl;
|
||||
}
|
||||
async handle(
|
||||
request: HttpRequest,
|
||||
{ abortSignal }: HttpHandlerOptions = {}
|
||||
): Promise<{ response: HttpResponse }> {
|
||||
if (abortSignal?.aborted) {
|
||||
const abortError = new Error("Request aborted");
|
||||
abortError.name = "AbortError";
|
||||
return Promise.reject(abortError);
|
||||
}
|
||||
|
||||
let path = request.path;
|
||||
if (request.query) {
|
||||
const queryString = buildQueryString(request.query);
|
||||
if (queryString) {
|
||||
path += `?${queryString}`;
|
||||
}
|
||||
}
|
||||
|
||||
const { port, method } = request;
|
||||
let url = `${request.protocol}//${request.hostname}${port ? `:${port}` : ""
|
||||
}${path}`;
|
||||
if (
|
||||
this.reverseProxyNoSignUrl !== undefined &&
|
||||
this.reverseProxyNoSignUrl !== ""
|
||||
) {
|
||||
const urlObj = new URL(url);
|
||||
urlObj.host = this.reverseProxyNoSignUrl;
|
||||
url = urlObj.href;
|
||||
}
|
||||
const body =
|
||||
method === "GET" || method === "HEAD" ? undefined : request.body;
|
||||
|
||||
const transformedHeaders: Record<string, string> = {};
|
||||
for (const key of Object.keys(request.headers)) {
|
||||
const keyLower = key.toLowerCase();
|
||||
if (keyLower === "host" || keyLower === "content-length") {
|
||||
continue;
|
||||
}
|
||||
transformedHeaders[keyLower] = request.headers[key];
|
||||
}
|
||||
|
||||
let contentType: string | undefined = undefined;
|
||||
if (transformedHeaders["content-type"] !== undefined) {
|
||||
contentType = transformedHeaders["content-type"];
|
||||
}
|
||||
|
||||
let transformedBody: any = body;
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
transformedBody = new Uint8Array(body.buffer).buffer;
|
||||
}
|
||||
|
||||
const param: RequestUrlParam = {
|
||||
body: transformedBody,
|
||||
headers: transformedHeaders,
|
||||
method: method,
|
||||
url: url,
|
||||
contentType: contentType,
|
||||
};
|
||||
|
||||
const raceOfPromises = [
|
||||
requestUrl(param).then((rsp) => {
|
||||
const headers = rsp.headers;
|
||||
const headersLower: Record<string, string> = {};
|
||||
for (const key of Object.keys(headers)) {
|
||||
headersLower[key.toLowerCase()] = headers[key];
|
||||
}
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(rsp.arrayBuffer));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new HttpResponse({
|
||||
headers: headersLower,
|
||||
statusCode: rsp.status,
|
||||
body: stream,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
requestTimeout(this.requestTimeoutInMs),
|
||||
];
|
||||
|
||||
if (abortSignal) {
|
||||
raceOfPromises.push(
|
||||
new Promise<never>((resolve, reject) => {
|
||||
abortSignal.onabort = () => {
|
||||
const abortError = new Error("Request aborted");
|
||||
abortError.name = "AbortError";
|
||||
reject(abortError);
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.race(raceOfPromises);
|
||||
}
|
||||
}
|
||||
232
src/common/dialogs.ts
Normal file
232
src/common/dialogs.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { ButtonComponent } from "obsidian";
|
||||
import { App, FuzzySuggestModal, MarkdownRenderer, Modal, Plugin, Setting } from "../deps.ts";
|
||||
import ObsidianLiveSyncPlugin from "../main.ts";
|
||||
|
||||
//@ts-ignore
|
||||
import PluginPane from "../ui/PluginPane.svelte";
|
||||
|
||||
export class PluginDialogModal extends Modal {
|
||||
plugin: ObsidianLiveSyncPlugin;
|
||||
component: PluginPane | undefined;
|
||||
isOpened() {
|
||||
return this.component != undefined;
|
||||
}
|
||||
|
||||
constructor(app: App, plugin: ObsidianLiveSyncPlugin) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText("Customization Sync (Beta2)")
|
||||
if (!this.component) {
|
||||
this.component = new PluginPane({
|
||||
target: contentEl,
|
||||
props: { plugin: this.plugin },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
if (this.component) {
|
||||
this.component.$destroy();
|
||||
this.component = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class InputStringDialog extends Modal {
|
||||
result: string | false = false;
|
||||
onSubmit: (result: string | false) => void;
|
||||
title: string;
|
||||
key: string;
|
||||
placeholder: string;
|
||||
isManuallyClosed = false;
|
||||
isPassword = false;
|
||||
|
||||
constructor(app: App, title: string, key: string, placeholder: string, isPassword: boolean, onSubmit: (result: string | false) => void) {
|
||||
super(app);
|
||||
this.onSubmit = onSubmit;
|
||||
this.title = title;
|
||||
this.placeholder = placeholder;
|
||||
this.key = key;
|
||||
this.isPassword = isPassword;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText(this.title);
|
||||
const formEl = contentEl.createDiv();
|
||||
new Setting(formEl).setName(this.key).setClass(this.isPassword ? "password-input" : "normal-input").addText((text) =>
|
||||
text.onChange((value) => {
|
||||
this.result = value;
|
||||
})
|
||||
);
|
||||
new Setting(formEl).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText("Ok")
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.isManuallyClosed = true;
|
||||
this.close();
|
||||
})
|
||||
).addButton((btn) =>
|
||||
btn
|
||||
.setButtonText("Cancel")
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.close();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.isManuallyClosed) {
|
||||
this.onSubmit(this.result);
|
||||
} else {
|
||||
this.onSubmit(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
export class PopoverSelectString extends FuzzySuggestModal<string> {
|
||||
app: App;
|
||||
callback: ((e: string) => void) | undefined = () => { };
|
||||
getItemsFun: () => string[] = () => {
|
||||
return ["yes", "no"];
|
||||
|
||||
}
|
||||
|
||||
constructor(app: App, note: string, placeholder: string | undefined, getItemsFun: (() => string[]) | undefined, callback: (e: string) => void) {
|
||||
super(app);
|
||||
this.app = app;
|
||||
this.setPlaceholder((placeholder ?? "y/n) ") + note);
|
||||
if (getItemsFun) this.getItemsFun = getItemsFun;
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
getItems(): string[] {
|
||||
return this.getItemsFun();
|
||||
}
|
||||
|
||||
getItemText(item: string): string {
|
||||
return item;
|
||||
}
|
||||
|
||||
onChooseItem(item: string, evt: MouseEvent | KeyboardEvent): void {
|
||||
// debugger;
|
||||
this.callback?.(item);
|
||||
this.callback = undefined;
|
||||
}
|
||||
onClose(): void {
|
||||
setTimeout(() => {
|
||||
if (this.callback) {
|
||||
this.callback("");
|
||||
this.callback = undefined;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageBox extends Modal {
|
||||
|
||||
plugin: Plugin;
|
||||
title: string;
|
||||
contentMd: string;
|
||||
buttons: string[];
|
||||
result: string | false = false;
|
||||
isManuallyClosed = false;
|
||||
defaultAction: string | undefined;
|
||||
timeout: number | undefined;
|
||||
timer: ReturnType<typeof setInterval> | undefined = undefined;
|
||||
defaultButtonComponent: ButtonComponent | undefined;
|
||||
|
||||
onSubmit: (result: string | false) => void;
|
||||
|
||||
constructor(plugin: Plugin, title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout: number | undefined, onSubmit: (result: (typeof buttons)[number] | false) => void) {
|
||||
super(plugin.app);
|
||||
this.plugin = plugin;
|
||||
this.title = title;
|
||||
this.contentMd = contentMd;
|
||||
this.buttons = buttons;
|
||||
this.onSubmit = onSubmit;
|
||||
this.defaultAction = defaultAction;
|
||||
this.timeout = timeout;
|
||||
if (this.timeout) {
|
||||
this.timer = setInterval(() => {
|
||||
if (this.timeout === undefined) return;
|
||||
this.timeout--;
|
||||
if (this.timeout < 0) {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
this.result = defaultAction;
|
||||
this.isManuallyClosed = true;
|
||||
this.close();
|
||||
} else {
|
||||
this.defaultButtonComponent?.setButtonText(`( ${this.timeout} ) ${defaultAction}`);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.addEventListener("click", () => {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
})
|
||||
const div = contentEl.createDiv();
|
||||
MarkdownRenderer.render(this.plugin.app, this.contentMd, div, "/", this.plugin);
|
||||
const buttonSetting = new Setting(contentEl);
|
||||
buttonSetting.controlEl.style.flexWrap = "wrap";
|
||||
for (const button of this.buttons) {
|
||||
buttonSetting.addButton((btn) => {
|
||||
btn
|
||||
.setButtonText(button)
|
||||
.onClick(() => {
|
||||
this.isManuallyClosed = true;
|
||||
this.result = button;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
this.close();
|
||||
})
|
||||
if (button == this.defaultAction) {
|
||||
this.defaultButtonComponent = btn;
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
if (this.isManuallyClosed) {
|
||||
this.onSubmit(this.result);
|
||||
} else {
|
||||
this.onSubmit(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function confirmWithMessage(plugin: Plugin, title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], timeout?: number): Promise<(typeof buttons)[number] | false> {
|
||||
return new Promise((res) => {
|
||||
const dialog = new MessageBox(plugin, title, contentMd, buttons, defaultAction, timeout, (result) => res(result));
|
||||
dialog.open();
|
||||
});
|
||||
}
|
||||
7
src/common/stores.ts
Normal file
7
src/common/stores.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PersistentMap } from "../lib/src/dataobject/PersistentMap.ts";
|
||||
|
||||
export let sameChangePairs: PersistentMap<number[]>;
|
||||
|
||||
export function initializeStores(vaultName: string) {
|
||||
sameChangePairs = new PersistentMap<number[]>(`ls-persist-same-changes-${vaultName}`);
|
||||
}
|
||||
82
src/common/types.ts
Normal file
82
src/common/types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { type PluginManifest, TFile } from "../deps.ts";
|
||||
import { type DatabaseEntry, type EntryBody, type FilePath } from "../lib/src/common/types.ts";
|
||||
|
||||
export interface PluginDataEntry extends DatabaseEntry {
|
||||
deviceVaultName: string;
|
||||
mtime: number;
|
||||
manifest: PluginManifest;
|
||||
mainJs: string;
|
||||
manifestJson: string;
|
||||
styleCss?: string;
|
||||
// it must be encrypted.
|
||||
dataJson?: string;
|
||||
_conflicts?: string[];
|
||||
type: "plugin";
|
||||
}
|
||||
|
||||
export interface PluginList {
|
||||
[key: string]: PluginDataEntry[];
|
||||
}
|
||||
|
||||
export interface DevicePluginList {
|
||||
[key: string]: PluginDataEntry;
|
||||
}
|
||||
export const PERIODIC_PLUGIN_SWEEP = 60;
|
||||
|
||||
export interface InternalFileInfo {
|
||||
path: FilePath;
|
||||
mtime: number;
|
||||
ctime: number;
|
||||
size: number;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
path: FilePath;
|
||||
mtime: number;
|
||||
ctime: number;
|
||||
size: number;
|
||||
deleted?: boolean;
|
||||
file: TFile;
|
||||
}
|
||||
|
||||
export type queueItem = {
|
||||
entry: EntryBody;
|
||||
missingChildren: string[];
|
||||
timeout?: number;
|
||||
done?: boolean;
|
||||
warned?: boolean;
|
||||
};
|
||||
|
||||
export type CacheData = string | ArrayBuffer;
|
||||
export type FileEventType = "CREATE" | "DELETE" | "CHANGED" | "RENAME" | "INTERNAL";
|
||||
export type FileEventArgs = {
|
||||
file: FileInfo | InternalFileInfo;
|
||||
cache?: CacheData;
|
||||
oldPath?: string;
|
||||
ctx?: any;
|
||||
}
|
||||
export type FileEventItem = {
|
||||
type: FileEventType,
|
||||
args: FileEventArgs,
|
||||
key: string,
|
||||
}
|
||||
|
||||
// Hidden items (Now means `chunk`)
|
||||
export const CHeader = "h:";
|
||||
|
||||
// Plug-in Stored Container (Obsolete)
|
||||
export const PSCHeader = "ps:";
|
||||
export const PSCHeaderEnd = "ps;";
|
||||
|
||||
// Internal data Container
|
||||
export const ICHeader = "i:";
|
||||
export const ICHeaderEnd = "i;";
|
||||
export const ICHeaderLength = ICHeader.length;
|
||||
|
||||
// Internal data Container (eXtended)
|
||||
export const ICXHeader = "ix:";
|
||||
|
||||
export const FileWatchEventQueueMax = 10;
|
||||
export const configURIBase = "obsidian://setuplivesync?settings=";
|
||||
|
||||
467
src/common/utils.ts
Normal file
467
src/common/utils.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import { normalizePath, Platform, TAbstractFile, App, type RequestUrlParam, requestUrl, TFile } from "../deps.ts";
|
||||
import { path2id_base, id2path_base, isValidFilenameInLinux, isValidFilenameInDarwin, isValidFilenameInWidows, isValidFilenameInAndroid, stripAllPrefixes } from "../lib/src/string_and_binary/path.ts";
|
||||
|
||||
import { Logger } from "../lib/src/common/logger.ts";
|
||||
import { LOG_LEVEL_VERBOSE, type AnyEntry, type DocumentID, type EntryHasPath, type FilePath, type FilePathWithPrefix } from "../lib/src/common/types.ts";
|
||||
import { CHeader, ICHeader, ICHeaderLength, ICXHeader, PSCHeader } from "./types.ts";
|
||||
import { InputStringDialog, PopoverSelectString } from "./dialogs.ts";
|
||||
import type ObsidianLiveSyncPlugin from "../main.ts";
|
||||
import { writeString } from "../lib/src/string_and_binary/strbin.ts";
|
||||
import { fireAndForget } from "../lib/src/common/utils.ts";
|
||||
import { sameChangePairs } from "./stores.ts";
|
||||
|
||||
export { scheduleTask, setPeriodicTask, cancelTask, cancelAllTasks, cancelPeriodicTask, cancelAllPeriodicTask, } from "../lib/src/concurrency/task.ts";
|
||||
|
||||
// For backward compatibility, using the path for determining id.
|
||||
// Only CouchDB unacceptable ID (that starts with an underscore) has been prefixed with "/".
|
||||
// The first slash will be deleted when the path is normalized.
|
||||
export async function path2id(filename: FilePathWithPrefix | FilePath, obfuscatePassphrase: string | false): Promise<DocumentID> {
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
temp.push(normalizedPath);
|
||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
||||
|
||||
const out = await path2id_base(fixedPath, obfuscatePassphrase);
|
||||
return out;
|
||||
}
|
||||
export function id2path(id: DocumentID, entry?: EntryHasPath): FilePathWithPrefix {
|
||||
const filename = id2path_base(id, entry);
|
||||
const temp = filename.split(":");
|
||||
const path = temp.pop();
|
||||
const normalizedPath = normalizePath(path as FilePath);
|
||||
temp.push(normalizedPath);
|
||||
const fixedPath = temp.join(":") as FilePathWithPrefix;
|
||||
return fixedPath;
|
||||
}
|
||||
export function getPath(entry: AnyEntry) {
|
||||
return id2path(entry._id, entry);
|
||||
|
||||
}
|
||||
export function getPathWithoutPrefix(entry: AnyEntry) {
|
||||
const f = getPath(entry);
|
||||
return stripAllPrefixes(f);
|
||||
}
|
||||
|
||||
export function getPathFromTFile(file: TAbstractFile) {
|
||||
return file.path as FilePath;
|
||||
}
|
||||
|
||||
|
||||
const memos: { [key: string]: any } = {};
|
||||
export function memoObject<T>(key: string, obj: T): T {
|
||||
memos[key] = obj;
|
||||
return memos[key] as T;
|
||||
}
|
||||
export async function memoIfNotExist<T>(key: string, func: () => T | Promise<T>): Promise<T> {
|
||||
if (!(key in memos)) {
|
||||
const w = func();
|
||||
const v = w instanceof Promise ? (await w) : w;
|
||||
memos[key] = v;
|
||||
}
|
||||
return memos[key] as T;
|
||||
}
|
||||
export function retrieveMemoObject<T>(key: string): T | false {
|
||||
if (key in memos) {
|
||||
return memos[key];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function disposeMemoObject(key: string) {
|
||||
delete memos[key];
|
||||
}
|
||||
|
||||
export function isSensibleMargeApplicable(path: string) {
|
||||
if (path.endsWith(".md")) return true;
|
||||
return false;
|
||||
}
|
||||
export function isObjectMargeApplicable(path: string) {
|
||||
if (path.endsWith(".canvas")) return true;
|
||||
if (path.endsWith(".json")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function tryParseJSON(str: string, fallbackValue?: any) {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch (ex) {
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
const MARK_OPERATOR = `\u{0001}`;
|
||||
const MARK_DELETED = `${MARK_OPERATOR}__DELETED`;
|
||||
const MARK_ISARRAY = `${MARK_OPERATOR}__ARRAY`;
|
||||
const MARK_SWAPPED = `${MARK_OPERATOR}__SWAP`;
|
||||
|
||||
function unorderedArrayToObject(obj: Array<any>) {
|
||||
return obj.map(e => ({ [e.id as string]: e })).reduce((p, c) => ({ ...p, ...c }), {})
|
||||
}
|
||||
function objectToUnorderedArray(obj: object) {
|
||||
const entries = Object.entries(obj);
|
||||
if (entries.some(e => e[0] != e[1]?.id)) throw new Error("Item looks like not unordered array")
|
||||
return entries.map(e => e[1]);
|
||||
}
|
||||
function generatePatchUnorderedArray(from: Array<any>, to: Array<any>) {
|
||||
if (from.every(e => typeof (e) == "object" && ("id" in e)) && to.every(e => typeof (e) == "object" && ("id" in e))) {
|
||||
const fObj = unorderedArrayToObject(from);
|
||||
const tObj = unorderedArrayToObject(to);
|
||||
const diff = generatePatchObj(fObj, tObj);
|
||||
if (Object.keys(diff).length > 0) {
|
||||
return { [MARK_ISARRAY]: diff };
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return { [MARK_SWAPPED]: to };
|
||||
}
|
||||
|
||||
export function generatePatchObj(from: Record<string | number | symbol, any>, to: Record<string | number | symbol, any>) {
|
||||
const entries = Object.entries(from);
|
||||
const tempMap = new Map<string | number | symbol, any>(entries);
|
||||
const ret = {} as Record<string | number | symbol, any>;
|
||||
const newEntries = Object.entries(to);
|
||||
for (const [key, value] of newEntries) {
|
||||
if (!tempMap.has(key)) {
|
||||
//New
|
||||
ret[key] = value;
|
||||
tempMap.delete(key);
|
||||
} else {
|
||||
//Exists
|
||||
const v = tempMap.get(key);
|
||||
if (typeof (v) !== typeof (value) || (Array.isArray(v) !== Array.isArray(value))) {
|
||||
//if type is not match, replace completely.
|
||||
ret[key] = { [MARK_SWAPPED]: value };
|
||||
} else {
|
||||
if (typeof (v) == "object" && typeof (value) == "object" && !Array.isArray(v) && !Array.isArray(value)) {
|
||||
const wk = generatePatchObj(v, value);
|
||||
if (Object.keys(wk).length > 0) ret[key] = wk;
|
||||
} else if (typeof (v) == "object" && typeof (value) == "object" && Array.isArray(v) && Array.isArray(value)) {
|
||||
const wk = generatePatchUnorderedArray(v, value);
|
||||
if (Object.keys(wk).length > 0) ret[key] = wk;
|
||||
} else if (typeof (v) != "object" && typeof (value) != "object") {
|
||||
if (JSON.stringify(tempMap.get(key)) !== JSON.stringify(value)) {
|
||||
ret[key] = value;
|
||||
}
|
||||
} else {
|
||||
if (JSON.stringify(tempMap.get(key)) !== JSON.stringify(value)) {
|
||||
ret[key] = { [MARK_SWAPPED]: value };
|
||||
}
|
||||
}
|
||||
}
|
||||
tempMap.delete(key);
|
||||
}
|
||||
}
|
||||
//Not used item, means deleted one
|
||||
for (const [key,] of tempMap) {
|
||||
ret[key] = MARK_DELETED
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
export function applyPatch(from: Record<string | number | symbol, any>, patch: Record<string | number | symbol, any>) {
|
||||
const ret = from;
|
||||
const patches = Object.entries(patch);
|
||||
for (const [key, value] of patches) {
|
||||
if (value == MARK_DELETED) {
|
||||
delete ret[key];
|
||||
continue;
|
||||
}
|
||||
if (typeof (value) == "object") {
|
||||
if (MARK_SWAPPED in value) {
|
||||
ret[key] = value[MARK_SWAPPED];
|
||||
continue;
|
||||
}
|
||||
if (MARK_ISARRAY in value) {
|
||||
if (!(key in ret)) ret[key] = [];
|
||||
if (!Array.isArray(ret[key])) {
|
||||
throw new Error("Patch target type is mismatched (array to something)");
|
||||
}
|
||||
const orgArrayObject = unorderedArrayToObject(ret[key]);
|
||||
const appliedObject = applyPatch(orgArrayObject, value[MARK_ISARRAY]);
|
||||
const appliedArray = objectToUnorderedArray(appliedObject);
|
||||
ret[key] = [...appliedArray];
|
||||
} else {
|
||||
if (!(key in ret)) {
|
||||
ret[key] = value;
|
||||
continue;
|
||||
}
|
||||
ret[key] = applyPatch(ret[key], value);
|
||||
}
|
||||
} else {
|
||||
ret[key] = value;
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function mergeObject(
|
||||
objA: Record<string | number | symbol, any> | [any],
|
||||
objB: Record<string | number | symbol, any> | [any]
|
||||
) {
|
||||
const newEntries = Object.entries(objB);
|
||||
const ret: any = { ...objA };
|
||||
if (
|
||||
typeof objA !== typeof objB ||
|
||||
Array.isArray(objA) !== Array.isArray(objB)
|
||||
) {
|
||||
return objB;
|
||||
}
|
||||
|
||||
for (const [key, v] of newEntries) {
|
||||
if (key in ret) {
|
||||
const value = ret[key];
|
||||
if (
|
||||
typeof v !== typeof value ||
|
||||
Array.isArray(v) !== Array.isArray(value)
|
||||
) {
|
||||
//if type is not match, replace completely.
|
||||
ret[key] = v;
|
||||
} else {
|
||||
if (
|
||||
typeof v == "object" &&
|
||||
typeof value == "object" &&
|
||||
!Array.isArray(v) &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
ret[key] = mergeObject(v, value);
|
||||
} else if (
|
||||
typeof v == "object" &&
|
||||
typeof value == "object" &&
|
||||
Array.isArray(v) &&
|
||||
Array.isArray(value)
|
||||
) {
|
||||
ret[key] = [...new Set([...v, ...value])];
|
||||
} else {
|
||||
ret[key] = v;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ret[key] = v;
|
||||
}
|
||||
}
|
||||
const retSorted = Object.fromEntries(Object.entries(ret).sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
if (Array.isArray(objA) && Array.isArray(objB)) {
|
||||
return Object.values(retSorted);
|
||||
}
|
||||
return retSorted;
|
||||
}
|
||||
|
||||
export function flattenObject(obj: Record<string | number | symbol, any>, path: string[] = []): [string, any][] {
|
||||
if (typeof (obj) != "object") return [[path.join("."), obj]];
|
||||
if (Array.isArray(obj)) return [[path.join("."), JSON.stringify(obj)]];
|
||||
const e = Object.entries(obj);
|
||||
const ret = []
|
||||
for (const [key, value] of e) {
|
||||
const p = flattenObject(value, [...path, key]);
|
||||
ret.push(...p);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
export function isValidPath(filename: string) {
|
||||
if (Platform.isDesktop) {
|
||||
// if(Platform.isMacOS) return isValidFilenameInDarwin(filename);
|
||||
if (process.platform == "darwin") return isValidFilenameInDarwin(filename);
|
||||
if (process.platform == "linux") return isValidFilenameInLinux(filename);
|
||||
return isValidFilenameInWidows(filename);
|
||||
}
|
||||
if (Platform.isAndroidApp) return isValidFilenameInAndroid(filename);
|
||||
if (Platform.isIosApp) return isValidFilenameInDarwin(filename);
|
||||
//Fallback
|
||||
Logger("Could not determine platform for checking filename", LOG_LEVEL_VERBOSE);
|
||||
return isValidFilenameInWidows(filename);
|
||||
}
|
||||
|
||||
export function trimPrefix(target: string, prefix: string) {
|
||||
return target.startsWith(prefix) ? target.substring(prefix.length) : target;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* returns is internal chunk of file
|
||||
* @param id ID
|
||||
* @returns
|
||||
*/
|
||||
export function isInternalMetadata(id: FilePath | FilePathWithPrefix | DocumentID): boolean {
|
||||
return id.startsWith(ICHeader);
|
||||
}
|
||||
export function stripInternalMetadataPrefix<T extends FilePath | FilePathWithPrefix | DocumentID>(id: T): T {
|
||||
return id.substring(ICHeaderLength) as T;
|
||||
}
|
||||
export function id2InternalMetadataId(id: DocumentID): DocumentID {
|
||||
return ICHeader + id as DocumentID;
|
||||
}
|
||||
|
||||
// const CHeaderLength = CHeader.length;
|
||||
export function isChunk(str: string): boolean {
|
||||
return str.startsWith(CHeader);
|
||||
}
|
||||
|
||||
export function isPluginMetadata(str: string): boolean {
|
||||
return str.startsWith(PSCHeader);
|
||||
}
|
||||
export function isCustomisationSyncMetadata(str: string): boolean {
|
||||
return str.startsWith(ICXHeader);
|
||||
}
|
||||
|
||||
export const askYesNo = (app: App, message: string): Promise<"yes" | "no"> => {
|
||||
return new Promise((res) => {
|
||||
const popover = new PopoverSelectString(app, message, undefined, undefined, (result) => res(result as "yes" | "no"));
|
||||
popover.open();
|
||||
});
|
||||
};
|
||||
|
||||
export const askSelectString = (app: App, message: string, items: string[]): Promise<string> => {
|
||||
const getItemsFun = () => items;
|
||||
return new Promise((res) => {
|
||||
const popover = new PopoverSelectString(app, message, "", getItemsFun, (result) => res(result));
|
||||
popover.open();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const askString = (app: App, title: string, key: string, placeholder: string, isPassword: boolean = false): Promise<string | false> => {
|
||||
return new Promise((res) => {
|
||||
const dialog = new InputStringDialog(app, title, key, placeholder, isPassword, (result) => res(result));
|
||||
dialog.open();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export class PeriodicProcessor {
|
||||
_process: () => Promise<any>;
|
||||
_timer?: number;
|
||||
_plugin: ObsidianLiveSyncPlugin;
|
||||
constructor(plugin: ObsidianLiveSyncPlugin, process: () => Promise<any>) {
|
||||
this._plugin = plugin;
|
||||
this._process = process;
|
||||
}
|
||||
async process() {
|
||||
try {
|
||||
await this._process();
|
||||
} catch (ex) {
|
||||
Logger(ex);
|
||||
}
|
||||
}
|
||||
enable(interval: number) {
|
||||
this.disable();
|
||||
if (interval == 0) return;
|
||||
this._timer = window.setInterval(() => fireAndForget(async () => {
|
||||
await this.process();
|
||||
if (this._plugin._unloaded) {
|
||||
this.disable();
|
||||
}
|
||||
}), interval);
|
||||
this._plugin.registerInterval(this._timer);
|
||||
}
|
||||
disable() {
|
||||
if (this._timer !== undefined) {
|
||||
window.clearInterval(this._timer);
|
||||
this._timer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const _requestToCouchDBFetch = async (baseUri: string, username: string, password: string, path?: string, body?: string | any, method?: string) => {
|
||||
const utf8str = String.fromCharCode.apply(null, [...writeString(`${username}:${password}`)]);
|
||||
const encoded = window.btoa(utf8str);
|
||||
const authHeader = "Basic " + encoded;
|
||||
const transformedHeaders: Record<string, string> = { authorization: authHeader, "content-type": "application/json" };
|
||||
const uri = `${baseUri}/${path}`;
|
||||
const requestParam = {
|
||||
url: uri,
|
||||
method: method || (body ? "PUT" : "GET"),
|
||||
headers: new Headers(transformedHeaders),
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
return await fetch(uri, requestParam);
|
||||
}
|
||||
|
||||
export const _requestToCouchDB = async (baseUri: string, username: string, password: string, origin: string, path?: string, body?: any, method?: string) => {
|
||||
const utf8str = String.fromCharCode.apply(null, [...writeString(`${username}:${password}`)]);
|
||||
const encoded = window.btoa(utf8str);
|
||||
const authHeader = "Basic " + encoded;
|
||||
const transformedHeaders: Record<string, string> = { authorization: authHeader, origin: origin };
|
||||
const uri = `${baseUri}/${path}`;
|
||||
const requestParam: RequestUrlParam = {
|
||||
url: uri,
|
||||
method: method || (body ? "PUT" : "GET"),
|
||||
headers: transformedHeaders,
|
||||
contentType: "application/json",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
return await requestUrl(requestParam);
|
||||
}
|
||||
export const requestToCouchDB = async (baseUri: string, username: string, password: string, origin: string = "", key?: string, body?: string, method?: string) => {
|
||||
const uri = `_node/_local/_config${key ? "/" + key : ""}`;
|
||||
return await _requestToCouchDB(baseUri, username, password, origin, uri, body, method);
|
||||
};
|
||||
|
||||
export async function performRebuildDB(plugin: ObsidianLiveSyncPlugin, method: "localOnly" | "remoteOnly" | "rebuildBothByThisDevice" | "localOnlyWithChunks") {
|
||||
if (method == "localOnly") {
|
||||
await plugin.addOnSetup.fetchLocal();
|
||||
}
|
||||
if (method == "localOnlyWithChunks") {
|
||||
await plugin.addOnSetup.fetchLocal(true);
|
||||
}
|
||||
if (method == "remoteOnly") {
|
||||
await plugin.addOnSetup.rebuildRemote();
|
||||
}
|
||||
if (method == "rebuildBothByThisDevice") {
|
||||
await plugin.addOnSetup.rebuildEverything();
|
||||
}
|
||||
}
|
||||
|
||||
export const BASE_IS_NEW = Symbol("base");
|
||||
export const TARGET_IS_NEW = Symbol("target");
|
||||
export const EVEN = Symbol("even");
|
||||
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
export function markChangesAreSame(file: TFile | AnyEntry | string, mtime1: number, mtime2: number) {
|
||||
if (mtime1 === mtime2) return true;
|
||||
const key = typeof file == "string" ? file : file instanceof TFile ? file.path : file.path ?? file._id;
|
||||
const pairs = sameChangePairs.get(key, []) || [];
|
||||
if (pairs.some(e => e == mtime1 || e == mtime2)) {
|
||||
sameChangePairs.set(key, [...new Set([...pairs, mtime1, mtime2])]);
|
||||
} else {
|
||||
sameChangePairs.set(key, [mtime1, mtime2]);
|
||||
}
|
||||
}
|
||||
export function isMarkedAsSameChanges(file: TFile | AnyEntry | string, mtimes: number[]) {
|
||||
const key = typeof file == "string" ? file : file instanceof TFile ? file.path : file.path ?? file._id;
|
||||
const pairs = sameChangePairs.get(key, []) || [];
|
||||
if (mtimes.every(e => pairs.indexOf(e) !== -1)) {
|
||||
return EVEN;
|
||||
}
|
||||
}
|
||||
export function compareFileFreshness(baseFile: TFile | AnyEntry | undefined, checkTarget: TFile | AnyEntry | undefined): typeof BASE_IS_NEW | typeof TARGET_IS_NEW | typeof EVEN {
|
||||
if (baseFile === undefined && checkTarget == undefined) return EVEN;
|
||||
if (baseFile == undefined) return TARGET_IS_NEW;
|
||||
if (checkTarget == undefined) return BASE_IS_NEW;
|
||||
|
||||
const modifiedBase = baseFile instanceof TFile ? baseFile?.stat?.mtime ?? 0 : baseFile?.mtime ?? 0;
|
||||
const modifiedTarget = checkTarget instanceof TFile ? checkTarget?.stat?.mtime ?? 0 : checkTarget?.mtime ?? 0;
|
||||
|
||||
if (modifiedBase && modifiedTarget && isMarkedAsSameChanges(baseFile, [modifiedBase, modifiedTarget])) {
|
||||
return EVEN;
|
||||
}
|
||||
return compareMTime(modifiedBase, modifiedTarget);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user