diff --git a/web/packages/new/photos/components/gallery/PeopleHeader.tsx b/web/packages/new/photos/components/gallery/PeopleHeader.tsx index e08afd5c4f..8db156db58 100644 --- a/web/packages/new/photos/components/gallery/PeopleHeader.tsx +++ b/web/packages/new/photos/components/gallery/PeopleHeader.tsx @@ -13,16 +13,17 @@ import { useIsSmallWidth } from "@/base/hooks"; import { pt } from "@/base/i18n"; import log from "@/base/log"; import { + applyPersonSuggestionUpdates, deleteCGroup, renameCGroup, suggestionsAndChoicesForPerson, } from "@/new/photos/services/ml"; import { - updateChoices, type CGroupPerson, type ClusterPerson, type Person, type PersonSuggestionsAndChoices, + type PersonSuggestionUpdates, type PreviewableCluster, } from "@/new/photos/services/ml/people"; import OverflowMenu from "@ente/shared/components/OverflowMenu/menu"; @@ -290,7 +291,7 @@ interface SuggestionsDialogState { * - saved choice for which the user has changed their mind. * - suggestion that the user has either explicitly assigned or rejected. */ - marks: Map; + updates: PersonSuggestionUpdates; } type SCItem = PreviewableCluster & { fixed?: boolean; assigned?: boolean }; @@ -303,7 +304,7 @@ type SuggestionsDialogAction = personID: string; suggestionsAndChoices: PersonSuggestionsAndChoices; } - | { type: "mark"; item: SCItem; value: boolean | undefined } + | { type: "updateItem"; item: SCItem; value: boolean | undefined } | { type: "save" } | { type: "toggleHistory" } | { type: "close" }; @@ -315,7 +316,7 @@ const initialSuggestionsDialogState: SuggestionsDialogState = { showChoices: false, choices: [], suggestions: [], - marks: new Map(), + updates: new Map(), }; const suggestionsDialogReducer = ( @@ -328,7 +329,7 @@ const suggestionsDialogReducer = ( ...initialSuggestionsDialogState, choices: [], suggestions: [], - marks: new Map(), + updates: new Map(), activity: "fetching", personID: action.personID, }; @@ -343,21 +344,21 @@ const suggestionsDialogReducer = ( choices: action.suggestionsAndChoices.choices, suggestions: action.suggestionsAndChoices.suggestions, }; - case "mark": { - const marks = new Map(state.marks); + case "updateItem": { + const updates = new Map(state.updates); const { item, value } = action; if (item.assigned === undefined && value === undefined) { - // If this was a suggestion, prune marks created as a result of - // the user toggling the item back to its original unset state. - marks.delete(item.id); + // If this was a suggestion, prune previous updates since the + // use has toggled the item back to its original unset state. + updates.delete(item.id); } else if (item.assigned !== undefined && value === item.assigned) { - // If this is a choice, prune marks which match the choice's + // If this is a choice, prune updates which match the choice's // original assigned state. - marks.delete(item.id); + updates.delete(item.id); } else { - marks.set(item.id, value); + updates.set(item.id, value); } - return { ...state, marks }; + return { ...state, updates: updates }; } case "toggleHistory": return { ...state, showChoices: !state.showChoices }; @@ -388,7 +389,7 @@ const SuggestionsDialog: React.FC = ({ const isSmallWidth = useIsSmallWidth(); - const hasUnsavedChanges = state.marks.size > 0; + const hasUnsavedChanges = state.updates.size > 0; const resetPersonAndClose = () => { dispatch({ type: "close" }); @@ -439,13 +440,13 @@ const SuggestionsDialog: React.FC = ({ resetPersonAndClose(); }; - const handleMark = (item: SCItem, value: boolean | undefined) => - dispatch({ type: "mark", item, value }); + const handleUpdateItem = (item: SCItem, value: boolean | undefined) => + dispatch({ type: "updateItem", item, value }); const handleSave = async () => { dispatch({ type: "save" }); try { - await updateChoices(person.cgroup, [...state.marks.entries()]); + await applyPersonSuggestionUpdates(person.cgroup, state.updates); resetPersonAndClose(); } catch (e) { log.error("Failed to save suggestion review", e); @@ -517,8 +518,8 @@ const SuggestionsDialog: React.FC = ({ ) : state.showChoices ? ( ) : state.suggestions.length == 0 ? ( @@ -532,8 +533,8 @@ const SuggestionsDialog: React.FC = ({ ) : ( )} @@ -561,18 +562,18 @@ const SuggestionsDialog: React.FC = ({ interface SuggestionOrChoiceListProps { items: SCItem[]; - marks: Map; + updates: PersonSuggestionUpdates; /** * Callback invoked when the user changes the value associated with the * given suggestion or choice. */ - onMarkItem: (item: SCItem, value: boolean | undefined) => void; + onUpdateItem: (item: SCItem, value: boolean | undefined) => void; } const SuggestionOrChoiceList: React.FC = ({ items, - marks, - onMarkItem, + updates, + onUpdateItem, }) => ( {items.map((item) => ( @@ -593,9 +594,9 @@ const SuggestionOrChoiceList: React.FC = ({ {!item.fixed && ( onMarkItem(item, toItemValue(v))} + onChange={(_, v) => onUpdateItem(item, toItemValue(v))} > @@ -610,13 +611,12 @@ const SuggestionOrChoiceList: React.FC = ({ ); -const fromItemValue = ( - item: SCItem, - marks: Map, -) => { +const fromItemValue = (item: SCItem, updates: PersonSuggestionUpdates) => { // Use the in-memory state if available. For choices, fallback to their // original state. - const resolved = marks.has(item.id) ? marks.get(item.id) : item.assigned; + const resolved = updates.has(item.id) + ? updates.get(item.id) + : item.assigned; return resolved ? "yes" : resolved === false ? "no" : undefined; }; diff --git a/web/packages/new/photos/services/ml/cluster.ts b/web/packages/new/photos/services/ml/cluster.ts index 36a39b5e09..ffdb52d877 100644 --- a/web/packages/new/photos/services/ml/cluster.ts +++ b/web/packages/new/photos/services/ml/cluster.ts @@ -153,7 +153,7 @@ export const _clusterFaces = async ( } const t = `(${Date.now() - startTime} ms)`; - log.info(`Generated ${clusters.length} clusters from ${total} faces ${t}`); + log.info(`Refreshed ${clusters.length} clusters from ${total} faces ${t}`); return clusters; }; diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index f203080b9c..d3a1fbc1f3 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -28,10 +28,12 @@ import type { FaceCluster } from "./cluster"; import { regenerateFaceCrops } from "./crop"; import { clearMLDB, getIndexableAndIndexedCounts, savedFaceIndex } from "./db"; import { + _applyPersonSuggestionUpdates, filterNamedPeople, reconstructPeople, type CGroupPerson, type Person, + type PersonSuggestionUpdates, } from "./people"; import { MLWorker } from "./worker"; import type { CLIPMatches } from "./worker-types"; @@ -812,3 +814,17 @@ export const deleteCGroup = async ({ id }: CGroup) => { */ export const suggestionsAndChoicesForPerson = async (person: CGroupPerson) => worker().then((w) => w.suggestionsAndChoicesForPerson(person)); + +/** + * Implementation for the "save" action on the SuggestionsDialog. + * + * See {@link _applyPersonSuggestionUpdates} for more details. + */ +export const applyPersonSuggestionUpdates = async ( + cgroup: CGroup, + updates: PersonSuggestionUpdates, +) => { + const masterKey = await masterKeyFromSession(); + await _applyPersonSuggestionUpdates(cgroup, updates, masterKey); + return mlSync(); +}; diff --git a/web/packages/new/photos/services/ml/people.ts b/web/packages/new/photos/services/ml/people.ts index 3e2d06eece..caa70567bd 100644 --- a/web/packages/new/photos/services/ml/people.ts +++ b/web/packages/new/photos/services/ml/people.ts @@ -1,16 +1,21 @@ import { assertionFailed } from "@/base/assert"; import log from "@/base/log"; import type { EnteFile } from "@/media/file"; -import { updateAssignedClustersForCGroup } from "@/new/photos/services/ml"; import { shuffled } from "@/utils/array"; import { ensure } from "@/utils/ensure"; -import { saveRejectedClustersForCGroup } from "../../services/ml/kvdb"; import { getLocalFiles } from "../files"; -import { savedCGroups, type CGroup } from "../user-entity"; +import { + savedCGroups, + updateOrCreateUserEntities, + type CGroup, +} from "../user-entity"; import type { FaceCluster } from "./cluster"; import { savedFaceClusters, savedFaceIndexes } from "./db"; import { fileIDFromFaceID } from "./face"; -import { savedRejectedClustersForCGroup } from "./kvdb"; +import { + savedRejectedClustersForCGroup, + saveRejectedClustersForCGroup, +} from "./kvdb"; import { dotProduct } from "./math"; /** @@ -534,6 +539,20 @@ const randomSample = (items: T[], n: number) => { return [...ix].map((i) => items[i]!); }; +/** + * A map specifying the changes to make when the user presses the save button on + * the people suggestions dialog. + * + * Each entry is a (clusterID, assigned) pair. + * + * * Entries with assigned `true` should be assigned to the cgroup, + * * Entries with assigned `false` should be rejected from the cgroup. + * * Entries with assigned `undefined` should be reset - i.e. they should be + * removed from both the assigned and rejected choices associated with the + * cgroup (if needed). + */ +export type PersonSuggestionUpdates = Map; + /** * Implementation for the "save" action on the SuggestionsDialog. * @@ -542,17 +561,15 @@ const randomSample = (items: T[], n: number) => { * * @param cgroup The cgroup that we want to update. * - * @param updates The changes to make. Each entry is a (clusterID, assigned) - * pair. + * @param updates The changes to make. See {@link PersonSuggestionUpdates}. * - * * Entries with assigned `true` should be assigned to the cgroup, - * * Entries with assigned `false` should be rejected from the cgroup. - * * Entries with assigned `undefined` should be removed from both the assigned - * and rejected choices associated with the cgroup. + * @param masterKey The user's masterKey, which is is used to encrypt and + * decrypt the entity key associated with cgroups. */ -export const updateChoices = async ( +export const _applyPersonSuggestionUpdates = async ( cgroup: CGroup, - updates: [string, boolean | undefined][], + updates: PersonSuggestionUpdates, + masterKey: Uint8Array, ) => { const clusters = await savedFaceClusters(); const clustersByID = new Map(clusters.map((c) => [c.id, c])); @@ -597,7 +614,7 @@ export const updateChoices = async ( } }; - for (const [clusterID, assigned] of updates) { + for (const [clusterID, assigned] of updates.entries()) { switch (assigned) { case true /* assign */: // TODO-Cluster sanity check, remove after wrapping up dev @@ -627,7 +644,12 @@ export const updateChoices = async ( } if (assignUpdateCount > 0) { - await updateAssignedClustersForCGroup(cgroup, assignedClusters); + const assigned = assignedClusters; + await updateOrCreateUserEntities( + "cgroup", + [{ ...cgroup, data: { ...cgroup.data, assigned } }], + masterKey, + ); } if (rejectUpdateCount > 0) {