Merge branch 'g3n35i5-fix/unassigned-faces-selection-problem'

pull/783/head
Varun Patil 2023-08-19 10:01:50 -07:00
commit 1f2305b804
3 changed files with 113 additions and 76 deletions

View File

@ -71,9 +71,7 @@ import AlbumsIcon from 'vue-material-design-icons/ImageAlbum.vue';
import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue'; import AlbumRemoveIcon from 'vue-material-design-icons/BookRemove.vue';
import FolderMoveIcon from 'vue-material-design-icons/FolderMove.vue'; import FolderMoveIcon from 'vue-material-design-icons/FolderMove.vue';
import { IDay, IHeadRow, IPhoto, IRow, IRowType, ISelectionAction } from '../types'; import { IDay, IHeadRow, IPhoto, IRow, IRowType } from '../types';
type Selection = Map<number, IPhoto>;
/** /**
* The distance for which the touch selection is clamped. * The distance for which the touch selection is clamped.
@ -86,6 +84,63 @@ const TOUCH_SELECT_CLAMP = {
bufferPx: 5, // number of pixels to clamp inside recycler area bufferPx: 5, // number of pixels to clamp inside recycler area
}; };
class Selection extends Map<string, IPhoto> {
addBy(photo: IPhoto): this {
console.assert(photo?.key, 'SelectionManager::addBy encountered a photo without a key');
this.set(photo.key!, photo);
return this;
}
getBy({ key }: { key?: string }): IPhoto | undefined {
console.assert(key, 'SelectionManager::getBy encountered a photo without a key');
return this.get(key!);
}
deleteBy({ key }: { key?: string }): boolean {
console.assert(key, 'SelectionManager::deleteBy encountered a photo without a key');
return this.delete(key!);
}
hasBy({ key }: { key?: string }): boolean {
console.assert(key, 'SelectionManager::hasBy encountered a photo without a key');
return this.has(key!);
}
fileids(): Set<number> {
return new Set(Array.from(this.values()).map((p) => p.fileid));
}
photosNoDupFileId(): IPhoto[] {
const fileids = this.fileids();
return Array.from(this.values()).filter((p) => fileids.delete(p.fileid));
}
photosFromFileIds(fileIds: number[] | Set<number>): IPhoto[] {
const idSet = new Set(fileIds);
const photos = Array.from(this.values());
return photos.filter((p) => idSet.has(p?.fileid));
}
clone(): Selection {
return new Selection(this);
}
}
type ISelectionAction = {
/** Identifier (optional) */
id?: string;
/** Display text */
name: string;
/** Icon component */
icon: any;
/** Action to perform */
callback: (selection: Selection) => Promise<void>;
/** Condition to check for including */
if?: (self?: any) => boolean;
/** Allow for public routes (default false) */
allowPublic?: boolean;
};
export default defineComponent({ export default defineComponent({
name: 'SelectionManager', name: 'SelectionManager',
components: { components: {
@ -124,7 +179,7 @@ export default defineComponent({
data: () => ({ data: () => ({
show: false, show: false,
size: 0, size: 0,
selection: new Map<number, IPhoto>(), selection: new Selection(),
defaultActions: null! as ISelectionAction[], defaultActions: null! as ISelectionAction[],
touchAnchor: null as IPhoto | null, touchAnchor: null as IPhoto | null,
@ -247,7 +302,7 @@ export default defineComponent({
}, },
deleteSelectedPhotosById(delIds: number[], selection: Selection) { deleteSelectedPhotosById(delIds: number[], selection: Selection) {
return this.deletePhotos(delIds.map((id) => selection.get(id)).filter((p): p is IPhoto => p !== undefined)); return this.deletePhotos(selection.photosFromFileIds(delIds));
}, },
updateLoading(delta: number) { updateLoading(delta: number) {
@ -285,12 +340,9 @@ export default defineComponent({
document.body.classList.toggle('has-top-bar', has); document.body.classList.toggle('has-top-bar', has);
}, },
/** Is this fileid (or anything if not specified) selected */ /** Is the selection empty */
has(fileid?: number) { empty(): boolean {
if (fileid === undefined) { return !this.selection.size;
return this.selection.size > 0;
}
return this.selection.has(fileid);
}, },
/** Get the actions list */ /** Get the actions list */
@ -316,7 +368,7 @@ export default defineComponent({
if (event.pointerType === 'touch') return; // let touch events handle this if (event.pointerType === 'touch') return; // let touch events handle this
if (event.pointerType === 'mouse' && event.button !== 0) return; // only left click for mouse if (event.pointerType === 'mouse' && event.button !== 0) return; // only left click for mouse
if (this.has() || event.ctrlKey || event.shiftKey) { if (!this.empty() || event.ctrlKey || event.shiftKey) {
this.clickSelectionIcon(photo, event, rowIdx); this.clickSelectionIcon(photo, event, rowIdx);
} else { } else {
this.openViewer(photo); this.openViewer(photo);
@ -325,7 +377,7 @@ export default defineComponent({
/** Clicking on checkmark icon */ /** Clicking on checkmark icon */
clickSelectionIcon(photo: IPhoto, event: PointerEvent, rowIdx: number) { clickSelectionIcon(photo: IPhoto, event: PointerEvent, rowIdx: number) {
if (this.has() && event.shiftKey) { if (!this.empty() && event.shiftKey) {
this.selectMulti(photo, this.rows, rowIdx); this.selectMulti(photo, this.rows, rowIdx);
} else { } else {
this.selectPhoto(photo); this.selectPhoto(photo);
@ -342,7 +394,7 @@ export default defineComponent({
this.touchAnchor = photo; this.touchAnchor = photo;
this.prevOver = photo; this.prevOver = photo;
this.prevTouch = event.touches[0]; this.prevTouch = event.touches[0];
this.touchPrevSel = new Map(this.selection); this.touchPrevSel = this.selection.clone();
this.touchMoved = false; this.touchMoved = false;
this.touchTimer = window.setTimeout(() => { this.touchTimer = window.setTimeout(() => {
if (this.touchAnchor === photo) { if (this.touchAnchor === photo) {
@ -499,7 +551,7 @@ export default defineComponent({
reverse = overPhoto.dayid > this.touchAnchor.dayid != this.isreverse; reverse = overPhoto.dayid > this.touchAnchor.dayid != this.isreverse;
} }
const newSelection = new Map(this.touchPrevSel); const newSelection = this.touchPrevSel!.clone();
const updatedDays = new Set<number>(); const updatedDays = new Set<number>();
// Walk over rows // Walk over rows
@ -520,31 +572,31 @@ export default defineComponent({
continue; continue;
} }
let p = this.rows[i]?.photos?.[j]; const photo = this.rows[i]?.photos?.[j];
if (!p) break; // shouldn't happen, ever if (!photo) break; // shouldn't happen, ever
// This is there now // This is there now
newSelection.set(p.fileid, p); newSelection.addBy(photo);
// Perf: only update heads if not selected // Perf: only update heads if not selected
if (!(p.flag & this.c.FLAG_SELECTED)) { if (!(photo.flag & this.c.FLAG_SELECTED)) {
this.selectPhoto(p, true, true); this.selectPhoto(photo, true, true);
updatedDays.add(p.dayid); updatedDays.add(photo.dayid);
} }
// We're trying to update too much -- something went wrong // We're trying to update too much -- something went wrong
if (newSelection.size - this.selection.size > 50) break; if (newSelection.size - this.selection.size > 50) break;
// Check goal // Check goal
if (p === overPhoto) break; if (photo === overPhoto) break;
j += reverse ? -1 : 1; j += reverse ? -1 : 1;
} }
// Remove unselected // Remove unselected
for (const [fileid, p] of this.selection) { for (const [_, photo] of this.selection) {
if (!newSelection.has(fileid)) { if (!newSelection.hasBy(photo)) {
this.selectPhoto(p, false, true); this.selectPhoto(photo, false, true);
updatedDays.add(p.dayid); updatedDays.add(photo.dayid);
} }
} }
@ -563,21 +615,15 @@ export default defineComponent({
return; // ignore placeholders return; // ignore placeholders
} }
const nval = val ?? !this.selection.has(photo.fileid); const nval = val ?? !this.selection.hasBy(photo);
if (nval) { if (nval) {
photo.flag |= this.c.FLAG_SELECTED; photo.flag |= this.c.FLAG_SELECTED;
this.selection.set(photo.fileid, photo); this.selection.addBy(photo);
this.selectionChanged(); this.selectionChanged();
} else { } else {
photo.flag &= ~this.c.FLAG_SELECTED; photo.flag &= ~this.c.FLAG_SELECTED;
this.selection.deleteBy(photo);
// Only do this if the photo in the selection set is this one. this.selectionChanged();
// The problem arises when there are duplicates (e.g. face rect)
// in the list, which creates an inconsistent state if we do this.
if (this.selection.get(photo.fileid) === photo) {
this.selection.delete(photo.fileid);
this.selectionChanged();
}
} }
if (!noUpdate) { if (!noUpdate) {
@ -679,7 +725,7 @@ export default defineComponent({
Array.from(toClear).forEach((photo: IPhoto) => { Array.from(toClear).forEach((photo: IPhoto) => {
photo.flag &= ~this.c.FLAG_SELECTED; photo.flag &= ~this.c.FLAG_SELECTED;
heads.add(this.heads[photo.dayid]); heads.add(this.heads[photo.dayid]);
this.selection.delete(photo.fileid); this.selection.deleteBy(photo);
this.selectionChanged(); this.selectionChanged();
}); });
heads.forEach(this.updateHeadSelected); heads.forEach(this.updateHeadSelected);
@ -688,31 +734,27 @@ export default defineComponent({
/** Restore selections from new day object */ /** Restore selections from new day object */
restoreDay(day: IDay) { restoreDay(day: IDay) {
if (!this.has()) { if (this.empty()) return;
return;
}
// FileID => Photo for new day // FileID => Photo for new day
const dayMap = new Map<number, IPhoto>(); const dayMap = new Selection();
day.detail?.forEach((photo) => { day.detail?.forEach((photo) => dayMap.addBy(photo));
dayMap.set(photo.fileid, photo);
});
this.selection.forEach((photo, fileid) => { this.selection.forEach((photo, key) => {
// Process this day only // Process this day only
if (photo.dayid !== day.dayid) { if (photo.dayid !== day.dayid) {
return; return;
} }
// Remove all selections that are not in the new day // Remove all selections that are not in the new day
const newPhoto = dayMap.get(fileid); const newPhoto = dayMap.get(key);
if (!newPhoto) { if (!newPhoto) {
this.selection.delete(fileid); this.selection.delete(key);
return; return;
} }
// Update the photo object // Update the photo object
this.selection.set(fileid, newPhoto); this.selection.addBy(newPhoto);
newPhoto.flag |= this.c.FLAG_SELECTED; newPhoto.flag |= this.c.FLAG_SELECTED;
}); });
@ -728,7 +770,7 @@ export default defineComponent({
return; return;
} }
} }
await dav.downloadFilesByPhotos(Array.from(selection.values())); await dav.downloadFilesByPhotos(selection.photosNoDupFileId());
}, },
/** /**
@ -743,7 +785,8 @@ export default defineComponent({
*/ */
async favoriteSelection(selection: Selection) { async favoriteSelection(selection: Selection) {
const val = !this.allSelectedFavorites(selection); const val = !this.allSelectedFavorites(selection);
for await (const favIds of dav.favoritePhotos(Array.from(selection.values()), val)) { for await (const ids of dav.favoritePhotos(selection.photosNoDupFileId(), val)) {
selection.photosFromFileIds(ids).forEach((photo) => dav.favoriteSetFlag(photo, val));
} }
this.clearSelection(); this.clearSelection();
}, },
@ -759,7 +802,7 @@ export default defineComponent({
} }
try { try {
for await (const delIds of dav.deletePhotos(Array.from(selection.values()))) { for await (const delIds of dav.deletePhotos(selection.photosNoDupFileId())) {
this.deleteSelectedPhotosById(delIds, selection); this.deleteSelectedPhotosById(delIds, selection);
} }
} catch (e) { } catch (e) {
@ -772,7 +815,7 @@ export default defineComponent({
* Open the edit date dialog * Open the edit date dialog
*/ */
async editMetadataSelection(selection: Selection, sections?: number[]) { async editMetadataSelection(selection: Selection, sections?: number[]) {
globalThis.editMetadata(Array.from(selection.values()), sections); globalThis.editMetadata(selection.photosNoDupFileId(), sections);
}, },
/** /**
@ -789,12 +832,12 @@ export default defineComponent({
*/ */
async archiveSelection(selection: Selection) { async archiveSelection(selection: Selection) {
if (selection.size >= 100) { if (selection.size >= 100) {
if (!confirm(this.t('memories', 'You are about to touch a large number of files. Are you sure?'))) { if (!confirm(this.t('memories', 'You are about to move a large number of files. Are you sure?'))) {
return; return;
} }
} }
for await (let delIds of dav.archiveFilesByIds(Array.from(selection.keys()), !this.routeIsArchive)) { for await (let delIds of dav.archiveFilesByIds(Array.from(selection.fileids()), !this.routeIsArchive)) {
this.deleteSelectedPhotosById(delIds, selection); this.deleteSelectedPhotosById(delIds, selection);
} }
}, },
@ -803,14 +846,14 @@ export default defineComponent({
* Move selected photos to album * Move selected photos to album
*/ */
async addToAlbum(selection: Selection) { async addToAlbum(selection: Selection) {
globalThis.updateAlbums(Array.from(selection.values())); globalThis.updateAlbums(selection.photosNoDupFileId());
}, },
/** /**
* Move selected photos to folder * Move selected photos to folder
*/ */
async moveToFolder(selection: Selection) { async moveToFolder(selection: Selection) {
(<any>this.$refs.moveToFolderModal).open(Array.from(selection.values())); (<any>this.$refs.moveToFolderModal).open(selection.photosNoDupFileId());
}, },
/** /**

View File

@ -60,11 +60,7 @@ export async function* favoritePhotos(photos: IPhoto[], favoriteState: boolean)
try { try {
await favoriteFile(fileInfo.originalFilename, favoriteState); await favoriteFile(fileInfo.originalFilename, favoriteState);
const photo = photos.find((p) => p.fileid === fileInfo.fileid)!; const photo = photos.find((p) => p.fileid === fileInfo.fileid)!;
if (favoriteState) { favoriteSetFlag(photo, favoriteState);
photo.flag |= utils.constants.c.FLAG_IS_FAVORITE;
} else {
photo.flag &= ~utils.constants.c.FLAG_IS_FAVORITE;
}
return fileInfo.fileid as number; return fileInfo.fileid as number;
} catch (error) { } catch (error) {
console.error('Failed to favorite', fileInfo, error); console.error('Failed to favorite', fileInfo, error);
@ -79,3 +75,16 @@ export async function* favoritePhotos(photos: IPhoto[], favoriteState: boolean)
yield* base.runInParallel(calls, 10); yield* base.runInParallel(calls, 10);
} }
/**
* Set the favorite flag on a photo
* @param photo Photo to set the flag on
* @param val New value of the flag
*/
export function favoriteSetFlag(photo: IPhoto, val: boolean) {
if (val) {
photo.flag |= utils.constants.c.FLAG_IS_FAVORITE;
} else {
photo.flag &= ~utils.constants.c.FLAG_IS_FAVORITE;
}
}

View File

@ -221,21 +221,6 @@ export type ITick = {
key?: number; key?: number;
}; };
export type ISelectionAction = {
/** Identifier (optional) */
id?: string;
/** Display text */
name: string;
/** Icon component */
icon: any;
/** Action to perform */
callback: (selection: Map<number, IPhoto>) => Promise<void>;
/** Condition to check for including */
if?: (self?: any) => boolean;
/** Allow for public routes (default false) */
allowPublic?: boolean;
};
export type IConfig = { export type IConfig = {
version: string; version: string;
vod_disable: boolean; vod_disable: boolean;