Merge branch 'master' into upload-types-refactor

This commit is contained in:
Abhinav 2022-01-04 12:20:28 +05:30
commit 530f40f5a0
17 changed files with 888 additions and 231 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "bada-frame", "name": "bada-frame",
"version": "0.3.44", "version": "0.4.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@ -38,9 +38,10 @@
"jszip": "3.7.1", "jszip": "3.7.1",
"libsodium-wrappers": "^0.7.8", "libsodium-wrappers": "^0.7.8",
"localforage": "^1.9.0", "localforage": "^1.9.0",
"next": "^11.1.2", "next": "^11.1.3",
"node-forge": "^0.10.0", "node-forge": "^0.10.0",
"photoswipe": "file:./thirdparty/photoswipe", "photoswipe": "file:./thirdparty/photoswipe",
"piexifjs": "^1.0.6",
"react": "^17.0.2", "react": "^17.0.2",
"react-bootstrap": "^1.3.0", "react-bootstrap": "^1.3.0",
"react-burger-menu": "^3.0.4", "react-burger-menu": "^3.0.4",

View file

@ -29,8 +29,8 @@ export default function ExportFinished(props: Props) {
padding: '0 5%', padding: '0 5%',
}}> }}>
<Row> <Row>
<Label width="40%">{constants.LAST_EXPORT_TIME}</Label> <Label width="35%">{constants.LAST_EXPORT_TIME}</Label>
<Value width="60%"> <Value width="65%">
{formatDateTime(props.lastExportTime)} {formatDateTime(props.lastExportTime)}
</Value> </Value>
</Row> </Row>
@ -38,7 +38,7 @@ export default function ExportFinished(props: Props) {
<Label width="60%"> <Label width="60%">
{constants.SUCCESSFULLY_EXPORTED_FILES} {constants.SUCCESSFULLY_EXPORTED_FILES}
</Label> </Label>
<Value width="35%"> <Value width="40%">
<ComfySpan> <ComfySpan>
{props.exportStats.success} / {totalFiles} {props.exportStats.success} / {totalFiles}
</ComfySpan> </ComfySpan>

View file

@ -8,9 +8,11 @@ import exportService, {
ExportType, ExportType,
} from 'services/exportService'; } from 'services/exportService';
import { getLocalFiles } from 'services/fileService'; import { getLocalFiles } from 'services/fileService';
import { User } from 'services/userService';
import styled from 'styled-components'; import styled from 'styled-components';
import { sleep } from 'utils/common'; import { sleep } from 'utils/common';
import { getExportRecordFileUID } from 'utils/export'; import { getExportRecordFileUID } from 'utils/export';
import { logError } from 'utils/sentry';
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage'; import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
import constants from 'utils/strings/constants'; import constants from 'utils/strings/constants';
import { Label, Row, Value } from './Container'; import { Label, Row, Value } from './Container';
@ -105,30 +107,41 @@ export default function ExportModal(props: Props) {
return; return;
} }
const main = async () => { const main = async () => {
const user: User = getData(LS_KEYS.USER);
if (exportStage === ExportStage.FINISHED) { if (exportStage === ExportStage.FINISHED) {
const localFiles = await getLocalFiles(); try {
const exportRecord = await exportService.getExportRecord(); const localFiles = await getLocalFiles();
const exportedFileCnt = exportRecord.exportedFiles.length; const userPersonalFiles = localFiles.filter(
const failedFilesCnt = exportRecord.failedFiles.length; (file) => file.ownerID === user?.id
const syncedFilesCnt = localFiles.length;
if (syncedFilesCnt > exportedFileCnt + failedFilesCnt) {
updateExportProgress({
current: exportedFileCnt + failedFilesCnt,
total: syncedFilesCnt,
});
const exportFileUIDs = new Set([
...exportRecord.exportedFiles,
...exportRecord.failedFiles,
]);
const unExportedFiles = localFiles.filter(
(file) =>
!exportFileUIDs.has(getExportRecordFileUID(file))
); );
exportService.addFilesQueuedRecord( const exportRecord = await exportService.getExportRecord();
exportFolder, const exportedFileCnt = exportRecord.exportedFiles?.length;
unExportedFiles const failedFilesCnt = exportRecord.failedFiles?.length;
); const syncedFilesCnt = userPersonalFiles.length;
updateExportStage(ExportStage.PAUSED); if (syncedFilesCnt > exportedFileCnt + failedFilesCnt) {
updateExportProgress({
current: exportedFileCnt + failedFilesCnt,
total: syncedFilesCnt,
});
const exportFileUIDs = new Set([
...exportRecord.exportedFiles,
...exportRecord.failedFiles,
]);
const unExportedFiles = userPersonalFiles.filter(
(file) =>
!exportFileUIDs.has(
getExportRecordFileUID(file)
)
);
exportService.addFilesQueuedRecord(
exportFolder,
unExportedFiles
);
updateExportStage(ExportStage.PAUSED);
}
} catch (e) {
setExportStage(ExportStage.INIT);
logError(e, 'error while updating exportModal on reopen');
} }
} }
}; };
@ -154,7 +167,7 @@ export default function ExportModal(props: Props) {
const updateExportTime = (newTime: number) => { const updateExportTime = (newTime: number) => {
setLastExportTime(newTime); setLastExportTime(newTime);
exportService.updateExportRecord({ time: newTime }); exportService.updateExportRecord({ lastAttemptTimestamp: newTime });
}; };
const updateExportProgress = (newProgress: ExportProgress) => { const updateExportProgress = (newProgress: ExportProgress) => {
@ -178,8 +191,8 @@ export default function ExportModal(props: Props) {
updateExportStage(ExportStage.INPROGRESS); updateExportStage(ExportStage.INPROGRESS);
await sleep(100); await sleep(100);
}; };
const postExportRun = async (paused: Boolean) => { const postExportRun = async (exportResult?: { paused?: boolean }) => {
if (!paused) { if (!exportResult?.paused) {
updateExportStage(ExportStage.FINISHED); updateExportStage(ExportStage.FINISHED);
await sleep(100); await sleep(100);
updateExportTime(Date.now()); updateExportTime(Date.now());
@ -189,22 +202,22 @@ export default function ExportModal(props: Props) {
const startExport = async () => { const startExport = async () => {
await preExportRun(); await preExportRun();
updateExportProgress({ current: 0, total: 0 }); updateExportProgress({ current: 0, total: 0 });
const { paused } = await exportService.exportFiles( const exportResult = await exportService.exportFiles(
updateExportProgress, updateExportProgress,
ExportType.NEW ExportType.NEW
); );
await postExportRun(paused); await postExportRun(exportResult);
}; };
const stopExport = async () => { const stopExport = async () => {
exportService.stopRunningExport(); exportService.stopRunningExport();
postExportRun(false); postExportRun();
}; };
const pauseExport = () => { const pauseExport = () => {
updateExportStage(ExportStage.PAUSED); updateExportStage(ExportStage.PAUSED);
exportService.pauseRunningExport(); exportService.pauseRunningExport();
postExportRun(true); postExportRun({ paused: true });
}; };
const resumeExport = async () => { const resumeExport = async () => {
@ -219,23 +232,23 @@ export default function ExportModal(props: Props) {
current: pausedStageProgress.current + progress.current, current: pausedStageProgress.current + progress.current,
total: pausedStageProgress.current + progress.total, total: pausedStageProgress.current + progress.total,
}); });
const { paused } = await exportService.exportFiles( const exportResult = await exportService.exportFiles(
updateExportStatsWithOffset, updateExportStatsWithOffset,
ExportType.PENDING ExportType.PENDING
); );
await postExportRun(paused); await postExportRun(exportResult);
}; };
const retryFailedExport = async () => { const retryFailedExport = async () => {
await preExportRun(); await preExportRun();
updateExportProgress({ current: 0, total: exportStats.failed }); updateExportProgress({ current: 0, total: exportStats.failed });
const { paused } = await exportService.exportFiles( const exportResult = await exportService.exportFiles(
updateExportProgress, updateExportProgress,
ExportType.RETRY_FAILED ExportType.RETRY_FAILED
); );
await postExportRun(paused); await postExportRun(exportResult);
}; };
const syncExportStatsWithReport = async () => { const syncExportStatsWithReport = async () => {
@ -327,11 +340,9 @@ export default function ExportModal(props: Props) {
</Button> </Button>
) : ( ) : (
<> <>
{/* <span style={{ overflow: 'hidden', direction: 'rtl', height: '1.5rem', width: '90%', whiteSpace: 'nowrap' }}> */}
<ExportFolderPathContainer> <ExportFolderPathContainer>
{exportFolder} {exportFolder}
</ExportFolderPathContainer> </ExportFolderPathContainer>
{/* </span> */}
{(exportStage === ExportStage.FINISHED || {(exportStage === ExportStage.FINISHED ||
exportStage === ExportStage.INIT) && ( exportStage === ExportStage.INIT) && (
<FolderIconWrapper <FolderIconWrapper

View file

@ -6,18 +6,20 @@ import {
deleteCollection, deleteCollection,
renameCollection, renameCollection,
} from 'services/collectionService'; } from 'services/collectionService';
import { getSelectedCollection } from 'utils/collection'; import { downloadCollection, getSelectedCollection } from 'utils/collection';
import constants from 'utils/strings/constants'; import constants from 'utils/strings/constants';
import { SetCollectionNamerAttributes } from './CollectionNamer'; import { SetCollectionNamerAttributes } from './CollectionNamer';
import LinkButton, { ButtonVariant, LinkButtonProps } from './LinkButton'; import LinkButton, { ButtonVariant, LinkButtonProps } from './LinkButton';
import { sleep } from 'utils/common';
interface Props { interface CollectionOptionsProps {
syncWithRemote: () => Promise<void>; syncWithRemote: () => Promise<void>;
setCollectionNamerAttributes: SetCollectionNamerAttributes; setCollectionNamerAttributes: SetCollectionNamerAttributes;
collections: Collection[]; collections: Collection[];
selectedCollectionID: number; selectedCollectionID: number;
setDialogMessage: SetDialogMessage; setDialogMessage: SetDialogMessage;
startLoadingBar: () => void; startLoading: () => void;
finishLoading: () => void;
showCollectionShareModal: () => void; showCollectionShareModal: () => void;
redirectToAll: () => void; redirectToAll: () => void;
} }
@ -43,7 +45,7 @@ export const MenuItem = (props: { children: any }) => (
</ListGroup.Item> </ListGroup.Item>
); );
const CollectionOptions = (props: Props) => { const CollectionOptions = (props: CollectionOptionsProps) => {
const collectionRename = async ( const collectionRename = async (
selectedCollection: Collection, selectedCollection: Collection,
newName: string newName: string
@ -62,7 +64,7 @@ const CollectionOptions = (props: Props) => {
props.collections props.collections
)?.name, )?.name,
callback: (newName) => { callback: (newName) => {
props.startLoadingBar(); props.startLoading();
collectionRename( collectionRename(
getSelectedCollection( getSelectedCollection(
props.selectedCollectionID, props.selectedCollectionID,
@ -81,7 +83,7 @@ const CollectionOptions = (props: Props) => {
proceed: { proceed: {
text: constants.DELETE_COLLECTION, text: constants.DELETE_COLLECTION,
action: () => { action: () => {
props.startLoadingBar(); props.startLoading();
deleteCollection( deleteCollection(
props.selectedCollectionID, props.selectedCollectionID,
props.syncWithRemote, props.syncWithRemote,
@ -97,6 +99,32 @@ const CollectionOptions = (props: Props) => {
}); });
}; };
const confirmDownloadCollection = () => {
props.setDialogMessage({
title: constants.CONFIRM_DOWNLOAD_COLLECTION,
content: constants.DOWNLOAD_COLLECTION_MESSAGE(),
staticBackdrop: true,
proceed: {
text: constants.DOWNLOAD,
action: downloadCollectionHelper,
variant: 'success',
},
close: {
text: constants.CANCEL,
},
});
};
const downloadCollectionHelper = async () => {
props.startLoading();
await downloadCollection(
props.selectedCollectionID,
props.setDialogMessage
);
await sleep(1000);
props.finishLoading();
};
return ( return (
<Popover id="collection-options" style={{ borderRadius: '10px' }}> <Popover id="collection-options" style={{ borderRadius: '10px' }}>
<Popover.Content style={{ padding: 0, border: 'none' }}> <Popover.Content style={{ padding: 0, border: 'none' }}>
@ -111,6 +139,11 @@ const CollectionOptions = (props: Props) => {
{constants.SHARE} {constants.SHARE}
</MenuLink> </MenuLink>
</MenuItem> </MenuItem>
<MenuItem>
<MenuLink onClick={confirmDownloadCollection}>
{constants.DOWNLOAD}
</MenuLink>
</MenuItem>
<MenuItem> <MenuItem>
<MenuLink <MenuLink
variant={ButtonVariant.danger} variant={ButtonVariant.danger}

View file

@ -35,7 +35,8 @@ interface CollectionProps {
setDialogMessage: SetDialogMessage; setDialogMessage: SetDialogMessage;
syncWithRemote: () => Promise<void>; syncWithRemote: () => Promise<void>;
setCollectionNamerAttributes: SetCollectionNamerAttributes; setCollectionNamerAttributes: SetCollectionNamerAttributes;
startLoadingBar: () => void; startLoading: () => void;
finishLoading: () => void;
isInSearchMode: boolean; isInSearchMode: boolean;
collectionFilesCount: Map<number, number>; collectionFilesCount: Map<number, number>;
} }
@ -169,7 +170,8 @@ export default function Collections(props: CollectionProps) {
collections: props.collections, collections: props.collections,
selectedCollectionID, selectedCollectionID,
setDialogMessage: props.setDialogMessage, setDialogMessage: props.setDialogMessage,
startLoadingBar: props.startLoadingBar, startLoading: props.startLoading,
finishLoading: props.finishLoading,
showCollectionShareModal: setCollectionShareModalView.bind(null, true), showCollectionShareModal: setCollectionShareModalView.bind(null, true),
redirectToAll: setActiveCollection.bind(null, ALL_SECTION), redirectToAll: setActiveCollection.bind(null, ALL_SECTION),
}); });

View file

@ -362,12 +362,17 @@ export default function Gallery() {
setSelected({ count: 0, collectionID: 0 }); setSelected({ count: 0, collectionID: 0 });
}; };
const startLoading = () =>
!syncInProgress.current && loadingBar.current?.continuousStart();
const finishLoading = () =>
!syncInProgress.current && loadingBar.current.complete();
if (!files) { if (!files) {
return <div />; return <div />;
} }
const collectionOpsHelper = const collectionOpsHelper =
(ops: COLLECTION_OPS_TYPE) => async (collection: Collection) => { (ops: COLLECTION_OPS_TYPE) => async (collection: Collection) => {
loadingBar.current?.continuousStart(); startLoading();
try { try {
await handleCollectionOps( await handleCollectionOps(
ops, ops,
@ -388,14 +393,14 @@ export default function Gallery() {
}); });
} finally { } finally {
await syncWithRemote(false, true); await syncWithRemote(false, true);
loadingBar.current.complete(); finishLoading();
} }
}; };
const changeFilesVisibilityHelper = async ( const changeFilesVisibilityHelper = async (
visibility: VISIBILITY_STATE visibility: VISIBILITY_STATE
) => { ) => {
loadingBar.current?.continuousStart(); startLoading();
try { try {
const updatedFiles = await changeFilesVisibility( const updatedFiles = await changeFilesVisibility(
files, files,
@ -424,7 +429,7 @@ export default function Gallery() {
}); });
} finally { } finally {
await syncWithRemote(false, true); await syncWithRemote(false, true);
loadingBar.current.complete(); finishLoading();
} }
}; };
@ -458,7 +463,7 @@ export default function Gallery() {
}; };
const deleteFileHelper = async (permanent?: boolean) => { const deleteFileHelper = async (permanent?: boolean) => {
loadingBar.current?.continuousStart(); startLoading();
try { try {
const selectedFiles = getSelectedFiles(selected, files); const selectedFiles = getSelectedFiles(selected, files);
if (permanent) { if (permanent) {
@ -489,7 +494,7 @@ export default function Gallery() {
}); });
} finally { } finally {
await syncWithRemote(false, true); await syncWithRemote(false, true);
loadingBar.current.complete(); finishLoading();
} }
}; };
@ -519,7 +524,7 @@ export default function Gallery() {
close: { text: constants.CANCEL }, close: { text: constants.CANCEL },
}); });
const emptyTrashHelper = async () => { const emptyTrashHelper = async () => {
loadingBar.current?.continuousStart(); startLoading();
try { try {
await emptyTrash(); await emptyTrash();
if (selected.collectionID === TRASH_SECTION) { if (selected.collectionID === TRASH_SECTION) {
@ -536,7 +541,7 @@ export default function Gallery() {
}); });
} finally { } finally {
await syncWithRemote(false, true); await syncWithRemote(false, true);
loadingBar.current.complete(); finishLoading();
} }
}; };
@ -549,9 +554,9 @@ export default function Gallery() {
const downloadHelper = async () => { const downloadHelper = async () => {
const selectedFiles = getSelectedFiles(selected, files); const selectedFiles = getSelectedFiles(selected, files);
clearSelection(); clearSelection();
!syncInProgress.current && loadingBar.current?.continuousStart(); startLoading();
await downloadFiles(selectedFiles); await downloadFiles(selectedFiles);
!syncInProgress.current && loadingBar.current.complete(); finishLoading();
}; };
return ( return (
@ -609,7 +614,8 @@ export default function Gallery() {
syncWithRemote={syncWithRemote} syncWithRemote={syncWithRemote}
setDialogMessage={setDialogMessage} setDialogMessage={setDialogMessage}
setCollectionNamerAttributes={setCollectionNamerAttributes} setCollectionNamerAttributes={setCollectionNamerAttributes}
startLoadingBar={loadingBar.current?.continuousStart} startLoading={startLoading}
finishLoading={finishLoading}
collectionFilesCount={collectionFilesCount} collectionFilesCount={collectionFilesCount}
/> />
<CollectionNamer <CollectionNamer

View file

@ -1,11 +1,23 @@
import { runningInBrowser } from 'utils/common'; import { runningInBrowser } from 'utils/common';
import { import {
getExportPendingFiles, getExportQueuedFiles,
getExportFailedFiles, getExportFailedFiles,
getFilesUploadedAfterLastExport, getFilesUploadedAfterLastExport,
dedupe, dedupe,
getGoogleLikeMetadataFile, getGoogleLikeMetadataFile,
getExportRecordFileUID, getExportRecordFileUID,
getUniqueCollectionFolderPath,
getUniqueFileSaveName,
getOldFileSavePath,
getOldCollectionFolderPath,
getFileMetadataSavePath,
getFileSavePath,
getOldFileMetadataSavePath,
getExportedFiles,
getMetadataFolderPath,
getCollectionsCreatedAfterLastExport,
getCollectionsRenamedAfterLastExport,
getCollectionIDPathMapFromExportRecord,
} from 'utils/export'; } from 'utils/export';
import { retryAsyncFunction } from 'utils/network'; import { retryAsyncFunction } from 'utils/network';
import { logError } from 'utils/sentry'; import { logError } from 'utils/sentry';
@ -21,24 +33,40 @@ import { decodeMotionPhoto } from './motionPhotoService';
import { import {
fileNameWithoutExtension, fileNameWithoutExtension,
generateStreamFromArrayBuffer, generateStreamFromArrayBuffer,
getFileExtension,
mergeMetadata,
TYPE_JPEG,
TYPE_JPG,
} from 'utils/file'; } from 'utils/file';
import { User } from './userService';
import { updateFileCreationDateInEXIF } from './upload/exifService';
import { MetadataObject } from './upload/uploadService';
import QueueProcessor from './upload/queueProcessor';
export type CollectionIDPathMap = Map<number, string>;
export interface ExportProgress { export interface ExportProgress {
current: number; current: number;
total: number; total: number;
} }
export interface ExportedCollectionPaths {
[collectionID: number]: string;
}
export interface ExportStats { export interface ExportStats {
failed: number; failed: number;
success: number; success: number;
} }
const LATEST_EXPORT_VERSION = 1;
export interface ExportRecord { export interface ExportRecord {
stage: ExportStage; version?: number;
lastAttemptTimestamp: number; stage?: ExportStage;
progress: ExportProgress; lastAttemptTimestamp?: number;
queuedFiles: string[]; progress?: ExportProgress;
exportedFiles: string[]; queuedFiles?: string[];
failedFiles: string[]; exportedFiles?: string[];
failedFiles?: string[];
exportedCollectionPaths?: ExportedCollectionPaths;
} }
export enum ExportStage { export enum ExportStage {
INIT, INIT,
@ -74,12 +102,14 @@ class ExportService {
ElectronAPIs: any; ElectronAPIs: any;
private exportInProgress: Promise<{ paused: boolean }> = null; private exportInProgress: Promise<{ paused: boolean }> = null;
private recordUpdateInProgress = Promise.resolve(); private exportRecordUpdater = new QueueProcessor<void>(1);
private stopExport: boolean = false; private stopExport: boolean = false;
private pauseExport: boolean = false; private pauseExport: boolean = false;
private allElectronAPIsExist: boolean = false;
constructor() { constructor() {
this.ElectronAPIs = runningInBrowser() && window['ElectronAPIs']; this.ElectronAPIs = runningInBrowser() && window['ElectronAPIs'];
this.allElectronAPIsExist = !!this.ElectronAPIs?.exists;
} }
async selectExportDirectory() { async selectExportDirectory() {
return await this.ElectronAPIs.selectRootDirectory(); return await this.ElectronAPIs.selectRootDirectory();
@ -94,53 +124,117 @@ class ExportService {
updateProgress: (progress: ExportProgress) => void, updateProgress: (progress: ExportProgress) => void,
exportType: ExportType exportType: ExportType
) { ) {
if (this.exportInProgress) { try {
this.ElectronAPIs.sendNotification(ExportNotification.IN_PROGRESS); if (this.exportInProgress) {
return this.exportInProgress; this.ElectronAPIs.sendNotification(
} ExportNotification.IN_PROGRESS
this.ElectronAPIs.showOnTray('starting export'); );
const exportDir = getData(LS_KEYS.EXPORT)?.folder; return await this.exportInProgress;
if (!exportDir) { }
// no-export folder set this.ElectronAPIs.showOnTray('starting export');
return; const exportDir = getData(LS_KEYS.EXPORT)?.folder;
} if (!exportDir) {
let filesToExport: File[]; // no-export folder set
const allFiles = await getLocalFiles(); return;
const collections = await getLocalCollections(); }
const nonEmptyCollections = getNonEmptyCollections( const user: User = getData(LS_KEYS.USER);
collections,
allFiles
);
const exportRecord = await this.getExportRecord(exportDir);
if (exportType === ExportType.NEW) { let filesToExport: File[];
filesToExport = await getFilesUploadedAfterLastExport( const localFiles = await getLocalFiles();
allFiles, const userPersonalFiles = localFiles
.filter((file) => file.ownerID === user?.id)
.sort((fileA, fileB) => fileA.id - fileB.id);
const collections = await getLocalCollections();
const nonEmptyCollections = getNonEmptyCollections(
collections,
userPersonalFiles
);
const userCollections = nonEmptyCollections
.filter((collection) => collection.owner.id === user?.id)
.sort(
(collectionA, collectionB) =>
collectionA.id - collectionB.id
);
if (this.checkAllElectronAPIsExists()) {
await this.migrateExport(
exportDir,
collections,
userPersonalFiles
);
}
const exportRecord = await this.getExportRecord(exportDir);
if (exportType === ExportType.NEW) {
filesToExport = getFilesUploadedAfterLastExport(
userPersonalFiles,
exportRecord
);
} else if (exportType === ExportType.RETRY_FAILED) {
filesToExport = getExportFailedFiles(
userPersonalFiles,
exportRecord
);
} else {
filesToExport = getExportQueuedFiles(
userPersonalFiles,
exportRecord
);
}
const collectionIDPathMap: CollectionIDPathMap =
getCollectionIDPathMapFromExportRecord(exportRecord);
const newCollections = getCollectionsCreatedAfterLastExport(
userCollections,
exportRecord exportRecord
); );
} else if (exportType === ExportType.RETRY_FAILED) {
filesToExport = await getExportFailedFiles(allFiles, exportRecord); const renamedCollections = getCollectionsRenamedAfterLastExport(
} else { userCollections,
filesToExport = await getExportPendingFiles(allFiles, exportRecord); exportRecord
);
this.exportInProgress = this.fileExporter(
filesToExport,
newCollections,
renamedCollections,
collectionIDPathMap,
updateProgress,
exportDir
);
const resp = await this.exportInProgress;
this.exportInProgress = null;
return resp;
} catch (e) {
logError(e, 'exportFiles failed');
return { paused: false };
} }
this.exportInProgress = this.fileExporter(
filesToExport,
nonEmptyCollections,
updateProgress,
exportDir
);
const resp = await this.exportInProgress;
this.exportInProgress = null;
return resp;
} }
async fileExporter( async fileExporter(
files: File[], files: File[],
collections: Collection[], newCollections: Collection[],
renamedCollections: Collection[],
collectionIDPathMap: CollectionIDPathMap,
updateProgress: (progress: ExportProgress) => void, updateProgress: (progress: ExportProgress) => void,
dir: string exportDir: string
): Promise<{ paused: boolean }> { ): Promise<{ paused: boolean }> {
try { try {
if (newCollections?.length) {
await this.createNewCollectionFolders(
newCollections,
exportDir,
collectionIDPathMap
);
}
if (
renamedCollections?.length &&
this.checkAllElectronAPIsExists()
) {
await this.renameCollectionFolders(
renamedCollections,
exportDir,
collectionIDPathMap
);
}
if (!files?.length) { if (!files?.length) {
this.ElectronAPIs.sendNotification( this.ElectronAPIs.sendNotification(
ExportNotification.UP_TO_DATE ExportNotification.UP_TO_DATE
@ -149,7 +243,7 @@ class ExportService {
} }
this.stopExport = false; this.stopExport = false;
this.pauseExport = false; this.pauseExport = false;
this.addFilesQueuedRecord(dir, files); this.addFilesQueuedRecord(exportDir, files);
const failedFileCount = 0; const failedFileCount = 0;
this.ElectronAPIs.showOnTray({ this.ElectronAPIs.showOnTray({
@ -161,19 +255,6 @@ class ExportService {
}); });
this.ElectronAPIs.sendNotification(ExportNotification.START); this.ElectronAPIs.sendNotification(ExportNotification.START);
const collectionIDMap = new Map<number, string>();
for (const collection of collections) {
const collectionFolderPath = `${dir}/${
collection.id
}_${this.sanitizeName(collection.name)}`;
await this.ElectronAPIs.checkExistsAndCreateCollectionDir(
collectionFolderPath
);
await this.ElectronAPIs.checkExistsAndCreateCollectionDir(
`${collectionFolderPath}/${METADATA_FOLDER_NAME}`
);
collectionIDMap.set(collection.id, collectionFolderPath);
}
for (const [index, file] of files.entries()) { for (const [index, file] of files.entries()) {
if (this.stopExport || this.pauseExport) { if (this.stopExport || this.pauseExport) {
if (this.pauseExport) { if (this.pauseExport) {
@ -184,20 +265,26 @@ class ExportService {
} }
break; break;
} }
const collectionPath = collectionIDMap.get(file.collectionID); const collectionPath = collectionIDPathMap.get(
file.collectionID
);
try { try {
await this.downloadAndSave(file, collectionPath); await this.downloadAndSave(file, collectionPath);
await this.addFileExportRecord( await this.addFileExportedRecord(
dir, exportDir,
file, file,
RecordType.SUCCESS RecordType.SUCCESS
); );
} catch (e) { } catch (e) {
await this.addFileExportRecord( await this.addFileExportedRecord(
dir, exportDir,
file, file,
RecordType.FAILED RecordType.FAILED
); );
console.log(
`export failed for fileID:${file.id}, reason:`,
e
);
logError( logError(
e, e,
'download and save failed for file during export' 'download and save failed for file during export'
@ -227,7 +314,8 @@ class ExportService {
} }
return { paused: false }; return { paused: false };
} catch (e) { } catch (e) {
logError(e, 'export failed '); logError(e, 'fileExporter failed');
throw e;
} }
} }
async addFilesQueuedRecord(folder: string, files: File[]) { async addFilesQueuedRecord(folder: string, files: File[]) {
@ -236,7 +324,7 @@ class ExportService {
await this.updateExportRecord(exportRecord, folder); await this.updateExportRecord(exportRecord, folder);
} }
async addFileExportRecord(folder: string, file: File, type: RecordType) { async addFileExportedRecord(folder: string, file: File, type: RecordType) {
const fileUID = getExportRecordFileUID(file); const fileUID = getExportRecordFileUID(file);
const exportRecord = await this.getExportRecord(folder); const exportRecord = await this.getExportRecord(folder);
exportRecord.queuedFiles = exportRecord.queuedFiles.filter( exportRecord.queuedFiles = exportRecord.queuedFiles.filter(
@ -265,28 +353,47 @@ class ExportService {
await this.updateExportRecord(exportRecord, folder); await this.updateExportRecord(exportRecord, folder);
} }
async updateExportRecord(newData: Record<string, any>, folder?: string) { async addCollectionExportedRecord(
await this.recordUpdateInProgress; folder: string,
this.recordUpdateInProgress = (async () => { collection: Collection,
try { collectionFolderPath: string
if (!folder) { ) {
folder = getData(LS_KEYS.EXPORT)?.folder; const exportRecord = await this.getExportRecord(folder);
} if (!exportRecord?.exportedCollectionPaths) {
const exportRecord = await this.getExportRecord(folder); exportRecord.exportedCollectionPaths = {};
const newRecord = { ...exportRecord, ...newData }; }
await this.ElectronAPIs.setExportRecord( exportRecord.exportedCollectionPaths = {
`${folder}/${EXPORT_RECORD_FILE_NAME}`, ...exportRecord.exportedCollectionPaths,
JSON.stringify(newRecord, null, 2) [collection.id]: collectionFolderPath,
); };
} catch (e) {
logError(e, 'error updating Export Record'); await this.updateExportRecord(exportRecord, folder);
}
async updateExportRecord(newData: ExportRecord, folder?: string) {
const response = this.exportRecordUpdater.queueUpRequest(() =>
this.updateExportRecordHelper(folder, newData)
);
await response.promise;
}
async updateExportRecordHelper(folder: string, newData: ExportRecord) {
try {
if (!folder) {
folder = getData(LS_KEYS.EXPORT)?.folder;
} }
})(); const exportRecord = await this.getExportRecord(folder);
const newRecord = { ...exportRecord, ...newData };
await this.ElectronAPIs.setExportRecord(
`${folder}/${EXPORT_RECORD_FILE_NAME}`,
JSON.stringify(newRecord, null, 2)
);
} catch (e) {
logError(e, 'error updating Export Record');
}
} }
async getExportRecord(folder?: string): Promise<ExportRecord> { async getExportRecord(folder?: string): Promise<ExportRecord> {
try { try {
await this.recordUpdateInProgress;
if (!folder) { if (!folder) {
folder = getData(LS_KEYS.EXPORT)?.folder; folder = getData(LS_KEYS.EXPORT)?.folder;
} }
@ -303,16 +410,89 @@ class ExportService {
} }
} }
async createNewCollectionFolders(
newCollections: Collection[],
exportFolder: string,
collectionIDPathMap: CollectionIDPathMap
) {
for (const collection of newCollections) {
const collectionFolderPath = getUniqueCollectionFolderPath(
exportFolder,
collection
);
await this.ElectronAPIs.checkExistsAndCreateCollectionDir(
collectionFolderPath
);
await this.ElectronAPIs.checkExistsAndCreateCollectionDir(
getMetadataFolderPath(collectionFolderPath)
);
await this.addCollectionExportedRecord(
exportFolder,
collection,
collectionFolderPath
);
collectionIDPathMap.set(collection.id, collectionFolderPath);
}
}
async renameCollectionFolders(
renamedCollections: Collection[],
exportFolder: string,
collectionIDPathMap: CollectionIDPathMap
) {
for (const collection of renamedCollections) {
const oldCollectionFolderPath = collectionIDPathMap.get(
collection.id
);
const newCollectionFolderPath = getUniqueCollectionFolderPath(
exportFolder,
collection
);
await this.ElectronAPIs.checkExistsAndRename(
oldCollectionFolderPath,
newCollectionFolderPath
);
await this.addCollectionExportedRecord(
exportFolder,
collection,
newCollectionFolderPath
);
collectionIDPathMap.set(collection.id, newCollectionFolderPath);
}
}
async downloadAndSave(file: File, collectionPath: string) { async downloadAndSave(file: File, collectionPath: string) {
const uid = `${file.id}_${this.sanitizeName(file.metadata.title)}`; file.metadata = mergeMetadata([file])[0].metadata;
const fileStream = await retryAsyncFunction(() => const fileSaveName = getUniqueFileSaveName(
collectionPath,
file.metadata.title,
file.id
);
let fileStream = await retryAsyncFunction(() =>
downloadManager.downloadFile(file) downloadManager.downloadFile(file)
); );
const fileType = getFileExtension(file.metadata.title);
if (
file.pubMagicMetadata?.data.editedTime &&
(fileType === TYPE_JPEG || fileType === TYPE_JPG)
) {
const fileBlob = await new Response(fileStream).blob();
const updatedFileBlob = await updateFileCreationDateInEXIF(
fileBlob,
new Date(file.pubMagicMetadata.data.editedTime / 1000)
);
fileStream = updatedFileBlob.stream();
}
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
this.exportMotionPhoto(fileStream, file, collectionPath); await this.exportMotionPhoto(fileStream, file, collectionPath);
} else { } else {
this.saveMediaFile(collectionPath, uid, fileStream); this.saveMediaFile(collectionPath, fileSaveName, fileStream);
this.saveMetadataFile(collectionPath, uid, file.metadata); await this.saveMetadataFile(
collectionPath,
fileSaveName,
file.metadata
);
} }
} }
@ -324,37 +504,173 @@ class ExportService {
const fileBlob = await new Response(fileStream).blob(); const fileBlob = await new Response(fileStream).blob();
const originalName = fileNameWithoutExtension(file.metadata.title); const originalName = fileNameWithoutExtension(file.metadata.title);
const motionPhoto = await decodeMotionPhoto(fileBlob, originalName); const motionPhoto = await decodeMotionPhoto(fileBlob, originalName);
const imageStream = generateStreamFromArrayBuffer(motionPhoto.image); const imageStream = generateStreamFromArrayBuffer(motionPhoto.image);
const imageUID = `${file.id}_${motionPhoto.imageNameTitle}`; const imageSaveName = getUniqueFileSaveName(
this.saveMediaFile(collectionPath, imageUID, imageStream); collectionPath,
this.saveMetadataFile(collectionPath, imageUID, file.metadata); motionPhoto.imageNameTitle,
file.id
);
this.saveMediaFile(collectionPath, imageSaveName, imageStream);
await this.saveMetadataFile(
collectionPath,
imageSaveName,
file.metadata
);
const videoStream = generateStreamFromArrayBuffer(motionPhoto.video); const videoStream = generateStreamFromArrayBuffer(motionPhoto.video);
const videoUID = `${file.id}_${motionPhoto.videoNameTitle}`; const videoSaveName = getUniqueFileSaveName(
this.saveMediaFile(collectionPath, videoUID, videoStream); collectionPath,
this.saveMetadataFile(collectionPath, videoUID, file.metadata); motionPhoto.videoNameTitle,
file.id
);
this.saveMediaFile(collectionPath, videoSaveName, videoStream);
await this.saveMetadataFile(
collectionPath,
videoSaveName,
file.metadata
);
} }
private saveMediaFile(collectionPath, uid, fileStream) { private saveMediaFile(
collectionFolderPath: string,
fileSaveName: string,
fileStream: ReadableStream<any>
) {
this.ElectronAPIs.saveStreamToDisk( this.ElectronAPIs.saveStreamToDisk(
`${collectionPath}/${uid}`, getFileSavePath(collectionFolderPath, fileSaveName),
fileStream fileStream
); );
} }
private saveMetadataFile(collectionPath, uid, metadata) { private async saveMetadataFile(
this.ElectronAPIs.saveFileToDisk( collectionFolderPath: string,
`${collectionPath}/${METADATA_FOLDER_NAME}/${uid}.json`, fileSaveName: string,
getGoogleLikeMetadataFile(uid, metadata) metadata: MetadataObject
) {
await this.ElectronAPIs.saveFileToDisk(
getFileMetadataSavePath(collectionFolderPath, fileSaveName),
getGoogleLikeMetadataFile(fileSaveName, metadata)
); );
} }
private sanitizeName(name) {
return name.replaceAll('/', '_').replaceAll(' ', '_');
}
isExportInProgress = () => { isExportInProgress = () => {
return this.exportInProgress !== null; return this.exportInProgress !== null;
}; };
exists = (path: string) => {
return this.ElectronAPIs.exists(path);
};
checkAllElectronAPIsExists = () => this.allElectronAPIsExist;
/*
this function migrates the exportRecord file to apply any schema changes.
currently we apply only a single migration to update file and collection name to newer format
so there is just a if condition check,
later this will be converted to a loop which applies the migration one by one
till the files reaches the latest version
*/
private async migrateExport(
exportDir: string,
collections: Collection[],
allFiles: File[]
) {
const exportRecord = await this.getExportRecord(exportDir);
const currentVersion = exportRecord?.version ?? 0;
if (currentVersion === 0) {
const collectionIDPathMap = new Map<number, string>();
await this.migrateCollectionFolders(
collections,
exportDir,
collectionIDPathMap
);
await this.migrateFiles(
getExportedFiles(allFiles, exportRecord),
collectionIDPathMap
);
await this.updateExportRecord({
version: LATEST_EXPORT_VERSION,
});
}
}
/*
This updates the folder name of already exported folders from the earlier format of
`collectionID_collectionName` to newer `collectionName(numbered)` format
*/
private async migrateCollectionFolders(
collections: Collection[],
exportDir: string,
collectionIDPathMap: CollectionIDPathMap
) {
for (const collection of collections) {
const oldCollectionFolderPath = getOldCollectionFolderPath(
exportDir,
collection
);
const newCollectionFolderPath = getUniqueCollectionFolderPath(
exportDir,
collection
);
collectionIDPathMap.set(collection.id, newCollectionFolderPath);
if (this.ElectronAPIs.exists(oldCollectionFolderPath)) {
await this.ElectronAPIs.checkExistsAndRename(
oldCollectionFolderPath,
newCollectionFolderPath
);
await this.addCollectionExportedRecord(
exportDir,
collection,
newCollectionFolderPath
);
}
}
}
/*
This updates the file name of already exported files from the earlier format of
`fileID_fileName` to newer `fileName(numbered)` format
*/
private async migrateFiles(
files: File[],
collectionIDPathMap: Map<number, string>
) {
for (let file of files) {
const oldFileSavePath = getOldFileSavePath(
collectionIDPathMap.get(file.collectionID),
file
);
const oldFileMetadataSavePath = getOldFileMetadataSavePath(
collectionIDPathMap.get(file.collectionID),
file
);
file = mergeMetadata([file])[0];
const newFileSaveName = getUniqueFileSaveName(
collectionIDPathMap.get(file.collectionID),
file.metadata.title,
file.id
);
const newFileSavePath = getFileSavePath(
collectionIDPathMap.get(file.collectionID),
newFileSaveName
);
const newFileMetadataSavePath = getFileMetadataSavePath(
collectionIDPathMap.get(file.collectionID),
newFileSaveName
);
await this.ElectronAPIs.checkExistsAndRename(
oldFileSavePath,
newFileSavePath
);
console.log(oldFileMetadataSavePath, newFileMetadataSavePath);
await this.ElectronAPIs.checkExistsAndRename(
oldFileMetadataSavePath,
newFileMetadataSavePath
);
}
}
} }
export default new ExportService(); export default new ExportService();

View file

@ -4,8 +4,8 @@ import { fileExtensionWithDot } from 'utils/file';
class MotionPhoto { class MotionPhoto {
image: Uint8Array; image: Uint8Array;
video: Uint8Array; video: Uint8Array;
imageNameTitle: String; imageNameTitle: string;
videoNameTitle: String; videoNameTitle: string;
} }
export const decodeMotionPhoto = async ( export const decodeMotionPhoto = async (

View file

@ -1,6 +1,6 @@
import exifr from 'exifr'; import exifr from 'exifr';
import piexif from 'piexifjs';
import { logError } from 'utils/sentry'; import { logError } from 'utils/sentry';
import { NULL_LOCATION, Location } from './metadataService'; import { NULL_LOCATION, Location } from './metadataService';
import { FileTypeInfo } from './readFileService'; import { FileTypeInfo } from './readFileService';
@ -55,6 +55,65 @@ export async function getExifData(
} }
} }
export async function updateFileCreationDateInEXIF(
fileBlob: Blob,
updatedDate: Date
) {
try {
const fileURL = URL.createObjectURL(fileBlob);
let imageDataURL = await convertImageToDataURL(fileURL);
imageDataURL =
'data:image/jpeg;base64' +
imageDataURL.slice(imageDataURL.indexOf(','));
const exifObj = piexif.load(imageDataURL);
if (!exifObj['Exif']) {
exifObj['Exif'] = {};
}
exifObj['Exif'][piexif.ExifIFD.DateTimeOriginal] =
convertToExifDateFormat(updatedDate);
const exifBytes = piexif.dump(exifObj);
const exifInsertedFile = piexif.insert(exifBytes, imageDataURL);
return dataURIToBlob(exifInsertedFile);
} catch (e) {
logError(e, 'updateFileModifyDateInEXIF failed');
return fileBlob;
}
}
export async function convertImageToDataURL(url: string) {
const blob = await fetch(url).then((r) => r.blob());
const dataUrl = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
return dataUrl;
}
function dataURIToBlob(dataURI) {
// convert base64 to raw binary data held in a string
// doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
const byteString = atob(dataURI.split(',')[1]);
// separate out the mime component
const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
// write the bytes of the string to an ArrayBuffer
const ab = new ArrayBuffer(byteString.length);
// create a view into the buffer
const ia = new Uint8Array(ab);
// set the bytes of the buffer to the correct values
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
// write the ArrayBuffer to a blob, and you're done
const blob = new Blob([ab], { type: mimeString });
return blob;
}
export async function getRawExif( export async function getRawExif(
receivedFile: File, receivedFile: File,
fileTypeInfo: FileTypeInfo fileTypeInfo: FileTypeInfo
@ -93,3 +152,9 @@ function getEXIFLocation(exifData): Location {
} }
return { latitude: exifData.latitude, longitude: exifData.longitude }; return { latitude: exifData.latitude, longitude: exifData.longitude };
} }
function convertToExifDateFormat(date: Date) {
return `${date.getFullYear()}:${
date.getMonth() + 1
}:${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
}

View file

@ -6,6 +6,7 @@ import { logError } from 'utils/sentry';
import { FILE_READER_CHUNK_SIZE, MULTIPART_PART_SIZE } from 'types/upload'; import { FILE_READER_CHUNK_SIZE, MULTIPART_PART_SIZE } from 'types/upload';
import FileType from 'file-type/browser'; import FileType from 'file-type/browser';
import { CustomError } from 'utils/common/errorUtil'; import { CustomError } from 'utils/common/errorUtil';
import { getFileExtension } from 'utils/file';
const TYPE_VIDEO = 'video'; const TYPE_VIDEO = 'video';
const TYPE_IMAGE = 'image'; const TYPE_IMAGE = 'image';
@ -48,7 +49,7 @@ export async function getFileType(
} }
return { fileType, exactType: typeParts[1] }; return { fileType, exactType: typeParts[1] };
} catch (e) { } catch (e) {
const fileFormat = receivedFile.name.split('.').pop(); const fileFormat = getFileExtension(receivedFile.name);
const formatMissedByTypeDetection = FORMAT_MISSED_BY_FILE_TYPE_LIB.find( const formatMissedByTypeDetection = FORMAT_MISSED_BY_FILE_TYPE_LIB.find(
(a) => a.exactType === fileFormat (a) => a.exactType === fileFormat
); );

View file

@ -125,7 +125,7 @@ class UploadManager {
fileWithCollection.collectionID, fileWithCollection.collectionID,
title title
), ),
parsedMetaDataJSON { ...parsedMetaDataJSON }
); );
UIService.increaseFileUploaded(); UIService.increaseFileUploaded();
} }

View file

@ -6,12 +6,15 @@ import {
removeFromCollection, removeFromCollection,
restoreToCollection, restoreToCollection,
} from 'services/collectionService'; } from 'services/collectionService';
import { getSelectedFiles } from 'utils/file'; import { downloadFiles, getSelectedFiles } from 'utils/file';
import { File } from 'services/fileService'; import { File, getLocalFiles } from 'services/fileService';
import { CustomError } from 'utils/common/errorUtil'; import { CustomError } from 'utils/common/errorUtil';
import { SelectedState } from 'pages/gallery'; import { SelectedState } from 'pages/gallery';
import { User } from 'services/userService'; import { User } from 'services/userService';
import { getData, LS_KEYS } from 'utils/storage/localStorage'; import { getData, LS_KEYS } from 'utils/storage/localStorage';
import { SetDialogMessage } from 'components/MessageDialog';
import { logError } from 'utils/sentry';
import constants from 'utils/strings/constants';
export enum COLLECTION_OPS_TYPE { export enum COLLECTION_OPS_TYPE {
ADD, ADD,
@ -83,3 +86,23 @@ export function isFavoriteCollection(
return collection.type === CollectionType.favorites; return collection.type === CollectionType.favorites;
} }
} }
export async function downloadCollection(
collectionID: number,
setDialogMessage: SetDialogMessage
) {
try {
const allFiles = await getLocalFiles();
const collectionFiles = allFiles.filter(
(file) => file.collectionID === collectionID
);
await downloadFiles(collectionFiles);
} catch (e) {
logError(e, 'download collection failed ');
setDialogMessage({
title: constants.ERROR,
content: constants.DELETE_COLLECTION_FAILED,
close: { variant: 'danger' },
});
}
}

View file

@ -1,46 +1,120 @@
import { ExportRecord } from 'services/exportService'; import { Collection } from 'services/collectionService';
import exportService, {
CollectionIDPathMap,
ExportRecord,
METADATA_FOLDER_NAME,
} from 'services/exportService';
import { File } from 'services/fileService'; import { File } from 'services/fileService';
import { MetadataObject } from 'types/upload'; import { MetadataObject } from 'types/upload';
import { formatDate } from 'utils/file'; import { formatDate, splitFilenameAndExtension } from 'utils/file';
export const getExportRecordFileUID = (file: File) => export const getExportRecordFileUID = (file: File) =>
`${file.id}_${file.collectionID}_${file.updationTime}`; `${file.id}_${file.collectionID}_${file.updationTime}`;
export const getExportPendingFiles = async ( export const getExportQueuedFiles = (
allFiles: File[], allFiles: File[],
exportRecord: ExportRecord exportRecord: ExportRecord
) => { ) => {
const queuedFiles = new Set(exportRecord?.queuedFiles); const queuedFiles = new Set(exportRecord?.queuedFiles);
const unExportedFiles = allFiles.filter((file) => { const unExportedFiles = allFiles.filter((file) => {
if (queuedFiles.has(getExportRecordFileUID(file))) { if (queuedFiles.has(getExportRecordFileUID(file))) {
return file; return true;
} }
return false;
}); });
return unExportedFiles; return unExportedFiles;
}; };
export const getFilesUploadedAfterLastExport = async ( export const getCollectionsCreatedAfterLastExport = (
collections: Collection[],
exportRecord: ExportRecord
) => {
const exportedCollections = new Set(
Object.keys(exportRecord?.exportedCollectionPaths ?? {}).map((x) =>
Number(x)
)
);
const unExportedCollections = collections.filter((collection) => {
if (!exportedCollections.has(collection.id)) {
return true;
}
return false;
});
return unExportedCollections;
};
export const getCollectionIDPathMapFromExportRecord = (
exportRecord: ExportRecord
): CollectionIDPathMap => {
return new Map<number, string>(
Object.entries(exportRecord.exportedCollectionPaths ?? {}).map((e) => {
return [Number(e[0]), String(e[1])];
})
);
};
export const getCollectionsRenamedAfterLastExport = (
collections: Collection[],
exportRecord: ExportRecord
) => {
const collectionIDPathMap =
getCollectionIDPathMapFromExportRecord(exportRecord);
const renamedCollections = collections.filter((collection) => {
if (collectionIDPathMap.has(collection.id)) {
const currentFolderName = collectionIDPathMap.get(collection.id);
const startIndex = currentFolderName.lastIndexOf('/');
const lastIndex = currentFolderName.lastIndexOf('(');
const nameRoot = currentFolderName.slice(
startIndex + 1,
lastIndex !== -1 ? lastIndex : currentFolderName.length
);
if (nameRoot !== sanitizeName(collection.name)) {
return true;
}
}
return false;
});
return renamedCollections;
};
export const getFilesUploadedAfterLastExport = (
allFiles: File[], allFiles: File[],
exportRecord: ExportRecord exportRecord: ExportRecord
) => { ) => {
const exportedFiles = new Set(exportRecord?.exportedFiles); const exportedFiles = new Set(exportRecord?.exportedFiles);
const unExportedFiles = allFiles.filter((file) => { const unExportedFiles = allFiles.filter((file) => {
if (!exportedFiles.has(getExportRecordFileUID(file))) { if (!exportedFiles.has(getExportRecordFileUID(file))) {
return file; return true;
} }
return false;
}); });
return unExportedFiles; return unExportedFiles;
}; };
export const getExportFailedFiles = async ( export const getExportedFiles = (
allFiles: File[],
exportRecord: ExportRecord
) => {
const exportedFileIds = new Set(exportRecord?.exportedFiles);
const exportedFiles = allFiles.filter((file) => {
if (exportedFileIds.has(getExportRecordFileUID(file))) {
return true;
}
return false;
});
return exportedFiles;
};
export const getExportFailedFiles = (
allFiles: File[], allFiles: File[],
exportRecord: ExportRecord exportRecord: ExportRecord
) => { ) => {
const failedFiles = new Set(exportRecord?.failedFiles); const failedFiles = new Set(exportRecord?.failedFiles);
const filesToExport = allFiles.filter((file) => { const filesToExport = allFiles.filter((file) => {
if (failedFiles.has(getExportRecordFileUID(file))) { if (failedFiles.has(getExportRecordFileUID(file))) {
return file; return true;
} }
return false;
}); });
return filesToExport; return filesToExport;
}; };
@ -52,14 +126,16 @@ export const dedupe = (files: any[]) => {
}; };
export const getGoogleLikeMetadataFile = ( export const getGoogleLikeMetadataFile = (
uid: string, fileSaveName: string,
metadata: MetadataObject metadata: MetadataObject
) => { ) => {
const creationTime = Math.floor(metadata.creationTime / 1000000); const creationTime = Math.floor(metadata.creationTime / 1000000);
const modificationTime = Math.floor(metadata.modificationTime / 1000000); const modificationTime = Math.floor(
(metadata.modificationTime ?? metadata.creationTime) / 1000000
);
return JSON.stringify( return JSON.stringify(
{ {
title: uid, title: fileSaveName,
creationTime: { creationTime: {
timestamp: creationTime, timestamp: creationTime,
formatted: formatDate(creationTime * 1000), formatted: formatDate(creationTime * 1000),
@ -77,3 +153,85 @@ export const getGoogleLikeMetadataFile = (
2 2
); );
}; };
export const oldSanitizeName = (name: string) =>
name.replaceAll('/', '_').replaceAll(' ', '_');
export const sanitizeName = (name: string) =>
name.replace(/[^a-z0-9.]/gi, '_').toLowerCase();
export const getUniqueCollectionFolderPath = (
dir: string,
collection: Collection
): string => {
if (!exportService.checkAllElectronAPIsExists()) {
return getOldCollectionFolderPath(dir, collection);
}
let collectionFolderPath = `${dir}/${sanitizeName(collection.name)}`;
let count = 1;
while (exportService.exists(collectionFolderPath)) {
collectionFolderPath = `${dir}/${sanitizeName(
collection.name
)}(${count})`;
count++;
}
return collectionFolderPath;
};
export const getMetadataFolderPath = (collectionFolderPath: string) =>
`${collectionFolderPath}/${METADATA_FOLDER_NAME}`;
export const getUniqueFileSaveName = (
collectionPath: string,
filename: string,
fileID: number
) => {
if (!exportService.checkAllElectronAPIsExists()) {
return getOldFileSaveName(filename, fileID);
}
let fileSaveName = sanitizeName(filename);
let count = 1;
while (
exportService.exists(getFileSavePath(collectionPath, fileSaveName))
) {
const filenameParts = splitFilenameAndExtension(sanitizeName(filename));
if (filenameParts[1]) {
fileSaveName = `${filenameParts[0]}(${count}).${filenameParts[1]}`;
} else {
fileSaveName = `${filenameParts[0]}(${count})`;
}
count++;
}
return fileSaveName;
};
export const getOldFileSaveName = (filename: string, fileID: number) =>
`${fileID}_${oldSanitizeName(filename)}`;
export const getFileMetadataSavePath = (
collectionFolderPath: string,
fileSaveName: string
) => `${collectionFolderPath}/${METADATA_FOLDER_NAME}/${fileSaveName}.json`;
export const getFileSavePath = (
collectionFolderPath: string,
fileSaveName: string
) => `${collectionFolderPath}/${fileSaveName}`;
export const getOldCollectionFolderPath = (
dir: string,
collection: Collection
) => `${dir}/${collection.id}_${oldSanitizeName(collection.name)}`;
export const getOldFileSavePath = (collectionFolderPath: string, file: File) =>
`${collectionFolderPath}/${file.id}_${oldSanitizeName(
file.metadata.title
)}`;
export const getOldFileMetadataSavePath = (
collectionFolderPath: string,
file: File
) =>
`${collectionFolderPath}/${METADATA_FOLDER_NAME}/${
file.id
}_${oldSanitizeName(file.metadata.title)}.json`;

View file

@ -16,9 +16,12 @@ import { logError } from 'utils/sentry';
import { User } from 'services/userService'; import { User } from 'services/userService';
import CryptoWorker from 'utils/crypto'; import CryptoWorker from 'utils/crypto';
import { getData, LS_KEYS } from 'utils/storage/localStorage'; import { getData, LS_KEYS } from 'utils/storage/localStorage';
import { updateFileCreationDateInEXIF } from 'services/upload/exifService';
export const TYPE_HEIC = 'heic'; export const TYPE_HEIC = 'heic';
export const TYPE_HEIF = 'heif'; export const TYPE_HEIF = 'heif';
export const TYPE_JPEG = 'jpeg';
export const TYPE_JPG = 'jpg';
const UNSUPPORTED_FORMATS = ['flv', 'mkv', '3gp', 'avi', 'wmv']; const UNSUPPORTED_FORMATS = ['flv', 'mkv', '3gp', 'avi', 'wmv'];
export function downloadAsFile(filename: string, content: string) { export function downloadAsFile(filename: string, content: string) {
@ -40,13 +43,32 @@ export function downloadAsFile(filename: string, content: string) {
export async function downloadFile(file: File) { export async function downloadFile(file: File) {
const a = document.createElement('a'); const a = document.createElement('a');
a.style.display = 'none'; a.style.display = 'none';
const cachedFileUrl = await DownloadManager.getCachedOriginalFile(file); let fileURL = await DownloadManager.getCachedOriginalFile(file);
const fileURL = let tempURL;
cachedFileUrl ?? if (!fileURL) {
URL.createObjectURL( tempURL = URL.createObjectURL(
await new Response(await DownloadManager.downloadFile(file)).blob() await new Response(await DownloadManager.downloadFile(file)).blob()
); );
fileURL = tempURL;
}
const fileType = getFileExtension(file.metadata.title);
let tempEditedFileURL;
if (
file.pubMagicMetadata?.data.editedTime &&
(fileType === TYPE_JPEG || fileType === TYPE_JPG)
) {
let fileBlob = await (await fetch(fileURL)).blob();
fileBlob = await updateFileCreationDateInEXIF(
fileBlob,
new Date(file.pubMagicMetadata.data.editedTime / 1000)
);
tempEditedFileURL = URL.createObjectURL(fileBlob);
fileURL = tempEditedFileURL;
}
a.href = fileURL; a.href = fileURL;
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) { if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
a.download = fileNameWithoutExtension(file.metadata.title) + '.zip'; a.download = fileNameWithoutExtension(file.metadata.title) + '.zip';
} else { } else {
@ -55,6 +77,8 @@ export async function downloadFile(file: File) {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
tempURL && URL.revokeObjectURL(tempURL);
tempEditedFileURL && URL.revokeObjectURL(tempEditedFileURL);
} }
export function isFileHEIC(mimeType: string) { export function isFileHEIC(mimeType: string) {
@ -257,6 +281,10 @@ export function splitFilenameAndExtension(filename): [string, string] {
]; ];
} }
export function getFileExtension(filename) {
return splitFilenameAndExtension(filename)[1];
}
export function generateStreamFromArrayBuffer(data: Uint8Array) { export function generateStreamFromArrayBuffer(data: Uint8Array) {
return new ReadableStream({ return new ReadableStream({
async start(controller: ReadableStreamDefaultController) { async start(controller: ReadableStreamDefaultController) {
@ -273,7 +301,7 @@ export async function convertForPreview(file: File, fileBlob: Blob) {
fileBlob = new Blob([motionPhoto.image]); fileBlob = new Blob([motionPhoto.image]);
} }
const typeFromExtension = file.metadata.title.split('.')[-1]; const typeFromExtension = getFileExtension(file.metadata.title);
const worker = await new CryptoWorker(); const worker = await new CryptoWorker();
const mimeType = const mimeType =

View file

@ -9,7 +9,7 @@ export const logError = (
) => { ) => {
const err = errorWithContext(error, msg); const err = errorWithContext(error, msg);
if (!process.env.NEXT_PUBLIC_SENTRY_ENV) { if (!process.env.NEXT_PUBLIC_SENTRY_ENV) {
console.log({ error, msg, info }); console.log(error, { msg, info });
} }
Sentry.captureException(err, { Sentry.captureException(err, {
level: Sentry.Severity.Info, level: Sentry.Severity.Info,

View file

@ -382,6 +382,14 @@ const englishConstants = {
`oops, you're already sharing this with ${email}`, `oops, you're already sharing this with ${email}`,
SHARING_BAD_REQUEST_ERROR: 'sharing album not allowed', SHARING_BAD_REQUEST_ERROR: 'sharing album not allowed',
SHARING_DISABLED_FOR_FREE_ACCOUNTS: 'sharing is disabled for free accounts', SHARING_DISABLED_FOR_FREE_ACCOUNTS: 'sharing is disabled for free accounts',
CONFIRM_DOWNLOAD_COLLECTION: 'download album',
DOWNLOAD_COLLECTION_MESSAGE: () => (
<>
<p>are you sure you want to download the complete album?</p>
<p>all files will be queued for download sequentially</p>
</>
),
DOWNLOAD_COLLECTION_FAILED: 'album downloading failed, please try again',
CREATE_ALBUM_FAILED: 'failed to create album , please try again', CREATE_ALBUM_FAILED: 'failed to create album , please try again',
SEARCH_HINT: () => ( SEARCH_HINT: () => (
<span>try searching for New York, April 14, Christmas...</span> <span>try searching for New York, April 14, Christmas...</span>

View file

@ -1104,20 +1104,20 @@
dependencies: dependencies:
webpack-bundle-analyzer "3.6.1" webpack-bundle-analyzer "3.6.1"
"@next/env@11.1.2": "@next/env@11.1.3":
version "11.1.2" version "11.1.3"
resolved "https://registry.npmjs.org/@next/env/-/env-11.1.2.tgz" resolved "https://registry.yarnpkg.com/@next/env/-/env-11.1.3.tgz#dc698e00259242012955e43a40788fcf21ba9e37"
integrity sha512-+fteyVdQ7C/OoulfcF6vd1Yk0FEli4453gr8kSFbU8sKseNSizYq6df5MKz/AjwLptsxrUeIkgBdAzbziyJ3mA== integrity sha512-5+vaeooJuWmICSlmVaAC8KG3O8hwKasACVfkHj58xQuCB5SW0TKW3hWxgxkBuefMBn1nM0yEVPKokXCsYjBtng==
"@next/polyfill-module@11.1.2": "@next/polyfill-module@11.1.3":
version "11.1.2" version "11.1.3"
resolved "https://registry.npmjs.org/@next/polyfill-module/-/polyfill-module-11.1.2.tgz" resolved "https://registry.yarnpkg.com/@next/polyfill-module/-/polyfill-module-11.1.3.tgz#95163973fe19f1827da32703d1fcb8198fb2c79a"
integrity sha512-xZmixqADM3xxtqBV0TpAwSFzWJP0MOQzRfzItHXf1LdQHWb0yofHHC+7eOrPFic8+ZGz5y7BdPkkgR1S25OymA== integrity sha512-7yr9cr4a0SrBoVE8psxXWK1wTFc8UzsY8Wc2cWGL7qA0hgtqACHaXC47M1ByJB410hFZenGrpE+KFaT1unQMyw==
"@next/react-dev-overlay@11.1.2": "@next/react-dev-overlay@11.1.3":
version "11.1.2" version "11.1.3"
resolved "https://registry.npmjs.org/@next/react-dev-overlay/-/react-dev-overlay-11.1.2.tgz" resolved "https://registry.yarnpkg.com/@next/react-dev-overlay/-/react-dev-overlay-11.1.3.tgz#5d08336931e48ebdb07d82b566223d0ee5941d2a"
integrity sha512-rDF/mGY2NC69mMg2vDqzVpCOlWqnwPUXB2zkARhvknUHyS6QJphPYv9ozoPJuoT/QBs49JJd9KWaAzVBvq920A== integrity sha512-zIwtMliSUR+IKl917ToFNB+0fD7bI5kYMdjHU/UEKpfIXAZPnXRHHISCvPDsczlr+bRsbjlUFW1CsNiuFedeuQ==
dependencies: dependencies:
"@babel/code-frame" "7.12.11" "@babel/code-frame" "7.12.11"
anser "1.4.9" anser "1.4.9"
@ -1131,30 +1131,30 @@
stacktrace-parser "0.1.10" stacktrace-parser "0.1.10"
strip-ansi "6.0.0" strip-ansi "6.0.0"
"@next/react-refresh-utils@11.1.2": "@next/react-refresh-utils@11.1.3":
version "11.1.2" version "11.1.3"
resolved "https://registry.npmjs.org/@next/react-refresh-utils/-/react-refresh-utils-11.1.2.tgz" resolved "https://registry.yarnpkg.com/@next/react-refresh-utils/-/react-refresh-utils-11.1.3.tgz#fc2c1a4f2403db1a0179d31caa0a4cc811b8ab58"
integrity sha512-hsoJmPfhVqjZ8w4IFzoo8SyECVnN+8WMnImTbTKrRUHOVJcYMmKLL7xf7T0ft00tWwAl/3f3Q3poWIN2Ueql/Q== integrity sha512-144kD8q2nChw67V3AJJlPQ6NUJVFczyn10bhTynn9o2rY5DEnkzuBipcyMuQl2DqfxMkV7sn+yOCOYbrLCk9zg==
"@next/swc-darwin-arm64@11.1.2": "@next/swc-darwin-arm64@11.1.3":
version "11.1.2" version "11.1.3"
resolved "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-11.1.2.tgz" resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-11.1.3.tgz#062eb7871048fdb313304e42ace5f91402dbc39f"
integrity sha512-hZuwOlGOwBZADA8EyDYyjx3+4JGIGjSHDHWrmpI7g5rFmQNltjlbaefAbiU5Kk7j3BUSDwt30quJRFv3nyJQ0w== integrity sha512-TwP4krjhs+uU9pesDYCShEXZrLSbJr78p12e7XnLBBaNf20SgWLlVmQUT9gX9KbWan5V0sUbJfmcS8MRNHgYuA==
"@next/swc-darwin-x64@11.1.2": "@next/swc-darwin-x64@11.1.3":
version "11.1.2" version "11.1.3"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-11.1.2.tgz#792003989f560c00677b5daeff360b35b510db83" resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-11.1.3.tgz#8bd515768d02e4c1e0cd80d33f3f29456ee890ee"
integrity sha512-PGOp0E1GisU+EJJlsmJVGE+aPYD0Uh7zqgsrpD3F/Y3766Ptfbe1lEPPWnRDl+OzSSrSrX1lkyM/Jlmh5OwNvA== integrity sha512-ZSWmkg/PxccHFNUSeBdrfaH8KwSkoeUtewXKvuYYt7Ph0yRsbqSyNIvhUezDua96lApiXXq6EL2d1THfeWomvw==
"@next/swc-linux-x64-gnu@11.1.2": "@next/swc-linux-x64-gnu@11.1.3":
version "11.1.2" version "11.1.3"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-11.1.2.tgz#8216b2ae1f21f0112958735c39dd861088108f37" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-11.1.3.tgz#40030577e6ee272afb0080b45468bea73208f46d"
integrity sha512-YcDHTJjn/8RqvyJVB6pvEKXihDcdrOwga3GfMv/QtVeLphTouY4BIcEUfrG5+26Nf37MP1ywN3RRl1TxpurAsQ== integrity sha512-PrTBN0iZudAuj4jSbtXcdBdmfpaDCPIneG4Oms4zcs93KwMgLhivYW082Mvlgx9QVEiRm7+RkFpIVtG/i7JitA==
"@next/swc-win32-x64-msvc@11.1.2": "@next/swc-win32-x64-msvc@11.1.3":
version "11.1.2" version "11.1.3"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-11.1.2.tgz#e15824405df137129918205e43cb5e9339589745" resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-11.1.3.tgz#2951cbc127f6ea57032a241fb94439cddb5d2482"
integrity sha512-e/pIKVdB+tGQYa1cW3sAeHm8gzEri/HYLZHT4WZojrUxgWXqx8pk7S7Xs47uBcFTqBDRvK3EcQpPLf3XdVsDdg== integrity sha512-mRwbscVjRoHk+tDY7XbkT5d9FCwujFIQJpGp0XNb1i5OHCSDO8WW/C9cLEWS4LxKRbIZlTLYg1MTXqLQkvva8w==
"@node-rs/helper@1.2.1": "@node-rs/helper@1.2.1":
version "1.2.1" version "1.2.1"
@ -4613,17 +4613,17 @@ negotiator@0.6.2:
resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz" resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz"
integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
next@^11.1.2: next@^11.1.3:
version "11.1.2" version "11.1.3"
resolved "https://registry.npmjs.org/next/-/next-11.1.2.tgz" resolved "https://registry.yarnpkg.com/next/-/next-11.1.3.tgz#0226b283cb9890e446aea759db8a867de2b279ef"
integrity sha512-azEYL0L+wFjv8lstLru3bgvrzPvK0P7/bz6B/4EJ9sYkXeW8r5Bjh78D/Ol7VOg0EIPz0CXoe72hzAlSAXo9hw== integrity sha512-ud/gKmnKQ8wtHC+pd1ZiqPRa7DdgulPkAk94MbpsspfNliwZkYs9SIYWhlLSyg+c661LzdUI2nZshvrtggSYWA==
dependencies: dependencies:
"@babel/runtime" "7.15.3" "@babel/runtime" "7.15.3"
"@hapi/accept" "5.0.2" "@hapi/accept" "5.0.2"
"@next/env" "11.1.2" "@next/env" "11.1.3"
"@next/polyfill-module" "11.1.2" "@next/polyfill-module" "11.1.3"
"@next/react-dev-overlay" "11.1.2" "@next/react-dev-overlay" "11.1.3"
"@next/react-refresh-utils" "11.1.2" "@next/react-refresh-utils" "11.1.3"
"@node-rs/helper" "1.2.1" "@node-rs/helper" "1.2.1"
assert "2.0.0" assert "2.0.0"
ast-types "0.13.2" ast-types "0.13.2"
@ -4669,10 +4669,10 @@ next@^11.1.2:
vm-browserify "1.1.2" vm-browserify "1.1.2"
watchpack "2.1.1" watchpack "2.1.1"
optionalDependencies: optionalDependencies:
"@next/swc-darwin-arm64" "11.1.2" "@next/swc-darwin-arm64" "11.1.3"
"@next/swc-darwin-x64" "11.1.2" "@next/swc-darwin-x64" "11.1.3"
"@next/swc-linux-x64-gnu" "11.1.2" "@next/swc-linux-x64-gnu" "11.1.3"
"@next/swc-win32-x64-msvc" "11.1.2" "@next/swc-win32-x64-msvc" "11.1.3"
node-fetch@2.6.1: node-fetch@2.6.1:
version "2.6.1" version "2.6.1"
@ -5055,6 +5055,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3:
resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz"
integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
piexifjs@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/piexifjs/-/piexifjs-1.0.6.tgz#883811d73f447218d0d06e9ed7866d04533e59e0"
integrity sha512-0wVyH0cKohzBQ5Gi2V1BuxYpxWfxF3cSqfFXfPIpl5tl9XLS5z4ogqhUCD20AbHi0h9aJkqXNJnkVev6gwh2ag==
pify@^2.0.0: pify@^2.0.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz" resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz"