From 67ea0cfe734f9128411cfc1f5e3dec303b09650a Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 12:22:22 +0530 Subject: [PATCH 01/84] Debugging code --- .../new/photos/services/ml/cluster-new.ts | 26 ++++++++++--------- .../new/photos/services/ml/cluster.ts | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts index 9e07b2812c..94e5efe11e 100644 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -343,17 +343,18 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // A flattened array of faces. // TODO-Cluster note the 2k slice - const faces = [...enumerateFaces(faceIndexes)].slice(0, 2000); + const faces0 = [...enumerateFaces(faceIndexes)];//.slice(0, 2000); + const faces = Array(1).fill(0).flatMap(() => faces0); const faceEmbeddings = faces.map(({ embedding }) => embedding); const { clusters: clusterIndices, - noise, - debugInfo, + // noise, + // debugInfo, } = clusterFacesHdbscan(faceEmbeddings); - log.info({ method: "hdbscan", clusterIndices, noise, debugInfo }); + // log.info({ method: "hdbscan", clusterIndices, noise, debugInfo }); log.info( `Clustered ${faces.length} faces into ${clusterIndices.length} clusters (${Date.now() - t} ms)`, ); @@ -387,7 +388,8 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // Convert into the data structure we're using to debug/visualize. const faceAndNeigbours: FaceNeighbours[] = []; - for (const fi of faces) { + const topFaces = faces.sort((a, b) => b.score - a.score).slice(0, 30); + for (const fi of topFaces) { let neighbours: FaceNeighbour[] = []; for (const fj of faces) { // The vectors are already normalized, so we can directly use their @@ -437,13 +439,13 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { ).faceID; } - log.info("ml/cluster", { - faces, - validClusters, - clusterIndexForClusterID: Object.fromEntries(clusterIndexForClusterID), - clusterIDForFaceID: Object.fromEntries(clusterIDForFaceID), - cgroups, - }); + // log.info("ml/cluster", { + // faces, + // validClusters, + // clusterIndexForClusterID: Object.fromEntries(clusterIndexForClusterID), + // clusterIDForFaceID: Object.fromEntries(clusterIDForFaceID), + // cgroups, + // }); log.info( `Clustered ${faces.length} faces into ${validClusters.length} clusters (${Date.now() - t} ms)`, ); diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index ff62f466a9..53e4930d94 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -24,7 +24,7 @@ export const clusterFacesHdbscan = ( minSamples: 5, clusterSelectionEpsilon: 0.6, clusterSelectionMethod: "leaf", - debug: true, + debug: false, }); return { From cd69e00451496ca4311f7a551a7e6ce2852bfe41 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 18:27:44 +0530 Subject: [PATCH 02/84] Batch --- .../new/photos/services/ml/cluster-new.ts | 132 +++++++++++++----- 1 file changed, 100 insertions(+), 32 deletions(-) diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts index 94e5efe11e..e934b54676 100644 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -342,48 +342,116 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { const t = Date.now(); // A flattened array of faces. - // TODO-Cluster note the 2k slice - const faces0 = [...enumerateFaces(faceIndexes)];//.slice(0, 2000); - const faces = Array(1).fill(0).flatMap(() => faces0); + const faces0 = [...enumerateFaces(faceIndexes)]; + // TODO-Cluster testing code, can be removed once done + const faces = Array(1) + .fill(0) + .flatMap(() => faces0); + + // For fast reverse lookup - map from face ids to the face. + const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); const faceEmbeddings = faces.map(({ embedding }) => embedding); - const { - clusters: clusterIndices, - // noise, - // debugInfo, - } = clusterFacesHdbscan(faceEmbeddings); - - // log.info({ method: "hdbscan", clusterIndices, noise, debugInfo }); - log.info( - `Clustered ${faces.length} faces into ${clusterIndices.length} clusters (${Date.now() - t} ms)`, - ); - // For fast reverse lookup - map from cluster ids to their index in the // clusters array. const clusterIndexForClusterID = new Map(); - // For fast reverse lookup - map from face ids to the id of the cluster to - // which they belong. + // For fast reverse lookup - map from the id of a face to the id of the + // cluster to which it belongs. const clusterIDForFaceID = new Map(); + // A function to chain two reverse lookup. + const firstFaceOfCluster = (cluster: FaceCluster) => + ensure(faceForFaceID.get(ensure(cluster.faceIDs[0]))); + // A function to generate new cluster IDs. const newClusterID = () => newNonSecureID("cluster_"); - // Convert the numerical face indices into the result. + // The resultant clusters. + // TODO-Cluster Later on, instead of starting from a blank slate, this will + // be list of existing clusters we fetch from remote. const clusters: FaceCluster[] = []; - for (const [ci, faceIndices] of clusterIndices.entries()) { - const clusterID = newClusterID(); - const faceIDs: string[] = []; - clusterIndexForClusterID.set(clusterID, ci); - for (const fi of faceIndices) { - // Can't find a way of avoiding the null assertion here. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const face = faces[fi]!; - clusterIDForFaceID.set(face.faceID, clusterID); - faceIDs.push(face.faceID); + + // Process the faces in batches of 10k. The faces are already sorted by file + // ID, which is a monotonically increasing integer, so we will also have + // some temporal locality. + // + // The number 10k was derived by ad-hoc observations. On a particular test + // dataset, clustering 10k took ~2 mins, while 20k took ~8 mins. Memory + // usage was constant in both cases. + // + // At around 100k faces, the clustering starts taking hours, and we start + // running into stack overflows. The stack overflows can perhaps be avoided + // by restructuring the code, but hours of uninterruptible work is anyways + // not feasible. + + const batchSize = 10_000; + for (let i = 0; i < faceEmbeddings.length; i += batchSize) { + const embeddings = faceEmbeddings.slice(i, i + batchSize); + const { clusters: hdbClusters } = clusterFacesHdbscan(embeddings); + + log.info( + `hdbscan produced ${hdbClusters.length} clusters from ${embeddings.length} faces (${Date.now() - t} ms)`, + ); + + // Merge the new clusters we got from hdbscan into the existing clusters + // if they are "near" them (using some heuristic). + // + // We need to ensure we don't change any of the existing cluster IDs, + // since these might be existing clusters we got from remote. + + for (const hdbCluster of hdbClusters) { + // Find the existing cluster whose (arbitrarily chosen) first face + // is the nearest neighbour of the (arbitrarily chosen) first face + // of the cluster produced by hdbscan. + + const newFace = ensure(faces[i + ensure(hdbCluster[0])]); + + let nnCluster: FaceCluster | undefined; + let nnCosineSimilarity = 0; + for (const existingCluster of clusters) { + const existingFace = firstFaceOfCluster(existingCluster); + + // The vectors are already normalized, so we can directly use their + // dot product as their cosine similarity. + const csim = dotProduct( + existingFace.embedding, + newFace.embedding, + ); + + // Use a higher cosine similarity threshold if either of the two + // faces are blurry. + const threshold = + existingFace.blur < 100 || newFace.blur < 100 ? 0.84 : 0.7; + if (csim > threshold && csim > nnCosineSimilarity) { + nnCluster = existingCluster; + nnCosineSimilarity = csim; + } + } + + if (nnCluster) { + // If we found an existing cluster that is near enough, + // sublimate the cluster produced by hdbscan into that cluster. + for (const j of hdbCluster) { + const { faceID } = ensure(faces[i + j]); + nnCluster.faceIDs.push(faceID); + clusterIDForFaceID.set(faceID, nnCluster.id); + } + } else { + // Otherwise make a new cluster from the cluster produced by + // hdbscan. + const clusterID = newClusterID(); + const faceIDs: string[] = []; + for (const j of hdbCluster) { + const { faceID } = ensure(faces[i + j]); + faceIDs.push(faceID); + clusterIDForFaceID.set(faceID, clusterID); + } + clusterIndexForClusterID.set(clusterID, clusters.length); + clusters.push({ id: clusterID, faceIDs }); + } } - clusters.push({ id: clusterID, faceIDs }); } // Convert into the data structure we're using to debug/visualize. @@ -398,14 +466,15 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { neighbours.push({ face: fj, cosineSimilarity: csim }); } - neighbours = neighbours.sort( - (a, b) => b.cosineSimilarity - a.cosineSimilarity, - ); + neighbours = neighbours + .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) + .slice(0, 30); faceAndNeigbours.push({ face: fi, neighbours }); } // Prune too small clusters. + // TODO-Cluster this is likely not needed since hdbscan already has a min? const validClusters = clusters.filter(({ faceIDs }) => faceIDs.length > 1); let cgroups = await clusterGroups(); @@ -426,7 +495,6 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // For each cluster group, use the highest scoring face in any of its // clusters as its display face. - const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); for (const cgroup of cgroups) { cgroup.displayFaceID = cgroup.clusterIDs .map((clusterID) => clusterIndexForClusterID.get(clusterID)) From 89a5a9f42f09be8a348676b04f55099d46b87630 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 18:42:17 +0530 Subject: [PATCH 03/84] Prune --- web/packages/new/photos/services/ml/index.ts | 23 +++++++------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 43d90578b8..53940232f6 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -387,16 +387,14 @@ export const wipClusterDebugPageContents = async (): Promise< const fileForFace = ({ faceID }: Face) => ensure(localFileByID.get(ensure(fileIDFromFaceID(faceID)))); - const faceFNs = faceAndNeigbours - .map(({ face, neighbours }) => ({ + const faceFNs = faceAndNeigbours.map(({ face, neighbours }) => ({ + face, + neighbours: neighbours.map(({ face, cosineSimilarity }) => ({ face, - neighbours: neighbours.map(({ face, cosineSimilarity }) => ({ - face, - enteFile: fileForFace(face), - cosineSimilarity, - })), - })) - .sort((a, b) => b.face.score - a.face.score); + enteFile: fileForFace(face), + cosineSimilarity, + })), + })); const clusterIDForFaceID = new Map( clusters.flatMap((cluster) => @@ -408,12 +406,7 @@ export const wipClusterDebugPageContents = async (): Promise< _wip_searchPersons = searchPersons; triggerStatusUpdate(); - const prunedFaceFNs = faceFNs.slice(0, 30).map(({ face, neighbours }) => ({ - face, - neighbours: neighbours.slice(0, 30), - })); - - return { faceFNs: prunedFaceFNs, clusters, clusterIDForFaceID }; + return { faceFNs, clusters, clusterIDForFaceID }; }; export const wipCluster = () => void wipClusterDebugPageContents(); From 2179b193d21af7a7f7218e889a6f16b37736245b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 19:07:54 +0530 Subject: [PATCH 04/84] Preview --- web/apps/photos/src/pages/cluster-debug.tsx | 4 +- .../new/photos/services/ml/cluster-new.ts | 76 +++++++++++++++---- web/packages/new/photos/services/ml/index.ts | 32 ++++++-- 3 files changed, 89 insertions(+), 23 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index c6abe7226f..dcffaecfd2 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -53,8 +53,8 @@ export default function ClusterDebug() { {`${clusterRes.clusters.length} clusters`} - Showing only upto first 30 faces (and only upto 30 nearest - neighbours of each). + Showing only top 20 and bottom 10 clusters (and only up to 50 faces in + each, sorted by cosine distance to highest scoring face in the cluster).
diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts index e934b54676..983c128a6a 100644 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -124,6 +124,16 @@ interface FaceNeighbour { cosineSimilarity: number; } +export interface ClusterPreview { + clusterSize: number; + faces: ClusterPreviewFace[]; +} + +interface ClusterPreviewFace { + face: Face; + cosineSimilarity: number; +} + /** * Cluster faces into groups. * @@ -455,22 +465,56 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { } // Convert into the data structure we're using to debug/visualize. - const faceAndNeigbours: FaceNeighbours[] = []; - const topFaces = faces.sort((a, b) => b.score - a.score).slice(0, 30); - for (const fi of topFaces) { - let neighbours: FaceNeighbour[] = []; - for (const fj of faces) { - // The vectors are already normalized, so we can directly use their - // dot product as their cosine similarity. - const csim = dotProduct(fi.embedding, fj.embedding); - neighbours.push({ face: fj, cosineSimilarity: csim }); + // const faceAndNeigbours: FaceNeighbours[] = []; + // const topFaces = faces.sort((a, b) => b.score - a.score).slice(0, 30); + // for (const fi of topFaces) { + // let neighbours: FaceNeighbour[] = []; + // for (const fj of faces) { + // // The vectors are already normalized, so we can directly use their + // // dot product as their cosine similarity. + // const csim = dotProduct(fi.embedding, fj.embedding); + // neighbours.push({ face: fj, cosineSimilarity: csim }); + // } + + // neighbours = neighbours + // .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) + // .slice(0, 30); + + // faceAndNeigbours.push({ face: fi, neighbours }); + // } + + // Convert into the data structure we're using to debug/visualize. + // + // > Showing only top 20 and bottom 10 clusters (and only up to 50 faces in + // > each, sorted by cosine distance to highest scoring face in the + // > cluster). + + const sortedClusters = clusters.sort( + (a, b) => b.faceIDs.length - a.faceIDs.length, + ); + const debugClusters = + sortedClusters.length < 30 + ? sortedClusters + : sortedClusters.slice(0, 20).concat(sortedClusters.slice(-10)); + const clusterPreviews: ClusterPreview[] = []; + for (const cluster of debugClusters) { + const faces = cluster.faceIDs.map((id) => + ensure(faceForFaceID.get(id)), + ); + const topFace = faces.reduce((max, face) => + max.score > face.score ? max : face, + ); + const previewFaces: ClusterPreviewFace[] = []; + for (const face of faces) { + const csim = dotProduct(topFace.embedding, face.embedding); + previewFaces.push({ face, cosineSimilarity: csim }); } - - neighbours = neighbours - .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) - .slice(0, 30); - - faceAndNeigbours.push({ face: fi, neighbours }); + clusterPreviews.push({ + clusterSize: cluster.faceIDs.length, + faces: previewFaces + .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) + .slice(0, 50), + }); } // Prune too small clusters. @@ -518,5 +562,5 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { `Clustered ${faces.length} faces into ${validClusters.length} clusters (${Date.now() - t} ms)`, ); - return { faces, clusters: validClusters, cgroups, faceAndNeigbours }; + return { faces, clusters: validClusters, cgroups, clusterPreviews }; }; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 53940232f6..df8d08235c 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -360,6 +360,18 @@ export interface FaceFileNeighbour { cosineSimilarity: number; } +// "with file" +export interface ClusterPreviewWF { + clusterSize: number; + faces: ClusterPreviewFaceWF[]; +} + +interface ClusterPreviewFaceWF { + face: Face; + enteFile: EnteFile; + cosineSimilarity: number; +} + export interface ClusterDebugPageContents { faceFNs: FaceFileNeighbours[]; clusters: FaceCluster[]; @@ -377,7 +389,7 @@ export const wipClusterDebugPageContents = async (): Promise< triggerStatusUpdate(); // const { faceAndNeigbours, clusters, cgroups } = await clusterFaces( - const { faceAndNeigbours, clusters, cgroups } = await clusterFacesHdb( + const { clusterPreviews, clusters, cgroups } = await clusterFacesHdb( await faceIndexes(), ); const searchPersons = await convertToSearchPersons(clusters, cgroups); @@ -387,9 +399,19 @@ export const wipClusterDebugPageContents = async (): Promise< const fileForFace = ({ faceID }: Face) => ensure(localFileByID.get(ensure(fileIDFromFaceID(faceID)))); - const faceFNs = faceAndNeigbours.map(({ face, neighbours }) => ({ - face, - neighbours: neighbours.map(({ face, cosineSimilarity }) => ({ + // const faceFNs = faceAndNeigbours.map( + // ({ topFace: face, faces: neighbours }) => ({ + // face, + // neighbours: neighbours.map(({ face, cosineSimilarity }) => ({ + // face, + // enteFile: fileForFace(face), + // cosineSimilarity, + // })), + // }), + // ); + const clusterPreviewWFs = clusterPreviews.map(({ clusterSize, faces }) => ({ + clusterSize, + faces: faces.map(({ face, cosineSimilarity }) => ({ face, enteFile: fileForFace(face), cosineSimilarity, @@ -406,7 +428,7 @@ export const wipClusterDebugPageContents = async (): Promise< _wip_searchPersons = searchPersons; triggerStatusUpdate(); - return { faceFNs, clusters, clusterIDForFaceID }; + return { clusterPreviewWFs, clusters, clusterIDForFaceID }; }; export const wipCluster = () => void wipClusterDebugPageContents(); From 3d952120233fe9de9b5a8a2dfc46422bfb4aba08 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 19:20:56 +0530 Subject: [PATCH 05/84] Preview --- web/apps/photos/src/pages/cluster-debug.tsx | 61 +++++++++++-------- .../new/photos/services/ml/cluster-new.ts | 7 ++- web/packages/new/photos/services/ml/index.ts | 5 +- 3 files changed, 42 insertions(+), 31 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index dcffaecfd2..23930f1f03 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -4,10 +4,9 @@ import { faceCrop, wipClusterDebugPageContents, type ClusterDebugPageContents, - type FaceFileNeighbour, - type FaceFileNeighbours, + type ClusterPreviewFaceWF, + type ClusterPreviewWF, } from "@/new/photos/services/ml"; -import type { Face } from "@/new/photos/services/ml/face"; import { FlexWrapper, FluidContainer, @@ -15,7 +14,7 @@ import { } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import BackButton from "@mui/icons-material/ArrowBackOutlined"; -import { Box, IconButton, styled, Typography } from "@mui/material"; +import { Box, IconButton, Stack, styled, Typography } from "@mui/material"; import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; @@ -53,8 +52,9 @@ export default function ClusterDebug() { {`${clusterRes.clusters.length} clusters`} - Showing only top 20 and bottom 10 clusters (and only up to 50 faces in - each, sorted by cosine distance to highest scoring face in the cluster). + Showing only top 20 and bottom 10 clusters (and only up to 50 + faces in each, sorted by cosine distance to highest scoring face + in the cluster).
@@ -112,7 +112,7 @@ const ClusterPhotoList: React.FC = ({ width, clusterRes, }) => { - const { faceFNs, clusterIDForFaceID } = clusterRes; + const { clusterPreviewWFs, clusterIDForFaceID } = clusterRes; const [itemList, setItemList] = useState([]); const listRef = useRef(null); @@ -125,8 +125,8 @@ const ClusterPhotoList: React.FC = ({ const listItemHeight = 120 * shrinkRatio + 24 + 4; useEffect(() => { - setItemList(itemListFromFaceFNs(faceFNs, columns)); - }, [columns, faceFNs]); + setItemList(itemListFromClusterPreviewWFs(clusterPreviewWFs, columns)); + }, [columns, clusterPreviewWFs]); useEffect(() => { listRef.current?.resetAfterIndex(0); @@ -138,7 +138,7 @@ const ClusterPhotoList: React.FC = ({ const generateKey = (i: number) => Array.isArray(itemList[i]) ? `${itemList[i][0].enteFile.id}/${itemList[i][0].face.faceID}-${itemList[i].slice(-1)[0].enteFile.id}/${itemList[i].slice(-1)[0].face.faceID}-${i}` - : `${itemList[i].faceID}-${i}`; + : `${itemList[i]}-${i}`; return ( = ({ > {!Array.isArray(item) ? ( - {`score ${item.score.toFixed(2)} blur ${item.blur.toFixed(0)}`} + {`cluster size ${item.toFixed(2)}`} ) : ( - item.map((faceFN, i) => ( + item.map((faceWF, i) => ( )) )} @@ -181,19 +181,20 @@ const ClusterPhotoList: React.FC = ({ ); }; -type ItemListItem = Face | FaceFileNeighbour[]; +// type ItemListItem = Face | FaceFileNeighbour[]; +type ItemListItem = number | ClusterPreviewFaceWF[]; -const itemListFromFaceFNs = ( - faceFNs: FaceFileNeighbours[], +const itemListFromClusterPreviewWFs = ( + clusterPreviewWFs: ClusterPreviewWF[], columns: number, ) => { const result: ItemListItem[] = []; - for (let index = 0; index < faceFNs.length; index++) { - const { face, neighbours } = faceFNs[index]; - result.push(face); + for (let index = 0; index < clusterPreviewWFs.length; index++) { + const { clusterSize, faces } = clusterPreviewWFs[index]; + result.push(clusterSize); let lastIndex = 0; - while (lastIndex < neighbours.length) { - result.push(neighbours.slice(lastIndex, lastIndex + columns)); + while (lastIndex < faces.length) { + result.push(faces.slice(lastIndex, lastIndex + columns)); lastIndex += columns; } } @@ -210,12 +211,12 @@ const getShrinkRatio = (width: number, columns: number) => (columns * 120); interface FaceItemProps { - faceFN: FaceFileNeighbour; + faceWF: ClusterPreviewFaceWF; clusterIDForFaceID: Map; } -const FaceItem: React.FC = ({ faceFN, clusterIDForFaceID }) => { - const { face, enteFile, cosineSimilarity } = faceFN; +const FaceItem: React.FC = ({ faceWF, clusterIDForFaceID }) => { + const { face, enteFile, cosineSimilarity } = faceWF; const { faceID } = face; const [objectURL, setObjectURL] = useState(); @@ -252,9 +253,15 @@ const FaceItem: React.FC = ({ faceFN, clusterIDForFaceID }) => { src={objectURL} /> )} - - {cosineSimilarity.toFixed(2)} - + + + {`${face.blur.toFixed(0)} blr`} + + + + {`cos ${cosineSimilarity.toFixed(2)}`} + + ); }; diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts index 983c128a6a..e2bf78eb35 100644 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -352,7 +352,10 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { const t = Date.now(); // A flattened array of faces. - const faces0 = [...enumerateFaces(faceIndexes)]; + // TODO-Cluster ad-hoc filtering and slicing + const faces0 = [...enumerateFaces(faceIndexes)] + .filter((f) => f.blur > 50) + .slice(0, 1000); // TODO-Cluster testing code, can be removed once done const faces = Array(1) .fill(0) @@ -433,7 +436,7 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // Use a higher cosine similarity threshold if either of the two // faces are blurry. const threshold = - existingFace.blur < 100 || newFace.blur < 100 ? 0.84 : 0.7; + existingFace.blur < 100 || newFace.blur < 100 ? 0.9 : 0.7; if (csim > threshold && csim > nnCosineSimilarity) { nnCluster = existingCluster; nnCosineSimilarity = csim; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index df8d08235c..699a9b9c14 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -366,14 +366,15 @@ export interface ClusterPreviewWF { faces: ClusterPreviewFaceWF[]; } -interface ClusterPreviewFaceWF { +export interface ClusterPreviewFaceWF { face: Face; enteFile: EnteFile; cosineSimilarity: number; } export interface ClusterDebugPageContents { - faceFNs: FaceFileNeighbours[]; + // faceFNs: FaceFileNeighbours[]; + clusterPreviewWFs: ClusterPreviewWF[]; clusters: FaceCluster[]; clusterIDForFaceID: Map; } From 29b5830e19a861e20fc646d7db5a8bfcbbab8382 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 19:31:24 +0530 Subject: [PATCH 06/84] Print scores --- web/apps/photos/src/pages/cluster-debug.tsx | 8 +++++--- web/packages/new/photos/services/ml/cluster-new.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 23930f1f03..62d2e05df4 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -255,11 +255,13 @@ const FaceItem: React.FC = ({ faceWF, clusterIDForFaceID }) => { )} - {`${face.blur.toFixed(0)} blr`} + {`b ${face.blur.toFixed(0)} b`} - - {`cos ${cosineSimilarity.toFixed(2)}`} + {`s ${face.score.toFixed(2)}`} + + + {`c ${cosineSimilarity.toFixed(2)}`} diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts index e2bf78eb35..e2149db180 100644 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -355,7 +355,7 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // TODO-Cluster ad-hoc filtering and slicing const faces0 = [...enumerateFaces(faceIndexes)] .filter((f) => f.blur > 50) - .slice(0, 1000); + .slice(0, 6000); // TODO-Cluster testing code, can be removed once done const faces = Array(1) .fill(0) From c9acda1b6d4b25149772039607bcbc8ff9e5dec9 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 19:47:16 +0530 Subject: [PATCH 07/84] Show direction --- web/apps/photos/src/pages/cluster-debug.tsx | 15 ++++++++++++--- web/packages/new/photos/services/ml/face.ts | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 62d2e05df4..0798ad608d 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -7,6 +7,7 @@ import { type ClusterPreviewFaceWF, type ClusterPreviewWF, } from "@/new/photos/services/ml"; +import { faceDirection } from "@/new/photos/services/ml/face"; import { FlexWrapper, FluidContainer, @@ -236,6 +237,8 @@ const FaceItem: React.FC = ({ faceWF, clusterIDForFaceID }) => { }; }, [faceID, enteFile]); + const fd = faceDirection(face.detection); + const d = fd == "straight" ? "•" : fd == "left" ? "←" : "→"; return ( = ({ faceWF, clusterIDForFaceID }) => { )} - {`b ${face.blur.toFixed(0)} b`} + {`b${face.blur.toFixed(0)} `} - {`s ${face.score.toFixed(2)}`} + {`s${face.score.toFixed(1)}`} - {`c ${cosineSimilarity.toFixed(2)}`} + {`c${cosineSimilarity.toFixed(1)}`} + + + {`c${cosineSimilarity.toFixed(1)}`} + + + {`d${d}`} diff --git a/web/packages/new/photos/services/ml/face.ts b/web/packages/new/photos/services/ml/face.ts index 891b605db2..d8616b7426 100644 --- a/web/packages/new/photos/services/ml/face.ts +++ b/web/packages/new/photos/services/ml/face.ts @@ -714,7 +714,7 @@ const detectBlur = ( type FaceDirection = "left" | "right" | "straight"; -const faceDirection = ({ landmarks }: FaceDetection): FaceDirection => { +export const faceDirection = ({ landmarks }: FaceDetection): FaceDirection => { const leftEye = landmarks[0]!; const rightEye = landmarks[1]!; const nose = landmarks[2]!; From 4fd32155dc98aee636a0f01e8933f18df91e3a7f Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 19:55:05 +0530 Subject: [PATCH 08/84] Worker --- .../new/photos/services/ml/cluster-new.ts | 13 +++++++++--- web/packages/new/photos/services/ml/index.ts | 21 +++++-------------- web/packages/new/photos/services/ml/worker.ts | 7 +++++++ 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts index e2149db180..1258551439 100644 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -399,7 +399,8 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // by restructuring the code, but hours of uninterruptible work is anyways // not feasible. - const batchSize = 10_000; + // const batchSize = 10_000; // TODO-Cluster + const batchSize = 1_000; for (let i = 0; i < faceEmbeddings.length; i += batchSize) { const embeddings = faceEmbeddings.slice(i, i + batchSize); const { clusters: hdbClusters } = clusterFacesHdbscan(embeddings); @@ -562,8 +563,14 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // cgroups, // }); log.info( - `Clustered ${faces.length} faces into ${validClusters.length} clusters (${Date.now() - t} ms)`, + `Clustered ${faces.length} faces into ${validClusters.length} clusters, with ${faces.length - clusterIDForFaceID.size} faces remaining unclustered (${Date.now() - t} ms)`, ); - return { faces, clusters: validClusters, cgroups, clusterPreviews }; + return { + faces, + clusters: validClusters, + cgroups, + clusterPreviews, + clusterIDForFaceID, + }; }; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 699a9b9c14..9f85f47119 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -20,14 +20,9 @@ import { getAllLocalFiles } from "../files"; import { getRemoteFlag, updateRemoteFlag } from "../remote-store"; import type { SearchPerson } from "../search/types"; import type { UploadItem } from "../upload/types"; -import { clusterFacesHdb, type CGroup, type FaceCluster } from "./cluster-new"; +import { type CGroup, type FaceCluster } from "./cluster-new"; import { regenerateFaceCrops } from "./crop"; -import { - clearMLDB, - faceIndex, - faceIndexes, - indexableAndIndexedCounts, -} from "./db"; +import { clearMLDB, faceIndex, indexableAndIndexedCounts } from "./db"; import type { Face } from "./face"; import { MLWorker } from "./worker"; import type { CLIPMatches } from "./worker-types"; @@ -390,9 +385,9 @@ export const wipClusterDebugPageContents = async (): Promise< triggerStatusUpdate(); // const { faceAndNeigbours, clusters, cgroups } = await clusterFaces( - const { clusterPreviews, clusters, cgroups } = await clusterFacesHdb( - await faceIndexes(), - ); + const { clusterPreviews, clusters, cgroups, clusterIDForFaceID } = + await worker().then((w) => w.clusterFacesHdb()); + const searchPersons = await convertToSearchPersons(clusters, cgroups); const localFiles = await getAllLocalFiles(); @@ -419,12 +414,6 @@ export const wipClusterDebugPageContents = async (): Promise< })), })); - const clusterIDForFaceID = new Map( - clusters.flatMap((cluster) => - cluster.faceIDs.map((id) => [id, cluster.id]), - ), - ); - _wip_isClustering = false; _wip_searchPersons = searchPersons; triggerStatusUpdate(); diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index f21f58d85a..e4a3e5ecab 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -24,8 +24,10 @@ import { indexCLIP, type CLIPIndex, } from "./clip"; +import { clusterFacesHdb } from "./cluster-new"; import { saveFaceCrops } from "./crop"; import { + faceIndexes, indexableFileIDs, markIndexingFailed, saveIndexes, @@ -272,6 +274,11 @@ export class MLWorker { remoteMLData: mlDataByID.get(id), })); } + + // TODO-Cluster + async clusterFacesHdb() { + return clusterFacesHdb(await faceIndexes()); + } } expose(MLWorker); From 15884597b4c27e4253f1db88eaf14360dcf27d51 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 19:57:30 +0530 Subject: [PATCH 09/84] uc --- web/apps/photos/src/pages/cluster-debug.tsx | 5 +---- .../new/photos/services/ml/cluster-new.ts | 11 +++++++--- web/packages/new/photos/services/ml/index.ts | 20 ++++++++++++++++--- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 0798ad608d..db187751de 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -50,7 +50,7 @@ export default function ClusterDebug() { return ( <> - {`${clusterRes.clusters.length} clusters`} + {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredCount} faces. ${clusterRes.unclusteredCount} unclustered faces.`} Showing only top 20 and bottom 10 clusters (and only up to 50 @@ -266,9 +266,6 @@ const FaceItem: React.FC = ({ faceWF, clusterIDForFaceID }) => { {`c${cosineSimilarity.toFixed(1)}`} - - {`c${cosineSimilarity.toFixed(1)}`} - {`d${d}`} diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts index 1258551439..e49db72385 100644 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -354,7 +354,7 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // A flattened array of faces. // TODO-Cluster ad-hoc filtering and slicing const faces0 = [...enumerateFaces(faceIndexes)] - .filter((f) => f.blur > 50) + .filter((f) => f.blur > 99) .slice(0, 6000); // TODO-Cluster testing code, can be removed once done const faces = Array(1) @@ -437,7 +437,7 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // Use a higher cosine similarity threshold if either of the two // faces are blurry. const threshold = - existingFace.blur < 100 || newFace.blur < 100 ? 0.9 : 0.7; + existingFace.blur < 200 || newFace.blur < 200 ? 0.9 : 0.7; if (csim > threshold && csim > nnCosineSimilarity) { nnCluster = existingCluster; nnCosineSimilarity = csim; @@ -566,8 +566,13 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { `Clustered ${faces.length} faces into ${validClusters.length} clusters, with ${faces.length - clusterIDForFaceID.size} faces remaining unclustered (${Date.now() - t} ms)`, ); + const clusteredCount = clusterIDForFaceID.size + const unclusteredCount = faces.length - clusteredCount; + return { - faces, + // faces, + clusteredCount, + unclusteredCount, clusters: validClusters, cgroups, clusterPreviews, diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 9f85f47119..3f588c09ad 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -368,6 +368,8 @@ export interface ClusterPreviewFaceWF { } export interface ClusterDebugPageContents { + clusteredCount: number; + unclusteredCount: number; // faceFNs: FaceFileNeighbours[]; clusterPreviewWFs: ClusterPreviewWF[]; clusters: FaceCluster[]; @@ -385,8 +387,14 @@ export const wipClusterDebugPageContents = async (): Promise< triggerStatusUpdate(); // const { faceAndNeigbours, clusters, cgroups } = await clusterFaces( - const { clusterPreviews, clusters, cgroups, clusterIDForFaceID } = - await worker().then((w) => w.clusterFacesHdb()); + const { + clusteredCount, + unclusteredCount, + clusterPreviews, + clusters, + cgroups, + clusterIDForFaceID, + } = await worker().then((w) => w.clusterFacesHdb()); const searchPersons = await convertToSearchPersons(clusters, cgroups); @@ -418,7 +426,13 @@ export const wipClusterDebugPageContents = async (): Promise< _wip_searchPersons = searchPersons; triggerStatusUpdate(); - return { clusterPreviewWFs, clusters, clusterIDForFaceID }; + return { + clusteredCount, + unclusteredCount, + clusterPreviewWFs, + clusters, + clusterIDForFaceID, + }; }; export const wipCluster = () => void wipClusterDebugPageContents(); From d6c7ab0735087aaff9eee6c9b574eaefcaa3fa19 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 20:18:31 +0530 Subject: [PATCH 10/84] Inline --- .../new/photos/services/ml/cluster-new.ts | 66 ++++++++++++------- web/packages/new/photos/services/ml/index.ts | 31 ++++++++- 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts index e49db72385..df97ca4bd9 100644 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -525,34 +525,56 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // TODO-Cluster this is likely not needed since hdbscan already has a min? const validClusters = clusters.filter(({ faceIDs }) => faceIDs.length > 1); - let cgroups = await clusterGroups(); + // let cgroups = await clusterGroups(); + + // // TODO-Cluster - Currently we're not syncing with remote or saving anything + // // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) + // // cgroup, one per cluster. + // cgroups = cgroups.concat( + // validClusters.map((c) => ({ + // id: c.id, + // name: undefined, + // clusterIDs: [c.id], + // isHidden: false, + // avatarFaceID: undefined, + // displayFaceID: undefined, + // })), + // ); + + // // For each cluster group, use the highest scoring face in any of its + // // clusters as its display face. + // for (const cgroup of cgroups) { + // cgroup.displayFaceID = cgroup.clusterIDs + // .map((clusterID) => clusterIndexForClusterID.get(clusterID)) + // .filter((i) => i !== undefined) /* 0 is a valid index */ + // .flatMap((i) => clusters[i]?.faceIDs ?? []) + // .map((faceID) => faceForFaceID.get(faceID)) + // .filter((face) => !!face) + // .reduce((max, face) => + // max.score > face.score ? max : face, + // ).faceID; + // } // TODO-Cluster - Currently we're not syncing with remote or saving anything // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) // cgroup, one per cluster. - cgroups = cgroups.concat( - validClusters.map((c) => ({ - id: c.id, + + const cgroups: CGroup[] = []; + for (const cluster of sortedClusters) { + const faces = cluster.faceIDs.map((id) => + ensure(faceForFaceID.get(id)), + ); + const topFace = faces.reduce((max, face) => + max.score > face.score ? max : face, + ); + cgroups.push({ + id: cluster.id, name: undefined, - clusterIDs: [c.id], + clusterIDs: [cluster.id], isHidden: false, avatarFaceID: undefined, - displayFaceID: undefined, - })), - ); - - // For each cluster group, use the highest scoring face in any of its - // clusters as its display face. - for (const cgroup of cgroups) { - cgroup.displayFaceID = cgroup.clusterIDs - .map((clusterID) => clusterIndexForClusterID.get(clusterID)) - .filter((i) => i !== undefined) /* 0 is a valid index */ - .flatMap((i) => clusters[i]?.faceIDs ?? []) - .map((faceID) => faceForFaceID.get(faceID)) - .filter((face) => !!face) - .reduce((max, face) => - max.score > face.score ? max : face, - ).faceID; + displayFaceID: topFace.faceID, + }); } // log.info("ml/cluster", { @@ -566,7 +588,7 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { `Clustered ${faces.length} faces into ${validClusters.length} clusters, with ${faces.length - clusterIDForFaceID.size} faces remaining unclustered (${Date.now() - t} ms)`, ); - const clusteredCount = clusterIDForFaceID.size + const clusteredCount = clusterIDForFaceID.size; const unclusteredCount = faces.length - clusteredCount; return { diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 3f588c09ad..4248c295f0 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -396,7 +396,7 @@ export const wipClusterDebugPageContents = async (): Promise< clusterIDForFaceID, } = await worker().then((w) => w.clusterFacesHdb()); - const searchPersons = await convertToSearchPersons(clusters, cgroups); + // const searchPersons = await convertToSearchPersons(clusters, cgroups); const localFiles = await getAllLocalFiles(); const localFileByID = new Map(localFiles.map((f) => [f.id, f])); @@ -422,6 +422,32 @@ export const wipClusterDebugPageContents = async (): Promise< })), })); + const clusterByID = new Map(clusters.map((c) => [c.id, c])); + + const searchPersons = cgroups + .map((cgroup) => { + const faceID = ensure(cgroup.displayFaceID); + const fileID = ensure(fileIDFromFaceID(faceID)); + const file = ensure(localFileByID.get(fileID)); + + const faceIDs = cgroup.clusterIDs + .map((id) => ensure(clusterByID.get(id))) + .flatMap((cluster) => cluster.faceIDs); + const fileIDs = faceIDs + .map((faceID) => fileIDFromFaceID(faceID)) + .filter((fileID) => fileID !== undefined); + + return { + id: cgroup.id, + name: cgroup.name, + faceIDs, + files: [...new Set(fileIDs)], + displayFaceID: faceID, + displayFaceFile: file, + }; + }) + .sort((a, b) => b.faceIDs.length - a.faceIDs.length); + _wip_isClustering = false; _wip_searchPersons = searchPersons; triggerStatusUpdate(); @@ -437,7 +463,8 @@ export const wipClusterDebugPageContents = async (): Promise< export const wipCluster = () => void wipClusterDebugPageContents(); -const convertToSearchPersons = async ( +// TODO-Cluster remove me +export const convertToSearchPersons = async ( clusters: FaceCluster[], cgroups: CGroup[], ) => { From 577b2624184a2d45a1c41bc5c86ae72c514b7a13 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Wed, 28 Aug 2024 01:12:41 +0530 Subject: [PATCH 11/84] fix: change sentence case for android debug builds --- mobile/android/app/src/debug/res/values/strings.xml | 2 +- mobile/android/app/src/dev/res/values/strings.xml | 2 +- mobile/android/app/src/face/res/values/strings.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mobile/android/app/src/debug/res/values/strings.xml b/mobile/android/app/src/debug/res/values/strings.xml index 9749285e5b..2253459b7e 100644 --- a/mobile/android/app/src/debug/res/values/strings.xml +++ b/mobile/android/app/src/debug/res/values/strings.xml @@ -1,4 +1,4 @@ - ente debug + Ente Debug backup debug diff --git a/mobile/android/app/src/dev/res/values/strings.xml b/mobile/android/app/src/dev/res/values/strings.xml index 3f5e2af1d1..50a363d10f 100644 --- a/mobile/android/app/src/dev/res/values/strings.xml +++ b/mobile/android/app/src/dev/res/values/strings.xml @@ -1,4 +1,4 @@ - ente dev + Ente Dev backup dev diff --git a/mobile/android/app/src/face/res/values/strings.xml b/mobile/android/app/src/face/res/values/strings.xml index 4932deb961..ac4281e80e 100644 --- a/mobile/android/app/src/face/res/values/strings.xml +++ b/mobile/android/app/src/face/res/values/strings.xml @@ -1,4 +1,4 @@ - ente face + Ente Face backup face From 2044d3eb6bebba5f4f55a374ef798460e2b5a37e Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Wed, 28 Aug 2024 01:13:00 +0530 Subject: [PATCH 12/84] chore: add translation keys --- mobile/lib/generated/intl/messages_en.dart | 3 +++ mobile/lib/generated/l10n.dart | 20 ++++++++++++++++++++ mobile/lib/l10n/intl_en.arb | 2 ++ 3 files changed, 25 insertions(+) diff --git a/mobile/lib/generated/intl/messages_en.dart b/mobile/lib/generated/intl/messages_en.dart index 0c377470aa..801ea0c58a 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -390,6 +390,9 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("Backup over mobile data"), "backupSettings": MessageLookupByLibrary.simpleMessage("Backup settings"), + "backupStatus": MessageLookupByLibrary.simpleMessage("Backup status"), + "backupStatusDescription": MessageLookupByLibrary.simpleMessage( + "Items that have been backed up will show up here"), "backupVideos": MessageLookupByLibrary.simpleMessage("Backup videos"), "blackFridaySale": MessageLookupByLibrary.simpleMessage("Black Friday Sale"), diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index e88b25f990..5ede242db1 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -3172,6 +3172,26 @@ class S { ); } + /// `Backup status` + String get backupStatus { + return Intl.message( + 'Backup status', + name: 'backupStatus', + desc: '', + args: [], + ); + } + + /// `Items that have been backed up will show up here` + String get backupStatusDescription { + return Intl.message( + 'Items that have been backed up will show up here', + name: 'backupStatusDescription', + desc: '', + args: [], + ); + } + /// `Backup over mobile data` String get backupOverMobileData { return Intl.message( diff --git a/mobile/lib/l10n/intl_en.arb b/mobile/lib/l10n/intl_en.arb index ae78fa60c3..c252714447 100644 --- a/mobile/lib/l10n/intl_en.arb +++ b/mobile/lib/l10n/intl_en.arb @@ -453,6 +453,8 @@ "showMemories": "Show memories", "yearsAgo": "{count, plural, one{{count} year ago} other{{count} years ago}}", "backupSettings": "Backup settings", + "backupStatus": "Backup status", + "backupStatusDescription": "Items that have been backed up will show up here", "backupOverMobileData": "Backup over mobile data", "backupVideos": "Backup videos", "disableAutoLock": "Disable auto lock", From 864b5514be07a2d0048843a69939d1bfdbd259f4 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Wed, 28 Aug 2024 01:19:44 +0530 Subject: [PATCH 13/84] feat(backup): introduce backup status screen --- mobile/lib/events/backup_updated_event.dart | 10 + mobile/lib/models/backup/backup_item.dart | 55 ++++++ .../lib/models/backup/backup_item_status.dart | 7 + .../ui/settings/backup/backup_item_card.dart | 171 ++++++++++++++++++ .../backup/backup_section_widget.dart | 16 ++ .../settings/backup/backup_status_screen.dart | 110 +++++++++++ mobile/lib/utils/file_uploader.dart | 63 ++++++- 7 files changed, 428 insertions(+), 4 deletions(-) create mode 100644 mobile/lib/events/backup_updated_event.dart create mode 100644 mobile/lib/models/backup/backup_item.dart create mode 100644 mobile/lib/models/backup/backup_item_status.dart create mode 100644 mobile/lib/ui/settings/backup/backup_item_card.dart create mode 100644 mobile/lib/ui/settings/backup/backup_status_screen.dart diff --git a/mobile/lib/events/backup_updated_event.dart b/mobile/lib/events/backup_updated_event.dart new file mode 100644 index 0000000000..7d710df199 --- /dev/null +++ b/mobile/lib/events/backup_updated_event.dart @@ -0,0 +1,10 @@ +import "dart:collection"; + +import "package:photos/events/event.dart"; +import "package:photos/models/backup/backup_item.dart"; + +class BackupUpdatedEvent extends Event { + final LinkedHashMap items; + + BackupUpdatedEvent(this.items); +} diff --git a/mobile/lib/models/backup/backup_item.dart b/mobile/lib/models/backup/backup_item.dart new file mode 100644 index 0000000000..02ea0c442d --- /dev/null +++ b/mobile/lib/models/backup/backup_item.dart @@ -0,0 +1,55 @@ +import "dart:async"; + +import "package:photos/models/backup/backup_item_status.dart"; +import "package:photos/models/file/file.dart"; + +class BackupItem { + final BackupItemStatus status; + final EnteFile file; + final int collectionID; + final Completer completer; + + BackupItem({ + required this.status, + required this.file, + required this.collectionID, + required this.completer, + }); + + BackupItem copyWith({ + BackupItemStatus? status, + EnteFile? file, + int? collectionID, + Completer? completer, + }) { + return BackupItem( + status: status ?? this.status, + file: file ?? this.file, + collectionID: collectionID ?? this.collectionID, + completer: completer ?? this.completer, + ); + } + + @override + String toString() { + return 'BackupItem(status: $status, file: $file, collectionID: $collectionID)'; + } + + @override + bool operator ==(covariant BackupItem other) { + if (identical(this, other)) return true; + + return other.status == status && + other.file == file && + other.collectionID == collectionID && + other.completer == completer; + } + + @override + int get hashCode { + return status.hashCode ^ + file.hashCode ^ + collectionID.hashCode ^ + completer.hashCode; + } +} diff --git a/mobile/lib/models/backup/backup_item_status.dart b/mobile/lib/models/backup/backup_item_status.dart new file mode 100644 index 0000000000..b4aedfa562 --- /dev/null +++ b/mobile/lib/models/backup/backup_item_status.dart @@ -0,0 +1,7 @@ +enum BackupItemStatus { + inBackground, + inQueue, + uploading, + completed, + retry, +} diff --git a/mobile/lib/ui/settings/backup/backup_item_card.dart b/mobile/lib/ui/settings/backup/backup_item_card.dart new file mode 100644 index 0000000000..a566fc6a4a --- /dev/null +++ b/mobile/lib/ui/settings/backup/backup_item_card.dart @@ -0,0 +1,171 @@ +import "dart:typed_data"; + +import 'package:flutter/material.dart'; +import "package:photos/models/backup/backup_item.dart"; +import "package:photos/models/backup/backup_item_status.dart"; +import 'package:photos/theme/ente_theme.dart'; +import "package:photos/utils/file_uploader.dart"; +import "package:photos/utils/thumbnail_util.dart"; + +class BackupItemCard extends StatefulWidget { + const BackupItemCard({ + super.key, + required this.item, + }); + + final BackupItem item; + + @override + State createState() => _BackupItemCardState(); +} + +class _BackupItemCardState extends State { + Uint8List? thumbnail; + String? folderName; + + @override + void initState() { + super.initState(); + _getThumbnail(); + _getFolderName(); + } + + @override + void dispose() { + super.dispose(); + } + + _getThumbnail() async { + thumbnail = await getThumbnail(widget.item.file); + setState(() {}); + } + + _getFolderName() async { + folderName = widget.item.file.deviceFolder ?? ''; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final colorScheme = getEnteColorScheme(context); + return Container( + height: 60, + margin: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xFF000000).withOpacity(0.08) + : const Color(0xFFFFFFFF).withOpacity(0.08), + width: 1, + ), + ), + child: Row( + children: [ + SizedBox( + width: 60, + height: 60, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: thumbnail != null + ? Image.memory( + thumbnail!, + fit: BoxFit.cover, + ) + : const SizedBox(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.item.file.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 20 / 16, + color: Theme.of(context).brightness == Brightness.light + ? const Color(0xFF000000) + : const Color(0xFFFFFFFF), + ), + ), + const SizedBox(height: 4), + Text( + folderName ?? "", + style: TextStyle( + fontSize: 14, + height: 17 / 14, + color: Theme.of(context).brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 0.7) + : const Color.fromRGBO(255, 255, 255, 0.7), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + SizedBox( + height: 48, + width: 48, + child: Center( + child: switch (widget.item.status) { + BackupItemStatus.uploading => SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2.0, + color: colorScheme.primary700, + ), + ), + BackupItemStatus.completed => const SizedBox( + width: 24, + height: 24, + child: Icon( + Icons.check, + color: Color(0xFF00B33C), + ), + ), + BackupItemStatus.inQueue => SizedBox( + width: 24, + height: 24, + child: Icon( + Icons.history, + color: Theme.of(context).brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, .6) + : const Color.fromRGBO(255, 255, 255, .6), + ), + ), + BackupItemStatus.retry => IconButton( + icon: const Icon( + Icons.sync, + color: Color(0xFFFDB816), + ), + onPressed: () async { + await FileUploader.instance.forceUpload( + widget.item.file, + widget.item.collectionID, + ); + }, + ), + BackupItemStatus.inBackground => SizedBox( + width: 24, + height: 24, + child: Icon( + Icons.lock_reset, + color: Theme.of(context).brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, .6) + : const Color.fromRGBO(255, 255, 255, .6), + ), + ), + }, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/ui/settings/backup/backup_section_widget.dart b/mobile/lib/ui/settings/backup/backup_section_widget.dart index 183b79b203..56ef0e02f7 100644 --- a/mobile/lib/ui/settings/backup/backup_section_widget.dart +++ b/mobile/lib/ui/settings/backup/backup_section_widget.dart @@ -6,6 +6,7 @@ import 'package:photos/ui/components/expandable_menu_item_widget.dart'; import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/settings/backup/backup_folder_selection_page.dart'; import 'package:photos/ui/settings/backup/backup_settings_screen.dart'; +import "package:photos/ui/settings/backup/backup_status_screen.dart"; import "package:photos/ui/settings/backup/free_space_options.dart"; import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/utils/navigation_util.dart'; @@ -47,6 +48,21 @@ class BackupSectionWidgetState extends State { }, ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: S.of(context).backupStatus, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + await routeToPage( + context, + const BackupStatusScreen(), + ); + }, + ), + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).backupSettings, diff --git a/mobile/lib/ui/settings/backup/backup_status_screen.dart b/mobile/lib/ui/settings/backup/backup_status_screen.dart new file mode 100644 index 0000000000..4117e2c980 --- /dev/null +++ b/mobile/lib/ui/settings/backup/backup_status_screen.dart @@ -0,0 +1,110 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import "dart:collection"; + +import 'package:flutter/material.dart'; +import "package:photos/core/event_bus.dart"; +import "package:photos/events/backup_updated_event.dart"; +import "package:photos/generated/l10n.dart"; +import "package:photos/models/backup/backup_item.dart"; +import 'package:photos/ui/components/title_bar_title_widget.dart'; +import 'package:photos/ui/components/title_bar_widget.dart'; +import "package:photos/ui/settings/backup/backup_item_card.dart"; +import "package:photos/utils/file_uploader.dart"; + +class BackupStatusScreen extends StatefulWidget { + const BackupStatusScreen({super.key}); + + @override + State createState() => _BackupStatusScreenState(); +} + +class _BackupStatusScreenState extends State { + LinkedHashMap items = FileUploader.instance.allBackups; + + @override + void initState() { + super.initState(); + + checkBackupUpdatedEvent(); + } + + void checkBackupUpdatedEvent() { + Bus.instance.on().listen((event) { + items = event.items; + setState(() {}); + }); + } + + @override + Widget build(BuildContext context) { + final List items = this.items.values.toList(); + + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: S.of(context).backupStatus, + ), + ), + items.isEmpty + ? SliverFillRemaining( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 60, + vertical: 12, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_upload_outlined, + color: + Theme.of(context).brightness == Brightness.light + ? const Color.fromRGBO(0, 0, 0, 0.6) + : const Color.fromRGBO(255, 255, 255, 0.6), + ), + const SizedBox(height: 16), + Text( + S.of(context).backupStatusDescription, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + height: 20 / 16, + color: + Theme.of(context).brightness == Brightness.light + ? const Color(0xFF000000).withOpacity(0.7) + : const Color(0xFFFFFFFF).withOpacity(0.7), + ), + ), + const SizedBox(height: 48), + ], + ), + ), + ) + : SliverList( + delegate: SliverChildBuilderDelegate( + (delegateBuildContext, index) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 20, + horizontal: 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final item in items) + BackupItemCard(item: item), + ], + ), + ); + }, + childCount: 1, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/utils/file_uploader.dart b/mobile/lib/utils/file_uploader.dart index 6e81e5acc5..c4c8217c5e 100644 --- a/mobile/lib/utils/file_uploader.dart +++ b/mobile/lib/utils/file_uploader.dart @@ -16,11 +16,14 @@ import 'package:photos/core/event_bus.dart'; import 'package:photos/core/network/network.dart'; import 'package:photos/db/files_db.dart'; import 'package:photos/db/upload_locks_db.dart'; +import "package:photos/events/backup_updated_event.dart"; import "package:photos/events/file_uploaded_event.dart"; import 'package:photos/events/files_updated_event.dart'; import 'package:photos/events/local_photos_updated_event.dart'; import 'package:photos/events/subscription_purchased_event.dart'; import 'package:photos/main.dart'; +import "package:photos/models/backup/backup_item.dart"; +import "package:photos/models/backup/backup_item_status.dart"; import 'package:photos/models/encryption_result.dart'; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; @@ -59,11 +62,15 @@ class FileUploader { final _enteDio = NetworkClient.instance.enteDio; final LinkedHashMap _queue = LinkedHashMap(); + final LinkedHashMap _allBackups = + LinkedHashMap(); final _uploadLocks = UploadLocksDB.instance; final kSafeBufferForLockExpiry = const Duration(days: 1).inMicroseconds; final kBGTaskDeathTimeout = const Duration(seconds: 5).inMicroseconds; final _uploadURLs = Queue(); + LinkedHashMap get allBackups => _allBackups; + // Maintains the count of files in the current upload session. // Upload session is the period between the first entry into the _queue and last entry out of the _queue int _totalCountInUploadSession = 0; @@ -160,6 +167,13 @@ class FileUploader { if (!_queue.containsKey(localID)) { final completer = Completer(); _queue[localID] = FileUploadItem(file, collectionID, completer); + _allBackups[localID] = BackupItem( + status: BackupItemStatus.inQueue, + file: file, + collectionID: collectionID, + completer: completer, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); _pollQueue(); return completer.future; } @@ -203,6 +217,10 @@ class FileUploader { }); for (final id in uploadsToBeRemoved) { _queue.remove(id)?.completer.completeError(reason); + _allBackups[id] = _allBackups[id]!.copyWith( + status: BackupItemStatus.retry, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); } _totalCountInUploadSession = 0; } @@ -225,6 +243,9 @@ class FileUploader { }); for (final id in uploadsToBeRemoved) { _queue.remove(id)?.completer.completeError(reason); + _allBackups[id] = + _allBackups[id]!.copyWith(status: BackupItemStatus.retry); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); } _logger.info( 'number of enteries removed from queue ${uploadsToBeRemoved.length}', @@ -291,13 +312,21 @@ class FileUploader { }, ); _queue.remove(localID)!.completer.complete(uploadedFile); + _allBackups[localID] = + _allBackups[localID]!.copyWith(status: BackupItemStatus.completed); return uploadedFile; } catch (e) { if (e is LockAlreadyAcquiredError) { _queue[localID]!.status = UploadStatus.inBackground; + _allBackups[localID] = _allBackups[localID]! + .copyWith(status: BackupItemStatus.inBackground); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); return _queue[localID]!.completer.future; } else { _queue.remove(localID)!.completer.completeError(e); + _allBackups[localID] = + _allBackups[localID]!.copyWith(status: BackupItemStatus.retry); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); return null; } } finally { @@ -406,7 +435,20 @@ class FileUploader { Future forceUpload(EnteFile file, int collectionID) async { _hasInitiatedForceUpload = true; - return _tryToUpload(file, collectionID, true); + try { + final result = await _tryToUpload(file, collectionID, true); + _allBackups[file.localID!] = _allBackups[file.localID]!.copyWith( + status: BackupItemStatus.completed, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); + return result; + } catch (_) { + _allBackups[file.localID!] = _allBackups[file.localID]!.copyWith( + status: BackupItemStatus.retry, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); + rethrow; + } } Future _tryToUpload( @@ -426,6 +468,14 @@ class FileUploader { return fileOnDisk; } } + + if (_allBackups[file.localID!] != null && + _allBackups[file.localID]!.status != BackupItemStatus.uploading) { + _allBackups[file.localID!] = _allBackups[file.localID]!.copyWith( + status: BackupItemStatus.uploading, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); + } if ((file.localID ?? '') == '') { _logger.severe('Trying to upload file with missing localID'); return file; @@ -442,7 +492,7 @@ class FileUploader { } final String lockKey = file.localID!; - bool _isMultipartUpload = false; + bool isMultipartUpload = false; try { await _uploadLocks.acquireLock( @@ -589,7 +639,7 @@ class FileUploader { final fileUploadURL = await _getUploadURL(); fileObjectKey = await _putFile(fileUploadURL, encryptedFile); } else { - _isMultipartUpload = true; + isMultipartUpload = true; _logger.finest( "Init multipartUpload $multipartEntryExists, isUpdate $isUpdatedFile", ); @@ -757,7 +807,7 @@ class FileUploader { encryptedFilePath, encryptedThumbnailPath, lockKey: lockKey, - isMultiPartUpload: _isMultipartUpload, + isMultiPartUpload: isMultipartUpload, ); } } @@ -1280,10 +1330,15 @@ class FileUploader { if (dbFile?.uploadedFileID != null) { _logger.info("Background upload success detected"); completer?.complete(dbFile); + _allBackups[upload.key] = _allBackups[upload.key]! + .copyWith(status: BackupItemStatus.completed); } else { _logger.info("Background upload failure detected"); completer?.completeError(SilentlyCancelUploadsError()); + _allBackups[upload.key] = + _allBackups[upload.key]!.copyWith(status: BackupItemStatus.retry); } + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); } } Future.delayed(kBlockedUploadsPollFrequency, () async { From 5662661326fd4c8cd62f146991cf1f6cdec88200 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Wed, 28 Aug 2024 01:22:00 +0530 Subject: [PATCH 14/84] fix(backup-status): limit folder name to single line --- mobile/lib/ui/settings/backup/backup_item_card.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mobile/lib/ui/settings/backup/backup_item_card.dart b/mobile/lib/ui/settings/backup/backup_item_card.dart index a566fc6a4a..e99d1ed54b 100644 --- a/mobile/lib/ui/settings/backup/backup_item_card.dart +++ b/mobile/lib/ui/settings/backup/backup_item_card.dart @@ -96,6 +96,8 @@ class _BackupItemCardState extends State { const SizedBox(height: 4), Text( folderName ?? "", + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14, height: 17 / 14, From 325871f7c5456be01e3b32dbde964f4814562056 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Thu, 29 Aug 2024 00:53:57 +0530 Subject: [PATCH 15/84] fix(backup): attach reason of error, use ListView.builder, use upload instead of forceUpload --- mobile/lib/models/backup/backup_item.dart | 9 +++- .../ui/settings/backup/backup_item_card.dart | 10 ++--- .../settings/backup/backup_status_screen.dart | 13 +++--- mobile/lib/utils/file_uploader.dart | 44 ++++++++++++------- 4 files changed, 48 insertions(+), 28 deletions(-) diff --git a/mobile/lib/models/backup/backup_item.dart b/mobile/lib/models/backup/backup_item.dart index 02ea0c442d..957f0f883c 100644 --- a/mobile/lib/models/backup/backup_item.dart +++ b/mobile/lib/models/backup/backup_item.dart @@ -8,12 +8,14 @@ class BackupItem { final EnteFile file; final int collectionID; final Completer completer; + final Object? error; BackupItem({ required this.status, required this.file, required this.collectionID, required this.completer, + this.error, }); BackupItem copyWith({ @@ -21,18 +23,20 @@ class BackupItem { EnteFile? file, int? collectionID, Completer? completer, + Object? error, }) { return BackupItem( status: status ?? this.status, file: file ?? this.file, collectionID: collectionID ?? this.collectionID, completer: completer ?? this.completer, + error: error ?? this.error, ); } @override String toString() { - return 'BackupItem(status: $status, file: $file, collectionID: $collectionID)'; + return 'BackupItem(status: $status, file: $file, collectionID: $collectionID, error: $error)'; } @override @@ -42,7 +46,8 @@ class BackupItem { return other.status == status && other.file == file && other.collectionID == collectionID && - other.completer == completer; + other.completer == completer && + other.error == error; } @override diff --git a/mobile/lib/ui/settings/backup/backup_item_card.dart b/mobile/lib/ui/settings/backup/backup_item_card.dart index e99d1ed54b..40be26cbdc 100644 --- a/mobile/lib/ui/settings/backup/backup_item_card.dart +++ b/mobile/lib/ui/settings/backup/backup_item_card.dart @@ -147,17 +147,17 @@ class _BackupItemCardState extends State { color: Color(0xFFFDB816), ), onPressed: () async { - await FileUploader.instance.forceUpload( + await FileUploader.instance.upload( widget.item.file, widget.item.collectionID, ); }, ), BackupItemStatus.inBackground => SizedBox( - width: 24, - height: 24, - child: Icon( - Icons.lock_reset, + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2.0, color: Theme.of(context).brightness == Brightness.light ? const Color.fromRGBO(0, 0, 0, .6) : const Color.fromRGBO(255, 255, 255, .6), diff --git a/mobile/lib/ui/settings/backup/backup_status_screen.dart b/mobile/lib/ui/settings/backup/backup_status_screen.dart index 4117e2c980..0d7199ca47 100644 --- a/mobile/lib/ui/settings/backup/backup_status_screen.dart +++ b/mobile/lib/ui/settings/backup/backup_status_screen.dart @@ -91,12 +91,13 @@ class _BackupStatusScreenState extends State { vertical: 20, horizontal: 16, ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final item in items) - BackupItemCard(item: item), - ], + child: ListView.builder( + shrinkWrap: true, + primary: false, + itemBuilder: (context, index) { + return BackupItemCard(item: items[index]); + }, + itemCount: items.length, ), ); }, diff --git a/mobile/lib/utils/file_uploader.dart b/mobile/lib/utils/file_uploader.dart index c4c8217c5e..0b3ee1c4bc 100644 --- a/mobile/lib/utils/file_uploader.dart +++ b/mobile/lib/utils/file_uploader.dart @@ -219,6 +219,7 @@ class FileUploader { _queue.remove(id)?.completer.completeError(reason); _allBackups[id] = _allBackups[id]!.copyWith( status: BackupItemStatus.retry, + error: reason, ); Bus.instance.fire(BackupUpdatedEvent(_allBackups)); } @@ -243,8 +244,8 @@ class FileUploader { }); for (final id in uploadsToBeRemoved) { _queue.remove(id)?.completer.completeError(reason); - _allBackups[id] = - _allBackups[id]!.copyWith(status: BackupItemStatus.retry); + _allBackups[id] = _allBackups[id]! + .copyWith(status: BackupItemStatus.retry, error: reason); Bus.instance.fire(BackupUpdatedEvent(_allBackups)); } _logger.info( @@ -283,6 +284,10 @@ class FileUploader { } if (pendingEntry != null) { pendingEntry.status = UploadStatus.inProgress; + _allBackups[pendingEntry.file.localID!] = + _allBackups[pendingEntry.file.localID]! + .copyWith(status: BackupItemStatus.uploading); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); _encryptAndUploadFileToCollection( pendingEntry.file, pendingEntry.collectionID, @@ -314,6 +319,7 @@ class FileUploader { _queue.remove(localID)!.completer.complete(uploadedFile); _allBackups[localID] = _allBackups[localID]!.copyWith(status: BackupItemStatus.completed); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); return uploadedFile; } catch (e) { if (e is LockAlreadyAcquiredError) { @@ -324,8 +330,8 @@ class FileUploader { return _queue[localID]!.completer.future; } else { _queue.remove(localID)!.completer.completeError(e); - _allBackups[localID] = - _allBackups[localID]!.copyWith(status: BackupItemStatus.retry); + _allBackups[localID] = _allBackups[localID]! + .copyWith(status: BackupItemStatus.retry, error: e); Bus.instance.fire(BackupUpdatedEvent(_allBackups)); return null; } @@ -435,18 +441,24 @@ class FileUploader { Future forceUpload(EnteFile file, int collectionID) async { _hasInitiatedForceUpload = true; + final isInQueue = _allBackups[file.localID!] != null; try { final result = await _tryToUpload(file, collectionID, true); - _allBackups[file.localID!] = _allBackups[file.localID]!.copyWith( - status: BackupItemStatus.completed, - ); - Bus.instance.fire(BackupUpdatedEvent(_allBackups)); + if (isInQueue) { + _allBackups[file.localID!] = _allBackups[file.localID]!.copyWith( + status: BackupItemStatus.completed, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); + } return result; - } catch (_) { - _allBackups[file.localID!] = _allBackups[file.localID]!.copyWith( - status: BackupItemStatus.retry, - ); - Bus.instance.fire(BackupUpdatedEvent(_allBackups)); + } catch (error) { + if (isInQueue) { + _allBackups[file.localID!] = _allBackups[file.localID]!.copyWith( + status: BackupItemStatus.retry, + error: error, + ); + Bus.instance.fire(BackupUpdatedEvent(_allBackups)); + } rethrow; } } @@ -1335,8 +1347,10 @@ class FileUploader { } else { _logger.info("Background upload failure detected"); completer?.completeError(SilentlyCancelUploadsError()); - _allBackups[upload.key] = - _allBackups[upload.key]!.copyWith(status: BackupItemStatus.retry); + _allBackups[upload.key] = _allBackups[upload.key]!.copyWith( + status: BackupItemStatus.retry, + error: SilentlyCancelUploadsError(), + ); } Bus.instance.fire(BackupUpdatedEvent(_allBackups)); } From ac0ae000154889154dcc25ca87889369d59554c0 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 27 Aug 2024 14:29:20 +0530 Subject: [PATCH 16/84] [mob][auth] Update flutter submodule to v3.24.1 --- auth/flutter | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth/flutter b/auth/flutter index 80c2e84975..5874a72aa4 160000 --- a/auth/flutter +++ b/auth/flutter @@ -1 +1 @@ -Subproject commit 80c2e84975bbd28ecf5f8d4bd4ca5a2490bfc819 +Subproject commit 5874a72aa4c779a02553007c47dacbefba2374dc From d413ed2de0e8bf47703ca9603be8e28a0aac9df5 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Tue, 27 Aug 2024 14:29:27 +0530 Subject: [PATCH 17/84] [mob][auth] Update flutter version in github workflows --- .github/workflows/auth-lint.yml | 2 +- .github/workflows/auth-release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auth-lint.yml b/.github/workflows/auth-lint.yml index 4518c542da..2362d2b344 100644 --- a/.github/workflows/auth-lint.yml +++ b/.github/workflows/auth-lint.yml @@ -8,7 +8,7 @@ on: - ".github/workflows/auth-lint.yml" env: - FLUTTER_VERSION: "3.24.0" + FLUTTER_VERSION: "3.24.1" jobs: lint: diff --git a/.github/workflows/auth-release.yml b/.github/workflows/auth-release.yml index ef7a3d9192..d67cf1c7e9 100644 --- a/.github/workflows/auth-release.yml +++ b/.github/workflows/auth-release.yml @@ -29,7 +29,7 @@ on: - "auth-v*" env: - FLUTTER_VERSION: "3.24.0" + FLUTTER_VERSION: "3.24.1" jobs: build-ubuntu: From d5a1187e13a20c3c58d73b01314eb3447ce14843 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Thu, 29 Aug 2024 20:33:18 +0530 Subject: [PATCH 18/84] Prep --- web/apps/photos/src/pages/cluster-debug.tsx | 24 +++++++++----- .../new/photos/components/MLSettings.tsx | 15 +++++---- .../new/photos/services/ml/cluster-new.ts | 32 +++++++++---------- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index db187751de..fcbeadfcab 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -49,14 +49,22 @@ export default function ClusterDebug() { } return ( <> - - {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredCount} faces. ${clusterRes.unclusteredCount} unclustered faces.`} - - - Showing only top 20 and bottom 10 clusters (and only up to 50 - faces in each, sorted by cosine distance to highest scoring face - in the cluster). - + + + {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredCount} faces. ${clusterRes.unclusteredCount} unclustered faces.`} + + + Showing only top 30 and bottom 30 clusters. + + + For each cluster showing only up to 50 faces, sorted by + cosine similarity to highest scoring face in the cluster. + + + Below each face is its{" "} + blur - score - cosineSimilarity - direction + +
diff --git a/web/packages/new/photos/components/MLSettings.tsx b/web/packages/new/photos/components/MLSettings.tsx index eeff4d1be8..c8785110b6 100644 --- a/web/packages/new/photos/components/MLSettings.tsx +++ b/web/packages/new/photos/components/MLSettings.tsx @@ -8,7 +8,6 @@ import { enableML, mlStatusSnapshot, mlStatusSubscribe, - wipCluster, wipClusterEnable, type MLStatus, } from "@/new/photos/services/ml"; @@ -341,7 +340,7 @@ const ManageML: React.FC = ({ // TODO-Cluster const router = useRouter(); - const wipClusterNow = () => wipCluster(); + // const wipClusterNow = () => wipCluster(); const wipClusterShowNow = () => router.push("/cluster-debug"); return ( @@ -391,18 +390,20 @@ const ManageML: React.FC = ({ )} - {showClusterOpt && ( + {/* {showClusterOpt && ( = ({ )} /> - )} + )} */} ); }; diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts index df97ca4bd9..d6e1dc505a 100644 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -348,14 +348,13 @@ function* enumerateFaces(faceIndices: FaceIndex[]) { } } -export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { +export const clusterFacesHdb = (faceIndexes: FaceIndex[]) => { const t = Date.now(); // A flattened array of faces. // TODO-Cluster ad-hoc filtering and slicing - const faces0 = [...enumerateFaces(faceIndexes)] - .filter((f) => f.blur > 99) - .slice(0, 6000); + const faces0 = [...enumerateFaces(faceIndexes)].filter((f) => f.blur > 99); + // .slice(0, 6000); // TODO-Cluster testing code, can be removed once done const faces = Array(1) .fill(0) @@ -386,27 +385,28 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // be list of existing clusters we fetch from remote. const clusters: FaceCluster[] = []; - // Process the faces in batches of 10k. The faces are already sorted by file - // ID, which is a monotonically increasing integer, so we will also have - // some temporal locality. + // Process the faces in batches. The faces are already sorted by file ID, + // which is a monotonically increasing integer, so we will also have some + // temporal locality. // - // The number 10k was derived by ad-hoc observations. On a particular test - // dataset, clustering 10k took ~2 mins, while 20k took ~8 mins. Memory - // usage was constant in both cases. + // The number 2500 was derived by ad-hoc observations and takes a few + // seconds. On a particular test dataset and a particular machine, + // clustering 1k took ~2 seconds, 10k took ~2 mins, while 20k took ~8 mins. + // Memory usage was constant in all these cases. // // At around 100k faces, the clustering starts taking hours, and we start // running into stack overflows. The stack overflows can perhaps be avoided // by restructuring the code, but hours of uninterruptible work is anyways // not feasible. - // const batchSize = 10_000; // TODO-Cluster - const batchSize = 1_000; + const batchSize = 2500; for (let i = 0; i < faceEmbeddings.length; i += batchSize) { + const it = Date.now(); const embeddings = faceEmbeddings.slice(i, i + batchSize); const { clusters: hdbClusters } = clusterFacesHdbscan(embeddings); log.info( - `hdbscan produced ${hdbClusters.length} clusters from ${embeddings.length} faces (${Date.now() - t} ms)`, + `hdbscan produced ${hdbClusters.length} clusters from ${embeddings.length} faces (${Date.now() - it} ms)`, ); // Merge the new clusters we got from hdbscan into the existing clusters @@ -489,7 +489,7 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { // Convert into the data structure we're using to debug/visualize. // - // > Showing only top 20 and bottom 10 clusters (and only up to 50 faces in + // > Showing only top 30 and bottom 30 clusters (and only up to 50 faces in // > each, sorted by cosine distance to highest scoring face in the // > cluster). @@ -497,9 +497,9 @@ export const clusterFacesHdb = async (faceIndexes: FaceIndex[]) => { (a, b) => b.faceIDs.length - a.faceIDs.length, ); const debugClusters = - sortedClusters.length < 30 + sortedClusters.length < 60 ? sortedClusters - : sortedClusters.slice(0, 20).concat(sortedClusters.slice(-10)); + : sortedClusters.slice(0, 30).concat(sortedClusters.slice(-30)); const clusterPreviews: ClusterPreview[] = []; for (const cluster of debugClusters) { const faces = cluster.faceIDs.map((id) => From be3a7093354e8e40096f8640da5c74f6b2047ace Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Thu, 29 Aug 2024 22:26:03 +0530 Subject: [PATCH 19/84] [server] Use nanoId as reqID --- server/cmd/museum/main.go | 10 +++++++++- server/ente/base/id.go | 10 ++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index 234b0435bf..531b720ee3 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -5,6 +5,7 @@ import ( "database/sql" b64 "encoding/base64" "fmt" + "github.com/ente-io/museum/ente/base" "github.com/ente-io/museum/pkg/controller/file_copy" "github.com/ente-io/museum/pkg/controller/filedata" "net/http" @@ -361,7 +362,14 @@ func main() { server.Use(p.HandlerFunc()) // note: the recover middleware must be in the last - server.Use(requestid.New(), middleware.Logger(urlSanitizer), cors(), gzip.Gzip(gzip.DefaultCompression), middleware.PanicRecover()) + + server.Use(requestid.New( + requestid.Config{ + Generator: func() string { + return base.ServerReqID() + }, + }), + middleware.Logger(urlSanitizer), cors(), gzip.Gzip(gzip.DefaultCompression), middleware.PanicRecover()) publicAPI := server.Group("/") publicAPI.Use(rateLimiter.GlobalRateLimiter(), rateLimiter.APIRateLimitMiddleware(urlSanitizer)) diff --git a/server/ente/base/id.go b/server/ente/base/id.go index f6579a05c6..559bc41542 100644 --- a/server/ente/base/id.go +++ b/server/ente/base/id.go @@ -3,6 +3,7 @@ package base import ( "errors" "fmt" + "github.com/google/uuid" "github.com/matoous/go-nanoid/v2" ) @@ -28,3 +29,12 @@ func NewID(prefix string) (*string, error) { result := fmt.Sprintf("%s_%s", prefix, id) return &result, nil } + +func ServerReqID() string { + // Generate a nanoid with a custom alphabet and length of 22 + id, err := NewID("ser") + if err != nil { + return "ser_" + uuid.New().String() + } + return *id +} From dc6fde9f77c5afcbf24982d6d1b85fc013c8c836 Mon Sep 17 00:00:00 2001 From: ashilkn Date: Thu, 29 Aug 2024 19:17:33 +0530 Subject: [PATCH 20/84] [mob][photos] Fix: audio not playing on iOS when in silent mode --- mobile/ios/Podfile.lock | 2 +- mobile/ios/Runner/AppDelegate.swift | 28 ++++++++++++++++++++++++++- mobile/lib/audio_session_handler.dart | 16 +++++++++++++++ mobile/lib/main.dart | 5 +++++ 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 mobile/lib/audio_session_handler.dart diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 8ebcc2c8fe..c40e47d7ab 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -472,7 +472,7 @@ SPEC CHECKSUMS: image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 in_app_purchase_storekit: 0e4b3c2e43ba1e1281f4f46dd71b0593ce529892 - integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 local_auth_darwin: c7e464000a6a89e952235699e32b329457608d98 local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9 diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 9824022683..3f14877d91 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,7 +1,8 @@ import Flutter import UIKit +import AVFoundation -@UIApplicationMain +@main @objc class AppDelegate: FlutterAppDelegate { override func application( _ application: UIApplication, @@ -12,11 +13,36 @@ import UIKit UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } + let controller : FlutterViewController = window?.rootViewController as! FlutterViewController + let audioSessionChannel = FlutterMethodChannel(name: "io.ente.frame/audio_session", + binaryMessenger: controller.binaryMessenger) + + audioSessionChannel.setMethodCallHandler({ + (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in + if call.method == "setAudioSessionCategory" { + self.setAudioSessionCategory(result: result) + } else { + result(FlutterMethodNotImplemented) + } + }) + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + private func setAudioSessionCategory(result: @escaping FlutterResult) { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [.mixWithOthers, .defaultToSpeaker]) + try AVAudioSession.sharedInstance().setActive(true) + result(nil) + } catch { + result(FlutterError(code: "AUDIO_SESSION_ERROR", + message: "Failed to set audio session category", + details: error.localizedDescription)) + } + } + override func applicationDidBecomeActive(_ application: UIApplication) { signal(SIGPIPE, SIG_IGN) } diff --git a/mobile/lib/audio_session_handler.dart b/mobile/lib/audio_session_handler.dart new file mode 100644 index 0000000000..8aede31b12 --- /dev/null +++ b/mobile/lib/audio_session_handler.dart @@ -0,0 +1,16 @@ +import "package:flutter/services.dart"; +import "package:logging/logging.dart"; + +class AudioSessionHandler { + static final _logger = Logger("AudioSessionHandler"); + static const MethodChannel _channel = + MethodChannel('io.ente.frame/audio_session'); + + static Future setAudioSessionCategory() async { + try { + await _channel.invokeMethod('setAudioSessionCategory'); + } on PlatformException catch (e) { + _logger.warning("Failed to set audio session category: '${e.message}'."); + } + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 09126649df..c2c3a38fa6 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -14,6 +14,7 @@ import 'package:logging/logging.dart'; import "package:media_kit/media_kit.dart"; import 'package:path_provider/path_provider.dart'; import 'package:photos/app.dart'; +import "package:photos/audio_session_handler.dart"; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/error-reporting/super_logging.dart'; @@ -73,6 +74,10 @@ const kFGTaskDeathTimeoutInMicroseconds = 5000000; void main() async { debugRepaintRainbowEnabled = false; WidgetsFlutterBinding.ensureInitialized(); + //For audio to work on vidoes in iOS when in silent mode. + if (Platform.isIOS) { + unawaited(AudioSessionHandler.setAudioSessionCategory()); + } MediaKit.ensureInitialized(); final savedThemeMode = await AdaptiveTheme.getThemeMode(); From 3feac9f0b40d65e1ce2c64d363af75c6e59a7c9d Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 30 Aug 2024 13:16:08 +0530 Subject: [PATCH 21/84] [mob] Bump version v0.9.31 --- mobile/lib/generated/intl/messages_en.dart | 2 +- mobile/lib/generated/l10n.dart | 4 ++-- mobile/pubspec.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mobile/lib/generated/intl/messages_en.dart b/mobile/lib/generated/intl/messages_en.dart index 801ea0c58a..9eaa0d5e5e 100644 --- a/mobile/lib/generated/intl/messages_en.dart +++ b/mobile/lib/generated/intl/messages_en.dart @@ -504,7 +504,7 @@ class MessageLookup extends MessageLookupByLibrary { "confirmAccountDeletion": MessageLookupByLibrary.simpleMessage("Confirm Account Deletion"), "confirmDeletePrompt": MessageLookupByLibrary.simpleMessage( - "Yes, I want to permanently delete this account and all its data."), + "Yes, I want to permanently delete this account and its data across all apps."), "confirmPassword": MessageLookupByLibrary.simpleMessage("Confirm password"), "confirmPlanChange": diff --git a/mobile/lib/generated/l10n.dart b/mobile/lib/generated/l10n.dart index 5ede242db1..e12296fb2c 100644 --- a/mobile/lib/generated/l10n.dart +++ b/mobile/lib/generated/l10n.dart @@ -170,10 +170,10 @@ class S { ); } - /// `Yes, I want to permanently delete this account and all its data.` + /// `Yes, I want to permanently delete this account and its data across all apps.` String get confirmDeletePrompt { return Intl.message( - 'Yes, I want to permanently delete this account and all its data.', + 'Yes, I want to permanently delete this account and its data across all apps.', name: 'confirmDeletePrompt', desc: '', args: [], diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b13a3237f0..1852638d13 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.9.30+930 +version: 0.9.31+931 publish_to: none environment: From d374960c353a073c35e4893e5d1c2215ed4c9f81 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 13:18:57 +0530 Subject: [PATCH 22/84] Tweak the debugging panel --- web/apps/photos/src/pages/gallery/index.tsx | 12 +++------- .../new/photos/components/MLSettings.tsx | 22 +++---------------- 2 files changed, 6 insertions(+), 28 deletions(-) diff --git a/web/apps/photos/src/pages/gallery/index.tsx b/web/apps/photos/src/pages/gallery/index.tsx index 1876fca7f4..74273eb014 100644 --- a/web/apps/photos/src/pages/gallery/index.tsx +++ b/web/apps/photos/src/pages/gallery/index.tsx @@ -6,10 +6,7 @@ import { getLocalFiles, getLocalTrashedFiles, } from "@/new/photos/services/files"; -import { - wipClusterEnable, - wipHasSwitchedOnceCmpAndSet, -} from "@/new/photos/services/ml"; +import { wipHasSwitchedOnceCmpAndSet } from "@/new/photos/services/ml"; import { EnteFile } from "@/new/photos/types/file"; import { mergeMetadata } from "@/new/photos/utils/file"; import { CenteredFlex } from "@ente/shared/components/Container"; @@ -677,11 +674,8 @@ export default function Gallery() { // TODO-Cluster if (process.env.NEXT_PUBLIC_ENTE_WIP_CL_AUTO) { setTimeout(() => { - if (!wipHasSwitchedOnceCmpAndSet()) { - void wipClusterEnable().then( - (y) => y && router.push("cluster-debug"), - ); - } + if (!wipHasSwitchedOnceCmpAndSet()) + router.push("cluster-debug"); }, 2000); } }, []); diff --git a/web/packages/new/photos/components/MLSettings.tsx b/web/packages/new/photos/components/MLSettings.tsx index c8785110b6..dde90b5368 100644 --- a/web/packages/new/photos/components/MLSettings.tsx +++ b/web/packages/new/photos/components/MLSettings.tsx @@ -340,8 +340,7 @@ const ManageML: React.FC = ({ // TODO-Cluster const router = useRouter(); - // const wipClusterNow = () => wipCluster(); - const wipClusterShowNow = () => router.push("/cluster-debug"); + const wipClusterDebug = () => router.push("/cluster-debug"); return ( @@ -393,31 +392,16 @@ const ManageML: React.FC = ({ label={ut( "Create clusters • internal only option", )} - onClick={wipClusterShowNow} + onClick={wipClusterDebug} /> )} - {/* {showClusterOpt && ( - - - - - - - )} */} ); }; From ed1970b6d81cc0702fba60ed69b6b7bd2cabb7c5 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:51:43 +0530 Subject: [PATCH 23/84] [docs] Add troubleshooting guide for auth --- docs/docs/.vitepress/sidebar.ts | 9 ++++ .../auth/troubleshooting/windows-login.md | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 docs/docs/auth/troubleshooting/windows-login.md diff --git a/docs/docs/.vitepress/sidebar.ts b/docs/docs/.vitepress/sidebar.ts index 62b3cedf87..35eac00fd5 100644 --- a/docs/docs/.vitepress/sidebar.ts +++ b/docs/docs/.vitepress/sidebar.ts @@ -205,6 +205,15 @@ export const sidebar = [ }, ], }, + { + text: "Troubleshooting", + items: [ + { + text: "Windows login", + link: "/auth/troubleshooting/windows-login", + }, + ], + }, ], }, { diff --git a/docs/docs/auth/troubleshooting/windows-login.md b/docs/docs/auth/troubleshooting/windows-login.md new file mode 100644 index 0000000000..2a990fc7af --- /dev/null +++ b/docs/docs/auth/troubleshooting/windows-login.md @@ -0,0 +1,43 @@ +--- +title: Unable to login on Windows Desktop +description: + Troubleshooting when you are not able to login or register on Ente Auth app on Windows +--- + + + +# Windows Login Error + + +### HandshakeException: Handshake error in client + +This error usually happens when the Trusted Root certificates on your Windows machine are outdated. + +To update the Trusted Root Certificates on Windows, you can use the `certutil` command. Here are the steps to do so: + +1. **Open Command Prompt as Administrator**: + - Press `Windows + X` and select `Command Prompt (Admin)` or `Windows PowerShell (Admin)`. + +2. **Run the following command to update the root certificates**: + ```bash + certutil -generateSSTFromWU roots.sst + ``` + This command will generate a file named `roots.sst` that contains the latest root certificates from Windows Update. + +3. **Install the new root certificates**: + ```bash + certutil -addstore -f ROOT roots.sst + ``` + This command will add the certificates from the `roots.sst` file to the Trusted Root Certification Authorities store. + +4. **Clean up**: + After the installation, you can delete the `roots.sst` file if you no longer need it: + ```bash + del roots.sst + ``` + +Make sure to restart your application after updating the certificates to ensure the changes take effect. + +If the above steps don't resolve the issue, please follow [this guide](https://woshub.com/updating-trusted-root-certificates-in-windows-10/#h2_3) to update your trusted root certicates, and try again. + + From ed3d8b984e721de8902b66549cdbafa8c1012a91 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 15:09:53 +0530 Subject: [PATCH 24/84] Scrollable header --- web/apps/photos/src/pages/cluster-debug.tsx | 221 +++++++++++--------- 1 file changed, 117 insertions(+), 104 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index fcbeadfcab..d1ab0342b0 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -2,10 +2,8 @@ import { SelectionBar } from "@/base/components/Navbar"; import { pt } from "@/base/i18n"; import { faceCrop, - wipClusterDebugPageContents, type ClusterDebugPageContents, type ClusterPreviewFaceWF, - type ClusterPreviewWF, } from "@/new/photos/services/ml"; import { faceDirection } from "@/new/photos/services/ml/face"; import { @@ -24,56 +22,18 @@ import { VariableSizeList } from "react-window"; // TODO-Cluster Temporary component for debugging export default function ClusterDebug() { - const { startLoading, finishLoading, showNavBar } = useContext(AppContext); - const [clusterRes, setClusterRes] = useState< - ClusterDebugPageContents | undefined - >(); + const { showNavBar } = useContext(AppContext); useEffect(() => { showNavBar(true); - cluster(); }, []); - const cluster = async () => { - startLoading(); - setClusterRes(await wipClusterDebugPageContents()); - finishLoading(); - }; - - if (!clusterRes) { - return ( - - - - ); - } return ( <> - - - {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredCount} faces. ${clusterRes.unclusteredCount} unclustered faces.`} - - - Showing only top 30 and bottom 30 clusters. - - - For each cluster showing only up to 50 faces, sorted by - cosine similarity to highest scoring face in the cluster. - - - Below each face is its{" "} - blur - score - cosineSimilarity - direction - - -
{({ height, width }) => ( - + )} @@ -101,6 +61,7 @@ const Options: React.FC = () => { const Container = styled("div")` display: block; + border: 1px solid tomato; flex: 1; width: 100%; flex-wrap: wrap; @@ -110,21 +71,36 @@ const Container = styled("div")` } `; -interface ClusterPhotoListProps { +interface ClusterListProps { height: number; width: number; - clusterRes: ClusterDebugPageContents; } -const ClusterPhotoList: React.FC = ({ - height, - width, - clusterRes, -}) => { - const { clusterPreviewWFs, clusterIDForFaceID } = clusterRes; - const [itemList, setItemList] = useState([]); +const ClusterList: React.FC = ({ height, width }) => { + const { startLoading, finishLoading } = useContext(AppContext); + + const [clusterRes, setClusterRes] = useState< + ClusterDebugPageContents | undefined + >(); + const [items, setItems] = useState([]); const listRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const cluster = async () => { + startLoading(); + // setClusterRes(await wipClusterDebugPageContents()); + setClusterRes({ + clusteredCount: 1, + unclusteredCount: 2, + clusterPreviewWFs: Array(100) + .fill(0) + .map(() => ({ clusterSize: 0, faces: [] })), + clusters: [], + clusterIDForFaceID: new Map(), + }); + finishLoading(); + }; + const columns = useMemo( () => Math.max(Math.floor(getFractionFittableColumns(width)), 4), [width], @@ -134,36 +110,40 @@ const ClusterPhotoList: React.FC = ({ const listItemHeight = 120 * shrinkRatio + 24 + 4; useEffect(() => { - setItemList(itemListFromClusterPreviewWFs(clusterPreviewWFs, columns)); - }, [columns, clusterPreviewWFs]); + setItems(clusterRes ? itemsFromClusterRes(clusterRes, columns) : []); + }, [columns, clusterRes]); useEffect(() => { listRef.current?.resetAfterIndex(0); - }, [itemList]); + }, [items]); - const getItemSize = (i: number) => - Array.isArray(itemList[i]) ? listItemHeight : 36; + const clusterIDForFaceID = clusterRes?.clusterIDForFaceID; - const generateKey = (i: number) => - Array.isArray(itemList[i]) - ? `${itemList[i][0].enteFile.id}/${itemList[i][0].face.faceID}-${itemList[i].slice(-1)[0].enteFile.id}/${itemList[i].slice(-1)[0].face.faceID}-${i}` - : `${itemList[i]}-${i}`; + const getItemSize = (index: number) => + index === 0 + ? 100 + : Array.isArray(items[index - 1]) + ? listItemHeight + : 36; return ( - {({ index, style, data }) => { - const { itemList, columns, shrinkRatio } = data; - const item = itemList[index]; + {({ index, style }) => { + if (index === 0) + return ( +
+
+
+ ); + + const item = items[index - 1]; return ( = ({ ); }; -// type ItemListItem = Face | FaceFileNeighbour[]; -type ItemListItem = number | ClusterPreviewFaceWF[]; +type Item = number | ClusterPreviewFaceWF[]; -const itemListFromClusterPreviewWFs = ( - clusterPreviewWFs: ClusterPreviewWF[], +const itemsFromClusterRes = ( + clusterRes: ClusterDebugPageContents, columns: number, ) => { - const result: ItemListItem[] = []; + const { clusterPreviewWFs } = clusterRes; + + const result: Item[] = []; for (let index = 0; index < clusterPreviewWFs.length; index++) { const { clusterSize, faces } = clusterPreviewWFs[index]; result.push(clusterSize); @@ -219,9 +200,69 @@ const getShrinkRatio = (width: number, columns: number) => (width - 2 * getGapFromScreenEdge(width) - (columns - 1) * 4) / (columns * 120); +const ListContainer = styled(Box, { + shouldForwardProp: (propName) => propName != "shrinkRatio", +})<{ + columns: number; + shrinkRatio: number; +}>` + display: grid; + grid-template-columns: ${({ columns, shrinkRatio }) => + `repeat(${columns},${120 * shrinkRatio}px)`}; + grid-column-gap: 4px; + width: 100%; + padding: 4px; +`; + +const ListItemContainer = styled(FlexWrapper)<{ span: number }>` + grid-column: span ${(props) => props.span}; +`; + +const LabelContainer = styled(ListItemContainer)` + color: ${({ theme }) => theme.colors.text.muted}; + height: 32px; +`; + +const ListItem = styled("div")` + display: flex; + justify-content: center; +`; + +interface HeaderProps { + clusterRes: ClusterDebugPageContents | undefined; +} + +const Header: React.FC = ({ clusterRes }) => { + if (!clusterRes) return ; + return ( + + + {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredCount} faces. ${clusterRes.unclusteredCount} unclustered faces.`} + + + Showing only top 30 and bottom 30 clusters. + + + For each cluster showing only up to 50 faces, sorted by cosine + similarity to highest scoring face in the cluster. + + + Below each face is its{" "} + blur - score - cosineSimilarity - direction + + + ); +}; + +const Loader = () => ( + + + +); + interface FaceItemProps { faceWF: ClusterPreviewFaceWF; - clusterIDForFaceID: Map; + clusterIDForFaceID: Map | undefined; } const FaceItem: React.FC = ({ faceWF, clusterIDForFaceID }) => { @@ -250,7 +291,7 @@ const FaceItem: React.FC = ({ faceWF, clusterIDForFaceID }) => { return ( @@ -292,31 +333,3 @@ const outlineForCluster = (clusterID: string | undefined) => const hForID = (id: string) => ([...id].reduce((s, c) => s + c.charCodeAt(0), 0) % 10) * 36; - -const ListContainer = styled(Box, { - shouldForwardProp: (propName) => propName != "shrinkRatio", -})<{ - columns: number; - shrinkRatio: number; -}>` - display: grid; - grid-template-columns: ${({ columns, shrinkRatio }) => - `repeat(${columns},${120 * shrinkRatio}px)`}; - grid-column-gap: 4px; - width: 100%; - padding: 4px; -`; - -const ListItemContainer = styled(FlexWrapper)<{ span: number }>` - grid-column: span ${(props) => props.span}; -`; - -const LabelContainer = styled(ListItemContainer)` - color: ${({ theme }) => theme.colors.text.muted}; - height: 32px; -`; - -const ListItem = styled("div")` - display: flex; - justify-content: center; -`; From 657f27822c210c7d03a55426a2af791c1491e9db Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 15:21:28 +0530 Subject: [PATCH 25/84] form 1 --- web/apps/photos/src/pages/cluster-debug.tsx | 39 ++++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index d1ab0342b0..6f6e9d4c23 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -6,6 +6,7 @@ import { type ClusterPreviewFaceWF, } from "@/new/photos/services/ml"; import { faceDirection } from "@/new/photos/services/ml/face"; +import { wait } from "@/utils/promise"; import { FlexWrapper, FluidContainer, @@ -14,6 +15,7 @@ import { import EnteSpinner from "@ente/shared/components/EnteSpinner"; import BackButton from "@mui/icons-material/ArrowBackOutlined"; import { Box, IconButton, Stack, styled, Typography } from "@mui/material"; +import { useFormik } from "formik"; import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; @@ -76,6 +78,12 @@ interface ClusterListProps { width: number; } +interface ClusteringOpts { + method: "hdbscan"; + batchSize: number; + joinThreshold: number; +} + const ClusterList: React.FC = ({ height, width }) => { const { startLoading, finishLoading } = useContext(AppContext); @@ -86,9 +94,11 @@ const ClusterList: React.FC = ({ height, width }) => { const listRef = useRef(null); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const cluster = async () => { + const cluster = async (opts: ClusteringOpts) => { startLoading(); // setClusterRes(await wipClusterDebugPageContents()); + console.log(opts); + await wait(5000); setClusterRes({ clusteredCount: 1, unclusteredCount: 2, @@ -139,7 +149,10 @@ const ClusterList: React.FC = ({ height, width }) => { if (index === 0) return (
-
+
void cluster(opts)} + />
); @@ -230,12 +243,26 @@ const ListItem = styled("div")` interface HeaderProps { clusterRes: ClusterDebugPageContents | undefined; + onCluster: (opts: ClusteringOpts) => void; } -const Header: React.FC = ({ clusterRes }) => { - if (!clusterRes) return ; - return ( +const Header: React.FC = ({ clusterRes, onCluster }) => { + const formik = useFormik({ + initialValues: { + method: "hdbscan", + batchSize: 2500, + joinThreshold: 0.7, + }, + onSubmit: onCluster, + }); + + const clusterInfo = !clusterRes ? ( + + ) : ( +
+ +
{`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredCount} faces. ${clusterRes.unclusteredCount} unclustered faces.`} @@ -252,6 +279,8 @@ const Header: React.FC = ({ clusterRes }) => {
); + + return
{clusterInfo}
; }; const Loader = () => ( From d9ca47914d0b25f8381c1f2f0caf0dc06ea49277 Mon Sep 17 00:00:00 2001 From: vishnukvmd Date: Fri, 30 Aug 2024 15:23:26 +0530 Subject: [PATCH 26/84] Add FAQ about shared item organization --- docs/docs/photos/features/share.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/docs/photos/features/share.md b/docs/docs/photos/features/share.md index a1b9be376a..076b5546e1 100644 --- a/docs/docs/photos/features/share.md +++ b/docs/docs/photos/features/share.md @@ -57,6 +57,26 @@ If you wish to collect photos from folks who are not Ente, you can do so with our Links. Simply tick the box that says "Allow uploads", and anyone who has access to the link will be able to add photos to your album. +## Organization + +You can favorite items that have been shared with you, and organize them into +your own albums. + +When you perform these operations, Ente will create a hard copy of these items, +that you fully own. This means, these copied items will count against your +storage space. + +We understand there are use cases where this approach will consume extra space +(for eg. if you are organizing photos of a family member). We chose hard copies +as a first version to avoid complexities regarding the ownership of shared +items, in case the original owner were to delete it from their own library. + +We plan to tackle these complexities in the future, by copying a reference to +the item that was shared, instead of the actual file, so that your storage will +only get consumed if the original owner deletes it from their library. If this +sounds useful to you, please participate in [this +discussion](https://github.com/ente-io/ente/discussions/790). + ## Technical details More details, including technical aspect about how the sharing features were From df3ba8697736c1192f4976452f9cfca2ec00e36c Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:27:18 +0530 Subject: [PATCH 27/84] Update build file --- mobile/ios/Podfile.lock | 2 +- mobile/pubspec.lock | 36 ++++++++++++++++++------------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index c40e47d7ab..8ebcc2c8fe 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -472,7 +472,7 @@ SPEC CHECKSUMS: image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 in_app_purchase_storekit: 0e4b3c2e43ba1e1281f4f46dd71b0593ce529892 - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 + integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 local_auth_darwin: c7e464000a6a89e952235699e32b329457608d98 local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9 diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 0223bc5236..7388ae92f2 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1297,18 +1297,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: @@ -1441,10 +1441,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.8.0" media_extension: dependency: "direct main" description: @@ -1529,10 +1529,10 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.12.0" mgrs_dart: dependency: transitive description: @@ -1901,10 +1901,10 @@ packages: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.4" plugin_platform_interface: dependency: transitive description: @@ -2410,26 +2410,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "7ee44229615f8f642b68120165ae4c2a75fe77ae2065b1e55ae4711f6cf0899e" + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" url: "https://pub.dev" source: hosted - version: "1.25.7" + version: "1.25.2" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.0" test_core: dependency: transitive description: name: test_core - sha256: "55ea5a652e38a1dfb32943a7973f3681a60f872f8c3a05a14664ad54ef9c6696" + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.6.0" timezone: dependency: transitive description: @@ -2708,10 +2708,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "14.2.5" + version: "14.2.1" volume_controller: dependency: transitive description: From e243a914e9ca081ee6263a5ec636618dcebf1549 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:31:17 +0530 Subject: [PATCH 28/84] [mob] Show backup status on status_bar tap --- mobile/lib/ui/home/status_bar_widget.dart | 12 +++++++++++- .../settings/backup/backup_section_widget.dart | 16 ---------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/mobile/lib/ui/home/status_bar_widget.dart b/mobile/lib/ui/home/status_bar_widget.dart index 8df1a90242..461a92a1c9 100644 --- a/mobile/lib/ui/home/status_bar_widget.dart +++ b/mobile/lib/ui/home/status_bar_widget.dart @@ -16,6 +16,7 @@ import 'package:photos/ui/account/verify_recovery_page.dart'; import 'package:photos/ui/components/home_header_widget.dart'; import 'package:photos/ui/components/notification_widget.dart'; import 'package:photos/ui/home/header_error_widget.dart'; +import "package:photos/ui/settings/backup/backup_status_screen.dart"; import 'package:photos/utils/navigation_util.dart'; const double kContainerHeight = 36; @@ -90,7 +91,16 @@ class _StatusBarWidgetState extends State { centerWidget: _showStatus ? _showErrorBanner ? const Text("ente", style: brandStyleMedium) - : const SyncStatusWidget() + : GestureDetector( + onTap: () { + routeToPage( + context, + const BackupStatusScreen(), + forceCustomPageRoute: true, + ).ignore(); + }, + child: const SyncStatusWidget(), + ) : const Text("ente", style: brandStyleMedium), ), _showErrorBanner diff --git a/mobile/lib/ui/settings/backup/backup_section_widget.dart b/mobile/lib/ui/settings/backup/backup_section_widget.dart index 56ef0e02f7..183b79b203 100644 --- a/mobile/lib/ui/settings/backup/backup_section_widget.dart +++ b/mobile/lib/ui/settings/backup/backup_section_widget.dart @@ -6,7 +6,6 @@ import 'package:photos/ui/components/expandable_menu_item_widget.dart'; import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart'; import 'package:photos/ui/settings/backup/backup_folder_selection_page.dart'; import 'package:photos/ui/settings/backup/backup_settings_screen.dart'; -import "package:photos/ui/settings/backup/backup_status_screen.dart"; import "package:photos/ui/settings/backup/free_space_options.dart"; import 'package:photos/ui/settings/common_settings.dart'; import 'package:photos/utils/navigation_util.dart'; @@ -48,21 +47,6 @@ class BackupSectionWidgetState extends State { }, ), sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: S.of(context).backupStatus, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async { - await routeToPage( - context, - const BackupStatusScreen(), - ); - }, - ), - sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: S.of(context).backupSettings, From 6da1f892ceefa5fbb1ecb1051536b99516ed93c0 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:33:26 +0530 Subject: [PATCH 29/84] Bump version --- mobile/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1852638d13..9188a87ea9 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -12,7 +12,7 @@ description: ente photos application # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.9.31+931 +version: 0.9.32+932 publish_to: none environment: From 20c742d43dc0c07990e06f89f4dba603e1d967aa Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 16:15:32 +0530 Subject: [PATCH 30/84] form 2 --- web/apps/photos/src/pages/cluster-debug.tsx | 101 +++++++++++++++----- 1 file changed, 79 insertions(+), 22 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 6f6e9d4c23..d5ca36db34 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -14,7 +14,16 @@ import { } from "@ente/shared/components/Container"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import BackButton from "@mui/icons-material/ArrowBackOutlined"; -import { Box, IconButton, Stack, styled, Typography } from "@mui/material"; +import { + Box, + Button, + IconButton, + MenuItem, + Stack, + styled, + TextField, + Typography, +} from "@mui/material"; import { useFormik } from "formik"; import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; @@ -55,7 +64,7 @@ const Options: React.FC = () => { - {pt("Faces")} + {pt("Face Clusters")} ); @@ -79,7 +88,7 @@ interface ClusterListProps { } interface ClusteringOpts { - method: "hdbscan"; + method: "linear" | "hdbscan"; batchSize: number; joinThreshold: number; } @@ -131,7 +140,7 @@ const ClusterList: React.FC = ({ height, width }) => { const getItemSize = (index: number) => index === 0 - ? 100 + ? 270 : Array.isArray(items[index - 1]) ? listItemHeight : 36; @@ -151,7 +160,7 @@ const ClusterList: React.FC = ({ height, width }) => {
void cluster(opts)} + onCluster={cluster} />
); @@ -243,26 +252,68 @@ const ListItem = styled("div")` interface HeaderProps { clusterRes: ClusterDebugPageContents | undefined; - onCluster: (opts: ClusteringOpts) => void; + onCluster: (opts: ClusteringOpts) => Promise; } const Header: React.FC = ({ clusterRes, onCluster }) => { - const formik = useFormik({ - initialValues: { - method: "hdbscan", - batchSize: 2500, - joinThreshold: 0.7, - }, - onSubmit: onCluster, - }); + const { values, handleSubmit, handleChange, isSubmitting } = + useFormik({ + initialValues: { + method: "hdbscan", + joinThreshold: 0.7, + batchSize: 2500, + }, + onSubmit: onCluster, + }); - const clusterInfo = !clusterRes ? ( - - ) : ( + const form = ( +
+ + Parameters + + + {["hdbscan", "linear"].map((v) => ( + + {v} + + ))} + + + + + + + + +
+ ); + + const clusterInfo = clusterRes && ( -
- -
{`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredCount} faces. ${clusterRes.unclusteredCount} unclustered faces.`} @@ -280,11 +331,17 @@ const Header: React.FC = ({ clusterRes, onCluster }) => {
); - return
{clusterInfo}
; + return ( +
+ {form} + {isSubmitting && } + {clusterInfo} +
+ ); }; const Loader = () => ( - + ); From 598d5aab10fa23c1e79f6f4331d25875c02bb797 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 16:44:58 +0530 Subject: [PATCH 31/84] propagate --- web/apps/photos/src/pages/cluster-debug.tsx | 60 +++----- .../new/photos/services/ml/cluster-new.ts | 22 +-- web/packages/new/photos/services/ml/index.ts | 145 +++++------------- web/packages/new/photos/services/ml/worker.ts | 6 +- 4 files changed, 75 insertions(+), 158 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index d5ca36db34..7f84b0c5f2 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -2,11 +2,12 @@ import { SelectionBar } from "@/base/components/Navbar"; import { pt } from "@/base/i18n"; import { faceCrop, + wipClusterDebugPageContents, type ClusterDebugPageContents, - type ClusterPreviewFaceWF, + type ClusterPreviewFaceWithFile, } from "@/new/photos/services/ml"; +import { type ClusteringOpts } from "@/new/photos/services/ml/cluster-new"; import { faceDirection } from "@/new/photos/services/ml/face"; -import { wait } from "@/utils/promise"; import { FlexWrapper, FluidContainer, @@ -87,12 +88,6 @@ interface ClusterListProps { width: number; } -interface ClusteringOpts { - method: "linear" | "hdbscan"; - batchSize: number; - joinThreshold: number; -} - const ClusterList: React.FC = ({ height, width }) => { const { startLoading, finishLoading } = useContext(AppContext); @@ -105,18 +100,7 @@ const ClusterList: React.FC = ({ height, width }) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const cluster = async (opts: ClusteringOpts) => { startLoading(); - // setClusterRes(await wipClusterDebugPageContents()); - console.log(opts); - await wait(5000); - setClusterRes({ - clusteredCount: 1, - unclusteredCount: 2, - clusterPreviewWFs: Array(100) - .fill(0) - .map(() => ({ clusterSize: 0, faces: [] })), - clusters: [], - clusterIDForFaceID: new Map(), - }); + setClusterRes(await wipClusterDebugPageContents(opts)); finishLoading(); }; @@ -136,8 +120,6 @@ const ClusterList: React.FC = ({ height, width }) => { listRef.current?.resetAfterIndex(0); }, [items]); - const clusterIDForFaceID = clusterRes?.clusterIDForFaceID; - const getItemSize = (index: number) => index === 0 ? 270 @@ -177,10 +159,10 @@ const ClusterList: React.FC = ({ height, width }) => { {`cluster size ${item.toFixed(2)}`} ) : ( - item.map((faceWF, i) => ( + item.map((f, i) => ( )) )} @@ -192,17 +174,17 @@ const ClusterList: React.FC = ({ height, width }) => { ); }; -type Item = number | ClusterPreviewFaceWF[]; +type Item = number | ClusterPreviewFaceWithFile[]; const itemsFromClusterRes = ( clusterRes: ClusterDebugPageContents, columns: number, ) => { - const { clusterPreviewWFs } = clusterRes; + const { clusterPreviewsWithFile } = clusterRes; const result: Item[] = []; - for (let index = 0; index < clusterPreviewWFs.length; index++) { - const { clusterSize, faces } = clusterPreviewWFs[index]; + for (let index = 0; index < clusterPreviewsWithFile.length; index++) { + const { clusterSize, faces } = clusterPreviewsWithFile[index]; result.push(clusterSize); let lastIndex = 0; while (lastIndex < faces.length) { @@ -315,7 +297,7 @@ const Header: React.FC = ({ clusterRes, onCluster }) => { const clusterInfo = clusterRes && ( - {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredCount} faces. ${clusterRes.unclusteredCount} unclustered faces.`} + {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredFaceCount} faces. ${clusterRes.unclusteredFaceCount} unclustered faces.`} Showing only top 30 and bottom 30 clusters. @@ -326,7 +308,10 @@ const Header: React.FC = ({ clusterRes, onCluster }) => { Below each face is its{" "} - blur - score - cosineSimilarity - direction + blur - score - cosineSimilarity - direction. + + + Faces added to the cluster as a result of merging are outlined. ); @@ -347,12 +332,11 @@ const Loader = () => ( ); interface FaceItemProps { - faceWF: ClusterPreviewFaceWF; - clusterIDForFaceID: Map | undefined; + faceWithFile: ClusterPreviewFaceWithFile; } -const FaceItem: React.FC = ({ faceWF, clusterIDForFaceID }) => { - const { face, enteFile, cosineSimilarity } = faceWF; +const FaceItem: React.FC = ({ faceWithFile }) => { + const { face, enteFile, cosineSimilarity, wasMerged } = faceWithFile; const { faceID } = face; const [objectURL, setObjectURL] = useState(); @@ -377,7 +361,7 @@ const FaceItem: React.FC = ({ faceWF, clusterIDForFaceID }) => { return ( @@ -413,9 +397,3 @@ const FaceChip = styled(Box)` width: 120px; height: 120px; `; - -const outlineForCluster = (clusterID: string | undefined) => - clusterID ? `1px solid oklch(0.8 0.2 ${hForID(clusterID)})` : undefined; - -const hForID = (id: string) => - ([...id].reduce((s, c) => s + c.charCodeAt(0), 0) % 10) * 36; diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts index d6e1dc505a..8bfb00b164 100644 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ b/web/packages/new/photos/services/ml/cluster-new.ts @@ -113,15 +113,10 @@ export interface CGroup { displayFaceID: string | undefined; } -// TODO-Cluster -export interface FaceNeighbours { - face: Face; - neighbours: FaceNeighbour[]; -} - -interface FaceNeighbour { - face: Face; - cosineSimilarity: number; +export interface ClusteringOpts { + method: "linear" | "hdbscan"; + batchSize: number; + joinThreshold: number; } export interface ClusterPreview { @@ -129,9 +124,10 @@ export interface ClusterPreview { faces: ClusterPreviewFace[]; } -interface ClusterPreviewFace { +export interface ClusterPreviewFace { face: Face; cosineSimilarity: number; + wasMerged: boolean; } /** @@ -348,7 +344,11 @@ function* enumerateFaces(faceIndices: FaceIndex[]) { } } -export const clusterFacesHdb = (faceIndexes: FaceIndex[]) => { +export const clusterFacesHdb = ( + faceIndexes: FaceIndex[], + opts: ClusteringOpts, +) => { + const { batch } = opts; const t = Date.now(); // A flattened array of faces. diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 4248c295f0..c5ff83c2ef 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -20,7 +20,11 @@ import { getAllLocalFiles } from "../files"; import { getRemoteFlag, updateRemoteFlag } from "../remote-store"; import type { SearchPerson } from "../search/types"; import type { UploadItem } from "../upload/types"; -import { type CGroup, type FaceCluster } from "./cluster-new"; +import { + type ClusteringOpts, + type ClusterPreviewFace, + type FaceCluster, +} from "./cluster-new"; import { regenerateFaceCrops } from "./crop"; import { clearMLDB, faceIndex, indexableAndIndexedCounts } from "./db"; import type { Face } from "./face"; @@ -344,42 +348,30 @@ export const wipSearchPersons = async () => { return _wip_searchPersons ?? []; }; -export interface FaceFileNeighbours { - face: Face; - neighbours: FaceFileNeighbour[]; -} - -export interface FaceFileNeighbour { - face: Face; - enteFile: EnteFile; - cosineSimilarity: number; -} - -// "with file" -export interface ClusterPreviewWF { +export interface ClusterPreviewWithFile { clusterSize: number; - faces: ClusterPreviewFaceWF[]; + faces: ClusterPreviewFaceWithFile[]; } -export interface ClusterPreviewFaceWF { - face: Face; +export type ClusterPreviewFaceWithFile = ClusterPreviewFace & { enteFile: EnteFile; - cosineSimilarity: number; -} +}; export interface ClusterDebugPageContents { - clusteredCount: number; - unclusteredCount: number; - // faceFNs: FaceFileNeighbours[]; - clusterPreviewWFs: ClusterPreviewWF[]; + clusteredFaceCount: number; + unclusteredFaceCount: number; clusters: FaceCluster[]; - clusterIDForFaceID: Map; + clusterPreviewsWithFile: ClusterPreviewWithFile[]; + unclusteredFacesWithFile: { + face: Face; + enteFile: EnteFile; + }; } -export const wipClusterDebugPageContents = async (): Promise< - ClusterDebugPageContents | undefined -> => { - if (!(await wipClusterEnable())) return undefined; +export const wipClusterDebugPageContents = async ( + opts: ClusteringOpts, +): Promise => { + if (!(await wipClusterEnable())) throw new Error("Not implemented"); log.info("clustering"); _wip_isClustering = true; @@ -388,38 +380,33 @@ export const wipClusterDebugPageContents = async (): Promise< // const { faceAndNeigbours, clusters, cgroups } = await clusterFaces( const { - clusteredCount, - unclusteredCount, + clusteredFaceCount, + unclusteredFaceCount, clusterPreviews, clusters, cgroups, - clusterIDForFaceID, - } = await worker().then((w) => w.clusterFacesHdb()); - - // const searchPersons = await convertToSearchPersons(clusters, cgroups); + unclusteredFaces, + } = await worker().then((w) => w.clusterFacesHdb(opts)); const localFiles = await getAllLocalFiles(); const localFileByID = new Map(localFiles.map((f) => [f.id, f])); const fileForFace = ({ faceID }: Face) => ensure(localFileByID.get(ensure(fileIDFromFaceID(faceID)))); - // const faceFNs = faceAndNeigbours.map( - // ({ topFace: face, faces: neighbours }) => ({ - // face, - // neighbours: neighbours.map(({ face, cosineSimilarity }) => ({ - // face, - // enteFile: fileForFace(face), - // cosineSimilarity, - // })), - // }), - // ); - const clusterPreviewWFs = clusterPreviews.map(({ clusterSize, faces }) => ({ - clusterSize, - faces: faces.map(({ face, cosineSimilarity }) => ({ - face, - enteFile: fileForFace(face), - cosineSimilarity, - })), + const clusterPreviewsWithFile = clusterPreviews.map( + ({ clusterSize, faces }) => ({ + clusterSize, + faces: faces.map(({ face, cosineSimilarity }) => ({ + face, + enteFile: fileForFace(face), + cosineSimilarity, + })), + }), + ); + + const unclusteredFacesWithFile = unclusteredFaces.map((face) => ({ + face, + enteFile: fileForFace(face), })); const clusterByID = new Map(clusters.map((c) => [c.id, c])); @@ -453,62 +440,14 @@ export const wipClusterDebugPageContents = async (): Promise< triggerStatusUpdate(); return { - clusteredCount, - unclusteredCount, - clusterPreviewWFs, + clusteredFaceCount, + unclusteredFaceCount, clusters, - clusterIDForFaceID, + clusterPreviewsWithFile, + unclusteredFacesWithFile, }; }; -export const wipCluster = () => void wipClusterDebugPageContents(); - -// TODO-Cluster remove me -export const convertToSearchPersons = async ( - clusters: FaceCluster[], - cgroups: CGroup[], -) => { - const clusterByID = new Map(clusters.map((c) => [c.id, c])); - - const localFiles = await getAllLocalFiles(); - const localFileByID = new Map(localFiles.map((f) => [f.id, f])); - - const result: SearchPerson[] = []; - for (const cgroup of cgroups) { - const displayFaceID = cgroup.displayFaceID; - if (!displayFaceID) { - // TODO-Cluster - assertionFailed(`cgroup ${cgroup.id} without displayFaceID`); - continue; - } - - const displayFaceFileID = fileIDFromFaceID(displayFaceID); - if (!displayFaceFileID) continue; - - const displayFaceFile = localFileByID.get(displayFaceFileID); - if (!displayFaceFile) { - assertionFailed(`Face ID ${displayFaceFileID} without local file`); - continue; - } - - const fileIDs = cgroup.clusterIDs - .map((id) => clusterByID.get(id)) - .flatMap((cluster) => cluster?.faceIDs ?? []) - .map((faceID) => fileIDFromFaceID(faceID)) - .filter((fileID) => fileID !== undefined); - - result.push({ - id: cgroup.id, - name: cgroup.name, - files: [...new Set(fileIDs)], - displayFaceID, - displayFaceFile, - }); - } - - return result.sort((a, b) => b.files.length - a.files.length); -}; - export type MLStatus = | { phase: "disabled" /* The ML remote flag is off */ } | { diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index e4a3e5ecab..6eff182347 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -24,7 +24,7 @@ import { indexCLIP, type CLIPIndex, } from "./clip"; -import { clusterFacesHdb } from "./cluster-new"; +import { clusterFacesHdb, type ClusteringOpts } from "./cluster-new"; import { saveFaceCrops } from "./crop"; import { faceIndexes, @@ -276,8 +276,8 @@ export class MLWorker { } // TODO-Cluster - async clusterFacesHdb() { - return clusterFacesHdb(await faceIndexes()); + async clusterFacesHdb(opts: ClusteringOpts) { + return clusterFacesHdb(await faceIndexes(), opts); } } From 48e00a0ecca6bb531e78816a89e167facc92ba5a Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 17:05:16 +0530 Subject: [PATCH 32/84] Linear --- .../new/photos/services/ml/cluster-hdb.ts | 35 + .../new/photos/services/ml/cluster-new.ts | 603 ------------------ .../new/photos/services/ml/cluster.ts | 522 ++++++++++++++- web/packages/new/photos/services/ml/db.ts | 2 +- web/packages/new/photos/services/ml/index.ts | 4 +- web/packages/new/photos/services/ml/worker.ts | 6 +- .../new/photos/services/user-entity.ts | 2 +- 7 files changed, 539 insertions(+), 635 deletions(-) create mode 100644 web/packages/new/photos/services/ml/cluster-hdb.ts delete mode 100644 web/packages/new/photos/services/ml/cluster-new.ts diff --git a/web/packages/new/photos/services/ml/cluster-hdb.ts b/web/packages/new/photos/services/ml/cluster-hdb.ts new file mode 100644 index 0000000000..3ecda4b5bc --- /dev/null +++ b/web/packages/new/photos/services/ml/cluster-hdb.ts @@ -0,0 +1,35 @@ +import { Hdbscan, type DebugInfo } from "hdbscan"; + +/** + * Each "cluster" is a list of indexes of the embeddings belonging to that + * particular cluster. + */ +export type EmbeddingCluster = number[]; + +export interface ClusterHdbscanResult { + clusters: EmbeddingCluster[]; + noise: number[]; + debugInfo?: DebugInfo; +} + +/** + * Cluster the given {@link embeddings} using hdbscan. + */ +export const clusterHdbscan = ( + embeddings: number[][], +): ClusterHdbscanResult => { + const hdbscan = new Hdbscan({ + input: embeddings, + minClusterSize: 3, + minSamples: 5, + clusterSelectionEpsilon: 0.6, + clusterSelectionMethod: "leaf", + debug: false, + }); + + return { + clusters: hdbscan.getClusters(), + noise: hdbscan.getNoise(), + debugInfo: hdbscan.getDebugInfo(), + }; +}; diff --git a/web/packages/new/photos/services/ml/cluster-new.ts b/web/packages/new/photos/services/ml/cluster-new.ts deleted file mode 100644 index 8bfb00b164..0000000000 --- a/web/packages/new/photos/services/ml/cluster-new.ts +++ /dev/null @@ -1,603 +0,0 @@ -import { newNonSecureID } from "@/base/id-worker"; -import log from "@/base/log"; -import { ensure } from "@/utils/ensure"; -import { clusterFacesHdbscan } from "./cluster"; -import { clusterGroups, faceClusters } from "./db"; -import type { Face, FaceIndex } from "./face"; -import { dotProduct } from "./math"; - -/** - * A face cluster is an set of faces. - * - * Each cluster has an id so that a {@link CGroup} can refer to it. - * - * The cluster is not directly synced to remote. Only clusters that the user - * interacts with get synced to remote, as part of a {@link CGroup}. - */ -export interface FaceCluster { - /** - * A nanoid for this cluster. - */ - id: string; - /** - * An unordered set of ids of the faces that belong to this cluster. - * - * For ergonomics of transportation and persistence this is an array, but it - * should conceptually be thought of as a set. - */ - faceIDs: string[]; -} - -/** - * A cgroup ("cluster group") is a group of clusters (possibly containing a - * single cluster) that the user has interacted with. - * - * Interactions include hiding, merging and giving a name and/or a cover photo. - * - * The most frequent interaction is naming a {@link FaceCluster}, which promotes - * it to a become a {@link CGroup}. The promotion comes with the ability to be - * synced with remote (as a "cgroup" user entity). - * - * There after, the user may attach more clusters to the same {@link CGroup}. - * - * > A named cluster group can be thought of as a "person", though this is not - * > necessarily an accurate characterization. e.g. there can be a named cluster - * > group that contains face clusters of pets. - * - * The other form of interaction is hiding. The user may hide a single (unnamed) - * cluster, or they may hide an named {@link CGroup}. In both cases, we promote - * the cluster to a CGroup if needed so that their request to hide gets synced. - * - * While in our local representation we separately maintain clusters and link to - * them from within CGroups by their clusterID, in the remote representation - * clusters themselves don't get synced. Instead, the "cgroup" entities synced - * with remote contain the clusters within themselves. So a group that gets - * synced with remote looks something like: - * - * { id, name, clusters: [{ clusterID, faceIDs }] } - * - */ -export interface CGroup { - /** - * A nanoid for this cluster group. - * - * This is the ID of the "cgroup" user entity (the envelope), and it is not - * contained as part of the group entity payload itself. - */ - id: string; - /** - * A name assigned by the user to this cluster group. - * - * The client should handle both empty strings and undefined as indicating a - * cgroup without a name. When the client needs to set this to an "empty" - * value, which happens when hiding an unnamed cluster, it should it to an - * empty string. That is, expect `"" | undefined`, but set `""`. - */ - name: string | undefined; - /** - * An unordered set of ids of the clusters that belong to this group. - * - * For ergonomics of transportation and persistence this is an array, but it - * should conceptually be thought of as a set. - */ - clusterIDs: string[]; - /** - * True if this cluster group should be hidden. - * - * The user can hide both named cluster groups and single unnamed clusters. - * If the user hides a single cluster that was offered as a suggestion to - * them on a client, the client will create a new unnamed cgroup containing - * it, and set its hidden flag to sync it with remote (so that other clients - * can also stop showing this cluster). - */ - isHidden: boolean; - /** - * The ID of the face that should be used as the cover photo for this - * cluster group (if the user has set one). - * - * This is similar to the [@link displayFaceID}, the difference being: - * - * - {@link avatarFaceID} is the face selected by the user. - * - * - {@link displayFaceID} is the automatic placeholder, and only comes - * into effect if the user has not explicitly selected a face. - */ - avatarFaceID: string | undefined; - /** - * Locally determined ID of the "best" face that should be used as the - * display face, to represent this cluster group in the UI. - * - * This property is not synced with remote. For more details, see - * {@link avatarFaceID}. - */ - displayFaceID: string | undefined; -} - -export interface ClusteringOpts { - method: "linear" | "hdbscan"; - batchSize: number; - joinThreshold: number; -} - -export interface ClusterPreview { - clusterSize: number; - faces: ClusterPreviewFace[]; -} - -export interface ClusterPreviewFace { - face: Face; - cosineSimilarity: number; - wasMerged: boolean; -} - -/** - * Cluster faces into groups. - * - * [Note: Face clustering algorithm] - * - * A cgroup (cluster group) consists of clusters, each of which itself is a set - * of faces. - * - * cgroup << cluster << face - * - * The clusters are generated locally by clients using the following algorithm: - * - * 1. clusters = [] initially, or fetched from remote. - * - * 2. For each face, find its nearest neighbour in the embedding space. - * - * 3. If no such neighbour is found within our threshold, create a new cluster. - * - * 4. Otherwise assign this face to the same cluster as its nearest neighbour. - * - * This user can then tweak the output of the algorithm by performing the - * following actions to the list of clusters that they can see: - * - * - They can provide a name for a cluster ("name a person"). This upgrades a - * cluster into a "cgroup", which is an entity that gets synced via remote - * to the user's other clients. - * - * - They can attach more clusters to a cgroup ("merge clusters") - * - * - They can remove a cluster from a cgroup ("break clusters"). - * - * After clustering, we also do some routine cleanup. Faces belonging to files - * that have been deleted (including those in Trash) should be pruned off. - * - * We should not make strict assumptions about the clusters we get from remote. - * In particular, the same face ID can be in different clusters. In such cases - * we should assign it arbitrarily assign it to the last cluster we find it in. - * Such leeway is intentionally provided to allow clients some slack in how they - * implement the sync without needing to make an blocking API request for every - * user interaction. - */ -export const clusterFaces = async (faceIndexes: FaceIndex[]) => { - const t = Date.now(); - - // A flattened array of faces. - // TODO-Cluster note the 2k slice - const faces = [...enumerateFaces(faceIndexes)].slice(0, 2000); - - // Start with the clusters we already have (either from a previous indexing, - // or fetched from remote). - const clusters = await faceClusters(); - - // For fast reverse lookup - map from cluster ids to their index in the - // clusters array. - const clusterIndexForClusterID = new Map(clusters.map((c, i) => [c.id, i])); - - // For fast reverse lookup - map from face ids to the id of the cluster to - // which they belong. - const clusterIDForFaceID = new Map( - clusters.flatMap((c) => c.faceIDs.map((id) => [id, c.id] as const)), - ); - - // A function to generate new cluster IDs. - const newClusterID = () => newNonSecureID("cluster_"); - - const faceAndNeigbours: FaceNeighbours[] = []; - - // For each face, - for (const [i, fi] of faces.entries()) { - // If the face is already part of a cluster, then skip it. - if (clusterIDForFaceID.get(fi.faceID)) continue; - - // Find the nearest neighbour from among all the other faces. - let nn: Face | undefined; - let nnCosineSimilarity = 0; - let neighbours: FaceNeighbour[] = []; - for (let j = 0; j < faces.length; j++) { - // ! This is an O(n^2) loop, be careful when adding more code here. - - // TODO-Cluster Commenting this here and moving it downward - // // Skip ourselves. - // if (i == j) continue; - - // Can't find a way of avoiding the null assertion here. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const fj = faces[j]!; - - // The vectors are already normalized, so we can directly use their - // dot product as their cosine similarity. - const csim = dotProduct(fi.embedding, fj.embedding); - - // TODO-Cluster Delete me and uncomment the check above - // Skip ourselves. - if (i == j) { - neighbours.push({ face: fj, cosineSimilarity: csim }); - continue; - } - - const threshold = fi.blur < 100 || fj.blur < 100 ? 0.7 : 0.6; - if (csim > threshold && csim > nnCosineSimilarity) { - nn = fj; - nnCosineSimilarity = csim; - } - - neighbours.push({ face: fj, cosineSimilarity: csim }); - } - - neighbours = neighbours.sort( - (a, b) => b.cosineSimilarity - a.cosineSimilarity, - ); - faceAndNeigbours.push({ face: fi, neighbours }); - - const { faceID } = fi; - - if (nn) { - // Found a neighbour near enough. - const nnFaceID = nn.faceID; - - // Find the cluster the nearest neighbour belongs to, if any. - const nnClusterID = clusterIDForFaceID.get(nn.faceID); - - if (nnClusterID) { - // If the neighbour is already part of a cluster, also add - // ourselves to that cluster. - - const nnClusterIndex = ensure( - clusterIndexForClusterID.get(nnClusterID), - ); - clusters[nnClusterIndex]?.faceIDs.push(faceID); - clusterIDForFaceID.set(faceID, nnClusterID); - } else { - // Otherwise create a new cluster with us and our nearest - // neighbour. - - const cluster = { - id: newClusterID(), - faceIDs: [faceID, nnFaceID], - }; - clusterIndexForClusterID.set(cluster.id, clusters.length); - clusterIDForFaceID.set(faceID, cluster.id); - clusterIDForFaceID.set(nnFaceID, cluster.id); - clusters.push(cluster); - } - } else { - // We didn't find a neighbour within the threshold. Create a new - // cluster with only this face. - - const cluster = { id: newClusterID(), faceIDs: [faceID] }; - clusterIndexForClusterID.set(cluster.id, clusters.length); - clusterIDForFaceID.set(faceID, cluster.id); - clusters.push(cluster); - } - } - - // Prune too small clusters. - const validClusters = clusters.filter(({ faceIDs }) => faceIDs.length > 1); - - let cgroups = await clusterGroups(); - - // TODO-Cluster - Currently we're not syncing with remote or saving anything - // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) - // cgroup, one per cluster. - cgroups = cgroups.concat( - validClusters.map((c) => ({ - id: c.id, - name: undefined, - clusterIDs: [c.id], - isHidden: false, - avatarFaceID: undefined, - displayFaceID: undefined, - })), - ); - - // For each cluster group, use the highest scoring face in any of its - // clusters as its display face. - const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); - for (const cgroup of cgroups) { - cgroup.displayFaceID = cgroup.clusterIDs - .map((clusterID) => clusterIndexForClusterID.get(clusterID)) - .filter((i) => i !== undefined) /* 0 is a valid index */ - .flatMap((i) => clusters[i]?.faceIDs ?? []) - .map((faceID) => faceForFaceID.get(faceID)) - .filter((face) => !!face) - .reduce((max, face) => - max.score > face.score ? max : face, - ).faceID; - } - - log.info("ml/cluster", { - faces, - validClusters, - clusterIndexForClusterID: Object.fromEntries(clusterIndexForClusterID), - clusterIDForFaceID: Object.fromEntries(clusterIDForFaceID), - cgroups, - }); - log.info( - `Clustered ${faces.length} faces into ${validClusters.length} clusters (${Date.now() - t} ms)`, - ); - - return { faces, clusters: validClusters, cgroups, faceAndNeigbours }; -}; - -/** - * A generator function that returns a stream of {faceID, embedding} values, - * flattening all the the faces present in the given {@link faceIndices}. - */ -function* enumerateFaces(faceIndices: FaceIndex[]) { - for (const fi of faceIndices) { - for (const f of fi.faces) { - yield f; - } - } -} - -export const clusterFacesHdb = ( - faceIndexes: FaceIndex[], - opts: ClusteringOpts, -) => { - const { batch } = opts; - const t = Date.now(); - - // A flattened array of faces. - // TODO-Cluster ad-hoc filtering and slicing - const faces0 = [...enumerateFaces(faceIndexes)].filter((f) => f.blur > 99); - // .slice(0, 6000); - // TODO-Cluster testing code, can be removed once done - const faces = Array(1) - .fill(0) - .flatMap(() => faces0); - - // For fast reverse lookup - map from face ids to the face. - const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); - - const faceEmbeddings = faces.map(({ embedding }) => embedding); - - // For fast reverse lookup - map from cluster ids to their index in the - // clusters array. - const clusterIndexForClusterID = new Map(); - - // For fast reverse lookup - map from the id of a face to the id of the - // cluster to which it belongs. - const clusterIDForFaceID = new Map(); - - // A function to chain two reverse lookup. - const firstFaceOfCluster = (cluster: FaceCluster) => - ensure(faceForFaceID.get(ensure(cluster.faceIDs[0]))); - - // A function to generate new cluster IDs. - const newClusterID = () => newNonSecureID("cluster_"); - - // The resultant clusters. - // TODO-Cluster Later on, instead of starting from a blank slate, this will - // be list of existing clusters we fetch from remote. - const clusters: FaceCluster[] = []; - - // Process the faces in batches. The faces are already sorted by file ID, - // which is a monotonically increasing integer, so we will also have some - // temporal locality. - // - // The number 2500 was derived by ad-hoc observations and takes a few - // seconds. On a particular test dataset and a particular machine, - // clustering 1k took ~2 seconds, 10k took ~2 mins, while 20k took ~8 mins. - // Memory usage was constant in all these cases. - // - // At around 100k faces, the clustering starts taking hours, and we start - // running into stack overflows. The stack overflows can perhaps be avoided - // by restructuring the code, but hours of uninterruptible work is anyways - // not feasible. - - const batchSize = 2500; - for (let i = 0; i < faceEmbeddings.length; i += batchSize) { - const it = Date.now(); - const embeddings = faceEmbeddings.slice(i, i + batchSize); - const { clusters: hdbClusters } = clusterFacesHdbscan(embeddings); - - log.info( - `hdbscan produced ${hdbClusters.length} clusters from ${embeddings.length} faces (${Date.now() - it} ms)`, - ); - - // Merge the new clusters we got from hdbscan into the existing clusters - // if they are "near" them (using some heuristic). - // - // We need to ensure we don't change any of the existing cluster IDs, - // since these might be existing clusters we got from remote. - - for (const hdbCluster of hdbClusters) { - // Find the existing cluster whose (arbitrarily chosen) first face - // is the nearest neighbour of the (arbitrarily chosen) first face - // of the cluster produced by hdbscan. - - const newFace = ensure(faces[i + ensure(hdbCluster[0])]); - - let nnCluster: FaceCluster | undefined; - let nnCosineSimilarity = 0; - for (const existingCluster of clusters) { - const existingFace = firstFaceOfCluster(existingCluster); - - // The vectors are already normalized, so we can directly use their - // dot product as their cosine similarity. - const csim = dotProduct( - existingFace.embedding, - newFace.embedding, - ); - - // Use a higher cosine similarity threshold if either of the two - // faces are blurry. - const threshold = - existingFace.blur < 200 || newFace.blur < 200 ? 0.9 : 0.7; - if (csim > threshold && csim > nnCosineSimilarity) { - nnCluster = existingCluster; - nnCosineSimilarity = csim; - } - } - - if (nnCluster) { - // If we found an existing cluster that is near enough, - // sublimate the cluster produced by hdbscan into that cluster. - for (const j of hdbCluster) { - const { faceID } = ensure(faces[i + j]); - nnCluster.faceIDs.push(faceID); - clusterIDForFaceID.set(faceID, nnCluster.id); - } - } else { - // Otherwise make a new cluster from the cluster produced by - // hdbscan. - const clusterID = newClusterID(); - const faceIDs: string[] = []; - for (const j of hdbCluster) { - const { faceID } = ensure(faces[i + j]); - faceIDs.push(faceID); - clusterIDForFaceID.set(faceID, clusterID); - } - clusterIndexForClusterID.set(clusterID, clusters.length); - clusters.push({ id: clusterID, faceIDs }); - } - } - } - - // Convert into the data structure we're using to debug/visualize. - // const faceAndNeigbours: FaceNeighbours[] = []; - // const topFaces = faces.sort((a, b) => b.score - a.score).slice(0, 30); - // for (const fi of topFaces) { - // let neighbours: FaceNeighbour[] = []; - // for (const fj of faces) { - // // The vectors are already normalized, so we can directly use their - // // dot product as their cosine similarity. - // const csim = dotProduct(fi.embedding, fj.embedding); - // neighbours.push({ face: fj, cosineSimilarity: csim }); - // } - - // neighbours = neighbours - // .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) - // .slice(0, 30); - - // faceAndNeigbours.push({ face: fi, neighbours }); - // } - - // Convert into the data structure we're using to debug/visualize. - // - // > Showing only top 30 and bottom 30 clusters (and only up to 50 faces in - // > each, sorted by cosine distance to highest scoring face in the - // > cluster). - - const sortedClusters = clusters.sort( - (a, b) => b.faceIDs.length - a.faceIDs.length, - ); - const debugClusters = - sortedClusters.length < 60 - ? sortedClusters - : sortedClusters.slice(0, 30).concat(sortedClusters.slice(-30)); - const clusterPreviews: ClusterPreview[] = []; - for (const cluster of debugClusters) { - const faces = cluster.faceIDs.map((id) => - ensure(faceForFaceID.get(id)), - ); - const topFace = faces.reduce((max, face) => - max.score > face.score ? max : face, - ); - const previewFaces: ClusterPreviewFace[] = []; - for (const face of faces) { - const csim = dotProduct(topFace.embedding, face.embedding); - previewFaces.push({ face, cosineSimilarity: csim }); - } - clusterPreviews.push({ - clusterSize: cluster.faceIDs.length, - faces: previewFaces - .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) - .slice(0, 50), - }); - } - - // Prune too small clusters. - // TODO-Cluster this is likely not needed since hdbscan already has a min? - const validClusters = clusters.filter(({ faceIDs }) => faceIDs.length > 1); - - // let cgroups = await clusterGroups(); - - // // TODO-Cluster - Currently we're not syncing with remote or saving anything - // // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) - // // cgroup, one per cluster. - // cgroups = cgroups.concat( - // validClusters.map((c) => ({ - // id: c.id, - // name: undefined, - // clusterIDs: [c.id], - // isHidden: false, - // avatarFaceID: undefined, - // displayFaceID: undefined, - // })), - // ); - - // // For each cluster group, use the highest scoring face in any of its - // // clusters as its display face. - // for (const cgroup of cgroups) { - // cgroup.displayFaceID = cgroup.clusterIDs - // .map((clusterID) => clusterIndexForClusterID.get(clusterID)) - // .filter((i) => i !== undefined) /* 0 is a valid index */ - // .flatMap((i) => clusters[i]?.faceIDs ?? []) - // .map((faceID) => faceForFaceID.get(faceID)) - // .filter((face) => !!face) - // .reduce((max, face) => - // max.score > face.score ? max : face, - // ).faceID; - // } - - // TODO-Cluster - Currently we're not syncing with remote or saving anything - // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) - // cgroup, one per cluster. - - const cgroups: CGroup[] = []; - for (const cluster of sortedClusters) { - const faces = cluster.faceIDs.map((id) => - ensure(faceForFaceID.get(id)), - ); - const topFace = faces.reduce((max, face) => - max.score > face.score ? max : face, - ); - cgroups.push({ - id: cluster.id, - name: undefined, - clusterIDs: [cluster.id], - isHidden: false, - avatarFaceID: undefined, - displayFaceID: topFace.faceID, - }); - } - - // log.info("ml/cluster", { - // faces, - // validClusters, - // clusterIndexForClusterID: Object.fromEntries(clusterIndexForClusterID), - // clusterIDForFaceID: Object.fromEntries(clusterIDForFaceID), - // cgroups, - // }); - log.info( - `Clustered ${faces.length} faces into ${validClusters.length} clusters, with ${faces.length - clusterIDForFaceID.size} faces remaining unclustered (${Date.now() - t} ms)`, - ); - - const clusteredCount = clusterIDForFaceID.size; - const unclusteredCount = faces.length - clusteredCount; - - return { - // faces, - clusteredCount, - unclusteredCount, - clusters: validClusters, - cgroups, - clusterPreviews, - clusterIDForFaceID, - }; -}; diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index 53e4930d94..f13b889aa1 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -1,35 +1,507 @@ -import { Hdbscan, type DebugInfo } from "hdbscan"; +import { newNonSecureID } from "@/base/id-worker"; +import log from "@/base/log"; +import { ensure } from "@/utils/ensure"; +import { type EmbeddingCluster, clusterHdbscan } from "./cluster-hdb"; +import type { Face, FaceIndex } from "./face"; +import { dotProduct } from "./math"; -export type Cluster = number[]; - -export interface ClusterFacesResult { - clusters: Cluster[]; - noise: Cluster; - debugInfo?: DebugInfo; +/** + * A face cluster is an set of faces. + * + * Each cluster has an id so that a {@link CGroup} can refer to it. + * + * The cluster is not directly synced to remote. Only clusters that the user + * interacts with get synced to remote, as part of a {@link CGroup}. + */ +export interface FaceCluster { + /** + * A nanoid for this cluster. + */ + id: string; + /** + * An unordered set of ids of the faces that belong to this cluster. + * + * For ergonomics of transportation and persistence this is an array, but it + * should conceptually be thought of as a set. + */ + faceIDs: string[]; } /** - * Cluster the given {@link faceEmbeddings}. + * A cgroup ("cluster group") is a group of clusters (possibly containing a + * single cluster) that the user has interacted with. + * + * Interactions include hiding, merging and giving a name and/or a cover photo. + * + * The most frequent interaction is naming a {@link FaceCluster}, which promotes + * it to a become a {@link CGroup}. The promotion comes with the ability to be + * synced with remote (as a "cgroup" user entity). + * + * There after, the user may attach more clusters to the same {@link CGroup}. + * + * > A named cluster group can be thought of as a "person", though this is not + * > necessarily an accurate characterization. e.g. there can be a named cluster + * > group that contains face clusters of pets. + * + * The other form of interaction is hiding. The user may hide a single (unnamed) + * cluster, or they may hide an named {@link CGroup}. In both cases, we promote + * the cluster to a CGroup if needed so that their request to hide gets synced. + * + * While in our local representation we separately maintain clusters and link to + * them from within CGroups by their clusterID, in the remote representation + * clusters themselves don't get synced. Instead, the "cgroup" entities synced + * with remote contain the clusters within themselves. So a group that gets + * synced with remote looks something like: + * + * { id, name, clusters: [{ clusterID, faceIDs }] } * - * @param faceEmbeddings An array of embeddings produced by our face indexing - * pipeline. Each embedding is for a face detected in an image (a single image - * may have multiple faces detected within it). */ -export const clusterFacesHdbscan = ( - faceEmbeddings: number[][], -): ClusterFacesResult => { - const hdbscan = new Hdbscan({ - input: faceEmbeddings, - minClusterSize: 3, - minSamples: 5, - clusterSelectionEpsilon: 0.6, - clusterSelectionMethod: "leaf", - debug: false, - }); +export interface CGroup { + /** + * A nanoid for this cluster group. + * + * This is the ID of the "cgroup" user entity (the envelope), and it is not + * contained as part of the group entity payload itself. + */ + id: string; + /** + * A name assigned by the user to this cluster group. + * + * The client should handle both empty strings and undefined as indicating a + * cgroup without a name. When the client needs to set this to an "empty" + * value, which happens when hiding an unnamed cluster, it should it to an + * empty string. That is, expect `"" | undefined`, but set `""`. + */ + name: string | undefined; + /** + * An unordered set of ids of the clusters that belong to this group. + * + * For ergonomics of transportation and persistence this is an array, but it + * should conceptually be thought of as a set. + */ + clusterIDs: string[]; + /** + * True if this cluster group should be hidden. + * + * The user can hide both named cluster groups and single unnamed clusters. + * If the user hides a single cluster that was offered as a suggestion to + * them on a client, the client will create a new unnamed cgroup containing + * it, and set its hidden flag to sync it with remote (so that other clients + * can also stop showing this cluster). + */ + isHidden: boolean; + /** + * The ID of the face that should be used as the cover photo for this + * cluster group (if the user has set one). + * + * This is similar to the [@link displayFaceID}, the difference being: + * + * - {@link avatarFaceID} is the face selected by the user. + * + * - {@link displayFaceID} is the automatic placeholder, and only comes + * into effect if the user has not explicitly selected a face. + */ + avatarFaceID: string | undefined; + /** + * Locally determined ID of the "best" face that should be used as the + * display face, to represent this cluster group in the UI. + * + * This property is not synced with remote. For more details, see + * {@link avatarFaceID}. + */ + displayFaceID: string | undefined; +} + +export interface ClusteringOpts { + method: "linear" | "hdbscan"; + batchSize: number; + joinThreshold: number; +} + +export interface ClusterPreview { + clusterSize: number; + faces: ClusterPreviewFace[]; +} + +export interface ClusterPreviewFace { + face: Face; + cosineSimilarity: number; + wasMerged: boolean; +} + +/** + * Cluster faces into groups. + * + * [Note: Face clustering algorithm] + * + * A cgroup (cluster group) consists of clusters, each of which itself is a set + * of faces. + * + * cgroup << cluster << face + * + * The clusters are generated locally by clients using the following algorithm: + * + * 1. clusters = [] initially, or fetched from remote. + * + * 2. For each face, find its nearest neighbour in the embedding space. + * + * 3. If no such neighbour is found within our threshold, create a new cluster. + * + * 4. Otherwise assign this face to the same cluster as its nearest neighbour. + * + * This user can then tweak the output of the algorithm by performing the + * following actions to the list of clusters that they can see: + * + * - They can provide a name for a cluster ("name a person"). This upgrades a + * cluster into a "cgroup", which is an entity that gets synced via remote + * to the user's other clients. + * + * - They can attach more clusters to a cgroup ("merge clusters") + * + * - They can remove a cluster from a cgroup ("break clusters"). + * + * After clustering, we also do some routine cleanup. Faces belonging to files + * that have been deleted (including those in Trash) should be pruned off. + * + * We should not make strict assumptions about the clusters we get from remote. + * In particular, the same face ID can be in different clusters. In such cases + * we should assign it arbitrarily assign it to the last cluster we find it in. + * Such leeway is intentionally provided to allow clients some slack in how they + * implement the sync without needing to make an blocking API request for every + * user interaction. + */ +export const clusterFaces = ( + faceIndexes: FaceIndex[], + opts: ClusteringOpts, +) => { + const { batchSize, joinThreshold } = opts; + const t = Date.now(); + + // A flattened array of faces. + // TODO-Cluster ad-hoc filtering and slicing + const faces0 = [...enumerateFaces(faceIndexes)].filter((f) => f.blur > 99); + // .slice(0, 6000); + // TODO-Cluster testing code, can be removed once done + const faces = Array(1) + .fill(0) + .flatMap(() => faces0); + + // For fast reverse lookup - map from face ids to the face. + const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); + + const faceEmbeddings = faces.map(({ embedding }) => embedding); + + // For fast reverse lookup - map from cluster ids to their index in the + // clusters array. + const clusterIndexForClusterID = new Map(); + + // For fast reverse lookup - map from the id of a face to the id of the + // cluster to which it belongs. + const clusterIDForFaceID = new Map(); + + // A function to chain two reverse lookup. + const firstFaceOfCluster = (cluster: FaceCluster) => + ensure(faceForFaceID.get(ensure(cluster.faceIDs[0]))); + + // A function to generate new cluster IDs. + const newClusterID = () => newNonSecureID("cluster_"); + + // The resultant clusters. + // TODO-Cluster Later on, instead of starting from a blank slate, this will + // be list of existing clusters we fetch from remote. + const clusters: FaceCluster[] = []; + + // Process the faces in batches. The faces are already sorted by file ID, + // which is a monotonically increasing integer, so we will also have some + // temporal locality. + // + // The number 2500 was derived by ad-hoc observations and takes a few + // seconds. On a particular test dataset and a particular machine, + // clustering 1k took ~2 seconds, 10k took ~2 mins, while 20k took ~8 mins. + // Memory usage was constant in all these cases. + // + // At around 100k faces, the clustering starts taking hours, and we start + // running into stack overflows. The stack overflows can perhaps be avoided + // by restructuring the code, but hours of uninterruptible work is anyways + // not feasible. + + const batchSize = 2500; + for (let i = 0; i < faceEmbeddings.length; i += batchSize) { + const it = Date.now(); + const embeddings = faceEmbeddings.slice(i, i + batchSize); + const { clusters: hdbClusters } = clusterHdbscan(embeddings); + + log.info( + `hdbscan produced ${hdbClusters.length} clusters from ${embeddings.length} faces (${Date.now() - it} ms)`, + ); + + // Merge the new clusters we got from hdbscan into the existing clusters + // if they are "near" them (using some heuristic). + // + // We need to ensure we don't change any of the existing cluster IDs, + // since these might be existing clusters we got from remote. + + for (const hdbCluster of hdbClusters) { + // Find the existing cluster whose (arbitrarily chosen) first face + // is the nearest neighbour of the (arbitrarily chosen) first face + // of the cluster produced by hdbscan. + + const newFace = ensure(faces[i + ensure(hdbCluster[0])]); + + let nnCluster: FaceCluster | undefined; + let nnCosineSimilarity = 0; + for (const existingCluster of clusters) { + const existingFace = firstFaceOfCluster(existingCluster); + + // The vectors are already normalized, so we can directly use their + // dot product as their cosine similarity. + const csim = dotProduct( + existingFace.embedding, + newFace.embedding, + ); + + // Use a higher cosine similarity threshold if either of the two + // faces are blurry. + const threshold = + existingFace.blur < 200 || newFace.blur < 200 ? 0.9 : 0.7; + if (csim > threshold && csim > nnCosineSimilarity) { + nnCluster = existingCluster; + nnCosineSimilarity = csim; + } + } + + if (nnCluster) { + // If we found an existing cluster that is near enough, + // sublimate the cluster produced by hdbscan into that cluster. + for (const j of hdbCluster) { + const { faceID } = ensure(faces[i + j]); + nnCluster.faceIDs.push(faceID); + clusterIDForFaceID.set(faceID, nnCluster.id); + } + } else { + // Otherwise make a new cluster from the cluster produced by + // hdbscan. + const clusterID = newClusterID(); + const faceIDs: string[] = []; + for (const j of hdbCluster) { + const { faceID } = ensure(faces[i + j]); + faceIDs.push(faceID); + clusterIDForFaceID.set(faceID, clusterID); + } + clusterIndexForClusterID.set(clusterID, clusters.length); + clusters.push({ id: clusterID, faceIDs }); + } + } + } + + // Convert into the data structure we're using to debug/visualize. + // const faceAndNeigbours: FaceNeighbours[] = []; + // const topFaces = faces.sort((a, b) => b.score - a.score).slice(0, 30); + // for (const fi of topFaces) { + // let neighbours: FaceNeighbour[] = []; + // for (const fj of faces) { + // // The vectors are already normalized, so we can directly use their + // // dot product as their cosine similarity. + // const csim = dotProduct(fi.embedding, fj.embedding); + // neighbours.push({ face: fj, cosineSimilarity: csim }); + // } + + // neighbours = neighbours + // .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) + // .slice(0, 30); + + // faceAndNeigbours.push({ face: fi, neighbours }); + // } + + // Convert into the data structure we're using to debug/visualize. + // + // > Showing only top 30 and bottom 30 clusters (and only up to 50 faces in + // > each, sorted by cosine distance to highest scoring face in the + // > cluster). + + const sortedClusters = clusters.sort( + (a, b) => b.faceIDs.length - a.faceIDs.length, + ); + const debugClusters = + sortedClusters.length < 60 + ? sortedClusters + : sortedClusters.slice(0, 30).concat(sortedClusters.slice(-30)); + const clusterPreviews: ClusterPreview[] = []; + for (const cluster of debugClusters) { + const faces = cluster.faceIDs.map((id) => + ensure(faceForFaceID.get(id)), + ); + const topFace = faces.reduce((max, face) => + max.score > face.score ? max : face, + ); + const previewFaces: ClusterPreviewFace[] = []; + for (const face of faces) { + const csim = dotProduct(topFace.embedding, face.embedding); + previewFaces.push({ face, cosineSimilarity: csim }); + } + clusterPreviews.push({ + clusterSize: cluster.faceIDs.length, + faces: previewFaces + .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) + .slice(0, 50), + }); + } + + // Prune too small clusters. + // TODO-Cluster this is likely not needed since hdbscan already has a min? + const validClusters = clusters.filter(({ faceIDs }) => faceIDs.length > 1); + + // let cgroups = await clusterGroups(); + + // // TODO-Cluster - Currently we're not syncing with remote or saving anything + // // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) + // // cgroup, one per cluster. + // cgroups = cgroups.concat( + // validClusters.map((c) => ({ + // id: c.id, + // name: undefined, + // clusterIDs: [c.id], + // isHidden: false, + // avatarFaceID: undefined, + // displayFaceID: undefined, + // })), + // ); + + // // For each cluster group, use the highest scoring face in any of its + // // clusters as its display face. + // for (const cgroup of cgroups) { + // cgroup.displayFaceID = cgroup.clusterIDs + // .map((clusterID) => clusterIndexForClusterID.get(clusterID)) + // .filter((i) => i !== undefined) /* 0 is a valid index */ + // .flatMap((i) => clusters[i]?.faceIDs ?? []) + // .map((faceID) => faceForFaceID.get(faceID)) + // .filter((face) => !!face) + // .reduce((max, face) => + // max.score > face.score ? max : face, + // ).faceID; + // } + + // TODO-Cluster - Currently we're not syncing with remote or saving anything + // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) + // cgroup, one per cluster. + + const cgroups: CGroup[] = []; + for (const cluster of sortedClusters) { + const faces = cluster.faceIDs.map((id) => + ensure(faceForFaceID.get(id)), + ); + const topFace = faces.reduce((max, face) => + max.score > face.score ? max : face, + ); + cgroups.push({ + id: cluster.id, + name: undefined, + clusterIDs: [cluster.id], + isHidden: false, + avatarFaceID: undefined, + displayFaceID: topFace.faceID, + }); + } + + // log.info("ml/cluster", { + // faces, + // validClusters, + // clusterIndexForClusterID: Object.fromEntries(clusterIndexForClusterID), + // clusterIDForFaceID: Object.fromEntries(clusterIDForFaceID), + // cgroups, + // }); + log.info( + `Clustered ${faces.length} faces into ${validClusters.length} clusters, with ${faces.length - clusterIDForFaceID.size} faces remaining unclustered (${Date.now() - t} ms)`, + ); + + const clusteredCount = clusterIDForFaceID.size; + const unclusteredCount = faces.length - clusteredCount; return { - clusters: hdbscan.getClusters(), - noise: hdbscan.getNoise(), - debugInfo: hdbscan.getDebugInfo(), + // faces, + clusteredCount, + unclusteredCount, + clusters: validClusters, + cgroups, + clusterPreviews, + clusterIDForFaceID, }; }; + +/** + * A generator function that returns a stream of {faceID, embedding} values, + * flattening all the the faces present in the given {@link faceIndices}. + */ +function* enumerateFaces(faceIndices: FaceIndex[]) { + for (const fi of faceIndices) { + for (const f of fi.faces) { + yield f; + } + } +} + +interface ClusterLinearResult { + clusters: EmbeddingCluster[]; +} + +const clusterLinear = ( + embeddings: number[][], + threshold: number, +): ClusterLinearResult => { + const clusters: EmbeddingCluster[] = []; + const clusterIndexForEmbeddingIndex = new Map(); + // For each embedding + for (const [i, ei] of embeddings.entries()) { + // If the embedding is already part of a cluster, then skip it. + if (clusterIndexForEmbeddingIndex.get(i)) continue; + + // Find the nearest neighbour from among all the other embeddings. + let nnIndex: number | undefined; + let nnCosineSimilarity = 0; + for (const [j, ej] of embeddings.entries()) { + // ! This is an O(n^2) loop, be careful when adding more code here. + + // Skip ourselves. + if (i == j) continue; + + // The vectors are already normalized, so we can directly use their + // dot product as their cosine similarity. + const csim = dotProduct(ei, ej); + if (csim > threshold && csim > nnCosineSimilarity) { + nnIndex = j; + nnCosineSimilarity = csim; + } + } + + if (nnIndex) { + // Find the cluster the nearest neighbour belongs to, if any. + const nnClusterIndex = clusterIndexForEmbeddingIndex.get(nnIndex); + + if (nnClusterIndex) { + // If the neighbour is already part of a cluster, also add + // ourselves to that cluster. + + ensure(clusters[nnClusterIndex]).push(i); + clusterIndexForEmbeddingIndex.set(i, nnClusterIndex); + } else { + // Otherwise create a new cluster with us and our nearest + // neighbour. + + clusterIndexForEmbeddingIndex.set(i, clusters.length); + clusterIndexForEmbeddingIndex.set(nnIndex, clusters.length); + clusters.push([i, nnIndex]); + } + } else { + // We didn't find a neighbour within the threshold. Create a new + // cluster with only this embedding. + + clusterIndexForEmbeddingIndex.set(i, clusters.length); + clusters.push([i]); + } + } + + // Prune singletone clusters. + const validClusters = clusters.filter((cs) => cs.length > 1); + + return { clusters: validClusters }; +}; diff --git a/web/packages/new/photos/services/ml/db.ts b/web/packages/new/photos/services/ml/db.ts index f6d2043752..5f57ea30e1 100644 --- a/web/packages/new/photos/services/ml/db.ts +++ b/web/packages/new/photos/services/ml/db.ts @@ -3,7 +3,7 @@ import log from "@/base/log"; import localForage from "@ente/shared/storage/localForage"; import { deleteDB, openDB, type DBSchema } from "idb"; import type { LocalCLIPIndex } from "./clip"; -import type { CGroup, FaceCluster } from "./cluster-new"; +import type { CGroup, FaceCluster } from "./cluster"; import type { LocalFaceIndex } from "./face"; /** diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index c5ff83c2ef..d4f3c862e3 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -24,7 +24,7 @@ import { type ClusteringOpts, type ClusterPreviewFace, type FaceCluster, -} from "./cluster-new"; +} from "./cluster"; import { regenerateFaceCrops } from "./crop"; import { clearMLDB, faceIndex, indexableAndIndexedCounts } from "./db"; import type { Face } from "./face"; @@ -386,7 +386,7 @@ export const wipClusterDebugPageContents = async ( clusters, cgroups, unclusteredFaces, - } = await worker().then((w) => w.clusterFacesHdb(opts)); + } = await worker().then((w) => w.clusterFaces(opts)); const localFiles = await getAllLocalFiles(); const localFileByID = new Map(localFiles.map((f) => [f.id, f])); diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 6eff182347..518bfb2804 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -24,7 +24,7 @@ import { indexCLIP, type CLIPIndex, } from "./clip"; -import { clusterFacesHdb, type ClusteringOpts } from "./cluster-new"; +import { type ClusteringOpts } from "./cluster"; import { saveFaceCrops } from "./crop"; import { faceIndexes, @@ -276,8 +276,8 @@ export class MLWorker { } // TODO-Cluster - async clusterFacesHdb(opts: ClusteringOpts) { - return clusterFacesHdb(await faceIndexes(), opts); + async clusterFaces(opts: ClusteringOpts) { + return clusterFace(await faceIndexes(), opts); } } diff --git a/web/packages/new/photos/services/user-entity.ts b/web/packages/new/photos/services/user-entity.ts index 7e26726dd5..121171d214 100644 --- a/web/packages/new/photos/services/user-entity.ts +++ b/web/packages/new/photos/services/user-entity.ts @@ -12,7 +12,7 @@ import { ensure } from "@/utils/ensure"; import { nullToUndefined } from "@/utils/transform"; import { z } from "zod"; import { gunzip } from "./gzip"; -import type { CGroup } from "./ml/cluster-new"; +import type { CGroup } from "./ml/cluster"; import { applyCGroupDiff } from "./ml/db"; /** From 4f4eb773fc6c9b6969285cfa7fa0629fea523e5d Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 17:24:49 +0530 Subject: [PATCH 33/84] Clean --- web/apps/photos/src/pages/cluster-debug.tsx | 4 +- .../new/photos/services/ml/cluster.ts | 139 ++++-------------- web/packages/new/photos/services/ml/index.ts | 10 +- web/packages/new/photos/services/ml/worker.ts | 4 +- 4 files changed, 41 insertions(+), 116 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 7f84b0c5f2..60efbb0118 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -6,7 +6,7 @@ import { type ClusterDebugPageContents, type ClusterPreviewFaceWithFile, } from "@/new/photos/services/ml"; -import { type ClusteringOpts } from "@/new/photos/services/ml/cluster-new"; +import { type ClusteringOpts } from "@/new/photos/services/ml/cluster"; import { faceDirection } from "@/new/photos/services/ml/face"; import { FlexWrapper, @@ -297,7 +297,7 @@ const Header: React.FC = ({ clusterRes, onCluster }) => { const clusterInfo = clusterRes && ( - {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredFaceCount} faces. ${clusterRes.unclusteredFaceCount} unclustered faces.`} + {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredFaceCount} faces in ${(clusterRes.timeTakenMs / 1000).toFixed(0)} seconds. ${clusterRes.unclusteredFaceCount} unclustered faces.`} Showing only top 30 and bottom 30 clusters. diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index f13b889aa1..7eec7af886 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -174,17 +174,11 @@ export const clusterFaces = ( faceIndexes: FaceIndex[], opts: ClusteringOpts, ) => { - const { batchSize, joinThreshold } = opts; + const { method, batchSize, joinThreshold } = opts; const t = Date.now(); // A flattened array of faces. - // TODO-Cluster ad-hoc filtering and slicing - const faces0 = [...enumerateFaces(faceIndexes)].filter((f) => f.blur > 99); - // .slice(0, 6000); - // TODO-Cluster testing code, can be removed once done - const faces = Array(1) - .fill(0) - .flatMap(() => faces0); + const faces = [...enumerateFaces(faceIndexes)].filter((f) => f.blur > 99); // For fast reverse lookup - map from face ids to the face. const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); @@ -199,6 +193,10 @@ export const clusterFaces = ( // cluster to which it belongs. const clusterIDForFaceID = new Map(); + // Keeps track of which faces were found by the OG clustering algorithm, and + // which were sublimated in from a later match. + const wasMergedFaceIDs = new Set(); + // A function to chain two reverse lookup. const firstFaceOfCluster = (cluster: FaceCluster) => ensure(faceForFaceID.get(ensure(cluster.faceIDs[0]))); @@ -214,18 +212,7 @@ export const clusterFaces = ( // Process the faces in batches. The faces are already sorted by file ID, // which is a monotonically increasing integer, so we will also have some // temporal locality. - // - // The number 2500 was derived by ad-hoc observations and takes a few - // seconds. On a particular test dataset and a particular machine, - // clustering 1k took ~2 seconds, 10k took ~2 mins, while 20k took ~8 mins. - // Memory usage was constant in all these cases. - // - // At around 100k faces, the clustering starts taking hours, and we start - // running into stack overflows. The stack overflows can perhaps be avoided - // by restructuring the code, but hours of uninterruptible work is anyways - // not feasible. - const batchSize = 2500; for (let i = 0; i < faceEmbeddings.length; i += batchSize) { const it = Date.now(); const embeddings = faceEmbeddings.slice(i, i + batchSize); @@ -294,92 +281,34 @@ export const clusterFaces = ( } } - // Convert into the data structure we're using to debug/visualize. - // const faceAndNeigbours: FaceNeighbours[] = []; - // const topFaces = faces.sort((a, b) => b.score - a.score).slice(0, 30); - // for (const fi of topFaces) { - // let neighbours: FaceNeighbour[] = []; - // for (const fj of faces) { - // // The vectors are already normalized, so we can directly use their - // // dot product as their cosine similarity. - // const csim = dotProduct(fi.embedding, fj.embedding); - // neighbours.push({ face: fj, cosineSimilarity: csim }); - // } - - // neighbours = neighbours - // .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) - // .slice(0, 30); - - // faceAndNeigbours.push({ face: fi, neighbours }); - // } - - // Convert into the data structure we're using to debug/visualize. - // - // > Showing only top 30 and bottom 30 clusters (and only up to 50 faces in - // > each, sorted by cosine distance to highest scoring face in the - // > cluster). - const sortedClusters = clusters.sort( (a, b) => b.faceIDs.length - a.faceIDs.length, ); - const debugClusters = + + // Convert into the data structure we're using to debug/visualize. + const clusterPreviewClusters = sortedClusters.length < 60 ? sortedClusters : sortedClusters.slice(0, 30).concat(sortedClusters.slice(-30)); - const clusterPreviews: ClusterPreview[] = []; - for (const cluster of debugClusters) { + const clusterPreviews = clusterPreviewClusters.map((cluster) => { const faces = cluster.faceIDs.map((id) => ensure(faceForFaceID.get(id)), ); - const topFace = faces.reduce((max, face) => - max.score > face.score ? max : face, + const topFace = faces.reduce((top, face) => + top.score > face.score ? top : face, ); - const previewFaces: ClusterPreviewFace[] = []; - for (const face of faces) { + const previewFaces: ClusterPreviewFace[] = faces.map((face) => { const csim = dotProduct(topFace.embedding, face.embedding); - previewFaces.push({ face, cosineSimilarity: csim }); - } - clusterPreviews.push({ + const wasMerged = wasMergedFaceIDs.has(face.faceID); + return { face, cosineSimilarity: csim, wasMerged }; + }); + return { clusterSize: cluster.faceIDs.length, faces: previewFaces .sort((a, b) => b.cosineSimilarity - a.cosineSimilarity) .slice(0, 50), - }); - } - - // Prune too small clusters. - // TODO-Cluster this is likely not needed since hdbscan already has a min? - const validClusters = clusters.filter(({ faceIDs }) => faceIDs.length > 1); - - // let cgroups = await clusterGroups(); - - // // TODO-Cluster - Currently we're not syncing with remote or saving anything - // // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) - // // cgroup, one per cluster. - // cgroups = cgroups.concat( - // validClusters.map((c) => ({ - // id: c.id, - // name: undefined, - // clusterIDs: [c.id], - // isHidden: false, - // avatarFaceID: undefined, - // displayFaceID: undefined, - // })), - // ); - - // // For each cluster group, use the highest scoring face in any of its - // // clusters as its display face. - // for (const cgroup of cgroups) { - // cgroup.displayFaceID = cgroup.clusterIDs - // .map((clusterID) => clusterIndexForClusterID.get(clusterID)) - // .filter((i) => i !== undefined) /* 0 is a valid index */ - // .flatMap((i) => clusters[i]?.faceIDs ?? []) - // .map((faceID) => faceForFaceID.get(faceID)) - // .filter((face) => !!face) - // .reduce((max, face) => - // max.score > face.score ? max : face, - // ).faceID; - // } + }; + }); // TODO-Cluster - Currently we're not syncing with remote or saving anything // locally, so cgroups will be empty. Create a temporary (unsaved, unsynced) @@ -390,8 +319,8 @@ export const clusterFaces = ( const faces = cluster.faceIDs.map((id) => ensure(faceForFaceID.get(id)), ); - const topFace = faces.reduce((max, face) => - max.score > face.score ? max : face, + const topFace = faces.reduce((top, face) => + top.score > face.score ? top : face, ); cgroups.push({ id: cluster.id, @@ -403,28 +332,22 @@ export const clusterFaces = ( }); } - // log.info("ml/cluster", { - // faces, - // validClusters, - // clusterIndexForClusterID: Object.fromEntries(clusterIndexForClusterID), - // clusterIDForFaceID: Object.fromEntries(clusterIDForFaceID), - // cgroups, - // }); + const timeTakenMs = Date.now() - t; log.info( - `Clustered ${faces.length} faces into ${validClusters.length} clusters, with ${faces.length - clusterIDForFaceID.size} faces remaining unclustered (${Date.now() - t} ms)`, + `Clustered ${faces.length} faces into ${clusters.length} clusters, with ${faces.length - clusterIDForFaceID.size} faces remaining unclustered (${timeTakenMs} ms)`, ); - const clusteredCount = clusterIDForFaceID.size; - const unclusteredCount = faces.length - clusteredCount; + const clusteredFaceCount = clusterIDForFaceID.size; + const unclusteredFaceCount = faces.length - clusteredFaceCount; return { - // faces, - clusteredCount, - unclusteredCount, - clusters: validClusters, - cgroups, + clusteredFaceCount, + unclusteredFaceCount, clusterPreviews, - clusterIDForFaceID, + clusters: sortedClusters, + cgroups, + unclusteredFaces: [], + timeTakenMs, }; }; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index d4f3c862e3..836eba693e 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -365,7 +365,8 @@ export interface ClusterDebugPageContents { unclusteredFacesWithFile: { face: Face; enteFile: EnteFile; - }; + }[]; + timeTakenMs: number; } export const wipClusterDebugPageContents = async ( @@ -378,7 +379,6 @@ export const wipClusterDebugPageContents = async ( _wip_searchPersons = undefined; triggerStatusUpdate(); - // const { faceAndNeigbours, clusters, cgroups } = await clusterFaces( const { clusteredFaceCount, unclusteredFaceCount, @@ -386,6 +386,7 @@ export const wipClusterDebugPageContents = async ( clusters, cgroups, unclusteredFaces, + timeTakenMs, } = await worker().then((w) => w.clusterFaces(opts)); const localFiles = await getAllLocalFiles(); @@ -396,10 +397,10 @@ export const wipClusterDebugPageContents = async ( const clusterPreviewsWithFile = clusterPreviews.map( ({ clusterSize, faces }) => ({ clusterSize, - faces: faces.map(({ face, cosineSimilarity }) => ({ + faces: faces.map(({ face, ...rest }) => ({ face, enteFile: fileForFace(face), - cosineSimilarity, + ...rest, })), }), ); @@ -445,6 +446,7 @@ export const wipClusterDebugPageContents = async ( clusters, clusterPreviewsWithFile, unclusteredFacesWithFile, + timeTakenMs, }; }; diff --git a/web/packages/new/photos/services/ml/worker.ts b/web/packages/new/photos/services/ml/worker.ts index 518bfb2804..c663abc2c9 100644 --- a/web/packages/new/photos/services/ml/worker.ts +++ b/web/packages/new/photos/services/ml/worker.ts @@ -24,7 +24,7 @@ import { indexCLIP, type CLIPIndex, } from "./clip"; -import { type ClusteringOpts } from "./cluster"; +import { clusterFaces, type ClusteringOpts } from "./cluster"; import { saveFaceCrops } from "./crop"; import { faceIndexes, @@ -277,7 +277,7 @@ export class MLWorker { // TODO-Cluster async clusterFaces(opts: ClusteringOpts) { - return clusterFace(await faceIndexes(), opts); + return clusterFaces(await faceIndexes(), opts); } } From 96397c24b41bf79f8a39ccb3f75730b113d46c1b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 17:36:01 +0530 Subject: [PATCH 34/84] Fin --- .../new/photos/services/ml/cluster.ts | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index 7eec7af886..f53b8bbf03 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -209,31 +209,37 @@ export const clusterFaces = ( // be list of existing clusters we fetch from remote. const clusters: FaceCluster[] = []; - // Process the faces in batches. The faces are already sorted by file ID, - // which is a monotonically increasing integer, so we will also have some - // temporal locality. - + // Process the faces in batches. for (let i = 0; i < faceEmbeddings.length; i += batchSize) { const it = Date.now(); - const embeddings = faceEmbeddings.slice(i, i + batchSize); - const { clusters: hdbClusters } = clusterHdbscan(embeddings); + + const embeddingBatch = faceEmbeddings.slice(i, i + batchSize); + let embeddingClusters: EmbeddingCluster[]; + if (method == "hdbscan") { + ({ clusters: embeddingClusters } = clusterHdbscan(embeddingBatch)); + } else { + ({ clusters: embeddingClusters } = clusterLinear( + embeddingBatch, + joinThreshold, + )); + } log.info( - `hdbscan produced ${hdbClusters.length} clusters from ${embeddings.length} faces (${Date.now() - it} ms)`, + `${method} produced ${embeddingClusters.length} clusters from ${embeddingBatch.length} faces (${Date.now() - it} ms)`, ); - // Merge the new clusters we got from hdbscan into the existing clusters - // if they are "near" them (using some heuristic). + // Merge the new clusters we got from this batch into the existing + // clusters if they are "near" enough (using some heuristic). // // We need to ensure we don't change any of the existing cluster IDs, // since these might be existing clusters we got from remote. - for (const hdbCluster of hdbClusters) { + for (const newCluster of embeddingClusters) { // Find the existing cluster whose (arbitrarily chosen) first face // is the nearest neighbour of the (arbitrarily chosen) first face - // of the cluster produced by hdbscan. + // of the cluster produced in this batch. - const newFace = ensure(faces[i + ensure(hdbCluster[0])]); + const newFace = ensure(faces[i + ensure(newCluster[0])]); let nnCluster: FaceCluster | undefined; let nnCosineSimilarity = 0; @@ -250,27 +256,29 @@ export const clusterFaces = ( // Use a higher cosine similarity threshold if either of the two // faces are blurry. const threshold = - existingFace.blur < 200 || newFace.blur < 200 ? 0.9 : 0.7; + existingFace.blur < 200 || newFace.blur < 200 + ? 0.9 + : joinThreshold; if (csim > threshold && csim > nnCosineSimilarity) { nnCluster = existingCluster; nnCosineSimilarity = csim; } } + // If we found an existing cluster that is near enough, merge the + // new cluster into the existing cluster. if (nnCluster) { - // If we found an existing cluster that is near enough, - // sublimate the cluster produced by hdbscan into that cluster. - for (const j of hdbCluster) { + for (const j of newCluster) { const { faceID } = ensure(faces[i + j]); + wasMergedFaceIDs.add(faceID); nnCluster.faceIDs.push(faceID); clusterIDForFaceID.set(faceID, nnCluster.id); } } else { - // Otherwise make a new cluster from the cluster produced by - // hdbscan. + // Otherwise retain the new cluster. const clusterID = newClusterID(); const faceIDs: string[] = []; - for (const j of hdbCluster) { + for (const j of newCluster) { const { faceID } = ensure(faces[i + j]); faceIDs.push(faceID); clusterIDForFaceID.set(faceID, clusterID); From 7ff9dd5a5750bf54b29c497f6c2f176a7ff64d12 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 17:48:29 +0530 Subject: [PATCH 35/84] LF --- web/apps/photos/src/pages/cluster-debug.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 60efbb0118..a0997da481 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -97,7 +97,7 @@ const ClusterList: React.FC = ({ height, width }) => { const [items, setItems] = useState([]); const listRef = useRef(null); - // eslint-disable-next-line @typescript-eslint/no-unused-vars + const cluster = async (opts: ClusteringOpts) => { startLoading(); setClusterRes(await wipClusterDebugPageContents(opts)); From b93a591401cfe9182e221ec4579179ed1653e2ad Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 17:58:47 +0530 Subject: [PATCH 36/84] Rem params --- web/apps/photos/src/pages/cluster-debug.tsx | 26 +++++++++++---------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index a0997da481..2795ee5303 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -73,7 +73,6 @@ const Options: React.FC = () => { const Container = styled("div")` display: block; - border: 1px solid tomato; flex: 1; width: 100%; flex-wrap: wrap; @@ -91,14 +90,16 @@ interface ClusterListProps { const ClusterList: React.FC = ({ height, width }) => { const { startLoading, finishLoading } = useContext(AppContext); + const [clusteringOpts, setClusteringOpts] = useState(); const [clusterRes, setClusterRes] = useState< ClusterDebugPageContents | undefined >(); const [items, setItems] = useState([]); const listRef = useRef(null); - const cluster = async (opts: ClusteringOpts) => { + setClusteringOpts(opts); + setClusterRes(undefined); startLoading(); setClusterRes(await wipClusterDebugPageContents(opts)); finishLoading(); @@ -141,6 +142,7 @@ const ClusterList: React.FC = ({ height, width }) => { return (
@@ -233,20 +235,20 @@ const ListItem = styled("div")` `; interface HeaderProps { + clusteringOpts: ClusteringOpts; clusterRes: ClusterDebugPageContents | undefined; onCluster: (opts: ClusteringOpts) => Promise; } -const Header: React.FC = ({ clusterRes, onCluster }) => { - const { values, handleSubmit, handleChange, isSubmitting } = - useFormik({ - initialValues: { - method: "hdbscan", - joinThreshold: 0.7, - batchSize: 2500, - }, - onSubmit: onCluster, - }); +const Header: React.FC = ({ + clusteringOpts, + clusterRes, + onCluster, +}) => { + const { values, handleSubmit, handleChange, isSubmitting } = useFormik({ + initialValues: clusteringOpts, + onSubmit: onCluster, + }); const form = (
From b06bd19bc9bfd7482fffb6fbdcb2e6ab223dbd8f Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 18:07:17 +0530 Subject: [PATCH 37/84] Unclustered --- web/apps/photos/src/pages/cluster-debug.tsx | 47 ++++++++++++++----- .../new/photos/services/ml/cluster.ts | 12 +++-- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 2795ee5303..127133b323 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -4,10 +4,10 @@ import { faceCrop, wipClusterDebugPageContents, type ClusterDebugPageContents, - type ClusterPreviewFaceWithFile, } from "@/new/photos/services/ml"; import { type ClusteringOpts } from "@/new/photos/services/ml/cluster"; -import { faceDirection } from "@/new/photos/services/ml/face"; +import { faceDirection, type Face } from "@/new/photos/services/ml/face"; +import type { EnteFile } from "@/new/photos/types/file"; import { FlexWrapper, FluidContainer, @@ -90,7 +90,11 @@ interface ClusterListProps { const ClusterList: React.FC = ({ height, width }) => { const { startLoading, finishLoading } = useContext(AppContext); - const [clusteringOpts, setClusteringOpts] = useState(); + const [clusteringOpts, setClusteringOpts] = useState({ + method: "hdbscan", + joinThreshold: 0.7, + batchSize: 2500, + }); const [clusterRes, setClusterRes] = useState< ClusterDebugPageContents | undefined >(); @@ -158,7 +162,7 @@ const ClusterList: React.FC = ({ height, width }) => { > {!Array.isArray(item) ? ( - {`cluster size ${item.toFixed(2)}`} + {item} ) : ( item.map((f, i) => ( @@ -176,24 +180,36 @@ const ClusterList: React.FC = ({ height, width }) => { ); }; -type Item = number | ClusterPreviewFaceWithFile[]; +type Item = string | FaceWithFile[]; const itemsFromClusterRes = ( clusterRes: ClusterDebugPageContents, columns: number, ) => { - const { clusterPreviewsWithFile } = clusterRes; + const { clusterPreviewsWithFile, unclusteredFacesWithFile } = clusterRes; const result: Item[] = []; for (let index = 0; index < clusterPreviewsWithFile.length; index++) { const { clusterSize, faces } = clusterPreviewsWithFile[index]; - result.push(clusterSize); + result.push(`cluster size ${clusterSize.toFixed(2)}`); let lastIndex = 0; while (lastIndex < faces.length) { result.push(faces.slice(lastIndex, lastIndex + columns)); lastIndex += columns; } } + + if (unclusteredFacesWithFile.length) { + result.push(`•• unclustered faces ${unclusteredFacesWithFile.length}`); + let lastIndex = 0; + while (lastIndex < unclusteredFacesWithFile.length) { + result.push( + unclusteredFacesWithFile.slice(lastIndex, lastIndex + columns), + ); + lastIndex += columns; + } + } + return result; }; @@ -334,7 +350,14 @@ const Loader = () => ( ); interface FaceItemProps { - faceWithFile: ClusterPreviewFaceWithFile; + faceWithFile: FaceWithFile; +} + +interface FaceWithFile { + face: Face; + enteFile: EnteFile; + cosineSimilarity?: number; + wasMerged?: boolean; } const FaceItem: React.FC = ({ faceWithFile }) => { @@ -384,9 +407,11 @@ const FaceItem: React.FC = ({ faceWithFile }) => { {`s${face.score.toFixed(1)}`} - - {`c${cosineSimilarity.toFixed(1)}`} - + {cosineSimilarity && ( + + {`c${cosineSimilarity.toFixed(1)}`} + + )} {`d${d}`} diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index f53b8bbf03..e667884cb8 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -340,21 +340,25 @@ export const clusterFaces = ( }); } + const clusteredFaceCount = clusterIDForFaceID.size; + const unclusteredFaceCount = faces.length - clusteredFaceCount; + + const unclusteredFaces = faces.filter( + ({ faceID }) => !clusterIDForFaceID.has(faceID), + ); + const timeTakenMs = Date.now() - t; log.info( `Clustered ${faces.length} faces into ${clusters.length} clusters, with ${faces.length - clusterIDForFaceID.size} faces remaining unclustered (${timeTakenMs} ms)`, ); - const clusteredFaceCount = clusterIDForFaceID.size; - const unclusteredFaceCount = faces.length - clusteredFaceCount; - return { clusteredFaceCount, unclusteredFaceCount, clusterPreviews, clusters: sortedClusters, cgroups, - unclusteredFaces: [], + unclusteredFaces: unclusteredFaces, timeTakenMs, }; }; From 1881dde11fe605fb8dc0a0d31e7dbea5739b3ca8 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 18:13:10 +0530 Subject: [PATCH 38/84] Fix aliasing --- web/packages/new/photos/services/ml/cluster.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index e667884cb8..266a089474 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -234,6 +234,10 @@ export const clusterFaces = ( // We need to ensure we don't change any of the existing cluster IDs, // since these might be existing clusters we got from remote. + // Create a copy so that we don't modify existing clusters as we're + // iterating. + const existingClusters = [...clusters]; + for (const newCluster of embeddingClusters) { // Find the existing cluster whose (arbitrarily chosen) first face // is the nearest neighbour of the (arbitrarily chosen) first face @@ -243,7 +247,7 @@ export const clusterFaces = ( let nnCluster: FaceCluster | undefined; let nnCosineSimilarity = 0; - for (const existingCluster of clusters) { + for (const existingCluster of existingClusters) { const existingFace = firstFaceOfCluster(existingCluster); // The vectors are already normalized, so we can directly use their From 4dc9ed643862b1d47f94a0b5097b11136558087f Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 18:14:47 +0530 Subject: [PATCH 39/84] Don't overwrite submitting state --- web/apps/photos/src/pages/cluster-debug.tsx | 38 ++++++++------------ web/packages/new/photos/services/ml/index.ts | 2 +- 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 127133b323..ddcc7b28b1 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -25,7 +25,7 @@ import { TextField, Typography, } from "@mui/material"; -import { useFormik } from "formik"; +import { useFormik, type Formik } from "formik"; import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; @@ -90,11 +90,6 @@ interface ClusterListProps { const ClusterList: React.FC = ({ height, width }) => { const { startLoading, finishLoading } = useContext(AppContext); - const [clusteringOpts, setClusteringOpts] = useState({ - method: "hdbscan", - joinThreshold: 0.7, - batchSize: 2500, - }); const [clusterRes, setClusterRes] = useState< ClusterDebugPageContents | undefined >(); @@ -102,13 +97,21 @@ const ClusterList: React.FC = ({ height, width }) => { const listRef = useRef(null); const cluster = async (opts: ClusteringOpts) => { - setClusteringOpts(opts); setClusterRes(undefined); startLoading(); setClusterRes(await wipClusterDebugPageContents(opts)); finishLoading(); }; + const formik = useFormik({ + initialValues: { + method: "hdbscan", + joinThreshold: 0.7, + batchSize: 2500, + }, + onSubmit: cluster, + }); + const columns = useMemo( () => Math.max(Math.floor(getFractionFittableColumns(width)), 4), [width], @@ -145,11 +148,7 @@ const ClusterList: React.FC = ({ height, width }) => { if (index === 0) return (
-
+
); @@ -251,21 +250,12 @@ const ListItem = styled("div")` `; interface HeaderProps { - clusteringOpts: ClusteringOpts; + formik: Formik; clusterRes: ClusterDebugPageContents | undefined; - onCluster: (opts: ClusteringOpts) => Promise; } -const Header: React.FC = ({ - clusteringOpts, - clusterRes, - onCluster, -}) => { - const { values, handleSubmit, handleChange, isSubmitting } = useFormik({ - initialValues: clusteringOpts, - onSubmit: onCluster, - }); - +const Header: React.FC = ({ formik, clusterRes }) => { + const { values, handleSubmit, handleChange, isSubmitting } = formik; const form = ( diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 836eba693e..6f8bb91a49 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -374,7 +374,7 @@ export const wipClusterDebugPageContents = async ( ): Promise => { if (!(await wipClusterEnable())) throw new Error("Not implemented"); - log.info("clustering"); + log.info("clustering", opts); _wip_isClustering = true; _wip_searchPersons = undefined; triggerStatusUpdate(); From b7e67d4e2a8f3cadd303f2b589437a67b8ff4426 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 19:11:51 +0530 Subject: [PATCH 40/84] Lint fix --- web/apps/photos/src/pages/cluster-debug.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index ddcc7b28b1..2cc97f4511 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -25,7 +25,7 @@ import { TextField, Typography, } from "@mui/material"; -import { useFormik, type Formik } from "formik"; +import { useFormik, type FormikContextType } from "formik"; import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; @@ -250,7 +250,7 @@ const ListItem = styled("div")` `; interface HeaderProps { - formik: Formik; + formik: FormikContextType; clusterRes: ClusterDebugPageContents | undefined; } From 737e46a90e5b98bd550ad2c008952d56645aed67 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 19:15:04 +0530 Subject: [PATCH 41/84] Use correct type --- web/apps/photos/src/pages/cluster-debug.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 2cc97f4511..225bd79780 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -25,7 +25,7 @@ import { TextField, Typography, } from "@mui/material"; -import { useFormik, type FormikContextType } from "formik"; +import { useFormik, type FormikProps } from "formik"; import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; @@ -250,7 +250,7 @@ const ListItem = styled("div")` `; interface HeaderProps { - formik: FormikContextType; + formik: FormikProps; clusterRes: ClusterDebugPageContents | undefined; } From ccb0e5278d480538fc8912529780beea1d770c39 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 19:58:55 +0530 Subject: [PATCH 42/84] Minscore --- web/apps/photos/src/pages/cluster-debug.tsx | 18 ++++++++++++++++++ web/packages/new/photos/services/ml/cluster.ts | 10 +++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 225bd79780..d0788500de 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -106,6 +106,8 @@ const ClusterList: React.FC = ({ height, width }) => { const formik = useFormik({ initialValues: { method: "hdbscan", + minBlur: 99, + minScore: 0, joinThreshold: 0.7, batchSize: 2500, }, @@ -276,6 +278,22 @@ const Header: React.FC = ({ formik, clusterRes }) => { ))} + + { - const { method, batchSize, joinThreshold } = opts; + const { method, batchSize, minBlur, minScore, joinThreshold } = opts; const t = Date.now(); // A flattened array of faces. - const faces = [...enumerateFaces(faceIndexes)].filter((f) => f.blur > 99); + const faces = [...enumerateFaces(faceIndexes)] + .filter((f) => f.blur > minBlur) + .filter((f) => f.score > minScore); // For fast reverse lookup - map from face ids to the face. const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); @@ -439,7 +443,7 @@ const clusterLinear = ( } } - // Prune singletone clusters. + // Prune singleton clusters. const validClusters = clusters.filter((cs) => cs.length > 1); return { clusters: validClusters }; From c147ec10673294e38554ac87930679e415e14e99 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 20:53:47 +0530 Subject: [PATCH 43/84] Ensure nums There's a better way, just debugging code for now --- web/apps/photos/src/pages/cluster-debug.tsx | 190 ++++++++++++-------- 1 file changed, 118 insertions(+), 72 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index d0788500de..48162a8f1c 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -25,16 +25,39 @@ import { TextField, Typography, } from "@mui/material"; -import { useFormik, type FormikProps } from "formik"; +import { useFormik } from "formik"; import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; -import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import AutoSizer from "react-virtualized-auto-sizer"; import { VariableSizeList } from "react-window"; // TODO-Cluster Temporary component for debugging export default function ClusterDebug() { - const { showNavBar } = useContext(AppContext); + const { startLoading, finishLoading, showNavBar } = useContext(AppContext); + + const [clusterRes, setClusterRes] = useState< + ClusterDebugPageContents | undefined + >(); + + const cluster = useCallback((opts: ClusteringOpts) => { + return new Promise((resolve) => { + setClusterRes(undefined); + startLoading(); + wipClusterDebugPageContents(opts).then((v) => { + setClusterRes(v); + finishLoading(); + resolve(true); + }); + }); + }, []); useEffect(() => { showNavBar(true); @@ -45,7 +68,10 @@ export default function ClusterDebug() { {({ height, width }) => ( - + )} @@ -82,43 +108,29 @@ const Container = styled("div")` } `; -interface ClusterListProps { - height: number; - width: number; -} - -const ClusterList: React.FC = ({ height, width }) => { - const { startLoading, finishLoading } = useContext(AppContext); - - const [clusterRes, setClusterRes] = useState< - ClusterDebugPageContents | undefined - >(); - const [items, setItems] = useState([]); - const listRef = useRef(null); - - const cluster = async (opts: ClusteringOpts) => { - setClusterRes(undefined); - startLoading(); - setClusterRes(await wipClusterDebugPageContents(opts)); - finishLoading(); +type ClusterListProps = Header1Props & + Header2Props & { + height: number; + width: number; }; - const formik = useFormik({ - initialValues: { - method: "hdbscan", - minBlur: 99, - minScore: 0, - joinThreshold: 0.7, - batchSize: 2500, - }, - onSubmit: cluster, - }); +const ClusterList: React.FC = ({ + width, + height, + onCluster, + clusterRes, +}) => { + const [items, setItems] = useState([]); + const listRef = useRef(null); const columns = useMemo( () => Math.max(Math.floor(getFractionFittableColumns(width)), 4), [width], ); + const Header1Memo = React.memo(Header1); + const Header2Memo = React.memo(Header2); + const shrinkRatio = getShrinkRatio(width, columns); const listItemHeight = 120 * shrinkRatio + 24 + 4; @@ -130,19 +142,25 @@ const ClusterList: React.FC = ({ height, width }) => { listRef.current?.resetAfterIndex(0); }, [items]); + const itemKey = (index: number) => + index === 0 || index === 1 ? `header-${index}` : `item-${index}`; + const getItemSize = (index: number) => index === 0 - ? 270 - : Array.isArray(items[index - 1]) - ? listItemHeight - : 36; + ? 140 + : index === 1 + ? 130 + : Array.isArray(items[index - 1 - 1]) + ? listItemHeight + : 36; return ( @@ -150,11 +168,18 @@ const ClusterList: React.FC = ({ height, width }) => { if (index === 0) return (
-
+
); - const item = items[index - 1]; + if (index === 1) + return ( +
+ +
+ ); + + const item = items[index - 2]; return ( ; - clusterRes: ClusterDebugPageContents | undefined; +interface Header1Props { + onCluster: (opts: ClusteringOpts) => Promise; } -const Header: React.FC = ({ formik, clusterRes }) => { - const { values, handleSubmit, handleChange, isSubmitting } = formik; - const form = ( +const Header1: React.FC = ({ onCluster }) => { + const toFloat = (n: number | string) => + typeof n == "string" ? parseFloat(n) : n; + const { values, handleSubmit, handleChange, isSubmitting } = + useFormik({ + initialValues: { + method: "linear", + minBlur: 10, + minScore: 0.8, + joinThreshold: 0.7, + batchSize: 12500, + }, + onSubmit: (values) => + onCluster({ + method: values.method, + minBlur: toFloat(values.minBlur), + minScore: toFloat(values.minScore), + joinThreshold: toFloat(values.joinThreshold), + batchSize: toFloat(values.batchSize), + }), + }); + + return ( Parameters @@ -316,38 +360,40 @@ const Header: React.FC = ({ formik, clusterRes }) => { Cluster + {isSubmitting && } ); +}; - const clusterInfo = clusterRes && ( - - - {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredFaceCount} faces in ${(clusterRes.timeTakenMs / 1000).toFixed(0)} seconds. ${clusterRes.unclusteredFaceCount} unclustered faces.`} - - - Showing only top 30 and bottom 30 clusters. - - - For each cluster showing only up to 50 faces, sorted by cosine - similarity to highest scoring face in the cluster. - - - Below each face is its{" "} - blur - score - cosineSimilarity - direction. - - - Faces added to the cluster as a result of merging are outlined. - - - ); +interface Header2Props { + clusterRes: ClusterDebugPageContents | undefined; +} +const Header2: React.FC = ({ clusterRes }) => { return ( -
- {form} - {isSubmitting && } - {clusterInfo} -
+ clusterRes && ( + + + {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredFaceCount} faces in ${(clusterRes.timeTakenMs / 1000).toFixed(0)} seconds. ${clusterRes.unclusteredFaceCount} unclustered faces.`} + + + Showing only top 30 and bottom 30 clusters. + + + For each cluster showing only up to 50 faces, sorted by + cosine similarity to highest scoring face in the cluster. + + + Below each face is its{" "} + blur - score - cosineSimilarity - direction. + + + Faces added to the cluster as a result of merging are + outlined. + + + ) ); }; From 0da8f45084af13d9011af840f88da78543fc916e Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Fri, 30 Aug 2024 21:09:42 +0530 Subject: [PATCH 44/84] Fix form reset --- web/apps/photos/src/pages/cluster-debug.tsx | 33 ++++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 48162a8f1c..65d66087fa 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -43,12 +43,20 @@ import { VariableSizeList } from "react-window"; export default function ClusterDebug() { const { startLoading, finishLoading, showNavBar } = useContext(AppContext); + const [clusteringOptions, setClusteringOptions] = useState({ + method: "linear", + minBlur: 10, + minScore: 0.8, + joinThreshold: 0.7, + batchSize: 12500, + }); const [clusterRes, setClusterRes] = useState< ClusterDebugPageContents | undefined >(); const cluster = useCallback((opts: ClusteringOpts) => { return new Promise((resolve) => { + setClusteringOptions(opts); setClusterRes(undefined); startLoading(); wipClusterDebugPageContents(opts).then((v) => { @@ -69,8 +77,13 @@ export default function ClusterDebug() { {({ height, width }) => ( )} @@ -117,6 +130,7 @@ type ClusterListProps = Header1Props & const ClusterList: React.FC = ({ width, height, + clusteringOptions, onCluster, clusterRes, }) => { @@ -168,7 +182,9 @@ const ClusterList: React.FC = ({ if (index === 0) return (
- +
); @@ -277,21 +293,16 @@ const ListItem = styled("div")` `; interface Header1Props { + clusteringOptions: ClusteringOpts; onCluster: (opts: ClusteringOpts) => Promise; } -const Header1: React.FC = ({ onCluster }) => { +const Header1: React.FC = ({ clusteringOptions, onCluster }) => { const toFloat = (n: number | string) => typeof n == "string" ? parseFloat(n) : n; const { values, handleSubmit, handleChange, isSubmitting } = useFormik({ - initialValues: { - method: "linear", - minBlur: 10, - minScore: 0.8, - joinThreshold: 0.7, - batchSize: 12500, - }, + initialValues: clusteringOptions, onSubmit: (values) => onCluster({ method: values.method, From 1e1ef7f94b689c043719e53b98078b72693a28d4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 07:22:48 +0530 Subject: [PATCH 45/84] Live photo changelog https://github.com/ente-io/ente/pull/2865 --- desktop/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/CHANGELOG.md b/desktop/CHANGELOG.md index 9a32cd383f..e55fa1e3e9 100644 --- a/desktop/CHANGELOG.md +++ b/desktop/CHANGELOG.md @@ -4,6 +4,7 @@ - Improved date search, including support for day of week and hour of day. - Fix video thumbnail generation and upload on Intel macOS. +- Club a photo and video into a live photo only if both are within 2 minutes. - . ## v1.7.3 From 854198f2156a607bbfcfaf3ac42e54e2ce14b238 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 09:25:17 +0530 Subject: [PATCH 46/84] [web] Indicate that hash comparision is also used in the detail message Context: https://github.com/ente-io/ente/discussions/3070 --- web/packages/base/locales/en-US/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json index 3adb4f3673..5585253915 100644 --- a/web/packages/base/locales/en-US/translation.json +++ b/web/packages/base/locales/en-US/translation.json @@ -301,7 +301,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Thumbnail generation failed", "UNSUPPORTED_FILES": "Unsupported files", "SUCCESSFUL_UPLOADS": "Successful uploads", - "SKIPPED_INFO": "Skipped these as there are files with matching names in the same album", + "SKIPPED_INFO": "Skipped these as there are files with matching name and content in the same album", "UNSUPPORTED_INFO": "Ente does not support these file formats yet", "BLOCKED_UPLOADS": "Blocked uploads", "INPROGRESS_METADATA_EXTRACTION": "In progress", From 14a4398a14219ab87df3cc9e926f3cbd25eada8a Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 09:45:43 +0530 Subject: [PATCH 47/84] Restructure to avoid unnecessary rerenders --- web/apps/photos/src/pages/cluster-debug.tsx | 242 ++++++++++---------- 1 file changed, 120 insertions(+), 122 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 65d66087fa..2ad6327b5f 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -43,24 +43,18 @@ import { VariableSizeList } from "react-window"; export default function ClusterDebug() { const { startLoading, finishLoading, showNavBar } = useContext(AppContext); - const [clusteringOptions, setClusteringOptions] = useState({ - method: "linear", - minBlur: 10, - minScore: 0.8, - joinThreshold: 0.7, - batchSize: 12500, - }); + const Header1Memo = React.memo(Header1); + const [clusterRes, setClusterRes] = useState< ClusterDebugPageContents | undefined >(); const cluster = useCallback((opts: ClusteringOpts) => { return new Promise((resolve) => { - setClusteringOptions(opts); - setClusterRes(undefined); startLoading(); - wipClusterDebugPageContents(opts).then((v) => { - setClusterRes(v); + // setClusterRes(undefined); + wipClusterDebugPageContents(opts).then((res) => { + setClusterRes(res); finishLoading(); resolve(true); }); @@ -71,20 +65,16 @@ export default function ClusterDebug() { showNavBar(true); }, []); + console.log("rendering Top", clusterRes); + return ( <> {({ height, width }) => ( - + + + )} @@ -121,18 +111,117 @@ const Container = styled("div")` } `; -type ClusterListProps = Header1Props & - Header2Props & { - height: number; - width: number; - }; +interface Header1Props { + onCluster: (opts: ClusteringOpts) => Promise; +} -const ClusterList: React.FC = ({ +const Header1: React.FC = ({ onCluster }) => { + const toFloat = (n: number | string) => + typeof n == "string" ? parseFloat(n) : n; + const { values, handleSubmit, handleChange, isSubmitting } = + useFormik({ + initialValues: { + method: "linear", + minBlur: 10, + minScore: 0.8, + joinThreshold: 0.7, + batchSize: 12500, + }, + // onSubmit1: (values) => { + // console.log("onSubmit"); + // return new Promise((resolve) => { + // console.log("onSubmit will resolve promise", { + // isSubmitting, + // }); + // setTimeout(resolve, 2000); + // }); + // }, + onSubmit: (values) => + onCluster({ + method: values.method, + minBlur: toFloat(values.minBlur), + minScore: toFloat(values.minScore), + joinThreshold: toFloat(values.joinThreshold), + batchSize: toFloat(values.batchSize), + }), + }); + + console.log("rendering form", { isSubmitting }); + + return ( +
+ + Parameters + + + {["hdbscan", "linear"].map((v) => ( + + {v} + + ))} + + + + + + + + + + {isSubmitting && } + +
+ ); +}; + +type ClusterListProps = Header2Props & { + height: number; + width: number; +}; + +const ClusterList: React.FC> = ({ width, height, - clusteringOptions, - onCluster, clusterRes, + children, }) => { const [items, setItems] = useState([]); const listRef = useRef(null); @@ -142,7 +231,6 @@ const ClusterList: React.FC = ({ [width], ); - const Header1Memo = React.memo(Header1); const Header2Memo = React.memo(Header2); const shrinkRatio = getShrinkRatio(width, columns); @@ -168,6 +256,8 @@ const ClusterList: React.FC = ({ ? listItemHeight : 36; + console.log("rendering Within AutoSizer", clusterRes, listRef); + return ( = ({ overscanCount={3} > {({ index, style }) => { - if (index === 0) - return ( -
- -
- ); + if (index === 0) return
{children}
; if (index === 1) return ( @@ -292,91 +375,6 @@ const ListItem = styled("div")` justify-content: center; `; -interface Header1Props { - clusteringOptions: ClusteringOpts; - onCluster: (opts: ClusteringOpts) => Promise; -} - -const Header1: React.FC = ({ clusteringOptions, onCluster }) => { - const toFloat = (n: number | string) => - typeof n == "string" ? parseFloat(n) : n; - const { values, handleSubmit, handleChange, isSubmitting } = - useFormik({ - initialValues: clusteringOptions, - onSubmit: (values) => - onCluster({ - method: values.method, - minBlur: toFloat(values.minBlur), - minScore: toFloat(values.minScore), - joinThreshold: toFloat(values.joinThreshold), - batchSize: toFloat(values.batchSize), - }), - }); - - return ( -
- - Parameters - - - {["hdbscan", "linear"].map((v) => ( - - {v} - - ))} - - - - - - - - - - {isSubmitting && } - -
- ); -}; - interface Header2Props { clusterRes: ClusterDebugPageContents | undefined; } From 067ba8ea85232aeed0539622c6c71bd51f0e9fb4 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 10:37:56 +0530 Subject: [PATCH 48/84] Fix form rerendering The item renderer should not be defined inline otherwise it will get re-created each time the parent component (list) gets rerendered. https://github.com/bvaughn/react-window/issues/413#issuecomment-597876562 --- web/apps/photos/src/pages/cluster-debug.tsx | 105 ++++++++++---------- 1 file changed, 52 insertions(+), 53 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 2ad6327b5f..b20350147f 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -37,14 +37,12 @@ import React, { useState, } from "react"; import AutoSizer from "react-virtualized-auto-sizer"; -import { VariableSizeList } from "react-window"; +import { areEqual, VariableSizeList } from "react-window"; // TODO-Cluster Temporary component for debugging export default function ClusterDebug() { const { startLoading, finishLoading, showNavBar } = useContext(AppContext); - const Header1Memo = React.memo(Header1); - const [clusterRes, setClusterRes] = useState< ClusterDebugPageContents | undefined >(); @@ -52,7 +50,7 @@ export default function ClusterDebug() { const cluster = useCallback((opts: ClusteringOpts) => { return new Promise((resolve) => { startLoading(); - // setClusterRes(undefined); + setClusterRes(undefined); wipClusterDebugPageContents(opts).then((res) => { setClusterRes(res); finishLoading(); @@ -73,7 +71,9 @@ export default function ClusterDebug() { {({ height, width }) => ( - + + + )} @@ -127,15 +127,6 @@ const Header1: React.FC = ({ onCluster }) => { joinThreshold: 0.7, batchSize: 12500, }, - // onSubmit1: (values) => { - // console.log("onSubmit"); - // return new Promise((resolve) => { - // console.log("onSubmit will resolve promise", { - // isSubmitting, - // }); - // setTimeout(resolve, 2000); - // }); - // }, onSubmit: (values) => onCluster({ method: values.method, @@ -212,6 +203,21 @@ const Header1: React.FC = ({ onCluster }) => { ); }; +const Header1Memo = React.memo(Header1); + +const Row = React.memo( + (props) => { + const { style, children } = props; + console.log("Rendering row", props); + return
{children}
; + }, + // areEqual, + (...args) => { + console.log("areEqual called", args); + return true; + }, +); + type ClusterListProps = Header2Props & { height: number; width: number; @@ -231,8 +237,6 @@ const ClusterList: React.FC> = ({ [width], ); - const Header2Memo = React.memo(Header2); - const shrinkRatio = getShrinkRatio(width, columns); const listItemHeight = 120 * shrinkRatio + 24 + 4; @@ -244,9 +248,6 @@ const ClusterList: React.FC> = ({ listRef.current?.resetAfterIndex(0); }, [items]); - const itemKey = (index: number) => - index === 0 || index === 1 ? `header-${index}` : `item-${index}`; - const getItemSize = (index: number) => index === 0 ? 140 @@ -262,49 +263,45 @@ const ClusterList: React.FC> = ({ - {({ index, style }) => { - if (index === 0) return
{children}
; - - if (index === 1) - return ( -
- -
- ); - - const item = items[index - 2]; - return ( - - - {!Array.isArray(item) ? ( - - {item} - - ) : ( - item.map((f, i) => ( - - )) - )} - - - ); - }} + {DefineMeOutside}
); }; +const DefineMeOutside = React.memo(({ index, style, data }) => { + const { clusterRes, columns, shrinkRatio, items, children } = data; + + if (index === 0) return children; + + if (index === 1) + return ( +
+ +
+ ); + + const item = items[index - 2]; + return ( + + + {!Array.isArray(item) ? ( + {item} + ) : ( + item.map((f, i) => ( + + )) + )} + + + ); +}, areEqual); + type Item = string | FaceWithFile[]; const itemsFromClusterRes = ( @@ -406,6 +403,8 @@ const Header2: React.FC = ({ clusterRes }) => { ); }; +const Header2Memo = React.memo(Header2); + const Loader = () => ( From c3cfb7ae2f70a20d2ebb0c5812b450cc743dffd6 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 11:09:07 +0530 Subject: [PATCH 49/84] Clean up --- web/apps/photos/src/pages/cluster-debug.tsx | 42 +++++++------------ .../new/photos/services/ml/cluster.ts | 3 +- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index b20350147f..b253d061b4 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -49,8 +49,8 @@ export default function ClusterDebug() { const cluster = useCallback((opts: ClusteringOpts) => { return new Promise((resolve) => { - startLoading(); setClusterRes(undefined); + startLoading(); wipClusterDebugPageContents(opts).then((res) => { setClusterRes(res); finishLoading(); @@ -63,17 +63,13 @@ export default function ClusterDebug() { showNavBar(true); }, []); - console.log("rendering Top", clusterRes); - return ( <> {({ height, width }) => ( - - - + )} @@ -205,17 +201,9 @@ const Header1: React.FC = ({ onCluster }) => { const Header1Memo = React.memo(Header1); -const Row = React.memo( - (props) => { - const { style, children } = props; - console.log("Rendering row", props); - return
{children}
; - }, - // areEqual, - (...args) => { - console.log("areEqual called", args); - return true; - }, +const DivMemo = React.memo( + ({ style, children }) =>
{children}
, + areEqual, ); type ClusterListProps = Header2Props & { @@ -248,36 +236,36 @@ const ClusterList: React.FC> = ({ listRef.current?.resetAfterIndex(0); }, [items]); - const getItemSize = (index: number) => + const itemSize = (index: number) => index === 0 ? 140 : index === 1 ? 130 - : Array.isArray(items[index - 1 - 1]) + : Array.isArray(items[index - 2]) ? listItemHeight : 36; - console.log("rendering Within AutoSizer", clusterRes, listRef); - return ( - {DefineMeOutside} + {ClusterListItemRenderer} ); }; -const DefineMeOutside = React.memo(({ index, style, data }) => { +const ClusterListItemRenderer = React.memo(({ index, style, data }) => { const { clusterRes, columns, shrinkRatio, items, children } = data; - if (index === 0) return children; + if (index === 0) { + return {children}; + } if (index === 1) return ( diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index dad9e509c2..c39e0304c9 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -182,7 +182,8 @@ export const clusterFaces = ( // A flattened array of faces. const faces = [...enumerateFaces(faceIndexes)] .filter((f) => f.blur > minBlur) - .filter((f) => f.score > minScore); + .filter((f) => f.score > minScore) + .slice(0, 2000); // For fast reverse lookup - map from face ids to the face. const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); From 60cac291fff922d95faf75478d980d2d76155547 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 11:13:23 +0530 Subject: [PATCH 50/84] Cleanup --- web/apps/photos/src/pages/cluster-debug.tsx | 30 +++++++-------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index b253d061b4..0595325458 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -28,14 +28,7 @@ import { import { useFormik } from "formik"; import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; -import React, { - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import AutoSizer from "react-virtualized-auto-sizer"; import { areEqual, VariableSizeList } from "react-window"; @@ -47,17 +40,12 @@ export default function ClusterDebug() { ClusterDebugPageContents | undefined >(); - const cluster = useCallback((opts: ClusteringOpts) => { - return new Promise((resolve) => { - setClusterRes(undefined); - startLoading(); - wipClusterDebugPageContents(opts).then((res) => { - setClusterRes(res); - finishLoading(); - resolve(true); - }); - }); - }, []); + const cluster = async (opts: ClusteringOpts) => { + setClusterRes(undefined); + startLoading(); + setClusterRes(await wipClusterDebugPageContents(opts)); + finishLoading(); + }; useEffect(() => { showNavBar(true); @@ -69,7 +57,7 @@ export default function ClusterDebug() { {({ height, width }) => ( - + )} @@ -108,7 +96,7 @@ const Container = styled("div")` `; interface Header1Props { - onCluster: (opts: ClusteringOpts) => Promise; + onCluster: (opts: ClusteringOpts) => Promise; } const Header1: React.FC = ({ onCluster }) => { From cfcd41fc7eb22e3e8e3d43fb138830bbeb9c65d5 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 11:26:48 +0530 Subject: [PATCH 51/84] Cleanup --- web/apps/photos/src/pages/cluster-debug.tsx | 170 ++++++++++---------- 1 file changed, 85 insertions(+), 85 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 0595325458..b5cd361fef 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -57,7 +57,7 @@ export default function ClusterDebug() { {({ height, width }) => ( - + )} @@ -95,11 +95,11 @@ const Container = styled("div")` } `; -interface Header1Props { +interface OptionsFormProps { onCluster: (opts: ClusteringOpts) => Promise; } -const Header1: React.FC = ({ onCluster }) => { +const OptionsForm: React.FC = ({ onCluster }) => { const toFloat = (n: number | string) => typeof n == "string" ? parseFloat(n) : n; const { values, handleSubmit, handleChange, isSubmitting } = @@ -187,14 +187,7 @@ const Header1: React.FC = ({ onCluster }) => { ); }; -const Header1Memo = React.memo(Header1); - -const DivMemo = React.memo( - ({ style, children }) =>
{children}
, - areEqual, -); - -type ClusterListProps = Header2Props & { +type ClusterListProps = ClusterResHeaderProps & { height: number; width: number; }; @@ -248,36 +241,6 @@ const ClusterList: React.FC> = ({ ); }; -const ClusterListItemRenderer = React.memo(({ index, style, data }) => { - const { clusterRes, columns, shrinkRatio, items, children } = data; - - if (index === 0) { - return {children}; - } - - if (index === 1) - return ( -
- -
- ); - - const item = items[index - 2]; - return ( - - - {!Array.isArray(item) ? ( - {item} - ) : ( - item.map((f, i) => ( - - )) - )} - - - ); -}, areEqual); - type Item = string | FaceWithFile[]; const itemsFromClusterRes = ( @@ -320,6 +283,87 @@ const getShrinkRatio = (width: number, columns: number) => (width - 2 * getGapFromScreenEdge(width) - (columns - 1) * 4) / (columns * 120); +const ClusterListItemRenderer = React.memo(({ index, style, data }) => { + const { clusterRes, columns, shrinkRatio, items, children } = data; + + if (index == 0) { + // It in necessary to memoize the div that contains the form otherwise + // the form loses its submitting state on unnecessary re-renders. + return {children}; + } + + if (index == 1) + return ( +
+ +
+ ); + + const item = items[index - 2]; + return ( + + + {!Array.isArray(item) ? ( + {item} + ) : ( + item.map((f, i) => ( + + )) + )} + + + ); +}, areEqual); + +const DivMemo = React.memo( + ({ style, children }) =>
{children}
, + areEqual, +); + +interface ClusterResHeaderProps { + clusterRes: ClusterDebugPageContents | undefined; +} + +const ClusterResHeader: React.FC = ({ clusterRes }) => { + if (!clusterRes) return null; + + const { clusteredFaceCount, unclusteredFaceCount, timeTakenMs, clusters } = + clusterRes; + + return ( + + + {`${clusters.length} clusters from ${clusteredFaceCount} faces in ${(timeTakenMs / 1000).toFixed(0)} seconds. ${unclusteredFaceCount} unclustered faces.`} + + + Showing only top 30 and bottom 30 clusters. + + + For each cluster showing only up to 50 faces, sorted by cosine + similarity to highest scoring face in the cluster. + + + Below each face is its{" "} + blur - score - cosineSimilarity - direction. + + + Faces added to the cluster as a result of merging are outlined. + + + ); +}; + +const Loader = () => ( + + + +); + +const ListItem = styled("div")` + display: flex; + justify-content: center; +`; + const ListContainer = styled(Box, { shouldForwardProp: (propName) => propName != "shrinkRatio", })<{ @@ -343,50 +387,6 @@ const LabelContainer = styled(ListItemContainer)` height: 32px; `; -const ListItem = styled("div")` - display: flex; - justify-content: center; -`; - -interface Header2Props { - clusterRes: ClusterDebugPageContents | undefined; -} - -const Header2: React.FC = ({ clusterRes }) => { - return ( - clusterRes && ( - - - {`${clusterRes.clusters.length} clusters from ${clusterRes.clusteredFaceCount} faces in ${(clusterRes.timeTakenMs / 1000).toFixed(0)} seconds. ${clusterRes.unclusteredFaceCount} unclustered faces.`} - - - Showing only top 30 and bottom 30 clusters. - - - For each cluster showing only up to 50 faces, sorted by - cosine similarity to highest scoring face in the cluster. - - - Below each face is its{" "} - blur - score - cosineSimilarity - direction. - - - Faces added to the cluster as a result of merging are - outlined. - - - ) - ); -}; - -const Header2Memo = React.memo(Header2); - -const Loader = () => ( - - - -); - interface FaceItemProps { faceWithFile: FaceWithFile; } From e36f081d96ac65ab9e276ff2cc5f96f0ef1cbdc1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 11:29:50 +0530 Subject: [PATCH 52/84] Cleanup --- web/apps/photos/src/pages/cluster-debug.tsx | 66 ++++++++++----------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index b5cd361fef..59d24b01ca 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -30,7 +30,11 @@ import { useRouter } from "next/router"; import { AppContext } from "pages/_app"; import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import AutoSizer from "react-virtualized-auto-sizer"; -import { areEqual, VariableSizeList } from "react-window"; +import { + areEqual, + VariableSizeList, + type ListChildComponentProps, +} from "react-window"; // TODO-Cluster Temporary component for debugging export default function ClusterDebug() { @@ -47,9 +51,7 @@ export default function ClusterDebug() { finishLoading(); }; - useEffect(() => { - showNavBar(true); - }, []); + useEffect(() => showNavBar(true), []); return ( <> @@ -283,40 +285,36 @@ const getShrinkRatio = (width: number, columns: number) => (width - 2 * getGapFromScreenEdge(width) - (columns - 1) * 4) / (columns * 120); -const ClusterListItemRenderer = React.memo(({ index, style, data }) => { - const { clusterRes, columns, shrinkRatio, items, children } = data; +// It in necessary to define the item renderer otherwise it gets recreated every +// time the parent rerenders, causing the form to lose its submitting state. +const ClusterListItemRenderer = React.memo( + ({ index, style, data }) => { + const { clusterRes, columns, shrinkRatio, items, children } = data; - if (index == 0) { - // It in necessary to memoize the div that contains the form otherwise - // the form loses its submitting state on unnecessary re-renders. - return {children}; - } + if (index == 0) return
{children}
; - if (index == 1) + if (index == 1) + return ( +
+ +
+ ); + + const item = items[index - 2]; return ( -
- -
+ + + {!Array.isArray(item) ? ( + {item} + ) : ( + item.map((f, i) => ( + + )) + )} + + ); - - const item = items[index - 2]; - return ( - - - {!Array.isArray(item) ? ( - {item} - ) : ( - item.map((f, i) => ( - - )) - )} - - - ); -}, areEqual); - -const DivMemo = React.memo( - ({ style, children }) =>
{children}
, + }, areEqual, ); From 1ec9dfea7f7a41cd1cdd22b7653c47b464cfd06d Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 12:21:25 +0530 Subject: [PATCH 53/84] Equal sized buttons --- web/apps/photos/src/pages/cluster-debug.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 59d24b01ca..25dbc202a5 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -129,7 +129,11 @@ const OptionsForm: React.FC = ({ onCluster }) => {
Parameters - + Date: Sat, 31 Aug 2024 12:36:51 +0530 Subject: [PATCH 54/84] Without ID --- web/apps/photos/src/pages/cluster-debug.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 25dbc202a5..7f04c3ec0d 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -135,7 +135,6 @@ const OptionsForm: React.FC = ({ onCluster }) => { sx={{ ".MuiFormControl-root": { flex: "1" } }} > = ({ onCluster }) => { ))} = ({ onCluster }) => { onChange={handleChange} /> = ({ onCluster }) => { onChange={handleChange} /> = ({ onCluster }) => { onChange={handleChange} /> Date: Sat, 31 Aug 2024 13:01:06 +0530 Subject: [PATCH 55/84] More counts --- web/apps/photos/src/pages/cluster-debug.tsx | 18 +++++++++++++----- .../new/photos/services/ml/cluster.ts | 11 ++++++++--- web/packages/new/photos/services/ml/index.ts | 19 ++++++------------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 7f04c3ec0d..ae2ea6b225 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -324,16 +324,23 @@ interface ClusterResHeaderProps { const ClusterResHeader: React.FC = ({ clusterRes }) => { if (!clusterRes) return null; - const { clusteredFaceCount, unclusteredFaceCount, timeTakenMs, clusters } = - clusterRes; + const { + totalFaceCount, + filteredFaceCount, + clusteredFaceCount, + unclusteredFaceCount, + timeTakenMs, + clusters, + } = clusterRes; return ( - {`${clusters.length} clusters from ${clusteredFaceCount} faces in ${(timeTakenMs / 1000).toFixed(0)} seconds. ${unclusteredFaceCount} unclustered faces.`} + {`${clusters.length} clusters in ${(timeTakenMs / 1000).toFixed(0)} seconds. Faces total ${totalFaceCount} filtered ${filteredFaceCount} clustered ${clusteredFaceCount} unclustered ${unclusteredFaceCount}.`} - Showing only top 30 and bottom 30 clusters. + Showing only top 30 clusters, bottom 30 clusters, and + unclustered faces. For each cluster showing only up to 50 faces, sorted by cosine @@ -344,7 +351,8 @@ const ClusterResHeader: React.FC = ({ clusterRes }) => { blur - score - cosineSimilarity - direction. - Faces added to the cluster as a result of merging are outlined. + Faces added to the cluster as a result of next batch merging are + outlined. ); diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index c39e0304c9..8d2607ff08 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -180,7 +180,8 @@ export const clusterFaces = ( const t = Date.now(); // A flattened array of faces. - const faces = [...enumerateFaces(faceIndexes)] + const allFaces = [...enumerateFaces(faceIndexes)]; + const faces = allFaces .filter((f) => f.blur > minBlur) .filter((f) => f.score > minScore) .slice(0, 2000); @@ -349,8 +350,10 @@ export const clusterFaces = ( }); } + const totalFaceCount = allFaces.length; + const filteredFaceCount = faces.length; const clusteredFaceCount = clusterIDForFaceID.size; - const unclusteredFaceCount = faces.length - clusteredFaceCount; + const unclusteredFaceCount = filteredFaceCount - clusteredFaceCount; const unclusteredFaces = faces.filter( ({ faceID }) => !clusterIDForFaceID.has(faceID), @@ -358,10 +361,12 @@ export const clusterFaces = ( const timeTakenMs = Date.now() - t; log.info( - `Clustered ${faces.length} faces into ${clusters.length} clusters, with ${faces.length - clusterIDForFaceID.size} faces remaining unclustered (${timeTakenMs} ms)`, + `Clustered ${faces.length} faces into ${clusters.length} clusters, ${faces.length - clusterIDForFaceID.size} faces remain unclustered (${timeTakenMs} ms)`, ); return { + totalFaceCount, + filteredFaceCount, clusteredFaceCount, unclusteredFaceCount, clusterPreviews, diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 6f8bb91a49..3bb6bb3eaf 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -358,15 +358,17 @@ export type ClusterPreviewFaceWithFile = ClusterPreviewFace & { }; export interface ClusterDebugPageContents { + totalFaceCount: number; + filteredFaceCount: number; clusteredFaceCount: number; unclusteredFaceCount: number; + timeTakenMs: number; clusters: FaceCluster[]; clusterPreviewsWithFile: ClusterPreviewWithFile[]; unclusteredFacesWithFile: { face: Face; enteFile: EnteFile; }[]; - timeTakenMs: number; } export const wipClusterDebugPageContents = async ( @@ -379,15 +381,8 @@ export const wipClusterDebugPageContents = async ( _wip_searchPersons = undefined; triggerStatusUpdate(); - const { - clusteredFaceCount, - unclusteredFaceCount, - clusterPreviews, - clusters, - cgroups, - unclusteredFaces, - timeTakenMs, - } = await worker().then((w) => w.clusterFaces(opts)); + const { clusterPreviews, clusters, cgroups, unclusteredFaces, ...rest } = + await worker().then((w) => w.clusterFaces(opts)); const localFiles = await getAllLocalFiles(); const localFileByID = new Map(localFiles.map((f) => [f.id, f])); @@ -441,12 +436,10 @@ export const wipClusterDebugPageContents = async ( triggerStatusUpdate(); return { - clusteredFaceCount, - unclusteredFaceCount, clusters, clusterPreviewsWithFile, unclusteredFacesWithFile, - timeTakenMs, + ...rest, }; }; From 72dc526724649826a023ff544cc569c438ae15e1 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 13:29:24 +0530 Subject: [PATCH 56/84] Form tweaks --- web/apps/photos/src/pages/cluster-debug.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index ae2ea6b225..6337daffd1 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -102,8 +102,10 @@ interface OptionsFormProps { } const OptionsForm: React.FC = ({ onCluster }) => { + // Formik converts nums to a string on edit. const toFloat = (n: number | string) => typeof n == "string" ? parseFloat(n) : n; + const { values, handleSubmit, handleChange, isSubmitting } = useFormik({ initialValues: { @@ -123,8 +125,6 @@ const OptionsForm: React.FC = ({ onCluster }) => { }), }); - console.log("rendering form", { isSubmitting }); - return ( @@ -178,7 +178,11 @@ const OptionsForm: React.FC = ({ onCluster }) => { /> - @@ -335,8 +339,8 @@ const ClusterResHeader: React.FC = ({ clusterRes }) => { return ( - - {`${clusters.length} clusters in ${(timeTakenMs / 1000).toFixed(0)} seconds. Faces total ${totalFaceCount} filtered ${filteredFaceCount} clustered ${clusteredFaceCount} unclustered ${unclusteredFaceCount}.`} + + {`${clusters.length} clusters in ${(timeTakenMs / 1000).toFixed(0)} seconds • ${totalFaceCount} faces ${filteredFaceCount} filtered ${clusteredFaceCount} clustered ${unclusteredFaceCount} unclustered`} Showing only top 30 clusters, bottom 30 clusters, and From 7a7c8c02dec8f3797666812081908ea9e2bc81f0 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 13:39:37 +0530 Subject: [PATCH 57/84] Remove debug code --- web/packages/new/photos/services/ml/cluster.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index 8d2607ff08..271cd6d491 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -183,8 +183,8 @@ export const clusterFaces = ( const allFaces = [...enumerateFaces(faceIndexes)]; const faces = allFaces .filter((f) => f.blur > minBlur) - .filter((f) => f.score > minScore) - .slice(0, 2000); + .filter((f) => f.score > minScore); + // .slice(0, 2000); // For fast reverse lookup - map from face ids to the face. const faceForFaceID = new Map(faces.map((f) => [f.faceID, f])); From 22c5485b3bd2482303203fdc5b85cc0734a9e5ee Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 15:43:18 +0530 Subject: [PATCH 58/84] [web] Make it more apparent what the create albums button does Change title of the button from "New album" to "Create albums" to indicate that it can be used to preserve albums. See: https://github.com/ente-io/ente/issues/3067 --- .../Collections/CollectionSelector/AddCollectionButton.tsx | 4 +--- web/packages/base/locales/en-US/translation.json | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/web/apps/photos/src/components/Collections/CollectionSelector/AddCollectionButton.tsx b/web/apps/photos/src/components/Collections/CollectionSelector/AddCollectionButton.tsx index 87e698361d..0b2245744c 100644 --- a/web/apps/photos/src/components/Collections/CollectionSelector/AddCollectionButton.tsx +++ b/web/apps/photos/src/components/Collections/CollectionSelector/AddCollectionButton.tsx @@ -23,9 +23,7 @@ export default function AddCollectionButton({ showNextModal }: Iprops) { onClick={() => showNextModal()} coverFile={null} > - - {t("CREATE_COLLECTION")} - + {t("create_albums")} + diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json index 5585253915..2e984af93b 100644 --- a/web/packages/base/locales/en-US/translation.json +++ b/web/packages/base/locales/en-US/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "How did you hear about Ente? (optional)", "REFERRAL_INFO": "We don't track app installs, It'd help us if you told us where you found us!", "PASSPHRASE_MATCH_ERROR": "Passwords don't match", + "create_albums": "Create albums", "CREATE_COLLECTION": "New album", "ENTER_ALBUM_NAME": "Album name", "CLOSE_OPTION": "Close (Esc)", From 7dece286ec600cd3582e326c99e92e29b5786888 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Sat, 31 Aug 2024 10:18:47 +0000 Subject: [PATCH 59/84] New Crowdin translations by GitHub Action --- .../base/locales/ar-SA/translation.json | 357 +++++----- .../base/locales/bg-BG/translation.json | 1 + .../base/locales/ca-ES/translation.json | 1 + .../base/locales/de-DE/translation.json | 3 +- .../base/locales/el-GR/translation.json | 1 + .../base/locales/es-ES/translation.json | 3 +- .../base/locales/et-EE/translation.json | 1 + .../base/locales/fa-IR/translation.json | 1 + .../base/locales/fi-FI/translation.json | 1 + .../base/locales/fr-FR/translation.json | 3 +- .../base/locales/gu-IN/translation.json | 1 + .../base/locales/hi-IN/translation.json | 1 + .../base/locales/id-ID/translation.json | 1 + .../base/locales/is-IS/translation.json | 1 + .../base/locales/it-IT/translation.json | 1 + .../base/locales/ja-JP/translation.json | 1 + .../base/locales/km-KH/translation.json | 1 + .../base/locales/ko-KR/translation.json | 1 + .../base/locales/nl-NL/translation.json | 3 +- .../base/locales/pl-PL/translation.json | 3 +- .../base/locales/pt-BR/translation.json | 3 +- .../base/locales/pt-PT/translation.json | 1 + .../base/locales/ru-RU/translation.json | 3 +- .../base/locales/sv-SE/translation.json | 15 +- .../base/locales/ta-IN/translation.json | 659 ++++++++++++++++++ .../base/locales/te-IN/translation.json | 1 + .../base/locales/th-TH/translation.json | 1 + .../base/locales/ti-ER/translation.json | 1 + .../base/locales/tr-TR/translation.json | 1 + .../base/locales/zh-CN/translation.json | 5 +- 30 files changed, 882 insertions(+), 194 deletions(-) create mode 100644 web/packages/base/locales/ta-IN/translation.json diff --git a/web/packages/base/locales/ar-SA/translation.json b/web/packages/base/locales/ar-SA/translation.json index 968e1627bd..d4d7e31068 100644 --- a/web/packages/base/locales/ar-SA/translation.json +++ b/web/packages/base/locales/ar-SA/translation.json @@ -33,14 +33,15 @@ "ENTER_ENC_PASSPHRASE": "الرجاء إدخال كلمة المرور التي يمكننا استخدامها لتشفير بياناتك", "PASSPHRASE_DISCLAIMER": "نحن لا نخزن كلمة مرورك، لذا إذا نسيتها، لن نتمكن من مساعدتك في استرداد بياناتك دون مفتاح الاسترداد.", "WELCOME_TO_ENTE_HEADING": "مرحبا بك في ", - "WELCOME_TO_ENTE_SUBHEADING": "", - "WHERE_YOUR_BEST_PHOTOS_LIVE": "", + "WELCOME_TO_ENTE_SUBHEADING": "تخزين الصور ومشاركتها بشكل مشفر من طرف إلى طرف", + "WHERE_YOUR_BEST_PHOTOS_LIVE": "أين تعيش أفضل صورك", "KEY_GENERATION_IN_PROGRESS_MESSAGE": "جار توليد مفاتيح التشفير...", "PASSPHRASE_HINT": "كلمة المرور", "CONFIRM_PASSPHRASE": "تأكيد كلمة المرور", "REFERRAL_CODE_HINT": "كيف سمعت عن Ente؟ (اختياري)", - "REFERRAL_INFO": "", + "REFERRAL_INFO": "نحن لا نتتبع عمليات تثبيت التطبيق، سيكون من المفيد لنا أن تخبرنا أين وجدتنا!", "PASSPHRASE_MATCH_ERROR": "كلمات المرور غير متطابقة", + "create_albums": "", "CREATE_COLLECTION": "ألبوم جديد", "ENTER_ALBUM_NAME": "اسم الألبوم", "CLOSE_OPTION": "إغلاق (Esc)", @@ -58,10 +59,10 @@ "FILE_UPLOAD": "تحميل الملف", "UPLOAD_STAGE_MESSAGE": { "0": "الإعداد للتحميل", - "1": "", - "2": "", - "3": "", - "4": "", + "1": "قراءة ملفات بيانات تعريف جوجل", + "2": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} البيانات الملفات الوصفية المستخرجة", + "3": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} ملفات معالجة", + "4": "إلغاء التحميلات المتبقية", "5": "اكتمل النسخ الاحتياطي" }, "FILE_NOT_UPLOADED_LIST": "لم يتم تحميل الملفات التالية", @@ -71,227 +72,227 @@ "ACCOUNT_EXISTS": "لديك حساب بالفعل", "CREATE": "إنشاء", "DOWNLOAD": "تنزيل", - "DOWNLOAD_OPTION": "", - "DOWNLOAD_FAVORITES": "", - "DOWNLOAD_UNCATEGORIZED": "", - "DOWNLOAD_HIDDEN_ITEMS": "", - "COPY_OPTION": "", - "TOGGLE_FULLSCREEN": "", - "ZOOM_IN_OUT": "", - "PREVIOUS": "", - "NEXT": "", - "title_photos": "", - "title_auth": "", - "title_accounts": "", - "UPLOAD_FIRST_PHOTO": "", - "IMPORT_YOUR_FOLDERS": "", - "UPLOAD_DROPZONE_MESSAGE": "", + "DOWNLOAD_OPTION": "تنزيل (D)", + "DOWNLOAD_FAVORITES": "تنزيل المفضلات", + "DOWNLOAD_UNCATEGORIZED": "التنزيل غير المصنف", + "DOWNLOAD_HIDDEN_ITEMS": "تنزيل العناصر المخفية", + "COPY_OPTION": "نسخ كـ PNG (Ctrl/Cmd - C)", + "TOGGLE_FULLSCREEN": "تبديل ملء الشاشة (F)", + "ZOOM_IN_OUT": "تكبير/تصغير", + "PREVIOUS": "السابق (←)", + "NEXT": "التالي (→)", + "title_photos": "صور Ente", + "title_auth": "مصادقة Ente", + "title_accounts": "حسابات Ente", + "UPLOAD_FIRST_PHOTO": "تحميل صورتك الأولى", + "IMPORT_YOUR_FOLDERS": "استيراد مجلداتك", + "UPLOAD_DROPZONE_MESSAGE": "إسقاط للنسخ الاحتياطي للملفاتك", "WATCH_FOLDER_DROPZONE_MESSAGE": "", - "TRASH_FILES_TITLE": "", - "TRASH_FILE_TITLE": "", - "DELETE_FILES_TITLE": "", - "DELETE_FILES_MESSAGE": "", - "DELETE": "", - "DELETE_OPTION": "", - "FAVORITE_OPTION": "", - "UNFAVORITE_OPTION": "", - "MULTI_FOLDER_UPLOAD": "", - "UPLOAD_STRATEGY_CHOICE": "", - "UPLOAD_STRATEGY_SINGLE_COLLECTION": "", - "OR": "", - "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "", - "SESSION_EXPIRED_MESSAGE": "", - "SESSION_EXPIRED": "", - "PASSWORD_GENERATION_FAILED": "", - "CHANGE_PASSWORD": "", - "password_changed_elsewhere": "", - "password_changed_elsewhere_message": "", - "GO_BACK": "", - "RECOVERY_KEY": "", - "SAVE_LATER": "", - "SAVE": "", + "TRASH_FILES_TITLE": "حذف الملفات؟", + "TRASH_FILE_TITLE": "حذف الملف؟", + "DELETE_FILES_TITLE": "حذف فورا؟", + "DELETE_FILES_MESSAGE": "سيتم حذف الملفات المحددة نهائيا من حساب Ente الخاص بك.", + "DELETE": "حذف", + "DELETE_OPTION": "حذف (DEL)", + "FAVORITE_OPTION": "مفضلة (L)", + "UNFAVORITE_OPTION": "غير مفضلة (L)", + "MULTI_FOLDER_UPLOAD": "تم اكتشاف مجلدات متعددة", + "UPLOAD_STRATEGY_CHOICE": "هل ترغب في تحميلهم إلى", + "UPLOAD_STRATEGY_SINGLE_COLLECTION": "ألبوم واحد", + "OR": "أو", + "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "ألبومات منفصلة", + "SESSION_EXPIRED_MESSAGE": "لقد انتهت صلاحية جلستك، يرجى تسجيل الدخول مرة أخرى للمتابعة", + "SESSION_EXPIRED": "انتهت صلاحية الجلسة", + "PASSWORD_GENERATION_FAILED": "لم يتمكن متصفحك من إنشاء مفتاح قوي يفي بمعايير تشفير Ente، يرجى المحاولة باستخدام تطبيق الهاتف المحمول أو متصفح آخر", + "CHANGE_PASSWORD": "تغيير كلمة المرور", + "password_changed_elsewhere": "تم تغيير كلمة المرور في مكان آخر", + "password_changed_elsewhere_message": "يرجى تسجيل الدخول مرة أخرى على هذا الجهاز لاستخدام كلمة المرور الجديدة للمصادقة.", + "GO_BACK": "رجوع", + "RECOVERY_KEY": "مفتاح الاستعادة", + "SAVE_LATER": "قم بهذا لاحقا", + "SAVE": "حفظ المفتاح", "RECOVERY_KEY_DESCRIPTION": "", "RECOVER_KEY_GENERATION_FAILED": "", "KEY_NOT_STORED_DISCLAIMER": "", - "FORGOT_PASSWORD": "", - "RECOVER_ACCOUNT": "", - "RECOVERY_KEY_HINT": "", - "RECOVER": "", - "NO_RECOVERY_KEY": "", - "INCORRECT_RECOVERY_KEY": "", - "SORRY": "", + "FORGOT_PASSWORD": "نسيت كلمة المرور", + "RECOVER_ACCOUNT": "إستعادة الحساب", + "RECOVERY_KEY_HINT": "مفتاح الاستعادة", + "RECOVER": "استعادة", + "NO_RECOVERY_KEY": "ما من مفتاح استعادة؟", + "INCORRECT_RECOVERY_KEY": "مفتاح استعادة غير صحيح", + "SORRY": "عذرا", "NO_RECOVERY_KEY_MESSAGE": "", "NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "", - "CONTACT_SUPPORT": "", - "REQUEST_FEATURE": "", - "SUPPORT": "", - "CONFIRM": "", - "cancel": "", - "LOGOUT": "", - "delete_account": "", + "CONTACT_SUPPORT": "الاتصال بالدعم", + "REQUEST_FEATURE": "طلب ميزة", + "SUPPORT": "الدعم", + "CONFIRM": "تأكيد", + "cancel": "إلغاء", + "LOGOUT": "تسجيل الخروج", + "delete_account": "حذف الحساب", "delete_account_manually_message": "", - "LOGOUT_MESSAGE": "", - "CHANGE_EMAIL": "", - "OK": "", - "SUCCESS": "", - "ERROR": "", - "MESSAGE": "", + "LOGOUT_MESSAGE": "هل أنت متأكد من أنك تريد تسجيل الخروج؟", + "CHANGE_EMAIL": "تغيير البريد الإلكتروني", + "OK": "حسنا", + "SUCCESS": "تم بنجاح", + "ERROR": "خطأ", + "MESSAGE": "رسالة", "OFFLINE_MSG": "", "INSTALL_MOBILE_APP": "", "DOWNLOAD_APP_MESSAGE": "", - "DOWNLOAD_APP": "", - "EXPORT": "", - "SUBSCRIPTION": "", - "SUBSCRIBE": "", - "MANAGEMENT_PORTAL": "", - "MANAGE_FAMILY_PORTAL": "", - "LEAVE_FAMILY_PLAN": "", - "LEAVE": "", - "LEAVE_FAMILY_CONFIRM": "", - "CHOOSE_PLAN": "", - "MANAGE_PLAN": "", - "CURRENT_USAGE": "", - "TWO_MONTHS_FREE": "", - "POPULAR": "", - "free_plan_option": "", - "free_plan_description": "", - "active": "", - "subscription_info_free": "", + "DOWNLOAD_APP": "تنزيل تطبيق سطح المكتب", + "EXPORT": "تصدير البيانات", + "SUBSCRIPTION": "اشتراك", + "SUBSCRIBE": "اشترك", + "MANAGEMENT_PORTAL": "إدارة طريقة الدفع", + "MANAGE_FAMILY_PORTAL": "إدارة العائلة", + "LEAVE_FAMILY_PLAN": "مغادرة خطة العائلة", + "LEAVE": "مغادرة", + "LEAVE_FAMILY_CONFIRM": "هل أنت متأكد من أنك تريد مغادرة الخطة العائلية؟", + "CHOOSE_PLAN": "اختر خطتك", + "MANAGE_PLAN": "إدارة اشتراكك", + "CURRENT_USAGE": "الاستخدام الحالي هو {{usage}}", + "TWO_MONTHS_FREE": "احصل على شهرين مجانا في الخطط السنوية", + "POPULAR": "رائج", + "free_plan_option": "المتابعة مع الخطة المجانية", + "free_plan_description": "{{storage}} مجاني للأبد", + "active": "نشط", + "subscription_info_free": "أنت في الخطة المجانية", "subscription_info_family": "", "subscription_info_expired": "", "subscription_info_renewal_cancelled": "", "subscription_info_storage_quota_exceeded": "", "subscription_status_renewal_active": "", - "subscription_status_renewal_cancelled": "", + "subscription_status_renewal_cancelled": "ينتهي في {{date, date}}", "add_on_valid_till": "", - "subscription_expired": "", - "storage_quota_exceeded": "", - "SUBSCRIPTION_PURCHASE_SUCCESS": "", + "subscription_expired": "إنتهت صلاحية الاشتراك", + "storage_quota_exceeded": "تم تجاوز حد التخزين", + "SUBSCRIPTION_PURCHASE_SUCCESS": "

لقد تلقينا دفعتك

اشتراكك صالح حتى {{date, date}}

", "SUBSCRIPTION_PURCHASE_CANCELLED": "", "SUBSCRIPTION_PURCHASE_FAILED": "", "SUBSCRIPTION_UPDATE_FAILED": "", "UPDATE_PAYMENT_METHOD_MESSAGE": "", "STRIPE_AUTHENTICATION_FAILED": "", "UPDATE_PAYMENT_METHOD": "", - "MONTHLY": "", - "YEARLY": "", - "MONTH_SHORT": "", - "YEAR": "", - "update_subscription_title": "", - "UPDATE_SUBSCRIPTION_MESSAGE": "", - "UPDATE_SUBSCRIPTION": "", - "CANCEL_SUBSCRIPTION": "", + "MONTHLY": "شهريا", + "YEARLY": "سنويا", + "MONTH_SHORT": "شهر", + "YEAR": "سنة", + "update_subscription_title": "تأكيد تغيير الخطة", + "UPDATE_SUBSCRIPTION_MESSAGE": "هل أنت متأكد من أنك تريد تغيير خطتك؟", + "UPDATE_SUBSCRIPTION": "تغيير الخطة", + "CANCEL_SUBSCRIPTION": "إلغاء الاشتراك", "CANCEL_SUBSCRIPTION_MESSAGE": "", "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "", - "SUBSCRIPTION_CANCEL_FAILED": "", - "SUBSCRIPTION_CANCEL_SUCCESS": "", - "REACTIVATE_SUBSCRIPTION": "", + "SUBSCRIPTION_CANCEL_FAILED": "فشل في إلغاء الاشتراك", + "SUBSCRIPTION_CANCEL_SUCCESS": "تم إلغاء الاشتراك بنجاح", + "REACTIVATE_SUBSCRIPTION": "إعادة تنشيط الاشتراك", "REACTIVATE_SUBSCRIPTION_MESSAGE": "", "SUBSCRIPTION_ACTIVATE_SUCCESS": "", "SUBSCRIPTION_ACTIVATE_FAILED": "", - "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "", + "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "شكرا لك", "CANCEL_SUBSCRIPTION_ON_MOBILE": "", "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "", "MAIL_TO_MANAGE_SUBSCRIPTION": "", - "RENAME": "", - "RENAME_FILE": "", - "RENAME_COLLECTION": "", - "DELETE_COLLECTION_TITLE": "", - "DELETE_COLLECTION": "", + "RENAME": "اعادة تسمية", + "RENAME_FILE": "إعادة تسمية ملف", + "RENAME_COLLECTION": "إعادة تسمية ألبوم", + "DELETE_COLLECTION_TITLE": "حذف ألبوم؟", + "DELETE_COLLECTION": "حذف ألبوم", "DELETE_COLLECTION_MESSAGE": "", - "DELETE_PHOTOS": "", - "KEEP_PHOTOS": "", - "SHARE_COLLECTION": "", - "SHARE_WITH_SELF": "", + "DELETE_PHOTOS": "حذف الصور", + "KEEP_PHOTOS": "الاحتفاظ بالصور", + "SHARE_COLLECTION": "مشاركة الألبوم", + "SHARE_WITH_SELF": "عفوا، لا يمكنك المشاركة مع نفسك", "ALREADY_SHARED": "", - "SHARING_BAD_REQUEST_ERROR": "", - "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "", - "DOWNLOAD_COLLECTION": "", + "SHARING_BAD_REQUEST_ERROR": "لا يسمح بمشاركة الألبوم", + "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "المشاركة معطلة للحسابات المجانية", + "DOWNLOAD_COLLECTION": "تنزيل الألبوم", "CREATE_ALBUM_FAILED": "", - "SEARCH": "", - "SEARCH_RESULTS": "", - "NO_RESULTS": "", - "SEARCH_HINT": "", + "SEARCH": "بحث", + "SEARCH_RESULTS": "نتائج البحث", + "NO_RESULTS": "لا توجد نتائج", + "SEARCH_HINT": "البحث عن الألبومات، التواريخ، والأوصاف...", "SEARCH_TYPE": { - "COLLECTION": "", - "LOCATION": "", - "CITY": "", - "DATE": "", - "FILE_NAME": "", - "THING": "", - "FILE_CAPTION": "", - "FILE_TYPE": "", - "CLIP": "" + "COLLECTION": "ألبوم", + "LOCATION": "الموقع", + "CITY": "الموقع", + "DATE": "تاريخ", + "FILE_NAME": "إسم الملف", + "THING": "المحتوى", + "FILE_CAPTION": "وصف", + "FILE_TYPE": "نوع الملف", + "CLIP": "سحر" }, - "photos_count_zero": "", - "photos_count_one": "", - "photos_count": "", - "TERMS_AND_CONDITIONS": "", - "ADD_TO_COLLECTION": "", - "SELECTED": "", + "photos_count_zero": "لا توجد ذكريات", + "photos_count_one": "ذكرى واحدة", + "photos_count": "{{count, number}} ذكريات", + "TERMS_AND_CONDITIONS": "أوافق على
شروط الخدمة وسياسة الخصوصية", + "ADD_TO_COLLECTION": "إضافة إلى الألبوم", + "SELECTED": "محدد", "PEOPLE": "", - "indexing_scheduled": "", + "indexing_scheduled": "الفهرسة مجدولة...", "indexing_photos": "", "indexing_fetching": "", "indexing_people": "", "indexing_done": "", - "UNIDENTIFIED_FACES": "", + "UNIDENTIFIED_FACES": "وجوه غير محددة", "OBJECTS": "", - "TEXT": "", - "INFO": "", - "INFO_OPTION": "", - "FILE_NAME": "", - "CAPTION_PLACEHOLDER": "", - "LOCATION": "", - "SHOW_ON_MAP": "", - "MAP": "", - "MAP_SETTINGS": "", - "ENABLE_MAPS": "", - "ENABLE_MAP": "", - "DISABLE_MAPS": "", + "TEXT": "نص", + "INFO": "معلومات ", + "INFO_OPTION": "معلومات (I)", + "FILE_NAME": "إسم الملف", + "CAPTION_PLACEHOLDER": "إضافة وصف", + "LOCATION": "الموقع", + "SHOW_ON_MAP": "عرض على OpenStreetMap", + "MAP": "خريطة", + "MAP_SETTINGS": "إعدادات الخريطة", + "ENABLE_MAPS": "تمكين الخرائط ؟", + "ENABLE_MAP": "تمكين الخريطة", + "DISABLE_MAPS": "تعطيل الخرائط؟", "ENABLE_MAP_DESCRIPTION": "", "DISABLE_MAP_DESCRIPTION": "", - "DISABLE_MAP": "", - "DETAILS": "", - "view_exif": "", - "no_exif": "", - "exif": "", - "ISO": "", + "DISABLE_MAP": "تعطيل الخريطة", + "DETAILS": "تفاصيل", + "view_exif": "عرض جميع بيانات Exif", + "no_exif": "لا توجد بيانات Exif", + "exif": "Exif", + "ISO": "ISO", "TWO_FACTOR": "", - "TWO_FACTOR_AUTHENTICATION": "", + "TWO_FACTOR_AUTHENTICATION": "المصادقة الثنائية", "TWO_FACTOR_QR_INSTRUCTION": "", - "ENTER_CODE_MANUALLY": "", + "ENTER_CODE_MANUALLY": "أدخل الرمز يدويا", "TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "", - "SCAN_QR_CODE": "", + "SCAN_QR_CODE": "مسح رمز QR بدلاً من ذلك", "ENABLE_TWO_FACTOR": "", - "enable": "", - "enabled": "", + "enable": "تفعيل", + "enabled": "مفعل", "LOST_DEVICE": "", - "INCORRECT_CODE": "", + "INCORRECT_CODE": "رمز غير صحيح", "TWO_FACTOR_INFO": "", "DISABLE_TWO_FACTOR_LABEL": "", "UPDATE_TWO_FACTOR_LABEL": "", - "disable": "", - "reconfigure": "", + "disable": "تعطيل", + "reconfigure": "إعادة التهيئة", "UPDATE_TWO_FACTOR": "", "UPDATE_TWO_FACTOR_MESSAGE": "", - "UPDATE": "", + "UPDATE": "تحديث", "DISABLE_TWO_FACTOR": "", "DISABLE_TWO_FACTOR_MESSAGE": "", "TWO_FACTOR_DISABLE_FAILED": "", - "EXPORT_DATA": "", + "EXPORT_DATA": "تصدير البيانات", "select_folder": "", "select_zips": "", - "faq": "", + "faq": "الأسئلة الشائعة", "takeout_hint": "", - "DESTINATION": "", - "START": "", - "LAST_EXPORT_TIME": "", + "DESTINATION": "الوجهة", + "START": "بدء", + "LAST_EXPORT_TIME": "آخر وقت تصدير", "EXPORT_AGAIN": "", - "LOCAL_STORAGE_NOT_ACCESSIBLE": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE": "التخزين المحلي غير قابل للوصول", "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "", "SEND_OTT": "", - "EMAIl_ALREADY_OWNED": "", + "EMAIl_ALREADY_OWNED": "البريد الإلكتروني مأخوذ بالفعل", "ETAGS_BLOCKED": "", "LIVE_PHOTOS_DETECTED": "", "RETRY_FAILED": "", @@ -321,22 +322,22 @@ "MOVE_TO_COLLECTION": "", "UNARCHIVE": "", "UNARCHIVE_COLLECTION": "", - "HIDE_COLLECTION": "", - "UNHIDE_COLLECTION": "", - "MOVE": "", - "ADD": "", - "REMOVE": "", - "YES_REMOVE": "", + "HIDE_COLLECTION": "إخفاء الألبوم", + "UNHIDE_COLLECTION": "إلغاء إخفاء الألبوم", + "MOVE": "نقل", + "ADD": "إضافة", + "REMOVE": "ازالة", + "YES_REMOVE": "نعم، إزالة", "REMOVE_FROM_COLLECTION": "", - "TRASH": "", - "MOVE_TO_TRASH": "", + "TRASH": "سلة المهملات", + "MOVE_TO_TRASH": "نقل إلى سلة المهملات", "TRASH_FILES_MESSAGE": "", "TRASH_FILE_MESSAGE": "", - "DELETE_PERMANENTLY": "", - "RESTORE": "", + "DELETE_PERMANENTLY": "حذف بشكل دائم", + "RESTORE": "استعادة", "RESTORE_TO_COLLECTION": "", - "EMPTY_TRASH": "", - "EMPTY_TRASH_TITLE": "", + "EMPTY_TRASH": "إفراغ سلة المهملات", + "EMPTY_TRASH_TITLE": "إفراغ سلة المهملات؟", "EMPTY_TRASH_MESSAGE": "", "LEAVE_SHARED_ALBUM": "", "LEAVE_ALBUM": "", diff --git a/web/packages/base/locales/bg-BG/translation.json b/web/packages/base/locales/bg-BG/translation.json index 7df973eb83..787567c446 100644 --- a/web/packages/base/locales/bg-BG/translation.json +++ b/web/packages/base/locales/bg-BG/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/ca-ES/translation.json b/web/packages/base/locales/ca-ES/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/ca-ES/translation.json +++ b/web/packages/base/locales/ca-ES/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/de-DE/translation.json b/web/packages/base/locales/de-DE/translation.json index 6c83a4dd72..fdad284a60 100644 --- a/web/packages/base/locales/de-DE/translation.json +++ b/web/packages/base/locales/de-DE/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Wie hast du von Ente erfahren? (optional)", "REFERRAL_INFO": "Wir tracken keine App-Installationen. Es würde uns jedoch helfen, wenn du uns mitteilst, wie du von uns erfahren hast!", "PASSPHRASE_MATCH_ERROR": "Die Passwörter stimmen nicht überein", + "create_albums": "", "CREATE_COLLECTION": "Neues Album", "ENTER_ALBUM_NAME": "Albumname", "CLOSE_OPTION": "Schließen (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Das Vorschaubild konnte nicht erzeugt werden", "UNSUPPORTED_FILES": "Nicht unterstützte Dateien", "SUCCESSFUL_UPLOADS": "Erfolgreiche Uploads", - "SKIPPED_INFO": "Diese wurden übersprungen, da es Dateien mit gleichen Namen im selben Album gibt", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "Ente unterstützt diese Dateiformate noch nicht", "BLOCKED_UPLOADS": "Blockierte Uploads", "INPROGRESS_METADATA_EXTRACTION": "In Bearbeitung", diff --git a/web/packages/base/locales/el-GR/translation.json b/web/packages/base/locales/el-GR/translation.json index 5f93899450..9ecc3b137f 100644 --- a/web/packages/base/locales/el-GR/translation.json +++ b/web/packages/base/locales/el-GR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Πώς ακούσατε για το Ente; (προαιρετικό)", "REFERRAL_INFO": "Δεν παρακολουθούμε τις εγκαταστάσεις εφαρμογών. Θα μας βοηθούσε αν μας λέγατε που μας βρήκατε!", "PASSPHRASE_MATCH_ERROR": "Οι κωδικοί πρόσβασης δεν ταιριάζουν", + "create_albums": "", "CREATE_COLLECTION": "Νέο άλμπουμ", "ENTER_ALBUM_NAME": "Όνομα άλμπουμ", "CLOSE_OPTION": "Κλείσιμο (Esc)", diff --git a/web/packages/base/locales/es-ES/translation.json b/web/packages/base/locales/es-ES/translation.json index 6fee153eb7..3254dc154c 100644 --- a/web/packages/base/locales/es-ES/translation.json +++ b/web/packages/base/locales/es-ES/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "¿Cómo escuchaste acerca de Ente? (opcional)", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "Las contraseñas no coinciden", + "create_albums": "", "CREATE_COLLECTION": "Nuevo álbum", "ENTER_ALBUM_NAME": "Nombre del álbum", "CLOSE_OPTION": "Cerrar (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Generación de miniaturas fallida", "UNSUPPORTED_FILES": "Archivos no soportados", "SUCCESSFUL_UPLOADS": "Subidas exitosas", - "SKIPPED_INFO": "Se han omitido ya que hay archivos con nombres coincidentes en el mismo álbum", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "ente no soporta estos formatos de archivo aún", "BLOCKED_UPLOADS": "Subidas bloqueadas", "INPROGRESS_METADATA_EXTRACTION": "En proceso", diff --git a/web/packages/base/locales/et-EE/translation.json b/web/packages/base/locales/et-EE/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/et-EE/translation.json +++ b/web/packages/base/locales/et-EE/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/fa-IR/translation.json b/web/packages/base/locales/fa-IR/translation.json index c3d6c8159a..b6d481c36a 100644 --- a/web/packages/base/locales/fa-IR/translation.json +++ b/web/packages/base/locales/fa-IR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/fi-FI/translation.json b/web/packages/base/locales/fi-FI/translation.json index d0f899abef..8794455ddc 100644 --- a/web/packages/base/locales/fi-FI/translation.json +++ b/web/packages/base/locales/fi-FI/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Miten kuulit Entestä? (valinnainen)", "REFERRAL_INFO": "Emme seuraa sovelluksen asennuksia. Se auttaisi meitä, jos kertoisit mistä löysit meidät!", "PASSPHRASE_MATCH_ERROR": "Salasanat eivät täsmää", + "create_albums": "", "CREATE_COLLECTION": "Uusi albumi", "ENTER_ALBUM_NAME": "Albumin nimi", "CLOSE_OPTION": "Sulje (Esc)", diff --git a/web/packages/base/locales/fr-FR/translation.json b/web/packages/base/locales/fr-FR/translation.json index b315c99929..639b8a29b6 100644 --- a/web/packages/base/locales/fr-FR/translation.json +++ b/web/packages/base/locales/fr-FR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Comment avez-vous entendu parler de Ente? (facultatif)", "REFERRAL_INFO": "Nous ne suivons pas les installations d'applications. Il serait utile que vous nous disiez comment vous nous avez trouvés !", "PASSPHRASE_MATCH_ERROR": "Les mots de passe ne correspondent pas", + "create_albums": "", "CREATE_COLLECTION": "Nouvel album", "ENTER_ALBUM_NAME": "Nom de l'album", "CLOSE_OPTION": "Fermer (Échap)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Échec de création d'une miniature", "UNSUPPORTED_FILES": "Fichiers non supportés", "SUCCESSFUL_UPLOADS": "Chargements réussis", - "SKIPPED_INFO": "Ignorés car il y a des fichiers avec des noms identiques dans le même album", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "Ente ne supporte pas encore ces formats de fichiers", "BLOCKED_UPLOADS": "Chargements bloqués", "INPROGRESS_METADATA_EXTRACTION": "En cours", diff --git a/web/packages/base/locales/gu-IN/translation.json b/web/packages/base/locales/gu-IN/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/gu-IN/translation.json +++ b/web/packages/base/locales/gu-IN/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/hi-IN/translation.json b/web/packages/base/locales/hi-IN/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/hi-IN/translation.json +++ b/web/packages/base/locales/hi-IN/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/id-ID/translation.json b/web/packages/base/locales/id-ID/translation.json index 9bf887d7be..3f0ae89793 100644 --- a/web/packages/base/locales/id-ID/translation.json +++ b/web/packages/base/locales/id-ID/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Dari mana Anda menemukan Ente? (opsional)", "REFERRAL_INFO": "Kami tidak melacak pemasangan aplikasi, Ini akan membantu kami jika Anda memberi tahu kami di mana Anda menemukan kami!", "PASSPHRASE_MATCH_ERROR": "Kata sandi tidak cocok", + "create_albums": "", "CREATE_COLLECTION": "Album baru", "ENTER_ALBUM_NAME": "Nama album", "CLOSE_OPTION": "Tutup (Esc)", diff --git a/web/packages/base/locales/is-IS/translation.json b/web/packages/base/locales/is-IS/translation.json index 609dff0ef7..326ed688ec 100644 --- a/web/packages/base/locales/is-IS/translation.json +++ b/web/packages/base/locales/is-IS/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/it-IT/translation.json b/web/packages/base/locales/it-IT/translation.json index e32e814fda..63d1d3fee1 100644 --- a/web/packages/base/locales/it-IT/translation.json +++ b/web/packages/base/locales/it-IT/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Come hai conosciuto Ente? (opzionale)", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "Le password non corrispondono", + "create_albums": "", "CREATE_COLLECTION": "Nuovo album", "ENTER_ALBUM_NAME": "Nome album", "CLOSE_OPTION": "Chiudi (Esc)", diff --git a/web/packages/base/locales/ja-JP/translation.json b/web/packages/base/locales/ja-JP/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/ja-JP/translation.json +++ b/web/packages/base/locales/ja-JP/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/km-KH/translation.json b/web/packages/base/locales/km-KH/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/km-KH/translation.json +++ b/web/packages/base/locales/km-KH/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/ko-KR/translation.json b/web/packages/base/locales/ko-KR/translation.json index 0afc0224fd..0a973ce7ae 100644 --- a/web/packages/base/locales/ko-KR/translation.json +++ b/web/packages/base/locales/ko-KR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "어떻게 Ente에 대해 들으셨나요? (선택사항)", "REFERRAL_INFO": "우리는 앱 설치를 추적하지 않습니다. 우리를 알게 된 곳을 남겨주시면 우리에게 도움이 될꺼에요!", "PASSPHRASE_MATCH_ERROR": "비밀번호가 일치하지 않습니다", + "create_albums": "", "CREATE_COLLECTION": "새 앨범", "ENTER_ALBUM_NAME": "앨범 이름", "CLOSE_OPTION": "닫기 (Esc)", diff --git a/web/packages/base/locales/nl-NL/translation.json b/web/packages/base/locales/nl-NL/translation.json index 1dd8934a3c..d8cba2d986 100644 --- a/web/packages/base/locales/nl-NL/translation.json +++ b/web/packages/base/locales/nl-NL/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Hoe hoorde je over Ente? (optioneel)", "REFERRAL_INFO": "Wij gebruiken geen tracking. Het zou helpen als je ons vertelt waar je ons gevonden hebt!", "PASSPHRASE_MATCH_ERROR": "Wachtwoorden komen niet overeen", + "create_albums": "", "CREATE_COLLECTION": "Nieuw album", "ENTER_ALBUM_NAME": "Albumnaam", "CLOSE_OPTION": "Sluiten (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Thumbnail generatie mislukt", "UNSUPPORTED_FILES": "Niet-ondersteunde bestanden", "SUCCESSFUL_UPLOADS": "Succesvolle uploads", - "SKIPPED_INFO": "Deze zijn overgeslagen omdat er bestanden zijn met overeenkomende namen in hetzelfde album", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "Ente ondersteunt deze bestandsformaten nog niet", "BLOCKED_UPLOADS": "Geblokkeerde uploads", "INPROGRESS_METADATA_EXTRACTION": "In behandeling", diff --git a/web/packages/base/locales/pl-PL/translation.json b/web/packages/base/locales/pl-PL/translation.json index 2bf6775c9a..33a6a9ba1a 100644 --- a/web/packages/base/locales/pl-PL/translation.json +++ b/web/packages/base/locales/pl-PL/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Jak usłyszałeś/aś o Ente? (opcjonalnie)", "REFERRAL_INFO": "Nie śledzimy instalacji aplikacji. Pomogłyby nam, gdybyś powiedział/a nam, gdzie nas znalazłeś/aś!", "PASSPHRASE_MATCH_ERROR": "Hasła nie pasują do siebie", + "create_albums": "", "CREATE_COLLECTION": "Nowy album", "ENTER_ALBUM_NAME": "Nazwa albumu", "CLOSE_OPTION": "Zamknij (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Generowanie miniatur nie powiodło się", "UNSUPPORTED_FILES": "Nieobsługiwane pliki", "SUCCESSFUL_UPLOADS": "Pomyślne przesłania", - "SKIPPED_INFO": "Pominięto te pliki, ponieważ są pliki z pasującymi nazwami w tym samym albumie", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "Ente nie obsługuje jeszcze tych formatów plików", "BLOCKED_UPLOADS": "Zablokowane przesłania", "INPROGRESS_METADATA_EXTRACTION": "W toku", diff --git a/web/packages/base/locales/pt-BR/translation.json b/web/packages/base/locales/pt-BR/translation.json index 7cb8e2e3ca..4b9245dfcd 100644 --- a/web/packages/base/locales/pt-BR/translation.json +++ b/web/packages/base/locales/pt-BR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Como você ouviu sobre o Ente? (opcional)", "REFERRAL_INFO": "Não rastreamos instalações do aplicativo. Seria útil se você nos contasse onde nos encontrou!", "PASSPHRASE_MATCH_ERROR": "As senhas não coincidem", + "create_albums": "", "CREATE_COLLECTION": "Novo álbum", "ENTER_ALBUM_NAME": "Nome do álbum", "CLOSE_OPTION": "Fechar (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Falha ao gerar miniaturas", "UNSUPPORTED_FILES": "Arquivos não suportados", "SUCCESSFUL_UPLOADS": "Envios bem sucedidos", - "SKIPPED_INFO": "Ignorar estes como existem arquivos com nomes correspondentes no mesmo álbum", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "ente ainda não suporta estes formatos de arquivo", "BLOCKED_UPLOADS": "Envios bloqueados", "INPROGRESS_METADATA_EXTRACTION": "Em andamento", diff --git a/web/packages/base/locales/pt-PT/translation.json b/web/packages/base/locales/pt-PT/translation.json index b1d4f2f26e..e5f318a3be 100644 --- a/web/packages/base/locales/pt-PT/translation.json +++ b/web/packages/base/locales/pt-PT/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "Novo álbum", "ENTER_ALBUM_NAME": "Nome do álbum", "CLOSE_OPTION": "Fechar (Esc)", diff --git a/web/packages/base/locales/ru-RU/translation.json b/web/packages/base/locales/ru-RU/translation.json index f15cc4a8f0..e8aa1eb726 100644 --- a/web/packages/base/locales/ru-RU/translation.json +++ b/web/packages/base/locales/ru-RU/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Как вы узнали о Ente? (необязательно)", "REFERRAL_INFO": "Будет полезно, если вы укажете, где нашли нас, так как мы не отслеживаем установки приложения!", "PASSPHRASE_MATCH_ERROR": "Пароли не совпадают", + "create_albums": "", "CREATE_COLLECTION": "Новый альбом", "ENTER_ALBUM_NAME": "Название альбома", "CLOSE_OPTION": "Закрыть (Esc)", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Не удалось создать миниатюру", "UNSUPPORTED_FILES": "Неподдерживаемые файлы", "SUCCESSFUL_UPLOADS": "Успешные загрузки", - "SKIPPED_INFO": "Пропустил их, так как в одном альбоме есть файлы с одинаковыми названиями", + "SKIPPED_INFO": "", "UNSUPPORTED_INFO": "Ente пока не поддерживает эти форматы файлов", "BLOCKED_UPLOADS": "Заблокированные загрузки", "INPROGRESS_METADATA_EXTRACTION": "В процессе", diff --git a/web/packages/base/locales/sv-SE/translation.json b/web/packages/base/locales/sv-SE/translation.json index 42cdf3e79a..59aaf3dc88 100644 --- a/web/packages/base/locales/sv-SE/translation.json +++ b/web/packages/base/locales/sv-SE/translation.json @@ -7,7 +7,7 @@ "HERO_SLIDE_3": "Android, iOS, webb, skrivbord", "LOGIN": "Logga in", "SIGN_UP": "Registrera", - "NEW_USER": "", + "NEW_USER": "Ny hos Ente", "EXISTING_USER": "Befintlig användare", "ENTER_NAME": "Ange namn", "PUBLIC_UPLOADER_NAME_MESSAGE": "Lägg till ett namn så att dina vänner vet vem de ska tacka för dessa fantastiska bilder!", @@ -26,7 +26,7 @@ "SENT": "Skickat!", "password": "Lösenord", "link_password_description": "Ange lösenord för att låsa upp albumet", - "unlock": "", + "unlock": "Lås upp", "SET_PASSPHRASE": "Välj lösenord", "VERIFY_PASSPHRASE": "Logga in", "INCORRECT_PASSPHRASE": "Felaktigt lösenord", @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "Hur hörde du talas om Ente? (valfritt)", "REFERRAL_INFO": "Vi spårar inte appinstallationer, Det skulle hjälpa oss om du berättade var du hittade oss!", "PASSPHRASE_MATCH_ERROR": "Lösenorden matchar inte", + "create_albums": "", "CREATE_COLLECTION": "Nytt album", "ENTER_ALBUM_NAME": "Albumnamn", "CLOSE_OPTION": "Stäng (Esc)", @@ -60,11 +61,11 @@ "0": "Förbereder att ladda upp", "1": "Läser Google metadatafiler", "2": "Metadata för {{uploadCounter.finished, number}} / {{uploadCounter.total, number}} filer extraherat", - "3": "", - "4": "", - "5": "" + "3": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} filer behandlade", + "4": "Avbryter återstående uppladdningar", + "5": "Säkerhetskopiering slutförd" }, - "FILE_NOT_UPLOADED_LIST": "", + "FILE_NOT_UPLOADED_LIST": "Följande filer laddades ej upp", "INITIAL_LOAD_DELAY_WARNING": "", "USER_DOES_NOT_EXIST": "", "NO_ACCOUNT": "", @@ -650,7 +651,7 @@ "redirect_close_instructions": "", "redirect_again": "", "autogenerated_first_album_name": "", - "autogenerated_default_album_name": "", + "autogenerated_default_album_name": "Nytt album", "developer_settings": "Utvecklarinställningar", "server_endpoint": "", "more_information": "", diff --git a/web/packages/base/locales/ta-IN/translation.json b/web/packages/base/locales/ta-IN/translation.json new file mode 100644 index 0000000000..f90267b710 --- /dev/null +++ b/web/packages/base/locales/ta-IN/translation.json @@ -0,0 +1,659 @@ +{ + "HERO_SLIDE_1_TITLE": "", + "HERO_SLIDE_1": "", + "HERO_SLIDE_2_TITLE": "", + "HERO_SLIDE_2": "", + "HERO_SLIDE_3_TITLE": "", + "HERO_SLIDE_3": "", + "LOGIN": "", + "SIGN_UP": "", + "NEW_USER": "", + "EXISTING_USER": "", + "ENTER_NAME": "", + "PUBLIC_UPLOADER_NAME_MESSAGE": "", + "ENTER_EMAIL": "", + "EMAIL_ERROR": "", + "REQUIRED": "", + "EMAIL_SENT": "", + "CHECK_INBOX": "", + "ENTER_OTT": "", + "RESEND_MAIL": "", + "VERIFY": "", + "UNKNOWN_ERROR": "", + "INVALID_CODE": "", + "EXPIRED_CODE": "", + "SENDING": "", + "SENT": "", + "password": "", + "link_password_description": "", + "unlock": "", + "SET_PASSPHRASE": "", + "VERIFY_PASSPHRASE": "", + "INCORRECT_PASSPHRASE": "", + "ENTER_ENC_PASSPHRASE": "", + "PASSPHRASE_DISCLAIMER": "", + "WELCOME_TO_ENTE_HEADING": "", + "WELCOME_TO_ENTE_SUBHEADING": "", + "WHERE_YOUR_BEST_PHOTOS_LIVE": "", + "KEY_GENERATION_IN_PROGRESS_MESSAGE": "", + "PASSPHRASE_HINT": "", + "CONFIRM_PASSPHRASE": "", + "REFERRAL_CODE_HINT": "", + "REFERRAL_INFO": "", + "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", + "CREATE_COLLECTION": "", + "ENTER_ALBUM_NAME": "", + "CLOSE_OPTION": "", + "ENTER_FILE_NAME": "", + "CLOSE": "", + "NO": "", + "NOTHING_HERE": "", + "upload": "", + "import": "", + "ADD_PHOTOS": "", + "ADD_MORE_PHOTOS": "", + "add_photos_count_one": "", + "add_photos_count": "", + "select_photos": "", + "FILE_UPLOAD": "", + "UPLOAD_STAGE_MESSAGE": { + "0": "", + "1": "", + "2": "", + "3": "", + "4": "", + "5": "" + }, + "FILE_NOT_UPLOADED_LIST": "", + "INITIAL_LOAD_DELAY_WARNING": "", + "USER_DOES_NOT_EXIST": "", + "NO_ACCOUNT": "", + "ACCOUNT_EXISTS": "", + "CREATE": "", + "DOWNLOAD": "", + "DOWNLOAD_OPTION": "", + "DOWNLOAD_FAVORITES": "", + "DOWNLOAD_UNCATEGORIZED": "", + "DOWNLOAD_HIDDEN_ITEMS": "", + "COPY_OPTION": "", + "TOGGLE_FULLSCREEN": "", + "ZOOM_IN_OUT": "", + "PREVIOUS": "", + "NEXT": "", + "title_photos": "", + "title_auth": "", + "title_accounts": "", + "UPLOAD_FIRST_PHOTO": "", + "IMPORT_YOUR_FOLDERS": "", + "UPLOAD_DROPZONE_MESSAGE": "", + "WATCH_FOLDER_DROPZONE_MESSAGE": "", + "TRASH_FILES_TITLE": "", + "TRASH_FILE_TITLE": "", + "DELETE_FILES_TITLE": "", + "DELETE_FILES_MESSAGE": "", + "DELETE": "", + "DELETE_OPTION": "", + "FAVORITE_OPTION": "", + "UNFAVORITE_OPTION": "", + "MULTI_FOLDER_UPLOAD": "", + "UPLOAD_STRATEGY_CHOICE": "", + "UPLOAD_STRATEGY_SINGLE_COLLECTION": "", + "OR": "", + "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "", + "SESSION_EXPIRED_MESSAGE": "", + "SESSION_EXPIRED": "", + "PASSWORD_GENERATION_FAILED": "", + "CHANGE_PASSWORD": "", + "password_changed_elsewhere": "", + "password_changed_elsewhere_message": "", + "GO_BACK": "", + "RECOVERY_KEY": "", + "SAVE_LATER": "", + "SAVE": "", + "RECOVERY_KEY_DESCRIPTION": "", + "RECOVER_KEY_GENERATION_FAILED": "", + "KEY_NOT_STORED_DISCLAIMER": "", + "FORGOT_PASSWORD": "", + "RECOVER_ACCOUNT": "", + "RECOVERY_KEY_HINT": "", + "RECOVER": "", + "NO_RECOVERY_KEY": "", + "INCORRECT_RECOVERY_KEY": "", + "SORRY": "", + "NO_RECOVERY_KEY_MESSAGE": "", + "NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "", + "CONTACT_SUPPORT": "", + "REQUEST_FEATURE": "", + "SUPPORT": "", + "CONFIRM": "", + "cancel": "", + "LOGOUT": "", + "delete_account": "", + "delete_account_manually_message": "", + "LOGOUT_MESSAGE": "", + "CHANGE_EMAIL": "", + "OK": "", + "SUCCESS": "", + "ERROR": "", + "MESSAGE": "", + "OFFLINE_MSG": "", + "INSTALL_MOBILE_APP": "", + "DOWNLOAD_APP_MESSAGE": "", + "DOWNLOAD_APP": "", + "EXPORT": "", + "SUBSCRIPTION": "", + "SUBSCRIBE": "", + "MANAGEMENT_PORTAL": "", + "MANAGE_FAMILY_PORTAL": "", + "LEAVE_FAMILY_PLAN": "", + "LEAVE": "", + "LEAVE_FAMILY_CONFIRM": "", + "CHOOSE_PLAN": "", + "MANAGE_PLAN": "", + "CURRENT_USAGE": "", + "TWO_MONTHS_FREE": "", + "POPULAR": "", + "free_plan_option": "", + "free_plan_description": "", + "active": "", + "subscription_info_free": "", + "subscription_info_family": "", + "subscription_info_expired": "", + "subscription_info_renewal_cancelled": "", + "subscription_info_storage_quota_exceeded": "", + "subscription_status_renewal_active": "", + "subscription_status_renewal_cancelled": "", + "add_on_valid_till": "", + "subscription_expired": "", + "storage_quota_exceeded": "", + "SUBSCRIPTION_PURCHASE_SUCCESS": "", + "SUBSCRIPTION_PURCHASE_CANCELLED": "", + "SUBSCRIPTION_PURCHASE_FAILED": "", + "SUBSCRIPTION_UPDATE_FAILED": "", + "UPDATE_PAYMENT_METHOD_MESSAGE": "", + "STRIPE_AUTHENTICATION_FAILED": "", + "UPDATE_PAYMENT_METHOD": "", + "MONTHLY": "", + "YEARLY": "", + "MONTH_SHORT": "", + "YEAR": "", + "update_subscription_title": "", + "UPDATE_SUBSCRIPTION_MESSAGE": "", + "UPDATE_SUBSCRIPTION": "", + "CANCEL_SUBSCRIPTION": "", + "CANCEL_SUBSCRIPTION_MESSAGE": "", + "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "", + "SUBSCRIPTION_CANCEL_FAILED": "", + "SUBSCRIPTION_CANCEL_SUCCESS": "", + "REACTIVATE_SUBSCRIPTION": "", + "REACTIVATE_SUBSCRIPTION_MESSAGE": "", + "SUBSCRIPTION_ACTIVATE_SUCCESS": "", + "SUBSCRIPTION_ACTIVATE_FAILED": "", + "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE": "", + "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "", + "MAIL_TO_MANAGE_SUBSCRIPTION": "", + "RENAME": "", + "RENAME_FILE": "", + "RENAME_COLLECTION": "", + "DELETE_COLLECTION_TITLE": "", + "DELETE_COLLECTION": "", + "DELETE_COLLECTION_MESSAGE": "", + "DELETE_PHOTOS": "", + "KEEP_PHOTOS": "", + "SHARE_COLLECTION": "", + "SHARE_WITH_SELF": "", + "ALREADY_SHARED": "", + "SHARING_BAD_REQUEST_ERROR": "", + "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "", + "DOWNLOAD_COLLECTION": "", + "CREATE_ALBUM_FAILED": "", + "SEARCH": "", + "SEARCH_RESULTS": "", + "NO_RESULTS": "", + "SEARCH_HINT": "", + "SEARCH_TYPE": { + "COLLECTION": "", + "LOCATION": "", + "CITY": "", + "DATE": "", + "FILE_NAME": "", + "THING": "", + "FILE_CAPTION": "", + "FILE_TYPE": "", + "CLIP": "" + }, + "photos_count_zero": "", + "photos_count_one": "", + "photos_count": "", + "TERMS_AND_CONDITIONS": "", + "ADD_TO_COLLECTION": "", + "SELECTED": "", + "PEOPLE": "", + "indexing_scheduled": "", + "indexing_photos": "", + "indexing_fetching": "", + "indexing_people": "", + "indexing_done": "", + "UNIDENTIFIED_FACES": "", + "OBJECTS": "", + "TEXT": "", + "INFO": "", + "INFO_OPTION": "", + "FILE_NAME": "", + "CAPTION_PLACEHOLDER": "", + "LOCATION": "", + "SHOW_ON_MAP": "", + "MAP": "", + "MAP_SETTINGS": "", + "ENABLE_MAPS": "", + "ENABLE_MAP": "", + "DISABLE_MAPS": "", + "ENABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP_DESCRIPTION": "", + "DISABLE_MAP": "", + "DETAILS": "", + "view_exif": "", + "no_exif": "", + "exif": "", + "ISO": "", + "TWO_FACTOR": "", + "TWO_FACTOR_AUTHENTICATION": "", + "TWO_FACTOR_QR_INSTRUCTION": "", + "ENTER_CODE_MANUALLY": "", + "TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "", + "SCAN_QR_CODE": "", + "ENABLE_TWO_FACTOR": "", + "enable": "", + "enabled": "", + "LOST_DEVICE": "", + "INCORRECT_CODE": "", + "TWO_FACTOR_INFO": "", + "DISABLE_TWO_FACTOR_LABEL": "", + "UPDATE_TWO_FACTOR_LABEL": "", + "disable": "", + "reconfigure": "", + "UPDATE_TWO_FACTOR": "", + "UPDATE_TWO_FACTOR_MESSAGE": "", + "UPDATE": "", + "DISABLE_TWO_FACTOR": "", + "DISABLE_TWO_FACTOR_MESSAGE": "", + "TWO_FACTOR_DISABLE_FAILED": "", + "EXPORT_DATA": "", + "select_folder": "", + "select_zips": "", + "faq": "", + "takeout_hint": "", + "DESTINATION": "", + "START": "", + "LAST_EXPORT_TIME": "", + "EXPORT_AGAIN": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE": "", + "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "", + "SEND_OTT": "", + "EMAIl_ALREADY_OWNED": "", + "ETAGS_BLOCKED": "", + "LIVE_PHOTOS_DETECTED": "", + "RETRY_FAILED": "", + "FAILED_UPLOADS": "", + "failed_uploads_hint": "", + "SKIPPED_FILES": "", + "THUMBNAIL_GENERATION_FAILED_UPLOADS": "", + "UNSUPPORTED_FILES": "", + "SUCCESSFUL_UPLOADS": "", + "SKIPPED_INFO": "", + "UNSUPPORTED_INFO": "", + "BLOCKED_UPLOADS": "", + "INPROGRESS_METADATA_EXTRACTION": "", + "INPROGRESS_UPLOADS": "", + "TOO_LARGE_UPLOADS": "", + "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "", + "LARGER_THAN_AVAILABLE_STORAGE_INFO": "", + "TOO_LARGE_INFO": "", + "THUMBNAIL_GENERATION_FAILED_INFO": "", + "UPLOAD_TO_COLLECTION": "", + "UNCATEGORIZED": "", + "ARCHIVE": "", + "FAVORITES": "", + "ARCHIVE_COLLECTION": "", + "ARCHIVE_SECTION_NAME": "", + "ALL_SECTION_NAME": "", + "MOVE_TO_COLLECTION": "", + "UNARCHIVE": "", + "UNARCHIVE_COLLECTION": "", + "HIDE_COLLECTION": "", + "UNHIDE_COLLECTION": "", + "MOVE": "", + "ADD": "", + "REMOVE": "", + "YES_REMOVE": "", + "REMOVE_FROM_COLLECTION": "", + "TRASH": "", + "MOVE_TO_TRASH": "", + "TRASH_FILES_MESSAGE": "", + "TRASH_FILE_MESSAGE": "", + "DELETE_PERMANENTLY": "", + "RESTORE": "", + "RESTORE_TO_COLLECTION": "", + "EMPTY_TRASH": "", + "EMPTY_TRASH_TITLE": "", + "EMPTY_TRASH_MESSAGE": "", + "LEAVE_SHARED_ALBUM": "", + "LEAVE_ALBUM": "", + "LEAVE_SHARED_ALBUM_TITLE": "", + "LEAVE_SHARED_ALBUM_MESSAGE": "", + "NOT_FILE_OWNER": "", + "CONFIRM_SELF_REMOVE_MESSAGE": "", + "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "", + "SORT_BY_CREATION_TIME_ASCENDING": "", + "SORT_BY_UPDATION_TIME_DESCENDING": "", + "SORT_BY_NAME": "", + "FIX_CREATION_TIME": "", + "FIX_CREATION_TIME_IN_PROGRESS": "", + "CREATION_TIME_UPDATED": "", + "UPDATE_CREATION_TIME_NOT_STARTED": "", + "UPDATE_CREATION_TIME_COMPLETED": "", + "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "", + "CAPTION_CHARACTER_LIMIT": "", + "DATE_TIME_ORIGINAL": "", + "DATE_TIME_DIGITIZED": "", + "METADATA_DATE": "", + "CUSTOM_TIME": "", + "REOPEN_PLAN_SELECTOR_MODAL": "", + "OPEN_PLAN_SELECTOR_MODAL_FAILED": "", + "INSTALL": "", + "SHARING_DETAILS": "", + "MODIFY_SHARING": "", + "ADD_COLLABORATORS": "", + "ADD_NEW_EMAIL": "", + "shared_with_people_count_zero": "", + "shared_with_people_count_one": "", + "shared_with_people_count": "", + "participants_count_zero": "", + "participants_count_one": "", + "participants_count": "", + "ADD_VIEWERS": "", + "CHANGE_PERMISSIONS_TO_VIEWER": "", + "CHANGE_PERMISSIONS_TO_COLLABORATOR": "", + "CONVERT_TO_VIEWER": "", + "CONVERT_TO_COLLABORATOR": "", + "CHANGE_PERMISSION": "", + "REMOVE_PARTICIPANT": "", + "CONFIRM_REMOVE": "", + "MANAGE": "", + "ADDED_AS": "", + "COLLABORATOR_RIGHTS": "", + "REMOVE_PARTICIPANT_HEAD": "", + "OWNER": "", + "COLLABORATORS": "", + "ADD_MORE": "", + "VIEWERS": "", + "OR_ADD_EXISTING": "", + "REMOVE_PARTICIPANT_MESSAGE": "", + "NOT_FOUND": "", + "LINK_EXPIRED": "", + "LINK_EXPIRED_MESSAGE": "", + "MANAGE_LINK": "", + "LINK_TOO_MANY_REQUESTS": "", + "FILE_DOWNLOAD": "", + "link_password_lock": "", + "PUBLIC_COLLECT": "", + "LINK_DEVICE_LIMIT": "", + "NO_DEVICE_LIMIT": "", + "LINK_EXPIRY": "", + "NEVER": "", + "DISABLE_FILE_DOWNLOAD": "", + "DISABLE_FILE_DOWNLOAD_MESSAGE": "", + "SHARED_USING": "", + "SHARING_REFERRAL_CODE": "", + "LIVE": "", + "DISABLE_PASSWORD": "", + "DISABLE_PASSWORD_MESSAGE": "", + "PASSWORD_LOCK": "", + "LOCK": "", + "DOWNLOAD_UPLOAD_LOGS": "", + "file": "", + "folder": "", + "google_takeout": "", + "DEDUPLICATE_FILES": "", + "NO_DUPLICATES_FOUND": "", + "FILES": "", + "EACH": "", + "DEDUPLICATE_BASED_ON_SIZE": "", + "STOP_ALL_UPLOADS_MESSAGE": "", + "STOP_UPLOADS_HEADER": "", + "YES_STOP_UPLOADS": "", + "STOP_DOWNLOADS_HEADER": "", + "YES_STOP_DOWNLOADS": "", + "STOP_ALL_DOWNLOADS_MESSAGE": "", + "albums_count_one": "", + "albums_count": "", + "ALL_ALBUMS": "", + "ALBUMS": "", + "ALL_HIDDEN_ALBUMS": "", + "HIDDEN_ALBUMS": "", + "HIDDEN_ITEMS": "", + "ENTER_TWO_FACTOR_OTP": "", + "CREATE_ACCOUNT": "", + "COPIED": "", + "WATCH_FOLDERS": "", + "upgrade_now": "", + "renew_now": "", + "STORAGE": "", + "USED": "", + "YOU": "", + "FAMILY": "", + "FREE": "", + "OF": "", + "WATCHED_FOLDERS": "", + "NO_FOLDERS_ADDED": "", + "FOLDERS_AUTOMATICALLY_MONITORED": "", + "UPLOAD_NEW_FILES_TO_ENTE": "", + "REMOVE_DELETED_FILES_FROM_ENTE": "", + "ADD_FOLDER": "", + "STOP_WATCHING": "", + "STOP_WATCHING_FOLDER": "", + "STOP_WATCHING_DIALOG_MESSAGE": "", + "YES_STOP": "", + "CHANGE_FOLDER": "", + "FAMILY_PLAN": "", + "DOWNLOAD_LOGS": "", + "DOWNLOAD_LOGS_MESSAGE": "", + "WEAK_DEVICE": "", + "drag_and_drop_hint": "", + "AUTHENTICATE": "", + "UPLOADED_TO_SINGLE_COLLECTION": "", + "UPLOADED_TO_SEPARATE_COLLECTIONS": "", + "NEVERMIND": "", + "UPDATE_AVAILABLE": "", + "UPDATE_INSTALLABLE_MESSAGE": "", + "INSTALL_NOW": "", + "INSTALL_ON_NEXT_LAUNCH": "", + "UPDATE_AVAILABLE_MESSAGE": "", + "DOWNLOAD_AND_INSTALL": "", + "IGNORE_THIS_VERSION": "", + "TODAY": "", + "YESTERDAY": "", + "NAME_PLACEHOLDER": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "", + "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "", + "CHOSE_THEME": "", + "more_details": "", + "ml_search": "", + "ml_search_description": "", + "ml_search_footnote": "", + "indexing": "", + "processed": "", + "indexing_status_running": "", + "indexing_status_fetching": "", + "indexing_status_scheduled": "", + "indexing_status_done": "", + "ml_search_disable": "", + "ml_search_disable_confirm": "", + "ml_consent": "", + "ml_consent_title": "", + "ml_consent_description": "", + "ml_consent_confirmation": "", + "labs": "", + "YOURS": "", + "passphrase_strength_weak": "", + "passphrase_strength_moderate": "", + "passphrase_strength_strong": "", + "preferences": "", + "language": "", + "advanced": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST": "", + "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "", + "SUBSCRIPTION_VERIFICATION_ERROR": "", + "storage_unit": { + "b": "", + "kb": "", + "mb": "", + "gb": "", + "tb": "" + }, + "AFTER_TIME": { + "HOUR": "", + "DAY": "", + "WEEK": "", + "MONTH": "", + "YEAR": "" + }, + "COPY_LINK": "", + "DONE": "", + "LINK_SHARE_TITLE": "", + "REMOVE_LINK": "", + "CREATE_PUBLIC_SHARING": "", + "PUBLIC_LINK_CREATED": "", + "PUBLIC_LINK_ENABLED": "", + "COLLECT_PHOTOS": "", + "PUBLIC_COLLECT_SUBTEXT": "", + "STOP_EXPORT": "", + "EXPORT_PROGRESS": "", + "MIGRATING_EXPORT": "", + "RENAMING_COLLECTION_FOLDERS": "", + "TRASHING_DELETED_FILES": "", + "TRASHING_DELETED_COLLECTIONS": "", + "CONTINUOUS_EXPORT": "", + "PENDING_ITEMS": "", + "EXPORT_STARTING": "", + "delete_account_reason_label": "", + "delete_account_reason_placeholder": "", + "delete_reason": { + "missing_feature": "", + "behaviour": "", + "found_another_service": "", + "not_listed": "" + }, + "delete_account_feedback_label": "", + "delete_account_feedback_placeholder": "", + "delete_account_confirm_checkbox_label": "", + "delete_account_confirm": "", + "delete_account_confirm_message": "", + "feedback_required": "", + "feedback_required_found_another_service": "", + "RECOVER_TWO_FACTOR": "", + "at": "", + "AUTH_NEXT": "", + "AUTH_DOWNLOAD_MOBILE_APP": "", + "HIDDEN": "", + "HIDE": "", + "UNHIDE": "", + "UNHIDE_TO_COLLECTION": "", + "SORT_BY": "", + "NEWEST_FIRST": "", + "OLDEST_FIRST": "", + "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "", + "SELECT_COLLECTION": "", + "PIN_ALBUM": "", + "UNPIN_ALBUM": "", + "DOWNLOAD_COMPLETE": "", + "DOWNLOADING_COLLECTION": "", + "DOWNLOAD_FAILED": "", + "DOWNLOAD_PROGRESS": "", + "CHRISTMAS": "", + "CHRISTMAS_EVE": "", + "NEW_YEAR": "", + "NEW_YEAR_EVE": "", + "IMAGE": "", + "VIDEO": "", + "LIVE_PHOTO": "", + "editor": { + "crop": "" + }, + "CONVERT": "", + "CONFIRM_EDITOR_CLOSE_MESSAGE": "", + "CONFIRM_EDITOR_CLOSE_DESCRIPTION": "", + "BRIGHTNESS": "", + "CONTRAST": "", + "SATURATION": "", + "BLUR": "", + "INVERT_COLORS": "", + "ASPECT_RATIO": "", + "SQUARE": "", + "ROTATE_LEFT": "", + "ROTATE_RIGHT": "", + "FLIP_VERTICALLY": "", + "FLIP_HORIZONTALLY": "", + "DOWNLOAD_EDITED": "", + "SAVE_A_COPY_TO_ENTE": "", + "RESTORE_ORIGINAL": "", + "TRANSFORM": "", + "COLORS": "", + "FLIP": "", + "ROTATION": "", + "RESET": "", + "PHOTO_EDITOR": "", + "FASTER_UPLOAD": "", + "FASTER_UPLOAD_DESCRIPTION": "", + "CAST_ALBUM_TO_TV": "", + "ENTER_CAST_PIN_CODE": "", + "PAIR_DEVICE_TO_TV": "", + "TV_NOT_FOUND": "", + "AUTO_CAST_PAIR": "", + "AUTO_CAST_PAIR_DESC": "", + "PAIR_WITH_PIN": "", + "CHOOSE_DEVICE_FROM_BROWSER": "", + "PAIR_WITH_PIN_DESC": "", + "VISIT_CAST_ENTE_IO": "", + "CAST_AUTO_PAIR_FAILED": "", + "FREEHAND": "", + "APPLY_CROP": "", + "PHOTO_EDIT_REQUIRED_TO_SAVE": "", + "passkeys": "", + "passkey_fetch_failed": "", + "manage_passkey": "", + "delete_passkey": "", + "delete_passkey_confirmation": "", + "rename_passkey": "", + "add_passkey": "", + "enter_passkey_name": "", + "passkeys_description": "", + "CREATED_AT": "", + "passkey_add_failed": "", + "passkey_login_failed": "", + "passkey_login_invalid_url": "", + "passkey_login_already_claimed_session": "", + "passkey_login_generic_error": "", + "passkey_login_credential_hint": "", + "passkeys_not_supported": "", + "try_again": "", + "check_status": "", + "passkey_login_instructions": "", + "passkey_login": "", + "passkey": "", + "passkey_verify_description": "", + "waiting_for_verification": "", + "verification_still_pending": "", + "passkey_verified": "", + "redirecting_back_to_app": "", + "redirect_close_instructions": "", + "redirect_again": "", + "autogenerated_first_album_name": "", + "autogenerated_default_album_name": "", + "developer_settings": "", + "server_endpoint": "", + "more_information": "", + "save": "" +} diff --git a/web/packages/base/locales/te-IN/translation.json b/web/packages/base/locales/te-IN/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/te-IN/translation.json +++ b/web/packages/base/locales/te-IN/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/th-TH/translation.json b/web/packages/base/locales/th-TH/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/th-TH/translation.json +++ b/web/packages/base/locales/th-TH/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/ti-ER/translation.json b/web/packages/base/locales/ti-ER/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/ti-ER/translation.json +++ b/web/packages/base/locales/ti-ER/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/tr-TR/translation.json b/web/packages/base/locales/tr-TR/translation.json index 2f7b02d9ee..f90267b710 100644 --- a/web/packages/base/locales/tr-TR/translation.json +++ b/web/packages/base/locales/tr-TR/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "", "REFERRAL_INFO": "", "PASSPHRASE_MATCH_ERROR": "", + "create_albums": "", "CREATE_COLLECTION": "", "ENTER_ALBUM_NAME": "", "CLOSE_OPTION": "", diff --git a/web/packages/base/locales/zh-CN/translation.json b/web/packages/base/locales/zh-CN/translation.json index 6064de6295..8e5cb95bea 100644 --- a/web/packages/base/locales/zh-CN/translation.json +++ b/web/packages/base/locales/zh-CN/translation.json @@ -41,6 +41,7 @@ "REFERRAL_CODE_HINT": "您是如何知道Ente的? (可选的)", "REFERRAL_INFO": "我们不跟踪应用程序安装情况,如果您告诉我们您是在哪里找到我们的,将会有所帮助!", "PASSPHRASE_MATCH_ERROR": "两次输入的密码不一致", + "create_albums": "", "CREATE_COLLECTION": "新建相册", "ENTER_ALBUM_NAME": "相册名称", "CLOSE_OPTION": "关闭 (或按Esc键)", @@ -98,7 +99,7 @@ "MULTI_FOLDER_UPLOAD": "检测到多个文件夹", "UPLOAD_STRATEGY_CHOICE": "你想要上传他们到", "UPLOAD_STRATEGY_SINGLE_COLLECTION": "单个相册", - "OR": "或者", + "OR": "还是", "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "独立相册", "SESSION_EXPIRED_MESSAGE": "您的会话已过期,请重新登录以继续", "SESSION_EXPIRED": "会话已过期", @@ -301,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "缩略图生成失败", "UNSUPPORTED_FILES": "不支持的文件", "SUCCESSFUL_UPLOADS": "上传成功", - "SKIPPED_INFO": "跳过这些,因为在同一相册中有具有匹配名称的文件", + "SKIPPED_INFO": "跳过这些文件,因为同一相册中有名称和内容相匹配的文件", "UNSUPPORTED_INFO": "Ente 尚不支持这些文件格式", "BLOCKED_UPLOADS": "已阻止上传", "INPROGRESS_METADATA_EXTRACTION": "进行中", From 823196402395292aba798a172b255bfbb0000485 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 17:16:02 +0530 Subject: [PATCH 60/84] [desktop] Fix flakiness in reading zip files I'm not sure what was the issue in the existing code, but I happened to chance on a setup that reproduced the flakiness that some customers have reported (that reading the zips sometimes fails). There wasn't anything specific in the setup - I was reading a 50 MB zip file, a file which I'd read multiple times before, except this time it seemed to invariably result in failures during read. Replacing the node stream to web stream conversion with this new approach fixes the flakiness, at least in the reproducible scenario that I was encountering. --- desktop/src/main/stream.ts | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index d32eecc627..49e20cdff5 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -4,8 +4,6 @@ import { net, protocol } from "electron/main"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; -import { Readable } from "node:stream"; -import { ReadableStream } from "node:stream/web"; import { pathToFileURL } from "node:url"; import log from "./log"; import { ffmpegConvertToMP4 } from "./services/ffmpeg"; @@ -17,6 +15,7 @@ import { deleteTempFileIgnoringErrors, makeTempFilePath, } from "./utils/temp"; +const { Readable } = require("node:stream"); /** * Register a protocol handler that we use for streaming large files between the @@ -120,20 +119,21 @@ const handleReadZip = async (zipPath: string, entryName: string) => { return new Response("", { status: 404 }); } - // This returns an "old style" NodeJS.ReadableStream. - const stream = await zip.stream(entry); - // Convert it into a new style NodeJS.Readable. - const nodeReadable = new Readable({ emitClose: true }).wrap(stream); - // Then convert it into a Web stream. - const webReadableStreamAny = Readable.toWeb(nodeReadable); - // However, we get a ReadableStream now. This doesn't go into the - // `BodyInit` expected by the Response constructor, which wants a - // ReadableStream. Force a cast. - const webReadableStream = - webReadableStreamAny as ReadableStream; + const { writable, readable } = new TransformStream(); + const writer = writable.getWriter(); - // Let go of the zip handle when the underlying stream closes. - nodeReadable.on("close", () => markClosableZip(zipPath)); + // zip.stream returns an "old style" NodeJS.ReadableStream. We then write it + // to the writable end of the web stream pipe, the readable end of which is + // relayed back to the renderer as the response. + const stream = await zip.stream(entry); + + stream.on("data", (chunk: Buffer) => { + void writer.write(chunk); + }); + + stream.on("end", () => { + void writer.close(); + }); // While it is documented that entry.time is the modification time, // the units are not mentioned. By seeing the source code, we can @@ -142,8 +142,7 @@ const handleReadZip = async (zipPath: string, entryName: string) => { // https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js const modifiedMs = entry.time; - // @ts-expect-error [Note: Node and web stream type mismatch] - return new Response(webReadableStream, { + return new Response(readable, { headers: { // We don't know the exact type, but it doesn't really matter, just // set it to a generic binary content-type so that the browser From 138dcf3d2a59e1e081518627700015ac9114a121 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 17:58:35 +0530 Subject: [PATCH 61/84] Simplify --- desktop/src/main/stream.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index 49e20cdff5..118587603b 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -4,6 +4,7 @@ import { net, protocol } from "electron/main"; import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; +import { Writable } from "node:stream"; import { pathToFileURL } from "node:url"; import log from "./log"; import { ffmpegConvertToMP4 } from "./services/ffmpeg"; @@ -15,7 +16,6 @@ import { deleteTempFileIgnoringErrors, makeTempFilePath, } from "./utils/temp"; -const { Readable } = require("node:stream"); /** * Register a protocol handler that we use for streaming large files between the @@ -119,21 +119,15 @@ const handleReadZip = async (zipPath: string, entryName: string) => { return new Response("", { status: 404 }); } - const { writable, readable } = new TransformStream(); - const writer = writable.getWriter(); - // zip.stream returns an "old style" NodeJS.ReadableStream. We then write it // to the writable end of the web stream pipe, the readable end of which is // relayed back to the renderer as the response. + const { writable, readable } = new TransformStream(); const stream = await zip.stream(entry); - stream.on("data", (chunk: Buffer) => { - void writer.write(chunk); - }); + stream.pipe(Writable.fromWeb(writable)); + - stream.on("end", () => { - void writer.close(); - }); // While it is documented that entry.time is the modification time, // the units are not mentioned. By seeing the source code, we can From 027e3425bbbadf2878b19513f9d7c29d9c86f8d3 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 18:17:38 +0530 Subject: [PATCH 62/84] Gracefully handle aborts --- desktop/src/main/stream.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index 118587603b..41c71285a9 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -125,9 +125,17 @@ const handleReadZip = async (zipPath: string, entryName: string) => { const { writable, readable } = new TransformStream(); const stream = await zip.stream(entry); - stream.pipe(Writable.fromWeb(writable)); - + const nodeWritable = Writable.fromWeb(writable); + stream.pipe(nodeWritable); + nodeWritable.on("error", (e: unknown) => { + // If the renderer process closes the network connection (say when it + // only needs the content-length and doesn't care about the body), we + // get an AbortError. Handle them here otherwise they litter the logs + // with unhandled exceptions. + if (e instanceof Error && e.name == "AbortError") return; + log.error("Error event for the writable end of zip stream", e); + }); // While it is documented that entry.time is the modification time, // the units are not mentioned. By seeing the source code, we can From 171a8670a469ab215b42dfeb902a3449226e37a2 Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Sat, 31 Aug 2024 18:19:38 +0530 Subject: [PATCH 63/84] Balance ref counts --- desktop/src/main/services/zip.ts | 4 ++-- desktop/src/main/stream.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/desktop/src/main/services/zip.ts b/desktop/src/main/services/zip.ts index 5a7f4242f0..17a7205bce 100644 --- a/desktop/src/main/services/zip.ts +++ b/desktop/src/main/services/zip.ts @@ -65,9 +65,9 @@ export const markClosableZip = (zipPath: string) => { */ export const clearOpenZipCache = () => { if (_refCount.size > 0) { - const keys = JSON.stringify([..._refCount.keys()]); + const kvs = JSON.stringify([..._refCount.entries()]); throw new Error( - `Attempting to clear zip file cache when some items are still in use: ${keys}`, + `Attempting to clear zip file cache when some items are still in use: ${kvs}`, ); } _cache.clear(); diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index 41c71285a9..261ab32a21 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -137,6 +137,10 @@ const handleReadZip = async (zipPath: string, entryName: string) => { log.error("Error event for the writable end of zip stream", e); }); + nodeWritable.on("close", () => { + markClosableZip(zipPath); + }); + // While it is documented that entry.time is the modification time, // the units are not mentioned. By seeing the source code, we can // verify that it is indeed epoch milliseconds. See `parseZipTime` From 33c843e5d8f1192a6133b58e1572e1dca82cde45 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 30 Aug 2024 16:57:45 +0530 Subject: [PATCH 64/84] [auth][perf] Reduce redundant painting --- auth/lib/ui/code_timer_progress.dart | 67 ++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/auth/lib/ui/code_timer_progress.dart b/auth/lib/ui/code_timer_progress.dart index a215f0ca02..98538788ed 100644 --- a/auth/lib/ui/code_timer_progress.dart +++ b/auth/lib/ui/code_timer_progress.dart @@ -1,48 +1,45 @@ import 'package:ente_auth/theme/ente_theme.dart'; -import 'package:ente_auth/ui/linear_progress_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; class CodeTimerProgress extends StatefulWidget { final int period; - CodeTimerProgress({ + const CodeTimerProgress({ super.key, required this.period, }); @override - State createState() => _CodeTimerProgressState(); + State createState() => _CodeTimerProgressState(); } class _CodeTimerProgressState extends State with SingleTickerProviderStateMixin { late final Ticker _ticker; - double _progress = 0.0; + late final ValueNotifier _progress; late final int _microSecondsInPeriod; @override void initState() { super.initState(); _microSecondsInPeriod = widget.period * 1000000; - _ticker = createTicker((elapsed) { - _updateTimeRemaining(); - }); + _progress = ValueNotifier(0.0); + _ticker = createTicker(_updateTimeRemaining); _ticker.start(); - _updateTimeRemaining(); + _updateTimeRemaining(Duration.zero); } - void _updateTimeRemaining() { - int timeRemaining = (_microSecondsInPeriod) - + void _updateTimeRemaining(Duration elapsed) { + int timeRemaining = _microSecondsInPeriod - (DateTime.now().microsecondsSinceEpoch % _microSecondsInPeriod); - setState(() { - _progress = (timeRemaining / _microSecondsInPeriod); - }); + _progress.value = timeRemaining / _microSecondsInPeriod; } @override void dispose() { _ticker.dispose(); + _progress.dispose(); super.dispose(); } @@ -50,12 +47,46 @@ class _CodeTimerProgressState extends State Widget build(BuildContext context) { return SizedBox( height: 3, - child: LinearProgressWidget( - color: _progress > 0.4 - ? getEnteColorScheme(context).primary700 - : Colors.orange, - fractionOfStorage: _progress, + child: ValueListenableBuilder( + valueListenable: _progress, + builder: (context, progress, _) { + return CustomPaint( + painter: _ProgressPainter( + progress: progress, + color: progress > 0.4 + ? getEnteColorScheme(context).primary700 + : Colors.orange, + ), + size: Size.infinite, + ); + }, ), ); } } + +class _ProgressPainter extends CustomPainter { + final double progress; + final Color color; + + _ProgressPainter({required this.progress, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final rect = RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width * progress, size.height), + const Radius.circular(2), + ); + + canvas.drawRRect(rect, paint); + } + + @override + bool shouldRepaint(_ProgressPainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.color != color; + } +} From 815dd6b4b657bacf6db456183457456a8df6f88b Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 30 Aug 2024 17:24:58 +0530 Subject: [PATCH 65/84] [auth][perf] Cache timer progress widget --- auth/lib/ui/code_timer_progress.dart | 11 +++++++++++ auth/lib/ui/code_widget.dart | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/auth/lib/ui/code_timer_progress.dart b/auth/lib/ui/code_timer_progress.dart index 98538788ed..a825a6ca43 100644 --- a/auth/lib/ui/code_timer_progress.dart +++ b/auth/lib/ui/code_timer_progress.dart @@ -2,6 +2,17 @@ import 'package:ente_auth/theme/ente_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +class CodeTimerProgressCache { + static final Map _cache = {}; + + static CodeTimerProgress getCachedWidget(int period) { + if (!_cache.containsKey(period)) { + _cache[period] = CodeTimerProgress(period: period); + } + return _cache[period]!; + } +} + class CodeTimerProgress extends StatefulWidget { final int period; diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 4cd263be5a..cb073e5dea 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -111,8 +111,8 @@ class _CodeWidgetState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (widget.code.type.isTOTPCompatible) - CodeTimerProgress( - period: widget.code.period, + CodeTimerProgressCache.getCachedWidget( + widget.code.period, ), const SizedBox(height: 28), Row( From 7354f69dc3a7a6d73c95701438c181e7d37d8f0d Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 30 Aug 2024 17:25:22 +0530 Subject: [PATCH 66/84] [auth][perf] Avoid redundant totp computation --- auth/lib/ui/code_widget.dart | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index cb073e5dea..4c3e748e3a 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -49,6 +49,7 @@ class _CodeWidgetState extends State { late bool _shouldShowLargeIcon; late bool _hideCode; bool isMaskingEnabled = false; + int _codeTimeStep = -1; @override void initState() { @@ -57,11 +58,22 @@ class _CodeWidgetState extends State { _hideCode = isMaskingEnabled; _everySecondTimer = Timer.periodic(const Duration(milliseconds: 500), (Timer t) { - String newCode = _getCurrentOTP(); - if (newCode != _currentCode.value) { - _currentCode.value = newCode; - if (widget.code.type.isTOTPCompatible) { - _nextCode.value = _getNextTotp(); + int newStep = 0; + if (widget.code.type != Type.hotp) { + newStep = (((DateTime.now().millisecondsSinceEpoch ~/ 1000).round()) ~/ + widget.code.period) + .floor(); + } else { + newStep = widget.code.counter; + } + if (_codeTimeStep != newStep) { + _codeTimeStep = newStep; + String newCode = _getCurrentOTP(); + if (newCode != _currentCode.value) { + _currentCode.value = newCode; + if (widget.code.type.isTOTPCompatible) { + _nextCode.value = _getNextTotp(); + } } } }); From 746aa4cb9617e97881053a0eee5610e0a68fc5d6 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 2 Sep 2024 00:32:36 +0000 Subject: [PATCH 67/84] New Crowdin translations by GitHub Action --- .../base/locales/pl-PL/translation.json | 4 +-- .../base/locales/pt-BR/translation.json | 4 +-- .../base/locales/ru-RU/translation.json | 32 +++++++++---------- .../base/locales/sv-SE/translation.json | 2 +- .../base/locales/zh-CN/translation.json | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/web/packages/base/locales/pl-PL/translation.json b/web/packages/base/locales/pl-PL/translation.json index 33a6a9ba1a..61e464e0af 100644 --- a/web/packages/base/locales/pl-PL/translation.json +++ b/web/packages/base/locales/pl-PL/translation.json @@ -41,7 +41,7 @@ "REFERRAL_CODE_HINT": "Jak usłyszałeś/aś o Ente? (opcjonalnie)", "REFERRAL_INFO": "Nie śledzimy instalacji aplikacji. Pomogłyby nam, gdybyś powiedział/a nam, gdzie nas znalazłeś/aś!", "PASSPHRASE_MATCH_ERROR": "Hasła nie pasują do siebie", - "create_albums": "", + "create_albums": "Utwórz albumy", "CREATE_COLLECTION": "Nowy album", "ENTER_ALBUM_NAME": "Nazwa albumu", "CLOSE_OPTION": "Zamknij (Esc)", @@ -302,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Generowanie miniatur nie powiodło się", "UNSUPPORTED_FILES": "Nieobsługiwane pliki", "SUCCESSFUL_UPLOADS": "Pomyślne przesłania", - "SKIPPED_INFO": "", + "SKIPPED_INFO": "Pominięto te pliki, ponieważ są pliki z pasującymi nazwami i zawartością w tym samym albumie", "UNSUPPORTED_INFO": "Ente nie obsługuje jeszcze tych formatów plików", "BLOCKED_UPLOADS": "Zablokowane przesłania", "INPROGRESS_METADATA_EXTRACTION": "W toku", diff --git a/web/packages/base/locales/pt-BR/translation.json b/web/packages/base/locales/pt-BR/translation.json index 4b9245dfcd..6e640539b1 100644 --- a/web/packages/base/locales/pt-BR/translation.json +++ b/web/packages/base/locales/pt-BR/translation.json @@ -41,7 +41,7 @@ "REFERRAL_CODE_HINT": "Como você ouviu sobre o Ente? (opcional)", "REFERRAL_INFO": "Não rastreamos instalações do aplicativo. Seria útil se você nos contasse onde nos encontrou!", "PASSPHRASE_MATCH_ERROR": "As senhas não coincidem", - "create_albums": "", + "create_albums": "Criar álbuns", "CREATE_COLLECTION": "Novo álbum", "ENTER_ALBUM_NAME": "Nome do álbum", "CLOSE_OPTION": "Fechar (Esc)", @@ -302,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Falha ao gerar miniaturas", "UNSUPPORTED_FILES": "Arquivos não suportados", "SUCCESSFUL_UPLOADS": "Envios bem sucedidos", - "SKIPPED_INFO": "", + "SKIPPED_INFO": "Estes foram pulados, pois há arquivos com nome e conteúdo correspondentes no mesmo álbum", "UNSUPPORTED_INFO": "ente ainda não suporta estes formatos de arquivo", "BLOCKED_UPLOADS": "Envios bloqueados", "INPROGRESS_METADATA_EXTRACTION": "Em andamento", diff --git a/web/packages/base/locales/ru-RU/translation.json b/web/packages/base/locales/ru-RU/translation.json index e8aa1eb726..26a8fb055c 100644 --- a/web/packages/base/locales/ru-RU/translation.json +++ b/web/packages/base/locales/ru-RU/translation.json @@ -1,11 +1,11 @@ { - "HERO_SLIDE_1_TITLE": "
Личные резервные копии
для твоих воспоминаний
", + "HERO_SLIDE_1_TITLE": "
Приватные резервные копии
для ваших воспоминаний
", "HERO_SLIDE_1": "Сквозное шифрование по умолчанию", "HERO_SLIDE_2_TITLE": "
Надежно хранится
в убежище от радиоактивных осадков
", "HERO_SLIDE_2": "Созданный для того, чтобы пережить", "HERO_SLIDE_3_TITLE": "
Доступно
везде
", "HERO_SLIDE_3": "Android, iOS, Веб, ПК", - "LOGIN": "Авторизоваться", + "LOGIN": "Войти", "SIGN_UP": "Регистрация", "NEW_USER": "Новенький в Ente", "EXISTING_USER": "Существующий пользователь", @@ -19,7 +19,7 @@ "ENTER_OTT": "Проверочный код", "RESEND_MAIL": "Отправить код еще раз", "VERIFY": "Подтвердить", - "UNKNOWN_ERROR": "Что-то пошло не так, Попробуйте еще раз", + "UNKNOWN_ERROR": "Что-то пошло не так, попробуйте еще раз", "INVALID_CODE": "Неверный код подтверждения", "EXPIRED_CODE": "Срок действия вашего проверочного кода истек", "SENDING": "Отправка...", @@ -28,7 +28,7 @@ "link_password_description": "Введите пароль, чтобы разблокировать альбом", "unlock": "Разблокировать", "SET_PASSPHRASE": "Установить пароль", - "VERIFY_PASSPHRASE": "Войти", + "VERIFY_PASSPHRASE": "Зарегистрироваться", "INCORRECT_PASSPHRASE": "Неверный пароль", "ENTER_ENC_PASSPHRASE": "Пожалуйста, введите пароль, который мы можем использовать для шифрования ваших данных", "PASSPHRASE_DISCLAIMER": "Мы не храним ваш пароль, поэтому, если вы его забудете,\nмы ничем не сможем вам помочь\nвосстановите ваши данные без ключа восстановления.", @@ -41,7 +41,7 @@ "REFERRAL_CODE_HINT": "Как вы узнали о Ente? (необязательно)", "REFERRAL_INFO": "Будет полезно, если вы укажете, где нашли нас, так как мы не отслеживаем установки приложения!", "PASSPHRASE_MATCH_ERROR": "Пароли не совпадают", - "create_albums": "", + "create_albums": "Создать альбомы", "CREATE_COLLECTION": "Новый альбом", "ENTER_ALBUM_NAME": "Название альбома", "CLOSE_OPTION": "Закрыть (Esc)", @@ -176,7 +176,7 @@ "UPDATE_PAYMENT_METHOD": "Обновить платёжную информацию", "MONTHLY": "Ежемесячно", "YEARLY": "Ежегодно", - "MONTH_SHORT": "мо", + "MONTH_SHORT": "мес", "YEAR": "год", "update_subscription_title": "Подтвердить изменение плана", "UPDATE_SUBSCRIPTION_MESSAGE": "Хотите сменить текущий план?", @@ -302,7 +302,7 @@ "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Не удалось создать миниатюру", "UNSUPPORTED_FILES": "Неподдерживаемые файлы", "SUCCESSFUL_UPLOADS": "Успешные загрузки", - "SKIPPED_INFO": "", + "SKIPPED_INFO": "Пропущено, так как в альбоме есть файлы с совпадающими именем и содержимым", "UNSUPPORTED_INFO": "Ente пока не поддерживает эти форматы файлов", "BLOCKED_UPLOADS": "Заблокированные загрузки", "INPROGRESS_METADATA_EXTRACTION": "В процессе", @@ -336,8 +336,8 @@ "DELETE_PERMANENTLY": "Удалить навсегда", "RESTORE": "Восстанавливать", "RESTORE_TO_COLLECTION": "Восстановить в альбом", - "EMPTY_TRASH": "Пустой мусор", - "EMPTY_TRASH_TITLE": "Пустой мусор?", + "EMPTY_TRASH": "Очистить корзину", + "EMPTY_TRASH_TITLE": "Очистить корзину?", "EMPTY_TRASH_MESSAGE": "Эти файлы будут безвозвратно удалены из вашей учетной записи Ente.", "LEAVE_SHARED_ALBUM": "Да, уходи", "LEAVE_ALBUM": "Оставить альбом", @@ -486,15 +486,15 @@ "indexing": "Индексирование", "processed": "Обработано", "indexing_status_running": "Выполняется", - "indexing_status_fetching": "", + "indexing_status_fetching": "Получение", "indexing_status_scheduled": "Запланировано", "indexing_status_done": "Готово", "ml_search_disable": "Отключить машинное обучение", "ml_search_disable_confirm": "Вы хотите отключить машинное обучение на всех ваших устройствах?", - "ml_consent": "", - "ml_consent_title": "", - "ml_consent_description": "", - "ml_consent_confirmation": "", + "ml_consent": "Включить машинное обучение", + "ml_consent_title": "Включить машинное обучение?", + "ml_consent_description": "

Если вы включите машинное обучение, Ente будет извлекать информацию из файлов (например, геометрию лица), включая те, которыми с вами поделились.

Это будет происходить на вашем устройстве, и любая сгенерированная биометрическая информация будет зашифрована с использованием сквозного (End-to-End) шифрования

Пожалуйста нажмите здесь для получения дополнительной информации об этой функции в нашей политике конфиденциальности

", + "ml_consent_confirmation": "Я понимаю, и хочу разрешить машинное обучение", "labs": "Лаборатории", "YOURS": "твой", "passphrase_strength_weak": "Надежность пароля: слабая", @@ -533,8 +533,8 @@ "EXPORT_PROGRESS": "{{progress.success, number}} / {{progress.total, number}} синхронизированные элементы", "MIGRATING_EXPORT": "Подготовка...", "RENAMING_COLLECTION_FOLDERS": "Переименование папок альбомов...", - "TRASHING_DELETED_FILES": "Удаление удаленных файлов...", - "TRASHING_DELETED_COLLECTIONS": "Удаление удаленных альбомов...", + "TRASHING_DELETED_FILES": "Очистка удаленных файлов...", + "TRASHING_DELETED_COLLECTIONS": "Очистка удаленных альбомов...", "CONTINUOUS_EXPORT": "Непрерывная синхронизация", "PENDING_ITEMS": "Отложенные пункты", "EXPORT_STARTING": "Запуск экспорта...", diff --git a/web/packages/base/locales/sv-SE/translation.json b/web/packages/base/locales/sv-SE/translation.json index 59aaf3dc88..48bea6d012 100644 --- a/web/packages/base/locales/sv-SE/translation.json +++ b/web/packages/base/locales/sv-SE/translation.json @@ -41,7 +41,7 @@ "REFERRAL_CODE_HINT": "Hur hörde du talas om Ente? (valfritt)", "REFERRAL_INFO": "Vi spårar inte appinstallationer, Det skulle hjälpa oss om du berättade var du hittade oss!", "PASSPHRASE_MATCH_ERROR": "Lösenorden matchar inte", - "create_albums": "", + "create_albums": "Skapa album", "CREATE_COLLECTION": "Nytt album", "ENTER_ALBUM_NAME": "Albumnamn", "CLOSE_OPTION": "Stäng (Esc)", diff --git a/web/packages/base/locales/zh-CN/translation.json b/web/packages/base/locales/zh-CN/translation.json index 8e5cb95bea..66fc6fa6f9 100644 --- a/web/packages/base/locales/zh-CN/translation.json +++ b/web/packages/base/locales/zh-CN/translation.json @@ -41,7 +41,7 @@ "REFERRAL_CODE_HINT": "您是如何知道Ente的? (可选的)", "REFERRAL_INFO": "我们不跟踪应用程序安装情况,如果您告诉我们您是在哪里找到我们的,将会有所帮助!", "PASSPHRASE_MATCH_ERROR": "两次输入的密码不一致", - "create_albums": "", + "create_albums": "创建相册", "CREATE_COLLECTION": "新建相册", "ENTER_ALBUM_NAME": "相册名称", "CLOSE_OPTION": "关闭 (或按Esc键)", From 71644e255d3e28714d3478e11f22c87227f24fe9 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 2 Sep 2024 01:04:42 +0000 Subject: [PATCH 68/84] New Crowdin translations by GitHub Action --- mobile/lib/l10n/intl_da.arb | 1 - mobile/lib/l10n/intl_de.arb | 2 +- mobile/lib/l10n/intl_es.arb | 1 - mobile/lib/l10n/intl_fa.arb | 1 - mobile/lib/l10n/intl_fr.arb | 34 ++++++++++- mobile/lib/l10n/intl_he.arb | 1 - mobile/lib/l10n/intl_hi.arb | 1 - mobile/lib/l10n/intl_id.arb | 58 +++++++++++++++++- mobile/lib/l10n/intl_it.arb | 117 +++++++++++++++++++++++++++++++++++- mobile/lib/l10n/intl_nl.arb | 8 ++- mobile/lib/l10n/intl_no.arb | 1 - mobile/lib/l10n/intl_pl.arb | 4 +- mobile/lib/l10n/intl_pt.arb | 4 +- mobile/lib/l10n/intl_ru.arb | 24 +++++--- mobile/lib/l10n/intl_sv.arb | 2 +- mobile/lib/l10n/intl_ta.arb | 19 ++++++ mobile/lib/l10n/intl_th.arb | 1 - mobile/lib/l10n/intl_tr.arb | 1 - mobile/lib/l10n/intl_zh.arb | 4 +- 19 files changed, 257 insertions(+), 27 deletions(-) create mode 100644 mobile/lib/l10n/intl_ta.arb diff --git a/mobile/lib/l10n/intl_da.arb b/mobile/lib/l10n/intl_da.arb index 49c26657f9..e4b2cc656b 100644 --- a/mobile/lib/l10n/intl_da.arb +++ b/mobile/lib/l10n/intl_da.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "Vi er kede af at du forlader os. Forklar venligst hvorfor, så vi kan forbedre os.", "feedback": "Feedback", "kindlyHelpUsWithThisInformation": "Hjælp os venligst med disse oplysninger", - "confirmDeletePrompt": "Ja, jeg ønsker at slette denne konto og alle dens data permanent.", "confirmAccountDeletion": "Bekræft Sletning Af Konto", "deleteAccountPermanentlyButton": "Slet konto permanent", "yourAccountHasBeenDeleted": "Din konto er blevet slettet", diff --git a/mobile/lib/l10n/intl_de.arb b/mobile/lib/l10n/intl_de.arb index aa50482f8d..50f0248d40 100644 --- a/mobile/lib/l10n/intl_de.arb +++ b/mobile/lib/l10n/intl_de.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Wir bedauern sehr, dass du dein Konto löschen möchtest. Du würdest uns sehr helfen, wenn du uns kurz einige Gründe hierfür nennen könntest.", "feedback": "Rückmeldung", "kindlyHelpUsWithThisInformation": "Bitte gib diese Daten ein", - "confirmDeletePrompt": "Ja, ich möchte dieses Konto und alle enthaltenen Daten endgültig und unwiderruflich löschen.", + "confirmDeletePrompt": "Ja, ich möchte dieses Konto und alle enthaltenen Daten über alle Apps endgültig und unwiderruflich löschen.", "confirmAccountDeletion": "Kontolöschung bestätigen", "deleteAccountPermanentlyButton": "Konto unwiderruflich löschen", "yourAccountHasBeenDeleted": "Dein Benutzerkonto wurde gelöscht", diff --git a/mobile/lib/l10n/intl_es.arb b/mobile/lib/l10n/intl_es.arb index 238f61095f..3131fb37eb 100644 --- a/mobile/lib/l10n/intl_es.arb +++ b/mobile/lib/l10n/intl_es.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "Lamentamos que te vayas. Por favor, explícanos el motivo para ayudarnos a mejorar.", "feedback": "Sugerencias", "kindlyHelpUsWithThisInformation": "Por favor ayúdanos con esta información", - "confirmDeletePrompt": "Sí, quiero eliminar permanentemente esta cuenta y todos sus datos.", "confirmAccountDeletion": "Confirmar borrado de cuenta", "deleteAccountPermanentlyButton": "Eliminar cuenta permanentemente", "yourAccountHasBeenDeleted": "Tu cuenta ha sido eliminada", diff --git a/mobile/lib/l10n/intl_fa.arb b/mobile/lib/l10n/intl_fa.arb index d5dbe63862..8d957cf574 100644 --- a/mobile/lib/l10n/intl_fa.arb +++ b/mobile/lib/l10n/intl_fa.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "ما متاسفیم که می‌بینیم شما می‌روید. لطفا نظرات خود را برای کمک به بهبود ما به اشتراک بگذارید.", "feedback": "بازخورد", "kindlyHelpUsWithThisInformation": "لطفا با این اطلاعات به ما کمک کنید", - "confirmDeletePrompt": "بله، من می‌خواهم برای همیشه این حساب کاربری و تمام اطلاعات آن را حذف کنم.", "confirmAccountDeletion": "تایید حذف حساب کاربری", "deleteAccountPermanentlyButton": "حذف دائمی حساب کاربری", "yourAccountHasBeenDeleted": "حساب کاربری شما حذف شده است", diff --git a/mobile/lib/l10n/intl_fr.arb b/mobile/lib/l10n/intl_fr.arb index 8f4b1e375e..01cf3c930d 100644 --- a/mobile/lib/l10n/intl_fr.arb +++ b/mobile/lib/l10n/intl_fr.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Nous sommes désolés de vous voir partir. N'hésitez pas à partager vos commentaires pour nous aider à améliorer le service.", "feedback": "Commentaires", "kindlyHelpUsWithThisInformation": "Merci de nous aider avec cette information", - "confirmDeletePrompt": "Oui, je veux supprimer définitivement ce compte et toutes ses données.", + "confirmDeletePrompt": "Oui, je veux supprimer définitivement ce compte et ses données dans toutes les applications.", "confirmAccountDeletion": "Confirmer la suppression du compte", "deleteAccountPermanentlyButton": "Supprimer définitivement le compte", "yourAccountHasBeenDeleted": "Votre compte a été supprimé", @@ -277,6 +277,7 @@ "change": "Modifier", "unavailableReferralCode": "Désolé, ce code n'est pas disponible.", "codeChangeLimitReached": "Désolé, vous avez atteint la limite de changements de code.", + "onlyFamilyAdminCanChangeCode": "Veuillez contacter {familyAdminEmail} pour modifier votre code.", "storageInGB": "{storageAmountInGB} Go", "claimed": "Réclamée", "@claimed": { @@ -413,7 +414,13 @@ "photoGridSize": "Taille de la grille photo", "manageDeviceStorage": "Gérer le stockage de l'appareil", "machineLearning": "Apprentissage automatique", + "mlConsent": "Activer l'apprentissage automatique", + "mlConsentTitle": "Activer l'apprentissage automatique ?", + "mlConsentDescription": "Si vous activez l'apprentissage automatique, Ente extraira des informations comme la géométrie des visages, incluant les photos partagées avec vous. \nCela se fera sur votre appareil, avec un cryptage de bout-en-bout de toutes les données biométriques générées.", + "mlConsentPrivacy": "Veuillez cliquer ici pour plus de détails sur cette fonctionnalité dans notre politique de confidentialité", + "mlConsentConfirmation": "Je comprends, et souhaite activer l'apprentissage automatique", "magicSearch": "Recherche magique", + "mlIndexingDescription": "Veuillez noter que l'apprentissage automatique entraînera une augmentation de l'utilisation de la bande passante et de la batterie, jusqu'à ce que tous les éléments soient indexés. \nEnvisagez d'utiliser l'application de bureau pour une indexation plus rapide, tous les résultats seront automatiquement synchronisés.", "loadingModel": "Téléchargement des modèles...", "waitingForWifi": "En attente de connexion Wi-Fi...", "status": "État", @@ -489,6 +496,7 @@ "removeDuplicates": "Supprimer les doublons", "removeDuplicatesDesc": "Examiner et supprimer les fichiers qui sont des doublons exacts.", "viewLargeFiles": "Fichiers volumineux", + "viewLargeFilesDesc": "Afficher les fichiers qui consomment le plus de stockage.", "noDuplicates": "✨ Aucun doublon", "youveNoDuplicateFilesThatCanBeCleared": "Vous n'avez aucun fichier dédupliqué pouvant être nettoyé", "success": "Succès", @@ -1145,6 +1153,7 @@ "successfullyHid": "Masquage réussi", "successfullyUnhid": "Masquage réussi", "crashReporting": "Rapports d'erreurs", + "resumableUploads": "Chargements à poursuivre", "addToHiddenAlbum": "Ajouter à un album masqué", "moveToHiddenAlbum": "Déplacer vers un album masqué", "fileTypes": "Types de fichiers", @@ -1247,12 +1256,29 @@ "foundFaces": "Visages trouvés", "clusteringProgress": "Progression du regroupement", "indexingIsPaused": "L'indexation est en pause. Elle reprendra automatiquement lorsque l'appareil sera prêt.", + "trim": "Recadrer", + "crop": "Rogner", "rotate": "Pivoter", "left": "Gauche", "right": "Droite", "whatsNew": "Nouveautés", "reviewSuggestions": "Consulter les suggestions", "useAsCover": "Utiliser comme couverture", + "notPersonLabel": "Pas {name}?", + "@notPersonLabel": { + "description": "Label to indicate that the person in the photo is not the person whose name is mentioned", + "placeholders": { + "name": { + "content": "{name}", + "type": "String" + } + } + }, + "enable": "Activer", + "enabled": "Activé", + "moreDetails": "Plus de détails", + "enableMLIndexingDesc": "Ente prend en charge l'apprentissage automatique sur l'appareil pour la reconnaissance faciale, la recherche magique et d'autres fonctionnalités de recherche avancée", + "magicSearchHint": "La recherche magique permet de rechercher des photos par leur contenu, par exemple 'fleur', 'voiture rouge', 'documents d'identité'", "panorama": "Panorama", "reenterPassword": "Ressaisir le mot de passe", "reenterPin": "Ressaisir le code PIN", @@ -1278,6 +1304,8 @@ "pleaseSelectQuickLinksToRemove": "Veuillez sélectionner les liens rapides à supprimer", "removePublicLinks": "Supprimer les liens publics", "thisWillRemovePublicLinksOfAllSelectedQuickLinks": "Ceci supprimera les liens publics de tous les liens rapides sélectionnés.", + "guestView": "Vue invité", + "guestViewEnablePreSteps": "Pour activer la vue invité, veuillez configurer le code d'accès de l'appareil ou le verrouillage de l'écran dans les paramètres de votre système.", "cl_guest_view_title": "Vue invité", "cl_guest_view_description": "Montrer des photos à un ami en les transmettant sur votre téléphone ? Ne vous inquiétez pas si vous les faites glisser trop loin.\nLa vue \"invité\" les verrouillera dans les photos que vous avez sélectionnées.", "cl_guest_view_call_to_action": "Sélectionnez les photos et fixez les en \"Vue Invité\".", @@ -1285,5 +1313,7 @@ "cl_panorama_viewer_description": "Nous avons ajouté le support pour visionner des photos panoramiques avec des vues à 360 degrés. L'expérience est immersive avec la navigation basée sur les mouvements !", "cl_video_player_title": "Lecteur vidéo", "cl_video_player_description": "Intégration d'un nouveau lecteur vidéo, avec de meilleurs contrôles de lecture et la prise en charge des vidéos HDR.", - "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Pour activer le verrouillage d'application, veuillez configurer le code d'accès de l'appareil ou le verrouillage de l'écran dans les paramètres de votre système." + "appLockDescriptions": "Choisissez entre l'écran de verrouillage par défaut de votre appareil et un écran de verrouillage personnalisé avec un code PIN ou un mot de passe.", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Pour activer le verrouillage d'application, veuillez configurer le code d'accès de l'appareil ou le verrouillage de l'écran dans les paramètres de votre système.", + "authToViewPasskey": "Veuillez vous authentifier pour afficher votre clé de récupération" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_he.arb b/mobile/lib/l10n/intl_he.arb index ad713b19d7..9342d8cf07 100644 --- a/mobile/lib/l10n/intl_he.arb +++ b/mobile/lib/l10n/intl_he.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "אנחנו מצטערים לראות שאתה עוזב. אנא תחלוק את המשוב שלך כדי לעזור לנו להשתפר.", "feedback": "משוב", "kindlyHelpUsWithThisInformation": "אנא עזור לנו עם המידע הזה", - "confirmDeletePrompt": "כן, אני רוצה למחוק לצמיתות את החשבון הזה וכל המידע שלו.", "confirmAccountDeletion": "אשר את מחיקת החשבון", "deleteAccountPermanentlyButton": "מחק את החשבון לצמיתות", "yourAccountHasBeenDeleted": "החשבון שלך נמחק", diff --git a/mobile/lib/l10n/intl_hi.arb b/mobile/lib/l10n/intl_hi.arb index 35f1e866b4..b79d9682f2 100644 --- a/mobile/lib/l10n/intl_hi.arb +++ b/mobile/lib/l10n/intl_hi.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "आपको जाता हुए देख कर हमें खेद है। कृपया हमें बेहतर बनने में सहायता के लिए अपनी प्रतिक्रिया साझा करें।", "feedback": "प्रतिपुष्टि", "kindlyHelpUsWithThisInformation": "कृपया हमें इस जानकारी के लिए सहायता करें", - "confirmDeletePrompt": "हां, मैं इस अकाउंट और इसके सभी डेटा को स्थायी रूप से हटाना चाहता/चाहती हूं।", "confirmAccountDeletion": "अकाउंट डिलीट करने की पुष्टि करें", "deleteAccountPermanentlyButton": "अकाउंट स्थायी रूप से डिलीट करें", "yourAccountHasBeenDeleted": "आपका अकाउंट डिलीट कर दिया गया है", diff --git a/mobile/lib/l10n/intl_id.arb b/mobile/lib/l10n/intl_id.arb index 0768a2d798..2a5c73f456 100644 --- a/mobile/lib/l10n/intl_id.arb +++ b/mobile/lib/l10n/intl_id.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Kami sedih kamu pergi. Silakan bagikan masukanmu agar kami bisa jadi lebih baik.", "feedback": "Masukan", "kindlyHelpUsWithThisInformation": "Harap bantu kami dengan informasi ini", - "confirmDeletePrompt": "Ya, saya ingin menghapus akun ini dan seluruh data yang terkait secara permanen.", + "confirmDeletePrompt": "Ya, saya ingin menghapus akun ini dan seluruh datanya secara permanen di semua aplikasi.", "confirmAccountDeletion": "Konfirmasi Penghapusan Akun", "deleteAccountPermanentlyButton": "Hapus Akun Secara Permanen", "yourAccountHasBeenDeleted": "Akunmu telah dihapus", @@ -426,6 +426,7 @@ "status": "Status", "indexedItems": "Item terindeks", "pendingItems": "Item menunggu", + "clearIndexes": "Hapus indeks", "selectFoldersForBackup": "Pilih folder yang perlu dicadangkan", "selectedFoldersWillBeEncryptedAndBackedUp": "Folder yang terpilih akan dienkripsi dan dicadangkan", "unselectAll": "Batalkan semua pilihan", @@ -437,11 +438,18 @@ "showMemories": "Lihat kenangan", "yearsAgo": "{count, plural, other{{count} tahun lalu}}", "backupSettings": "Pengaturan pencadangan", + "backupStatus": "Status pencadangan", + "backupStatusDescription": "Item yang sudah dicadangkan akan terlihat di sini", "backupOverMobileData": "Cadangkan dengan data seluler", "backupVideos": "Cadangkan video", "disableAutoLock": "Nonaktifkan kunci otomatis", + "deviceLockExplanation": "Nonaktfikan kunci layar perangkat saat Ente berada di latar depan dan ada pencadangan yang sedang berlangsung. Hal ini biasanya tidak diperlukan, namun dapat membantu unggahan dan import awal berkas berkas besar selesai lebih cepat.", + "about": "Tentang", + "weAreOpenSource": "Kode kami sumber terbuka!", "privacy": "Privasi", "terms": "Ketentuan", + "checkForUpdates": "Periksa pembaruan", + "checkStatus": "Periksa status", "checking": "Memeriksa...", "youAreOnTheLatestVersion": "Kamu menggunakan versi terbaru", "account": "Akun", @@ -458,10 +466,13 @@ "yesLogout": "Ya, keluar", "aNewVersionOfEnteIsAvailable": "Versi baru dari Ente telah tersedia.", "update": "Perbarui", + "installManually": "Instal secara manual", "criticalUpdateAvailable": "Pembaruan penting tersedia", "updateAvailable": "Pembaruan tersedia", "ignoreUpdate": "Abaikan", "downloading": "Mengunduh...", + "cannotDeleteSharedFiles": "Tidak dapat menghapus file berbagi", + "theDownloadCouldNotBeCompleted": "Unduhan tidak dapat diselesaikan", "retry": "Coba lagi", "backedUpFolders": "Folder yang dicadangkan", "backup": "Pencadangan", @@ -472,8 +483,12 @@ "removeDuplicates": "Hapus duplikat", "removeDuplicatesDesc": "Lihat dan hapus file yang sama persis.", "viewLargeFiles": "File berukuran besar", + "viewLargeFilesDesc": "Tampilkan file yang banyak mengkonsumsi ruang penyimpanan.", "noDuplicates": "✨ Tak ada file duplikat", + "youveNoDuplicateFilesThatCanBeCleared": "Kamu tidak memiliki file duplikat yang dapat di hapus", "success": "Berhasil", + "rateUs": "Beri kami nilai", + "remindToEmptyDeviceTrash": "Kosongkan juga “Baru Saja Dihapus” dari “Pengaturan” -> “Penyimpanan” untuk mengklaim ruang yang baru dikosongkan", "youHaveSuccessfullyFreedUp": "Kamu telah berhasil membersihkan {storageSaved}!", "@youHaveSuccessfullyFreedUp": { "description": "The text to display when the user has successfully freed up storage", @@ -485,6 +500,7 @@ } } }, + "remindToEmptyEnteTrash": "Kosongkan juga \"Sampah\" untuk mendapatkan ruang yang baru dikosongkan", "sparkleSuccess": "✨ Berhasil", "duplicateFileCountWithStorageSaved": "Kamu telah menghapus {count, plural, other{{count} file duplikat}} dan membersihkan ({storageSaved}!)", "@duplicateFileCountWithStorageSaved": { @@ -502,14 +518,18 @@ } }, "familyPlans": "Paket keluarga", + "referrals": "Referensi", "notifications": "Notifikasi", "sharedPhotoNotifications": "Foto terbagi baru", + "sharedPhotoNotificationsExplanation": "Terima notifikasi apabila seseorang menambahkan foto ke album bersama yang kamu ikuti", "advanced": "Lanjutan", "general": "Umum", "security": "Keamanan", "authToViewYourRecoveryKey": "Harap autentikasi untuk melihat kunci pemulihan kamu", "twofactor": "Autentikasi dua langkah", "authToConfigureTwofactorAuthentication": "Harap autentikasi untuk mengatur autentikasi dua langkah", + "lockscreen": "Kunci layar", + "authToChangeLockscreenSetting": "Lakukan autentikasi untuk mengubah pengaturan kunci layar", "viewActiveSessions": "Lihat sesi aktif", "authToViewYourActiveSessions": "Harap autentikasi untuk melihat sesi aktif kamu", "disableTwofactor": "Nonaktifkan autentikasi dua langkah", @@ -519,6 +539,7 @@ "social": "Sosial", "rateUsOnStore": "Beri nilai di {storeName}", "blog": "Blog", + "merchandise": "Barang Dagangan", "twitter": "Twitter", "mastodon": "Mastodon", "matrix": "Matrix", @@ -549,6 +570,7 @@ "renewsOn": "Langganan akan diperpanjang pada {endDate}", "freeTrialValidTill": "Percobaan gratis berlaku hingga {endDate}", "validTill": "Berlaku hingga {endDate}", + "addOnValidTill": "Add-on {storageAmount} kamu berlaku sampai {endDate}", "playStoreFreeTrialValidTill": "Percobaan gratis berlaku hingga {endDate}.\nKamu dapat memilih paket berbayar setelahnya.", "subWillBeCancelledOn": "Langganan kamu akan dibatalkan pada {endDate}", "subscription": "Langganan", @@ -576,18 +598,46 @@ }, "confirmPlanChange": "Konfirmasi perubahan paket", "areYouSureYouWantToChangeYourPlan": "Apakah kamu yakin ingin mengubah paket kamu?", + "youCannotDowngradeToThisPlan": "Anda tidak dapat turun ke paket ini", + "cancelOtherSubscription": "Harap batalkan langganan kamu dari {paymentProvider} terlebih dahulu", + "@cancelOtherSubscription": { + "description": "The text to display when the user has an existing subscription from a different payment provider", + "type": "text", + "placeholders": { + "paymentProvider": { + "example": "Apple", + "type": "String" + } + } + }, "optionalAsShortAsYouLike": "Opsional, pendek pun tak apa...", "send": "Kirim", "askCancelReason": "Langganan kamu telah dibatalkan. Apakah kamu ingin membagikan alasannya?", "thankYouForSubscribing": "Terima kasih telah berlangganan!", "yourPurchaseWasSuccessful": "Pembelianmu berhasil", + "yourPlanWasSuccessfullyUpgraded": "Paket kamu berhasil ditingkatkan", + "yourPlanWasSuccessfullyDowngraded": "Paket kamu berhasil di turunkan", "yourSubscriptionWasUpdatedSuccessfully": "Langgananmu telah berhasil diperbarui", "googlePlayId": "ID Google Play", "appleId": "ID Apple", + "playstoreSubscription": "Langganan PlayStore", + "appstoreSubscription": "Langganan AppStore", "subAlreadyLinkedErrMessage": "{id} kamu telah terhubung dengan akun Ente lain.\nJika kamu ingin menggunakan {id} kamu untuk akun ini, silahkan hubungi tim bantuan kami", "visitWebToManage": "Silakan buka web.ente.io untuk mengatur langgananmu", + "couldNotUpdateSubscription": "Tidak dapat memperbarui langganan", "pleaseContactSupportAndWeWillBeHappyToHelp": "Silakan hubungi support@ente.io dan kami akan dengan senang hati membantu!", "paymentFailed": "Pembayaran gagal", + "paymentFailedTalkToProvider": "Harap hubungi dukungan {providerName} jika kamu dikenai biaya", + "@paymentFailedTalkToProvider": { + "description": "The text to display when the payment failed", + "type": "text", + "placeholders": { + "providerName": { + "example": "AppStore|PlayStore", + "type": "String" + } + } + }, "continueOnFreeTrial": "Lanjut dengan percobaan gratis", "areYouSureYouWantToExit": "Apakah kamu yakin ingin keluar?", "thankYou": "Terima kasih", @@ -601,8 +651,10 @@ "leave": "Tinggalkan", "rateTheApp": "Nilai app ini", "startBackup": "Mulai pencadangan", + "noPhotosAreBeingBackedUpRightNow": "Tidak ada foto yang sedang dicadangkan sekarang", "grantFullAccessPrompt": "Harap berikan akses ke semua foto di app Pengaturan", "openSettings": "Buka Pengaturan", + "selectMorePhotos": "Pilih lebih banyak foto", "existingUser": "Masuk", "privateBackups": "Cadangan pribadi", "forYourMemories": "untuk kenanganmu", @@ -614,6 +666,7 @@ "everywhere": "di mana saja", "androidIosWebDesktop": "Android, iOS, Web, Desktop", "mobileWebDesktop": "Seluler, Web, Desktop", + "newToEnte": "Baru di Ente", "pleaseLoginAgain": "Silakan masuk akun lagi", "autoLogoutMessage": "Akibat kesalahan teknis, kamu telah keluar dari akunmu. Kami mohon maaf atas ketidaknyamanannya.", "yourSubscriptionHasExpired": "Langgananmu telah berakhir", @@ -848,6 +901,9 @@ "networkHostLookUpErr": "Tidak dapat terhubung dengan Ente, harap periksa pengaturan jaringan kamu dan hubungi dukungan jika masalah berlanjut.", "networkConnectionRefusedErr": "Tidak dapat terhubung dengan Ente, silakan coba lagi setelah beberapa saat. Jika masalah berlanjut, harap hubungi dukungan.", "cachedData": "Data cache", + "remoteThumbnails": "Thumbnail jarak jauh", + "pendingSync": "Sinkronisasi yang tertunda", + "localGallery": "Galeri lokal", "todaysLogs": "Log hari ini", "viewLogs": "Lihat log", "preparingLogs": "Menyiapkan log...", diff --git a/mobile/lib/l10n/intl_it.arb b/mobile/lib/l10n/intl_it.arb index ac66ecca17..37ac274bda 100644 --- a/mobile/lib/l10n/intl_it.arb +++ b/mobile/lib/l10n/intl_it.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Ci dispiace vederti andare via. Facci sapere se hai bisogno di aiuto o se vuoi aiutarci a migliorare.", "feedback": "Suggerimenti", "kindlyHelpUsWithThisInformation": "Aiutaci con queste informazioni", - "confirmDeletePrompt": "Sì, voglio eliminare definitivamente questo account e tutti i suoi dati.", + "confirmDeletePrompt": "Sì, voglio eliminare definitivamente questo account e i dati associati a esso su tutte le applicazioni.", "confirmAccountDeletion": "Conferma eliminazione account", "deleteAccountPermanentlyButton": "Cancella definitivamente il tuo account", "yourAccountHasBeenDeleted": "Il tuo account è stato eliminato", @@ -24,6 +24,7 @@ "sendEmail": "Invia email", "deleteRequestSLAText": "La tua richiesta verrà elaborata entro 72 ore.", "deleteEmailRequest": "Invia un'email a account-deletion@ente.io dal tuo indirizzo email registrato.", + "entePhotosPerm": "Ente necessita del permesso per preservare le tue foto", "ok": "Ok", "createAccount": "Crea account", "createNewAccount": "Crea un nuovo account", @@ -225,14 +226,17 @@ }, "description": "Number of participants in an album, including the album owner." }, + "collabLinkSectionDescription": "Crea un link per consentire alle persone di aggiungere e visualizzare foto nel tuo album condiviso senza bisogno di un'applicazione o di un account Ente. Ottimo per raccogliere foto di un evento.", "collectPhotos": "Raccogli le foto", "collaborativeLink": "Link collaborativo", + "shareWithNonenteUsers": "Condividi con utenti che non hanno un account Ente", "createPublicLink": "Crea link pubblico", "sendLink": "Invia link", "copyLink": "Copia link", "linkHasExpired": "Il link è scaduto", "publicLinkEnabled": "Link pubblico abilitato", "shareALink": "Condividi un link", + "sharedAlbumSectionDescription": "Crea album condivisi e collaborativi con altri utenti di Ente, inclusi gli utenti con piani gratuiti.", "shareWithPeopleSectionTitle": "{numberOfPeople, plural, =0 {Condividi con persone specifiche} =1 {Condividi con una persona} other {Condividi con {numberOfPeople} persone}}", "@shareWithPeopleSectionTitle": { "placeholders": { @@ -256,10 +260,12 @@ }, "verificationId": "ID di verifica", "verifyEmailID": "Verifica {email}", + "emailNoEnteAccount": "{email} non ha un account Ente.\n\nInvia un invito per condividere foto.", "shareMyVerificationID": "Ecco il mio ID di verifica: {verificationID} per ente.io.", "shareTextConfirmOthersVerificationID": "Hey, puoi confermare che questo è il tuo ID di verifica: {verificationID} su ente.io", "somethingWentWrong": "Qualcosa è andato storto", "sendInvite": "Invita", + "shareTextRecommendUsingEnte": "Scarica Ente in modo da poter facilmente condividere foto e video in qualità originale\n\nhttps://ente.io", "done": "Completato", "applyCodeTitle": "Applica codice", "enterCodeDescription": "Inserisci il codice fornito dal tuo amico per richiedere spazio gratuito per entrambi", @@ -267,6 +273,11 @@ "failedToApplyCode": "Impossibile applicare il codice", "enterReferralCode": "Inserisci il codice di invito", "codeAppliedPageTitle": "Codice applicato", + "changeYourReferralCode": "Cambia il tuo codice invito", + "change": "Cambia", + "unavailableReferralCode": "Siamo spiacenti, questo codice non è disponibile.", + "codeChangeLimitReached": "Siamo spiacenti, hai raggiunto il limite di modifiche del codice.", + "onlyFamilyAdminCanChangeCode": "Per favore contatta {familyAdminEmail} per cambiare il tuo codice.", "storageInGB": "{storageAmountInGB} GB", "claimed": "Riscattato", "@claimed": { @@ -276,6 +287,7 @@ "claimMore": "Richiedine di più!", "theyAlsoGetXGb": "Anche loro riceveranno {storageAmountInGB} GB", "freeStorageOnReferralSuccess": "{storageAmountInGB} GB ogni volta che qualcuno si iscrive a un piano a pagamento e applica il tuo codice", + "shareTextReferralCode": "Codice invito Ente: {referralCode} \n\nInseriscilo in Impostazioni → Generali → Inviti per ottenere {referralStorageInGB} GB gratis dopo la sottoscrizione a un piano a pagamento\n\nhttps://ente.io", "claimFreeStorage": "Richiedi spazio gratuito", "inviteYourFriends": "Invita i tuoi amici", "failedToFetchReferralDetails": "Impossibile recuperare i dettagli. Per favore, riprova più tardi.", @@ -298,6 +310,7 @@ } }, "faq": "FAQ", + "help": "Aiuto", "oopsSomethingWentWrong": "Oops! Qualcosa è andato storto", "peopleUsingYourCode": "Persone che hanno usato il tuo codice", "eligible": "idoneo", @@ -327,6 +340,7 @@ "removeParticipantBody": "{userEmail} verrà rimosso da questo album condiviso\n\nQualsiasi foto aggiunta dall'utente verrà rimossa dall'album", "keepPhotos": "Mantieni foto", "deletePhotos": "Elimina foto", + "inviteToEnte": "Invita su Ente", "removePublicLink": "Rimuovi link pubblico", "disableLinkMessage": "Questo rimuoverà il link pubblico per accedere a \"{albumName}\".", "sharing": "Condivisione in corso...", @@ -342,7 +356,10 @@ "videoSmallCase": "video", "photoSmallCase": "foto", "singleFileDeleteHighlight": "Verrà eliminato da tutti gli album.", + "singleFileInBothLocalAndRemote": "Questo {fileType} è sia su Ente che sul tuo dispositivo.", + "singleFileInRemoteOnly": "Questo {fileType} verrà eliminato da Ente.", "singleFileDeleteFromDevice": "Questo {fileType} verrà eliminato dal tuo dispositivo.", + "deleteFromEnte": "Elimina da Ente", "yesDelete": "Sì, elimina", "movedToTrash": "Spostato nel cestino", "deleteFromDevice": "Elimina dal dispositivo", @@ -396,6 +413,10 @@ }, "photoGridSize": "Dimensione griglia foto", "manageDeviceStorage": "Gestisci memoria dispositivo", + "mlConsentDescription": "Se abiliti il Machine Learning, Ente estrarrà informazioni come la geometria del volto dai file, inclusi quelli condivisi con te.\n\nQuesto accadrà sul tuo dispositivo, e qualsiasi informazione biometrica generata sarà crittografata end-to-end.", + "mlConsentPrivacy": "Clicca qui per maggiori dettagli su questa funzione nella nostra informativa sulla privacy", + "mlIndexingDescription": "Si prega di notare che l'attivazione dell'apprendimento automatico si tradurrà in un maggior utilizzo della connessione e della batteria fino a quando tutti gli elementi non saranno indicizzati. Valuta di utilizzare l'applicazione desktop per un'indicizzazione più veloce, tutti i risultati verranno sincronizzati automaticamente.", + "loadingModel": "Scaricamento modelli...", "waitingForWifi": "In attesa del WiFi...", "status": "Stato", "indexedItems": "Elementi indicizzati", @@ -430,11 +451,13 @@ "backupOverMobileData": "Backup su dati mobili", "backupVideos": "Backup dei video", "disableAutoLock": "Disabilita blocco automatico", + "deviceLockExplanation": "Disabilita il blocco schermo del dispositivo quando Ente è in primo piano e c'è un backup in corso. Questo normalmente non è necessario ma può aiutare a completare più velocemente grossi caricamenti e l'importazione iniziale di grandi librerie.", "about": "Info", "weAreOpenSource": "Siamo open source!", "privacy": "Privacy", "terms": "Termini d'uso", "checkForUpdates": "Controlla aggiornamenti", + "checkStatus": "Verifica stato", "checking": "Controllo in corso...", "youAreOnTheLatestVersion": "Stai utilizzando l'ultima versione", "account": "Account", @@ -449,6 +472,7 @@ "authToInitiateAccountDeletion": "Autenticati per avviare l'eliminazione dell'account", "areYouSureYouWantToLogout": "Sei sicuro di volerti disconnettere?", "yesLogout": "Sì, disconnetti", + "aNewVersionOfEnteIsAvailable": "Una nuova versione di Ente è disponibile.", "update": "Aggiorna", "installManually": "Installa manualmente", "criticalUpdateAvailable": "Un aggiornamento importante è disponibile", @@ -461,9 +485,13 @@ "backedUpFolders": "Cartelle salvate", "backup": "Backup", "freeUpDeviceSpace": "Libera spazio", + "freeUpDeviceSpaceDesc": "Risparmia spazio sul tuo dispositivo cancellando i file che sono già stati salvati online.", "allClear": "✨ Tutto pulito", "noDeviceThatCanBeDeleted": "Non hai file su questo dispositivo che possono essere eliminati", "removeDuplicates": "Rimuovi i doppioni", + "removeDuplicatesDesc": "Verifica e rimuovi i file che sono esattamente duplicati.", + "viewLargeFiles": "File di grandi dimensioni", + "viewLargeFilesDesc": "Visualizza i file che stanno occupando la maggior parte dello spazio di archiviazione.", "noDuplicates": "✨ Nessun doppione", "youveNoDuplicateFilesThatCanBeCleared": "Non hai file duplicati che possono essere cancellati", "success": "Operazione riuscita", @@ -536,6 +564,7 @@ "systemTheme": "Sistema", "freeTrial": "Prova gratuita", "selectYourPlan": "Seleziona un piano", + "enteSubscriptionPitch": "Ente conserva i tuoi ricordi in modo che siano sempre a disposizione, anche se perdi il tuo dispositivo.", "enteSubscriptionShareWithFamily": "Aggiungi la tua famiglia al tuo piano.", "currentUsageIs": "Spazio attualmente utilizzato ", "@currentUsageIs": { @@ -549,6 +578,8 @@ "renewsOn": "Si rinnova il {endDate}", "freeTrialValidTill": "La prova gratuita termina il {endDate}", "validTill": "Valido fino al {endDate}", + "addOnValidTill": "Il tuo spazio aggiuntivo di {storageAmount} è valido fino al {endDate}", + "playStoreFreeTrialValidTill": "Prova gratuita valida fino al {endDate}.\nIn seguito potrai scegliere un piano a pagamento.", "subWillBeCancelledOn": "L'abbonamento verrà cancellato il {endDate}", "subscription": "Abbonamento", "paymentDetails": "Dettagli di Pagamento", @@ -599,6 +630,7 @@ "appleId": "Apple ID", "playstoreSubscription": "Abbonamento su PlayStore", "appstoreSubscription": "abbonamento AppStore", + "subAlreadyLinkedErrMessage": "Il tuo {id} è già collegato a un altro account Ente.\nSe desideri utilizzare il tuo {id} con questo account, per favore contatta il nostro supporto''", "visitWebToManage": "Visita web.ente.io per gestire il tuo abbonamento", "couldNotUpdateSubscription": "Impossibile aggiornare l'abbonamento", "pleaseContactSupportAndWeWillBeHappyToHelp": "Contatta support@ente.io e saremo felici di aiutarti!", @@ -619,6 +651,7 @@ "thankYou": "Grazie", "failedToVerifyPaymentStatus": "Impossibile verificare lo stato del pagamento", "pleaseWaitForSometimeBeforeRetrying": "Riprova tra qualche minuto", + "paymentFailedMessage": "Purtroppo il tuo pagamento non è riuscito. Contatta l'assistenza e ti aiuteremo!", "youAreOnAFamilyPlan": "Sei un utente con piano famiglia!", "contactFamilyAdmin": "Contatta {familyAdminEmail} per gestire il tuo abbonamento", "leaveFamily": "Abbandona il piano famiglia", @@ -642,7 +675,9 @@ "everywhere": "ovunque", "androidIosWebDesktop": "Android, iOS, Web, Desktop", "mobileWebDesktop": "Mobile, Web, Desktop", + "newToEnte": "Prima volta con Ente", "pleaseLoginAgain": "Effettua nuovamente l'accesso", + "autoLogoutMessage": "A causa di problemi tecnici, sei stato disconnesso. Ci scusiamo per l'inconveniente.", "yourSubscriptionHasExpired": "Il tuo abbonamento è scaduto", "storageLimitExceeded": "Limite d'archiviazione superato", "upgrade": "Acquista altro spazio", @@ -653,10 +688,12 @@ }, "backupFailed": "Backup fallito", "couldNotBackUpTryLater": "Impossibile eseguire il backup dei tuoi dati.\nRiproveremo più tardi.", + "enteCanEncryptAndPreserveFilesOnlyIfYouGrant": "Ente può criptare e conservare i file solo se gliene concedi l'accesso", "pleaseGrantPermissions": "Concedi i permessi", "grantPermission": "Concedi il permesso", "privateSharing": "Condivisioni private", "shareOnlyWithThePeopleYouWant": "Condividi solo con le persone che vuoi", + "usePublicLinksForPeopleNotOnEnte": "Usa link pubblici per persone non registrate su Ente", "allowPeopleToAddPhotos": "Permetti alle persone di aggiungere foto", "shareAnAlbumNow": "Condividi un album", "collectEventPhotos": "Raccogli le foto di un evento", @@ -679,6 +716,21 @@ "deleteEmptyAlbumsWithQuestionMark": "Eliminare gli album vuoti?", "deleteAlbumsDialogBody": "Questo eliminerà tutti gli album vuoti. È utile quando si desidera ridurre l'ingombro nella lista degli album.", "deleteProgress": "Eliminazione di {currentlyDeleting} / {totalCount}", + "genericProgress": "Elaborazione {currentlyProcessing} / {totalCount}", + "@genericProgress": { + "description": "Generic progress text to display when processing multiple items", + "type": "text", + "placeholders": { + "currentlyProcessing": { + "example": "1", + "type": "int" + }, + "totalCount": { + "example": "10", + "type": "int" + } + } + }, "permanentlyDelete": "Elimina definitivamente", "canOnlyCreateLinkForFilesOwnedByYou": "Puoi creare solo link per i file di tua proprietà", "publicLinkCreated": "Link pubblico creato", @@ -693,11 +745,13 @@ "unhide": "Mostra", "unarchive": "Rimuovi dall'archivio", "favorite": "Preferito", + "removeFromFavorite": "Rimuovi dai preferiti", "shareLink": "Condividi link", "createCollage": "Crea un collage", "saveCollage": "Salva il collage", "collageSaved": "Collage salvato nella galleria", "collageLayout": "Disposizione", + "addToEnte": "Aggiungi a Ente", "addToAlbum": "Aggiungi all'album", "delete": "Cancella", "hide": "Nascondi", @@ -762,7 +816,10 @@ "photosAddedByYouWillBeRemovedFromTheAlbum": "Le foto aggiunte da te verranno rimosse dall'album", "youveNoFilesInThisAlbumThatCanBeDeleted": "Non hai file in questo album che possono essere eliminati", "youDontHaveAnyArchivedItems": "Non hai nulla di archiviato.", + "ignoredFolderUploadReason": "Alcuni file in questo album vengono ignorati dal caricamento perché erano stati precedentemente eliminati da Ente.", "resetIgnoredFiles": "Ripristina i file ignorati", + "deviceFilesAutoUploading": "I file aggiunti a questo album del dispositivo verranno automaticamente caricati su Ente.", + "turnOnBackupForAutoUpload": "Attiva il backup per caricare automaticamente i file aggiunti a questa cartella del dispositivo su Ente.", "noHiddenPhotosOrVideos": "Nessuna foto o video nascosti", "toHideAPhotoOrVideo": "Per nascondere una foto o un video", "openTheItem": "• Apri la foto o il video", @@ -788,6 +845,7 @@ "close": "Chiudi", "setAs": "Imposta come", "fileSavedToGallery": "File salvato nella galleria", + "filesSavedToGallery": "File salvati nella galleria", "fileFailedToSaveToGallery": "Impossibile salvare il file nella galleria", "download": "Scarica", "pressAndHoldToPlayVideo": "Tieni premuto per riprodurre il video", @@ -890,6 +948,7 @@ "renameFile": "Rinomina file", "enterFileName": "Inserisci un nome per il file", "filesDeleted": "File eliminati", + "selectedFilesAreNotOnEnte": "I file selezionati non sono su Ente", "thisActionCannotBeUndone": "Questa azione non può essere annullata", "emptyTrash": "Vuoi svuotare il cestino?", "permDeleteWarning": "Tutti gli elementi nel cestino verranno eliminati definitivamente\n\nQuesta azione non può essere annullata", @@ -898,6 +957,7 @@ "permanentlyDeleteFromDevice": "Eliminare definitivamente dal dispositivo?", "someOfTheFilesYouAreTryingToDeleteAre": "Alcuni dei file che si sta tentando di eliminare sono disponibili solo sul dispositivo e non possono essere recuperati se cancellati", "theyWillBeDeletedFromAllAlbums": "Verranno eliminati da tutti gli album.", + "someItemsAreInBothEnteAndYourDevice": "Alcuni elementi sono sia su Ente che sul tuo dispositivo.", "selectedItemsWillBeDeletedFromAllAlbumsAndMoved": "Gli elementi selezionati verranno eliminati da tutti gli album e spostati nel cestino.", "theseItemsWillBeDeletedFromYourDevice": "Questi file verranno eliminati dal tuo dispositivo.", "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Sembra che qualcosa sia andato storto. Riprova tra un po'. Se l'errore persiste, contatta il nostro team di supporto.", @@ -933,11 +993,17 @@ "loadMessage7": "Le nostre app per smartphone vengono eseguite in background per crittografare e eseguire il backup di qualsiasi nuova foto o video", "loadMessage8": "web.ente.io ha un uploader intuitivo", "loadMessage9": "Usiamo Xchacha20Poly1305 per crittografare in modo sicuro i tuoi dati", + "photoDescriptions": "Descrizioni delle foto", + "fileTypesAndNames": "Tipi e nomi di file", "location": "Luogo", + "moments": "Momenti", + "searchFaceEmptySection": "Le persone saranno mostrate qui una volta completata l'indicizzazione", "searchDatesEmptySection": "Ricerca per data, mese o anno", "searchLocationEmptySection": "Raggruppa foto scattate entro un certo raggio da una foto", "searchPeopleEmptySection": "Invita persone e vedrai qui tutte le foto condivise da loro", "searchAlbumsEmptySection": "Album", + "searchFileTypesAndNamesEmptySection": "Tipi e nomi di file", + "searchCaptionEmptySection": "Aggiungi descrizioni come \"#viaggio\" nelle informazioni delle foto per trovarle rapidamente qui", "language": "Lingua", "selectLanguage": "Seleziona una lingua", "locationName": "Nome della località", @@ -986,6 +1052,7 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, + "availableStorageSpace": "{freeAmount} {storageUnit} liberi", "appVersion": "Versione: {versionValue}", "verifyIDLabel": "Verifica", "fileInfoAddDescHint": "Aggiungi descrizione...", @@ -996,6 +1063,7 @@ }, "setRadius": "Imposta raggio", "familyPlanPortalTitle": "Famiglia", + "familyPlanOverview": "Aggiungi 5 membri della famiglia al tuo piano esistente senza pagare extra.\n\nOgni membro ottiene il proprio spazio privato e non può vedere i file dell'altro a meno che non siano condivisi.\n\nI piani familiari sono disponibili per i clienti che hanno un abbonamento Ente a pagamento.\n\nIscriviti ora per iniziare!", "androidBiometricHint": "Verifica l'identità", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -1073,21 +1141,43 @@ "noAlbumsSharedByYouYet": "Ancora nessun album condiviso da te", "sharedWithYou": "Condivise con te", "sharedByYou": "Condivise da te", + "inviteYourFriendsToEnte": "Invita i tuoi amici a Ente", "failedToDownloadVideo": "Download del video non riuscito", "hiding": "Nascondendo...", "unhiding": "Rimuovendo dal nascondiglio...", "successfullyHid": "Nascosta con successo", "successfullyUnhid": "Rimossa dal nascondiglio con successo", "crashReporting": "Segnalazione di crash", + "resumableUploads": "Caricamenti riattivabili", "addToHiddenAlbum": "Aggiungi ad album nascosto", "moveToHiddenAlbum": "Sposta in album nascosto", + "fileTypes": "Tipi di file", "hearUsWhereTitle": "Come hai sentito parlare di Ente? (opzionale)", "hearUsExplanation": "Non teniamo traccia del numero di installazioni dell'app. Sarebbe utile se ci dicesse dove ci ha trovato!", "viewAddOnButton": "Visualizza componenti aggiuntivi", "addOns": "Componenti aggiuntivi", "addOnPageSubtitle": "Dettagli dei componenti aggiuntivi", + "yourMap": "La tua mappa", + "modifyYourQueryOrTrySearchingFor": "Modifica la tua interrogazione o prova a cercare", + "blackFridaySale": "Offerta del Black Friday", + "photos": "Foto", + "videos": "Video", "searchHint3": "Album, nomi di file e tipi", "searchHint4": "Luogo", + "addYourPhotosNow": "Aggiungi le tue foto ora", + "searchResultCount": "{count, plural, one{{count} risultato trovato} other{{count} risultati trovati}}", + "@searchResultCount": { + "description": "Text to tell user how many results were found for their search query", + "placeholders": { + "count": { + "example": "1|2|3", + "type": "int" + } + } + }, + "faces": "Volti", + "people": "Persone", + "contents": "Contenuti", "addNew": "Aggiungi nuovo", "@addNew": { "description": "Text to add a new item (location tag, album, caption etc)" @@ -1103,5 +1193,28 @@ "selectALocation": "Seleziona un luogo", "selectALocationFirst": "Scegli prima una posizione", "changeLocationOfSelectedItems": "Cambiare la posizione degli elementi selezionati?", - "editsToLocationWillOnlyBeSeenWithinEnte": "Le modifiche alla posizione saranno visibili solo all'interno di Ente" + "editsToLocationWillOnlyBeSeenWithinEnte": "Le modifiche alla posizione saranno visibili solo all'interno di Ente", + "waitingForVerification": "In attesa di verifica...", + "passkey": "Passkey", + "passkeyAuthTitle": "Verifica della passkey", + "passKeyPendingVerification": "La verifica è ancora in corso", + "loginSessionExpired": "Sessione scaduta", + "loginSessionExpiredDetails": "La sessione è scaduta. Si prega di accedere nuovamente.", + "verifyPasskey": "Verifica passkey", + "playOnTv": "Riproduci album sulla TV", + "pair": "Abbina", + "deviceNotFound": "Dispositivo non trovato", + "castInstruction": "Visita cast.ente.io sul dispositivo che vuoi abbinare.\n\nInserisci il codice qui sotto per riprodurre l'album sulla tua TV.", + "deviceCodeHint": "Inserisci il codice", + "joinDiscord": "Unisciti a Discord", + "locations": "Luoghi", + "descriptions": "Descrizioni", + "addAName": "Aggiungi un nome", + "findPeopleByName": "Trova rapidamente le persone per nome", + "addViewers": "{count, plural, zero {Aggiungi visualizzatore} one {Aggiungi visualizzatore} other {Aggiungi visualizzatori}}", + "addCollaborators": "{count, plural, zero {Aggiungi collaboratore} one {Aggiungi collaboratore} other {Aggiungi collaboratori}}", + "developerSettings": "Impostazioni sviluppatore", + "serverEndpoint": "Endpoint del server", + "invalidEndpoint": "Endpoint invalido", + "invalidEndpointMessage": "Spiacenti, l'endpoint inserito non è valido. Inserisci un endpoint valido e riprova." } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_nl.arb b/mobile/lib/l10n/intl_nl.arb index 62dc45e844..92a66a3401 100644 --- a/mobile/lib/l10n/intl_nl.arb +++ b/mobile/lib/l10n/intl_nl.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "We vinden het jammer je te zien gaan. Deel je feedback om ons te helpen verbeteren.", "feedback": "Feedback", "kindlyHelpUsWithThisInformation": "Help ons alsjeblieft met deze informatie", - "confirmDeletePrompt": "Ja, ik wil permanent mijn account inclusief alle gegevens verwijderen.", + "confirmDeletePrompt": "Ja, ik wil mijn account en de bijbehorende gegevens verspreid over alle apps permanent verwijderen.", "confirmAccountDeletion": "Account verwijderen bevestigen", "deleteAccountPermanentlyButton": "Account permanent verwijderen", "yourAccountHasBeenDeleted": "Je account is verwijderd", @@ -453,6 +453,8 @@ "showMemories": "Toon herinneringen", "yearsAgo": "{count, plural, one{{count} jaar geleden} other{{count} jaar geleden}}", "backupSettings": "Back-up instellingen", + "backupStatus": "Back-upstatus", + "backupStatusDescription": "Items die zijn geback-upt, worden hier getoond", "backupOverMobileData": "Back-up maken via mobiele data", "backupVideos": "Back-up video's", "disableAutoLock": "Automatisch vergrendelen uitschakelen", @@ -496,6 +498,7 @@ "removeDuplicates": "Duplicaten verwijderen", "removeDuplicatesDesc": "Controleer en verwijder bestanden die exacte kopieën zijn.", "viewLargeFiles": "Grote bestanden", + "viewLargeFilesDesc": "Bekijk bestanden die de meeste opslagruimte verbruiken.", "noDuplicates": "✨ Geen duplicaten", "youveNoDuplicateFilesThatCanBeCleared": "Je hebt geen dubbele bestanden die kunnen worden gewist", "success": "Succes", @@ -1313,5 +1316,6 @@ "cl_video_player_title": "Videospeler", "cl_video_player_description": "Een verfrissende nieuwe videospeler, met betere afspeelknoppen en ondersteuning voor HDR-video's.", "appLockDescriptions": "Kies tussen het standaard vergrendelscherm van uw apparaat en een aangepast vergrendelscherm met een pincode of wachtwoord.", - "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Om appvergrendeling in te schakelen, moet u een toegangscode of schermvergrendeling instellen in uw systeeminstellingen." + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Om appvergrendeling in te schakelen, moet u een toegangscode of schermvergrendeling instellen in uw systeeminstellingen.", + "authToViewPasskey": "Verifieer uzelf om uw toegangssleutel te bekijken" } \ No newline at end of file diff --git a/mobile/lib/l10n/intl_no.arb b/mobile/lib/l10n/intl_no.arb index 9bf690cd30..1319dca79c 100644 --- a/mobile/lib/l10n/intl_no.arb +++ b/mobile/lib/l10n/intl_no.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "Vi er lei oss for at du forlater oss. Gi oss gjerne en tilbakemelding så vi kan forbedre oss.", "feedback": "Tilbakemelding", "kindlyHelpUsWithThisInformation": "Vær vennlig og hjelp oss med denne informasjonen", - "confirmDeletePrompt": "Ja, jeg ønsker å slette denne kontoen og all dataen dens permanent.", "confirmAccountDeletion": "Bekreft sletting av konto", "deleteAccountPermanentlyButton": "Slett bruker for altid", "yourAccountHasBeenDeleted": "Brukeren din har blitt slettet", diff --git a/mobile/lib/l10n/intl_pl.arb b/mobile/lib/l10n/intl_pl.arb index 7be752f684..bbbaddce95 100644 --- a/mobile/lib/l10n/intl_pl.arb +++ b/mobile/lib/l10n/intl_pl.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Przykro nam, że odchodzisz. Wyjaśnij nam, dlaczego nas opuszczasz, aby pomóc ulepszać nasze usługi.", "feedback": "Opinia", "kindlyHelpUsWithThisInformation": "Pomóż nam z tą informacją", - "confirmDeletePrompt": "Tak, chcę trwale usunąć konto i wszystkie dane z nim powiązane.", + "confirmDeletePrompt": "Tak, chcę trwale usunąć to konto i jego dane ze wszystkich aplikacji.", "confirmAccountDeletion": "Potwierdź usunięcie konta", "deleteAccountPermanentlyButton": "Usuń konto na stałe", "yourAccountHasBeenDeleted": "Twoje konto zostało usunięte", @@ -453,6 +453,8 @@ "showMemories": "Pokaż wspomnienia", "yearsAgo": "{count, plural, one{{count} rok temu} few {{count} lata temu} many {{count} lat temu} other{{count} lata temu}}", "backupSettings": "Ustawienia kopii zapasowej", + "backupStatus": "Status kopii zapasowej", + "backupStatusDescription": "Elementy, których kopia zapasowa została utworzona, zostaną wyświetlone w tym miejscu", "backupOverMobileData": "Kopia zapasowa przez dane mobilne", "backupVideos": "Utwórz kopię zapasową wideo", "disableAutoLock": "Wyłącz automatyczną blokadę", diff --git a/mobile/lib/l10n/intl_pt.arb b/mobile/lib/l10n/intl_pt.arb index 0ca1ce69cd..630f8f63fa 100644 --- a/mobile/lib/l10n/intl_pt.arb +++ b/mobile/lib/l10n/intl_pt.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Lamentamos ver você partir. Por favor, compartilhe seus comentários para nos ajudar a melhorar.", "feedback": "Comentários", "kindlyHelpUsWithThisInformation": "Ajude-nos com esta informação", - "confirmDeletePrompt": "Sim, desejo excluir permanentemente esta conta e todos os seus dados.", + "confirmDeletePrompt": "Sim, eu quero excluir permanentemente esta conta e seus dados em todos os aplicativos.", "confirmAccountDeletion": "Confirmar exclusão da conta", "deleteAccountPermanentlyButton": "Excluir conta permanentemente", "yourAccountHasBeenDeleted": "Sua conta foi excluída", @@ -453,6 +453,8 @@ "showMemories": "Mostrar memórias", "yearsAgo": "{count, plural, one{{count} anos atrás} other{{count} anos atrás}}", "backupSettings": "Configurações de backup", + "backupStatus": "Status do Backup", + "backupStatusDescription": "Os itens que foram salvos no backup aparecerão aqui", "backupOverMobileData": "Backup usando dados móveis", "backupVideos": "Backup de vídeos", "disableAutoLock": "Desativar bloqueio automático", diff --git a/mobile/lib/l10n/intl_ru.arb b/mobile/lib/l10n/intl_ru.arb index 2bf2d4efc1..d646ec6803 100644 --- a/mobile/lib/l10n/intl_ru.arb +++ b/mobile/lib/l10n/intl_ru.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "Мы сожалеем, что вы уходите. Пожалуйста, объясните, почему вы уходите, чтобы помочь нам развиваться.", "feedback": "Отзыв", "kindlyHelpUsWithThisInformation": "Пожалуйста, помогите нам с этой информацией", - "confirmDeletePrompt": "Да, я хочу навсегда удалить эту учётную запись и все её данные.", + "confirmDeletePrompt": "Да, я хочу навсегда удалить эту учётную запись и все её данные во всех приложениях Ente.", "confirmAccountDeletion": "Подтвердить удаление учётной записи", "deleteAccountPermanentlyButton": "Удалить аккаунт навсегда", "yourAccountHasBeenDeleted": "Ваша учетная запись была удалена", @@ -128,7 +128,7 @@ } } }, - "twofactorSetup": "Установка двуфакторной аутентификации", + "twofactorSetup": "Вход с 2FA", "enterCode": "Введите код", "scanCode": "Сканировать код", "codeCopiedToClipboard": "Код скопирован в буфер обмена", @@ -275,6 +275,7 @@ "codeAppliedPageTitle": "Код применён", "changeYourReferralCode": "Изменить ваш реферальный код", "change": "Изменить", + "unavailableReferralCode": "Извините, такого кода не существует.", "storageInGB": "{storageAmountInGB} Гигабайт", "claimed": "Получено", "@claimed": { @@ -306,8 +307,8 @@ } } }, - "faq": "ЧаВо", - "help": "помощь", + "faq": "Ответы на ваши вопросы", + "help": "Помощь", "oopsSomethingWentWrong": "Ой! Что-то пошло не так", "peopleUsingYourCode": "Люди использующие ваш код", "eligible": "подходящий", @@ -411,7 +412,13 @@ "photoGridSize": "Размер сетки фотографий", "manageDeviceStorage": "Управление хранилищем устройства", "machineLearning": "Machine learning", + "mlConsent": "Включить машинное обучение", + "mlConsentTitle": "Включить машинное обучение?", + "mlConsentDescription": "Если вы включите машинное обучение, Ente будет извлекать информацию из файлов (например, геометрию лица), включая те, которыми с вами поделились.\n\nЭто будет происходить на вашем устройстве, и любая сгенерированная биометрическая информация будет зашифрована с использованием сквозного (End-to-End) шифрования между вашим устройством и сервером.", + "mlConsentPrivacy": "Пожалуйста, нажмите здесь, чтобы узнать больше об этой функции в нашей политике конфиденциальности", + "mlConsentConfirmation": "Я понимаю и хочу включить машинное обучение", "magicSearch": "Волшебный поиск", + "mlIndexingDescription": "Обратите внимание, что машинное обучение приведёт к повышенному потреблению трафика и батареи, пока все элементы не будут проиндексированы. Рекомендуем использовать ПК версию для более быстрого индексирования. Полученные результаты будут синхронизированы автоматически между устройствами.", "loadingModel": "Загрузка моделей...", "waitingForWifi": "Ожидание WiFi...", "status": "Статус", @@ -443,7 +450,7 @@ }, "showMemories": "Показать воспоминания", "yearsAgo": "{count, plural, one{{count} год назад} other{{count} лет назад}}", - "backupSettings": "Резервная копия настроек", + "backupSettings": "Настройки резервного копирования", "backupOverMobileData": "Резервное копирование через мобильную сеть", "backupVideos": "Резервное копирование видео", "disableAutoLock": "Отключить автоблокировку", @@ -461,8 +468,8 @@ "authToChangeYourEmail": "Пожалуйста, авторизуйтесь, чтобы изменить адрес электронной почты", "changePassword": "Изменить пароль", "authToChangeYourPassword": "Пожалуйста, авторизуйтесь, чтобы изменить пароль", - "emailVerificationToggle": "Подтверждение электронной почты", - "authToChangeEmailVerificationSetting": "Авторизуйтесь, чтобы изменить подтверждение электронной почты", + "emailVerificationToggle": "Вход с кодом на почту", + "authToChangeEmailVerificationSetting": "Пожалуйста, войдите, чтобы изменить настройку подтверждения электронной почты", "exportYourData": "Экспорт данных", "logout": "Выйти", "authToInitiateAccountDeletion": "Пожалуйста, авторизуйтесь, чтобы начать удаление аккаунта", @@ -1143,6 +1150,7 @@ "successfullyHid": "Успешно скрыто", "successfullyUnhid": "Успешно показано", "crashReporting": "Отчеты об ошибках", + "resumableUploads": "Поддержка дозагрузки файл(а/ов) при разрыве связи", "addToHiddenAlbum": "Добавить в скрытый альбом", "moveToHiddenAlbum": "Переместить в скрытый альбом", "fileTypes": "Типы файлов", @@ -1266,6 +1274,8 @@ "enable": "Включить", "enabled": "Включено", "moreDetails": "Подробнее", + "enableMLIndexingDesc": "Ente поддерживает машинное обучение на устройстве для распознавания лиц, умного поиска и других расширенных функций поиска", + "magicSearchHint": "Умный поиск позволяет искать фотографии по их содержимому, например, 'цветок', 'красная машина', 'паспорт', 'документы'", "panorama": "Панорама", "reenterPassword": "Подтвердите пароль", "reenterPin": "Введите PIN-код ещё раз", diff --git a/mobile/lib/l10n/intl_sv.arb b/mobile/lib/l10n/intl_sv.arb index 3c51f175e0..bfb0e0291a 100644 --- a/mobile/lib/l10n/intl_sv.arb +++ b/mobile/lib/l10n/intl_sv.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "Vi är ledsna att se dig lämna oss. Vänligen dela dina synpunkter för att hjälpa oss att förbättra.", "feedback": "Feedback", "kindlyHelpUsWithThisInformation": "Vänligen hjälp oss med denna information", - "confirmDeletePrompt": "Ja, jag vill ta bort detta konto och all data permanent.", "confirmAccountDeletion": "Bekräfta radering av konto", "deleteAccountPermanentlyButton": "Radera kontot permanent", "yourAccountHasBeenDeleted": "Ditt konto har raderats", @@ -281,6 +280,7 @@ "description": "Used to indicate storage claimed, like 10GB Claimed" }, "inviteYourFriends": "Bjud in dina vänner", + "help": "Hjälp", "subscribe": "Prenumerera", "trash": "Papperskorg", "photoSmallCase": "foto", diff --git a/mobile/lib/l10n/intl_ta.arb b/mobile/lib/l10n/intl_ta.arb new file mode 100644 index 0000000000..d3d26e203c --- /dev/null +++ b/mobile/lib/l10n/intl_ta.arb @@ -0,0 +1,19 @@ +{ + "@@locale ": "en", + "enterYourEmailAddress": "உங்கள் மின்னஞ்சல் முகவரியை உள்ளிடவும்", + "accountWelcomeBack": "மீண்டும் வருக!", + "email": "மின்னஞ்சல்", + "cancel": "ரத்து செய்", + "verify": "சரிபார்க்கவும்", + "invalidEmailAddress": "தவறான மின்னஞ்சல் முகவரி", + "enterValidEmail": "சரியான மின்னஞ்சல் முகவரியை உள்ளிடவும்.", + "deleteAccount": "கணக்கை நீக்கு", + "askDeleteReason": "உங்கள் கணக்கை நீக்குவதற்கான முக்கிய காரணம் என்ன?", + "deleteAccountFeedbackPrompt": "நீங்கள் வெளியேறுவதை கண்டு வருந்துகிறோம். எங்களை மேம்படுத்த உதவ உங்கள் கருத்தைப் பகிரவும்.", + "feedback": "பின்னூட்டம்", + "kindlyHelpUsWithThisInformation": "இந்த தகவலுடன் தயவுசெய்து எங்களுக்கு உதவுங்கள்", + "confirmDeletePrompt": "ஆம், எல்லா செயலிகளிலும் இந்தக் கணக்கையும் அதன் தரவையும் நிரந்தரமாக நீக்க விரும்புகிறேன்.", + "confirmAccountDeletion": "கணக்கு நீக்குதலை உறுதிப்படுத்தவும்", + "deleteAccountPermanentlyButton": "கணக்கை நிரந்தரமாக நீக்கவும்", + "deleteReason1": "எனக்கு தேவையான ஒரு முக்கிய அம்சம் இதில் இல்லை" +} \ No newline at end of file diff --git a/mobile/lib/l10n/intl_th.arb b/mobile/lib/l10n/intl_th.arb index 9d6b4c0801..3cce924a17 100644 --- a/mobile/lib/l10n/intl_th.arb +++ b/mobile/lib/l10n/intl_th.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "เราเสียใจที่เห็นคุณไป โปรดแบ่งปันความคิดเห็นของคุณเพื่อช่วยให้เราปรับปรุง", "feedback": "ความคิดเห็น", "kindlyHelpUsWithThisInformation": "กรุณาช่วยเราด้วยข้อมูลนี้", - "confirmDeletePrompt": "ใช่ ฉันต้องการลบบัญชีนี้และข้อมูลที่เกี่ยวข้องทั้งหมดแบบถาวร", "confirmAccountDeletion": "ยืนยันการลบบัญชี", "deleteAccountPermanentlyButton": "ลบบัญชีถาวร", "yourAccountHasBeenDeleted": "บัญชีของคุณถูกลบแล้ว", diff --git a/mobile/lib/l10n/intl_tr.arb b/mobile/lib/l10n/intl_tr.arb index a51913a120..1e20822143 100644 --- a/mobile/lib/l10n/intl_tr.arb +++ b/mobile/lib/l10n/intl_tr.arb @@ -12,7 +12,6 @@ "deleteAccountFeedbackPrompt": "Aramızdan ayrıldığınız için üzgünüz. Lütfen kendimizi geliştirmemize yardımcı olun. Neden ayrıldığınızı Açıklar mısınız.", "feedback": "Geri Bildirim", "kindlyHelpUsWithThisInformation": "Lütfen bu bilgilerle bize yardımcı olun", - "confirmDeletePrompt": "Evet, bu hesabı ve tüm verileri kalıcı olarak silmek istiyorum.", "confirmAccountDeletion": "Hesap silme işlemini onayla", "deleteAccountPermanentlyButton": "Hesabımı kalıcı olarak sil", "yourAccountHasBeenDeleted": "Hesabınız silindi", diff --git a/mobile/lib/l10n/intl_zh.arb b/mobile/lib/l10n/intl_zh.arb index abb3a223ae..20912fdc44 100644 --- a/mobile/lib/l10n/intl_zh.arb +++ b/mobile/lib/l10n/intl_zh.arb @@ -12,7 +12,7 @@ "deleteAccountFeedbackPrompt": "我们很抱歉看到您离开。请分享您的反馈以帮助我们改进。", "feedback": "反馈", "kindlyHelpUsWithThisInformation": "请帮助我们了解这个信息", - "confirmDeletePrompt": "是的,我想永久删除此账户及其相关数据.", + "confirmDeletePrompt": "是的,我想永久删除此账户及其所有关联的应用程序的数据。", "confirmAccountDeletion": "确认删除账户", "deleteAccountPermanentlyButton": "永久删除账户", "yourAccountHasBeenDeleted": "您的账户已删除", @@ -453,6 +453,8 @@ "showMemories": "显示回忆", "yearsAgo": "{count, plural, one{{count} 年前} other{{count} 年前}}", "backupSettings": "备份设置", + "backupStatus": "备份状态", + "backupStatusDescription": "已备份的项目将显示在此处", "backupOverMobileData": "通过移动数据备份", "backupVideos": "备份视频", "disableAutoLock": "禁用自动锁定", From 743fc4aa41125842cdac9291bfb60165757e12ed Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Mon, 2 Sep 2024 01:17:03 +0000 Subject: [PATCH 69/84] New Crowdin translations by GitHub Action --- auth/lib/l10n/arb/app_bg.arb | 128 ++++++++++++++++++++++++++++++++++- auth/lib/l10n/arb/app_de.arb | 29 +++++++- auth/lib/l10n/arb/app_el.arb | 5 ++ auth/lib/l10n/arb/app_fr.arb | 13 ++-- auth/lib/l10n/arb/app_sv.arb | 22 +++++- auth/lib/l10n/arb/app_ta.arb | 1 + auth/lib/l10n/arb/app_vi.arb | 42 ++++++++++-- auth/lib/l10n/arb/app_zh.arb | 56 +++++++-------- 8 files changed, 256 insertions(+), 40 deletions(-) create mode 100644 auth/lib/l10n/arb/app_ta.arb diff --git a/auth/lib/l10n/arb/app_bg.arb b/auth/lib/l10n/arb/app_bg.arb index 12730e7933..85b0440407 100644 --- a/auth/lib/l10n/arb/app_bg.arb +++ b/auth/lib/l10n/arb/app_bg.arb @@ -67,7 +67,7 @@ "pleaseWait": "Моля изчакайте...", "generatingEncryptionKeysTitle": "Генерират се ключове за шифроване...", "recreatePassword": "Създайте отново парола", - "recreatePasswordMessage": "Текущото устройство не е достатъчно мощно, за да потвърди паролата Ви, така че трябва да го генерираме отново веднъж по начин, който работи с всички устройства. \n\nВлезте с Вашия ключ за възстановяване и генерирайте отново паролата си (можете да използвате същата отново, ако желаете).", + "recreatePasswordMessage": "Текущото устройство не е достатъчно мощно, за да потвърди паролата Ви, така че трябва да го генерираме отново веднъж по начин, който работи с всички устройства. \n\nМоля, влезте с Вашия ключ за възстановяване и генерирайте отново паролата си (можете да използвате същата отново, ако желаете).", "useRecoveryKey": "Използвайте ключ за възстановяване", "incorrectPasswordTitle": "Грешна парола", "welcomeBack": "Добре дошли отново!", @@ -130,7 +130,61 @@ "faq_q_3": "Как мога да изтрия кодове?", "faq_a_3": "Можете да изтриете код, като плъзнете наляво върху него.", "faq_q_4": "Как мога да подкрепя този проект?", + "faq_a_4": "Можете да подкрепите развитието на този проект, като се абонирате за нашето приложение за снимки @ ente.io.", + "faq_q_5": "Как мога да активирам заключване чрез FaceID в Auth", + "faq_a_5": "Можете да активирате заключване чрез FaceID в Настройки → Сигурност → Заключен екран.", + "somethingWentWrongMessage": "Нещо се обърка, моля опитайте отново", + "leaveFamily": "Напуснете семейството", + "leaveFamilyMessage": "Сигурни ли сте, че искате да напуснете семейния план?", + "inFamilyPlanMessage": "Вие сте на семеен план!", + "swipeHint": "Плъзнете наляво, за да редактирате или премахнете кодове", "scan": "Сканиране", + "scanACode": "Скениране на код", + "verify": "Потвърждаване", + "verifyEmail": "Потвърдете имейла", + "enterCodeHint": "Въведете 6-цифрения код от\nВашето приложение за удостоверяване", + "lostDeviceTitle": "Загубено устройство?", + "twoFactorAuthTitle": "Двуфакторно удостоверяване", + "passkeyAuthTitle": "Удостоверяване с ключ за парола", + "verifyPasskey": "Потвърдете ключ за парола", + "recoverAccount": "Възстановяване на акаунт", + "enterRecoveryKeyHint": "Въведете Вашия ключ за възстановяване", + "recover": "Възстановяване", + "contactSupportViaEmailMessage": "Моля, изпратете имейл до {email} от Вашия регистриран имейл адрес", + "@contactSupportViaEmailMessage": { + "placeholders": { + "email": { + "type": "String" + } + } + }, + "invalidQRCode": "Невалиден QR код", + "noRecoveryKeyTitle": "Няма ключ за възстановяване?", + "enterEmailHint": "Въведете Вашият имейл адрес", + "invalidEmailTitle": "Невалиден имейл адрес", + "invalidEmailMessage": "Моля, въведете валиден имейл адрес.", + "deleteAccount": "Изтриване на акаунта", + "deleteAccountQuery": "Ще съжаляваме да си тръгнете. Изправени ли сте пред някакъв проблем?", + "yesSendFeedbackAction": "Да, изпращане на обратна връзка", + "noDeleteAccountAction": "Не, изтриване на акаунта", + "initiateAccountDeleteTitle": "Моля, удостоверете се, за да инициирате изтриването на акаунта", + "sendEmail": "Изпратете имейл", + "createNewAccount": "Създаване на нов акаунт", + "weakStrength": "Слаба", + "strongStrength": "Силна", + "moderateStrength": "Умерена", + "confirmPassword": "Потвърждаване на паролата", + "close": "Затваряне", + "oopsSomethingWentWrong": "Ами сега, нещо се обърка.", + "selectLanguage": "Изберете език", + "language": "Език", + "social": "Социални мрежи", + "security": "Сигурност", + "lockscreen": "Заключен екран", + "authToChangeLockscreenSetting": "Моля, удостоверете се, за да промените настройката за заключен екран", + "deviceLockEnablePreSteps": "За да активирате заключването на устройството, моля, задайте парола за устройството или заключване на екрана в системните настройки.", + "viewActiveSessions": "Вижте активните сесии", + "authToViewYourActiveSessions": "Моля, удостоверете се, за да видите Вашите активни сесии", "searchHint": "Търсене...", "search": "Търсене", "sorryUnableToGenCode": "За съжаление не може да се генерира код за {issuerName}", @@ -183,46 +237,118 @@ "insecureDevice": "Несигурно устройство", "sorryWeCouldNotGenerateSecureKeysOnThisDevicennplease": "За съжаление не можахме да генерираме защитени ключове на това устройство.\n\nМоля, регистрирайте се от друго устройство.", "howItWorks": "Как работи", + "ackPasswordLostWarning": "Разбирам, че ако загубя паролата си, може да загубя данните си, тъй като данните ми са шифровани от край до край.", + "loginTerms": "С натискането на вход, се съгласявам с условията за ползване и политиката за поверителност", + "logInLabel": "Вход", "logout": "Изход", "areYouSureYouWantToLogout": "Наистина ли искате да излезете от профила си?", "yesLogout": "Да, излез", "exit": "Изход", + "verifyingRecoveryKey": "Проверка на ключа за възстановяване...", + "recoveryKeyVerified": "Ключът за възстановяване е проверен", + "recoveryKeySuccessBody": "Страхотно! Вашият ключ за възстановяване е валиден. Благодарим Ви за проверката.\n\nМоля, не забравяйте да запазите безопасно архивирания си ключ за възстановяване.", + "invalidRecoveryKey": "Въведеният от Вас ключ за възстановяване не е валиден. Моля, уверете се, че съдържа 24 думи и проверете правописа на всяка.\n\nАко сте въвели по-стар код за възстановяване, уверете се, че е дълъг 64 знака и проверете всеки от тях.", + "recreatePasswordTitle": "Създайте отново парола", + "recreatePasswordBody": "Текущото устройство не е достатъчно мощно, за да потвърди паролата Ви, но можем да я регенерираме по начин, който работи с всички устройства.\n\nМоля, влезте с Вашия ключ за възстановяване и генерирайте отново паролата си (можете да използвате същата отново, ако желаете).", "invalidKey": "Невалиден ключ", "tryAgain": "Опитайте отново", + "viewRecoveryKey": "Вижте ключа за възстановяване", + "confirmRecoveryKey": "Потвърдете ключа за възстановяване", + "recoveryKeyVerifyReason": "Вашият ключ за възстановяване е единственият начин да възстановите Вашите снимки, ако забравите паролата си. Можете да намерите своя ключ за възстановяване в Настройки > Акаунт.\n\nМоля, въведете Вашия ключ за възстановяване тук, за да проверите дали сте го запазили правилно.", + "confirmYourRecoveryKey": "Потвърдете Вашия ключ за възстановяване", "confirm": "Потвърждаване", + "emailYourLogs": "Изпратете Вашата история на действията на имейл", + "pleaseSendTheLogsTo": "Моля, изпратете историята на действията на \n{toEmail}", + "copyEmailAddress": "Копиране на имейл адрес", + "exportLogs": "Експорт на файловете с историята", + "enterYourRecoveryKey": "Въведете Вашия ключ за възстановяване", + "tempErrorContactSupportIfPersists": "Изглежда нещо се обърка. Моля, опитайте отново след известно време. Ако грешката продължава, моля, свържете се с нашия екип за поддръжка.", + "networkHostLookUpErr": "Не може да се свърже с Ente, моля, проверете мрежовите си настройки и се свържете с поддръжката, ако проблемът продължава.", + "networkConnectionRefusedErr": "Не може да се свърже с Ente, моля, опитайте отново след известно време. Ако проблемът продължава, моля, свържете се с поддръжката.", + "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Изглежда нещо се обърка. Моля, опитайте отново след известно време. Ако грешката продължава, моля, свържете се с нашия екип за поддръжка.", "about": "Относно", + "weAreOpenSource": "Ние сме с отворен код!", "privacy": "Поверителност", "terms": "Условия", "checkForUpdates": "Провери за актуализации", "checkStatus": "Проверка на състоянието", "downloadUpdate": "Изтегляне", + "criticalUpdateAvailable": "Налична е критична актуализация", "updateAvailable": "Налична актуализация", "update": "Актуализиране", "checking": "Извършва се проверка...", + "youAreOnTheLatestVersion": "Вие сте с най-новата версия", "warning": "Предупреждение", + "exportWarningDesc": "Експортираният файл съдържа поверителна информация. Моля, съхранявайте го безопасно.", "iUnderStand": "Разбрах", "@iUnderStand": { "description": "Text for the button to confirm the user understands the warning" }, + "authToExportCodes": "Моля, удостоверете се, за да експортирате Вашите кодове", "importSuccessTitle": "Ура!", + "importSuccessDesc": "Импортирахте {count} кода!", + "@importSuccessDesc": { + "placeholders": { + "count": { + "description": "The number of codes imported", + "type": "int", + "example": "1" + } + } + }, "sorry": "Съжаляваме", + "importFailureDesc": "Неуспешен анализ на избрания файл.\nМоля, пишете на support@ente.io, ако имате нужда от помощ!", "pendingSyncs": "Предупреждение", + "pendingSyncsWarningBody": "Някои от вашите кодове не са архивирани.\n\nМоля, уверете се, че имате резервно копие на тези кодове, преди да излезете.", + "checkInboxAndSpamFolder": "Моля, проверете входящата си поща (и спама), за да завършите проверката", + "tapToEnterCode": "Докоснете, за да въведете код", "resendEmail": "Повторно изпращане на имейл", + "weHaveSendEmailTo": "Изпратихме имейл до {email}", + "@weHaveSendEmailTo": { + "description": "Text to indicate that we have sent a mail to the user", + "placeholders": { + "email": { + "description": "The email address of the user", + "type": "String", + "example": "example@ente.io" + } + } + }, "activeSessions": "Активни сесии", "somethingWentWrongPleaseTryAgain": "Нещо се обърка, моля опитайте отново", + "thisWillLogYouOutOfThisDevice": "Това ще Ви изкара от профила на това устройство!", + "thisWillLogYouOutOfTheFollowingDevice": "Това ще Ви изкара от профила на следното устройство:", + "terminateSession": "Прекратяване на сесията?", "terminate": "Прекратяване", "thisDevice": "Това устройство", + "toResetVerifyEmail": "За да нулирате паролата си, моля, първо потвърдете своя имейл.", "thisEmailIsAlreadyInUse": "Този имейл вече се използва", + "verificationFailedPleaseTryAgain": "Неуспешно проверка, моля опитайте отново", + "yourVerificationCodeHasExpired": "Вашият код за потвърждение е изтекъл", "incorrectCode": "Неправилен код", + "sorryTheCodeYouveEnteredIsIncorrect": "За съжаление кодът, който сте въвели, е неправилен", + "emailChangedTo": "Имейлът е променен на {newEmail}", "authenticationFailedPleaseTryAgain": "Неуспешно удостоверяване, моля опитайте отново", "authenticationSuccessful": "Успешно удостоверяване!", "twofactorAuthenticationSuccessfullyReset": "Двуфакторното удостоверяване бе успешно нулирано", + "incorrectRecoveryKey": "Неправилен ключ за възстановяване", + "theRecoveryKeyYouEnteredIsIncorrect": "Въведеният от Вас ключ за възстановяване е неправилен", "enterPassword": "Въведете парола", "selectExportFormat": "Изберете формат за експортиране", + "exportDialogDesc": "Шифрованите експорти ще бъдат защитени с парола по Ваш избор.", "encrypted": "Шифровано", "plainText": "Обикновен текст", "passwordToEncryptExport": "Парола за шифроване на експортирането", "export": "Експортиране", + "useOffline": "Използвайте без резервни копия", + "signInToBackup": "Влезте, за да архивирате Вашите кодове", + "singIn": "Вход", + "sigInBackupReminder": "Моля, експортирайте Вашите кодове, за да сте сигурни, че имате резервно копие, от което можете да ги възстановите.", + "offlineModeWarning": "Избрахте да продължите без резервни копия. Моля, направете ръчни резервни копия, за да сте сигурни, че Вашите кодове са в безопасност.", + "showLargeIcons": "Показване на големи икони", + "shouldHideCode": "Скриване на кодове", + "doubleTapToViewHiddenCode": "Можете да докоснете два пъти върху запис, за да видите кода", + "focusOnSearchBar": "Фокусиране на търсенето при стартиране на приложението", "confirmUpdatingkey": "Сигурни ли сте, че искате да актуализирате секретния ключ?", "minimizeAppOnCopy": "Минимизиране на приложението при копиране", "editCodeAuthMessage": "Удостоверете се, за да редактирате кода", diff --git a/auth/lib/l10n/arb/app_de.arb b/auth/lib/l10n/arb/app_de.arb index 4d67965b2c..217fe8f2ae 100644 --- a/auth/lib/l10n/arb/app_de.arb +++ b/auth/lib/l10n/arb/app_de.arb @@ -72,7 +72,7 @@ "incorrectPasswordTitle": "Falsches Passwort", "welcomeBack": "Willkommen zurück!", "madeWithLoveAtPrefix": "gemacht mit ❤️ bei ", - "supportDevs": "Bei ente registrieren um das Projekt zu unterstützen.", + "supportDevs": "Bei ente registrieren, um das Projekt zu unterstützen", "supportDiscount": "Benutze den Rabattcode \"AUTH\" für 10% Rabatt im ersten Jahr", "changeEmail": "E-Mail ändern", "changePassword": "Passwort ändern", @@ -182,6 +182,7 @@ "security": "Sicherheit", "lockscreen": "Sperrbildschirm", "authToChangeLockscreenSetting": "Bitte authentifizieren um die Einstellungen des Sperrbildschirms zu ändern", + "deviceLockEnablePreSteps": "Um die Gerätesperre zu aktivieren, richte bitte einen Gerätepasscode oder eine Bildschirmsperre in den Systemeinstellungen ein.", "viewActiveSessions": "Aktive Sitzungen anzeigen", "authToViewYourActiveSessions": "Bitte authentifizieren um, die aktiven Sitzungen zu sehen", "searchHint": "Suchen...", @@ -441,5 +442,29 @@ "deleteTagTitle": "Tag löschen?", "deleteTagMessage": "Sind Sie sicher, dass Sie diesen Code löschen wollen? Diese Aktion ist unumkehrbar.", "somethingWentWrongParsingCode": "Wir konnten {x} Codes nicht parsen.", - "updateNotAvailable": "Update ist nicht verfügbar" + "updateNotAvailable": "Update ist nicht verfügbar", + "viewRawCodes": "Rohcodes anzeigen", + "rawCodes": "Rohcodes", + "rawCodeData": "Rohcode Daten", + "appLock": "App-Sperre", + "noSystemLockFound": "Keine Systemsperre gefunden", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Um die App-Sperre zu aktivieren, konfiguriere bitte den Gerätepasscode oder die Bildschirmsperre in den Systemeinstellungen.", + "autoLock": "Automatisches Sperren", + "immediately": "Sofort", + "reEnterPassword": "Passwort erneut eingeben", + "reEnterPin": "PIN erneut eingeben", + "next": "Weiter", + "tooManyIncorrectAttempts": "Zu viele fehlerhafte Versuche", + "tapToUnlock": "Zum Entsperren antippen", + "setNewPassword": "Neues Passwort festlegen", + "deviceLock": "Gerätesperre", + "hideContent": "Inhalte verstecken", + "hideContentDescriptionAndroid": "Versteckt Inhalte der App beim Wechseln zwischen Apps und deaktiviert Screenshots", + "hideContentDescriptioniOS": "Versteckt Inhalte der App beim Wechseln zwischen Apps", + "autoLockFeatureDescription": "Zeit, nach der die App gesperrt wird, nachdem sie in den Hintergrund verschoben wurde", + "appLockDescription": "Wähle zwischen dem Standard-Sperrbildschirm deines Gerätes und einem eigenen Sperrbildschirm mit PIN oder Passwort.", + "pinLock": "PIN-Sperre", + "enterPin": "PIN eingeben", + "setNewPin": "Neue PIN festlegen", + "importFailureDescNew": "Die ausgewählte Datei konnte nicht verarbeitet werden." } \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_el.arb b/auth/lib/l10n/arb/app_el.arb index 0bbe9b9b81..e6f1d23b7c 100644 --- a/auth/lib/l10n/arb/app_el.arb +++ b/auth/lib/l10n/arb/app_el.arb @@ -182,6 +182,7 @@ "security": "Ασφάλεια", "lockscreen": "Οθόνη κλειδώματος", "authToChangeLockscreenSetting": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να αλλάξετε τις ρυθμίσεις οθόνης κλειδώματος", + "deviceLockEnablePreSteps": "Για να ενεργοποιήσετε το κλείδωμα της συσκευής, παρακαλώ ρυθμίστε τον κωδικό πρόσβασης της συσκευής ή το κλείδωμα οθόνης στις ρυθμίσεις του συστήματός σας.", "viewActiveSessions": "Προβολή ενεργών συνεδριών", "authToViewYourActiveSessions": "Παρακαλώ πραγματοποιήστε έλεγχο ταυτότητας για να δείτε τις ενεργές συνεδρίες σας", "searchHint": "Αναζήτηση...", @@ -458,6 +459,10 @@ "setNewPassword": "Ορίστε νέο κωδικό πρόσβασης", "deviceLock": "Κλείδωμα συσκευής", "hideContent": "Απόκρυψη περιεχομένου", + "hideContentDescriptionAndroid": "Απόκρυψη περιεχομένου εφαρμογής στην εναλλαγή εφαρμογών και απενεργοποίηση στιγμιότυπων οθόνης", + "hideContentDescriptioniOS": "Απόκρυψη περιεχομένου εφαρμογών στην εναλλαγή εφαρμογών", + "autoLockFeatureDescription": "Χρόνος μετά τον οποίο η εφαρμογή κλειδώνει μετά την τοποθέτηση στο παρασκήνιο", + "appLockDescription": "Επιλέξτε ανάμεσα στην προεπιλεγμένη οθόνη κλειδώματος της συσκευής σας και σε μια προσαρμοσμένη οθόνη κλειδώματος με ένα PIN ή έναν κωδικό πρόσβασης.", "pinLock": "Κλείδωμα καρφιτσωμάτων", "enterPin": "Εισαγωγή PIN", "setNewPin": "Ορίστε νέο PIN", diff --git a/auth/lib/l10n/arb/app_fr.arb b/auth/lib/l10n/arb/app_fr.arb index 1c411338ea..ec8ce6f86d 100644 --- a/auth/lib/l10n/arb/app_fr.arb +++ b/auth/lib/l10n/arb/app_fr.arb @@ -19,7 +19,7 @@ "pleaseVerifyDetails": "Veuillez vérifier vos informations et réessayez", "codeIssuerHint": "Émetteur", "codeSecretKeyHint": "Clé secrète", - "codeAccountHint": "Compte (vous@exemple.com)", + "codeAccountHint": "Compte (nom@exemple.com)", "codeTagHint": "Tag", "accountKeyType": "Type de clé", "sessionExpired": "Session expirée", @@ -118,7 +118,7 @@ "existingUser": "Utilisateur existant", "newUser": "Nouveau dans Ente", "delete": "Supprimer", - "enterYourPasswordHint": "Saisir votre mot de passe", + "enterYourPasswordHint": "Entrez votre mot de passe", "forgotPassword": "Mot de passe oublié", "oops": "Oups", "suggestFeatures": "Suggérer des fonctionnalités", @@ -135,14 +135,14 @@ "faq_a_5": "Vous pouvez activer le verrouillage FaceID dans Paramètres → Sécurité → Écran de verrouillage.", "somethingWentWrongMessage": "Quelque chose s'est mal passé, veuillez recommencer", "leaveFamily": "Quitter le plan familial", - "leaveFamilyMessage": "Êtes-vous certains de vouloir quitter le plan familial?", + "leaveFamilyMessage": "Êtes-vous sûr de vouloir quitter le plan familial ?", "inFamilyPlanMessage": "Vous êtes sur un plan familial !", "swipeHint": "Glisser vers la gauche pour modifier ou supprimer des codes", "scan": "Analyser", "scanACode": "Scanner un code", "verify": "Vérifier", "verifyEmail": "Vérifier l'e-mail", - "enterCodeHint": "Saisir le code à 6 caractères de votre appli d'authentification", + "enterCodeHint": "Entrez le code à 6 chiffres de votre application d'authentification", "lostDeviceTitle": "Appareil perdu ?", "twoFactorAuthTitle": "Authentification à deux facteurs", "passkeyAuthTitle": "Vérification du code d'accès", @@ -446,7 +446,9 @@ "viewRawCodes": "Afficher les codes bruts", "rawCodes": "Codes bruts", "rawCodeData": "Données de code brut", + "appLock": "Verrouillage d'application", "noSystemLockFound": "Aucun verrou système trouvé", + "toEnableAppLockPleaseSetupDevicePasscodeOrScreen": "Pour activer le verrouillage d'application, veuillez configurer un code d'accès ou le verrouillage de l'écran dans les paramètres de votre appareil.", "autoLock": "Verrouillage automatique", "immediately": "Immédiatement", "reEnterPassword": "Ressaisir le mot de passe", @@ -457,6 +459,9 @@ "setNewPassword": "Définir un nouveau mot de passe", "deviceLock": "Verrouillage de l'appareil", "hideContent": "Masquer le contenu", + "autoLockFeatureDescription": "Délai après lequel l'application se verrouille une fois qu'elle a été mise en arrière-plan", + "appLockDescription": "Choisissez entre l'écran de verrouillage par défaut de votre appareil et un écran de verrouillage par code PIN ou mot de passe personnalisé.", + "pinLock": "Verrouillage par code PIN", "enterPin": "Saisir le code PIN", "setNewPin": "Définir un nouveau code PIN", "importFailureDescNew": "Impossible de lire le fichier sélectionné." diff --git a/auth/lib/l10n/arb/app_sv.arb b/auth/lib/l10n/arb/app_sv.arb index e47c1b8c0e..e0c817c13c 100644 --- a/auth/lib/l10n/arb/app_sv.arb +++ b/auth/lib/l10n/arb/app_sv.arb @@ -20,10 +20,11 @@ "@sessionExpired": { "description": "Title of the dialog when the users current session is invalid/expired" }, - "pleaseLoginAgain": "Vänligen logga in igen", + "pleaseLoginAgain": "Logga in igen", "loggingOut": "Loggar ut...", "saveAction": "Spara", "nextTotpTitle": "nästa", + "deleteCodeTitle": "Radera kod?", "deleteCodeMessage": "Vill du ta bort den här koden? Det går inte att ångra den här åtgärden.", "viewLogsAction": "Visa loggar", "emailLogsTitle": "E-posta loggar", @@ -63,6 +64,7 @@ "importCodes": "Importera koder", "exportCodes": "Exportera koder", "importLabel": "Importera", + "ok": "OK", "cancel": "Avbryt", "yes": "Ja", "no": "Nej", @@ -109,8 +111,21 @@ "recoveryKeyOnForgotPassword": "Om du glömmer ditt lösenord är det enda sättet du kan återställa dina data med denna nyckel.", "saveKey": "Spara nyckel", "save": "Spara", + "send": "Skicka", "back": "Tillbaka", "createAccount": "Skapa konto", + "passwordStrength": "Lösenordsstyrka: {passwordStrengthValue}", + "@passwordStrength": { + "description": "Text to indicate the password strength", + "placeholders": { + "passwordStrengthValue": { + "description": "The strength of the password as a string", + "type": "String", + "example": "Weak or Moderate or Strong" + } + }, + "message": "Password Strength: {passwordStrengthText}" + }, "password": "Lösenord", "privacyPolicyTitle": "Integritetspolicy", "termsOfServicesTitle": "Villkor", @@ -151,6 +166,7 @@ "incorrectRecoveryKey": "Felaktig återställningsnyckel", "enterPassword": "Ange lösenord", "export": "Exportera", + "signInToBackup": "Logga in för att säkerhetskopiera dina koder", "singIn": "Logga in", "shouldHideCode": "Dölj koder", "androidCancelButton": "Avbryt", @@ -163,7 +179,11 @@ }, "noInternetConnection": "Ingen internetanslutning", "pleaseCheckYourInternetConnectionAndTryAgain": "Kontrollera din internetanslutning och försök igen.", + "signOutOtherDevices": "Logga ut andra enheter", "loginSessionExpiredDetails": "Din session har upphört. Logga in igen.", + "create": "Skapa", + "editTag": "Redigera tagg", + "deleteTagTitle": "Radera tagg?", "immediately": "Omedelbart", "reEnterPassword": "Ange lösenord igen", "reEnterPin": "Ange PIN-kod igen", diff --git a/auth/lib/l10n/arb/app_ta.arb b/auth/lib/l10n/arb/app_ta.arb new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/auth/lib/l10n/arb/app_ta.arb @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/auth/lib/l10n/arb/app_vi.arb b/auth/lib/l10n/arb/app_vi.arb index 672afa36c3..1c75de3109 100644 --- a/auth/lib/l10n/arb/app_vi.arb +++ b/auth/lib/l10n/arb/app_vi.arb @@ -21,12 +21,13 @@ "codeSecretKeyHint": "Khóa bí mật", "codeAccountHint": "Tài khoản (bạn@miền.com)", "codeTagHint": "Thẻ", + "accountKeyType": "Loại khóa", "sessionExpired": "Phiên làm việc đã hết hạn", "@sessionExpired": { "description": "Title of the dialog when the users current session is invalid/expired" }, "pleaseLoginAgain": "Vui lòng đăng nhập lại", - "loggingOut": "Đang thoát...", + "loggingOut": "Đang đăng xuất...", "timeBasedKeyType": "Dựa trên thời gian (TOTP)", "counterBasedKeyType": "Dựa trên bộ đếm (HOTP)", "saveAction": "Lưu", @@ -74,7 +75,7 @@ "supportDevs": "Đăng ký ente để hỗ trợ dự án này.", "supportDiscount": "Sử dụng mã giảm giá \"AUTH\" để được giảm 10% trong năm đầu tiên", "changeEmail": "Thay đổi email", - "changePassword": "Thay đổi mật khẩu", + "changePassword": "Đổi mật khẩu", "data": "Dữ liệu", "importCodes": "Nhập mã", "importTypePlainText": "Văn bản thuần", @@ -131,7 +132,7 @@ "faq_a_4": "Bạn có thể hỗ trợ sự phát triển của dự án này bằng cách đăng ký ứng dụng Ảnh @ ente.io của chúng tôi.", "faq_q_5": "Làm sao để tôi bật FaceID trong ente", "faq_a_5": "Bạn có thể bật khóa FaceID trong Cài đặt → Bảo mật → Màn hình khóa.", - "somethingWentWrongMessage": "Phát hiện có lỗi, xin thử lại", + "somethingWentWrongMessage": "Đã xảy ra lỗi, xin thử lại", "leaveFamily": "Rời khỏi gia đình", "leaveFamilyMessage": "Bạn có chắc chắn muốn thoát khỏi gói dành cho gia đình không?", "inFamilyPlanMessage": "Bạn đang sử dụng gói dành cho gia đình!", @@ -141,7 +142,7 @@ "verify": "Xác minh", "verifyEmail": "Xác nhận địa chỉ Email", "enterCodeHint": "Nhập mã gồm 6 chữ số từ ứng dụng xác thực của bạn", - "lostDeviceTitle": "Bạn đã mất thiết bị?", + "lostDeviceTitle": "Mất thiết bị?", "twoFactorAuthTitle": "Xác thực hai yếu tố", "recoverAccount": "Khôi phục tài khoản", "enterRecoveryKeyHint": "Nhập khóa khôi phục của bạn", @@ -154,6 +155,7 @@ } } }, + "invalidQRCode": "Mã QR không hợp lệ", "noRecoveryKeyTitle": "Không có khóa khôi phục?", "enterEmailHint": "Nhập địa chỉ email của bạn", "invalidEmailTitle": "Địa chỉ email không hợp lệ", @@ -177,6 +179,7 @@ "security": "Bảo mật", "lockscreen": "Màn hình khoá", "authToChangeLockscreenSetting": "Vui lòng xác thực để thay đổi cài đặt màn hình khóa", + "deviceLockEnablePreSteps": "Để bật khoá thiết bị, vui lòng thiết lập mật khẩu thiết bị hoặc khóa màn hình trong cài đặt hệ thống của bạn.", "viewActiveSessions": "Xem danh sách phiên làm việc hiện tại", "authToViewYourActiveSessions": "Vui lòng xác thực để xem danh sách phiên làm việc của bạn", "searchHint": "Tìm kiếm...", @@ -195,6 +198,8 @@ "recoveryKeySaveDescription": "Chúng tôi không lưu trữ khóa này, vui lòng lưu khóa 24 từ này ở nơi an toàn.", "doThisLater": "Để sau", "saveKey": "Lưu khóa", + "save": "Lưu", + "send": "Gửi", "back": "Quay lại", "createAccount": "Tạo tài khoản", "passwordStrength": "Độ mạnh mật khẩu: {passwordStrengthValue}", @@ -253,6 +258,8 @@ "exportLogs": "Xuất nhật ký", "enterYourRecoveryKey": "Nhập khóa khôi phục của bạn", "tempErrorContactSupportIfPersists": "Có vẻ như đã xảy ra sự cố. Vui lòng thử lại sau một thời gian. Nếu lỗi vẫn tiếp diễn, vui lòng liên hệ với nhóm hỗ trợ của chúng tôi.", + "networkHostLookUpErr": "Không thể kết nối đến Ente, vui lòng kiểm tra lại kết nối mạng. Nếu vẫn còn lỗi, xin vui lòng liên hệ hỗ trợ.", + "networkConnectionRefusedErr": "Không thể kết nối đến Ente, vui lòng thử lại sau. Nếu vẫn còn lỗi, xin vui lòng liên hệ hỗ trợ.", "itLooksLikeSomethingWentWrongPleaseRetryAfterSome": "Có vẻ như đã xảy ra sự cố. Vui lòng thử lại sau một thời gian. Nếu lỗi vẫn tiếp diễn, vui lòng liên hệ với nhóm hỗ trợ của chúng tôi.", "about": "Về chúng tôi", "weAreOpenSource": "Chúng tôi có mã nguồn mở!", @@ -342,6 +349,7 @@ "deleteCodeAuthMessage": "Xác minh để xóa mã", "showQRAuthMessage": "Xác minh để hiển thị mã QR", "confirmAccountDeleteTitle": "Xác nhận xóa tài khoản", + "confirmAccountDeleteMessage": "Tài khoản này được liên kết với các ứng dụng Ente trên các nền tảng khác, nếu bạn có sử dụng.\n\nDữ liệu đã tải lên của bạn, trên mọi nền tảng, sẽ bị lên lịch xóa và tài khoản của bạn sẽ bị xóa vĩnh viễn.", "androidBiometricHint": "Xác định danh tính", "@androidBiometricHint": { "description": "Hint message advising the user how to authenticate with biometrics. It is used on Android side. Maximum 60 characters." @@ -402,11 +410,37 @@ "doNotSignOut": "Không được đăng xuất", "hearUsWhereTitle": "Bạn biết đến Ente bằng cách nào? (không bắt buộc)", "hearUsExplanation": "Chúng tôi không theo dõi lượt cài đặt ứng dụng. Sẽ rất hữu ích nếu bạn cho chúng tôi biết nơi bạn tìm thấy chúng tôi!", + "recoveryKeySaved": "Đã lưu khoá dự phòng vào thư mục Tải về!", + "waitingForVerification": "Đang chờ xác thực", + "loginSessionExpired": "Phiên làm việc hết hạn", + "loginSessionExpiredDetails": "Phiên làm việc hết hạn. Vui lòng đăng nhập lại.", + "developerSettings": "Cài đặt cho nhà phát triển", + "customEndpoint": "Đã kết nối đến", + "pinText": "Ghim", + "unpinText": "Bỏ ghim", + "tags": "Thẻ", + "createNewTag": "Tạo thẻ mới", + "tag": "Thẻ", + "create": "Tạo", + "editTag": "Sửa thẻ", + "deleteTagTitle": "Xóa thẻ?", + "deleteTagMessage": "Bạn có chắc chắn muốn xóa thẻ này không? Hành động này không thể đảo ngược.", "updateNotAvailable": "Cập nhật không khả dụng", "viewRawCodes": "Xem mã nguồn", "rawCodes": "Mã nguồn", + "appLock": "Khóa ứng dụng", + "autoLock": "Tự động khóa", + "immediately": "Tức thì", + "reEnterPassword": "Nhập lại mật khẩu", + "reEnterPin": "Nhập lại mã PIN", + "next": "Tiếp", + "tooManyIncorrectAttempts": "Quá nhiều lần thử không chính xác", + "tapToUnlock": "Nhấn để mở khóa", "setNewPassword": "Đặt lại mật khẩu", "deviceLock": "Khóa thiết bị", + "hideContent": "Ẩn nội dung", + "hideContentDescriptionAndroid": "Ẩn nội dung khi chuyển ứng dụng và chặn chụp màn hình", + "hideContentDescriptioniOS": "Ẩn nội dung khi chuyển ứng dụng", "pinLock": "Mã PIN", "enterPin": "Nhập mã PIN", "setNewPin": "Đổi mã PIN" diff --git a/auth/lib/l10n/arb/app_zh.arb b/auth/lib/l10n/arb/app_zh.arb index b20bf2dd38..a62a7c13f3 100644 --- a/auth/lib/l10n/arb/app_zh.arb +++ b/auth/lib/l10n/arb/app_zh.arb @@ -27,15 +27,15 @@ "description": "Title of the dialog when the users current session is invalid/expired" }, "pleaseLoginAgain": "请重新登录", - "loggingOut": "正在退出登录...", - "timeBasedKeyType": "基于时间的 (TOTP)", - "counterBasedKeyType": "基于计数器的(HOTP)", + "loggingOut": "正在登出...", + "timeBasedKeyType": "基于时间 (TOTP)", + "counterBasedKeyType": "基于计数器 (HOTP)", "saveAction": "保存", "nextTotpTitle": "下一个", "deleteCodeTitle": "要删除代码吗?", - "deleteCodeMessage": "您确定要删除此代码吗?此操作是不可逆的。", + "deleteCodeMessage": "您确定要删除此代码吗?此操作不可逆。", "viewLogsAction": "查看日志", - "sendLogsDescription": "这将跨日志发送以帮助我们调试您的问题。 虽然我们采取预防措施以确保不记录敏感信息,但我们鼓励您在共享这些日志之前先查看它们。", + "sendLogsDescription": "这将发送日志以帮助我们调试您的问题。虽然我们采取预防措施确保不记录敏感信息,但我们建议您在共享这些日志之前先查看它们。", "preparingLogsTitle": "正在准备日志...", "emailLogsTitle": "电子邮件日志", "emailLogsMessage": "请将日志发送至 {email}", @@ -67,13 +67,13 @@ "pleaseWait": "请稍候...", "generatingEncryptionKeysTitle": "正在生成加密密钥...", "recreatePassword": "重新创建密码", - "recreatePasswordMessage": "当前设备的强度不足以验证您的密码, 所以我们需要以一种能够与所有设备一起运行的方式重新生成它。 \n\n请使用您的恢复密钥登录并重新生成您的密码 (如果您愿意,您可以再次使用相同密钥)。", + "recreatePasswordMessage": "当前设备的功能不足以验证您的密码,因此我们需要以一种适用于所有设备的方式重新生成一次密码。\n\n请使用您的恢复密钥登录并重新生成您的密码(如果您愿意,可以再次使用相同的密码)。", "useRecoveryKey": "使用恢复密钥", "incorrectPasswordTitle": "密码错误", "welcomeBack": "欢迎回来!", "madeWithLoveAtPrefix": "用❤️制成 ", "supportDevs": "订阅 ente 以支持此项目。", - "supportDiscount": "使用优惠券号码“AUTH”获得第一年优惠10%的折扣", + "supportDiscount": "使用优惠码“AUTH”可享受首年 10% 折扣", "changeEmail": "修改邮箱", "changePassword": "修改密码", "data": "数据", @@ -83,29 +83,29 @@ "passwordForDecryptingExport": "用来解密导出的密码", "passwordEmptyError": "密码不能为空", "importFromApp": "从 {appName} 导入代码", - "importGoogleAuthGuide": "使用“转移帐户”选项将您的帐户从 Google 身份验证器导出到二维码。然后使用另一台设备扫描二维码。\n\n提示:您可以使用笔记本电脑的网络摄像头拍摄二维码的照片。", + "importGoogleAuthGuide": "使用“转移账户”选项将您的账户从 Google Authenticator 导出到二维码。然后使用另一台设备扫描二维码。\n\n提示:您可以使用笔记本电脑的摄像头拍摄二维码的照片。", "importSelectJsonFile": "选择 JSON 文件", "importSelectAppExport": "选择 {appName} 的导出文件", "importEnteEncGuide": "选择从 Ente 导出的 JSON 加密文件", "importRaivoGuide": "使用 Raivo 设置中的“将 OTP 导出到 Zip 存档”选项。\n\n解压 zip 文件并导入 JSON 文件。", - "importBitwardenGuide": "使用 Bitwarden 工具中的“导出保管库”选项并导入未加密的 JSON 文件。", - "importAegisGuide": "在Aegis的设置中使用\"导出密码库\"选项。\n\n如果您的密码库已加密,您需要输入密码才能解密密码库。", - "import2FasGuide": "使用 2FAS 中的“设置 -> 备份 - 导出”选项。\n\n如果您的备份已被加密,则需要输入密码才能解密备份", + "importBitwardenGuide": "使用 Bitwarden 工具中的“导出密码库”选项并导入未加密的 JSON 文件。", + "importAegisGuide": "使用 Aegis 设置中的“导出密码库”选项。\n\n如果您的密码库已加密,则需要输入密码库密码才能解密密码库。", + "import2FasGuide": "使用 2FAS 中的“设置 -> 备份 -> 导出”选项。\n\n如果您的备份已加密,则需要输入密码来解密备份", "importLastpassGuide": "使用 Lastpass Authenticator 设置中的“转移账户”选项,然后按“将账户导出到文件”。导入下载的 JSON。", "exportCodes": "导出代码", "importLabel": "导入", - "importInstruction": "请以以下格式选择包含代码列表的文件", + "importInstruction": "请选择一个包含以下格式的代码列表的文件", "importCodeDelimiterInfo": "代码可以用逗号或新行分隔。", "selectFile": "选择文件", "emailVerificationToggle": "电子邮件验证", - "emailVerificationEnableWarning": "如果您将 2FA 存储到我们的电子邮件中,则打开电子邮件验证可能会导致僵局。如果您被一项服务锁定,您可能无法登录另一项服务。", + "emailVerificationEnableWarning": "为避免被锁在您的账户之外,请在启用电子邮件验证之前确保在 Ente Auth 之外存储电子邮件双重验证的副本。", "authToChangeEmailVerificationSetting": "请进行身份验证以更改电子邮件验证", "authToViewYourRecoveryKey": "请验证以查看您的恢复密钥", "authToChangeYourEmail": "请验证以更改您的电子邮件", "authToChangeYourPassword": "请验证以更改密码", - "authToViewSecrets": "请进行身份验证以查看您的秘密", + "authToViewSecrets": "请进行身份验证以查看您的密钥", "authToInitiateSignIn": "请进行身份验证以启动登录进行备份。", - "ok": "好的", + "ok": "确定", "cancel": "取消", "yes": "是", "no": "否", @@ -125,8 +125,8 @@ "faq": "常见问题", "faq_q_1": "Auth 的安全性如何?", "faq_a_1": "您通过 Auth 备份的所有代码均以端到端加密方式存储。这意味着只有您可以访问您的代码。我们的应用程序是开源的并且我们的加密技术已经过外部审计。", - "faq_q_2": "我可以在桌面上访问我的代码吗?", - "faq_a_2": "您可以在 web @auth.ente.io 上访问您的代码。", + "faq_q_2": "我可以在桌面设备上访问我的代码吗?", + "faq_a_2": "您可以在网页 auth.ente.io 上访问您的代码。", "faq_q_3": "我如何删除代码?", "faq_a_3": "您可以通过向左滑动该项目来删除该代码。", "faq_q_4": "我该如何支持该项目?", @@ -240,9 +240,9 @@ "ackPasswordLostWarning": "我明白,如果我丢失密码,我可能会丢失我的数据,因为我的数据是 端到端加密的。", "loginTerms": "点击登录后,我同意 服务条款隐私政策", "logInLabel": "登录", - "logout": "退出登录", - "areYouSureYouWantToLogout": "您确定要退出登录吗?", - "yesLogout": "是的,退出登陆", + "logout": "登出", + "areYouSureYouWantToLogout": "您确定要登出吗?", + "yesLogout": "是的,登出", "exit": "退出", "verifyingRecoveryKey": "正在验证恢复密钥...", "recoveryKeyVerified": "恢复密钥已验证", @@ -299,7 +299,7 @@ "sorry": "抱歉", "importFailureDesc": "无法解析选定的文件。\n如果您需要帮助,请写入support@ente.io!", "pendingSyncs": "警告", - "pendingSyncsWarningBody": "您的一些代码尚未备份。\n\n请确保您在退出登录之前备份这些代码。", + "pendingSyncsWarningBody": "您的一些代码尚未备份。\n\n请确保您在登出之前备份这些代码。", "checkInboxAndSpamFolder": "请检查您的收件箱 (或者是在您的“垃圾邮件”列表内) 以完成验证", "tapToEnterCode": "点击以输入代码", "resendEmail": "重新发送电子邮件", @@ -316,8 +316,8 @@ }, "activeSessions": "已登录的设备", "somethingWentWrongPleaseTryAgain": "出了点问题,请重试", - "thisWillLogYouOutOfThisDevice": "这将使您在此设备上退出登录!", - "thisWillLogYouOutOfTheFollowingDevice": "这将使您在以下设备中退出登录:", + "thisWillLogYouOutOfThisDevice": "这将使您登出该设备!", + "thisWillLogYouOutOfTheFollowingDevice": "这将使您登出以下设备:", "terminateSession": "是否终止会话?", "terminate": "终止", "thisDevice": "此设备", @@ -396,7 +396,7 @@ "@androidGoToSettingsDescription": { "description": "Message advising the user to go to the settings and configure biometric on their device. It shows in a dialog on Android side." }, - "iOSLockOut": "生物识别身份验证已禁用。请锁定再解锁您的屏幕以启用它。", + "iOSLockOut": "生物识别身份验证已禁用。请锁定并解锁屏幕以启用该功能。", "@iOSLockOut": { "description": "Message advising the user to re-enable biometrics on their device. It shows in a dialog on iOS side." }, @@ -410,11 +410,11 @@ }, "noInternetConnection": "无互联网连接", "pleaseCheckYourInternetConnectionAndTryAgain": "请检查您的互联网连接,然后重试。", - "signOutFromOtherDevices": "从其他设备退出登录", - "signOutOtherBody": "如果你认为有人可能知道你的密码,你可以强制所有使用你账户的其他设备退出登录。", + "signOutFromOtherDevices": "从其他设备登出", + "signOutOtherBody": "如果您认为有人可能知道您的密码,您可以强制所有其他使用您账户的设备登出。", "signOutOtherDevices": "登出其他设备", - "doNotSignOut": "不要退登", - "hearUsWhereTitle": "您是如何知道Ente的? (可选的)", + "doNotSignOut": "不要登出", + "hearUsWhereTitle": "您是怎么知道 Ente 的?(可选)", "hearUsExplanation": "我们不跟踪应用程序安装情况。如果您告诉我们您是在哪里找到我们的,将会有所帮助!", "recoveryKeySaved": "恢复密钥已保存在下载文件夹中!", "waitingForBrowserRequest": "正在等待浏览器请求...", From 1197e11f58fc17e5b2b35870efba3101f16eebbe Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 18:18:04 +0200 Subject: [PATCH 70/84] Remove unnecessary slug fields --- .../custom-icons/_data/custom-icons.json | 56 ++++++------------- 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index 0918925ace..77e7f20b01 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -70,19 +70,16 @@ "blockchain.com", "blockchain.com Wallet", "blockchain.com Exchange" - ], - "slug": "blockchain" + ] }, { "title": "BorgBase", "altNames": [ "borg" - ], - "slug": "BorgBase" + ] }, { "title": "Booking", - "slug": "booking", "altNames":[ "Booking.com" ] @@ -117,8 +114,7 @@ "title": "CloudAMQP" }, { - "title": "ConfigCat", - "slug": "configcat" + "title": "ConfigCat" }, { "title": "CoinDCX" @@ -147,8 +143,7 @@ "title": "DCS", "altNames": [ "Digital Combat Simulator" - ], - "slug": "dcs" + ] }, { "title": "DEGIRO" @@ -240,16 +235,13 @@ "title": "HuggingFace", "altNames": [ "Hugging Face" - ], - "slug": "huggingface" + ] }, { - "title": "IceDrive", - "slug": "Icedrive" + "title": "IceDrive" }, { - "titile": "Infomaniak", - "slug": "infomaniak" + "titile": "Infomaniak" }, { "title": "ING" @@ -270,8 +262,7 @@ "hex": "000000" }, { - "title": "IVPN", - "slug": "IVPN" + "title": "IVPN" }, { "title": "Jagex", @@ -333,7 +324,6 @@ "mathstodon", "fosstodon" ], - "slug": "mastodon", "hex": "6364FF" }, { @@ -420,8 +410,7 @@ "title": "Notion" }, { - "title": "NuCommunity", - "slug": "nucommunity" + "title": "NuCommunity" }, { "title": "NVIDIA" @@ -447,8 +436,7 @@ "title": "PayPal" }, { - "title": "pCloud", - "slug": "pCloud" + "title": "pCloud" }, { "title": "Peerberry", @@ -495,7 +483,6 @@ "title": "Registro br", "slug": "registro_br", "altNames": [ - "Registro br", "registrobr", "Registro.br" ] @@ -551,8 +538,7 @@ ] }, { - "title": "SMTP2GO", - "slug": "smtp2go" + "title": "SMTP2GO" }, { "title": "Snapchat" @@ -571,7 +557,6 @@ }, { "title": "TCPShield", - "slug": "tcpshield", "hex": "FFFFFF" }, { @@ -597,7 +582,6 @@ }, { "title": "Trading 212", - "slug": "trading212", "hex": "4BA4DE" }, { @@ -636,7 +620,6 @@ }, { "title": "Uphold", - "slug": "uphold", "hex": "6FE68A" }, { @@ -660,12 +643,10 @@ "title": "Wise" }, { - "title": "WYZE", - "slug": "wyze" + "title": "WYZE" }, { "title": "WorkOS", - "slug": "workos", "altNames": [ "Work OS" ] @@ -675,8 +656,7 @@ "altNames": [ "Ya", "Яндекс" - ], - "slug": "Yandex" + ] }, { "title": "yahoo" @@ -686,21 +666,17 @@ "altNames": [ "You Need A Budget" ], - "slug": "ynab", "hex": "3B5EDA" }, { - "title": "Shakepay", - "slug": "shakepay" + "title": "Shakepay" }, { "title": "Newton", - "altNames": ["Newton Crypto"], - "slug": "newton" + "altNames": ["Newton Crypto"] }, { - "title": "RippleMatch", - "slug": "ripplematch" + "title": "RippleMatch" }, { "title": "T-Mobile ID", From a13256cf3968a488b7be4bc8a8d536a08c6afdd7 Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 18:19:00 +0200 Subject: [PATCH 71/84] Remove unnecessary slug fields --- auth/assets/custom-icons/_data/custom-icons.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index 77e7f20b01..a3b49db55d 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -176,8 +176,8 @@ "hex": "1DB954" }, { - "title": "enom" - }, + "title": "enom" + }, { "title": "Epic Games", "slug": "epic_games", @@ -686,8 +686,7 @@ "slug": "t-mobile" }, { - "title": "Wealthfront", - "slug": "wealthfront" + "title": "Wealthfront" }, { "title": "BinanceUS", From dbde6abc8c58608ef01463c14e8c46d2f41c4b2f Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 18:22:12 +0200 Subject: [PATCH 72/84] Refactor and clean code --- .../custom-icons/_data/custom-icons.json | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index a3b49db55d..cbf3dc6b60 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -31,12 +31,12 @@ "title": "bitget" }, { - "titile":"bitget wallet", - "slug":"bitget_wallet" + "titile": "bitget wallet", + "slug": "bitget_wallet" }, { "title": "Bitmart", - "hex":"000000" + "hex": "000000" }, { "title": "BitMEX" @@ -80,14 +80,14 @@ }, { "title": "Booking", - "altNames":[ + "altNames": [ "Booking.com" ] }, { "title": "Brave Creators", "slug": "brave_creators", - "altNames":[ + "altNames": [ "Brave", "Brave Rewards", "Brave Browser" @@ -168,8 +168,8 @@ "slug": "dusnet" }, { - "title":"ecitizen kenya", - "slug":"ecitizen_kenya" + "title": "ecitizen kenya", + "slug": "ecitizen_kenya" }, { "title": "ente", @@ -213,10 +213,10 @@ }, { "title": "Gosuslugi", + "slug": "Gosuslugi", "altNames": [ "Госуслуги" - ], - "slug": "Gosuslugi" + ] }, { "title": "Habbo" @@ -355,11 +355,10 @@ "title": "Mozilla" }, { - "title": "Murena", + "title": "ecloud", "altNames": [ - "eCloud" - ], - "slug": "ecloud" + "Murena" + ] }, { "title": "MyFRITZ!Net", @@ -421,14 +420,15 @@ { "titile": "OpenObserve", "slug": "open_observe", - "altNames":[ + "altNames": [ "openobserve.ai", "openobserve ai" ] }, { "title": "okx", - "hex": "000000" }, + "hex": "000000" + }, { "title": "Parsec" }, @@ -673,32 +673,35 @@ }, { "title": "Newton", - "altNames": ["Newton Crypto"] + "altNames": [ + "Newton Crypto" + ] }, { "title": "RippleMatch" }, { - "title": "T-Mobile ID", + "title": "T-Mobile", "altNames": [ - "T-Mobile" - ], - "slug": "t-mobile" + "T-Mobile ID" + ] }, { "title": "Wealthfront" }, { "title": "BinanceUS", + "slug": "binance_us", "altNames": [ "Binance US" - ], - "slug": "binance_us" + ] }, { "title": "Bethesda Softworks", - "altNames": ["Bethesda"], - "slug": "bethesda" + "slug": "bethesda", + "altNames": [ + "Bethesda" + ] } ] } From b1e727f269e4f998af14da7363c984be929ea70c Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 18:24:51 +0200 Subject: [PATCH 73/84] Refactor --- auth/assets/custom-icons/_data/custom-icons.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index cbf3dc6b60..d4b5de23a8 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -697,10 +697,9 @@ ] }, { - "title": "Bethesda Softworks", - "slug": "bethesda", + "title": "Bethesda", "altNames": [ - "Bethesda" + "Bethesda Softworks" ] } ] From a1742f71e0c5f6b275566d923905cafea7302a16 Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 18:32:11 +0200 Subject: [PATCH 74/84] Fix typo --- auth/assets/custom-icons/_data/custom-icons.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index d4b5de23a8..ea9d05a0f3 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -31,7 +31,7 @@ "title": "bitget" }, { - "titile": "bitget wallet", + "title": "bitget wallet", "slug": "bitget_wallet" }, { @@ -241,7 +241,7 @@ "title": "IceDrive" }, { - "titile": "Infomaniak" + "title": "Infomaniak" }, { "title": "ING" @@ -418,7 +418,7 @@ "title": "Odido" }, { - "titile": "OpenObserve", + "title": "OpenObserve", "slug": "open_observe", "altNames": [ "openobserve.ai", @@ -626,7 +626,7 @@ "title": "Upstox" }, { - "titile": "Vikunja", + "title": "Vikunja", "slug": "vikunja" }, { From 0a7a8e49fed329879134badda97d83338d301e85 Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 18:32:51 +0200 Subject: [PATCH 75/84] Reorder alphabetically --- .../custom-icons/_data/custom-icons.json | 104 +++++++++--------- 1 file changed, 49 insertions(+), 55 deletions(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index ea9d05a0f3..f80c4f0551 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -24,6 +24,19 @@ { "title": "AscendEX" }, + { + "title": "Bethesda", + "altNames": [ + "Bethesda Softworks" + ] + }, + { + "title": "BinanceUS", + "slug": "binance_us", + "altNames": [ + "Binance US" + ] + }, { "title": "Bitfinex" }, @@ -31,7 +44,7 @@ "title": "bitget" }, { - "title": "bitget wallet", + "titile": "bitget wallet", "slug": "bitget_wallet" }, { @@ -107,14 +120,11 @@ "slug": "cih", "hex": "D14633" }, - { - "title": "Cloudflare" - }, { "title": "CloudAMQP" }, { - "title": "ConfigCat" + "title": "Cloudflare" }, { "title": "CoinDCX" @@ -122,6 +132,9 @@ { "title": "ConfigCat" }, + { + "title": "ConfigCat" + }, { "title": "Control D", "slug": "controld", @@ -171,6 +184,12 @@ "title": "ecitizen kenya", "slug": "ecitizen_kenya" }, + { + "title": "ecloud", + "altNames": [ + "Murena" + ] + }, { "title": "ente", "hex": "1DB954" @@ -241,7 +260,7 @@ "title": "IceDrive" }, { - "title": "Infomaniak" + "titile": "Infomaniak" }, { "title": "ING" @@ -354,12 +373,6 @@ { "title": "Mozilla" }, - { - "title": "ecloud", - "altNames": [ - "Murena" - ] - }, { "title": "MyFRITZ!Net", "slug": "myfritz", @@ -395,6 +408,12 @@ { "title": "NextDNS" }, + { + "title": "Newton", + "altNames": [ + "Newton Crypto" + ] + }, { "title": "ngrok", "hex": "858585" @@ -418,7 +437,7 @@ "title": "Odido" }, { - "title": "OpenObserve", + "titile": "OpenObserve", "slug": "open_observe", "altNames": [ "openobserve.ai", @@ -494,6 +513,9 @@ "title": "Revolt", "hex": "858585" }, + { + "title": "RippleMatch" + }, { "title": "Rockstar Games", "slug": "rockstar_games" @@ -516,6 +538,9 @@ { "title": "service-bw" }, + { + "title": "Shakepay" + }, { "title": "SimpleLogin" }, @@ -626,9 +651,12 @@ "title": "Upstox" }, { - "title": "Vikunja", + "titile": "Vikunja", "slug": "vikunja" }, + { + "title": "Wealthfront" + }, { "title": "Wealthsimple" }, @@ -642,15 +670,18 @@ { "title": "Wise" }, - { - "title": "WYZE" - }, { "title": "WorkOS", "altNames": [ "Work OS" ] }, + { + "title": "WYZE" + }, + { + "title": "yahoo" + }, { "title": "Yandex", "altNames": [ @@ -658,49 +689,12 @@ "Яндекс" ] }, - { - "title": "yahoo" - }, { "title": "YNAB", "altNames": [ "You Need A Budget" ], "hex": "3B5EDA" - }, - { - "title": "Shakepay" - }, - { - "title": "Newton", - "altNames": [ - "Newton Crypto" - ] - }, - { - "title": "RippleMatch" - }, - { - "title": "T-Mobile", - "altNames": [ - "T-Mobile ID" - ] - }, - { - "title": "Wealthfront" - }, - { - "title": "BinanceUS", - "slug": "binance_us", - "altNames": [ - "Binance US" - ] - }, - { - "title": "Bethesda", - "altNames": [ - "Bethesda Softworks" - ] } ] -} +} \ No newline at end of file From 05cf33ffb2caf9c1d76920e3159dac3a4737f1d5 Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 18:50:21 +0200 Subject: [PATCH 76/84] Remove unnecessary hex fields --- .../custom-icons/_data/custom-icons.json | 42 +++++++------------ auth/assets/custom-icons/icons/kucoin.svg | 2 +- auth/assets/custom-icons/icons/postnl.svg | 2 +- 3 files changed, 16 insertions(+), 30 deletions(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index f80c4f0551..5c04d71028 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -64,8 +64,7 @@ "title": "Bitstamp" }, { - "title": "Bitvavo", - "hex": "0051FF" + "title": "Bitvavo" }, { "title": "Bitwarden" @@ -137,8 +136,7 @@ }, { "title": "Control D", - "slug": "controld", - "hex": "5FD800" + "slug": "controld" }, { "title": "Crowdpear" @@ -291,8 +289,7 @@ "title": "Kagi" }, { - "title": "Kick", - "hex": "53FC19" + "title": "Kick" }, { "title": "Kite" @@ -305,15 +302,13 @@ "color": "00CC00" }, { - "title": "Kraken", - "hex": "5848D5" + "title": "Kraken" }, { "title": "Kronos" }, { - "title": "KuCoin", - "hex": "01BC8D" + "title": "KuCoin" }, { "title": "La Poste", @@ -458,12 +453,10 @@ "title": "pCloud" }, { - "title": "Peerberry", - "hex": "03E5A5" + "title": "Peerberry" }, { - "title": "Pingvin Share", - "hex": "485099" + "title": "Pingvin Share" }, { "title": "Plutus", @@ -473,12 +466,10 @@ "title": "Poloniex" }, { - "title": "Porkbun", - "hex": "F27777" + "title": "Porkbun" }, { - "title": "PostNL", - "color": "EF8300" + "title": "PostNL" }, { "title": "Privacy Guides", @@ -521,8 +512,7 @@ "slug": "rockstar_games" }, { - "title": "RuneMate", - "hex": "2ECC71" + "title": "RuneMate" }, { "title": "Rust Language Forum", @@ -570,8 +560,7 @@ }, { "title": "Standard Notes", - "slug": "standardnotes", - "hex": "2173E6" + "slug": "standardnotes" }, { "title": "Surfshark" @@ -606,8 +595,7 @@ "title": "TorGuard" }, { - "title": "Trading 212", - "hex": "4BA4DE" + "title": "Trading 212" }, { "title": "TradingView" @@ -644,8 +632,7 @@ "hex": "858585" }, { - "title": "Uphold", - "hex": "6FE68A" + "title": "Uphold" }, { "title": "Upstox" @@ -693,8 +680,7 @@ "title": "YNAB", "altNames": [ "You Need A Budget" - ], - "hex": "3B5EDA" + ] } ] } \ No newline at end of file diff --git a/auth/assets/custom-icons/icons/kucoin.svg b/auth/assets/custom-icons/icons/kucoin.svg index 1b67b54717..d51a1660d8 100644 --- a/auth/assets/custom-icons/icons/kucoin.svg +++ b/auth/assets/custom-icons/icons/kucoin.svg @@ -1 +1 @@ - + diff --git a/auth/assets/custom-icons/icons/postnl.svg b/auth/assets/custom-icons/icons/postnl.svg index 3aa9415188..8ab3ff6784 100644 --- a/auth/assets/custom-icons/icons/postnl.svg +++ b/auth/assets/custom-icons/icons/postnl.svg @@ -1 +1 @@ - + From dc120a06ca8b80e40fc5bab021af95b6978eae62 Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 18:51:22 +0200 Subject: [PATCH 77/84] Refactor --- auth/assets/custom-icons/_data/custom-icons.json | 1 - 1 file changed, 1 deletion(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index 5c04d71028..5989c5bb00 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -146,7 +146,6 @@ "slug": "crypto", "altNames": [ "crypto", - "Crypto.com", "Crypto com" ] }, From e16fa2dc3193ecc3d618ba20640ac8e61fa8bd62 Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 18:52:07 +0200 Subject: [PATCH 78/84] Fix typo --- auth/assets/custom-icons/_data/custom-icons.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index 5989c5bb00..842afc6dab 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -44,7 +44,7 @@ "title": "bitget" }, { - "titile": "bitget wallet", + "title": "bitget wallet", "slug": "bitget_wallet" }, { @@ -257,7 +257,7 @@ "title": "IceDrive" }, { - "titile": "Infomaniak" + "title": "Infomaniak" }, { "title": "ING" @@ -431,7 +431,7 @@ "title": "Odido" }, { - "titile": "OpenObserve", + "title": "OpenObserve", "slug": "open_observe", "altNames": [ "openobserve.ai", @@ -637,8 +637,7 @@ "title": "Upstox" }, { - "titile": "Vikunja", - "slug": "vikunja" + "title": "Vikunja" }, { "title": "Wealthfront" From dca50a4e458f2a5ad59038f1cd6f7d61fdb1c7e2 Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 18:54:08 +0200 Subject: [PATCH 79/84] Refactor --- auth/assets/custom-icons/_data/custom-icons.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index 842afc6dab..d1d90ff4f0 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -131,9 +131,6 @@ { "title": "ConfigCat" }, - { - "title": "ConfigCat" - }, { "title": "Control D", "slug": "controld" From 05bcfdc16e1c3fc18aaf83a5eeafa09c01180ab7 Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 19:17:21 +0200 Subject: [PATCH 80/84] Fix TCPShield icon --- .../custom-icons/_data/custom-icons.json | 3 +- auth/assets/custom-icons/icons/tcpshield.svg | 35 +++++-------------- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index d1d90ff4f0..dcd03b50e8 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -566,8 +566,7 @@ "slug": "synology_dsm" }, { - "title": "TCPShield", - "hex": "FFFFFF" + "title": "TCPShield" }, { "title": "Techlore", diff --git a/auth/assets/custom-icons/icons/tcpshield.svg b/auth/assets/custom-icons/icons/tcpshield.svg index 9f6ce24091..6e6914700f 100644 --- a/auth/assets/custom-icons/icons/tcpshield.svg +++ b/auth/assets/custom-icons/icons/tcpshield.svg @@ -1,27 +1,8 @@ - - TCPShield - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + From 0fc0a00f47644cf4e7b7171268eec08afcd624d3 Mon Sep 17 00:00:00 2001 From: Tanguy Date: Fri, 30 Aug 2024 21:25:26 +0200 Subject: [PATCH 81/84] Add Battle.net icon --- auth/assets/custom-icons/_data/custom-icons.json | 8 ++++++++ auth/assets/custom-icons/icons/battlenet.svg | 10 ++++++++++ 2 files changed, 18 insertions(+) create mode 100644 auth/assets/custom-icons/icons/battlenet.svg diff --git a/auth/assets/custom-icons/_data/custom-icons.json b/auth/assets/custom-icons/_data/custom-icons.json index dcd03b50e8..aee0df534c 100644 --- a/auth/assets/custom-icons/_data/custom-icons.json +++ b/auth/assets/custom-icons/_data/custom-icons.json @@ -24,6 +24,14 @@ { "title": "AscendEX" }, + { + "title": "Battle.net", + "slug": "battlenet", + "altNames": [ + "Battle net", + "Blizzard" + ] + }, { "title": "Bethesda", "altNames": [ diff --git a/auth/assets/custom-icons/icons/battlenet.svg b/auth/assets/custom-icons/icons/battlenet.svg new file mode 100644 index 0000000000..023e205622 --- /dev/null +++ b/auth/assets/custom-icons/icons/battlenet.svg @@ -0,0 +1,10 @@ + + + + + + + + + + From 7500fdd380243e65b962643d181acd192f18d94d Mon Sep 17 00:00:00 2001 From: Louis Lam Date: Wed, 28 Aug 2024 04:11:04 +0800 Subject: [PATCH 82/84] Request focus on the search box when clicked the search icon --- auth/lib/ui/home_page.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index 1e17947210..f9c6410d9d 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -55,6 +55,9 @@ class _HomePageState extends State { final Logger _logger = Logger("HomePage"); final scaffoldKey = GlobalKey(); + // Used to request focus on the search box when clicked the search icon + late FocusNode searchBoxFocusNode; + final TextEditingController _textController = TextEditingController(); final bool _autoFocusSearch = PreferenceService.instance.shouldAutoFocusOnSearchBar(); @@ -89,6 +92,8 @@ class _HomePageState extends State { setState(() {}); }); _showSearchBox = _autoFocusSearch; + + searchBoxFocusNode = FocusNode(); } void _loadCodes() { @@ -158,6 +163,9 @@ class _HomePageState extends State { _triggerLogoutEvent?.cancel(); _iconsChangedEvent?.cancel(); _textController.removeListener(_applyFilteringAndRefresh); + + searchBoxFocusNode.dispose(); + super.dispose(); } @@ -241,6 +249,7 @@ class _HomePageState extends State { border: InputBorder.none, focusedBorder: InputBorder.none, ), + focusNode: searchBoxFocusNode, ), centerTitle: true, actions: [ @@ -258,6 +267,12 @@ class _HomePageState extends State { _searchText = ""; } else { _searchText = _textController.text; + + // Request focus on the search box + // For Windows only for now. "Platform.isWindows" can be removed if other platforms has been tested. + if (Platform.isWindows) { + searchBoxFocusNode.requestFocus(); + } } _applyFilteringAndRefresh(); }, From a6359f07564296e60a01a1c49d6a93f686cacb2b Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 2 Sep 2024 13:43:26 +0530 Subject: [PATCH 83/84] Prefer existing clusters when adding --- .../new/photos/services/ml/cluster.ts | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index 271cd6d491..6513b9432e 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -393,7 +393,8 @@ interface ClusterLinearResult { clusters: EmbeddingCluster[]; } -const clusterLinear = ( +// TODO-Cluster remove me +export const clusterLinear_Direct = ( embeddings: number[][], threshold: number, ): ClusterLinearResult => { @@ -454,3 +455,82 @@ const clusterLinear = ( return { clusters: validClusters }; }; + +const clusterLinear = ( + embeddings: number[][], + threshold: number, +): ClusterLinearResult => { + const clusters: EmbeddingCluster[] = []; + const clusterIndexForEmbeddingIndex = new Map(); + // For each embedding + for (const [i, ei] of embeddings.entries()) { + // If the embedding is already part of a cluster, then skip it. + if (clusterIndexForEmbeddingIndex.get(i)) continue; + + // Find the nearest neighbour from among all the other embeddings. + let nnIndex: number | undefined; + let nnCosineSimilarity = 0; + // Find the nearest cluster from among all the existing clusters. + let nClusterIndex: number | undefined; + let nClusterCosineSimilarity = 0; + for (const [j, ej] of embeddings.entries()) { + // ! This is an O(n^2) loop, be careful when adding more code here. + + // Skip ourselves. + if (i == j) continue; + + // The vectors are already normalized, so we can directly use their + // dot product as their cosine similarity. + const csim = dotProduct(ei, ej); + if (csim > threshold) { + if (csim > nnCosineSimilarity) { + nnIndex = j; + nnCosineSimilarity = csim; + } + if (csim > nClusterCosineSimilarity) { + const jClusterIndex = clusterIndexForEmbeddingIndex.get(j); + if (jClusterIndex) { + nClusterIndex = jClusterIndex; + nClusterCosineSimilarity = csim; + } + } + } + } + + if (nClusterIndex) { + // Found a neighbouring cluster close enough, add ourselves to that. + + ensure(clusters[nClusterIndex]).push(i); + clusterIndexForEmbeddingIndex.set(i, nClusterIndex); + } else if (nnIndex) { + // Find the cluster the nearest neighbour belongs to, if any. + const nnClusterIndex = clusterIndexForEmbeddingIndex.get(nnIndex); + + if (nnClusterIndex) { + // If the neighbour is already part of a cluster, also add + // ourselves to that cluster. + + ensure(clusters[nnClusterIndex]).push(i); + clusterIndexForEmbeddingIndex.set(i, nnClusterIndex); + } else { + // Otherwise create a new cluster with us and our nearest + // neighbour. + + clusterIndexForEmbeddingIndex.set(i, clusters.length); + clusterIndexForEmbeddingIndex.set(nnIndex, clusters.length); + clusters.push([i, nnIndex]); + } + } else { + // We didn't find a neighbour within the threshold. Create a new + // cluster with only this embedding. + + clusterIndexForEmbeddingIndex.set(i, clusters.length); + clusters.push([i]); + } + } + + // Prune singleton clusters. + const validClusters = clusters.filter((cs) => cs.length > 1); + + return { clusters: validClusters }; +}; From c0ad778c90d9fc5e4bff2711fa3adc2a480ad24d Mon Sep 17 00:00:00 2001 From: Manav Rathi Date: Mon, 2 Sep 2024 13:56:00 +0530 Subject: [PATCH 84/84] Add min threshold --- web/apps/photos/src/pages/cluster-debug.tsx | 9 +++++++ .../new/photos/services/ml/cluster.ts | 25 +++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/web/apps/photos/src/pages/cluster-debug.tsx b/web/apps/photos/src/pages/cluster-debug.tsx index 6337daffd1..d2f921b532 100644 --- a/web/apps/photos/src/pages/cluster-debug.tsx +++ b/web/apps/photos/src/pages/cluster-debug.tsx @@ -112,6 +112,7 @@ const OptionsForm: React.FC = ({ onCluster }) => { method: "linear", minBlur: 10, minScore: 0.8, + minClusterSize: 2, joinThreshold: 0.7, batchSize: 12500, }, @@ -120,6 +121,7 @@ const OptionsForm: React.FC = ({ onCluster }) => { method: values.method, minBlur: toFloat(values.minBlur), minScore: toFloat(values.minScore), + minClusterSize: toFloat(values.minClusterSize), joinThreshold: toFloat(values.joinThreshold), batchSize: toFloat(values.batchSize), }), @@ -162,6 +164,13 @@ const OptionsForm: React.FC = ({ onCluster }) => { size="small" onChange={handleChange} /> + { - const { method, batchSize, minBlur, minScore, joinThreshold } = opts; + const { + method, + batchSize, + minBlur, + minScore, + minClusterSize, + joinThreshold, + } = opts; const t = Date.now(); // A flattened array of faces. @@ -299,7 +307,12 @@ export const clusterFaces = ( } } - const sortedClusters = clusters.sort( + // Prune clusters that are smaller than the threshold. + const validClusters = clusters.filter( + (cs) => cs.faceIDs.length > minClusterSize, + ); + + const sortedClusters = validClusters.sort( (a, b) => b.faceIDs.length - a.faceIDs.length, ); @@ -361,7 +374,7 @@ export const clusterFaces = ( const timeTakenMs = Date.now() - t; log.info( - `Clustered ${faces.length} faces into ${clusters.length} clusters, ${faces.length - clusterIDForFaceID.size} faces remain unclustered (${timeTakenMs} ms)`, + `Clustered ${faces.length} faces into ${sortedClusters.length} clusters, ${faces.length - clusterIDForFaceID.size} faces remain unclustered (${timeTakenMs} ms)`, ); return { @@ -507,11 +520,13 @@ const clusterLinear = ( const nnClusterIndex = clusterIndexForEmbeddingIndex.get(nnIndex); if (nnClusterIndex) { + // TODO-Cluster remove this case. // If the neighbour is already part of a cluster, also add // ourselves to that cluster. - ensure(clusters[nnClusterIndex]).push(i); - clusterIndexForEmbeddingIndex.set(i, nnClusterIndex); + // ensure(clusters[nnClusterIndex]).push(i); + // clusterIndexForEmbeddingIndex.set(i, nnClusterIndex); + throw new Error("We shouldn't have reached here"); } else { // Otherwise create a new cluster with us and our nearest // neighbour.