import { TAbstractFile, TFile, TFolder, Vault, requireApiVersion, } from "obsidian"; import AggregateError from "aggregate-error"; import PQueue from "p-queue"; import type { RemoteItem, SyncTriggerSourceType, DecisionType, FileOrFolderMixedState, SUPPORTED_SERVICES_TYPE, } from "./baseTypes"; import { API_VER_STAT_FOLDER } from "./baseTypes"; import { decryptBase32ToString, decryptBase64urlToString, encryptStringToBase64url, getSizeFromOrigToEnc, MAGIC_ENCRYPTED_PREFIX_BASE32, MAGIC_ENCRYPTED_PREFIX_BASE64URL, } from "./encrypt"; import type { FileFolderHistoryRecord, InternalDBs } from "./localdb"; import { clearDeleteRenameHistoryOfKeyAndVault, getSyncMetaMappingByRemoteKeyAndVault, upsertSyncMetaMappingDataByVault, } from "./localdb"; import { isHiddenPath, isVaildText, mkdirpInVault, getFolderLevels, getParentFolder, atWhichLevel, unixTimeToStr, statFix, } from "./misc"; import { RemoteClient } from "./remote"; import { MetadataOnRemote, DeletionOnRemote, serializeMetadataOnRemote, deserializeMetadataOnRemote, DEFAULT_FILE_NAME_FOR_METADATAONREMOTE, DEFAULT_FILE_NAME_FOR_METADATAONREMOTE2, isEqualMetadataOnRemote, } from "./metadataOnRemote"; import { isInsideObsFolder, ObsConfigDirFileType } from "./obsFolderLister"; import { log } from "./moreOnLog"; export type SyncStatusType = | "idle" | "preparing" | "getting_remote_files_list" | "getting_remote_extra_meta" | "getting_local_meta" | "checking_password" | "generating_plan" | "syncing" | "cleaning" | "finish"; export interface SyncPlanType { ts: number; tsFmt?: string; syncTriggerSource?: SyncTriggerSourceType; remoteType: SUPPORTED_SERVICES_TYPE; mixedStates: Record<string, FileOrFolderMixedState>; } export interface PasswordCheckType { ok: boolean; reason: | "ok" | "empty_remote" | "remote_encrypted_local_no_password" | "password_matched" | "password_not_matched" | "invalid_text_after_decryption" | "remote_not_encrypted_local_has_password" | "no_password_both_sides"; } export const isPasswordOk = async ( remote: RemoteItem[], password: string = "" ) => { if (remote === undefined || remote.length === 0) { // remote empty return { ok: true, reason: "empty_remote", } as PasswordCheckType; } const santyCheckKey = remote[0].key; if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) { // this is encrypted using old base32! // try to decrypt it using the provided password. if (password === "") { return { ok: false, reason: "remote_encrypted_local_no_password", } as PasswordCheckType; } try { const res = await decryptBase32ToString(santyCheckKey, password); // additional test // because iOS Safari bypasses decryption with wrong password! if (isVaildText(res)) { return { ok: true, reason: "password_matched", } as PasswordCheckType; } else { return { ok: false, reason: "invalid_text_after_decryption", } as PasswordCheckType; } } catch (error) { return { ok: false, reason: "password_not_matched", } as PasswordCheckType; } } if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE64URL)) { // this is encrypted using new base64url! // try to decrypt it using the provided password. if (password === "") { return { ok: false, reason: "remote_encrypted_local_no_password", } as PasswordCheckType; } try { const res = await decryptBase64urlToString(santyCheckKey, password); // additional test // because iOS Safari bypasses decryption with wrong password! if (isVaildText(res)) { return { ok: true, reason: "password_matched", } as PasswordCheckType; } else { return { ok: false, reason: "invalid_text_after_decryption", } as PasswordCheckType; } } catch (error) { return { ok: false, reason: "password_not_matched", } as PasswordCheckType; } } else { // it is not encrypted! if (password !== "") { return { ok: false, reason: "remote_not_encrypted_local_has_password", } as PasswordCheckType; } return { ok: true, reason: "no_password_both_sides", } as PasswordCheckType; } }; export const parseRemoteItems = async ( remote: RemoteItem[], db: InternalDBs, vaultRandomID: string, remoteType: SUPPORTED_SERVICES_TYPE, password: string = "" ) => { const remoteStates = [] as FileOrFolderMixedState[]; let metadataFile: FileOrFolderMixedState = undefined; if (remote === undefined) { return { remoteStates: remoteStates, metadataFile: metadataFile, }; } for (const entry of remote) { const remoteEncryptedKey = entry.key; let key = remoteEncryptedKey; if (password !== "") { if (remoteEncryptedKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) { key = await decryptBase32ToString(remoteEncryptedKey, password); } else if ( remoteEncryptedKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE64URL) ) { key = await decryptBase64urlToString(remoteEncryptedKey, password); } else { throw Error(`unexpected key=${remoteEncryptedKey}`); } } const backwardMapping = await getSyncMetaMappingByRemoteKeyAndVault( remoteType, db, key, entry.lastModified, entry.etag, vaultRandomID ); let r = {} as FileOrFolderMixedState; if (backwardMapping !== undefined) { key = backwardMapping.localKey; const mtimeRemote = backwardMapping.localMtime || entry.lastModified; // the backwardMapping.localSize is the file BEFORE encryption // we want to split two sizes for comparation later r = { key: key, existRemote: true, mtimeRemote: mtimeRemote, mtimeRemoteFmt: unixTimeToStr(mtimeRemote), sizeRemote: backwardMapping.localSize, sizeRemoteEnc: password === "" ? undefined : entry.size, remoteEncryptedKey: remoteEncryptedKey, changeRemoteMtimeUsingMapping: true, }; } else { // do not have backwardMapping r = { key: key, existRemote: true, mtimeRemote: entry.lastModified, mtimeRemoteFmt: unixTimeToStr(entry.lastModified), sizeRemote: password === "" ? entry.size : undefined, sizeRemoteEnc: password === "" ? undefined : entry.size, remoteEncryptedKey: remoteEncryptedKey, changeRemoteMtimeUsingMapping: false, }; } if (r.key === DEFAULT_FILE_NAME_FOR_METADATAONREMOTE) { metadataFile = Object.assign({}, r); } if (r.key === DEFAULT_FILE_NAME_FOR_METADATAONREMOTE2) { throw Error( `A reserved file name ${r.key} has been found. You may upgrade the plugin to latest version to try to deal with it.` ); } remoteStates.push(r); } return { remoteStates: remoteStates, metadataFile: metadataFile, }; }; export const fetchMetadataFile = async ( metadataFile: FileOrFolderMixedState, client: RemoteClient, vault: Vault, password: string = "" ) => { if (metadataFile === undefined) { log.debug("no metadata file, so no fetch"); return { deletions: [], } as MetadataOnRemote; } const buf = await client.downloadFromRemote( metadataFile.key, vault, metadataFile.mtimeRemote, password, metadataFile.remoteEncryptedKey, true ); const metadata = deserializeMetadataOnRemote(buf); return metadata; }; const isSkipItem = ( key: string, syncConfigDir: boolean, syncUnderscoreItems: boolean, configDir: string ) => { if (syncConfigDir && isInsideObsFolder(key, configDir)) { return false; } return ( isHiddenPath(key, true, false) || (!syncUnderscoreItems && isHiddenPath(key, false, true)) || key === DEFAULT_FILE_NAME_FOR_METADATAONREMOTE || key === DEFAULT_FILE_NAME_FOR_METADATAONREMOTE2 ); }; const ensembleMixedStates = async ( remoteStates: FileOrFolderMixedState[], local: TAbstractFile[], localConfigDirContents: ObsConfigDirFileType[] | undefined, remoteDeleteHistory: DeletionOnRemote[], localFileHistory: FileFolderHistoryRecord[], syncConfigDir: boolean, configDir: string, syncUnderscoreItems: boolean, password: string ) => { const results = {} as Record<string, FileOrFolderMixedState>; for (const r of remoteStates) { const key = r.key; if (isSkipItem(key, syncConfigDir, syncUnderscoreItems, configDir)) { continue; } results[key] = r; results[key].existLocal = false; } for (const entry of local) { let r = {} as FileOrFolderMixedState; let key = entry.path; if (entry.path === "/") { // ignore continue; } else if (entry instanceof TFile) { const mtimeLocal = Math.max(entry.stat.mtime ?? 0, entry.stat.ctime ?? 0); r = { key: entry.path, existLocal: true, mtimeLocal: mtimeLocal, mtimeLocalFmt: unixTimeToStr(mtimeLocal), sizeLocal: entry.stat.size, sizeLocalEnc: password === "" ? undefined : getSizeFromOrigToEnc(entry.stat.size), }; } else if (entry instanceof TFolder) { key = `${entry.path}/`; r = { key: key, existLocal: true, mtimeLocal: undefined, mtimeLocalFmt: undefined, sizeLocal: 0, sizeLocalEnc: password === "" ? undefined : getSizeFromOrigToEnc(0), }; } else { throw Error(`unexpected ${entry}`); } if (isSkipItem(key, syncConfigDir, syncUnderscoreItems, configDir)) { continue; } if (results.hasOwnProperty(key)) { results[key].key = r.key; results[key].existLocal = r.existLocal; results[key].mtimeLocal = r.mtimeLocal; results[key].mtimeLocalFmt = r.mtimeLocalFmt; results[key].sizeLocal = r.sizeLocal; results[key].sizeLocalEnc = r.sizeLocalEnc; } else { results[key] = r; results[key].existRemote = false; } } if (syncConfigDir && localConfigDirContents !== undefined) { for (const entry of localConfigDirContents) { const key = entry.key; let mtimeLocal = Math.max(entry.mtime ?? 0, entry.ctime ?? 0); if (Number.isNaN(mtimeLocal) || mtimeLocal === 0) { mtimeLocal = undefined; } const r: FileOrFolderMixedState = { key: key, existLocal: true, mtimeLocal: mtimeLocal, mtimeLocalFmt: unixTimeToStr(mtimeLocal), sizeLocal: entry.size, sizeLocalEnc: password === "" ? undefined : getSizeFromOrigToEnc(entry.size), }; if (results.hasOwnProperty(key)) { results[key].key = r.key; results[key].existLocal = r.existLocal; results[key].mtimeLocal = r.mtimeLocal; results[key].mtimeLocalFmt = r.mtimeLocalFmt; results[key].sizeLocal = r.sizeLocal; results[key].sizeLocalEnc = r.sizeLocalEnc; } else { results[key] = r; results[key].existRemote = false; } } } for (const entry of remoteDeleteHistory) { const key = entry.key; const r = { key: key, deltimeRemote: entry.actionWhen, deltimeRemoteFmt: unixTimeToStr(entry.actionWhen), } as FileOrFolderMixedState; if (isSkipItem(key, syncConfigDir, syncUnderscoreItems, configDir)) { continue; } if (results.hasOwnProperty(key)) { results[key].key = r.key; results[key].deltimeRemote = r.deltimeRemote; results[key].deltimeRemoteFmt = r.deltimeRemoteFmt; } else { results[key] = r; results[key].existLocal = false; results[key].existRemote = false; } } for (const entry of localFileHistory) { let key = entry.key; if (entry.keyType === "folder") { if (!entry.key.endsWith("/")) { key = `${entry.key}/`; } } else if (entry.keyType === "file") { // pass } else { throw Error(`unexpected ${entry}`); } if (isSkipItem(key, syncConfigDir, syncUnderscoreItems, configDir)) { continue; } if (entry.actionType === "delete" || entry.actionType === "rename") { const r = { key: key, deltimeLocal: entry.actionWhen, deltimeLocalFmt: unixTimeToStr(entry.actionWhen), } as FileOrFolderMixedState; if (results.hasOwnProperty(key)) { results[key].deltimeLocal = r.deltimeLocal; results[key].deltimeLocalFmt = r.deltimeLocalFmt; } else { results[key] = r; results[key].existLocal = false; // we have already checked local results[key].existRemote = false; // we have already checked remote } } else if (entry.actionType === "renameDestination") { const r = { key: key, mtimeLocal: entry.actionWhen, mtimeLocalFmt: unixTimeToStr(entry.actionWhen), changeLocalMtimeUsingMapping: true, }; if (results.hasOwnProperty(key)) { let mtimeLocal = Math.max( r.mtimeLocal ?? 0, results[key].mtimeLocal ?? 0 ); if (Number.isNaN(mtimeLocal) || mtimeLocal === 0) { mtimeLocal = undefined; } results[key].mtimeLocal = mtimeLocal; results[key].mtimeLocalFmt = unixTimeToStr(mtimeLocal); results[key].changeLocalMtimeUsingMapping = r.changeLocalMtimeUsingMapping; } else { // So, the file doesn't exist, // except that it existed in the "renamed to" history records. // Most likely because that the user deleted the file while Obsidian was closed, // so Obsidian could not track the deletions. // We are not sure how to deal with this, so do not generate anything here! // // // The following 3 lines are of old logic, and have been removed: // // results[key] = r; // // results[key].existLocal = false; // we have already checked local // // results[key].existRemote = false; // we have already checked remote } } else { throw Error( `do not know how to deal with local file history ${entry.key} with ${entry.actionType}` ); } } return results; }; const assignOperationToFileInplace = ( origRecord: FileOrFolderMixedState, keptFolder: Set<string>, skipSizeLargerThan: number, password: string = "" ) => { let r = origRecord; // files and folders are treated differently // here we only check files if (r.key.endsWith("/")) { return r; } // we find the max date from four sources // 0. find anything inconsistent if (r.existLocal && (r.mtimeLocal === undefined || r.mtimeLocal <= 0)) { throw Error( `Error: Abnormal last modified time locally: ${JSON.stringify( r, null, 2 )}` ); } if (r.existRemote && (r.mtimeRemote === undefined || r.mtimeRemote <= 0)) { throw Error( `Error: Abnormal last modified time remotely: ${JSON.stringify( r, null, 2 )}` ); } if (r.deltimeLocal !== undefined && r.deltimeLocal <= 0) { throw Error( `Error: Abnormal deletion time locally: ${JSON.stringify(r, null, 2)}` ); } if (r.deltimeRemote !== undefined && r.deltimeRemote <= 0) { throw Error( `Error: Abnormal deletion time remotely: ${JSON.stringify(r, null, 2)}` ); } if ( (r.existLocal && password !== "" && r.sizeLocalEnc === undefined) || (r.existRemote && password !== "" && r.sizeRemoteEnc === undefined) ) { throw new Error( `Error: No encryption sizes: ${JSON.stringify(r, null, 2)}` ); } const sizeLocalComp = password === "" ? r.sizeLocal : r.sizeLocalEnc; const sizeRemoteComp = password === "" ? r.sizeRemote : r.sizeRemoteEnc; // 1. mtimeLocal if (r.existLocal) { const mtimeRemote = r.existRemote ? r.mtimeRemote : -1; const deltimeRemote = r.deltimeRemote !== undefined ? r.deltimeRemote : -1; const deltimeLocal = r.deltimeLocal !== undefined ? r.deltimeLocal : -1; if ( r.mtimeLocal >= mtimeRemote && r.mtimeLocal >= deltimeLocal && r.mtimeLocal >= deltimeRemote ) { if (sizeLocalComp === undefined) { throw new Error( `Error: no local size but has local mtime: ${JSON.stringify( r, null, 2 )}` ); } if (r.mtimeLocal === r.mtimeRemote) { // local and remote both exist and mtimes are the same if (sizeLocalComp === sizeRemoteComp) { // do not need to consider skipSizeLargerThan in this case r.decision = "skipUploading"; r.decisionBranch = 1; } else { if (skipSizeLargerThan <= 0) { r.decision = "uploadLocalToRemote"; r.decisionBranch = 2; } else { // limit the sizes if (sizeLocalComp <= skipSizeLargerThan) { if (sizeRemoteComp <= skipSizeLargerThan) { r.decision = "uploadLocalToRemote"; r.decisionBranch = 18; } else { r.decision = "errorRemoteTooLargeConflictLocal"; r.decisionBranch = 19; } } else { if (sizeRemoteComp <= skipSizeLargerThan) { r.decision = "errorLocalTooLargeConflictRemote"; r.decisionBranch = 20; } else { r.decision = "skipUploadingTooLarge"; r.decisionBranch = 21; } } } } } else { // we have local laregest mtime, // and the remote not existing or smaller mtime if (skipSizeLargerThan <= 0) { // no need to consider sizes r.decision = "uploadLocalToRemote"; r.decisionBranch = 4; } else { // need to consider sizes if (sizeLocalComp <= skipSizeLargerThan) { if (sizeRemoteComp === undefined) { r.decision = "uploadLocalToRemote"; r.decisionBranch = 22; } else if (sizeRemoteComp <= skipSizeLargerThan) { r.decision = "uploadLocalToRemote"; r.decisionBranch = 23; } else { r.decision = "errorRemoteTooLargeConflictLocal"; r.decisionBranch = 24; } } else { if (sizeRemoteComp === undefined) { r.decision = "skipUploadingTooLarge"; r.decisionBranch = 25; } else if (sizeRemoteComp <= skipSizeLargerThan) { r.decision = "errorLocalTooLargeConflictRemote"; r.decisionBranch = 26; } else { r.decision = "skipUploadingTooLarge"; r.decisionBranch = 27; } } } } keptFolder.add(getParentFolder(r.key)); return r; } } // 2. mtimeRemote if (r.existRemote) { const mtimeLocal = r.existLocal ? r.mtimeLocal : -1; const deltimeRemote = r.deltimeRemote !== undefined ? r.deltimeRemote : -1; const deltimeLocal = r.deltimeLocal !== undefined ? r.deltimeLocal : -1; if ( r.mtimeRemote > mtimeLocal && r.mtimeRemote >= deltimeLocal && r.mtimeRemote >= deltimeRemote ) { // we have remote laregest mtime, // and the local not existing or smaller mtime if (sizeRemoteComp === undefined) { throw new Error( `Error: no remote size but has remote mtime: ${JSON.stringify( r, null, 2 )}` ); } if (skipSizeLargerThan <= 0) { // no need to consider sizes r.decision = "downloadRemoteToLocal"; r.decisionBranch = 5; } else { // need to consider sizes if (sizeRemoteComp <= skipSizeLargerThan) { if (sizeLocalComp === undefined) { r.decision = "downloadRemoteToLocal"; r.decisionBranch = 28; } else if (sizeLocalComp <= skipSizeLargerThan) { r.decision = "downloadRemoteToLocal"; r.decisionBranch = 29; } else { r.decision = "errorLocalTooLargeConflictRemote"; r.decisionBranch = 30; } } else { if (sizeLocalComp === undefined) { r.decision = "skipDownloadingTooLarge"; r.decisionBranch = 31; } else if (sizeLocalComp <= skipSizeLargerThan) { r.decision = "errorRemoteTooLargeConflictLocal"; r.decisionBranch = 32; } else { r.decision = "skipDownloadingTooLarge"; r.decisionBranch = 33; } } } keptFolder.add(getParentFolder(r.key)); return r; } } // 3. deltimeLocal if (r.deltimeLocal !== undefined && r.deltimeLocal !== 0) { const mtimeLocal = r.existLocal ? r.mtimeLocal : -1; const mtimeRemote = r.existRemote ? r.mtimeRemote : -1; const deltimeRemote = r.deltimeRemote !== undefined ? r.deltimeRemote : -1; if ( r.deltimeLocal >= mtimeLocal && r.deltimeLocal >= mtimeRemote && r.deltimeLocal >= deltimeRemote ) { if (skipSizeLargerThan <= 0) { r.decision = "uploadLocalDelHistToRemote"; r.decisionBranch = 6; if (r.existLocal || r.existRemote) { // actual deletion would happen } } else { const localTooLargeToDelete = r.existLocal && sizeLocalComp > skipSizeLargerThan; const remoteTooLargeToDelete = r.existRemote && sizeRemoteComp > skipSizeLargerThan; if (localTooLargeToDelete) { if (remoteTooLargeToDelete) { r.decision = "skipUsingLocalDelTooLarge"; r.decisionBranch = 34; } else { if (r.existRemote) { r.decision = "errorLocalTooLargeConflictRemote"; r.decisionBranch = 35; } else { r.decision = "skipUsingLocalDelTooLarge"; r.decisionBranch = 36; } } } else { if (remoteTooLargeToDelete) { if (r.existLocal) { r.decision = "errorLocalTooLargeConflictRemote"; r.decisionBranch = 37; } else { r.decision = "skipUsingLocalDelTooLarge"; r.decisionBranch = 38; } } else { r.decision = "uploadLocalDelHistToRemote"; r.decisionBranch = 39; } } } return r; } } // 4. deltimeRemote if (r.deltimeRemote !== undefined && r.deltimeRemote !== 0) { const mtimeLocal = r.existLocal ? r.mtimeLocal : -1; const mtimeRemote = r.existRemote ? r.mtimeRemote : -1; const deltimeLocal = r.deltimeLocal !== undefined ? r.deltimeLocal : -1; if ( r.deltimeRemote >= mtimeLocal && r.deltimeRemote >= mtimeRemote && r.deltimeRemote >= deltimeLocal ) { if (skipSizeLargerThan <= 0) { r.decision = "keepRemoteDelHist"; r.decisionBranch = 7; if (r.existLocal || r.existRemote) { // actual deletion would happen } } else { const localTooLargeToDelete = r.existLocal && sizeLocalComp > skipSizeLargerThan; const remoteTooLargeToDelete = r.existRemote && sizeRemoteComp > skipSizeLargerThan; if (localTooLargeToDelete) { if (remoteTooLargeToDelete) { r.decision = "skipUsingRemoteDelTooLarge"; r.decisionBranch = 40; } else { if (r.existRemote) { r.decision = "errorLocalTooLargeConflictRemote"; r.decisionBranch = 41; } else { r.decision = "skipUsingRemoteDelTooLarge"; r.decisionBranch = 42; } } } else { if (remoteTooLargeToDelete) { if (r.existLocal) { r.decision = "errorLocalTooLargeConflictRemote"; r.decisionBranch = 43; } else { r.decision = "skipUsingRemoteDelTooLarge"; r.decisionBranch = 44; } } else { r.decision = "keepRemoteDelHist"; r.decisionBranch = 45; } } } return r; } } throw Error(`no decision for ${JSON.stringify(r)}`); }; const assignOperationToFolderInplace = async ( origRecord: FileOrFolderMixedState, keptFolder: Set<string>, vault: Vault, password: string = "" ) => { let r = origRecord; // files and folders are treated differently // here we only check folders if (!r.key.endsWith("/")) { return r; } if (!keptFolder.has(r.key)) { // the folder does NOT have any must-be-kept children! if (r.deltimeLocal !== undefined || r.deltimeRemote !== undefined) { // it has some deletion "commands" const deltimeLocal = r.deltimeLocal !== undefined ? r.deltimeLocal : -1; const deltimeRemote = r.deltimeRemote !== undefined ? r.deltimeRemote : -1; // if it was created after deletion, we should keep it as is if (requireApiVersion(API_VER_STAT_FOLDER)) { if (r.existLocal) { const { ctime, mtime } = await statFix(vault, r.key); const cmtime = Math.max(ctime ?? 0, mtime ?? 0); if ( !Number.isNaN(cmtime) && cmtime > 0 && cmtime >= deltimeLocal && cmtime >= deltimeRemote ) { keptFolder.add(getParentFolder(r.key)); if (r.existLocal && r.existRemote) { r.decision = "skipFolder"; r.decisionBranch = 14; } else if (r.existLocal || r.existRemote) { r.decision = "createFolder"; r.decisionBranch = 15; } else { throw Error( `Error: Folder ${r.key} doesn't exist locally and remotely but is marked must be kept. Abort.` ); } } } } // If it was moved to here, after deletion, we should keep it as is. // The logic not necessarily needs API_VER_STAT_FOLDER. // The folder needs this logic because it's also determined by file children. // But the file do not need this logic because the mtimeLocal is checked firstly. if ( r.existLocal && r.changeLocalMtimeUsingMapping && r.mtimeLocal > 0 && r.mtimeLocal > deltimeLocal && r.mtimeLocal > deltimeRemote ) { keptFolder.add(getParentFolder(r.key)); if (r.existLocal && r.existRemote) { r.decision = "skipFolder"; r.decisionBranch = 16; } else if (r.existLocal || r.existRemote) { r.decision = "createFolder"; r.decisionBranch = 17; } else { throw Error( `Error: Folder ${r.key} doesn't exist locally and remotely but is marked must be kept. Abort.` ); } } if (r.decision === undefined) { // not yet decided by the above reason if (deltimeLocal > 0 && deltimeLocal > deltimeRemote) { r.decision = "uploadLocalDelHistToRemoteFolder"; r.decisionBranch = 8; } else { r.decision = "keepRemoteDelHistFolder"; r.decisionBranch = 9; } } } else { // it does not have any deletion commands // keep it as is, and create it if necessary keptFolder.add(getParentFolder(r.key)); if (r.existLocal && r.existRemote) { r.decision = "skipFolder"; r.decisionBranch = 10; } else if (r.existLocal || r.existRemote) { r.decision = "createFolder"; r.decisionBranch = 11; } else { throw Error( `Error: Folder ${r.key} doesn't exist locally and remotely but is marked must be kept. Abort.` ); } } } else { // the folder has some must be kept children! // so itself and its parent folder must be kept keptFolder.add(getParentFolder(r.key)); if (r.existLocal && r.existRemote) { r.decision = "skipFolder"; r.decisionBranch = 12; } else if (r.existLocal || r.existRemote) { r.decision = "createFolder"; r.decisionBranch = 13; } else { throw Error( `Error: Folder ${r.key} doesn't exist locally and remotely but is marked must be kept. Abort.` ); } } // save the memory, save the world! // we have dealt with it, so we don't need it any more. keptFolder.delete(r.key); return r; }; const DELETION_DECISIONS: Set<DecisionType> = new Set([ "uploadLocalDelHistToRemote", "keepRemoteDelHist", "uploadLocalDelHistToRemoteFolder", "keepRemoteDelHistFolder", ]); const SIZES_GO_WRONG_DECISIONS: Set<DecisionType> = new Set([ "errorLocalTooLargeConflictRemote", "errorRemoteTooLargeConflictLocal", ]); export const getSyncPlan = async ( remoteStates: FileOrFolderMixedState[], local: TAbstractFile[], localConfigDirContents: ObsConfigDirFileType[] | undefined, remoteDeleteHistory: DeletionOnRemote[], localFileHistory: FileFolderHistoryRecord[], remoteType: SUPPORTED_SERVICES_TYPE, triggerSource: SyncTriggerSourceType, vault: Vault, syncConfigDir: boolean, configDir: string, syncUnderscoreItems: boolean, skipSizeLargerThan: number, password: string = "" ) => { const mixedStates = await ensembleMixedStates( remoteStates, local, localConfigDirContents, remoteDeleteHistory, localFileHistory, syncConfigDir, configDir, syncUnderscoreItems, password ); const sortedKeys = Object.keys(mixedStates).sort( (k1, k2) => k2.length - k1.length ); const sizesGoWrong: FileOrFolderMixedState[] = []; const deletions: DeletionOnRemote[] = []; const keptFolder = new Set<string>(); for (let i = 0; i < sortedKeys.length; ++i) { const key = sortedKeys[i]; const val = mixedStates[key]; if (key.endsWith("/")) { // decide some folders // because the keys are sorted by length // so all the children must have been shown up before in the iteration await assignOperationToFolderInplace(val, keptFolder, vault, password); } else { // get all operations of files // and at the same time get some helper info for folders assignOperationToFileInplace( val, keptFolder, skipSizeLargerThan, password ); } if (SIZES_GO_WRONG_DECISIONS.has(val.decision)) { sizesGoWrong.push(val); } if (DELETION_DECISIONS.has(val.decision)) { if (val.decision === "uploadLocalDelHistToRemote") { deletions.push({ key: key, actionWhen: val.deltimeLocal, }); } else if (val.decision === "keepRemoteDelHist") { deletions.push({ key: key, actionWhen: val.deltimeRemote, }); } else if (val.decision === "uploadLocalDelHistToRemoteFolder") { deletions.push({ key: key, actionWhen: val.deltimeLocal, }); } else if (val.decision === "keepRemoteDelHistFolder") { deletions.push({ key: key, actionWhen: val.deltimeRemote, }); } else { throw Error(`do not know how to delete for decision ${val.decision}`); } } } const currTs = Date.now(); const currTsFmt = unixTimeToStr(currTs); const plan = { ts: currTs, tsFmt: currTsFmt, remoteType: remoteType, syncTriggerSource: triggerSource, mixedStates: mixedStates, } as SyncPlanType; return { plan: plan, sortedKeys: sortedKeys, deletions: deletions, sizesGoWrong: sizesGoWrong, }; }; const uploadExtraMeta = async ( client: RemoteClient, metadataFile: FileOrFolderMixedState | undefined, origMetadata: MetadataOnRemote | undefined, deletions: DeletionOnRemote[], password: string = "" ) => { if (deletions === undefined || deletions.length === 0) { return; } const key = DEFAULT_FILE_NAME_FOR_METADATAONREMOTE; let remoteEncryptedKey = key; if (password !== "") { if (metadataFile === undefined) { remoteEncryptedKey = undefined; } else { remoteEncryptedKey = metadataFile.remoteEncryptedKey; } if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") { // remoteEncryptedKey = await encryptStringToBase32(key, password); remoteEncryptedKey = await encryptStringToBase64url(key, password); } } const newMetadata: MetadataOnRemote = { deletions: deletions, }; if (isEqualMetadataOnRemote(origMetadata, newMetadata)) { log.debug( "metadata are the same, no need to re-generate and re-upload it." ); return; } const resultText = serializeMetadataOnRemote(newMetadata); await client.uploadToRemote( key, undefined, false, password, remoteEncryptedKey, undefined, true, resultText ); }; const dispatchOperationToActual = async ( key: string, vaultRandomID: string, r: FileOrFolderMixedState, client: RemoteClient, db: InternalDBs, vault: Vault, localDeleteFunc: any, password: string = "" ) => { let remoteEncryptedKey = key; if (password !== "") { remoteEncryptedKey = r.remoteEncryptedKey; if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") { // the old version uses base32 // remoteEncryptedKey = await encryptStringToBase32(key, password); // the new version users base64url remoteEncryptedKey = await encryptStringToBase64url(key, password); } } if (r.decision === undefined) { throw Error(`unknown decision in ${JSON.stringify(r)}`); } else if (r.decision === "skipUploading") { // do nothing! } else if (r.decision === "uploadLocalDelHistToRemote") { if (r.existLocal) { await localDeleteFunc(r.key); } if (r.existRemote) { await client.deleteFromRemote(r.key, password, remoteEncryptedKey); } await clearDeleteRenameHistoryOfKeyAndVault(db, r.key, vaultRandomID); } else if (r.decision === "keepRemoteDelHist") { if (r.existLocal) { await localDeleteFunc(r.key); } if (r.existRemote) { await client.deleteFromRemote(r.key, password, remoteEncryptedKey); } await clearDeleteRenameHistoryOfKeyAndVault(db, r.key, vaultRandomID); } else if (r.decision === "uploadLocalToRemote") { if ( client.serviceType === "onedrive" && r.sizeLocal === 0 && password === "" ) { // special treatment for empty files for OneDrive // TODO: it's ugly, any other way? // special treatment for OneDrive: do nothing, skip empty file without encryption // if it's empty folder, or it's encrypted file/folder, it continues to be uploaded. } else { const remoteObjMeta = await client.uploadToRemote( r.key, vault, false, password, remoteEncryptedKey ); await upsertSyncMetaMappingDataByVault( client.serviceType, db, r.key, r.mtimeLocal, r.sizeLocal, r.key, remoteObjMeta.lastModified, remoteObjMeta.size, remoteObjMeta.etag, vaultRandomID ); } await clearDeleteRenameHistoryOfKeyAndVault(db, r.key, vaultRandomID); } else if (r.decision === "downloadRemoteToLocal") { await mkdirpInVault(r.key, vault); /* should be unnecessary */ await client.downloadFromRemote( r.key, vault, r.mtimeRemote, password, remoteEncryptedKey ); await clearDeleteRenameHistoryOfKeyAndVault(db, r.key, vaultRandomID); } else if (r.decision === "createFolder") { if (!r.existLocal) { await mkdirpInVault(r.key, vault); } if (!r.existRemote) { const remoteObjMeta = await client.uploadToRemote( r.key, vault, false, password, remoteEncryptedKey ); await upsertSyncMetaMappingDataByVault( client.serviceType, db, r.key, r.mtimeLocal, r.sizeLocal, r.key, remoteObjMeta.lastModified, remoteObjMeta.size, remoteObjMeta.etag, vaultRandomID ); } await clearDeleteRenameHistoryOfKeyAndVault(db, r.key, vaultRandomID); } else if (r.decision === "uploadLocalDelHistToRemoteFolder") { if (r.existLocal) { await localDeleteFunc(r.key); } if (r.existRemote) { await client.deleteFromRemote(r.key, password, remoteEncryptedKey); } await clearDeleteRenameHistoryOfKeyAndVault(db, r.key, vaultRandomID); } else if (r.decision === "keepRemoteDelHistFolder") { if (r.existLocal) { await localDeleteFunc(r.key); } if (r.existRemote) { await client.deleteFromRemote(r.key, password, remoteEncryptedKey); } await clearDeleteRenameHistoryOfKeyAndVault(db, r.key, vaultRandomID); } else if (r.decision === "skipFolder") { // do nothing! } else if (r.decision === "skipUploadingTooLarge") { // do nothing! } else if (r.decision === "skipDownloadingTooLarge") { // do nothing! } else if (r.decision === "skipUsingLocalDelTooLarge") { // do nothing! } else if (r.decision === "skipUsingRemoteDelTooLarge") { // do nothing! } else { throw Error(`unknown decision in ${JSON.stringify(r)}`); } }; const splitThreeSteps = (syncPlan: SyncPlanType, sortedKeys: string[]) => { const mixedStates = syncPlan.mixedStates; const totalCount = sortedKeys.length || 0; const folderCreationOps: FileOrFolderMixedState[][] = []; const deletionOps: FileOrFolderMixedState[][] = []; const uploadDownloads: FileOrFolderMixedState[][] = []; let realTotalCount = 0; for (let i = 0; i < sortedKeys.length; ++i) { const key = sortedKeys[i]; const val: FileOrFolderMixedState = Object.assign({}, mixedStates[key]); // copy to avoid issue if ( val.decision === "skipFolder" || val.decision === "skipUploading" || val.decision === "skipDownloadingTooLarge" || val.decision === "skipUploadingTooLarge" || val.decision === "skipUsingLocalDelTooLarge" || val.decision === "skipUsingRemoteDelTooLarge" ) { // pass } else if (val.decision === "createFolder") { const level = atWhichLevel(key); if (folderCreationOps[level - 1] === undefined) { folderCreationOps[level - 1] = [val]; } else { folderCreationOps[level - 1].push(val); } realTotalCount += 1; } else if ( val.decision === "uploadLocalDelHistToRemoteFolder" || val.decision === "keepRemoteDelHistFolder" || val.decision === "uploadLocalDelHistToRemote" || val.decision === "keepRemoteDelHist" ) { const level = atWhichLevel(key); if (deletionOps[level - 1] === undefined) { deletionOps[level - 1] = [val]; } else { deletionOps[level - 1].push(val); } realTotalCount += 1; } else if ( val.decision === "uploadLocalToRemote" || val.decision === "downloadRemoteToLocal" ) { if (uploadDownloads.length === 0) { uploadDownloads[0] = [val]; } else { uploadDownloads[0].push(val); // only one level needed here } realTotalCount += 1; } else { throw Error(`unknown decision ${val.decision} for ${key}`); } } // the deletionOps should be run from max level to min level // right now it is sorted by level from min to max (NOT length of key!) // so we need to reverse it! deletionOps.reverse(); // inplace reverse return { folderCreationOps: folderCreationOps, deletionOps: deletionOps, uploadDownloads: uploadDownloads, realTotalCount: realTotalCount, }; }; export const doActualSync = async ( client: RemoteClient, db: InternalDBs, vaultRandomID: string, vault: Vault, syncPlan: SyncPlanType, sortedKeys: string[], metadataFile: FileOrFolderMixedState, origMetadata: MetadataOnRemote, sizesGoWrong: FileOrFolderMixedState[], deletions: DeletionOnRemote[], localDeleteFunc: any, password: string = "", concurrency: number = 1, callbackSizesGoWrong?: any, callbackSyncProcess?: any ) => { const mixedStates = syncPlan.mixedStates; const totalCount = sortedKeys.length || 0; if (sizesGoWrong.length > 0) { log.debug(`some sizes are larger than the threshold, abort and show hints`); callbackSizesGoWrong(sizesGoWrong); return; } log.debug(`start syncing extra data firstly`); await uploadExtraMeta( client, metadataFile, origMetadata, deletions, password ); log.debug(`finish syncing extra data firstly`); log.debug(`concurrency === ${concurrency}`); if (concurrency === 1) { // run everything in sequence // good old way for (let i = 0; i < sortedKeys.length; ++i) { const key = sortedKeys[i]; const val = mixedStates[key]; log.debug(`start syncing "${key}" with plan ${JSON.stringify(val)}`); if (callbackSyncProcess !== undefined) { await callbackSyncProcess(i, totalCount, key, val.decision); } await dispatchOperationToActual( key, vaultRandomID, val, client, db, vault, localDeleteFunc, password ); log.debug(`finished ${key}`); } return; // shortcut return, avoid too many nests below } const { folderCreationOps, deletionOps, uploadDownloads, realTotalCount } = splitThreeSteps(syncPlan, sortedKeys); const nested = [folderCreationOps, deletionOps, uploadDownloads]; const logTexts = [ `1. create all folders from shadowest to deepest, also check undefined decision`, `2. delete files and folders from deepest to shadowest`, `3. upload or download files in parallel, with the desired concurrency=${concurrency}`, ]; let realCounter = 0; for (let i = 0; i < nested.length; ++i) { log.debug(logTexts[i]); const operations: FileOrFolderMixedState[][] = nested[i]; for (let j = 0; j < operations.length; ++j) { const singleLevelOps: FileOrFolderMixedState[] | undefined = operations[j]; if (singleLevelOps === undefined || singleLevelOps === null) { continue; } const queue = new PQueue({ concurrency: concurrency, autoStart: true }); const potentialErrors: Error[] = []; let tooManyErrors = false; for (let k = 0; k < singleLevelOps.length; ++k) { const val: FileOrFolderMixedState = singleLevelOps[k]; const key = val.key; const fn = async () => { log.debug(`start syncing "${key}" with plan ${JSON.stringify(val)}`); if (callbackSyncProcess !== undefined) { await callbackSyncProcess( realCounter, realTotalCount, key, val.decision ); realCounter += 1; } await dispatchOperationToActual( key, vaultRandomID, val, client, db, vault, localDeleteFunc, password ); log.debug(`finished ${key}`); }; queue.add(fn).catch((e) => { const msg = `${key}: ${e.message}`; potentialErrors.push(new Error(msg)); if (potentialErrors.length >= 3) { tooManyErrors = true; queue.pause(); queue.clear(); } }); } await queue.onIdle(); if (potentialErrors.length > 0) { if (tooManyErrors) { potentialErrors.push( new Error("too many errors, stop the remaining tasks") ); } throw new AggregateError(potentialErrors); } } } };