diff --git a/package-lock.json b/package-lock.json index f691e09..78c38db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "idb": "^8.0.2", "minimatch": "^10.0.1", "octagonal-wheels": "^0.1.24", + "qrcode-generator": "^1.4.4", "svelte-check": "^4.1.4", "trystero": "^0.20.1", "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" @@ -9355,6 +9356,12 @@ "node": ">=6.0.0" } }, + "node_modules/qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==", + "license": "MIT" + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -18189,6 +18196,11 @@ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==" }, + "qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==" + }, "querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", diff --git a/package.json b/package.json index 79adedd..122079d 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "idb": "^8.0.2", "minimatch": "^10.0.1", "octagonal-wheels": "^0.1.24", + "qrcode-generator": "^1.4.4", "svelte-check": "^4.1.4", "trystero": "^0.20.1", "xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2" diff --git a/src/common/types.ts b/src/common/types.ts index 1f28fa8..7cfbd0b 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -88,3 +88,4 @@ export const ICXHeader = "ix:"; export const FileWatchEventQueueMax = 10; export const configURIBase = "obsidian://setuplivesync?settings="; +export const configURIBaseQR = "obsidian://setuplivesync?settingsQR="; diff --git a/src/common/utils.ts b/src/common/utils.ts index 1b324f7..85dd57b 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -538,3 +538,119 @@ export function updatePreviousExecutionTime(key: string, timeDelta: number = 0) } waitingTasks[key].leastNext = Math.max(Date.now() + timeDelta, waitingTasks[key].leastNext); } + +const prefixMapObject = { + s: { + 1: "V", + 2: "W", + 3: "X", + 4: "Y", + 5: "Z", + }, + o: { + 1: "v", + 2: "w", + 3: "x", + 4: "y", + 5: "z", + }, +} as Record>; + +const decodePrefixMapObject = Object.fromEntries( + Object.entries(prefixMapObject).flatMap(([prefix, map]) => + Object.entries(map).map(([len, char]) => [char, { prefix, len: parseInt(len) }]) + ) +); + +const prefixMapNumber = { + n: { + 1: "a", + 2: "b", + 3: "c", + 4: "d", + 5: "e", + }, + N: { + 1: "A", + 2: "B", + 3: "C", + 4: "D", + 5: "E", + }, +} as Record>; + +const decodePrefixMapNumber = Object.fromEntries( + Object.entries(prefixMapNumber).flatMap(([prefix, map]) => + Object.entries(map).map(([len, char]) => [char, { prefix, len: parseInt(len) }]) + ) +); +export function encodeAnyArray(obj: any[]): string { + const tempArray = obj.map((v) => { + if (v == null) return "n"; + if (v == false) return "f"; + if (v == true) return "t"; + if (v == undefined) return "u"; + if (typeof v == "number") { + const b36 = v.toString(36); + const strNum = v.toString(); + const expression = b36.length < strNum.length ? "N" : "n"; + const encodedStr = expression == "N" ? b36 : strNum; + const len = encodedStr.length.toString(36); + const lenLen = len.length; + + const prefix2 = prefixMapNumber[expression][lenLen]; + return prefix2 + len + encodedStr; + } + const str = typeof v == "string" ? v : JSON.stringify(v); + const prefix = typeof v == "string" ? "s" : "o"; + const length = str.length.toString(36); + const lenLen = length.length; + + const prefix2 = prefixMapObject[prefix][lenLen]; + return prefix2 + length + str; + }); + const w = tempArray.join(""); + return w; +} + +const decodeMapConstant = { + u: undefined, + n: null, + f: false, + t: true, +} as Record; +export function decodeAnyArray(str: string): any[] { + const result = []; + let i = 0; + while (i < str.length) { + const char = str[i]; + i++; + if (char in decodeMapConstant) { + result.push(decodeMapConstant[char]); + continue; + } + if (char in decodePrefixMapNumber) { + const { prefix, len } = decodePrefixMapNumber[char]; + const lenStr = str.substring(i, i + len); + i += len; + const radix = prefix == "N" ? 36 : 10; + const lenNum = parseInt(lenStr, 36); + const value = str.substring(i, i + lenNum); + i += lenNum; + result.push(parseInt(value, radix)); + continue; + } + const { prefix, len } = decodePrefixMapObject[char]; + const lenStr = str.substring(i, i + len); + i += len; + const lenNum = parseInt(lenStr, 36); + const value = str.substring(i, i + lenNum); + i += lenNum; + if (prefix == "s") { + result.push(value); + } else { + result.push(JSON.parse(value)); + } + } + return result; +} diff --git a/src/lib b/src/lib index b8e4fa6..a5d21af 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit b8e4fa6b9e73f1cd5df739c05be1496c48c7a71c +Subproject commit a5d21afb61bec76fb6ca931e5cfae8fcb11ec3a6 diff --git a/src/modules/features/ModuleSetupObsidian.ts b/src/modules/features/ModuleSetupObsidian.ts index aef3ec2..cffbe61 100644 --- a/src/modules/features/ModuleSetupObsidian.ts +++ b/src/modules/features/ModuleSetupObsidian.ts @@ -1,22 +1,34 @@ import { type ObsidianLiveSyncSettings, DEFAULT_SETTINGS, + KeyIndexOfSettings, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, } from "../../lib/src/common/types.ts"; -import { configURIBase } from "../../common/types.ts"; +import { configURIBase, configURIBaseQR } from "../../common/types.ts"; // import { PouchDB } from "../../lib/src/pouchdb/pouchdb-browser.js"; import { decrypt, encrypt } from "../../lib/src/encryption/e2ee_v2.ts"; import { fireAndForget } from "../../lib/src/common/utils.ts"; import { EVENT_REQUEST_COPY_SETUP_URI, EVENT_REQUEST_OPEN_SETUP_URI, eventHub } from "../../common/events.ts"; import { AbstractObsidianModule, type IObsidianModule } from "../AbstractObsidianModule.ts"; +import { decodeAnyArray, encodeAnyArray } from "../../common/utils.ts"; +import qrcode from "qrcode-generator"; +import { $msg } from "../../lib/src/common/i18n.ts"; export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsidianModule { $everyOnload(): Promise { - this.registerObsidianProtocolHandler( - "setuplivesync", - async (conf: any) => await this.setupWizard(conf.settings) - ); + this.registerObsidianProtocolHandler("setuplivesync", async (conf: any) => { + if (conf.settings) { + await this.setupWizard(conf.settings); + } else if (conf.settingsQR) { + await this.decodeQR(conf.settingsQR); + } + }); + this.addCommand({ + id: "livesync-setting-qr", + name: "Show settings as a QR code", + callback: () => fireAndForget(this.encodeQR()), + }); this.addCommand({ id: "livesync-copysetupuri", @@ -44,7 +56,45 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi eventHub.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => fireAndForget(() => this.command_copySetupURI())); return Promise.resolve(true); } - + async encodeQR() { + const settingArr = []; + const fullIndexes = Object.entries(KeyIndexOfSettings) as [keyof ObsidianLiveSyncSettings, number][]; + for (const [settingKey, index] of fullIndexes) { + const settingValue = this.settings[settingKey]; + settingArr[index] = settingValue; + } + const w = encodeAnyArray(settingArr); + // console.warn(w.length) + // console.warn(w); + // const j = decodeAnyArray(w); + // console.warn(j); + // console.warn(`is equal: ${isObjectDifferent(settingArr, j)}`); + const qr = qrcode(0, "L"); + const uri = `${configURIBaseQR}${encodeURIComponent(w)}`; + qr.addData(uri); + qr.make(); + const img = qr.createSvgTag(3); + const msg = $msg("Setup.QRCode", { qr_image: img }); + await this.core.confirm.confirmWithMessage("Settings QR Code", msg, ["OK"], "OK"); + return await Promise.resolve(w); + } + async decodeQR(qr: string) { + const settingArr = decodeAnyArray(qr); + // console.warn(settingArr); + const fullIndexes = Object.entries(KeyIndexOfSettings) as [keyof ObsidianLiveSyncSettings, number][]; + const newSettings = { ...DEFAULT_SETTINGS } as ObsidianLiveSyncSettings; + for (const [settingKey, index] of fullIndexes) { + if (index >= settingArr.length) { + // Possibly a new setting added. + continue; + } + const settingValue = settingArr[index]; + //@ts-ignore + newSettings[settingKey] = settingValue; + } + console.warn(newSettings); + await this.applySettingWizard(this.settings, newSettings, "QR Code"); + } async command_copySetupURI(stripExtra = true) { const encryptingPassphrase = await this.core.confirm.askString( "Encrypt your settings", @@ -74,7 +124,7 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi const encryptedSetting = encodeURIComponent( await encrypt(JSON.stringify(setting), encryptingPassphrase, false) ); - const uri = `${configURIBase}${encryptedSetting}`; + const uri = `${configURIBase}${encryptedSetting} `; await navigator.clipboard.writeText(uri); this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE); } @@ -95,7 +145,7 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi const encryptedSetting = encodeURIComponent( await encrypt(JSON.stringify(setting), encryptingPassphrase, false) ); - const uri = `${configURIBase}${encryptedSetting}`; + const uri = `${configURIBase}${encryptedSetting} `; await navigator.clipboard.writeText(uri); this._log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE); } @@ -103,16 +153,155 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi await this.command_copySetupURI(false); } async command_openSetupURI() { - const setupURI = await this.core.confirm.askString("Easy setup", "Set up URI", `${configURIBase}aaaaa`); + const setupURI = await this.core.confirm.askString("Easy setup", "Set up URI", `${configURIBase} aaaaa`); if (setupURI === false) return; if (!setupURI.startsWith(`${configURIBase}`)) { this._log("Set up URI looks wrong.", LOG_LEVEL_NOTICE); return; } const config = decodeURIComponent(setupURI.substring(configURIBase.length)); - console.dir(config); await this.setupWizard(config); } + async applySettingWizard( + oldConf: ObsidianLiveSyncSettings, + newConf: ObsidianLiveSyncSettings, + method = "Setup URI" + ) { + const result = await this.core.confirm.askYesNoDialog( + "Importing Configuration from the " + method + ". Are you sure to proceed ? ", + {} + ); + if (result == "yes") { + const newSettingW = Object.assign({}, DEFAULT_SETTINGS, newConf) as ObsidianLiveSyncSettings; + this.core.replicator.closeReplication(); + this.settings.suspendFileWatching = true; + console.dir(newSettingW); + // Back into the default method once. + newSettingW.configPassphraseStore = ""; + newSettingW.encryptedPassphrase = ""; + newSettingW.encryptedCouchDBConnection = ""; + newSettingW.additionalSuffixOfDatabaseName = `${"appId" in this.app ? this.app.appId : ""} `; + const setupJustImport = "Don't sync anything, just apply the settings."; + const setupAsNew = "This is a new client - sync everything from the remote server."; + const setupAsMerge = "This is an existing client - merge existing files with the server."; + const setupAgain = "Initialise new server data - ideal for new or broken servers."; + const setupManually = "Continue and configure manually."; + newSettingW.syncInternalFiles = false; + newSettingW.usePluginSync = false; + newSettingW.isConfigured = true; + // Migrate completely obsoleted configuration. + if (!newSettingW.useIndexedDBAdapter) { + newSettingW.useIndexedDBAdapter = true; + } + + const setupType = await this.core.confirm.askSelectStringDialogue( + "How would you like to set it up?", + [setupAsNew, setupAgain, setupAsMerge, setupJustImport, setupManually], + { defaultAction: setupAsNew } + ); + if (setupType == setupJustImport) { + this.core.settings = newSettingW; + this.core.$$clearUsedPassphrase(); + await this.core.saveSettings(); + } else if (setupType == setupAsNew) { + this.core.settings = newSettingW; + this.core.$$clearUsedPassphrase(); + await this.core.saveSettings(); + await this.core.rebuilder.$fetchLocal(); + } else if (setupType == setupAsMerge) { + this.core.settings = newSettingW; + this.core.$$clearUsedPassphrase(); + await this.core.saveSettings(); + await this.core.rebuilder.$fetchLocal(true); + } else if (setupType == setupAgain) { + const confirm = + "This operation will rebuild all databases with files on this device. Any files on the remote database not synced here will be lost."; + if ( + (await this.core.confirm.askSelectStringDialogue( + "Are you sure you want to do this?", + ["Cancel", confirm], + { defaultAction: "Cancel" } + )) != confirm + ) { + return; + } + this.core.settings = newSettingW; + await this.core.saveSettings(); + this.core.$$clearUsedPassphrase(); + await this.core.rebuilder.$rebuildEverything(); + } else if (setupType == setupManually) { + const keepLocalDB = await this.core.confirm.askYesNoDialog("Keep local DB?", { + defaultOption: "No", + }); + const keepRemoteDB = await this.core.confirm.askYesNoDialog("Keep remote DB?", { + defaultOption: "No", + }); + if (keepLocalDB == "yes" && keepRemoteDB == "yes") { + // nothing to do. so peaceful. + this.core.settings = newSettingW; + this.core.$$clearUsedPassphrase(); + await this.core.$allSuspendAllSync(); + await this.core.$allSuspendExtraSync(); + await this.core.saveSettings(); + const replicate = await this.core.confirm.askYesNoDialog("Unlock and replicate?", { + defaultOption: "Yes", + }); + if (replicate == "yes") { + await this.core.$$replicate(true); + await this.core.$$markRemoteUnlocked(); + } + this._log("Configuration loaded.", LOG_LEVEL_NOTICE); + return; + } + if (keepLocalDB == "no" && keepRemoteDB == "no") { + const reset = await this.core.confirm.askYesNoDialog("Drop everything?", { + defaultOption: "No", + }); + if (reset != "yes") { + this._log("Cancelled", LOG_LEVEL_NOTICE); + this.core.settings = oldConf; + return; + } + } + let initDB; + this.core.settings = newSettingW; + this.core.$$clearUsedPassphrase(); + await this.core.saveSettings(); + if (keepLocalDB == "no") { + await this.core.$$resetLocalDatabase(); + await this.core.localDatabase.initializeDatabase(); + const rebuild = await this.core.confirm.askYesNoDialog("Rebuild the database?", { + defaultOption: "Yes", + }); + if (rebuild == "yes") { + initDB = this.core.$$initializeDatabase(true); + } else { + await this.core.$$markRemoteResolved(); + } + } + if (keepRemoteDB == "no") { + await this.core.$$tryResetRemoteDatabase(); + await this.core.$$markRemoteLocked(); + } + if (keepLocalDB == "no" || keepRemoteDB == "no") { + const replicate = await this.core.confirm.askYesNoDialog("Replicate once?", { + defaultOption: "Yes", + }); + if (replicate == "yes") { + if (initDB != null) { + await initDB; + } + await this.core.$$replicate(true); + } + } + } + this._log("Configuration loaded.", LOG_LEVEL_NOTICE); + } else { + this._log("Cancelled", LOG_LEVEL_NOTICE); + this.core.settings = oldConf; + return; + } + } async setupWizard(confString: string) { try { const oldConf = JSON.parse(JSON.stringify(this.settings)); @@ -125,133 +314,7 @@ export class ModuleSetupObsidian extends AbstractObsidianModule implements IObsi if (encryptingPassphrase === false) return; const newConf = await JSON.parse(await decrypt(confString, encryptingPassphrase, false)); if (newConf) { - const result = await this.core.confirm.askYesNoDialog( - "Importing Configuration from the Setup-URI. Are you sure to proceed?", - {} - ); - if (result == "yes") { - const newSettingW = Object.assign({}, DEFAULT_SETTINGS, newConf) as ObsidianLiveSyncSettings; - this.core.replicator.closeReplication(); - this.settings.suspendFileWatching = true; - console.dir(newSettingW); - // Back into the default method once. - newSettingW.configPassphraseStore = ""; - newSettingW.encryptedPassphrase = ""; - newSettingW.encryptedCouchDBConnection = ""; - newSettingW.additionalSuffixOfDatabaseName = `${"appId" in this.app ? this.app.appId : ""}`; - const setupJustImport = "Don't sync anything, just apply the settings."; - const setupAsNew = "This is a new client - sync everything from the remote server."; - const setupAsMerge = "This is an existing client - merge existing files with the server."; - const setupAgain = "Initialise new server data - ideal for new or broken servers."; - const setupManually = "Continue and configure manually."; - newSettingW.syncInternalFiles = false; - newSettingW.usePluginSync = false; - newSettingW.isConfigured = true; - // Migrate completely obsoleted configuration. - if (!newSettingW.useIndexedDBAdapter) { - newSettingW.useIndexedDBAdapter = true; - } - - const setupType = await this.core.confirm.askSelectStringDialogue( - "How would you like to set it up?", - [setupAsNew, setupAgain, setupAsMerge, setupJustImport, setupManually], - { defaultAction: setupAsNew } - ); - if (setupType == setupJustImport) { - this.core.settings = newSettingW; - this.core.$$clearUsedPassphrase(); - await this.core.saveSettings(); - } else if (setupType == setupAsNew) { - this.core.settings = newSettingW; - this.core.$$clearUsedPassphrase(); - await this.core.rebuilder.$fetchLocal(); - } else if (setupType == setupAsMerge) { - this.core.settings = newSettingW; - this.core.$$clearUsedPassphrase(); - await this.core.rebuilder.$fetchLocal(true); - } else if (setupType == setupAgain) { - const confirm = - "This operation will rebuild all databases with files on this device. Any files on the remote database not synced here will be lost."; - if ( - (await this.core.confirm.askSelectStringDialogue( - "Are you sure you want to do this?", - ["Cancel", confirm], - { defaultAction: "Cancel" } - )) != confirm - ) { - return; - } - this.core.settings = newSettingW; - this.core.$$clearUsedPassphrase(); - await this.core.rebuilder.$rebuildEverything(); - } else if (setupType == setupManually) { - const keepLocalDB = await this.core.confirm.askYesNoDialog("Keep local DB?", { - defaultOption: "No", - }); - const keepRemoteDB = await this.core.confirm.askYesNoDialog("Keep remote DB?", { - defaultOption: "No", - }); - if (keepLocalDB == "yes" && keepRemoteDB == "yes") { - // nothing to do. so peaceful. - this.core.settings = newSettingW; - this.core.$$clearUsedPassphrase(); - await this.core.$allSuspendAllSync(); - await this.core.$allSuspendExtraSync(); - await this.core.saveSettings(); - const replicate = await this.core.confirm.askYesNoDialog("Unlock and replicate?", { - defaultOption: "Yes", - }); - if (replicate == "yes") { - await this.core.$$replicate(true); - await this.core.$$markRemoteUnlocked(); - } - this._log("Configuration loaded.", LOG_LEVEL_NOTICE); - return; - } - if (keepLocalDB == "no" && keepRemoteDB == "no") { - const reset = await this.core.confirm.askYesNoDialog("Drop everything?", { - defaultOption: "No", - }); - if (reset != "yes") { - this._log("Cancelled", LOG_LEVEL_NOTICE); - this.core.settings = oldConf; - return; - } - } - let initDB; - this.core.settings = newSettingW; - this.core.$$clearUsedPassphrase(); - await this.core.saveSettings(); - if (keepLocalDB == "no") { - await this.core.$$resetLocalDatabase(); - await this.core.localDatabase.initializeDatabase(); - const rebuild = await this.core.confirm.askYesNoDialog("Rebuild the database?", { - defaultOption: "Yes", - }); - if (rebuild == "yes") { - initDB = this.core.$$initializeDatabase(true); - } else { - await this.core.$$markRemoteResolved(); - } - } - if (keepRemoteDB == "no") { - await this.core.$$tryResetRemoteDatabase(); - await this.core.$$markRemoteLocked(); - } - if (keepLocalDB == "no" || keepRemoteDB == "no") { - const replicate = await this.core.confirm.askYesNoDialog("Replicate once?", { - defaultOption: "Yes", - }); - if (replicate == "yes") { - if (initDB != null) { - await initDB; - } - await this.core.$$replicate(true); - } - } - } - } - + await this.applySettingWizard(oldConf, newConf); this._log("Configuration loaded.", LOG_LEVEL_NOTICE); } else { this._log("Cancelled.", LOG_LEVEL_NOTICE); diff --git a/styles.css b/styles.css index 6dab815..fa84cd6 100644 --- a/styles.css +++ b/styles.css @@ -436,4 +436,11 @@ span.ls-mark-cr::after { .sls-dialogue-note-countdown { font-size: 0.8em; +} + +.sls-qr { + display: flex; + justify-content: center; + align-items: center; + max-width: max-content; } \ No newline at end of file