diff --git a/backend/__tests__/services/photoExportService.txt.test.js b/backend/__tests__/services/photoExportService.txt.test.js new file mode 100644 index 00000000..f2985113 --- /dev/null +++ b/backend/__tests__/services/photoExportService.txt.test.js @@ -0,0 +1,71 @@ +/** + * exportAsTxt — issue #623 regression test. + * + * The admin UI labels the TXT export "for Lightroom search". Lightroom's + * filename search wants ONE comma-separated line WITHOUT file extensions + * (the gallery JPEGs may map to RAW files in the catalog). The frontend + * now passes separator='comma' + include_extension=false for the TXT + * format; this test pins the resulting shape so a future refactor can't + * silently regress it back to the newline-separated form the bug reported. + * + * Also pins backward compatibility: a direct API caller passing no options + * still gets the original newline-with-extension behaviour, so existing + * integrations don't break. + */ +jest.mock('../../src/database/db', () => ({ db: jest.fn() })); +jest.mock('../../src/services/xmpGenerator', () => ({ XmpGenerator: class {} })); + +const { PhotoExportService } = require('../../src/services/photoExportService'); +const service = new PhotoExportService(); + +const PHOTOS = [ + { original_filename: 'IMG_0001.jpg', filename: 'abc123.jpg' }, + { original_filename: 'IMG_0002.JPEG', filename: 'def456.jpeg' }, + { original_filename: 'shoot.final.tif', filename: 'ghi789.tif' }, + { original_filename: null, filename: 'fallback.png' }, // null original → falls back to filename +]; + +describe('exportAsTxt (issue #623)', () => { + it('Lightroom mode: comma-joined, no extension, no space', () => { + const result = service.exportAsTxt(PHOTOS, { + separator: 'comma', + include_extension: false, + }); + expect(result.content).toBe('IMG_0001,IMG_0002,shoot.final,fallback'); + expect(result.contentType).toBe('text/plain'); + }); + + it('backward compatible: no options → newline-joined with extensions', () => { + const result = service.exportAsTxt(PHOTOS); + expect(result.content).toBe( + 'IMG_0001.jpg\nIMG_0002.JPEG\nshoot.final.tif\nfallback.png', + ); + }); + + it('semicolon separator joins without a trailing space', () => { + const result = service.exportAsTxt(PHOTOS, { + separator: 'semicolon', + include_extension: false, + }); + expect(result.content).toBe('IMG_0001;IMG_0002;shoot.final;fallback'); + }); + + it('filename_format=picpeak uses photo.filename (hashed) instead of original', () => { + const result = service.exportAsTxt(PHOTOS, { + filename_format: 'picpeak', + separator: 'comma', + include_extension: false, + }); + expect(result.content).toBe('abc123,def456,ghi789,fallback'); + }); + + it('extension stripping uses only the last segment ("a.b.c" → "a.b")', () => { + // path.parse('shoot.final.tif').name === 'shoot.final' — Lightroom + // catalogs that store basenames like "shoot.final" still match. + const result = service.exportAsTxt( + [{ original_filename: 'shoot.final.tif', filename: 'x.tif' }], + { separator: 'comma', include_extension: false }, + ); + expect(result.content).toBe('shoot.final'); + }); +}); diff --git a/backend/src/services/photoExportService.js b/backend/src/services/photoExportService.js index 0f48d245..5b424112 100644 --- a/backend/src/services/photoExportService.js +++ b/backend/src/services/photoExportService.js @@ -81,21 +81,36 @@ class PhotoExportService { /** * Export as plain text filename list + * + * include_extension defaults to true for backward compatibility with any + * direct API consumer. The admin UI sets it to false for the Lightroom + * search use case — the gallery JPEGs may correspond to RAW files in the + * photographer's catalog, so the search has to match on the stem only. + * + * The comma separator joins without a space, the form Lightroom's filename + * search expects (per issue #623). */ exportAsTxt(photos, options = {}) { - const { filename_format = 'original', separator = 'newline' } = options; + const { + filename_format = 'original', + separator = 'newline', + include_extension = true, + } = options; - const filenames = photos.map(photo => - filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename - ); + const filenames = photos.map(photo => { + const name = filename_format === 'original' + ? (photo.original_filename || photo.filename) + : photo.filename; + return include_extension ? name : path.parse(name).name; + }); let content; switch (separator) { case 'comma': - content = filenames.join(', '); + content = filenames.join(','); break; case 'semicolon': - content = filenames.join('; '); + content = filenames.join(';'); break; default: content = filenames.join('\n'); diff --git a/frontend/src/components/admin/PhotoExportMenu.tsx b/frontend/src/components/admin/PhotoExportMenu.tsx index 69c7a7b4..ef8a3e60 100644 --- a/frontend/src/components/admin/PhotoExportMenu.tsx +++ b/frontend/src/components/admin/PhotoExportMenu.tsx @@ -64,6 +64,11 @@ export const PhotoExportMenu: React.FC = ({ format, options: { filename_format: 'original', + // TXT is labelled "for Lightroom search" — Lightroom's filename + // search field takes one comma-separated line, and the gallery + // JPEGs may correspond to RAW files in the catalog so the search + // has to match on the stem only (issue #623). + ...(format === 'txt' ? { separator: 'comma' as const, include_extension: false } : {}), include_rating: true, include_label: true, include_description: true, diff --git a/frontend/src/services/photos.service.ts b/frontend/src/services/photos.service.ts index 004f92d7..629ba4d7 100644 --- a/frontend/src/services/photos.service.ts +++ b/frontend/src/services/photos.service.ts @@ -345,6 +345,7 @@ export interface ExportOptions { options?: { filename_format?: 'original' | 'picpeak'; separator?: 'newline' | 'comma' | 'semicolon'; + include_extension?: boolean; include_rating?: boolean; include_label?: boolean; include_description?: boolean;