Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
57 lines
2.2 KiB
TypeScript
57 lines
2.2 KiB
TypeScript
/**
|
|
* Turning a submitted selection into something the photographer can act on.
|
|
*
|
|
* The client picked from web previews; the photographer edits RAW files. So
|
|
* the most useful export is the list of file names *without* their extension:
|
|
* pasted into Lightroom's or Capture One's filename filter, it matches
|
|
* IMG_0412.CR3 as readily as IMG_0412.JPG.
|
|
*/
|
|
|
|
export interface ExportAsset {
|
|
/** 1-based position in the album, as the client saw it. */
|
|
position: number;
|
|
fileName: string;
|
|
takenAt?: string;
|
|
}
|
|
|
|
export type ExportFormat = 'lightroom' | 'txt' | 'csv';
|
|
|
|
/** `IMG_0412.JPG` → `IMG_0412`. A name without a dot stays as it is. */
|
|
export function baseName(fileName: string): string {
|
|
const dot = fileName.lastIndexOf('.');
|
|
return dot > 0 ? fileName.slice(0, dot) : fileName;
|
|
}
|
|
|
|
/** Quote a CSV field when it needs it (RFC 4180), and defuse spreadsheet formulas. */
|
|
function csvField(value: string): string {
|
|
// A leading =, +, - or @ makes Excel and LibreOffice evaluate the cell. File
|
|
// names come from the client's camera, not from us — never let one run.
|
|
// Tab and CR start a formula in some spreadsheet apps as well (OWASP).
|
|
const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
|
|
return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
|
|
}
|
|
|
|
export function formatExport(assets: ExportAsset[], format: ExportFormat): string {
|
|
switch (format) {
|
|
case 'lightroom':
|
|
// De-duplicated: a RAW+JPEG pair uploaded to Immich shares one base name.
|
|
return Array.from(new Set(assets.map((a) => baseName(a.fileName)))).join(', ');
|
|
case 'txt':
|
|
return assets.map((a) => a.fileName).join('\n') + (assets.length ? '\n' : '');
|
|
case 'csv': {
|
|
const rows = [['position', 'file_name', 'taken_at']].concat(
|
|
assets.map((a) => [String(a.position), a.fileName, a.takenAt ?? '']),
|
|
);
|
|
return rows.map((row) => row.map(csvField).join(',')).join('\r\n') + '\r\n';
|
|
}
|
|
}
|
|
}
|
|
|
|
export function exportContentType(format: ExportFormat): string {
|
|
return format === 'csv' ? 'text/csv; charset=utf-8' : 'text/plain; charset=utf-8';
|
|
}
|
|
|
|
export function exportExtension(format: ExportFormat): string {
|
|
return format === 'csv' ? 'csv' : 'txt';
|
|
}
|