|
@@ -3,56 +3,42 @@ import log from "@/next/log";
|
|
|
import { CustomError } from "@ente/shared/error";
|
|
|
import { Events, eventBus } from "@ente/shared/events";
|
|
|
import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
|
|
|
+import { formatDateTimeShort } from "@ente/shared/time/format";
|
|
|
import { User } from "@ente/shared/user/types";
|
|
|
import { sleep } from "@ente/shared/utils";
|
|
|
import QueueProcessor, {
|
|
|
CancellationStatus,
|
|
|
RequestCanceller,
|
|
|
} from "@ente/shared/utils/queueProcessor";
|
|
|
-import { ExportStage } from "constants/export";
|
|
|
import { FILE_TYPE } from "constants/file";
|
|
|
import { Collection } from "types/collection";
|
|
|
import {
|
|
|
+ CollectionExportNames,
|
|
|
ExportProgress,
|
|
|
ExportRecord,
|
|
|
ExportSettings,
|
|
|
ExportUIUpdaters,
|
|
|
+ FileExportNames,
|
|
|
} from "types/export";
|
|
|
import { EnteFile } from "types/file";
|
|
|
+import { Metadata } from "types/upload";
|
|
|
import {
|
|
|
constructCollectionNameMap,
|
|
|
getCollectionUserFacingName,
|
|
|
getNonEmptyPersonalCollections,
|
|
|
} from "utils/collection";
|
|
|
-import {
|
|
|
- convertCollectionIDExportNameObjectToMap,
|
|
|
- convertFileIDExportNameObjectToMap,
|
|
|
- getCollectionExportPath,
|
|
|
- getCollectionExportedFiles,
|
|
|
- getCollectionIDFromFileUID,
|
|
|
- getDeletedExportedCollections,
|
|
|
- getDeletedExportedFiles,
|
|
|
- getExportRecordFileUID,
|
|
|
- getFileExportPath,
|
|
|
- getFileMetadataExportPath,
|
|
|
- getGoogleLikeMetadataFile,
|
|
|
- getLivePhotoExportName,
|
|
|
- getMetadataFileExportPath,
|
|
|
- getMetadataFolderExportPath,
|
|
|
- getRenamedExportedCollections,
|
|
|
- getTrashedFileExportPath,
|
|
|
- getUnExportedFiles,
|
|
|
- getUniqueCollectionExportName,
|
|
|
- getUniqueFileExportName,
|
|
|
- isLivePhotoExportName,
|
|
|
- parseLivePhotoExportName,
|
|
|
-} from "utils/export";
|
|
|
import {
|
|
|
generateStreamFromArrayBuffer,
|
|
|
getPersonalFiles,
|
|
|
getUpdatedEXIFFileForDownload,
|
|
|
mergeMetadata,
|
|
|
+ splitFilenameAndExtension,
|
|
|
} from "utils/file";
|
|
|
+import {
|
|
|
+ ENTE_TRASH_FOLDER,
|
|
|
+ getUniqueCollectionExportName,
|
|
|
+ getUniqueFileExportName,
|
|
|
+} from "utils/native-fs";
|
|
|
import { getAllLocalCollections } from "../collectionService";
|
|
|
import downloadManager from "../download";
|
|
|
import { getAllLocalFiles } from "../fileService";
|
|
@@ -63,6 +49,19 @@ const EXPORT_RECORD_FILE_NAME = "export_status.json";
|
|
|
|
|
|
export const ENTE_EXPORT_DIRECTORY = "ente Photos";
|
|
|
|
|
|
+export const ENTE_METADATA_FOLDER = "metadata";
|
|
|
+
|
|
|
+export enum ExportStage {
|
|
|
+ INIT = 0,
|
|
|
+ MIGRATION = 1,
|
|
|
+ STARTING = 2,
|
|
|
+ EXPORTING_FILES = 3,
|
|
|
+ TRASHING_DELETED_FILES = 4,
|
|
|
+ RENAMING_COLLECTION_FOLDERS = 5,
|
|
|
+ TRASHING_DELETED_COLLECTIONS = 6,
|
|
|
+ FINISHED = 7,
|
|
|
+}
|
|
|
+
|
|
|
export const NULL_EXPORT_RECORD: ExportRecord = {
|
|
|
version: 3,
|
|
|
lastAttemptTimestamp: null,
|
|
@@ -484,11 +483,7 @@ class ExportService {
|
|
|
await this.verifyExportFolderExists(exportFolder);
|
|
|
const oldCollectionExportName =
|
|
|
collectionIDExportNameMap.get(collection.id);
|
|
|
- const oldCollectionExportPath = getCollectionExportPath(
|
|
|
- exportFolder,
|
|
|
- oldCollectionExportName,
|
|
|
- );
|
|
|
-
|
|
|
+ const oldCollectionExportPath = `${exportFolder}/${oldCollectionExportName}`;
|
|
|
const newCollectionExportName =
|
|
|
await getUniqueCollectionExportName(
|
|
|
exportFolder,
|
|
@@ -497,11 +492,7 @@ class ExportService {
|
|
|
log.info(
|
|
|
`renaming collection with id ${collection.id} from ${oldCollectionExportName} to ${newCollectionExportName}`,
|
|
|
);
|
|
|
- const newCollectionExportPath = getCollectionExportPath(
|
|
|
- exportFolder,
|
|
|
- newCollectionExportName,
|
|
|
- );
|
|
|
-
|
|
|
+ const newCollectionExportPath = `${exportFolder}/${newCollectionExportName}`;
|
|
|
await this.addCollectionExportedRecord(
|
|
|
exportFolder,
|
|
|
collection.id,
|
|
@@ -587,10 +578,7 @@ class ExportService {
|
|
|
"collection is not empty, can't remove",
|
|
|
);
|
|
|
}
|
|
|
- const collectionExportPath = getCollectionExportPath(
|
|
|
- exportFolder,
|
|
|
- collectionExportName,
|
|
|
- );
|
|
|
+ const collectionExportPath = `${exportFolder}/${collectionExportName}`;
|
|
|
await this.removeCollectionExportedRecord(
|
|
|
exportFolder,
|
|
|
collectionID,
|
|
@@ -682,10 +670,7 @@ class ExportService {
|
|
|
collectionExportName,
|
|
|
);
|
|
|
}
|
|
|
- const collectionExportPath = getCollectionExportPath(
|
|
|
- exportDir,
|
|
|
- collectionExportName,
|
|
|
- );
|
|
|
+ const collectionExportPath = `${exportDir}/${collectionExportName}`;
|
|
|
await ensureElectron().checkExistsAndCreateDir(
|
|
|
collectionExportPath,
|
|
|
);
|
|
@@ -750,10 +735,10 @@ class ExportService {
|
|
|
try {
|
|
|
const fileExportName = fileIDExportNameMap.get(fileUID);
|
|
|
const collectionID = getCollectionIDFromFileUID(fileUID);
|
|
|
- const collectionExportPath = getCollectionExportPath(
|
|
|
- exportDir,
|
|
|
- collectionIDExportNameMap.get(collectionID),
|
|
|
- );
|
|
|
+ const collectionExportName =
|
|
|
+ collectionIDExportNameMap.get(collectionID);
|
|
|
+ const collectionExportPath = `${exportDir}/${collectionExportName}`;
|
|
|
+
|
|
|
await this.removeFileExportedRecord(exportDir, fileUID);
|
|
|
try {
|
|
|
if (isLivePhotoExportName(fileExportName)) {
|
|
@@ -761,10 +746,7 @@ class ExportService {
|
|
|
image: imageExportName,
|
|
|
video: videoExportName,
|
|
|
} = parseLivePhotoExportName(fileExportName);
|
|
|
- const imageExportPath = getFileExportPath(
|
|
|
- collectionExportPath,
|
|
|
- imageExportName,
|
|
|
- );
|
|
|
+ const imageExportPath = `${collectionExportPath}/${imageExportName}`;
|
|
|
log.info(
|
|
|
`moving image file ${imageExportPath} to trash folder`,
|
|
|
);
|
|
@@ -793,10 +775,7 @@ class ExportService {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- const videoExportPath = getFileExportPath(
|
|
|
- collectionExportPath,
|
|
|
- videoExportName,
|
|
|
- );
|
|
|
+ const videoExportPath = `${collectionExportPath}/${videoExportName}`;
|
|
|
log.info(
|
|
|
`moving video file ${videoExportPath} to trash folder`,
|
|
|
);
|
|
@@ -823,10 +802,7 @@ class ExportService {
|
|
|
);
|
|
|
}
|
|
|
} else {
|
|
|
- const fileExportPath = getFileExportPath(
|
|
|
- collectionExportPath,
|
|
|
- fileExportName,
|
|
|
- );
|
|
|
+ const fileExportPath = `${collectionExportPath}/${fileExportName}`;
|
|
|
const trashedFilePath =
|
|
|
await getTrashedFileExportPath(
|
|
|
exportDir,
|
|
@@ -1037,10 +1013,7 @@ class ExportService {
|
|
|
exportFolder,
|
|
|
collectionName,
|
|
|
);
|
|
|
- const collectionExportPath = getCollectionExportPath(
|
|
|
- exportFolder,
|
|
|
- collectionExportName,
|
|
|
- );
|
|
|
+ const collectionExportPath = `${exportFolder}/${collectionExportName}`;
|
|
|
await ensureElectron().checkExistsAndCreateDir(collectionExportPath);
|
|
|
await ensureElectron().checkExistsAndCreateDir(
|
|
|
getMetadataFolderExportPath(collectionExportPath),
|
|
@@ -1090,7 +1063,7 @@ class ExportService {
|
|
|
file,
|
|
|
);
|
|
|
await ensureElectron().saveStreamToDisk(
|
|
|
- getFileExportPath(collectionExportPath, fileExportName),
|
|
|
+ `${collectionExportPath}/${fileExportName}`,
|
|
|
updatedFileStream,
|
|
|
);
|
|
|
} catch (e) {
|
|
@@ -1138,7 +1111,7 @@ class ExportService {
|
|
|
file,
|
|
|
);
|
|
|
await ensureElectron().saveStreamToDisk(
|
|
|
- getFileExportPath(collectionExportPath, imageExportName),
|
|
|
+ `${collectionExportPath}/${imageExportName}`,
|
|
|
imageStream,
|
|
|
);
|
|
|
|
|
@@ -1150,12 +1123,12 @@ class ExportService {
|
|
|
);
|
|
|
try {
|
|
|
await ensureElectron().saveStreamToDisk(
|
|
|
- getFileExportPath(collectionExportPath, videoExportName),
|
|
|
+ `${collectionExportPath}/${videoExportName}`,
|
|
|
videoStream,
|
|
|
);
|
|
|
} catch (e) {
|
|
|
await ensureElectron().deleteFile(
|
|
|
- getFileExportPath(collectionExportPath, imageExportName),
|
|
|
+ `${collectionExportPath}/${imageExportName}`,
|
|
|
);
|
|
|
throw e;
|
|
|
}
|
|
@@ -1218,4 +1191,261 @@ class ExportService {
|
|
|
return exportRecord;
|
|
|
};
|
|
|
}
|
|
|
-export default new ExportService();
|
|
|
+
|
|
|
+const exportService = new ExportService();
|
|
|
+
|
|
|
+export default exportService;
|
|
|
+
|
|
|
+/**
|
|
|
+ * If there are any in-progress exports, or if continuous exports are enabled,
|
|
|
+ * resume them.
|
|
|
+ */
|
|
|
+export const resumeExportsIfNeeded = async () => {
|
|
|
+ const exportSettings = exportService.getExportSettings();
|
|
|
+ if (!(await exportService.exportFolderExists(exportSettings?.folder))) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const exportRecord = await exportService.getExportRecord(
|
|
|
+ exportSettings.folder,
|
|
|
+ );
|
|
|
+ if (exportSettings.continuousExport) {
|
|
|
+ exportService.enableContinuousExport();
|
|
|
+ }
|
|
|
+ if (isExportInProgress(exportRecord.stage)) {
|
|
|
+ log.debug(() => "Resuming in-progress export");
|
|
|
+ exportService.scheduleExport();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+export const getExportRecordFileUID = (file: EnteFile) =>
|
|
|
+ `${file.id}_${file.collectionID}_${file.updationTime}`;
|
|
|
+
|
|
|
+export const getCollectionIDFromFileUID = (fileUID: string) =>
|
|
|
+ Number(fileUID.split("_")[1]);
|
|
|
+
|
|
|
+const convertCollectionIDExportNameObjectToMap = (
|
|
|
+ collectionExportNames: CollectionExportNames,
|
|
|
+): Map<number, string> => {
|
|
|
+ return new Map<number, string>(
|
|
|
+ Object.entries(collectionExportNames ?? {}).map((e) => {
|
|
|
+ return [Number(e[0]), String(e[1])];
|
|
|
+ }),
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+const convertFileIDExportNameObjectToMap = (
|
|
|
+ fileExportNames: FileExportNames,
|
|
|
+): Map<string, string> => {
|
|
|
+ return new Map<string, string>(
|
|
|
+ Object.entries(fileExportNames ?? {}).map((e) => {
|
|
|
+ return [String(e[0]), String(e[1])];
|
|
|
+ }),
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+const getRenamedExportedCollections = (
|
|
|
+ collections: Collection[],
|
|
|
+ exportRecord: ExportRecord,
|
|
|
+) => {
|
|
|
+ if (!exportRecord?.collectionExportNames) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ const collectionIDExportNameMap = convertCollectionIDExportNameObjectToMap(
|
|
|
+ exportRecord.collectionExportNames,
|
|
|
+ );
|
|
|
+ const renamedCollections = collections.filter((collection) => {
|
|
|
+ if (collectionIDExportNameMap.has(collection.id)) {
|
|
|
+ const currentExportName = collectionIDExportNameMap.get(
|
|
|
+ collection.id,
|
|
|
+ );
|
|
|
+
|
|
|
+ const collectionExportName =
|
|
|
+ getCollectionUserFacingName(collection);
|
|
|
+
|
|
|
+ if (currentExportName === collectionExportName) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ const hasNumberedSuffix = currentExportName.match(/\(\d+\)$/);
|
|
|
+ const currentExportNameWithoutNumberedSuffix = hasNumberedSuffix
|
|
|
+ ? currentExportName.replace(/\(\d+\)$/, "")
|
|
|
+ : currentExportName;
|
|
|
+
|
|
|
+ return (
|
|
|
+ collectionExportName !== currentExportNameWithoutNumberedSuffix
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ });
|
|
|
+ return renamedCollections;
|
|
|
+};
|
|
|
+
|
|
|
+const getDeletedExportedCollections = (
|
|
|
+ collections: Collection[],
|
|
|
+ exportRecord: ExportRecord,
|
|
|
+) => {
|
|
|
+ if (!exportRecord?.collectionExportNames) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ const presentCollections = new Set(
|
|
|
+ collections.map((collection) => collection.id),
|
|
|
+ );
|
|
|
+ const deletedExportedCollections = Object.keys(
|
|
|
+ exportRecord?.collectionExportNames,
|
|
|
+ )
|
|
|
+ .map(Number)
|
|
|
+ .filter((collectionID) => {
|
|
|
+ if (!presentCollections.has(collectionID)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ });
|
|
|
+ return deletedExportedCollections;
|
|
|
+};
|
|
|
+
|
|
|
+const getUnExportedFiles = (
|
|
|
+ allFiles: EnteFile[],
|
|
|
+ exportRecord: ExportRecord,
|
|
|
+) => {
|
|
|
+ if (!exportRecord?.fileExportNames) {
|
|
|
+ return allFiles;
|
|
|
+ }
|
|
|
+ const exportedFiles = new Set(Object.keys(exportRecord?.fileExportNames));
|
|
|
+ const unExportedFiles = allFiles.filter((file) => {
|
|
|
+ if (!exportedFiles.has(getExportRecordFileUID(file))) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ });
|
|
|
+ return unExportedFiles;
|
|
|
+};
|
|
|
+
|
|
|
+const getDeletedExportedFiles = (
|
|
|
+ allFiles: EnteFile[],
|
|
|
+ exportRecord: ExportRecord,
|
|
|
+): string[] => {
|
|
|
+ if (!exportRecord?.fileExportNames) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ const presentFileUIDs = new Set(
|
|
|
+ allFiles?.map((file) => getExportRecordFileUID(file)),
|
|
|
+ );
|
|
|
+ const deletedExportedFiles = Object.keys(
|
|
|
+ exportRecord?.fileExportNames,
|
|
|
+ ).filter((fileUID) => {
|
|
|
+ if (!presentFileUIDs.has(fileUID)) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ });
|
|
|
+ return deletedExportedFiles;
|
|
|
+};
|
|
|
+
|
|
|
+const getCollectionExportedFiles = (
|
|
|
+ exportRecord: ExportRecord,
|
|
|
+ collectionID: number,
|
|
|
+): string[] => {
|
|
|
+ if (!exportRecord?.fileExportNames) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ const collectionExportedFiles = Object.keys(
|
|
|
+ exportRecord?.fileExportNames,
|
|
|
+ ).filter((fileUID) => {
|
|
|
+ const fileCollectionID = Number(fileUID.split("_")[1]);
|
|
|
+ if (fileCollectionID === collectionID) {
|
|
|
+ return true;
|
|
|
+ } else {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return collectionExportedFiles;
|
|
|
+};
|
|
|
+
|
|
|
+const getGoogleLikeMetadataFile = (fileExportName: string, file: EnteFile) => {
|
|
|
+ const metadata: Metadata = file.metadata;
|
|
|
+ const creationTime = Math.floor(metadata.creationTime / 1000000);
|
|
|
+ const modificationTime = Math.floor(
|
|
|
+ (metadata.modificationTime ?? metadata.creationTime) / 1000000,
|
|
|
+ );
|
|
|
+ const captionValue: string = file?.pubMagicMetadata?.data?.caption;
|
|
|
+ return JSON.stringify(
|
|
|
+ {
|
|
|
+ title: fileExportName,
|
|
|
+ caption: captionValue,
|
|
|
+ creationTime: {
|
|
|
+ timestamp: creationTime,
|
|
|
+ formatted: formatDateTimeShort(creationTime * 1000),
|
|
|
+ },
|
|
|
+ modificationTime: {
|
|
|
+ timestamp: modificationTime,
|
|
|
+ formatted: formatDateTimeShort(modificationTime * 1000),
|
|
|
+ },
|
|
|
+ geoData: {
|
|
|
+ latitude: metadata.latitude,
|
|
|
+ longitude: metadata.longitude,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ null,
|
|
|
+ 2,
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export const getMetadataFolderExportPath = (collectionExportPath: string) =>
|
|
|
+ `${collectionExportPath}/${ENTE_METADATA_FOLDER}`;
|
|
|
+
|
|
|
+const getFileMetadataExportPath = (
|
|
|
+ collectionExportPath: string,
|
|
|
+ fileExportName: string,
|
|
|
+) => `${collectionExportPath}/${ENTE_METADATA_FOLDER}/${fileExportName}.json`;
|
|
|
+
|
|
|
+const getTrashedFileExportPath = async (exportDir: string, path: string) => {
|
|
|
+ const fileRelativePath = path.replace(`${exportDir}/`, "");
|
|
|
+ let trashedFilePath = `${exportDir}/${ENTE_TRASH_FOLDER}/${fileRelativePath}`;
|
|
|
+ let count = 1;
|
|
|
+ while (await exportService.exists(trashedFilePath)) {
|
|
|
+ const trashedFilePathParts = splitFilenameAndExtension(trashedFilePath);
|
|
|
+ if (trashedFilePathParts[1]) {
|
|
|
+ trashedFilePath = `${trashedFilePathParts[0]}(${count}).${trashedFilePathParts[1]}`;
|
|
|
+ } else {
|
|
|
+ trashedFilePath = `${trashedFilePathParts[0]}(${count})`;
|
|
|
+ }
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ return trashedFilePath;
|
|
|
+};
|
|
|
+
|
|
|
+// if filepath is /home/user/Ente/Export/Collection1/1.jpg
|
|
|
+// then metadata path is /home/user/Ente/Export/Collection1/ENTE_METADATA_FOLDER/1.jpg.json
|
|
|
+const getMetadataFileExportPath = (filePath: string) => {
|
|
|
+ // extract filename and collection folder path
|
|
|
+ const filename = filePath.split("/").pop();
|
|
|
+ const collectionExportPath = filePath.replace(`/${filename}`, "");
|
|
|
+ return `${collectionExportPath}/${ENTE_METADATA_FOLDER}/${filename}.json`;
|
|
|
+};
|
|
|
+
|
|
|
+export const getLivePhotoExportName = (
|
|
|
+ imageExportName: string,
|
|
|
+ videoExportName: string,
|
|
|
+) =>
|
|
|
+ JSON.stringify({
|
|
|
+ image: imageExportName,
|
|
|
+ video: videoExportName,
|
|
|
+ });
|
|
|
+
|
|
|
+export const isLivePhotoExportName = (exportName: string) => {
|
|
|
+ try {
|
|
|
+ JSON.parse(exportName);
|
|
|
+ return true;
|
|
|
+ } catch (e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const parseLivePhotoExportName = (
|
|
|
+ livePhotoExportName: string,
|
|
|
+): { image: string; video: string } => {
|
|
|
+ const { image, video } = JSON.parse(livePhotoExportName);
|
|
|
+ return { image, video };
|
|
|
+};
|
|
|
+
|
|
|
+const isExportInProgress = (exportStage: ExportStage) =>
|
|
|
+ exportStage > ExportStage.INIT && exportStage < ExportStage.FINISHED;
|