diff --git a/frontend/src/component/photo/album/dialog.vue b/frontend/src/component/photo/album/dialog.vue index f2db0a2d2..5adc11f37 100644 --- a/frontend/src/component/photo/album/dialog.vue +++ b/frontend/src/component/photo/album/dialog.vue @@ -143,25 +143,59 @@ export default { return; } - Promise.all( - namesToCreate.map((title) => { - const newAlbum = new Album({ Title: title, UID: "", Favorite: false }); - return newAlbum - .save() - .then((a) => a?.UID) - .catch(() => null); - }) - ) - .then((created) => { - const createdUids = created.filter((u) => typeof u === "string" && u.length > 0); - this.$emit("confirm", [...uniqueExistingUids, ...createdUids]); - }) - .catch((error) => { - console.error("Failed to create some albums:", error); - // Still emit successful ones if any exist - if (uniqueExistingUids.length > 0) { - this.$emit("confirm", uniqueExistingUids); + // Create albums in parallel and handle partial failures without closing the dialog + const creations = namesToCreate.map((title) => ({ + title, + promise: new Album({ Title: title, UID: "", Favorite: false }).save(), + })); + + Promise.allSettled(creations.map((c) => c.promise)) + .then((results) => { + const createdAlbums = []; + const failedTitles = []; + + results.forEach((res, idx) => { + const originalTitle = creations[idx].title; + if (res.status === "fulfilled" && res.value && res.value.UID) { + createdAlbums.push(res.value); + } else { + failedTitles.push(originalTitle); + } + }); + + if (failedTitles.length > 0) { + // Replace successfully created string tokens with album objects so they are not retried + const byTitle = new Map(createdAlbums.map((a) => [a.Title || a.title || "", a])); + this.selectedAlbums = (this.selectedAlbums || []).map((it) => { + if (typeof it === "string") { + const t = it.trim(); + const created = byTitle.get(t); + return created ? created : it; + } + return it; + }); + + // Add created albums to the combobox items so they can be selected by object + const known = new Set((this.items || []).map((a) => a.UID)); + createdAlbums.forEach((a) => { + if (a && a.UID && !known.has(a.UID)) { + this.items.push(a); + known.add(a.UID); + } + }); + + // Notify user and keep dialog open for corrections + this.$notify.error( + this.$gettext("Some albums could not be created. Please correct the names and try again.") + ); + return; // Do not emit confirm; keep dialog open } + + // All created successfully → emit and let parent close the dialog + const createdUids = createdAlbums + .map((a) => a && a.UID) + .filter((u) => typeof u === "string" && u.length > 0); + this.$emit("confirm", [...uniqueExistingUids, ...createdUids]); }) .finally(() => { this.loading = false; diff --git a/frontend/tests/vitest/common/albums.test.js b/frontend/tests/vitest/common/albums.test.js new file mode 100644 index 000000000..04e718313 --- /dev/null +++ b/frontend/tests/vitest/common/albums.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { processAlbumSelection } from '../../../src/common/albums.js'; + +function album(title, uid) { + return { Title: title, UID: uid }; +} + +describe('processAlbumSelection', () => { + it('trims whitespace and matches existing albums (case-insensitive)', () => { + const available = [album('Summer', '1')]; + const selected = [' summer ']; + + const { processed, changed } = processAlbumSelection(selected, available); + + expect(processed).toHaveLength(1); + expect(processed[0]).toEqual(available[0]); + expect(changed).toBe(true); + }); + + it('deduplicates identical UIDs and strings resolving to same album', () => { + const a1 = album('Trips', 't1'); + const selected = [a1, a1, 'trips', 'TRIPS']; + + const { processed, changed } = processAlbumSelection(selected, [a1]); + + expect(processed).toHaveLength(1); + expect(processed[0]).toEqual(a1); + expect(changed).toBe(true); + }); + + it('keeps unmatched names as trimmed strings (for creation later)', () => { + const selected = [' New Album ']; + + const { processed, changed } = processAlbumSelection(selected, []); + + expect(processed).toEqual(['New Album']); + // No structural change: only trimming does not count as change if lengths are equal and no replacements/drops + expect(changed).toBe(false); + }); + + it('drops empty / whitespace-only entries and reports change', () => { + const selected = [' ', '\n', '\t', ' Name ']; + + const { processed, changed } = processAlbumSelection(selected, []); + + expect(processed).toEqual(['Name']); + expect(changed).toBe(true); + }); + + it('reconciles selection with new available items (race condition)', () => { + const selected = [' Road Trip ']; + const availableThen = [album('Road Trip', 'rt01')]; + + const { processed, changed } = processAlbumSelection(selected, availableThen); + + expect(processed).toHaveLength(1); + expect(processed[0]).toEqual(availableThen[0]); + expect(changed).toBe(true); + }); + + it('preserves existing album objects and prevents duplicates', () => { + const a = album('Family', 'fam1'); + const selected = [a, { ...a }]; // two distinct objects with same UID + + const { processed, changed } = processAlbumSelection(selected, [a]); + + expect(processed).toHaveLength(1); + expect(processed[0]).toEqual(a); + expect(changed).toBe(true); + }); +});