Compare commits

..
9 Commits
Author SHA1 Message Date
fyears c37bf6aedd bump to 0.4.11
Release A New Version / build (16.x) (push) Failing after 50s
2024-03-27 00:44:57 +08:00
fyears bed28d9f0b fix too large operation 2024-03-27 00:44:16 +08:00
fyears 02e03681f7 fix encrypt method 2024-03-27 00:33:27 +08:00
fyears 62452341a3 correctly remove empty folders 2024-03-26 23:57:00 +08:00
fyears bff2f6a642 clean protection for 100% 2024-03-26 23:39:53 +08:00
fyears 833fdee69e finnally we have a big version 0.4.10
Release A New Version / build (16.x) (push) Failing after 47s
2024-03-25 01:19:11 +08:00
fyears 936fce76a1 hack for status bar 2024-03-25 01:18:34 +08:00
fyears e2e8265d43 add https checking 2024-03-25 00:43:13 +08:00
fyears e228250613 round second to dropbox 2024-03-25 00:31:21 +08:00
14 changed files with 230 additions and 91 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "remotely-save",
"name": "Remotely Save",
"version": "0.4.9",
"version": "0.4.11",
"minAppVersion": "0.13.21",
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
"author": "fyears",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "remotely-save",
"name": "Remotely Save",
"version": "0.3.40",
"version": "0.4.11",
"minAppVersion": "0.13.21",
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
"author": "fyears",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "remotely-save",
"version": "0.4.9",
"version": "0.4.11",
"description": "This is yet another sync plugin for Obsidian app.",
"scripts": {
"dev2": "node esbuild.config.mjs --watch",
+5 -1
View File
@@ -165,6 +165,8 @@ export type DecisionTypeForMixedEntity =
| "remote_is_modified_then_pull"
| "local_is_created_then_push"
| "remote_is_created_then_pull"
| "local_is_created_too_large_then_do_nothing"
| "remote_is_created_too_large_then_do_nothing"
| "local_is_deleted_thus_also_delete_remote"
| "remote_is_deleted_thus_also_delete_local"
| "conflict_created_then_keep_local"
@@ -179,7 +181,9 @@ export type DecisionTypeForMixedEntity =
| "folder_existed_remote_then_also_create_local"
| "folder_to_be_created"
| "folder_to_skip"
| "folder_to_be_deleted";
| "folder_to_be_deleted_on_both"
| "folder_to_be_deleted_on_remote"
| "folder_to_be_deleted_on_local";
/**
* uniform representation
+78 -11
View File
@@ -42,9 +42,18 @@ export class Cipher {
return content;
}
if (this.method === "openssl-base64") {
return await openssl.encryptArrayBuffer(content, this.password);
const res = await openssl.encryptArrayBuffer(content, this.password);
if (res === undefined) {
throw Error(`cannot encrypt content`);
}
return res;
} else if (this.method === "rclone-base64") {
return await this.cipherRClone!.encryptContentByCallingWorker(content);
const res =
await this.cipherRClone!.encryptContentByCallingWorker(content);
if (res === undefined) {
throw Error(`cannot encrypt content`);
}
return res;
} else {
throw Error(`not supported encrypt method=${this.method}`);
}
@@ -56,9 +65,18 @@ export class Cipher {
return content;
}
if (this.method === "openssl-base64") {
return await openssl.decryptArrayBuffer(content, this.password);
const res = await openssl.decryptArrayBuffer(content, this.password);
if (res === undefined) {
throw Error(`cannot decrypt content`);
}
return res;
} else if (this.method === "rclone-base64") {
return await this.cipherRClone!.decryptContentByCallingWorker(content);
const res =
await this.cipherRClone!.decryptContentByCallingWorker(content);
if (res === undefined) {
throw Error(`cannot decrypt content`);
}
return res;
} else {
throw Error(`not supported decrypt method=${this.method}`);
}
@@ -70,15 +88,23 @@ export class Cipher {
return name;
}
if (this.method === "openssl-base64") {
return await openssl.encryptStringToBase64url(name, this.password);
const res = await openssl.encryptStringToBase64url(name, this.password);
if (res === undefined) {
throw Error(`cannot encrypt name=${name}`);
}
return res;
} else if (this.method === "rclone-base64") {
return await this.cipherRClone!.encryptNameByCallingWorker(name);
const res = await this.cipherRClone!.encryptNameByCallingWorker(name);
if (res === undefined) {
throw Error(`cannot encrypt name=${name}`);
}
return res;
} else {
throw Error(`not supported encrypt method=${this.method}`);
}
}
async decryptName(name: string) {
async decryptName(name: string): Promise<string> {
// console.debug("start decryptName");
if (this.password === "") {
return name;
@@ -88,7 +114,7 @@ export class Cipher {
// backward compitable with the openssl-base32
try {
const res = await openssl.decryptBase32ToString(name, this.password);
if (isVaildText(res)) {
if (res !== undefined && isVaildText(res)) {
return res;
} else {
throw Error(`cannot decrypt name=${name}`);
@@ -102,7 +128,7 @@ export class Cipher {
name,
this.password
);
if (isVaildText(res)) {
if (res !== undefined && isVaildText(res)) {
return res;
} else {
throw Error(`cannot decrypt name=${name}`);
@@ -110,9 +136,17 @@ export class Cipher {
} catch (error) {
throw Error(`cannot decrypt name=${name}`);
}
} else {
throw Error(
`method=${this.method} but the name=${name}, likely mismatch`
);
}
} else if (this.method === "rclone-base64") {
return await this.cipherRClone!.decryptNameByCallingWorker(name);
const res = await this.cipherRClone!.decryptNameByCallingWorker(name);
if (res === undefined) {
throw Error(`cannot decrypt name=${name}`);
}
return res;
} else {
throw Error(`not supported decrypt method=${this.method}`);
}
@@ -136,7 +170,7 @@ export class Cipher {
* @param name
* @returns
*/
static isLikelyEncryptedName(name: string): boolean {
static isLikelyOpenSSLEncryptedName(name: string): boolean {
if (
name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE32) ||
name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE64URL)
@@ -145,4 +179,37 @@ export class Cipher {
}
return false;
}
/**
* quick guess, no actual decryption here
* @param name
* @returns
*/
static isLikelyEncryptedName(name: string): boolean {
return Cipher.isLikelyOpenSSLEncryptedName(name);
}
/**
* quick guess, no actual decryption here, only openssl can be guessed here
* @param name
* @returns
*/
static isLikelyEncryptedNameNotMatchMethod(
name: string,
method: CipherMethodType
): boolean {
if (
Cipher.isLikelyOpenSSLEncryptedName(name) &&
method !== "openssl-base64"
) {
return true;
}
if (
!Cipher.isLikelyOpenSSLEncryptedName(name) &&
method === "openssl-base64"
) {
return true;
}
return false;
}
}
+2 -2
View File
@@ -23,7 +23,7 @@
"syncrun_shortstep2skip": "2/2 Remotely Save real sync is skipped in dry run mode.",
"syncrun_shortstep2": "2/2 Remotely Save finished!",
"syncrun_abort": "{{manifestID}}-{{theDate}}: abort sync, triggerSource={{triggerSource}}, error while {{syncStatus}}",
"syncrun_abort_protectmodifypercentage": "Abort! you set changing files >= {{protectModifyPercentage}}% is not allowed but {{realModifyDeleteCount}}/{{allFilesCount}}={{percent}}% is going to be modified or deleted! If you are sure you want this sync, please adjust the allowed ratio in the settings.",
"syncrun_abort_protectmodifypercentage": "Abort! you set changing files >= {{protectModifyPercentage}}% is not allowed but {{realModifyDeleteCount}}/{{allFilesCount}}={{percent}}% is going to be modified or deleted! If you are sure you want this sync, please adjust the allowed ratio in the settings.",
"protocol_saveqr": "New not-oauth2 settings for {{manifestName}} is saved. Reopen the plugin settings to make it effective.",
"protocol_callbacknotsupported": "Your uri calls a callback that's not supported yet: {{params}}",
"protocol_dropbox_connecting": "Connecting to Dropbox...\nPlease DO NOT close this modal.",
@@ -271,7 +271,7 @@
"setting_syncdirection_bidirectional_desc": "Bidirectional (default)",
"setting_syncdirection_incremental_push_only_desc": "Incremental Push Only (aka backup mode)",
"setting_syncdirection_incremental_pull_only_desc": "Incremental Pull Only",
"settings_enablemobilestatusbar": "Enable Mobile Status Bar Or Not",
"settings_enablemobilestatusbar": "Mobile Status Bar (experimental)",
"settings_enablemobilestatusbar_desc": "By default Obsidian mobile hides status bar. But some users want to show it up. So here is a hack.",
"settings_importexport": "Import and Export Partial Settings",
"settings_export": "Export",
+1 -1
View File
@@ -270,7 +270,7 @@
"setting_syncdirection_bidirectional_desc": "双向同步(默认)",
"setting_syncdirection_incremental_push_only_desc": "只增量推送(也即:备份模式)",
"setting_syncdirection_incremental_pull_only_desc": "只增量拉取",
"settings_enablemobilestatusbar": "是否显示手机的状态栏",
"settings_enablemobilestatusbar": "手机的状态栏(实验性质)",
"settings_enablemobilestatusbar_desc": "Obsidian 手机版默认隐藏了状态栏。有些用户希望展示它。这里提供了设置选项。",
"settings_importexport": "导入导出部分设置",
"settings_export": "导出",
+1 -1
View File
@@ -270,7 +270,7 @@
"setting_syncdirection_bidirectional_desc": "雙向同步(預設)",
"setting_syncdirection_incremental_push_only_desc": "只增量推送(也即:備份模式)",
"setting_syncdirection_incremental_pull_only_desc": "只增量拉取",
"settings_enablemobilestatusbar": "是否顯示手機的狀態列",
"settings_enablemobilestatusbar": "手機的狀態列(實驗性質)",
"settings_enablemobilestatusbar_desc": "Obsidian 手機版預設隱藏了狀態列。有些使用者希望展示它。這裡提供了設定選項。",
"settings_importexport": "匯入匯出部分設定",
"settings_export": "匯出",
+10 -2
View File
@@ -1,4 +1,4 @@
import { Vault } from "obsidian";
import { Platform, Vault } from "obsidian";
import * as path from "path";
import { base32, base64url } from "rfc4648";
@@ -165,6 +165,9 @@ export const base64ToBase64url = (a: string, pad: boolean = false) => {
* @param a
*/
export const isVaildText = (a: string) => {
if (a === undefined) {
return false;
}
// If the regex matches, the string is invalid.
return !XRegExp("\\p{Cc}|\\p{Cf}|\\p{Co}|\\p{Cn}|\\p{Zl}|\\p{Zp}", "A").test(
a
@@ -520,7 +523,12 @@ export const changeMobileStatusBar = (op: "enable" | "disable") => {
) as HTMLElement;
if (op === "enable") {
bar.style.setProperty("display", "flex");
bar.style.setProperty("margin-bottom", "40px");
const navBar = document.getElementsByClassName(
"mobile-navbar"
)[0] as HTMLElement;
// thanks to community's solution
const height = window.getComputedStyle(navBar).getPropertyValue("height");
bar.style.setProperty("margin-bottom", height);
} else {
bar.style.removeProperty("display");
bar.style.removeProperty("margin-bottom");
+2 -2
View File
@@ -483,8 +483,8 @@ export const uploadToRemote = async (
let ctime = 0;
const s = await vault?.adapter?.stat(fileOrFolderPath);
if (s !== undefined && s !== null) {
mtime = Math.round(s.mtime / 1000.0) * 1000;
ctime = Math.round(s.ctime / 1000.0) * 1000;
mtime = Math.floor(s.mtime / 1000.0) * 1000;
ctime = Math.floor(s.ctime / 1000.0) * 1000;
}
const mtimeStr = new Date(mtime).toISOString().replace(/\.\d{3}Z$/, "Z");
+8 -1
View File
@@ -22,7 +22,7 @@ import { buildQueryString } from "@smithy/querystring-builder";
import { HeaderBag, HttpHandlerOptions, Provider } from "@aws-sdk/types";
import { Buffer } from "buffer";
import * as mime from "mime-types";
import { Vault, requestUrl, RequestUrlParam } from "obsidian";
import { Vault, requestUrl, RequestUrlParam, Platform } from "obsidian";
import { Readable } from "stream";
import * as path from "path";
import AggregateError from "aggregate-error";
@@ -770,6 +770,13 @@ export const checkConnectivity = async (
callbackFunc?: any
) => {
try {
// TODO: no universal way now, just check this in connectivity
if (Platform.isIosApp && !s3Config.s3Endpoint.startsWith("https")) {
throw Error(
`Your s3 endpoint could only be https, not http, because of the iOS restriction.`
);
}
// const results = await s3Client.send(
// new HeadBucketCommand({ Bucket: s3Config.s3BucketName })
// );
+7
View File
@@ -228,6 +228,13 @@ export class WrappedWebdavClient {
if (this.client !== undefined) {
return;
}
if (Platform.isIosApp && !this.webdavConfig.address.startsWith("https")) {
throw Error(
`Your webdav address could only be https, not http, because of the iOS restriction.`
);
}
const headers = {
"Cache-Control": "no-cache",
};
+16 -40
View File
@@ -125,15 +125,9 @@ class PasswordModal extends Modal {
class EncryptionMethodModal extends Modal {
plugin: RemotelySavePlugin;
newEncryptionMethod: CipherMethodType;
constructor(
app: App,
plugin: RemotelySavePlugin,
newEncryptionMethod: CipherMethodType
) {
constructor(app: App, plugin: RemotelySavePlugin) {
super(app);
this.plugin = plugin;
this.newEncryptionMethod = newEncryptionMethod;
}
onOpen() {
@@ -153,22 +147,13 @@ class EncryptionMethodModal extends Modal {
});
});
new Setting(contentEl)
.addButton((button) => {
button.setButtonText(t("confirm"));
button.onClick(async () => {
this.plugin.settings.encryptionMethod = this.newEncryptionMethod;
await this.plugin.saveSettings();
this.close();
});
button.setClass("encryptionmethod-second-confirm");
})
.addButton((button) => {
button.setButtonText(t("goback"));
button.onClick(() => {
this.close();
});
new Setting(contentEl).addButton((button) => {
button.setButtonText(t("confirm"));
button.onClick(async () => {
this.close();
});
button.setClass("encryptionmethod-second-confirm");
});
}
onClose() {
@@ -1693,26 +1678,17 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
.setName(t("settings_encryptionmethod"))
.setDesc(stringToFragment(t("settings_encryptionmethod_desc")))
.addDropdown((dropdown) => {
dropdown.addOption(
"rclone-base64",
t("settings_encryptionmethod_rclone")
);
dropdown.addOption(
"openssl-base64",
t("settings_encryptionmethod_openssl")
);
dropdown.onChange(async (val: string) => {
if (this.plugin.settings.password === "") {
dropdown
.addOption("rclone-base64", t("settings_encryptionmethod_rclone"))
.addOption("openssl-base64", t("settings_encryptionmethod_openssl"))
.setValue(this.plugin.settings.encryptionMethod ?? "rclone-base64")
.onChange(async (val: string) => {
this.plugin.settings.encryptionMethod = val as CipherMethodType;
await this.plugin.saveSettings();
} else {
new EncryptionMethodModal(
this.app,
this.plugin,
val as CipherMethodType
).open();
}
});
if (this.plugin.settings.password !== "") {
new EncryptionMethodModal(this.app, this.plugin).open();
}
});
});
new Setting(basicDiv)
+97 -27
View File
@@ -53,8 +53,9 @@ export interface PasswordCheckType {
| "unknown_encryption_method"
| "remote_encrypted_local_no_password"
| "password_matched"
| "password_not_matched_or_remote_not_encrypted"
| "likely_no_password_both_sides";
| "password_or_method_not_matched_or_remote_not_encrypted"
| "likely_no_password_both_sides"
| "encryption_method_not_matched";
}
export const isPasswordOk = async (
@@ -91,8 +92,19 @@ export const isPasswordOk = async (
reason: "unknown_encryption_method",
};
}
if (
Cipher.isLikelyEncryptedNameNotMatchMethod(santyCheckKey, cipher.method)
) {
return {
ok: false,
reason: "encryption_method_not_matched",
};
}
try {
await cipher.decryptName(santyCheckKey);
const k = await cipher.decryptName(santyCheckKey);
if (k === undefined) {
throw Error(`decryption failed`);
}
return {
ok: true,
reason: "password_matched",
@@ -100,7 +112,7 @@ export const isPasswordOk = async (
} catch (error) {
return {
ok: false,
reason: "password_not_matched_or_remote_not_encrypted",
reason: "password_or_method_not_matched_or_remote_not_encrypted",
};
}
}
@@ -147,7 +159,7 @@ const copyEntityAndFixTimeFormat = (
if (result.mtimeCli === 0) {
result.mtimeCli = undefined;
} else {
if (serviceType === "s3") {
if (serviceType === "s3" || serviceType === "dropbox") {
// round to second instead of millisecond
result.mtimeCli = Math.floor(result.mtimeCli / 1000.0) * 1000;
}
@@ -158,7 +170,7 @@ const copyEntityAndFixTimeFormat = (
if (result.mtimeSvr === 0) {
result.mtimeSvr = undefined;
} else {
if (serviceType === "s3") {
if (serviceType === "s3" || serviceType === "dropbox") {
// round to second instead of millisecond
result.mtimeSvr = Math.floor(result.mtimeSvr / 1000.0) * 1000;
}
@@ -169,7 +181,7 @@ const copyEntityAndFixTimeFormat = (
if (result.prevSyncTime === 0) {
result.prevSyncTime = undefined;
} else {
if (serviceType === "s3") {
if (serviceType === "s3" || serviceType === "dropbox") {
// round to second instead of millisecond
result.prevSyncTime = Math.floor(result.prevSyncTime / 1000.0) * 1000;
}
@@ -494,9 +506,41 @@ export const getSyncPlanInplace = async (
mixedEntry.decisionBranch = 105;
mixedEntry.decision = "folder_to_skip";
} else if (howToCleanEmptyFolder === "clean_both") {
mixedEntry.decisionBranch = 106;
mixedEntry.decision = "folder_to_be_deleted";
// TODO: what to do in different sync direction?
if (local !== undefined && remote !== undefined) {
if (syncDirection === "bidirectional") {
mixedEntry.decisionBranch = 106;
mixedEntry.decision = "folder_to_be_deleted_on_both";
} else {
// right now it does nothing because of "incremental"
// TODO: should we delete??
mixedEntry.decisionBranch = 109;
mixedEntry.decision = "folder_to_skip";
}
} else if (local !== undefined && remote === undefined) {
if (syncDirection === "bidirectional") {
mixedEntry.decisionBranch = 110;
mixedEntry.decision = "folder_to_be_deleted_on_local";
} else {
// right now it does nothing because of "incremental"
// TODO: should we delete??
mixedEntry.decisionBranch = 111;
mixedEntry.decision = "folder_to_skip";
}
} else if (local === undefined && remote !== undefined) {
if (syncDirection === "bidirectional") {
mixedEntry.decisionBranch = 112;
mixedEntry.decision = "folder_to_be_deleted_on_remote";
} else {
// right now it does nothing because of "incremental"
// TODO: should we delete??
mixedEntry.decisionBranch = 113;
mixedEntry.decision = "folder_to_skip";
}
} else {
// no folder to delete, do nothing
mixedEntry.decisionBranch = 114;
mixedEntry.decision = "folder_to_skip";
}
} else {
throw Error(
`do not know how to deal with empty folder ${mixedEntry.key}`
@@ -694,11 +738,9 @@ export const getSyncPlanInplace = async (
keptFolder.add(getParentFolder(key));
}
} else {
throw Error(
`remote is created (branch 3) but size larger than ${skipSizeLargerThan}, don't know what to do: ${JSON.stringify(
mixedEntry
)}`
);
mixedEntry.decisionBranch = 36;
mixedEntry.decision = "remote_is_created_too_large_then_do_nothing";
keptFolder.add(getParentFolder(key));
}
} else if (
(prevSync.mtimeSvr === remote.mtimeCli ||
@@ -757,11 +799,9 @@ export const getSyncPlanInplace = async (
keptFolder.add(getParentFolder(key));
}
} else {
throw Error(
`local is created (branch 6) but size larger than ${skipSizeLargerThan}, don't know what to do: ${JSON.stringify(
mixedEntry
)}`
);
mixedEntry.decisionBranch = 37;
mixedEntry.decision = "local_is_created_too_large_then_do_nothing";
keptFolder.add(getParentFolder(key));
}
} else if (
(prevSync.mtimeSvr === local.mtimeCli ||
@@ -855,6 +895,8 @@ const splitThreeStepsOnEntityMappings = (
val.decision === "equal" ||
val.decision === "conflict_created_then_do_nothing" ||
val.decision === "folder_existed_both_then_do_nothing" ||
val.decision === "local_is_created_too_large_then_do_nothing" ||
val.decision === "remote_is_created_too_large_then_do_nothing" ||
val.decision === "folder_to_skip"
) {
// pass
@@ -877,7 +919,9 @@ const splitThreeStepsOnEntityMappings = (
val.decision === "only_history" ||
val.decision === "local_is_deleted_thus_also_delete_remote" ||
val.decision === "remote_is_deleted_thus_also_delete_local" ||
val.decision === "folder_to_be_deleted"
val.decision === "folder_to_be_deleted_on_both" ||
val.decision === "folder_to_be_deleted_on_local" ||
val.decision === "folder_to_be_deleted_on_remote"
) {
const level = atWhichLevel(key);
const k = deletionOps[level - 1];
@@ -888,7 +932,11 @@ const splitThreeStepsOnEntityMappings = (
}
realTotalCount += 1;
if (val.decision.startsWith("deleted")) {
if (
val.decision.includes("deleted") &&
!val.decision.includes("folder")
) {
// only count files here, skip folder
realModifyDeleteCount += 1;
}
} else if (
@@ -915,8 +963,8 @@ const splitThreeStepsOnEntityMappings = (
realTotalCount += 1;
if (
val.decision.startsWith("modified") ||
val.decision.startsWith("conflict")
val.decision.includes("modified") ||
val.decision.includes("conflict")
) {
realModifyDeleteCount += 1;
}
@@ -963,6 +1011,8 @@ const dispatchOperationToActualV3 = async (
} else if (
r.decision === "equal" ||
r.decision === "conflict_created_then_do_nothing" ||
r.decision === "local_is_created_too_large_then_do_nothing" ||
r.decision === "remote_is_created_too_large_then_do_nothing" ||
r.decision === "folder_to_skip" ||
r.decision === "folder_existed_both_then_do_nothing"
) {
@@ -1063,9 +1113,23 @@ const dispatchOperationToActualV3 = async (
profileID,
entity
);
} else if (r.decision === "folder_to_be_deleted") {
await localDeleteFunc(r.key);
await client.deleteFromRemote(r.key, cipher, r.remote!.keyEnc);
} else if (
r.decision === "folder_to_be_deleted_on_both" ||
r.decision === "folder_to_be_deleted_on_local" ||
r.decision === "folder_to_be_deleted_on_remote"
) {
if (
r.decision === "folder_to_be_deleted_on_both" ||
r.decision === "folder_to_be_deleted_on_local"
) {
await localDeleteFunc(r.key);
}
if (
r.decision === "folder_to_be_deleted_on_both" ||
r.decision === "folder_to_be_deleted_on_remote"
) {
await client.deleteFromRemote(r.key, cipher, r.remote!.keyEnc);
}
await clearPrevSyncRecordByVaultAndProfile(
db,
vaultRandomID,
@@ -1115,6 +1179,12 @@ export const doActualSync = async (
allFilesCount > 0
) {
if (
protectModifyPercentage === 100 &&
realModifyDeleteCount === allFilesCount
) {
// special treatment for 100%
// let it pass, we do nothing here
} else if (
realModifyDeleteCount * 100 >=
allFilesCount * protectModifyPercentage
) {