Compare commits

...
12 Commits
Author SHA1 Message Date
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
17 changed files with 423 additions and 91 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.11", "version": "0.4.13",
"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.4.11", "version": "0.4.13",
"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.11", "version": "0.4.13",
"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",
+5 -2
View File
@@ -204,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 {
@@ -223,6 +224,8 @@ export interface MixedEntity {
decisionBranch?: number; decisionBranch?: number;
decision?: DecisionTypeForMixedEntity; decision?: DecisionTypeForMixedEntity;
conflictAction?: ConflictActionType; conflictAction?: ConflictActionType;
sideNotes?: any;
} }
/** /**
@@ -269,9 +272,9 @@ export const DEFAULT_LOG_HISTORY_FILE_PREFIX = "log_hist_exported_on_";
export type SyncTriggerSourceType = export type SyncTriggerSourceType =
| "manual" | "manual"
| "auto"
| "dry" | "dry"
| "autoOnceInit" | "auto"
| "auto_once_init"
| "auto_sync_on_save"; | "auto_sync_on_save";
export const REMOTELY_SAVE_VERSION_2022 = "0.3.25"; export const REMOTELY_SAVE_VERSION_2022 = "0.3.25";
+1 -1
View File
@@ -5,7 +5,7 @@
"goback": "Go Back", "goback": "Go Back",
"submit": "Submit", "submit": "Submit",
"sometext": "Here are some texts.", "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_syncingribbon": "{{pluginName}}: syncing from {{triggerSource}}",
"syncrun_step0": "0/8 Remotely Save is running in dry mode, thus not actual file changes would happen.", "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}})", "syncrun_step1": "1/8 Remotely Save is preparing ({{serviceType}})",
+1 -1
View File
@@ -5,7 +5,7 @@
"goback": "返回", "goback": "返回",
"submit": "提交", "submit": "提交",
"sometext": "这里有一段文字。", "sometext": "这里有一段文字。",
"syncrun_alreadyrunning": "{{pluginName}} 正处于此阶段:{{syncStatus}}!", "syncrun_alreadyrunning": "{{pluginName}} 正处于此阶段:{{syncStatus}}!中断触发 {{newTriggerSource}}。",
"syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 触发运行", "syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 触发运行",
"syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不会发生实际的文件交换。", "syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不会发生实际的文件交换。",
"syncrun_step1": "1/8 Remotely Save 准备同步({{serviceType}}", "syncrun_step1": "1/8 Remotely Save 准备同步({{serviceType}}",
+1 -1
View File
@@ -5,7 +5,7 @@
"goback": "返回", "goback": "返回",
"submit": "提交", "submit": "提交",
"sometext": "這裡有一段文字。", "sometext": "這裡有一段文字。",
"syncrun_alreadyrunning": "{{pluginName}} 正處於此階段:{{syncStatus}}!", "syncrun_alreadyrunning": "{{pluginName}} 正處於此階段:{{syncStatus}}! 中斷觸發 {{newTriggerSource}}。",
"syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 觸發執行", "syncrun_syncingribbon": "{{pluginName}}:正在由 {{triggerSource}} 觸發執行",
"syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不會發生實際的檔案交換。", "syncrun_step0": "0/8 Remotely Save 在空跑(dry run)模式,不會發生實際的檔案交換。",
"syncrun_step1": "1/8 Remotely Save 準備同步({{serviceType}}", "syncrun_step1": "1/8 Remotely Save 準備同步({{serviceType}}",
+71 -45
View File
@@ -9,6 +9,7 @@ import {
Platform, Platform,
requestUrl, requestUrl,
requireApiVersion, requireApiVersion,
Events,
} from "obsidian"; } from "obsidian";
import cloneDeep from "lodash/cloneDeep"; import cloneDeep from "lodash/cloneDeep";
import { createElement, RotateCcw, RefreshCcw, FileText } from "lucide"; import { createElement, RotateCcw, RefreshCcw, FileText } from "lucide";
@@ -149,6 +150,8 @@ export default class RemotelySavePlugin extends Plugin {
i18n!: I18n; i18n!: I18n;
vaultRandomID!: string; vaultRandomID!: string;
debugServerTemp?: string; debugServerTemp?: string;
syncEvent?: Events;
appContainerObserver?: MutationObserver;
async syncRun(triggerSource: SyncTriggerSourceType = "manual") { async syncRun(triggerSource: SyncTriggerSourceType = "manual") {
const t = (x: TransItemType, vars?: any) => { const t = (x: TransItemType, vars?: any) => {
@@ -165,15 +168,17 @@ export default class RemotelySavePlugin extends Plugin {
} }
}; };
if (this.syncStatus !== "idle") { if (this.syncStatus !== "idle") {
// here the notice is shown regardless of triggerSource // really, users don't want to see this in auto mode
new Notice( // so we use getNotice to avoid unnecessary show up
getNotice(
t("syncrun_alreadyrunning", { t("syncrun_alreadyrunning", {
pluginName: this.manifest.name, pluginName: this.manifest.name,
syncStatus: this.syncStatus, syncStatus: this.syncStatus,
newTriggerSource: triggerSource,
}) })
); );
if (this.currSyncMsg !== undefined && this.currSyncMsg !== "") { if (this.currSyncMsg !== undefined && this.currSyncMsg !== "") {
new Notice(this.currSyncMsg); getNotice(this.currSyncMsg);
} }
return; return;
} }
@@ -376,7 +381,8 @@ export default class RemotelySavePlugin extends Plugin {
realCounter, realCounter,
realTotalCount, realTotalCount,
pathName, pathName,
decision decision,
triggerSource
), ),
this.db this.db
); );
@@ -416,6 +422,7 @@ export default class RemotelySavePlugin extends Plugin {
this.updateLastSuccessSyncMsg(lastSuccessSyncMillis); this.updateLastSuccessSyncMsg(lastSuccessSyncMillis);
} }
this.syncEvent?.trigger("SYNC_DONE");
console.info( console.info(
`${ `${
this.manifest.id this.manifest.id
@@ -465,6 +472,8 @@ export default class RemotelySavePlugin extends Plugin {
this.currSyncMsg = ""; this.currSyncMsg = "";
this.syncEvent = new Events();
await this.loadSettings(); await this.loadSettings();
// MUST after loadSettings and before prepareDB // MUST after loadSettings and before prepareDB
@@ -812,6 +821,10 @@ export default class RemotelySavePlugin extends Plugin {
async onunload() { async onunload() {
console.info(`unloading plugin ${this.manifest.id}`); console.info(`unloading plugin ${this.manifest.id}`);
this.syncRibbon = undefined; this.syncRibbon = undefined;
if (this.appContainerObserver !== undefined) {
this.appContainerObserver.disconnect();
this.appContainerObserver = undefined;
}
if (this.oauth2Info !== undefined) { if (this.oauth2Info !== undefined) {
this.oauth2Info.helperModal = undefined; this.oauth2Info.helperModal = undefined;
this.oauth2Info = { this.oauth2Info = {
@@ -1100,7 +1113,7 @@ export default class RemotelySavePlugin extends Plugin {
) { ) {
this.app.workspace.onLayoutReady(() => { this.app.workspace.onLayoutReady(() => {
window.setTimeout(() => { window.setTimeout(() => {
this.syncRun("autoOnceInit"); this.syncRun("auto_once_init");
}, this.settings.initRunAfterMilliseconds); }, this.settings.initRunAfterMilliseconds);
}); });
} }
@@ -1126,52 +1139,64 @@ export default class RemotelySavePlugin extends Plugin {
}, scheduleTimeFromNow); }, scheduleTimeFromNow);
}; };
this.app.workspace.onLayoutReady(() => { const checkCurrFileModified = async (caller: "SYNC" | "FILE_CHANGES") => {
const intervalID = window.setInterval(() => { const currentFile = this.app.workspace.getActiveFile();
const currentFile = this.app.workspace.getActiveFile();
if (currentFile) { if (currentFile) {
// get the last modified time of the current file // get the last modified time of the current file
// if it has been modified within the last syncOnSaveAfterMilliseconds // if it has modified after lastSuccessSync
// then schedule a run for syncOnSaveAfterMilliseconds after it was modified // then schedule a run for syncOnSaveAfterMilliseconds after it was modified
const lastModified = currentFile.stat.mtime; const lastModified = currentFile.stat.mtime;
const currentTime = Date.now(); const lastSuccessSyncMillis = await getLastSuccessSyncTimeByVault(
if ( this.db,
currentTime - lastModified < this.vaultRandomID
this.settings!.syncOnSaveAfterMilliseconds! );
) { if (
if ( this.syncStatus === "idle" &&
!needToRunAgain && lastModified > lastSuccessSyncMillis &&
!runScheduled && !runScheduled
this.syncStatus === "idle" ) {
) { scheduleSyncOnSave(this.settings!.syncOnSaveAfterMilliseconds!);
const scheduleTimeFromNow = } else if (
this.settings!.syncOnSaveAfterMilliseconds! - this.syncStatus === "idle" &&
(currentTime - lastModified); needToRunAgain &&
scheduleSyncOnSave(scheduleTimeFromNow); !runScheduled
} else if ( ) {
needToRunAgain && scheduleSyncOnSave(this.settings!.syncOnSaveAfterMilliseconds!);
!runScheduled && needToRunAgain = false;
this.syncStatus === "idle" } else {
) { if (caller === "FILE_CHANGES") {
scheduleSyncOnSave(this.settings!.syncOnSaveAfterMilliseconds!); needToRunAgain = true;
needToRunAgain = false;
} else {
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() { enableMobileStatusBarIfSet() {
if (Platform.isMobile && this.settings.enableMobileStatusBar) { this.app.workspace.onLayoutReady(() => {
changeMobileStatusBar("enable"); if (Platform.isMobile && this.settings.enableMobileStatusBar) {
} this.appContainerObserver = changeMobileStatusBar("enable");
}
});
} }
async saveAgreeToUseNewSyncAlgorithm() { async saveAgreeToUseNewSyncAlgorithm() {
@@ -1183,9 +1208,10 @@ export default class RemotelySavePlugin extends Plugin {
i: number, i: number,
totalCount: number, totalCount: number,
pathName: string, 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; this.currSyncMsg = msg;
} }
+121 -12
View File
@@ -517,20 +517,129 @@ export const stringToFragment = (string: string) => {
* https://forum.obsidian.md/t/css-to-show-status-bar-on-mobile-devices/77185 * https://forum.obsidian.md/t/css-to-show-status-bar-on-mobile-devices/77185
* @param op * @param op
*/ */
export const changeMobileStatusBar = (op: "enable" | "disable") => { export const changeMobileStatusBar = (
const bar = document.querySelector( 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" ".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") { if (op === "enable") {
bar.style.setProperty("display", "flex"); const callback = async (
const navBar = document.getElementsByClassName( mutationList: MutationRecord[],
"mobile-navbar" observer: MutationObserver
)[0] as HTMLElement; ) => {
// thanks to community's solution for (const mutation of mutationList) {
const height = window.getComputedStyle(navBar).getPropertyValue("height"); // console.debug(mutation);
bar.style.setProperty("margin-bottom", height); 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 { } else {
bar.style.removeProperty("display"); if (oldAppContainerObserver !== undefined) {
bar.style.removeProperty("margin-bottom"); 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 ( 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(
+3
View File
@@ -12,6 +12,7 @@ import {
} from "./baseTypes"; } from "./baseTypes";
import { import {
bufferToArrayBuffer, bufferToArrayBuffer,
fixEntityListCasesInplace,
getFolderLevels, getFolderLevels,
hasEmojiInText, hasEmojiInText,
headersToRecord, headersToRecord,
@@ -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;
}; };
+11 -3
View File
@@ -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;
@@ -771,7 +779,7 @@ export const checkConnectivity = async (
) => { ) => {
try { try {
// TODO: no universal way now, just check this in connectivity // TODO: no universal way now, just check this in connectivity
if (Platform.isIosApp && !s3Config.s3Endpoint.startsWith("https")) { if (Platform.isIosApp && s3Config.s3Endpoint.startsWith("http://")) {
throw Error( throw Error(
`Your s3 endpoint could only be https, not http, because of the iOS restriction.` `Your s3 endpoint could only be https, not http, because of the iOS restriction.`
); );
+8 -2
View File
@@ -2098,10 +2098,16 @@ export class RemotelySaveSettingTab extends PluginSettingTab {
.onChange(async (val) => { .onChange(async (val) => {
if (val === "enable") { if (val === "enable") {
this.plugin.settings.enableMobileStatusBar = true; this.plugin.settings.enableMobileStatusBar = true;
changeMobileStatusBar("enable"); this.plugin.appContainerObserver =
changeMobileStatusBar("enable");
} else { } else {
this.plugin.settings.enableMobileStatusBar = false; this.plugin.settings.enableMobileStatusBar = false;
changeMobileStatusBar("disable"); changeMobileStatusBar(
"disable",
this.plugin.appContainerObserver
);
this.plugin.appContainerObserver?.disconnect();
this.plugin.appContainerObserver = undefined;
} }
await this.plugin.saveSettings(); await this.plugin.saveSettings();
}); });
+128 -15
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,
@@ -326,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(
@@ -352,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) {
@@ -863,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[] = [];
@@ -885,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("/")) {
@@ -892,14 +951,27 @@ const splitThreeStepsOnEntityMappings = (
} }
if ( 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 === "local_is_created_too_large_then_do_nothing" ||
val.decision === "remote_is_created_too_large_then_do_nothing" || val.decision === "remote_is_created_too_large_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" ||
@@ -979,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,
@@ -1009,14 +1082,36 @@ 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 ( } else if (
r.decision === "equal" ||
r.decision === "conflict_created_then_do_nothing" ||
r.decision === "local_is_created_too_large_then_do_nothing" || r.decision === "local_is_created_too_large_then_do_nothing" ||
r.decision === "remote_is_created_too_large_then_do_nothing" || r.decision === "remote_is_created_too_large_then_do_nothing" ||
r.decision === "folder_to_skip" || 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_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" ||
@@ -1074,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,
@@ -1128,7 +1228,12 @@ const dispatchOperationToActualV3 = async (
r.decision === "folder_to_be_deleted_on_both" || r.decision === "folder_to_be_deleted_on_both" ||
r.decision === "folder_to_be_deleted_on_remote" r.decision === "folder_to_be_deleted_on_remote"
) { ) {
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,
@@ -1157,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)}`);
@@ -1198,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);
});
});