502 lines
14 KiB
TypeScript
502 lines
14 KiB
TypeScript
import { DEFAULT_ASPECT_RATIO, Photo, PhotoDbInsert } from '..';
|
|
import {
|
|
generateLocalNaivePostgresString,
|
|
generateLocalPostgresString,
|
|
validationMessageNaivePostgresDateString,
|
|
validationMessagePostgresDateString,
|
|
} from '@/utility/date';
|
|
import { roundToNumber } from '@/utility/number';
|
|
import { convertStringToArray, parameterize } from '@/utility/string';
|
|
import { generateNanoid } from '@/utility/nanoid';
|
|
import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
|
|
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
|
|
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
|
|
import { ReactNode } from 'react';
|
|
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
|
|
import { SelectMenuOptionType } from '@/components/SelectMenuOption';
|
|
import { COLOR_SORT_ENABLED } from '@/app/config';
|
|
|
|
type VirtualFields =
|
|
'albums' |
|
|
'visibility' |
|
|
'favorite' |
|
|
'applyRecipeTitleGlobally' |
|
|
'shouldStripGpsData';
|
|
|
|
export type FormFields = keyof PhotoDbInsert | VirtualFields;
|
|
|
|
export type PhotoFormData = Record<FormFields, string>
|
|
|
|
export type FieldSetType =
|
|
'text' |
|
|
'email' |
|
|
'password' |
|
|
'checkbox' |
|
|
'textarea' |
|
|
'hidden';
|
|
|
|
export type AnnotatedTag = {
|
|
value: string,
|
|
label?: string,
|
|
icon?: ReactNode
|
|
annotation?: string,
|
|
annotationAria?: string,
|
|
};
|
|
|
|
export type FormMeta = {
|
|
section: string
|
|
label: string
|
|
note?: string
|
|
noteShort?: string
|
|
required?: boolean
|
|
excludeFromInsert?: boolean
|
|
readOnly?: boolean
|
|
hideModificationStatus?: boolean
|
|
validate?: (value?: string) => string | undefined
|
|
validateStringMaxLength?: number
|
|
spellCheck?: boolean
|
|
capitalize?: boolean
|
|
hideIfEmpty?: boolean
|
|
shouldHide?: (
|
|
formData: Partial<PhotoFormData>,
|
|
changedFormKeys?: (keyof PhotoFormData)[],
|
|
) => boolean
|
|
loadingMessage?: string
|
|
type?: FieldSetType
|
|
selectOptions?: SelectMenuOptionType[]
|
|
selectOptionsDefaultLabel?: string
|
|
tagOptions?: AnnotatedTag[]
|
|
tagOptionsLimit?: number
|
|
tagOptionsLimitValidationMessage?: string
|
|
tagOptionsShouldParameterize?: boolean
|
|
shouldNotOverwriteWithNullDataOnSync?: boolean
|
|
isJson?: boolean
|
|
staticValue?: string
|
|
};
|
|
|
|
const STRING_MAX_LENGTH_SHORT = 255;
|
|
const STRING_MAX_LENGTH_LONG = 1000;
|
|
|
|
const FORM_METADATA = (
|
|
tagOptions?: AnnotatedTag[],
|
|
recipeOptions?: AnnotatedTag[],
|
|
filmOptions?: AnnotatedTag[],
|
|
aiTextGeneration?: boolean,
|
|
shouldStripGpsData?: boolean,
|
|
): Record<keyof PhotoFormData, FormMeta> => ({
|
|
title: {
|
|
section: 'text',
|
|
label: 'title',
|
|
capitalize: true,
|
|
validateStringMaxLength: STRING_MAX_LENGTH_SHORT,
|
|
shouldNotOverwriteWithNullDataOnSync: true,
|
|
},
|
|
caption: {
|
|
section: 'text',
|
|
label: 'caption',
|
|
capitalize: true,
|
|
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
|
|
shouldHide: ({ title, caption }) =>
|
|
!aiTextGeneration && (!title && !caption),
|
|
},
|
|
tags: {
|
|
section: 'text',
|
|
label: 'tags',
|
|
tagOptions,
|
|
validate: getValidationMessageForTags,
|
|
},
|
|
semanticDescription: {
|
|
section: 'text',
|
|
type: 'textarea',
|
|
label: 'semantic description (not visible)',
|
|
capitalize: true,
|
|
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
|
|
shouldHide: () => !aiTextGeneration,
|
|
},
|
|
albums: {
|
|
section: 'text',
|
|
label: 'albums',
|
|
excludeFromInsert: true,
|
|
},
|
|
visibility: {
|
|
section: 'text',
|
|
type: 'text',
|
|
label: 'visibility',
|
|
excludeFromInsert: true,
|
|
},
|
|
excludeFromFeeds: {
|
|
section: 'text',
|
|
label: 'exclude from feeds',
|
|
type: 'hidden',
|
|
},
|
|
hidden: {
|
|
section: 'text',
|
|
label: 'hidden',
|
|
type: 'hidden',
|
|
},
|
|
favorite: {
|
|
section: 'text',
|
|
label: 'favorite',
|
|
type: 'checkbox',
|
|
excludeFromInsert: true,
|
|
},
|
|
make: {
|
|
section: 'exif',
|
|
label: 'camera make',
|
|
},
|
|
model: {
|
|
section: 'exif',
|
|
label: 'camera model',
|
|
},
|
|
film: {
|
|
section: 'exif',
|
|
label: 'film',
|
|
note: 'Intended for Fujifilm / Nikon / analog scans',
|
|
noteShort: 'Fujifilm / Nikon / analog scans',
|
|
tagOptions: filmOptions,
|
|
tagOptionsLimit: 1,
|
|
shouldNotOverwriteWithNullDataOnSync: true,
|
|
},
|
|
recipeTitle: {
|
|
section: 'exif',
|
|
label: 'recipe title',
|
|
tagOptions: recipeOptions,
|
|
tagOptionsLimit: 1,
|
|
spellCheck: false,
|
|
capitalize: false,
|
|
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
|
|
},
|
|
applyRecipeTitleGlobally: {
|
|
section: 'exif',
|
|
label: 'apply recipe title globally',
|
|
type: 'checkbox',
|
|
excludeFromInsert: true,
|
|
hideModificationStatus: true,
|
|
shouldHide: ({ make, recipeTitle, recipeData }, changedFormKeys) =>
|
|
!(
|
|
make === MAKE_FUJIFILM &&
|
|
recipeData &&
|
|
recipeTitle &&
|
|
changedFormKeys?.includes('recipeTitle')
|
|
),
|
|
},
|
|
recipeData: {
|
|
section: 'exif',
|
|
type: 'textarea',
|
|
label: 'recipe data',
|
|
spellCheck: false,
|
|
capitalize: false,
|
|
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
|
|
shouldNotOverwriteWithNullDataOnSync: true,
|
|
isJson: true,
|
|
validate: value => {
|
|
let validationMessage = undefined;
|
|
if (value) {
|
|
try {
|
|
JSON.parse(value);
|
|
} catch {
|
|
validationMessage = 'Invalid JSON';
|
|
}
|
|
}
|
|
return validationMessage;
|
|
},
|
|
},
|
|
focalLength: {
|
|
section: 'exif',
|
|
label: 'focal length',
|
|
},
|
|
focalLengthIn35MmFormat: {
|
|
section: 'exif',
|
|
label: 'focal length 35mm-equivalent',
|
|
},
|
|
lensMake: { section: 'exif', label: 'lens make' },
|
|
lensModel: { section: 'exif', label: 'lens model' },
|
|
fNumber: { section: 'exif', label: 'aperture' },
|
|
iso: { section: 'exif', label: 'ISO' },
|
|
exposureTime: { section: 'exif', label: 'exposure time' },
|
|
exposureCompensation: { section: 'exif', label: 'exposure compensation' },
|
|
locationName: {
|
|
section: 'exif',
|
|
label: 'location name',
|
|
shouldHide: () => true,
|
|
},
|
|
latitude: { section: 'exif', label: 'latitude' },
|
|
longitude: { section: 'exif', label: 'longitude' },
|
|
takenAt: {
|
|
section: 'exif',
|
|
label: 'taken at',
|
|
validate: validationMessagePostgresDateString,
|
|
},
|
|
takenAtNaive: {
|
|
section: 'exif',
|
|
label: 'taken at (naive)',
|
|
validate: validationMessageNaivePostgresDateString,
|
|
},
|
|
id: {
|
|
section: 'storage',
|
|
label: 'id',
|
|
readOnly: true,
|
|
hideIfEmpty: true,
|
|
},
|
|
url: {
|
|
section: 'storage',
|
|
label: 'storage url',
|
|
readOnly: true,
|
|
},
|
|
extension: {
|
|
section: 'storage',
|
|
label: 'extension',
|
|
readOnly: true,
|
|
},
|
|
blurData: {
|
|
section: 'storage',
|
|
label: 'blur data',
|
|
readOnly: true,
|
|
},
|
|
width: {
|
|
section: 'storage',
|
|
label: 'width',
|
|
readOnly: true,
|
|
hideIfEmpty: true,
|
|
},
|
|
height: {
|
|
section: 'storage',
|
|
label: 'height',
|
|
readOnly: true,
|
|
hideIfEmpty: true,
|
|
},
|
|
aspectRatio: {
|
|
section: 'storage',
|
|
label: 'aspect ratio',
|
|
readOnly: true,
|
|
},
|
|
priorityOrder: {
|
|
section: 'misc',
|
|
label: 'priority order',
|
|
},
|
|
colorData: {
|
|
section: 'misc',
|
|
type: 'textarea',
|
|
label: 'color data',
|
|
isJson: true,
|
|
shouldHide: () => !COLOR_SORT_ENABLED,
|
|
},
|
|
colorSort: {
|
|
section: 'misc',
|
|
label: 'color sort',
|
|
shouldHide: () => !COLOR_SORT_ENABLED,
|
|
},
|
|
shouldStripGpsData: {
|
|
section: 'misc',
|
|
label: 'strip gps data',
|
|
type: 'hidden',
|
|
excludeFromInsert: true,
|
|
staticValue: shouldStripGpsData ? 'true' : 'false',
|
|
},
|
|
});
|
|
|
|
export const FIELDS_WITH_JSON = Object.entries(FORM_METADATA())
|
|
.filter(([_, meta]) => meta.isJson)
|
|
.map(([key]) => key as keyof PhotoFormData);
|
|
|
|
export const FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC =
|
|
Object.entries(FORM_METADATA())
|
|
.filter(([_, meta]) => meta.shouldNotOverwriteWithNullDataOnSync)
|
|
.map(([key]) => key as keyof PhotoFormData);
|
|
|
|
export const FORM_METADATA_ENTRIES = (
|
|
...args: Parameters<typeof FORM_METADATA>
|
|
) =>
|
|
(Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]);
|
|
|
|
export const FORM_METADATA_ENTRIES_BY_SECTION = (
|
|
...args: Parameters<typeof FORM_METADATA>
|
|
) => {
|
|
const fields = (Object
|
|
.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]);
|
|
return fields.reduce((acc, field) => {
|
|
const section = acc.find(s => s.section === field[1].section);
|
|
if (section) {
|
|
section.fields.push(field);
|
|
} else {
|
|
acc.push({ section: field[1].section, fields: [field] });
|
|
}
|
|
return acc;
|
|
}, [] as {
|
|
section: string
|
|
fields: [keyof PhotoFormData, FormMeta][]
|
|
}[]);
|
|
};
|
|
|
|
export const FORM_SECTIONS = FORM_METADATA_ENTRIES_BY_SECTION()
|
|
.map(section => section.section);
|
|
|
|
export const convertFormKeysToLabels = (keys: (keyof PhotoFormData)[]) =>
|
|
keys.map(key => FORM_METADATA()[key].label.toUpperCase());
|
|
|
|
export const getFormErrors = (
|
|
formData: Partial<PhotoFormData>,
|
|
): Partial<Record<keyof PhotoFormData, string>> =>
|
|
Object.keys(formData).reduce((acc, key) => ({
|
|
...acc,
|
|
[key]: FORM_METADATA_ENTRIES().find(([k]) => k === key)?.[1]
|
|
.validate?.(formData[key as keyof PhotoFormData]),
|
|
}), {});
|
|
|
|
export const isFormValid = (formData: Partial<PhotoFormData>) =>
|
|
FORM_METADATA_ENTRIES().every(
|
|
([key, { required, validate, validateStringMaxLength }]) =>
|
|
(!required || Boolean(formData[key])) &&
|
|
(!validate?.(formData[key])) &&
|
|
// eslint-disable-next-line max-len
|
|
(!validateStringMaxLength || (formData[key]?.length ?? 0) <= validateStringMaxLength),
|
|
);
|
|
|
|
export const formHasExistingAiTextContent = ({
|
|
title,
|
|
caption,
|
|
tags,
|
|
semanticDescription,
|
|
}: Partial<PhotoFormData> = {}) =>
|
|
Boolean(title || caption || tags || semanticDescription);
|
|
|
|
// CREATE FORM DATA: FROM PHOTO
|
|
|
|
export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
|
|
const valueForKey = (key: keyof Photo, value: any) => {
|
|
switch (key) {
|
|
case 'tags':
|
|
return (value ?? [])
|
|
.filter((tag: string) => tag !== TAG_FAVS)
|
|
.join(', ');
|
|
case 'takenAt':
|
|
return value?.toISOString ? value.toISOString() : value;
|
|
case 'hidden':
|
|
return value ? 'true' : 'false';
|
|
case 'recipeData':
|
|
return JSON.stringify(value);
|
|
case 'colorData':
|
|
return JSON.stringify(value);
|
|
default:
|
|
return value !== undefined && value !== null
|
|
? value.toString()
|
|
: undefined;
|
|
}
|
|
};
|
|
return Object.entries(photo).reduce((photoForm, [key, value]) => ({
|
|
...photoForm,
|
|
[key]: valueForKey(key as keyof Photo, value),
|
|
}), {
|
|
favorite: photo.tags.includes(TAG_FAVS) ? 'true' : 'false',
|
|
} as PhotoFormData);
|
|
};
|
|
|
|
// PREPARE FORM FOR DB INSERT
|
|
|
|
export const convertFormDataToPhotoDbInsert = (
|
|
formData: FormData | Partial<PhotoFormData>,
|
|
): PhotoDbInsert => {
|
|
const photoForm = formData instanceof FormData
|
|
? Object.fromEntries(formData) as PhotoFormData
|
|
: formData;
|
|
|
|
// Capture tags before 'favorite' is excluded from insert
|
|
const tags = convertStringToArray(photoForm.tags) ?? [];
|
|
if (photoForm.favorite === 'true') {
|
|
tags.push(TAG_FAVS);
|
|
}
|
|
|
|
// Parse FormData:
|
|
// - remove server action ID
|
|
// - remove empty strings
|
|
// - remove fields excluded from insert
|
|
// - trim strings
|
|
Object.keys(photoForm).forEach(key => {
|
|
const meta = FORM_METADATA()[key as keyof PhotoFormData];
|
|
if (
|
|
key.startsWith('$ACTION_ID_') ||
|
|
(photoForm as any)[key] === '' ||
|
|
meta?.excludeFromInsert
|
|
) {
|
|
delete (photoForm as any)[key];
|
|
} else if (typeof (photoForm as any)[key] === 'string') {
|
|
(photoForm as any)[key] = (photoForm as any)[key].trim();
|
|
}
|
|
});
|
|
|
|
return {
|
|
...(photoForm as PhotoFormData & {
|
|
film?: FujifilmSimulation
|
|
recipeData?: FujifilmRecipe
|
|
}),
|
|
...!photoForm.id && { id: generateNanoid() },
|
|
// Delete array field when empty
|
|
tags: tags.length > 0 ? tags : undefined,
|
|
...photoForm.recipeTitle && {
|
|
recipeTitle: parameterize(photoForm.recipeTitle),
|
|
},
|
|
width: photoForm.width
|
|
? parseInt(photoForm.width)
|
|
: undefined,
|
|
height: photoForm.height
|
|
? parseInt(photoForm.height)
|
|
: undefined,
|
|
// Convert form strings to numbers
|
|
aspectRatio: photoForm.aspectRatio
|
|
? roundToNumber(parseFloat(photoForm.aspectRatio), 6)
|
|
: DEFAULT_ASPECT_RATIO,
|
|
focalLength: photoForm.focalLength
|
|
? parseInt(photoForm.focalLength)
|
|
: undefined,
|
|
focalLengthIn35MmFormat: photoForm.focalLengthIn35MmFormat
|
|
? parseInt(photoForm.focalLengthIn35MmFormat)
|
|
: undefined,
|
|
fNumber: photoForm.fNumber
|
|
? parseFloat(photoForm.fNumber)
|
|
: undefined,
|
|
latitude: photoForm.latitude
|
|
? parseFloat(photoForm.latitude)
|
|
: undefined,
|
|
longitude: photoForm.longitude
|
|
? parseFloat(photoForm.longitude)
|
|
: undefined,
|
|
iso: photoForm.iso
|
|
? parseInt(photoForm.iso)
|
|
: undefined,
|
|
exposureTime: photoForm.exposureTime
|
|
? parseFloat(photoForm.exposureTime)
|
|
: undefined,
|
|
exposureCompensation: photoForm.exposureCompensation
|
|
? parseFloat(photoForm.exposureCompensation)
|
|
: undefined,
|
|
colorSort: photoForm.colorSort
|
|
? parseInt(photoForm.colorSort)
|
|
: undefined,
|
|
priorityOrder: photoForm.priorityOrder
|
|
? parseFloat(photoForm.priorityOrder)
|
|
: undefined,
|
|
excludeFromFeeds: photoForm.excludeFromFeeds === 'true',
|
|
hidden: photoForm.hidden === 'true',
|
|
...generateTakenAtFields(photoForm),
|
|
};
|
|
};
|
|
|
|
export const getChangedFormFields = (
|
|
original: Partial<PhotoFormData>,
|
|
current: Partial<PhotoFormData>,
|
|
) => {
|
|
return Object
|
|
.keys(current)
|
|
.filter(key =>
|
|
(original[key as keyof PhotoFormData] ?? '') !==
|
|
(current[key as keyof PhotoFormData] ?? ''),
|
|
) as (keyof PhotoFormData)[];
|
|
};
|
|
|
|
export const generateTakenAtFields = (
|
|
form?: Partial<PhotoFormData>,
|
|
): { takenAt: string, takenAtNaive: string } => ({
|
|
takenAt: form?.takenAt || generateLocalPostgresString(),
|
|
takenAtNaive: form?.takenAtNaive || generateLocalNaivePostgresString(),
|
|
});
|