Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b674dd0f10 | ||
|
|
7930509e2a | ||
|
|
1c918d82da | ||
|
|
593fd7471b | ||
|
|
222f386586 | ||
|
|
dcd02457cb | ||
|
|
791c0e8df6 | ||
|
|
c37bf6aedd | ||
|
|
bed28d9f0b | ||
|
|
02e03681f7 | ||
|
|
62452341a3 | ||
|
|
bff2f6a642 | ||
|
|
833fdee69e | ||
|
|
936fce76a1 | ||
|
|
e2e8265d43 | ||
|
|
e228250613 |
@@ -33,6 +33,7 @@ Using the principle of least privilege is crucial for security when allowing a t
|
|||||||
"Effect": "Allow",
|
"Effect": "Allow",
|
||||||
"Action": [
|
"Action": [
|
||||||
"s3:HeadObject",
|
"s3:HeadObject",
|
||||||
|
"s3:ListBucket",
|
||||||
"s3:PutObject",
|
"s3:PutObject",
|
||||||
"s3:CopyObject",
|
"s3:CopyObject",
|
||||||
"s3:UploadPart",
|
"s3:UploadPart",
|
||||||
@@ -48,7 +49,10 @@ Using the principle of least privilege is crucial for security when allowing a t
|
|||||||
"s3:DeleteObject",
|
"s3:DeleteObject",
|
||||||
"s3:DeleteObjects"
|
"s3:DeleteObjects"
|
||||||
],
|
],
|
||||||
"Resource": "arn:aws:s3:::my-bucket/*"
|
"Resource": [
|
||||||
|
"arn:aws:s3:::my-bucket",
|
||||||
|
"arn:aws:s3:::my-bucket/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "remotely-save",
|
"id": "remotely-save",
|
||||||
"name": "Remotely Save",
|
"name": "Remotely Save",
|
||||||
"version": "0.4.9",
|
"version": "0.4.12",
|
||||||
"minAppVersion": "0.13.21",
|
"minAppVersion": "0.13.21",
|
||||||
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
||||||
"author": "fyears",
|
"author": "fyears",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "remotely-save",
|
"id": "remotely-save",
|
||||||
"name": "Remotely Save",
|
"name": "Remotely Save",
|
||||||
"version": "0.3.40",
|
"version": "0.4.12",
|
||||||
"minAppVersion": "0.13.21",
|
"minAppVersion": "0.13.21",
|
||||||
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
||||||
"author": "fyears",
|
"author": "fyears",
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "remotely-save",
|
"name": "remotely-save",
|
||||||
"version": "0.4.9",
|
"version": "0.4.12",
|
||||||
"description": "This is yet another sync plugin for Obsidian app.",
|
"description": "This is yet another sync plugin for Obsidian app.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev2": "node esbuild.config.mjs --watch",
|
"dev2": "node esbuild.config.mjs --watch",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@microsoft/microsoft-graph-types": "^2.40.0",
|
"@microsoft/microsoft-graph-types": "^2.40.0",
|
||||||
"@types/chai": "^4.3.11",
|
"@types/chai": "^4.3.14",
|
||||||
"@types/chai-as-promised": "^7.1.8",
|
"@types/chai-as-promised": "^7.1.8",
|
||||||
"@types/jsdom": "^21.1.6",
|
"@types/jsdom": "^21.1.6",
|
||||||
"@types/lodash": "^4.14.202",
|
"@types/lodash": "^4.14.202",
|
||||||
@@ -34,14 +34,14 @@
|
|||||||
"@types/node": "^20.10.4",
|
"@types/node": "^20.10.4",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"builtin-modules": "^3.3.0",
|
"builtin-modules": "^3.3.0",
|
||||||
"chai": "^4.3.10",
|
"chai": "^4.4.1",
|
||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"esbuild": "^0.19.9",
|
"esbuild": "^0.19.9",
|
||||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||||
"jsdom": "^23.0.1",
|
"jsdom": "^23.0.1",
|
||||||
"mocha": "^10.2.0",
|
"mocha": "^10.4.0",
|
||||||
"npm-check-updates": "^16.14.12",
|
"npm-check-updates": "^16.14.12",
|
||||||
"obsidian": "^1.4.11",
|
"obsidian": "^1.4.11",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
|
|||||||
+8
-1
@@ -165,6 +165,8 @@ export type DecisionTypeForMixedEntity =
|
|||||||
| "remote_is_modified_then_pull"
|
| "remote_is_modified_then_pull"
|
||||||
| "local_is_created_then_push"
|
| "local_is_created_then_push"
|
||||||
| "remote_is_created_then_pull"
|
| "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"
|
| "local_is_deleted_thus_also_delete_remote"
|
||||||
| "remote_is_deleted_thus_also_delete_local"
|
| "remote_is_deleted_thus_also_delete_local"
|
||||||
| "conflict_created_then_keep_local"
|
| "conflict_created_then_keep_local"
|
||||||
@@ -179,7 +181,9 @@ export type DecisionTypeForMixedEntity =
|
|||||||
| "folder_existed_remote_then_also_create_local"
|
| "folder_existed_remote_then_also_create_local"
|
||||||
| "folder_to_be_created"
|
| "folder_to_be_created"
|
||||||
| "folder_to_skip"
|
| "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
|
* uniform representation
|
||||||
@@ -200,6 +204,7 @@ export interface Entity {
|
|||||||
sizeRaw: number;
|
sizeRaw: number;
|
||||||
hash?: string;
|
hash?: string;
|
||||||
etag?: string;
|
etag?: string;
|
||||||
|
synthesizedFolder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadedType {
|
export interface UploadedType {
|
||||||
@@ -219,6 +224,8 @@ export interface MixedEntity {
|
|||||||
decisionBranch?: number;
|
decisionBranch?: number;
|
||||||
decision?: DecisionTypeForMixedEntity;
|
decision?: DecisionTypeForMixedEntity;
|
||||||
conflictAction?: ConflictActionType;
|
conflictAction?: ConflictActionType;
|
||||||
|
|
||||||
|
sideNotes?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+78
-11
@@ -42,9 +42,18 @@ export class Cipher {
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
if (this.method === "openssl-base64") {
|
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") {
|
} 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 {
|
} else {
|
||||||
throw Error(`not supported encrypt method=${this.method}`);
|
throw Error(`not supported encrypt method=${this.method}`);
|
||||||
}
|
}
|
||||||
@@ -56,9 +65,18 @@ export class Cipher {
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
if (this.method === "openssl-base64") {
|
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") {
|
} 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 {
|
} else {
|
||||||
throw Error(`not supported decrypt method=${this.method}`);
|
throw Error(`not supported decrypt method=${this.method}`);
|
||||||
}
|
}
|
||||||
@@ -70,15 +88,23 @@ export class Cipher {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
if (this.method === "openssl-base64") {
|
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") {
|
} 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 {
|
} else {
|
||||||
throw Error(`not supported encrypt method=${this.method}`);
|
throw Error(`not supported encrypt method=${this.method}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async decryptName(name: string) {
|
async decryptName(name: string): Promise<string> {
|
||||||
// console.debug("start decryptName");
|
// console.debug("start decryptName");
|
||||||
if (this.password === "") {
|
if (this.password === "") {
|
||||||
return name;
|
return name;
|
||||||
@@ -88,7 +114,7 @@ export class Cipher {
|
|||||||
// backward compitable with the openssl-base32
|
// backward compitable with the openssl-base32
|
||||||
try {
|
try {
|
||||||
const res = await openssl.decryptBase32ToString(name, this.password);
|
const res = await openssl.decryptBase32ToString(name, this.password);
|
||||||
if (isVaildText(res)) {
|
if (res !== undefined && isVaildText(res)) {
|
||||||
return res;
|
return res;
|
||||||
} else {
|
} else {
|
||||||
throw Error(`cannot decrypt name=${name}`);
|
throw Error(`cannot decrypt name=${name}`);
|
||||||
@@ -102,7 +128,7 @@ export class Cipher {
|
|||||||
name,
|
name,
|
||||||
this.password
|
this.password
|
||||||
);
|
);
|
||||||
if (isVaildText(res)) {
|
if (res !== undefined && isVaildText(res)) {
|
||||||
return res;
|
return res;
|
||||||
} else {
|
} else {
|
||||||
throw Error(`cannot decrypt name=${name}`);
|
throw Error(`cannot decrypt name=${name}`);
|
||||||
@@ -110,9 +136,17 @@ export class Cipher {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw Error(`cannot decrypt name=${name}`);
|
throw Error(`cannot decrypt name=${name}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw Error(
|
||||||
|
`method=${this.method} but the name=${name}, likely mismatch`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (this.method === "rclone-base64") {
|
} 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 {
|
} else {
|
||||||
throw Error(`not supported decrypt method=${this.method}`);
|
throw Error(`not supported decrypt method=${this.method}`);
|
||||||
}
|
}
|
||||||
@@ -136,7 +170,7 @@ export class Cipher {
|
|||||||
* @param name
|
* @param name
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
static isLikelyEncryptedName(name: string): boolean {
|
static isLikelyOpenSSLEncryptedName(name: string): boolean {
|
||||||
if (
|
if (
|
||||||
name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE32) ||
|
name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE32) ||
|
||||||
name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE64URL)
|
name.startsWith(openssl.MAGIC_ENCRYPTED_PREFIX_BASE64URL)
|
||||||
@@ -145,4 +179,37 @@ export class Cipher {
|
|||||||
}
|
}
|
||||||
return false;
|
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
@@ -23,7 +23,7 @@
|
|||||||
"syncrun_shortstep2skip": "2/2 Remotely Save real sync is skipped in dry run mode.",
|
"syncrun_shortstep2skip": "2/2 Remotely Save real sync is skipped in dry run mode.",
|
||||||
"syncrun_shortstep2": "2/2 Remotely Save finished!",
|
"syncrun_shortstep2": "2/2 Remotely Save finished!",
|
||||||
"syncrun_abort": "{{manifestID}}-{{theDate}}: abort sync, triggerSource={{triggerSource}}, error while {{syncStatus}}",
|
"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_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_callbacknotsupported": "Your uri calls a callback that's not supported yet: {{params}}",
|
||||||
"protocol_dropbox_connecting": "Connecting to Dropbox...\nPlease DO NOT close this modal.",
|
"protocol_dropbox_connecting": "Connecting to Dropbox...\nPlease DO NOT close this modal.",
|
||||||
@@ -271,7 +271,7 @@
|
|||||||
"setting_syncdirection_bidirectional_desc": "Bidirectional (default)",
|
"setting_syncdirection_bidirectional_desc": "Bidirectional (default)",
|
||||||
"setting_syncdirection_incremental_push_only_desc": "Incremental Push Only (aka backup mode)",
|
"setting_syncdirection_incremental_push_only_desc": "Incremental Push Only (aka backup mode)",
|
||||||
"setting_syncdirection_incremental_pull_only_desc": "Incremental Pull Only",
|
"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_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_importexport": "Import and Export Partial Settings",
|
||||||
"settings_export": "Export",
|
"settings_export": "Export",
|
||||||
|
|||||||
@@ -270,7 +270,7 @@
|
|||||||
"setting_syncdirection_bidirectional_desc": "双向同步(默认)",
|
"setting_syncdirection_bidirectional_desc": "双向同步(默认)",
|
||||||
"setting_syncdirection_incremental_push_only_desc": "只增量推送(也即:备份模式)",
|
"setting_syncdirection_incremental_push_only_desc": "只增量推送(也即:备份模式)",
|
||||||
"setting_syncdirection_incremental_pull_only_desc": "只增量拉取",
|
"setting_syncdirection_incremental_pull_only_desc": "只增量拉取",
|
||||||
"settings_enablemobilestatusbar": "是否显示手机的状态栏",
|
"settings_enablemobilestatusbar": "手机的状态栏(实验性质)",
|
||||||
"settings_enablemobilestatusbar_desc": "Obsidian 手机版默认隐藏了状态栏。有些用户希望展示它。这里提供了设置选项。",
|
"settings_enablemobilestatusbar_desc": "Obsidian 手机版默认隐藏了状态栏。有些用户希望展示它。这里提供了设置选项。",
|
||||||
"settings_importexport": "导入导出部分设置",
|
"settings_importexport": "导入导出部分设置",
|
||||||
"settings_export": "导出",
|
"settings_export": "导出",
|
||||||
|
|||||||
@@ -270,7 +270,7 @@
|
|||||||
"setting_syncdirection_bidirectional_desc": "雙向同步(預設)",
|
"setting_syncdirection_bidirectional_desc": "雙向同步(預設)",
|
||||||
"setting_syncdirection_incremental_push_only_desc": "只增量推送(也即:備份模式)",
|
"setting_syncdirection_incremental_push_only_desc": "只增量推送(也即:備份模式)",
|
||||||
"setting_syncdirection_incremental_pull_only_desc": "只增量拉取",
|
"setting_syncdirection_incremental_pull_only_desc": "只增量拉取",
|
||||||
"settings_enablemobilestatusbar": "是否顯示手機的狀態列",
|
"settings_enablemobilestatusbar": "手機的狀態列(實驗性質)",
|
||||||
"settings_enablemobilestatusbar_desc": "Obsidian 手機版預設隱藏了狀態列。有些使用者希望展示它。這裡提供了設定選項。",
|
"settings_enablemobilestatusbar_desc": "Obsidian 手機版預設隱藏了狀態列。有些使用者希望展示它。這裡提供了設定選項。",
|
||||||
"settings_importexport": "匯入匯出部分設定",
|
"settings_importexport": "匯入匯出部分設定",
|
||||||
"settings_export": "匯出",
|
"settings_export": "匯出",
|
||||||
|
|||||||
+59
-2
@@ -1,4 +1,4 @@
|
|||||||
import { Vault } from "obsidian";
|
import { Platform, Vault } from "obsidian";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
import { base32, base64url } from "rfc4648";
|
import { base32, base64url } from "rfc4648";
|
||||||
@@ -165,6 +165,9 @@ export const base64ToBase64url = (a: string, pad: boolean = false) => {
|
|||||||
* @param a
|
* @param a
|
||||||
*/
|
*/
|
||||||
export const isVaildText = (a: string) => {
|
export const isVaildText = (a: string) => {
|
||||||
|
if (a === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// If the regex matches, the string is invalid.
|
// If the regex matches, the string is invalid.
|
||||||
return !XRegExp("\\p{Cc}|\\p{Cf}|\\p{Co}|\\p{Cn}|\\p{Zl}|\\p{Zp}", "A").test(
|
return !XRegExp("\\p{Cc}|\\p{Cf}|\\p{Co}|\\p{Cn}|\\p{Zl}|\\p{Zp}", "A").test(
|
||||||
a
|
a
|
||||||
@@ -520,9 +523,63 @@ export const changeMobileStatusBar = (op: "enable" | "disable") => {
|
|||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
if (op === "enable") {
|
if (op === "enable") {
|
||||||
bar.style.setProperty("display", "flex");
|
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 {
|
} else {
|
||||||
bar.style.removeProperty("display");
|
bar.style.removeProperty("display");
|
||||||
bar.style.removeProperty("margin-bottom");
|
bar.style.removeProperty("margin-bottom");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/remotely-save/remotely-save/issues/567
|
||||||
|
* https://www.dropboxforum.com/t5/Dropbox-API-Support-Feedback/Case-Sensitivity-in-API-2/td-p/191279
|
||||||
|
* @param entities
|
||||||
|
*/
|
||||||
|
export const fixEntityListCasesInplace = (entities: { keyRaw: string }[]) => {
|
||||||
|
entities.sort((a, b) => a.keyRaw.length - b.keyRaw.length);
|
||||||
|
// console.log(JSON.stringify(entities,null,2));
|
||||||
|
|
||||||
|
const caseMapping: Record<string, string> = { "": "" };
|
||||||
|
for (const e of entities) {
|
||||||
|
// console.log(`looking for: ${JSON.stringify(e, null, 2)}`);
|
||||||
|
|
||||||
|
let parentFolder = getParentFolder(e.keyRaw);
|
||||||
|
if (parentFolder === "/") {
|
||||||
|
parentFolder = "";
|
||||||
|
}
|
||||||
|
const parentFolderLower = parentFolder.toLocaleLowerCase();
|
||||||
|
const segs = e.keyRaw.split("/");
|
||||||
|
if (e.keyRaw.endsWith("/")) {
|
||||||
|
// folder
|
||||||
|
if (caseMapping.hasOwnProperty(parentFolderLower)) {
|
||||||
|
const newKeyRaw = `${caseMapping[parentFolderLower]}${segs
|
||||||
|
.slice(-2)
|
||||||
|
.join("/")}`;
|
||||||
|
caseMapping[newKeyRaw.toLocaleLowerCase()] = newKeyRaw;
|
||||||
|
e.keyRaw = newKeyRaw;
|
||||||
|
// console.log(JSON.stringify(caseMapping,null,2));
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
throw Error(`${parentFolder} doesn't have cases record??`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// file
|
||||||
|
if (caseMapping.hasOwnProperty(parentFolderLower)) {
|
||||||
|
const newKeyRaw = `${caseMapping[parentFolderLower]}${segs
|
||||||
|
.slice(-1)
|
||||||
|
.join("/")}`;
|
||||||
|
e.keyRaw = newKeyRaw;
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
throw Error(`${parentFolder} doesn't have cases record??`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
};
|
||||||
|
|||||||
+4
-2
@@ -239,7 +239,8 @@ export class RemoteClient {
|
|||||||
deleteFromRemote = async (
|
deleteFromRemote = async (
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
cipher: Cipher,
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = ""
|
remoteEncryptedKey: string = "",
|
||||||
|
synthesizedFolder: boolean = false
|
||||||
) => {
|
) => {
|
||||||
if (this.serviceType === "s3") {
|
if (this.serviceType === "s3") {
|
||||||
return await s3.deleteFromRemote(
|
return await s3.deleteFromRemote(
|
||||||
@@ -247,7 +248,8 @@ export class RemoteClient {
|
|||||||
this.s3Config!,
|
this.s3Config!,
|
||||||
fileOrFolderPath,
|
fileOrFolderPath,
|
||||||
cipher,
|
cipher,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey,
|
||||||
|
synthesizedFolder
|
||||||
);
|
);
|
||||||
} else if (this.serviceType === "webdav") {
|
} else if (this.serviceType === "webdav") {
|
||||||
return await webdav.deleteFromRemote(
|
return await webdav.deleteFromRemote(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
import {
|
import {
|
||||||
bufferToArrayBuffer,
|
bufferToArrayBuffer,
|
||||||
|
fixEntityListCasesInplace,
|
||||||
getFolderLevels,
|
getFolderLevels,
|
||||||
hasEmojiInText,
|
hasEmojiInText,
|
||||||
headersToRecord,
|
headersToRecord,
|
||||||
@@ -483,8 +484,8 @@ export const uploadToRemote = async (
|
|||||||
let ctime = 0;
|
let ctime = 0;
|
||||||
const s = await vault?.adapter?.stat(fileOrFolderPath);
|
const s = await vault?.adapter?.stat(fileOrFolderPath);
|
||||||
if (s !== undefined && s !== null) {
|
if (s !== undefined && s !== null) {
|
||||||
mtime = Math.round(s.mtime / 1000.0) * 1000;
|
mtime = Math.floor(s.mtime / 1000.0) * 1000;
|
||||||
ctime = Math.round(s.ctime / 1000.0) * 1000;
|
ctime = Math.floor(s.ctime / 1000.0) * 1000;
|
||||||
}
|
}
|
||||||
const mtimeStr = new Date(mtime).toISOString().replace(/\.\d{3}Z$/, "Z");
|
const mtimeStr = new Date(mtime).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
|
||||||
@@ -635,6 +636,8 @@ export const listAllFromRemote = async (client: WrappedDropboxClient) => {
|
|||||||
unifiedContents.push(...unifiedContents2);
|
unifiedContents.push(...unifiedContents2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fixEntityListCasesInplace(unifiedContents);
|
||||||
|
|
||||||
return unifiedContents;
|
return unifiedContents;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -471,6 +471,11 @@ export class WrappedOnedriveClient {
|
|||||||
const pathFrag = encodeURI(pathFragOrig);
|
const pathFrag = encodeURI(pathFragOrig);
|
||||||
theUrl = `${API_PREFIX}${pathFrag}`;
|
theUrl = `${API_PREFIX}${pathFrag}`;
|
||||||
}
|
}
|
||||||
|
// we want to support file name with hash #
|
||||||
|
// because every url we construct here do not contain the # symbol
|
||||||
|
// thus it should be safe to directly replace the character
|
||||||
|
theUrl = theUrl.replace(/#/g, "%23");
|
||||||
|
// console.debug(`building url: [${pathFragOrig}] => [${theUrl}]`)
|
||||||
return theUrl;
|
return theUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+18
-3
@@ -22,7 +22,7 @@ import { buildQueryString } from "@smithy/querystring-builder";
|
|||||||
import { HeaderBag, HttpHandlerOptions, Provider } from "@aws-sdk/types";
|
import { HeaderBag, HttpHandlerOptions, Provider } from "@aws-sdk/types";
|
||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
import * as mime from "mime-types";
|
import * as mime from "mime-types";
|
||||||
import { Vault, requestUrl, RequestUrlParam } from "obsidian";
|
import { Vault, requestUrl, RequestUrlParam, Platform } from "obsidian";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import AggregateError from "aggregate-error";
|
import AggregateError from "aggregate-error";
|
||||||
@@ -250,6 +250,7 @@ const fromS3ObjectToEntity = (
|
|||||||
mtimeCli: mtimeCli,
|
mtimeCli: mtimeCli,
|
||||||
sizeRaw: x.Size!,
|
sizeRaw: x.Size!,
|
||||||
etag: x.ETag,
|
etag: x.ETag,
|
||||||
|
synthesizedFolder: false,
|
||||||
};
|
};
|
||||||
return r;
|
return r;
|
||||||
};
|
};
|
||||||
@@ -599,7 +600,10 @@ export const listAllFromRemote = async (
|
|||||||
s3Client: S3Client,
|
s3Client: S3Client,
|
||||||
s3Config: S3Config
|
s3Config: S3Config
|
||||||
) => {
|
) => {
|
||||||
return await listFromRemoteRaw(s3Client, s3Config, s3Config.remotePrefix);
|
const res = (
|
||||||
|
await listFromRemoteRaw(s3Client, s3Config, s3Config.remotePrefix)
|
||||||
|
).filter((x) => x.keyRaw !== "" && x.keyRaw !== "/");
|
||||||
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -715,11 +719,15 @@ export const deleteFromRemote = async (
|
|||||||
s3Config: S3Config,
|
s3Config: S3Config,
|
||||||
fileOrFolderPath: string,
|
fileOrFolderPath: string,
|
||||||
cipher: Cipher,
|
cipher: Cipher,
|
||||||
remoteEncryptedKey: string = ""
|
remoteEncryptedKey: string = "",
|
||||||
|
synthesizedFolder: boolean = false
|
||||||
) => {
|
) => {
|
||||||
if (fileOrFolderPath === "/") {
|
if (fileOrFolderPath === "/") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (synthesizedFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let remoteFileName = fileOrFolderPath;
|
let remoteFileName = fileOrFolderPath;
|
||||||
if (!cipher.isPasswordEmpty()) {
|
if (!cipher.isPasswordEmpty()) {
|
||||||
remoteFileName = remoteEncryptedKey;
|
remoteFileName = remoteEncryptedKey;
|
||||||
@@ -770,6 +778,13 @@ export const checkConnectivity = async (
|
|||||||
callbackFunc?: any
|
callbackFunc?: any
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
|
// TODO: no universal way now, just check this in connectivity
|
||||||
|
if (Platform.isIosApp && s3Config.s3Endpoint.startsWith("http://")) {
|
||||||
|
throw Error(
|
||||||
|
`Your s3 endpoint could only be https, not http, because of the iOS restriction.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// const results = await s3Client.send(
|
// const results = await s3Client.send(
|
||||||
// new HeadBucketCommand({ Bucket: s3Config.s3BucketName })
|
// new HeadBucketCommand({ Bucket: s3Config.s3BucketName })
|
||||||
// );
|
// );
|
||||||
|
|||||||
@@ -228,6 +228,13 @@ export class WrappedWebdavClient {
|
|||||||
if (this.client !== undefined) {
|
if (this.client !== undefined) {
|
||||||
return;
|
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 = {
|
const headers = {
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-40
@@ -125,15 +125,9 @@ class PasswordModal extends Modal {
|
|||||||
|
|
||||||
class EncryptionMethodModal extends Modal {
|
class EncryptionMethodModal extends Modal {
|
||||||
plugin: RemotelySavePlugin;
|
plugin: RemotelySavePlugin;
|
||||||
newEncryptionMethod: CipherMethodType;
|
constructor(app: App, plugin: RemotelySavePlugin) {
|
||||||
constructor(
|
|
||||||
app: App,
|
|
||||||
plugin: RemotelySavePlugin,
|
|
||||||
newEncryptionMethod: CipherMethodType
|
|
||||||
) {
|
|
||||||
super(app);
|
super(app);
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
this.newEncryptionMethod = newEncryptionMethod;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpen() {
|
onOpen() {
|
||||||
@@ -153,22 +147,13 @@ class EncryptionMethodModal extends Modal {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
new Setting(contentEl)
|
new Setting(contentEl).addButton((button) => {
|
||||||
.addButton((button) => {
|
button.setButtonText(t("confirm"));
|
||||||
button.setButtonText(t("confirm"));
|
button.onClick(async () => {
|
||||||
button.onClick(async () => {
|
this.close();
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
button.setClass("encryptionmethod-second-confirm");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose() {
|
onClose() {
|
||||||
@@ -1693,26 +1678,17 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
|
|||||||
.setName(t("settings_encryptionmethod"))
|
.setName(t("settings_encryptionmethod"))
|
||||||
.setDesc(stringToFragment(t("settings_encryptionmethod_desc")))
|
.setDesc(stringToFragment(t("settings_encryptionmethod_desc")))
|
||||||
.addDropdown((dropdown) => {
|
.addDropdown((dropdown) => {
|
||||||
dropdown.addOption(
|
dropdown
|
||||||
"rclone-base64",
|
.addOption("rclone-base64", t("settings_encryptionmethod_rclone"))
|
||||||
t("settings_encryptionmethod_rclone")
|
.addOption("openssl-base64", t("settings_encryptionmethod_openssl"))
|
||||||
);
|
.setValue(this.plugin.settings.encryptionMethod ?? "rclone-base64")
|
||||||
dropdown.addOption(
|
.onChange(async (val: string) => {
|
||||||
"openssl-base64",
|
|
||||||
t("settings_encryptionmethod_openssl")
|
|
||||||
);
|
|
||||||
dropdown.onChange(async (val: string) => {
|
|
||||||
if (this.plugin.settings.password === "") {
|
|
||||||
this.plugin.settings.encryptionMethod = val as CipherMethodType;
|
this.plugin.settings.encryptionMethod = val as CipherMethodType;
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
} else {
|
if (this.plugin.settings.password !== "") {
|
||||||
new EncryptionMethodModal(
|
new EncryptionMethodModal(this.app, this.plugin).open();
|
||||||
this.app,
|
}
|
||||||
this.plugin,
|
});
|
||||||
val as CipherMethodType
|
|
||||||
).open();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
new Setting(basicDiv)
|
new Setting(basicDiv)
|
||||||
|
|||||||
+222
-39
@@ -18,6 +18,7 @@ import {
|
|||||||
isVaildText,
|
isVaildText,
|
||||||
atWhichLevel,
|
atWhichLevel,
|
||||||
mkdirpInVault,
|
mkdirpInVault,
|
||||||
|
getFolderLevels,
|
||||||
} from "./misc";
|
} from "./misc";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FILE_NAME_FOR_METADATAONREMOTE,
|
DEFAULT_FILE_NAME_FOR_METADATAONREMOTE,
|
||||||
@@ -53,8 +54,9 @@ export interface PasswordCheckType {
|
|||||||
| "unknown_encryption_method"
|
| "unknown_encryption_method"
|
||||||
| "remote_encrypted_local_no_password"
|
| "remote_encrypted_local_no_password"
|
||||||
| "password_matched"
|
| "password_matched"
|
||||||
| "password_not_matched_or_remote_not_encrypted"
|
| "password_or_method_not_matched_or_remote_not_encrypted"
|
||||||
| "likely_no_password_both_sides";
|
| "likely_no_password_both_sides"
|
||||||
|
| "encryption_method_not_matched";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isPasswordOk = async (
|
export const isPasswordOk = async (
|
||||||
@@ -91,8 +93,19 @@ export const isPasswordOk = async (
|
|||||||
reason: "unknown_encryption_method",
|
reason: "unknown_encryption_method",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
Cipher.isLikelyEncryptedNameNotMatchMethod(santyCheckKey, cipher.method)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "encryption_method_not_matched",
|
||||||
|
};
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await cipher.decryptName(santyCheckKey);
|
const k = await cipher.decryptName(santyCheckKey);
|
||||||
|
if (k === undefined) {
|
||||||
|
throw Error(`decryption failed`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
reason: "password_matched",
|
reason: "password_matched",
|
||||||
@@ -100,7 +113,7 @@ export const isPasswordOk = async (
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
reason: "password_not_matched_or_remote_not_encrypted",
|
reason: "password_or_method_not_matched_or_remote_not_encrypted",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +160,7 @@ const copyEntityAndFixTimeFormat = (
|
|||||||
if (result.mtimeCli === 0) {
|
if (result.mtimeCli === 0) {
|
||||||
result.mtimeCli = undefined;
|
result.mtimeCli = undefined;
|
||||||
} else {
|
} else {
|
||||||
if (serviceType === "s3") {
|
if (serviceType === "s3" || serviceType === "dropbox") {
|
||||||
// round to second instead of millisecond
|
// round to second instead of millisecond
|
||||||
result.mtimeCli = Math.floor(result.mtimeCli / 1000.0) * 1000;
|
result.mtimeCli = Math.floor(result.mtimeCli / 1000.0) * 1000;
|
||||||
}
|
}
|
||||||
@@ -158,7 +171,7 @@ const copyEntityAndFixTimeFormat = (
|
|||||||
if (result.mtimeSvr === 0) {
|
if (result.mtimeSvr === 0) {
|
||||||
result.mtimeSvr = undefined;
|
result.mtimeSvr = undefined;
|
||||||
} else {
|
} else {
|
||||||
if (serviceType === "s3") {
|
if (serviceType === "s3" || serviceType === "dropbox") {
|
||||||
// round to second instead of millisecond
|
// round to second instead of millisecond
|
||||||
result.mtimeSvr = Math.floor(result.mtimeSvr / 1000.0) * 1000;
|
result.mtimeSvr = Math.floor(result.mtimeSvr / 1000.0) * 1000;
|
||||||
}
|
}
|
||||||
@@ -169,7 +182,7 @@ const copyEntityAndFixTimeFormat = (
|
|||||||
if (result.prevSyncTime === 0) {
|
if (result.prevSyncTime === 0) {
|
||||||
result.prevSyncTime = undefined;
|
result.prevSyncTime = undefined;
|
||||||
} else {
|
} else {
|
||||||
if (serviceType === "s3") {
|
if (serviceType === "s3" || serviceType === "dropbox") {
|
||||||
// round to second instead of millisecond
|
// round to second instead of millisecond
|
||||||
result.prevSyncTime = Math.floor(result.prevSyncTime / 1000.0) * 1000;
|
result.prevSyncTime = Math.floor(result.prevSyncTime / 1000.0) * 1000;
|
||||||
}
|
}
|
||||||
@@ -314,7 +327,10 @@ export const ensembleMixedEnties = async (
|
|||||||
): Promise<SyncPlanType> => {
|
): Promise<SyncPlanType> => {
|
||||||
const finalMappings: SyncPlanType = {};
|
const finalMappings: SyncPlanType = {};
|
||||||
|
|
||||||
|
const synthFolders: Record<string, Entity> = {};
|
||||||
|
|
||||||
// remote has to be first
|
// remote has to be first
|
||||||
|
// we also have to synthesize folders here
|
||||||
for (const remote of remoteEntityList) {
|
for (const remote of remoteEntityList) {
|
||||||
const remoteCopied = ensureMTimeOfRemoteEntityValid(
|
const remoteCopied = ensureMTimeOfRemoteEntityValid(
|
||||||
await decryptRemoteEntityInplace(
|
await decryptRemoteEntityInplace(
|
||||||
@@ -340,6 +356,42 @@ export const ensembleMixedEnties = async (
|
|||||||
key: key,
|
key: key,
|
||||||
remote: remoteCopied,
|
remote: remoteCopied,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for (const f of getFolderLevels(key, true)) {
|
||||||
|
if (finalMappings.hasOwnProperty(f)) {
|
||||||
|
delete synthFolders[f];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!synthFolders.hasOwnProperty(f) ||
|
||||||
|
remoteCopied.mtimeSvr! >= synthFolders[f].mtimeSvr!
|
||||||
|
) {
|
||||||
|
synthFolders[f] = {
|
||||||
|
key: f,
|
||||||
|
keyRaw: `<synth: ${f}>`,
|
||||||
|
keyEnc: `<enc synth: ${f}>`,
|
||||||
|
size: 0,
|
||||||
|
sizeRaw: 0,
|
||||||
|
sizeEnc: 0,
|
||||||
|
mtimeSvr: remoteCopied.mtimeSvr,
|
||||||
|
mtimeSvrFmt: remoteCopied.mtimeSvrFmt,
|
||||||
|
mtimeCli: remoteCopied.mtimeCli,
|
||||||
|
mtimeCliFmt: remoteCopied.mtimeCliFmt,
|
||||||
|
synthesizedFolder: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug(`synthFolders:`);
|
||||||
|
console.debug(synthFolders);
|
||||||
|
|
||||||
|
// special: add synth folders
|
||||||
|
for (const key of Object.keys(synthFolders)) {
|
||||||
|
finalMappings[key] = {
|
||||||
|
key: key,
|
||||||
|
remote: synthFolders[key],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(finalMappings).length === 0 || localEntityList.length === 0) {
|
if (Object.keys(finalMappings).length === 0 || localEntityList.length === 0) {
|
||||||
@@ -494,9 +546,41 @@ export const getSyncPlanInplace = async (
|
|||||||
mixedEntry.decisionBranch = 105;
|
mixedEntry.decisionBranch = 105;
|
||||||
mixedEntry.decision = "folder_to_skip";
|
mixedEntry.decision = "folder_to_skip";
|
||||||
} else if (howToCleanEmptyFolder === "clean_both") {
|
} else if (howToCleanEmptyFolder === "clean_both") {
|
||||||
mixedEntry.decisionBranch = 106;
|
if (local !== undefined && remote !== undefined) {
|
||||||
mixedEntry.decision = "folder_to_be_deleted";
|
if (syncDirection === "bidirectional") {
|
||||||
// TODO: what to do in different sync direction?
|
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 {
|
} else {
|
||||||
throw Error(
|
throw Error(
|
||||||
`do not know how to deal with empty folder ${mixedEntry.key}`
|
`do not know how to deal with empty folder ${mixedEntry.key}`
|
||||||
@@ -694,11 +778,9 @@ export const getSyncPlanInplace = async (
|
|||||||
keptFolder.add(getParentFolder(key));
|
keptFolder.add(getParentFolder(key));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw Error(
|
mixedEntry.decisionBranch = 36;
|
||||||
`remote is created (branch 3) but size larger than ${skipSizeLargerThan}, don't know what to do: ${JSON.stringify(
|
mixedEntry.decision = "remote_is_created_too_large_then_do_nothing";
|
||||||
mixedEntry
|
keptFolder.add(getParentFolder(key));
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
(prevSync.mtimeSvr === remote.mtimeCli ||
|
(prevSync.mtimeSvr === remote.mtimeCli ||
|
||||||
@@ -757,11 +839,9 @@ export const getSyncPlanInplace = async (
|
|||||||
keptFolder.add(getParentFolder(key));
|
keptFolder.add(getParentFolder(key));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw Error(
|
mixedEntry.decisionBranch = 37;
|
||||||
`local is created (branch 6) but size larger than ${skipSizeLargerThan}, don't know what to do: ${JSON.stringify(
|
mixedEntry.decision = "local_is_created_too_large_then_do_nothing";
|
||||||
mixedEntry
|
keptFolder.add(getParentFolder(key));
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
(prevSync.mtimeSvr === local.mtimeCli ||
|
(prevSync.mtimeSvr === local.mtimeCli ||
|
||||||
@@ -823,13 +903,27 @@ export const getSyncPlanInplace = async (
|
|||||||
throw Error(`unexpectedly keptFolder no decisions: ${[...keptFolder]}`);
|
throw Error(`unexpectedly keptFolder no decisions: ${[...keptFolder]}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// finally we want to make our life easier
|
||||||
|
const currTime = Date.now();
|
||||||
|
const currTimeFmt = unixTimeToStr(currTime);
|
||||||
|
// because the path should not as / in the beginning,
|
||||||
|
// we should be safe to add these keys:
|
||||||
|
mixedEntityMappings["/$@meta"] = {
|
||||||
|
key: "/$@meta", // don't mess up with the types
|
||||||
|
sideNotes: {
|
||||||
|
generateTime: currTime,
|
||||||
|
generateTimeFmt: currTimeFmt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return mixedEntityMappings;
|
return mixedEntityMappings;
|
||||||
};
|
};
|
||||||
|
|
||||||
const splitThreeStepsOnEntityMappings = (
|
const splitFourStepsOnEntityMappings = (
|
||||||
mixedEntityMappings: Record<string, MixedEntity>
|
mixedEntityMappings: Record<string, MixedEntity>
|
||||||
) => {
|
) => {
|
||||||
type StepArrayType = MixedEntity[] | undefined | null;
|
type StepArrayType = MixedEntity[] | undefined | null;
|
||||||
|
const onlyMarkSyncedOps: StepArrayType[] = [];
|
||||||
const folderCreationOps: StepArrayType[] = [];
|
const folderCreationOps: StepArrayType[] = [];
|
||||||
const deletionOps: StepArrayType[] = [];
|
const deletionOps: StepArrayType[] = [];
|
||||||
const uploadDownloads: StepArrayType[] = [];
|
const uploadDownloads: StepArrayType[] = [];
|
||||||
@@ -845,6 +939,11 @@ const splitThreeStepsOnEntityMappings = (
|
|||||||
|
|
||||||
for (let i = 0; i < sortedKeys.length; ++i) {
|
for (let i = 0; i < sortedKeys.length; ++i) {
|
||||||
const key = sortedKeys[i];
|
const key = sortedKeys[i];
|
||||||
|
|
||||||
|
if (key === "/$@meta") {
|
||||||
|
continue; // special
|
||||||
|
}
|
||||||
|
|
||||||
const val = mixedEntityMappings[key];
|
const val = mixedEntityMappings[key];
|
||||||
|
|
||||||
if (!key.endsWith("/")) {
|
if (!key.endsWith("/")) {
|
||||||
@@ -852,12 +951,27 @@ const splitThreeStepsOnEntityMappings = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
val.decision === "equal" ||
|
val.decision === "local_is_created_too_large_then_do_nothing" ||
|
||||||
val.decision === "conflict_created_then_do_nothing" ||
|
val.decision === "remote_is_created_too_large_then_do_nothing" ||
|
||||||
val.decision === "folder_existed_both_then_do_nothing" ||
|
|
||||||
val.decision === "folder_to_skip"
|
val.decision === "folder_to_skip"
|
||||||
) {
|
) {
|
||||||
// pass
|
// pass
|
||||||
|
} else if (
|
||||||
|
val.decision === "equal" ||
|
||||||
|
val.decision === "conflict_created_then_do_nothing" ||
|
||||||
|
val.decision === "folder_existed_both_then_do_nothing"
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
onlyMarkSyncedOps.length === 0 ||
|
||||||
|
onlyMarkSyncedOps[0] === undefined ||
|
||||||
|
onlyMarkSyncedOps[0] === null
|
||||||
|
) {
|
||||||
|
onlyMarkSyncedOps[0] = [val];
|
||||||
|
} else {
|
||||||
|
onlyMarkSyncedOps[0].push(val); // only one level is needed here
|
||||||
|
}
|
||||||
|
|
||||||
|
// don't need to update realTotalCount here
|
||||||
} else if (
|
} else if (
|
||||||
val.decision === "folder_existed_local_then_also_create_remote" ||
|
val.decision === "folder_existed_local_then_also_create_remote" ||
|
||||||
val.decision === "folder_existed_remote_then_also_create_local" ||
|
val.decision === "folder_existed_remote_then_also_create_local" ||
|
||||||
@@ -877,7 +991,9 @@ const splitThreeStepsOnEntityMappings = (
|
|||||||
val.decision === "only_history" ||
|
val.decision === "only_history" ||
|
||||||
val.decision === "local_is_deleted_thus_also_delete_remote" ||
|
val.decision === "local_is_deleted_thus_also_delete_remote" ||
|
||||||
val.decision === "remote_is_deleted_thus_also_delete_local" ||
|
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 level = atWhichLevel(key);
|
||||||
const k = deletionOps[level - 1];
|
const k = deletionOps[level - 1];
|
||||||
@@ -888,7 +1004,11 @@ const splitThreeStepsOnEntityMappings = (
|
|||||||
}
|
}
|
||||||
realTotalCount += 1;
|
realTotalCount += 1;
|
||||||
|
|
||||||
if (val.decision.startsWith("deleted")) {
|
if (
|
||||||
|
val.decision.includes("deleted") &&
|
||||||
|
!val.decision.includes("folder")
|
||||||
|
) {
|
||||||
|
// only count files here, skip folder
|
||||||
realModifyDeleteCount += 1;
|
realModifyDeleteCount += 1;
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
@@ -915,8 +1035,8 @@ const splitThreeStepsOnEntityMappings = (
|
|||||||
realTotalCount += 1;
|
realTotalCount += 1;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
val.decision.startsWith("modified") ||
|
val.decision.includes("modified") ||
|
||||||
val.decision.startsWith("conflict")
|
val.decision.includes("conflict")
|
||||||
) {
|
) {
|
||||||
realModifyDeleteCount += 1;
|
realModifyDeleteCount += 1;
|
||||||
}
|
}
|
||||||
@@ -931,6 +1051,7 @@ const splitThreeStepsOnEntityMappings = (
|
|||||||
deletionOps.reverse(); // inplace reverse
|
deletionOps.reverse(); // inplace reverse
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
onlyMarkSyncedOps: onlyMarkSyncedOps,
|
||||||
folderCreationOps: folderCreationOps,
|
folderCreationOps: folderCreationOps,
|
||||||
deletionOps: deletionOps,
|
deletionOps: deletionOps,
|
||||||
uploadDownloads: uploadDownloads,
|
uploadDownloads: uploadDownloads,
|
||||||
@@ -960,13 +1081,37 @@ const dispatchOperationToActualV3 = async (
|
|||||||
// );
|
// );
|
||||||
if (r.decision === "only_history") {
|
if (r.decision === "only_history") {
|
||||||
clearPrevSyncRecordByVaultAndProfile(db, vaultRandomID, profileID, key);
|
clearPrevSyncRecordByVaultAndProfile(db, vaultRandomID, profileID, key);
|
||||||
|
} else if (
|
||||||
|
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"
|
||||||
|
) {
|
||||||
|
// !! no actual sync being kept happens,
|
||||||
|
// so no sync record here
|
||||||
|
// pass
|
||||||
} else if (
|
} else if (
|
||||||
r.decision === "equal" ||
|
r.decision === "equal" ||
|
||||||
r.decision === "conflict_created_then_do_nothing" ||
|
r.decision === "conflict_created_then_do_nothing" ||
|
||||||
r.decision === "folder_to_skip" ||
|
|
||||||
r.decision === "folder_existed_both_then_do_nothing"
|
r.decision === "folder_existed_both_then_do_nothing"
|
||||||
) {
|
) {
|
||||||
// pass
|
// !! we need to upsert the record,
|
||||||
|
// so that next time we can determine the change delta
|
||||||
|
const entity = r.remote ?? r.local;
|
||||||
|
console.debug(
|
||||||
|
`we are in actual operation of equal, entity=${JSON.stringify(
|
||||||
|
entity,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
if (entity !== undefined) {
|
||||||
|
await upsertPrevSyncRecordByVaultAndProfile(
|
||||||
|
db,
|
||||||
|
vaultRandomID,
|
||||||
|
profileID,
|
||||||
|
entity
|
||||||
|
);
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
r.decision === "local_is_modified_then_push" ||
|
r.decision === "local_is_modified_then_push" ||
|
||||||
r.decision === "local_is_created_then_push" ||
|
r.decision === "local_is_created_then_push" ||
|
||||||
@@ -1024,7 +1169,12 @@ const dispatchOperationToActualV3 = async (
|
|||||||
);
|
);
|
||||||
} else if (r.decision === "local_is_deleted_thus_also_delete_remote") {
|
} else if (r.decision === "local_is_deleted_thus_also_delete_remote") {
|
||||||
// local is deleted, we need to delete remote now
|
// local is deleted, we need to delete remote now
|
||||||
await client.deleteFromRemote(r.key, cipher, r.remote!.keyEnc);
|
await client.deleteFromRemote(
|
||||||
|
r.key,
|
||||||
|
cipher,
|
||||||
|
r.remote!.keyEnc,
|
||||||
|
r.remote!.synthesizedFolder
|
||||||
|
);
|
||||||
await clearPrevSyncRecordByVaultAndProfile(
|
await clearPrevSyncRecordByVaultAndProfile(
|
||||||
db,
|
db,
|
||||||
vaultRandomID,
|
vaultRandomID,
|
||||||
@@ -1063,9 +1213,28 @@ const dispatchOperationToActualV3 = async (
|
|||||||
profileID,
|
profileID,
|
||||||
entity
|
entity
|
||||||
);
|
);
|
||||||
} else if (r.decision === "folder_to_be_deleted") {
|
} else if (
|
||||||
await localDeleteFunc(r.key);
|
r.decision === "folder_to_be_deleted_on_both" ||
|
||||||
await client.deleteFromRemote(r.key, cipher, r.remote!.keyEnc);
|
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,
|
||||||
|
r.remote!.synthesizedFolder
|
||||||
|
);
|
||||||
|
}
|
||||||
await clearPrevSyncRecordByVaultAndProfile(
|
await clearPrevSyncRecordByVaultAndProfile(
|
||||||
db,
|
db,
|
||||||
vaultRandomID,
|
vaultRandomID,
|
||||||
@@ -1093,13 +1262,15 @@ export const doActualSync = async (
|
|||||||
) => {
|
) => {
|
||||||
console.debug(`concurrency === ${concurrency}`);
|
console.debug(`concurrency === ${concurrency}`);
|
||||||
const {
|
const {
|
||||||
|
onlyMarkSyncedOps,
|
||||||
folderCreationOps,
|
folderCreationOps,
|
||||||
deletionOps,
|
deletionOps,
|
||||||
uploadDownloads,
|
uploadDownloads,
|
||||||
allFilesCount,
|
allFilesCount,
|
||||||
realModifyDeleteCount,
|
realModifyDeleteCount,
|
||||||
realTotalCount,
|
realTotalCount,
|
||||||
} = splitThreeStepsOnEntityMappings(mixedEntityMappings);
|
} = splitFourStepsOnEntityMappings(mixedEntityMappings);
|
||||||
|
// console.debug(`onlyMarkSyncedOps: ${JSON.stringify(onlyMarkSyncedOps)}`);
|
||||||
// console.debug(`folderCreationOps: ${JSON.stringify(folderCreationOps)}`);
|
// console.debug(`folderCreationOps: ${JSON.stringify(folderCreationOps)}`);
|
||||||
// console.debug(`deletionOps: ${JSON.stringify(deletionOps)}`);
|
// console.debug(`deletionOps: ${JSON.stringify(deletionOps)}`);
|
||||||
// console.debug(`uploadDownloads: ${JSON.stringify(uploadDownloads)}`);
|
// console.debug(`uploadDownloads: ${JSON.stringify(uploadDownloads)}`);
|
||||||
@@ -1115,6 +1286,12 @@ export const doActualSync = async (
|
|||||||
allFilesCount > 0
|
allFilesCount > 0
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
|
protectModifyPercentage === 100 &&
|
||||||
|
realModifyDeleteCount === allFilesCount
|
||||||
|
) {
|
||||||
|
// special treatment for 100%
|
||||||
|
// let it pass, we do nothing here
|
||||||
|
} else if (
|
||||||
realModifyDeleteCount * 100 >=
|
realModifyDeleteCount * 100 >=
|
||||||
allFilesCount * protectModifyPercentage
|
allFilesCount * protectModifyPercentage
|
||||||
) {
|
) {
|
||||||
@@ -1128,11 +1305,17 @@ export const doActualSync = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nested = [folderCreationOps, deletionOps, uploadDownloads];
|
const nested = [
|
||||||
|
onlyMarkSyncedOps,
|
||||||
|
folderCreationOps,
|
||||||
|
deletionOps,
|
||||||
|
uploadDownloads,
|
||||||
|
];
|
||||||
const logTexts = [
|
const logTexts = [
|
||||||
`1. create all folders from shadowest to deepest`,
|
`1. record the items already being synced`,
|
||||||
`2. delete files and folders from deepest to shadowest`,
|
`2. create all folders from shadowest to deepest`,
|
||||||
`3. upload or download files in parallel, with the desired concurrency=${concurrency}`,
|
`3. delete files and folders from deepest to shadowest`,
|
||||||
|
`4. upload or download files in parallel, with the desired concurrency=${concurrency}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
let realCounter = 0;
|
let realCounter = 0;
|
||||||
|
|||||||
@@ -285,3 +285,56 @@ describe("Misc: special char for dir", () => {
|
|||||||
expect(misc.checkHasSpecialCharForDir("xxx?yyy")).to.be.true;
|
expect(misc.checkHasSpecialCharForDir("xxx?yyy")).to.be.true;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Misc: Dropbox: should fix the folder name cases", () => {
|
||||||
|
it("should do nothing on empty folders", () => {
|
||||||
|
const input: any[] = [];
|
||||||
|
expect(misc.fixEntityListCasesInplace(input)).to.be.empty;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should sort folders by length by side effect", () => {
|
||||||
|
const input = [
|
||||||
|
{ keyRaw: "aaaa/" },
|
||||||
|
{ keyRaw: "bbb/" },
|
||||||
|
{ keyRaw: "c/" },
|
||||||
|
{ keyRaw: "dd/" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const output = [
|
||||||
|
{ keyRaw: "c/" },
|
||||||
|
{ keyRaw: "dd/" },
|
||||||
|
{ keyRaw: "bbb/" },
|
||||||
|
{ keyRaw: "aaaa/" },
|
||||||
|
];
|
||||||
|
expect(misc.fixEntityListCasesInplace(input)).to.deep.equal(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fix folder names", () => {
|
||||||
|
const input = [
|
||||||
|
{ keyRaw: "AAA/" },
|
||||||
|
{ keyRaw: "aaa/bbb/CCC.md" },
|
||||||
|
{ keyRaw: "aaa/BBB/" },
|
||||||
|
|
||||||
|
{ keyRaw: "ddd/" },
|
||||||
|
{ keyRaw: "DDD/EEE/fff.md" },
|
||||||
|
{ keyRaw: "DDD/eee/" },
|
||||||
|
|
||||||
|
{ keyRaw: "Ggg/" },
|
||||||
|
{ keyRaw: "ggG/hHH你好/Fff世界.md" },
|
||||||
|
{ keyRaw: "ggG/Hhh你好/" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const output = [
|
||||||
|
{ keyRaw: "AAA/" },
|
||||||
|
{ keyRaw: "ddd/" },
|
||||||
|
{ keyRaw: "Ggg/" },
|
||||||
|
{ keyRaw: "AAA/BBB/" },
|
||||||
|
{ keyRaw: "ddd/eee/" },
|
||||||
|
{ keyRaw: "Ggg/Hhh你好/" },
|
||||||
|
{ keyRaw: "AAA/BBB/CCC.md" },
|
||||||
|
{ keyRaw: "ddd/eee/fff.md" },
|
||||||
|
{ keyRaw: "Ggg/Hhh你好/Fff世界.md" },
|
||||||
|
];
|
||||||
|
expect(misc.fixEntityListCasesInplace(input)).to.deep.equal(output);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user