Compare commits

...
24 Commits
Author SHA1 Message Date
fyears 577cdde21f bump to 0.4.14
Release A New Version / build (16.x) (push) Failing after 43s
2024-04-03 00:22:20 +08:00
fyears 583b365e72 optimize export sync plans, and clear too many sync plans 2024-04-03 00:21:43 +08:00
fyears 3d213b2be8 fix comparation of equality 2024-04-03 00:03:58 +08:00
fyears ee224bf4f2 new delay function to avoid the import problem
Release A New Version / build (16.x) (push) Failing after 36s
2024-04-01 00:13:33 +08:00
fyears f1bd0b1ce6 bump to 0.4.13 2024-03-31 23:52:07 +08:00
fyears 300ed213af improvement of statusbar, finally fully working on iphone 2024-03-31 23:50:42 +08:00
fyears 4be67ce491 minor change to synconsave 2024-03-31 19:07:47 +08:00
Vijayasanthamandfyears 98107ba4ea Improvement of sync_on_save (#502)
* Improve sync_on_save

* fix typo

---------

Co-authored-by: fyears <1142836+fyears@users.noreply.github.com>
2024-03-31 00:07:07 +08:00
fyears b674dd0f10 bump to 0.4.12
Release A New Version / build (16.x) (push) Failing after 35s
2024-03-30 22:01:59 +08:00
fyears 7930509e2a fix sync records 2024-03-30 21:44:09 +08:00
fyears 1c918d82da allowing # in onedrive 2024-03-30 15:39:35 +08:00
fyears 593fd7471b fix s3 detect http 2024-03-30 15:18:14 +08:00
Hamza Megahed 222f386586 Update s3_user_policy.md (#570)
fix AWS S3 user's policy configuration issue https://github.com/remotely-save/remotely-save/issues/564
2024-03-30 14:55:06 +08:00
fyears dcd02457cb fix folder segments of dropbox 2024-03-30 14:53:23 +08:00
fyears 791c0e8df6 very basic synthesizedFolder 2024-03-30 03:30:18 +08:00
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
21 changed files with 740 additions and 186 deletions
@@ -33,6 +33,7 @@ Using the principle of least privilege is crucial for security when allowing a t
"Effect": "Allow",
"Action": [
"s3:HeadObject",
"s3:ListBucket",
"s3:PutObject",
"s3:CopyObject",
"s3:UploadPart",
@@ -48,7 +49,10 @@ Using the principle of least privilege is crucial for security when allowing a t
"s3:DeleteObject",
"s3:DeleteObjects"
],
"Resource": "arn:aws:s3:::my-bucket/*"
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
]
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "remotely-save",
"name": "Remotely Save",
"version": "0.4.9",
"version": "0.4.14",
"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.14",
"minAppVersion": "0.13.21",
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
"author": "fyears",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "remotely-save",
"version": "0.4.9",
"version": "0.4.14",
"description": "This is yet another sync plugin for Obsidian app.",
"scripts": {
"dev2": "node esbuild.config.mjs --watch",
@@ -24,7 +24,7 @@
"license": "Apache-2.0",
"devDependencies": {
"@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/jsdom": "^21.1.6",
"@types/lodash": "^4.14.202",
@@ -34,14 +34,14 @@
"@types/node": "^20.10.4",
"@types/qrcode": "^1.5.5",
"builtin-modules": "^3.3.0",
"chai": "^4.3.10",
"chai": "^4.4.1",
"chai-as-promised": "^7.1.1",
"cross-env": "^7.0.3",
"dotenv": "^16.3.1",
"esbuild": "^0.19.9",
"esbuild-plugin-inline-worker": "^0.1.1",
"jsdom": "^23.0.1",
"mocha": "^10.2.0",
"mocha": "^10.4.0",
"npm-check-updates": "^16.14.12",
"obsidian": "^1.4.11",
"prettier": "^3.1.1",
+10 -3
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
@@ -200,6 +204,7 @@ export interface Entity {
sizeRaw: number;
hash?: string;
etag?: string;
synthesizedFolder?: boolean;
}
export interface UploadedType {
@@ -219,6 +224,8 @@ export interface MixedEntity {
decisionBranch?: number;
decision?: DecisionTypeForMixedEntity;
conflictAction?: ConflictActionType;
sideNotes?: any;
}
/**
@@ -265,9 +272,9 @@ export const DEFAULT_LOG_HISTORY_FILE_PREFIX = "log_hist_exported_on_";
export type SyncTriggerSourceType =
| "manual"
| "auto"
| "dry"
| "autoOnceInit"
| "auto"
| "auto_once_init"
| "auto_sync_on_save";
export const REMOTELY_SAVE_VERSION_2022 = "0.3.25";
+14 -4
View File
@@ -73,7 +73,8 @@ const turnSyncPlanToTable = (record: string) => {
export const exportVaultSyncPlansToFiles = async (
db: InternalDBs,
vault: Vault,
vaultRandomID: string
vaultRandomID: string,
howMany: number
) => {
console.info("exporting");
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
@@ -82,9 +83,18 @@ export const exportVaultSyncPlansToFiles = async (
if (records.length === 0) {
md = "No sync plans history found";
} else {
md =
"Sync plans found:\n\n" +
records.map((x) => "```json\n" + x + "\n```\n").join("\n");
if (howMany <= 0) {
md =
"Sync plans found:\n\n" +
records.map((x) => "```json\n" + x + "\n```\n").join("\n");
} else {
md =
"Sync plans found:\n\n" +
records
.map((x) => "```json\n" + x + "\n```\n")
.slice(0, howMany)
.join("\n");
}
}
const ts = Date.now();
const filePath = `${DEFAULT_DEBUG_FOLDER}${DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX}${ts}.md`;
+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;
}
}
+10 -5
View File
@@ -5,7 +5,7 @@
"goback": "Go Back",
"submit": "Submit",
"sometext": "Here are some texts.",
"syncrun_alreadyrunning": "{{pluginName}} already running in stage {{syncStatus}}!",
"syncrun_alreadyrunning": "New command {{newTriggerSource}} stops because {{pluginName}} is already running in stage {{syncStatus}}!",
"syncrun_syncingribbon": "{{pluginName}}: syncing from {{triggerSource}}",
"syncrun_step0": "0/8 Remotely Save is running in dry mode, thus not actual file changes would happen.",
"syncrun_step1": "1/8 Remotely Save is preparing ({{serviceType}})",
@@ -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.",
@@ -38,7 +38,10 @@
"protocol_onedrive_connect_unknown": "Do not know how to deal with the callback: {{params}}",
"command_startsync": "start sync",
"command_drynrun": "start sync (dry run only)",
"command_exportsyncplans_json": "export sync plans in json format",
"command_exportsyncplans_1": "export sync plans (latest 1)",
"command_exportsyncplans_5": "export sync plans (latest 5)",
"command_exportsyncplans_all": "export sync plans (all)",
"command_exportlogsindb": "export logs saved in db",
"statusbar_time_years": "Synced {{time}} years ago",
@@ -271,7 +274,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",
@@ -292,7 +295,9 @@
"settings_viewconsolelog_desc": "On desktop, please press \"ctrl+shift+i\" or \"cmd+shift+i\" to view the log. On mobile, please install the third-party plugin <a href='https://obsidian.md/plugins?search=Logstravaganza'>Logstravaganza</a> to export the console log to a note.",
"settings_syncplans": "Export Sync Plans",
"settings_syncplans_desc": "Sync plans are created every time after you trigger sync and before the actual sync. Useful to know what would actually happen in those sync. Click the button to export sync plans.",
"settings_syncplans_button_json": "Export",
"settings_syncplans_button_1": "Export latest 1",
"settings_syncplans_button_5": "Export latest 5",
"settings_syncplans_button_all": "Export All",
"settings_syncplans_notice": "Sync plans history exported.",
"settings_delsyncplans": "Delete Sync Plans History In DB",
"settings_delsyncplans_desc": "Delete sync plans history in DB.",
+8 -3
View File
@@ -5,7 +5,7 @@
"goback": "返回",
"submit": "提交",
"sometext": "这里有一段文字。",
"syncrun_alreadyrunning": "{{pluginName}} 正处于此阶段:{{syncStatus}}!",
"syncrun_alreadyrunning": "{{pluginName}} 正处于此阶段:{{syncStatus}}!中断触发 {{newTriggerSource}}。",
"syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 触发运行",
"syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不会发生实际的文件交换。",
"syncrun_step1": "1/8 Remotely Save 准备同步({{serviceType}}",
@@ -39,6 +39,9 @@
"command_startsync": "开始同步(start sync",
"command_drynrun": "开始同步(空跑模式)(start sync (dry run only)",
"command_exportsyncplans_json": "导出同步计划为 json 格式(export sync plans in json format",
"command_exportsyncplans_1": "导出同步计划(最近 1 次)(export sync plans (latest 1)",
"command_exportsyncplans_5": "导出同步计划(最近 5 次)(export sync plans (latest 5)",
"command_exportsyncplans_all": "导出同步计划(所有)(export sync plans (all)",
"command_exportlogsindb": "从数据库导出终端日志(export logs saved in db",
"statusbar_time_years": "{{time}} 年前同步",
@@ -270,7 +273,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": "导出",
@@ -291,7 +294,9 @@
"settings_viewconsolelog_desc": "电脑上,输入“ctrl+shift+i”或“cmd+shift+i”来查看终端输出。手机上,安装第三方插件 <a href='https://obsidian.md/plugins?search=Logstravaganza'>Logstravaganza</a> 来导出终端输出到一篇笔记上。",
"settings_syncplans": "导出同步计划",
"settings_syncplans_desc": "每次您启动同步,并在实际上传下载前,插件会生成同步计划。它可以使您知道每次同步发生了什么。点击按钮可以导出同步计划。",
"settings_syncplans_button_json": "导出",
"settings_syncplans_button_1": "导出最近 1 次",
"settings_syncplans_button_5": "导出最近 5 次",
"settings_syncplans_button_all": "导出所有",
"settings_syncplans_notice": "同步计划已导出",
"settings_delsyncplans": "删除数据库里的同步计划历史",
"settings_delsyncplans_desc": "删除数据库里的同步计划历史。",
+8 -4
View File
@@ -5,7 +5,7 @@
"goback": "返回",
"submit": "提交",
"sometext": "這裡有一段文字。",
"syncrun_alreadyrunning": "{{pluginName}} 正處於此階段:{{syncStatus}}!",
"syncrun_alreadyrunning": "{{pluginName}} 正處於此階段:{{syncStatus}}! 中斷觸發 {{newTriggerSource}}。",
"syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 觸發執行",
"syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不會發生實際的檔案交換。",
"syncrun_step1": "1/8 Remotely Save 準備同步({{serviceType}}",
@@ -38,7 +38,9 @@
"protocol_onedrive_connect_unknown": "不知道如何處理此 callback{{params}}",
"command_startsync": "開始同步(start sync",
"command_drynrun": "開始同步(空跑模式)(start sync (dry run only)",
"command_exportsyncplans_json": "匯出同步計劃為 json 格式export sync plans in json format",
"command_exportsyncplans_1": "匯出同步計劃(最近 1 次)export sync plans (latest 1)",
"command_exportsyncplans_5": "匯出同步計劃(最近 5 次)(export sync plans (latest 5)",
"command_exportsyncplans_all": "匯出同步計劃(所有)(export sync plans (all)",
"command_exportlogsindb": "從資料庫匯出終端日誌(export logs saved in db",
"statusbar_time_years": "{{time}} 年前同步",
@@ -270,7 +272,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": "匯出",
@@ -291,7 +293,9 @@
"settings_viewconsolelog_desc": "電腦上,輸入“ctrl+shift+i”或“cmd+shift+i”來檢視終端輸出。手機上,安裝第三方外掛 <a href='https://obsidian.md/plugins?search=Logstravaganza'>Logstravaganza</a> 來匯出終端輸出到一篇筆記上。",
"settings_syncplans": "匯出同步計劃",
"settings_syncplans_desc": "每次您啟動同步,並在實際上傳下載前,外掛會生成同步計劃。它可以使您知道每次同步發生了什麼。點選按鈕可以匯出同步計劃。",
"settings_syncplans_button_json": "匯出",
"settings_syncplans_button_1": "匯出最近 1 次",
"settings_syncplans_button_5": "匯出最近 5 次",
"settings_syncplans_button_all": "匯出所有",
"settings_syncplans_notice": "同步計劃已匯出",
"settings_delsyncplans": "刪除資料庫裡的同步計劃歷史",
"settings_delsyncplans_desc": "刪除資料庫裡的同步計劃歷史。",
+3 -3
View File
@@ -382,13 +382,13 @@ export const readAllSyncPlanRecordTextsByVault = async (
};
/**
* We remove records that are older than 3 days or 100 records.
* We remove records that are older than 1 days or 20 records.
* It's a heavy operation, so we shall not place it in the start up.
* @param db
*/
export const clearExpiredSyncPlanRecords = async (db: InternalDBs) => {
const MILLISECONDS_OLD = 1000 * 60 * 60 * 24 * 3; // 3 days
const COUNT_TO_MANY = 100;
const MILLISECONDS_OLD = 1000 * 60 * 60 * 24 * 1; // 1 days
const COUNT_TO_MANY = 20;
const currTs = Date.now();
const expiredTs = currTs - MILLISECONDS_OLD;
+105 -48
View File
@@ -9,6 +9,7 @@ import {
Platform,
requestUrl,
requireApiVersion,
Events,
} from "obsidian";
import cloneDeep from "lodash/cloneDeep";
import { createElement, RotateCcw, RefreshCcw, FileText } from "lucide";
@@ -149,6 +150,8 @@ export default class RemotelySavePlugin extends Plugin {
i18n!: I18n;
vaultRandomID!: string;
debugServerTemp?: string;
syncEvent?: Events;
appContainerObserver?: MutationObserver;
async syncRun(triggerSource: SyncTriggerSourceType = "manual") {
const t = (x: TransItemType, vars?: any) => {
@@ -165,15 +168,17 @@ export default class RemotelySavePlugin extends Plugin {
}
};
if (this.syncStatus !== "idle") {
// here the notice is shown regardless of triggerSource
new Notice(
// really, users don't want to see this in auto mode
// so we use getNotice to avoid unnecessary show up
getNotice(
t("syncrun_alreadyrunning", {
pluginName: this.manifest.name,
syncStatus: this.syncStatus,
newTriggerSource: triggerSource,
})
);
if (this.currSyncMsg !== undefined && this.currSyncMsg !== "") {
new Notice(this.currSyncMsg);
getNotice(this.currSyncMsg);
}
return;
}
@@ -376,7 +381,8 @@ export default class RemotelySavePlugin extends Plugin {
realCounter,
realTotalCount,
pathName,
decision
decision,
triggerSource
),
this.db
);
@@ -416,6 +422,7 @@ export default class RemotelySavePlugin extends Plugin {
this.updateLastSuccessSyncMsg(lastSuccessSyncMillis);
}
this.syncEvent?.trigger("SYNC_DONE");
console.info(
`${
this.manifest.id
@@ -465,6 +472,8 @@ export default class RemotelySavePlugin extends Plugin {
this.currSyncMsg = "";
this.syncEvent = new Events();
await this.loadSettings();
// MUST after loadSettings and before prepareDB
@@ -773,14 +782,45 @@ export default class RemotelySavePlugin extends Plugin {
});
this.addCommand({
id: "export-sync-plans-json",
name: t("command_exportsyncplans_json"),
id: "export-sync-plans-1",
name: t("command_exportsyncplans_1"),
icon: iconNameLogs,
callback: async () => {
await exportVaultSyncPlansToFiles(
this.db,
this.app.vault,
this.vaultRandomID
this.vaultRandomID,
1
);
new Notice(t("settings_syncplans_notice"));
},
});
this.addCommand({
id: "export-sync-plans-5",
name: t("command_exportsyncplans_5"),
icon: iconNameLogs,
callback: async () => {
await exportVaultSyncPlansToFiles(
this.db,
this.app.vault,
this.vaultRandomID,
5
);
new Notice(t("settings_syncplans_notice"));
},
});
this.addCommand({
id: "export-sync-plans-all",
name: t("command_exportsyncplans_all"),
icon: iconNameLogs,
callback: async () => {
await exportVaultSyncPlansToFiles(
this.db,
this.app.vault,
this.vaultRandomID,
-1
);
new Notice(t("settings_syncplans_notice"));
},
@@ -812,6 +852,10 @@ export default class RemotelySavePlugin extends Plugin {
async onunload() {
console.info(`unloading plugin ${this.manifest.id}`);
this.syncRibbon = undefined;
if (this.appContainerObserver !== undefined) {
this.appContainerObserver.disconnect();
this.appContainerObserver = undefined;
}
if (this.oauth2Info !== undefined) {
this.oauth2Info.helperModal = undefined;
this.oauth2Info = {
@@ -1100,7 +1144,7 @@ export default class RemotelySavePlugin extends Plugin {
) {
this.app.workspace.onLayoutReady(() => {
window.setTimeout(() => {
this.syncRun("autoOnceInit");
this.syncRun("auto_once_init");
}, this.settings.initRunAfterMilliseconds);
});
}
@@ -1126,52 +1170,64 @@ export default class RemotelySavePlugin extends Plugin {
}, scheduleTimeFromNow);
};
this.app.workspace.onLayoutReady(() => {
const intervalID = window.setInterval(() => {
const currentFile = this.app.workspace.getActiveFile();
const checkCurrFileModified = async (caller: "SYNC" | "FILE_CHANGES") => {
const currentFile = this.app.workspace.getActiveFile();
if (currentFile) {
// get the last modified time of the current file
// if it has been modified within the last syncOnSaveAfterMilliseconds
// then schedule a run for syncOnSaveAfterMilliseconds after it was modified
const lastModified = currentFile.stat.mtime;
const currentTime = Date.now();
if (
currentTime - lastModified <
this.settings!.syncOnSaveAfterMilliseconds!
) {
if (
!needToRunAgain &&
!runScheduled &&
this.syncStatus === "idle"
) {
const scheduleTimeFromNow =
this.settings!.syncOnSaveAfterMilliseconds! -
(currentTime - lastModified);
scheduleSyncOnSave(scheduleTimeFromNow);
} else if (
needToRunAgain &&
!runScheduled &&
this.syncStatus === "idle"
) {
scheduleSyncOnSave(this.settings!.syncOnSaveAfterMilliseconds!);
needToRunAgain = false;
} else {
needToRunAgain = true;
}
if (currentFile) {
// get the last modified time of the current file
// if it has modified after lastSuccessSync
// then schedule a run for syncOnSaveAfterMilliseconds after it was modified
const lastModified = currentFile.stat.mtime;
const lastSuccessSyncMillis = await getLastSuccessSyncTimeByVault(
this.db,
this.vaultRandomID
);
if (
this.syncStatus === "idle" &&
lastModified > lastSuccessSyncMillis &&
!runScheduled
) {
scheduleSyncOnSave(this.settings!.syncOnSaveAfterMilliseconds!);
} else if (
this.syncStatus === "idle" &&
needToRunAgain &&
!runScheduled
) {
scheduleSyncOnSave(this.settings!.syncOnSaveAfterMilliseconds!);
needToRunAgain = false;
} else {
if (caller === "FILE_CHANGES") {
needToRunAgain = true;
}
}
}, this.settings.syncOnSaveAfterMilliseconds);
this.syncOnSaveIntervalID = intervalID;
this.registerInterval(intervalID);
}
};
this.app.workspace.onLayoutReady(() => {
// listen to sync done
this.registerEvent(
this.syncEvent?.on("SYNC_DONE", () => {
checkCurrFileModified("SYNC");
})!
);
// listen to current file save changes
this.registerEvent(
this.app.vault.on("modify", (x) => {
// console.debug(`event=modify! file=${x}`);
checkCurrFileModified("FILE_CHANGES");
})
);
});
}
}
enableMobileStatusBarIfSet() {
if (Platform.isMobile && this.settings.enableMobileStatusBar) {
changeMobileStatusBar("enable");
}
this.app.workspace.onLayoutReady(() => {
if (Platform.isMobile && this.settings.enableMobileStatusBar) {
this.appContainerObserver = changeMobileStatusBar("enable");
}
});
}
async saveAgreeToUseNewSyncAlgorithm() {
@@ -1183,9 +1239,10 @@ export default class RemotelySavePlugin extends Plugin {
i: number,
totalCount: number,
pathName: string,
decision: string
decision: string,
triggerSource: SyncTriggerSourceType
) {
const msg = `syncing progress=${i}/${totalCount},decision=${decision},path=${pathName}`;
const msg = `syncing progress=${i}/${totalCount},decision=${decision},path=${pathName},source=${triggerSource}`;
this.currSyncMsg = msg;
}
+125 -8
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
@@ -514,15 +517,129 @@ export const stringToFragment = (string: string) => {
* https://forum.obsidian.md/t/css-to-show-status-bar-on-mobile-devices/77185
* @param op
*/
export const changeMobileStatusBar = (op: "enable" | "disable") => {
const bar = document.querySelector(
export const changeMobileStatusBar = (
op: "enable" | "disable",
oldAppContainerObserver?: MutationObserver
) => {
const appContainer = document.getElementsByClassName("app-container")[0] as
| HTMLElement
| undefined;
const statusbar = document.querySelector(
".is-mobile .app-container .status-bar"
) as HTMLElement;
) as HTMLElement | undefined;
if (appContainer === undefined || statusbar === undefined) {
// give up, exit
console.warn(`give up watching appContainer for statusbar`);
console.warn(`appContainer=${appContainer}, statusbar=${statusbar}`);
return undefined;
}
if (op === "enable") {
bar.style.setProperty("display", "flex");
bar.style.setProperty("margin-bottom", "40px");
const callback = async (
mutationList: MutationRecord[],
observer: MutationObserver
) => {
for (const mutation of mutationList) {
// console.debug(mutation);
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
const k = mutation.addedNodes[0] as Element;
if (
k.className.contains("mobile-navbar") ||
k.className.contains("mobile-toolbar")
) {
// have to wait, otherwise the height is not correct??
await new Promise((resolve) => setTimeout(resolve, 300));
const height = window
.getComputedStyle(k as Element)
.getPropertyValue("height");
statusbar.style.setProperty("display", "flex");
statusbar.style.setProperty("margin-bottom", height);
}
}
}
};
const observer = new MutationObserver(callback);
observer.observe(appContainer, {
attributes: false,
childList: true,
characterData: false,
subtree: false,
});
try {
// init, manual call
const navBar = document.getElementsByClassName(
"mobile-navbar"
)[0] as HTMLElement;
// thanks to community's solution
const height = window.getComputedStyle(navBar).getPropertyValue("height");
statusbar.style.setProperty("display", "flex");
statusbar.style.setProperty("margin-bottom", height);
} catch (e) {
// skip
}
return observer;
} else {
bar.style.removeProperty("display");
bar.style.removeProperty("margin-bottom");
if (oldAppContainerObserver !== undefined) {
console.debug(`disconnect oldAppContainerObserver`);
oldAppContainerObserver.disconnect();
oldAppContainerObserver = undefined;
}
statusbar.style.removeProperty("display");
statusbar.style.removeProperty("margin-bottom");
return undefined;
}
};
/**
* 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
View File
@@ -239,7 +239,8 @@ export class RemoteClient {
deleteFromRemote = async (
fileOrFolderPath: string,
cipher: Cipher,
remoteEncryptedKey: string = ""
remoteEncryptedKey: string = "",
synthesizedFolder: boolean = false
) => {
if (this.serviceType === "s3") {
return await s3.deleteFromRemote(
@@ -247,7 +248,8 @@ export class RemoteClient {
this.s3Config!,
fileOrFolderPath,
cipher,
remoteEncryptedKey
remoteEncryptedKey,
synthesizedFolder
);
} else if (this.serviceType === "webdav") {
return await webdav.deleteFromRemote(
+5 -2
View File
@@ -12,6 +12,7 @@ import {
} from "./baseTypes";
import {
bufferToArrayBuffer,
fixEntityListCasesInplace,
getFolderLevels,
hasEmojiInText,
headersToRecord,
@@ -483,8 +484,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");
@@ -635,6 +636,8 @@ export const listAllFromRemote = async (client: WrappedDropboxClient) => {
unifiedContents.push(...unifiedContents2);
}
fixEntityListCasesInplace(unifiedContents);
return unifiedContents;
};
+5
View File
@@ -471,6 +471,11 @@ export class WrappedOnedriveClient {
const pathFrag = encodeURI(pathFragOrig);
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;
};
+18 -3
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";
@@ -250,6 +250,7 @@ const fromS3ObjectToEntity = (
mtimeCli: mtimeCli,
sizeRaw: x.Size!,
etag: x.ETag,
synthesizedFolder: false,
};
return r;
};
@@ -599,7 +600,10 @@ export const listAllFromRemote = async (
s3Client: S3Client,
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,
fileOrFolderPath: string,
cipher: Cipher,
remoteEncryptedKey: string = ""
remoteEncryptedKey: string = "",
synthesizedFolder: boolean = false
) => {
if (fileOrFolderPath === "/") {
return;
}
if (synthesizedFolder) {
return;
}
let remoteFileName = fileOrFolderPath;
if (!cipher.isPasswordEmpty()) {
remoteFileName = remoteEncryptedKey;
@@ -770,6 +778,13 @@ export const checkConnectivity = async (
callbackFunc?: any
) => {
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(
// 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",
};
+51 -44
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)
@@ -2122,10 +2098,16 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
.onChange(async (val) => {
if (val === "enable") {
this.plugin.settings.enableMobileStatusBar = true;
changeMobileStatusBar("enable");
this.plugin.appContainerObserver =
changeMobileStatusBar("enable");
} else {
this.plugin.settings.enableMobileStatusBar = false;
changeMobileStatusBar("disable");
changeMobileStatusBar(
"disable",
this.plugin.appContainerObserver
);
this.plugin.appContainerObserver?.disconnect();
this.plugin.appContainerObserver = undefined;
}
await this.plugin.saveSettings();
});
@@ -2222,12 +2204,37 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
.setName(t("settings_syncplans"))
.setDesc(t("settings_syncplans_desc"))
.addButton(async (button) => {
button.setButtonText(t("settings_syncplans_button_json"));
button.setButtonText(t("settings_syncplans_button_1"));
button.onClick(async () => {
await exportVaultSyncPlansToFiles(
this.plugin.db,
this.app.vault,
this.plugin.vaultRandomID
this.plugin.vaultRandomID,
1
);
new Notice(t("settings_syncplans_notice"));
});
})
.addButton(async (button) => {
button.setButtonText(t("settings_syncplans_button_5"));
button.onClick(async () => {
await exportVaultSyncPlansToFiles(
this.plugin.db,
this.app.vault,
this.plugin.vaultRandomID,
5
);
new Notice(t("settings_syncplans_notice"));
});
})
.addButton(async (button) => {
button.setButtonText(t("settings_syncplans_button_all"));
button.onClick(async () => {
await exportVaultSyncPlansToFiles(
this.plugin.db,
this.app.vault,
this.plugin.vaultRandomID,
-1
);
new Notice(t("settings_syncplans_notice"));
});
+225 -39
View File
@@ -18,6 +18,7 @@ import {
isVaildText,
atWhichLevel,
mkdirpInVault,
getFolderLevels,
} from "./misc";
import {
DEFAULT_FILE_NAME_FOR_METADATAONREMOTE,
@@ -53,8 +54,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 +93,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 +113,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 +160,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 +171,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 +182,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;
}
@@ -314,7 +327,10 @@ export const ensembleMixedEnties = async (
): Promise<SyncPlanType> => {
const finalMappings: SyncPlanType = {};
const synthFolders: Record<string, Entity> = {};
// remote has to be first
// we also have to synthesize folders here
for (const remote of remoteEntityList) {
const remoteCopied = ensureMTimeOfRemoteEntityValid(
await decryptRemoteEntityInplace(
@@ -340,6 +356,42 @@ export const ensembleMixedEnties = async (
key: key,
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) {
@@ -494,9 +546,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 +778,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 +839,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 ||
@@ -823,13 +903,27 @@ export const getSyncPlanInplace = async (
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;
};
const splitThreeStepsOnEntityMappings = (
const splitFourStepsOnEntityMappings = (
mixedEntityMappings: Record<string, MixedEntity>
) => {
type StepArrayType = MixedEntity[] | undefined | null;
const onlyMarkSyncedOps: StepArrayType[] = [];
const folderCreationOps: StepArrayType[] = [];
const deletionOps: StepArrayType[] = [];
const uploadDownloads: StepArrayType[] = [];
@@ -845,6 +939,11 @@ const splitThreeStepsOnEntityMappings = (
for (let i = 0; i < sortedKeys.length; ++i) {
const key = sortedKeys[i];
if (key === "/$@meta") {
continue; // special
}
const val = mixedEntityMappings[key];
if (!key.endsWith("/")) {
@@ -852,12 +951,27 @@ const splitThreeStepsOnEntityMappings = (
}
if (
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
} 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 (
val.decision === "folder_existed_local_then_also_create_remote" ||
val.decision === "folder_existed_remote_then_also_create_local" ||
@@ -877,7 +991,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 +1004,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 +1035,8 @@ const splitThreeStepsOnEntityMappings = (
realTotalCount += 1;
if (
val.decision.startsWith("modified") ||
val.decision.startsWith("conflict")
val.decision.includes("modified") ||
val.decision.includes("conflict")
) {
realModifyDeleteCount += 1;
}
@@ -931,6 +1051,7 @@ const splitThreeStepsOnEntityMappings = (
deletionOps.reverse(); // inplace reverse
return {
onlyMarkSyncedOps: onlyMarkSyncedOps,
folderCreationOps: folderCreationOps,
deletionOps: deletionOps,
uploadDownloads: uploadDownloads,
@@ -960,13 +1081,40 @@ const dispatchOperationToActualV3 = async (
// );
if (r.decision === "only_history") {
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 (
r.decision === "equal" ||
r.decision === "conflict_created_then_do_nothing" ||
r.decision === "folder_to_skip" ||
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
// if we have prevSync, we store it because it should keep all necessary info
let entity = r.prevSync;
// if we don't have prevSync, we use remote entity AND local mtime
// as if it is "uploaded"
if (entity === undefined && r.remote !== undefined) {
entity = await decryptRemoteEntityInplace(r.remote, cipher);
entity = await fullfillMTimeOfRemoteEntityInplace(
entity,
r.local?.mtimeCli
);
}
if (entity !== undefined) {
await upsertPrevSyncRecordByVaultAndProfile(
db,
vaultRandomID,
profileID,
entity
);
}
} else if (
r.decision === "local_is_modified_then_push" ||
r.decision === "local_is_created_then_push" ||
@@ -1024,7 +1172,12 @@ const dispatchOperationToActualV3 = async (
);
} else if (r.decision === "local_is_deleted_thus_also_delete_remote") {
// 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(
db,
vaultRandomID,
@@ -1063,9 +1216,28 @@ 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,
r.remote!.synthesizedFolder
);
}
await clearPrevSyncRecordByVaultAndProfile(
db,
vaultRandomID,
@@ -1093,13 +1265,15 @@ export const doActualSync = async (
) => {
console.debug(`concurrency === ${concurrency}`);
const {
onlyMarkSyncedOps,
folderCreationOps,
deletionOps,
uploadDownloads,
allFilesCount,
realModifyDeleteCount,
realTotalCount,
} = splitThreeStepsOnEntityMappings(mixedEntityMappings);
} = splitFourStepsOnEntityMappings(mixedEntityMappings);
// console.debug(`onlyMarkSyncedOps: ${JSON.stringify(onlyMarkSyncedOps)}`);
// console.debug(`folderCreationOps: ${JSON.stringify(folderCreationOps)}`);
// console.debug(`deletionOps: ${JSON.stringify(deletionOps)}`);
// console.debug(`uploadDownloads: ${JSON.stringify(uploadDownloads)}`);
@@ -1115,6 +1289,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
) {
@@ -1128,11 +1308,17 @@ export const doActualSync = async (
}
}
const nested = [folderCreationOps, deletionOps, uploadDownloads];
const nested = [
onlyMarkSyncedOps,
folderCreationOps,
deletionOps,
uploadDownloads,
];
const logTexts = [
`1. create all folders from shadowest to deepest`,
`2. delete files and folders from deepest to shadowest`,
`3. upload or download files in parallel, with the desired concurrency=${concurrency}`,
`1. record the items already being synced`,
`2. create all folders from shadowest to deepest`,
`3. delete files and folders from deepest to shadowest`,
`4. upload or download files in parallel, with the desired concurrency=${concurrency}`,
];
let realCounter = 0;
+53
View File
@@ -285,3 +285,56 @@ describe("Misc: special char for dir", () => {
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);
});
});