diff --git a/web/apps/photos/src/components/PhotoFrame.tsx b/web/apps/photos/src/components/PhotoFrame.tsx index ed2d85d81a..2f529248ea 100644 --- a/web/apps/photos/src/components/PhotoFrame.tsx +++ b/web/apps/photos/src/components/PhotoFrame.tsx @@ -8,7 +8,6 @@ import { EnteFile } from "@/media/file"; import { FileType } from "@/media/file-type"; import type { GalleryBarMode } from "@/new/photos/components/gallery/reducer"; import { TRASH_SECTION } from "@/new/photos/services/collection"; -import { PHOTOS_PAGES } from "@ente/shared/constants/pages"; import useMemoSingleThreaded from "@ente/shared/hooks/useMemoSingleThreaded"; import { styled } from "@mui/material"; import PhotoViewer, { type PhotoViewerProps } from "components/PhotoViewer"; @@ -17,14 +16,12 @@ import { GalleryContext } from "pages/gallery"; import PhotoSwipe from "photoswipe"; import { useContext, useEffect, useState } from "react"; import AutoSizer from "react-virtualized-auto-sizer"; -import { Duplicate } from "services/deduplicationService"; import { SelectedState, SetFilesDownloadProgressAttributesCreator, } from "types/gallery"; import { handleSelectCreator } from "utils/photoFrame"; import { PhotoList } from "./PhotoList"; -import { DedupePhotoList } from "./PhotoList/dedupe"; import PreviewCard from "./pages/gallery/PreviewCard"; const Container = styled("div")` @@ -60,10 +57,6 @@ export type DisplayFile = EnteFile & { }; export interface PhotoFrameProps { - page: - | PHOTOS_PAGES.GALLERY - | PHOTOS_PAGES.DEDUPLICATE - | PHOTOS_PAGES.SHARED_ALBUMS; mode?: GalleryBarMode; /** * This is an experimental prop, to see if we can merge the separate @@ -72,7 +65,6 @@ export interface PhotoFrameProps { */ modePlus?: GalleryBarMode | "search"; files: EnteFile[]; - duplicates?: Duplicate[]; syncWithRemote: () => Promise; favItemIds?: Set; setSelected: ( @@ -96,8 +88,6 @@ export interface PhotoFrameProps { } const PhotoFrame = ({ - page, - duplicates, mode, modePlus, files, @@ -476,30 +466,19 @@ const PhotoFrame = ({ return ( - {({ height, width }) => - page === PHOTOS_PAGES.DEDUPLICATE ? ( - - ) : ( - - ) - } + {({ height, width }) => ( + + )} { - if (groups) { - // need to confirm why this was there - // const sum = groups.reduce((acc, item) => acc + item, 0); - // if (sum < columns) { - // groups[groups.length - 1] += columns - sum; - // } - return groups - .map( - (x) => - `repeat(${x}, ${IMAGE_CONTAINER_MAX_WIDTH * shrinkRatio}px)`, - ) - .join(` ${SPACE_BTW_DATES}px `); - } else { - return `repeat(${columns},${ - IMAGE_CONTAINER_MAX_WIDTH * shrinkRatio - }px)`; - } -}; - -function getFractionFittableColumns(width: number): number { - return ( - (width - 2 * getGapFromScreenEdge(width) + GAP_BTW_TILES) / - (IMAGE_CONTAINER_MAX_WIDTH + GAP_BTW_TILES) - ); -} - -function getGapFromScreenEdge(width: number) { - if (width > MIN_COLUMNS * IMAGE_CONTAINER_MAX_WIDTH) { - return 24; - } else { - return 4; - } -} - -function getShrinkRatio(width: number, columns: number) { - return ( - (width - - 2 * getGapFromScreenEdge(width) - - (columns - 1) * GAP_BTW_TILES) / - (columns * IMAGE_CONTAINER_MAX_WIDTH) - ); -} - -const ListContainer = styled(Box)<{ - columns: number; - shrinkRatio: number; - groups?: number[]; -}>` - display: grid; - grid-template-columns: ${({ columns, shrinkRatio, groups }) => - getTemplateColumns(columns, shrinkRatio, groups)}; - grid-column-gap: ${GAP_BTW_TILES}px; - width: 100%; - color: #fff; - padding: 0 24px; - @media (max-width: ${IMAGE_CONTAINER_MAX_WIDTH * MIN_COLUMNS}px) { - padding: 0 4px; - } -`; - -const ListItemContainer = styled(FlexWrapper)<{ span: number }>` - grid-column: span ${(props) => props.span}; -`; - -const DateContainer = styled(ListItemContainer)` - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - height: ${DATE_CONTAINER_HEIGHT}px; - color: ${({ theme }) => theme.colors.text.muted}; -`; - -const SizeAndCountContainer = styled(DateContainer)` - margin-top: 1rem; - height: ${SIZE_AND_COUNT_CONTAINER_HEIGHT}px; -`; - -interface Props { - height: number; - width: number; - duplicates: Duplicate[]; - showAppDownloadBanner: boolean; - getThumbnail: ( - file: EnteFile, - index: number, - isScrolling?: boolean, - ) => React.JSX.Element; - activeCollectionID: number; -} - -interface ItemData { - timeStampList: TimeStampListItem[]; - columns: number; - shrinkRatio: number; - renderListItem: ( - timeStampListItem: TimeStampListItem, - isScrolling?: boolean, - ) => React.JSX.Element; -} - -const createItemData = memoize( - ( - timeStampList: TimeStampListItem[], - columns: number, - shrinkRatio: number, - renderListItem: ( - timeStampListItem: TimeStampListItem, - isScrolling?: boolean, - ) => React.JSX.Element, - ): ItemData => ({ - timeStampList, - columns, - shrinkRatio, - renderListItem, - }), -); -const PhotoListRow = React.memo( - ({ - index, - style, - isScrolling, - data, - }: ListChildComponentProps) => { - const { timeStampList, columns, shrinkRatio, renderListItem } = data; - return ( - - - {renderListItem(timeStampList[index], isScrolling)} - - - ); - }, - areEqual, -); - -const getTimeStampListFromDuplicates = (duplicates: Duplicate[], columns) => { - const timeStampList: TimeStampListItem[] = []; - for (let index = 0; index < duplicates.length; index++) { - const dupes = duplicates[index]; - timeStampList.push({ - itemType: ITEM_TYPE.SIZE_AND_COUNT, - fileSize: dupes.size, - fileCount: dupes.files.length, - }); - let lastIndex = 0; - while (lastIndex < dupes.files.length) { - timeStampList.push({ - itemType: ITEM_TYPE.FILE, - items: dupes.files.slice(lastIndex, lastIndex + columns), - itemStartIndex: index, - }); - lastIndex += columns; - } - } - return timeStampList; -}; - -export function DedupePhotoList({ - height, - width, - duplicates, - getThumbnail, - activeCollectionID, -}: Props) { - const [timeStampList, setTimeStampList] = useState([]); - const refreshInProgress = useRef(false); - const shouldRefresh = useRef(false); - const listRef = useRef(null); - - const columns = useMemo(() => { - const fittableColumns = getFractionFittableColumns(width); - let columns = Math.floor(fittableColumns); - if (columns < MIN_COLUMNS) { - columns = MIN_COLUMNS; - } - return columns; - }, [width]); - - const shrinkRatio = getShrinkRatio(width, columns); - const listItemHeight = - IMAGE_CONTAINER_MAX_HEIGHT * shrinkRatio + GAP_BTW_TILES; - - const refreshList = () => { - listRef.current?.resetAfterIndex(0); - }; - - useEffect(() => { - const main = () => { - if (refreshInProgress.current) { - shouldRefresh.current = true; - return; - } - refreshInProgress.current = true; - const timeStampList = getTimeStampListFromDuplicates( - duplicates, - columns, - ); - setTimeStampList(timeStampList); - refreshInProgress.current = false; - if (shouldRefresh.current) { - shouldRefresh.current = false; - setTimeout(main, 0); - } - }; - main(); - }, [columns, duplicates]); - - useEffect(() => { - refreshList(); - }, [timeStampList]); - - const getItemSize = (timeStampList) => (index) => { - switch (timeStampList[index].itemType) { - case ITEM_TYPE.TIME: - return DATE_CONTAINER_HEIGHT; - case ITEM_TYPE.SIZE_AND_COUNT: - return SIZE_AND_COUNT_CONTAINER_HEIGHT; - case ITEM_TYPE.FILE: - return listItemHeight; - default: - return timeStampList[index].height; - } - }; - - const generateKey = (index) => { - switch (timeStampList[index].itemType) { - case ITEM_TYPE.FILE: - return `${timeStampList[index].items[0].id}-${ - timeStampList[index].items.slice(-1)[0].id - }`; - default: - return `${timeStampList[index].id}-${index}`; - } - }; - - const renderListItem = ( - listItem: TimeStampListItem, - isScrolling: boolean, - ) => { - switch (listItem.itemType) { - case ITEM_TYPE.SIZE_AND_COUNT: - return ( - /*TODO: Translate the full phrase instead of piecing - together parts like this See: - https://crowdin.com/editor/ente-photos-web/9/enus-de?view=comfortable&filter=basic&value=0#8104 - */ - - {listItem.fileCount} {t("FILES")},{" "} - {formattedByteSize(listItem.fileSize || 0)} {t("EACH")} - - ); - case ITEM_TYPE.FILE: { - const ret = listItem.items.map((item, idx) => - getThumbnail( - item, - listItem.itemStartIndex + idx, - isScrolling, - ), - ); - if (listItem.groups) { - let sum = 0; - for (let i = 0; i < listItem.groups.length - 1; i++) { - sum = sum + listItem.groups[i]; - ret.splice( - sum, - 0, -
, - ); - sum += 1; - } - } - return ret; - } - default: - return listItem.item; - } - }; - - if (!timeStampList?.length) { - return <>; - } - - const itemData = createItemData( - timeStampList, - columns, - shrinkRatio, - renderListItem, - ); - - return ( - - {PhotoListRow} - - ); -} diff --git a/web/apps/photos/src/components/PhotoList/index.tsx b/web/apps/photos/src/components/PhotoList/index.tsx index f6a3aeee1f..e8448725b4 100644 --- a/web/apps/photos/src/components/PhotoList/index.tsx +++ b/web/apps/photos/src/components/PhotoList/index.tsx @@ -1,5 +1,4 @@ import { EnteFile } from "@/media/file"; -import { formattedByteSize } from "@/new/photos/utils/units"; import { FlexWrapper } from "@ente/shared/components/Container"; import { formatDate } from "@ente/shared/time/format"; import { Box, Checkbox, Link, Typography, styled } from "@mui/material"; @@ -26,7 +25,6 @@ import { import type { PhotoFrameProps } from "components/PhotoFrame"; export const DATE_CONTAINER_HEIGHT = 48; -export const SIZE_AND_COUNT_CONTAINER_HEIGHT = 72; export const SPACE_BTW_DATES = 44; const SPACE_BTW_DATES_TO_IMAGE_CONTAINER_WIDTH_RATIO = 0.244; @@ -38,7 +36,6 @@ const ALBUM_FOOTER_HEIGHT_WITH_REFERRAL = 113; export enum ITEM_TYPE { TIME = "TIME", FILE = "FILE", - SIZE_AND_COUNT = "SIZE_AND_COUNT", HEADER = "HEADER", FOOTER = "FOOTER", MARKETING_FOOTER = "MARKETING_FOOTER", @@ -143,11 +140,6 @@ const DateContainer = styled(ListItemContainer)` color: ${({ theme }) => theme.colors.text.muted}; `; -const SizeAndCountContainer = styled(DateContainer)` - margin-top: 1rem; - height: ${SIZE_AND_COUNT_CONTAINER_HEIGHT}px; -`; - const FooterContainer = styled(ListItemContainer)` margin-bottom: 0.75rem; @media (max-width: 540px) { @@ -712,8 +704,6 @@ export function PhotoList({ switch (timeStampList[index].itemType) { case ITEM_TYPE.TIME: return DATE_CONTAINER_HEIGHT; - case ITEM_TYPE.SIZE_AND_COUNT: - return SIZE_AND_COUNT_CONTAINER_HEIGHT; case ITEM_TYPE.FILE: return listItemHeight; default: @@ -842,13 +832,6 @@ export function PhotoList({ {listItem.date} ); - case ITEM_TYPE.SIZE_AND_COUNT: - return ( - - {listItem.fileCount} {t("FILES")},{" "} - {formattedByteSize(listItem.fileSize || 0)} {t("EACH")} - - ); case ITEM_TYPE.FILE: { const ret = listItem.items.map((item, idx) => getThumbnail( diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index 2f09ccc578..362bc89251 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -514,7 +514,7 @@ const UtilitySection: React.FC = ({ closeSidebar }) => { await openAccountsManagePasskeysPage(); }; - const redirectToDeduplicatePage = () => router.push(PAGES.DEDUPLICATE); + const handleDeduplicate = () => router.push("/duplicates"); const toggleTheme = () => setThemeColor( @@ -573,7 +573,7 @@ const UtilitySection: React.FC = ({ closeSidebar }) => { /> void; - close: () => void; - count: number; - clearSelection: () => void; -} - -export default function DeduplicateOptions({ - deleteFileHelper, - close, - count, - clearSelection, -}: IProps) { - const { showMiniDialog } = useContext(AppContext); - - const trashHandler = () => - showMiniDialog({ - title: t("trash_files_title"), - message: t("trash_files_message"), - continue: { - text: t("move_to_trash"), - color: "critical", - action: deleteFileHelper, - }, - }); - - return ( - - - {count ? ( - - - - ) : ( - - - - )} - {t("selected_count", { selected: count })} - - - - - - - - ); -} diff --git a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx index bd0200bc2c..d5d598c3ee 100644 --- a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx +++ b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx @@ -15,10 +15,9 @@ import useLongPress from "@ente/shared/hooks/useLongPress"; import AlbumOutlined from "@mui/icons-material/AlbumOutlined"; import Favorite from "@mui/icons-material/FavoriteRounded"; import PlayCircleOutlineOutlinedIcon from "@mui/icons-material/PlayCircleOutlineOutlined"; -import { Tooltip, styled } from "@mui/material"; +import { styled } from "@mui/material"; import type { DisplayFile } from "components/PhotoFrame"; import i18n from "i18next"; -import { DeduplicateContext } from "pages/deduplicate"; import { GalleryContext } from "pages/gallery"; import React, { useContext, useEffect, useRef, useState } from "react"; import { shouldShowAvatar } from "utils/file"; @@ -235,7 +234,6 @@ const Cont = styled("div")<{ disabled: boolean }>` export default function PreviewCard(props: IProps) { const galleryContext = useContext(GalleryContext); - const deduplicateContext = useContext(DeduplicateContext); const longPressCallback = () => { onSelect(!selected); @@ -318,7 +316,7 @@ export default function PreviewCard(props: IProps) { } }; - const renderFn = () => ( + return ( - {deduplicateContext.isOnDeduplicatePage && ( - -

{file.metadata.title}

-

- {deduplicateContext.collectionNameMap.get( - file.collectionID, - )} -

-
- )} {props?.activeCollectionID === TRASH_SECTION && file.isTrashed && (

{formatDateRelative(file.deleteBy / 1000)}

@@ -389,25 +377,6 @@ export default function PreviewCard(props: IProps) { )}
); - - if (deduplicateContext.isOnDeduplicatePage) { - return ( - - {renderFn()} - - ); - } else { - return renderFn(); - } } function formatDateRelative(date: number) { diff --git a/web/apps/photos/src/pages/deduplicate.tsx b/web/apps/photos/src/pages/deduplicate.tsx deleted file mode 100644 index fbeb5bcbd1..0000000000 --- a/web/apps/photos/src/pages/deduplicate.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import { stashRedirect } from "@/accounts/services/redirect"; -import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; -import { errorDialogAttributes } from "@/base/components/utils/dialog"; -import log from "@/base/log"; -import { ALL_SECTION, moveToTrash } from "@/new/photos/services/collection"; -import { - getAllLatestCollections, - getLocalCollections, - syncTrash, -} from "@/new/photos/services/collections"; -import { - createFileCollectionIDs, - getLocalFiles, - syncFiles, -} from "@/new/photos/services/files"; -import { useAppContext } from "@/new/photos/types/context"; -import { VerticallyCentered } from "@ente/shared/components/Container"; -import { PHOTOS_PAGES as PAGES } from "@ente/shared/constants/pages"; -import { ApiError } from "@ente/shared/error"; -import useMemoSingleThreaded from "@ente/shared/hooks/useMemoSingleThreaded"; -import { SESSION_KEYS, getKey } from "@ente/shared/storage/sessionStorage"; -import { styled } from "@mui/material"; -import Typography from "@mui/material/Typography"; -import { HttpStatusCode } from "axios"; -import DeduplicateOptions from "components/pages/dedupe/SelectedFileOptions"; -import PhotoFrame from "components/PhotoFrame"; -import { t } from "i18next"; -import { default as Router, default as router } from "next/router"; -import { createContext, useEffect, useState } from "react"; -import { Duplicate, getDuplicates } from "services/deduplicationService"; -import { SelectedState } from "types/gallery"; -import { getSelectedFiles } from "utils/file"; - -export interface DeduplicateContextType { - isOnDeduplicatePage: boolean; - collectionNameMap: Map; -} - -export const DeduplicateContext = createContext({ - isOnDeduplicatePage: false, - collectionNameMap: new Map(), -}); - -export const Info = styled("div")` - padding: 24px; - font-size: 18px; -`; - -export default function Deduplicate() { - const { showNavBar, showLoadingBar, hideLoadingBar, showMiniDialog } = - useAppContext(); - const [duplicates, setDuplicates] = useState(null); - const [collectionNameMap, setCollectionNameMap] = useState( - new Map(), - ); - const [selected, setSelected] = useState({ - count: 0, - collectionID: 0, - ownCount: 0, - context: undefined, - }); - const closeDeduplication = function () { - Router.push(PAGES.GALLERY); - }; - useEffect(() => { - const key = getKey(SESSION_KEYS.ENCRYPTION_KEY); - if (!key) { - stashRedirect(PAGES.DEDUPLICATE); - router.push("/"); - return; - } - showNavBar(true); - }, []); - - useEffect(() => { - syncWithRemote(); - }, []); - - const syncWithRemote = async () => { - showLoadingBar(); - try { - const collections = await getLocalCollections(); - const collectionNameMap = new Map(); - for (const collection of collections) { - collectionNameMap.set(collection.id, collection.name); - } - setCollectionNameMap(collectionNameMap); - const files = await getLocalFiles(); - const duplicateFiles = await getDuplicates( - files, - collectionNameMap, - ); - const currFileSizeMap = new Map(); - let toSelectFileIDs: number[] = []; - let count = 0; - for (const dupe of duplicateFiles) { - // select all except first file - toSelectFileIDs = [ - ...toSelectFileIDs, - ...dupe.files.slice(1).map((f) => f.id), - ]; - count += dupe.files.length - 1; - - for (const file of dupe.files) { - currFileSizeMap.set(file.id, dupe.size); - } - } - setDuplicates(duplicateFiles); - const selectedFiles = { - count: count, - ownCount: count, - collectionID: ALL_SECTION, - context: undefined, - }; - for (const fileID of toSelectFileIDs) { - selectedFiles[fileID] = true; - } - setSelected(selectedFiles); - } finally { - hideLoadingBar(); - } - }; - - const duplicateFiles = useMemoSingleThreaded(() => { - return (duplicates ?? []).reduce((acc, dupe) => { - return [...acc, ...dupe.files]; - }, []); - }, [duplicates]); - - const fileToCollectionsMap = useMemoSingleThreaded(() => { - return createFileCollectionIDs(duplicateFiles ?? []); - }, [duplicateFiles]); - - const deleteFileHelper = async () => { - try { - showLoadingBar(); - const selectedFiles = getSelectedFiles(selected, duplicateFiles); - await moveToTrash(selectedFiles); - - // moveToTrash above does an API request, we still need to update - // our local state. - // - // Enhancement: This can be done in a more granular manner. Also, it - // is better to funnel these syncs instead of adding these here and - // there in an ad-hoc manner. For now, this fixes the issue with the - // UI not updating if the user deletes only some of the duplicates. - const collections = await getAllLatestCollections(); - await syncFiles( - "normal", - collections, - () => {}, - () => {}, - ); - await syncTrash(collections, () => {}); - await syncWithRemote(); - } catch (e) { - log.error("Dedup delete failed", e); - await syncWithRemote(); - // See: [Note: Chained MiniDialogs] - setTimeout(() => { - showMiniDialog( - errorDialogAttributes( - e instanceof ApiError && - e.httpStatusCode == HttpStatusCode.Forbidden - ? t("not_file_owner_delete_error") - : t("generic_error"), - ), - ); - }, 0); - } finally { - hideLoadingBar(); - } - }; - - const clearSelection = function () { - setSelected({ - count: 0, - collectionID: 0, - ownCount: 0, - context: undefined, - }); - }; - - if (!duplicates) { - return ( - - - - ); - } - - return ( - - {duplicateFiles.length > 0 && ( - {t("DEDUPLICATE_BASED_ON_SIZE")} - )} - {duplicateFiles.length === 0 ? ( - - - {t("NO_DUPLICATES_FOUND")} - - - ) : ( - - )} - - - ); -} diff --git a/web/apps/photos/src/pages/gallery.tsx b/web/apps/photos/src/pages/gallery.tsx index 7b8d799930..4c2550a32a 100644 --- a/web/apps/photos/src/pages/gallery.tsx +++ b/web/apps/photos/src/pages/gallery.tsx @@ -1029,7 +1029,6 @@ export default function Gallery() { ) : ( , -) { - try { - const ascDupes = await fetchDuplicateFileIDs(); - - const descSortedDupes = ascDupes.sort((firstDupe, secondDupe) => { - return secondDupe.size - firstDupe.size; - }); - - const fileMap = new Map(); - for (const file of files) { - fileMap.set(file.id, file); - } - - let result: Duplicate[] = []; - - for (const dupe of descSortedDupes) { - let duplicateFiles: EnteFile[] = []; - for (const fileID of dupe.fileIDs) { - if (fileMap.has(fileID)) { - duplicateFiles.push(fileMap.get(fileID)); - } - } - duplicateFiles = await sortDuplicateFiles( - duplicateFiles, - collectionNameMap, - ); - - if (duplicateFiles.length > 1) { - result = [ - ...result, - ...getDupesGroupedBySameFileHashes({ - files: duplicateFiles, - size: dupe.size, - }), - ]; - } - } - - return result; - } catch (e) { - log.error("failed to get duplicate files", e); - } -} - -const hasFileHash = (file: Metadata) => !!metadataHash(file); - -function getDupesGroupedBySameFileHashes(dupe: Duplicate) { - const result: Duplicate[] = []; - - const fileWithHashes: EnteFile[] = []; - const fileWithoutHashes: EnteFile[] = []; - for (const file of dupe.files) { - if (hasFileHash(file.metadata)) { - fileWithHashes.push(file); - } else { - fileWithoutHashes.push(file); - } - } - - if (fileWithHashes.length > 1) { - result.push( - ...groupDupesByFileHashes({ - files: fileWithHashes, - size: dupe.size, - }), - ); - } - - if (fileWithoutHashes.length > 1) { - result.push({ - files: fileWithoutHashes, - size: dupe.size, - }); - } - return result; -} - -function groupDupesByFileHashes(dupe: Duplicate) { - const result: Duplicate[] = []; - - const filesSortedByFileHash = dupe.files - .map((file) => { - return { - file, - hash: metadataHash(file.metadata), - }; - }) - .sort((firstFile, secondFile) => { - return firstFile.hash.localeCompare(secondFile.hash); - }); - - let sameHashFiles: EnteFile[] = []; - sameHashFiles.push(filesSortedByFileHash[0].file); - for (let i = 1; i < filesSortedByFileHash.length; i++) { - if ( - areFileHashesSame( - filesSortedByFileHash[i - 1].file.metadata, - filesSortedByFileHash[i].file.metadata, - ) - ) { - sameHashFiles.push(filesSortedByFileHash[i].file); - } else { - if (sameHashFiles.length > 1) { - result.push({ - files: [...sameHashFiles], - size: dupe.size, - }); - } - sameHashFiles = [filesSortedByFileHash[i].file]; - } - } - if (sameHashFiles.length > 1) { - result.push({ - files: sameHashFiles, - size: dupe.size, - }); - } - - return result; -} - -async function fetchDuplicateFileIDs() { - try { - const response = await HTTPService.get( - await apiURL("/files/duplicates"), - null, - { - "X-Auth-Token": getToken(), - }, - ); - return (response.data as DuplicatesResponse).duplicates; - } catch (e) { - log.error("failed to fetch duplicate file IDs", e); - } -} - -async function sortDuplicateFiles( - files: EnteFile[], - collectionNameMap: Map, -) { - return files.sort((firstFile, secondFile) => { - const firstCollectionName = collectionNameMap - .get(firstFile.collectionID) - .toLocaleLowerCase(); - const secondCollectionName = collectionNameMap - .get(secondFile.collectionID) - .toLocaleLowerCase(); - return firstCollectionName.localeCompare(secondCollectionName); - }); -} - -function areFileHashesSame(firstFile: Metadata, secondFile: Metadata) { - return metadataHash(firstFile) === metadataHash(secondFile); -} diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json index 256aaab9bd..37ebc9d83c 100644 --- a/web/packages/base/locales/en-US/translation.json +++ b/web/packages/base/locales/en-US/translation.json @@ -349,7 +349,6 @@ "leave_shared_album_title": "Leave shared album?", "leave_shared_album_message": "You will leave the album, and it will stop being visible to you.", "leave_shared_album": "Yes, leave", - "not_file_owner_delete_error": "You cannot delete files in a shared album", "confirm_remove_message": "Selected items will be removed from this album. Items which are only in this album will be moved to Uncategorized.", "confirm_remove_incl_others_message": "Some of the items you are removing were added by other people, and you will lose access to them.", "oldest": "Oldest", @@ -418,10 +417,14 @@ "folder": "Folder", "google_takeout": "Google takeout", "DEDUPLICATE_FILES": "Deduplicate files", - "NO_DUPLICATES_FOUND": "You have no duplicate files that can be cleared", + "remove_duplicates": "Remove duplicates", + "total_size": "Total size", + "count": "Count", + "deselect_all": "Deselect all", + "no_duplicates": "No duplicates", + "duplicate_group_description": "{{count}} items, {{itemSize}} each", + "remove_duplicates_button_count": "Delete {{count, number}} items", "FILES": "files", - "EACH": "each", - "DEDUPLICATE_BASED_ON_SIZE": "The following files were clubbed based on their sizes, please review and delete items you believe are duplicates", "stop_uploads_title": "Stop uploads?", "stop_uploads_message": "Are you sure that you want to stop all the uploads in progress?", "yes_stop_uploads": "Yes, stop uploads", diff --git a/web/packages/new/photos/pages/duplicates.tsx b/web/packages/new/photos/pages/duplicates.tsx index 5802ad6b95..c09030aadd 100644 --- a/web/packages/new/photos/pages/duplicates.tsx +++ b/web/packages/new/photos/pages/duplicates.tsx @@ -8,7 +8,6 @@ import { OverflowMenuOption, } from "@/base/components/OverflowMenu"; import { Ellipsized2LineTypography } from "@/base/components/Typography"; -import { pt } from "@/base/i18n"; import log from "@/base/log"; import { formattedByteSize } from "@/new/photos/utils/units"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; @@ -26,6 +25,7 @@ import { Tooltip, Typography, } from "@mui/material"; +import { t } from "i18next"; import { useRouter } from "next/router"; import React, { memo, @@ -57,13 +57,14 @@ import { import { useAppContext } from "../types/context"; const Page: React.FC = () => { - const { onGenericError } = useAppContext(); + const { showNavBar, onGenericError } = useAppContext(); const [state, dispatch] = useReducer(dedupReducer, initialDedupState); useRedirectIfNeedsCredentials("/duplicates"); useEffect(() => { + showNavBar(false); dispatch({ type: "analyze" }); void deduceDuplicates() .then((duplicateGroups) => @@ -93,13 +94,13 @@ const Page: React.FC = () => { }, [state.duplicateGroups, onGenericError]); const contents = (() => { - switch (state.status) { + switch (state.analysisStatus) { case undefined: - case "analyzing": + case "started": return ; - case "analysisFailed": + case "failed": return ; - case "analysisCompleted": + case "completed": if (state.duplicateGroups.length == 0) { return ; } else { @@ -117,8 +118,6 @@ const Page: React.FC = () => { /> ); } - default: - return ; } })(); @@ -141,8 +140,8 @@ export default Page; type SortOrder = "prunableCount" | "prunableSize"; interface DedupState { - /** Status of the screen, between initial state => analysis */ - status: undefined | "analyzing" | "analysisFailed" | "analysisCompleted"; + /** Status of the analysis ("loading") process. */ + analysisStatus: undefined | "started" | "failed" | "completed"; /** * Groups of duplicates. * @@ -188,7 +187,7 @@ type DedupAction = | { type: "dedupeCompleted"; removedGroupIDs: Set }; const initialDedupState: DedupState = { - status: undefined, + analysisStatus: undefined, duplicateGroups: [], sortOrder: "prunableSize", prunableCount: 0, @@ -202,9 +201,9 @@ const dedupReducer: React.Reducer = ( ) => { switch (action.type) { case "analyze": - return { ...state, status: "analyzing" }; + return { ...state, analysisStatus: "started" }; case "analysisFailed": - return { ...state, status: "analysisFailed" }; + return { ...state, analysisStatus: "failed" }; case "analysisCompleted": { const duplicateGroups = sortedCopyOfDuplicateGroups( action.duplicateGroups, @@ -215,7 +214,7 @@ const dedupReducer: React.Reducer = ( deducePrunableCountAndSize(duplicateGroups); return { ...state, - status: "analysisCompleted", + analysisStatus: "completed", duplicateGroups, selected, prunableCount, @@ -295,7 +294,8 @@ const dedupReducer: React.Reducer = ( * Return a copy of the given {@link duplicateGroups}, also sorting them as per * the given {@link sortOrder}. * - * Helper method for the reducer */ + * Helper method for the reducer. + */ const sortedCopyOfDuplicateGroups = ( duplicateGroups: DuplicateGroup[], sortOrder: DedupState["sortOrder"], @@ -306,7 +306,7 @@ const sortedCopyOfDuplicateGroups = ( : b.prunableCount - a.prunableCount, ); -/** Helper method for the reducer */ +/** Helper method for the reducer. */ const deducePrunableCountAndSize = (duplicateGroups: DuplicateGroup[]) => { const prunableCount = duplicateGroups.reduce( (sum, { prunableCount, isSelected }) => @@ -359,7 +359,7 @@ const Navbar: React.FC = ({ - {pt("Remove duplicates")} + {t("remove_duplicates")} @@ -377,7 +377,7 @@ const SortMenu: React.FC = ({ + } @@ -386,13 +386,13 @@ const SortMenu: React.FC = ({ endIcon={sortOrder == "prunableSize" ? : undefined} onClick={() => onChangeSortOrder("prunableSize")} > - {pt("Total size")} + {t("total_size")} : undefined} onClick={() => onChangeSortOrder("prunableCount")} > - {pt("Count")} + {t("count")} ); @@ -405,7 +405,7 @@ const OptionsMenu: React.FC = ({ onDeselectAll }) => ( startIcon={} onClick={onDeselectAll} > - {pt("Deselect all")} + {t("deselect_all")} ); @@ -425,7 +425,7 @@ const LoadFailed: React.FC = () => ( const NoDuplicatesFound: React.FC = () => ( - {pt("No duplicates")} + {t("no_duplicates")} ); @@ -574,7 +574,7 @@ const ListItem: React.FC> = }} > - {pt(`${count} items, ${itemSize} each`)} + {t("duplicate_group_description", { count, itemSize })} {/* The size of this Checkbox is 42px. */} @@ -661,7 +661,9 @@ const DeduplicateButton: React.FC = ({ ) : ( <> - {pt(`Delete ${prunableCount} items`)} + {t("remove_duplicates_button_count", { + count: prunableCount, + })} {formattedByteSize(prunableSize)} diff --git a/web/packages/new/photos/services/dedup.ts b/web/packages/new/photos/services/dedup.ts index 5ceed26127..0efeb21e5d 100644 --- a/web/packages/new/photos/services/dedup.ts +++ b/web/packages/new/photos/services/dedup.ts @@ -32,16 +32,17 @@ export interface DuplicateGroup { /** * The underlying file to delete. * - * This is one of the files from amongst {@link collectionFiles}, - * arbitrarily picked to stand in for the entire set of files in the UI. + * This is one of the files from amongst collection files that have the + * same ID, arbitrarily picked to stand in for the entire set of files + * in the UI. The set of the collections to which these files belong is + * retained separately in {@link collectionIDs}. */ file: EnteFile; /** - * All the collection files for the underlying file. - * - * This includes {@link file} too. + * IDs of the collection to which {@link file} and its collection file + * siblings belong. */ - collectionFiles: EnteFile[]; + collectionIDs: Set; /** * The name of the collection to which {@link file} belongs. * @@ -124,9 +125,9 @@ export const deduceDuplicates = async () => { ); // Group the filtered collection files by their hashes, keeping only one - // entry per file ID. We also retain all the collections files for a - // particular file ID. - const collectionFilesByFileID = new Map(); + // entry per file ID. Also retain the IDs of all the collections to which a + // particular file (ID) belongs. + const collectionIDsByFileID = new Map>(); const filesByHash = new Map(); for (const file of filteredCollectionFiles) { const hash = metadataHash(file.metadata); @@ -136,16 +137,14 @@ export const deduceDuplicates = async () => { continue; } - const collectionFiles = collectionFilesByFileID.get(file.id); - if (!collectionFiles) { + let collectionIDs = collectionIDsByFileID.get(file.id); + if (!collectionIDs) { + collectionIDsByFileID.set(file.id, (collectionIDs = new Set())); // This is the first collection file we're seeing for a particular // file ID, so also create an entry in the filesByHash map. filesByHash.set(hash, [...(filesByHash.get(hash) ?? []), file]); } - collectionFilesByFileID.set(file.id, [ - ...(collectionFiles ?? []), - file, - ]); + collectionIDs.add(file.collectionID); } // Construct the results from groups that have more than one file with the @@ -183,15 +182,15 @@ export const deduceDuplicates = async () => { const collectionName = collectionNameByID.get( file.collectionID, ); - const collectionFiles = collectionFilesByFileID.get(file.id); + const collectionIDs = collectionIDsByFileID.get(file.id); // Ignore duplicates for which we do not have a collection. This // shouldn't really happen though, so retain an assert. - if (!collectionName || !collectionFiles) { + if (!collectionName || !collectionIDs) { assertionFailed(); return undefined; } - return { file, collectionFiles, collectionName }; + return { file, collectionIDs, collectionName }; }) .filter((item) => !!item); if (items.length < 2) continue; @@ -214,7 +213,7 @@ export const deduceDuplicates = async () => { }; /** - * Remove duplicate groups that the user has retained from those that we + * Remove duplicate groups that the user has selected from those that we * returned in {@link deduceDuplicates}. * * @param duplicateGroups A list of duplicate groups. This is the same list as @@ -240,34 +239,30 @@ export const removeSelectedDuplicateGroups = async ( // 1. For each selected duplicate group, determine the file to retain. // 2. Add these to the user owned collections the other files exist in. // 3. Delete the other files. - // + /* collection ID => files */ const filesToAdd = new Map(); + /* only one entry per fileID */ const filesToTrash: EnteFile[] = []; for (const duplicateGroup of selectedDuplicateGroups) { const retainedItem = duplicateGroupItemToRetain(duplicateGroup); - // Find the existing collection IDs to which this item already belongs. - const existingCollectionIDs = new Set( - retainedItem.collectionFiles.map((cf) => cf.collectionID), - ); // For each item, find all the collections to which any of the files // (except the file we're retaining) belongs. - const collectionIDs = new Set(); + let collectionIDs = new Set(); for (const item of duplicateGroup.items) { // Skip the item we're retaining. if (item.file.id == retainedItem.file.id) continue; - // Determine the collections to which any of the item's files belong. - for (const { collectionID } of item.collectionFiles) { - if (!existingCollectionIDs.has(collectionID)) - collectionIDs.add(collectionID); - } + collectionIDs = collectionIDs.union(item.collectionIDs); // Move the item's file to trash. filesToTrash.push(item.file); } - // Add the file we're retaining to these (uniqued) collections. + // Skip the existing collection IDs to which this item already belongs. + collectionIDs = collectionIDs.difference(retainedItem.collectionIDs); + + // Add the file we're retaining to these collections. for (const collectionID of collectionIDs) { filesToAdd.set(collectionID, [ ...(filesToAdd.get(collectionID) ?? []), @@ -283,11 +278,8 @@ export const removeSelectedDuplicateGroups = async ( // Process the adds. const collections = await getLocalCollections("normal"); const collectionsByID = new Map(collections.map((c) => [c.id, c])); - for (const [collectionID, collectionFiles] of filesToAdd.entries()) { - await addToCollection( - collectionsByID.get(collectionID)!, - collectionFiles, - ); + for (const [collectionID, files] of filesToAdd.entries()) { + await addToCollection(collectionsByID.get(collectionID)!, files); tickProgress(); } @@ -297,6 +289,7 @@ export const removeSelectedDuplicateGroups = async ( tickProgress(); } + // Sync our local state. await syncFilesAndCollections(); tickProgress(); diff --git a/web/packages/shared/constants/pages.tsx b/web/packages/shared/constants/pages.tsx index 45dd108a2a..1348c0f4ae 100644 --- a/web/packages/shared/constants/pages.tsx +++ b/web/packages/shared/constants/pages.tsx @@ -13,7 +13,6 @@ export enum PHOTOS_PAGES { VERIFY = "/verify", ROOT = "/", SHARED_ALBUMS = "/shared-albums", - DEDUPLICATE = "/deduplicate", } export enum AUTH_PAGES {