fix(admin/exports): Lightroom TXT export joins with comma + drops extension (#623)
The PhotoExportMenu's TXT format advertises "Simple text list for Lightroom search" but emitted newline-separated filenames WITH `.jpg`. Lightroom's filename search wants a comma-separated one-liner, and the gallery JPEGs may correspond to RAW files in the catalog — so the search has to match on the stem only. The frontend now passes `separator: 'comma'` + `include_extension: false` for the TXT format specifically. The backend gains an `include_extension` option (defaulting to true so direct API consumers don't break), and the comma case joins without a trailing space (the form Lightroom expects). Unit test pins the Lightroom-mode output AND the backward-compatible default for any direct API caller. CSV / XMP / JSON exports are unchanged.
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,21 +81,36 @@ class PhotoExportService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Export as plain text filename list
|
* 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 = {}) {
|
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 =>
|
const filenames = photos.map(photo => {
|
||||||
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename
|
const name = filename_format === 'original'
|
||||||
);
|
? (photo.original_filename || photo.filename)
|
||||||
|
: photo.filename;
|
||||||
|
return include_extension ? name : path.parse(name).name;
|
||||||
|
});
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
switch (separator) {
|
switch (separator) {
|
||||||
case 'comma':
|
case 'comma':
|
||||||
content = filenames.join(', ');
|
content = filenames.join(',');
|
||||||
break;
|
break;
|
||||||
case 'semicolon':
|
case 'semicolon':
|
||||||
content = filenames.join('; ');
|
content = filenames.join(';');
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
content = filenames.join('\n');
|
content = filenames.join('\n');
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
|||||||
format,
|
format,
|
||||||
options: {
|
options: {
|
||||||
filename_format: 'original',
|
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_rating: true,
|
||||||
include_label: true,
|
include_label: true,
|
||||||
include_description: true,
|
include_description: true,
|
||||||
|
|||||||
@@ -345,6 +345,7 @@ export interface ExportOptions {
|
|||||||
options?: {
|
options?: {
|
||||||
filename_format?: 'original' | 'picpeak';
|
filename_format?: 'original' | 'picpeak';
|
||||||
separator?: 'newline' | 'comma' | 'semicolon';
|
separator?: 'newline' | 'comma' | 'semicolon';
|
||||||
|
include_extension?: boolean;
|
||||||
include_rating?: boolean;
|
include_rating?: boolean;
|
||||||
include_label?: boolean;
|
include_label?: boolean;
|
||||||
include_description?: boolean;
|
include_description?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user