Compare commits

..
16 Commits
Author SHA1 Message Date
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
18 changed files with 490 additions and 111 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
} }
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "导出",
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+5 -2
View File
@@ -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;
}; };
+5
View File
@@ -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
View File
@@ -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 })
// ); // );
+7
View File
@@ -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",
}; };
+9 -33
View File
@@ -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,21 +147,12 @@ 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.plugin.settings.encryptionMethod = this.newEncryptionMethod;
await this.plugin.saveSettings();
this.close(); this.close();
}); });
button.setClass("encryptionmethod-second-confirm"); button.setClass("encryptionmethod-second-confirm");
})
.addButton((button) => {
button.setButtonText(t("goback"));
button.onClick(() => {
this.close();
});
}); });
} }
@@ -1693,24 +1678,15 @@ 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();
} }
}); });
}); });
+220 -37
View File
@@ -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") {
if (local !== undefined && remote !== undefined) {
if (syncDirection === "bidirectional") {
mixedEntry.decisionBranch = 106; mixedEntry.decisionBranch = 106;
mixedEntry.decision = "folder_to_be_deleted"; mixedEntry.decision = "folder_to_be_deleted_on_both";
// TODO: what to do in different sync direction? } 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 (
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); await localDeleteFunc(r.key);
await client.deleteFromRemote(r.key, cipher, r.remote!.keyEnc); }
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;
+53
View File
@@ -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);
});
});