Allow editing recipe data, protect manually configured fuji fields

This commit is contained in:
Sam Becker 2025-02-24 17:28:37 -06:00
parent 2b44a5fa04
commit 541c09c551
8 changed files with 72 additions and 33 deletions

View File

@ -21,7 +21,7 @@ export default async function UploadPage({ params }: Params) {
const {
blobId,
photoFormExif,
formDataFromExif,
imageResizedBase64: imageThumbnailBase64,
shouldStripGpsData,
error,
@ -32,7 +32,7 @@ export default async function UploadPage({ params }: Params) {
});
const isDataMissing =
!photoFormExif ||
!formDataFromExif ||
(AI_TEXT_GENERATION_ENABLED && !imageThumbnailBase64);
if (isDataMissing && !error) {
@ -50,7 +50,7 @@ export default async function UploadPage({ params }: Params) {
!isDataMissing
? <UploadPageClient {...{
blobId,
photoFormExif,
formDataFromExif,
uniqueTags,
hasAiTextGeneration,
textFieldsToAutoGenerate,

View File

@ -23,6 +23,7 @@ export default function FieldSetWithStatus({
loading,
required,
readOnly,
spellCheck,
capitalize,
type = 'text',
inputRef,
@ -43,6 +44,7 @@ export default function FieldSetWithStatus({
loading?: boolean
required?: boolean
readOnly?: boolean
spellCheck?: boolean
capitalize?: boolean
type?: FieldSetType
inputRef?: Ref<HTMLInputElement>
@ -140,6 +142,8 @@ export default function FieldSetWithStatus({
placeholder={placeholder}
onChange={e => onChange?.(e.target.value)}
readOnly={readOnly || pending || loading}
spellCheck={spellCheck}
autoCapitalize={!capitalize ? 'off' : undefined}
className={clsx(
'w-full h-24 resize-none',
Boolean(error) && 'error',
@ -156,6 +160,7 @@ export default function FieldSetWithStatus({
? e.target.value === 'true' ? 'false' : 'true'
: e.target.value)}
type={type}
spellCheck={spellCheck}
autoComplete="off"
autoCapitalize={!capitalize ? 'off' : undefined}
readOnly={readOnly || pending || loading}

View File

@ -307,12 +307,16 @@ export default function PhotoLarge({
<li>{photo.isoFormatted}</li>
<li>{photo.exposureCompensationFormatted ?? '0ev'}</li>
</ul>
{showSimulation && photo.filmSimulation &&
{(
(showSimulation && photo.filmSimulation)
|| photo.fujifilmRecipe
) &&
<div className="flex items-center gap-2 *:w-auto">
<PhotoFilmSimulation
simulation={photo.filmSimulation}
prefetch={prefetchRelatedLinks}
/>
{showSimulation && photo.filmSimulation &&
<PhotoFilmSimulation
simulation={photo.filmSimulation}
prefetch={prefetchRelatedLinks}
/>}
{photo.fujifilmRecipe &&
<button
ref={refRecipeTrigger}

View File

@ -12,7 +12,7 @@ import { useMemo } from 'react';
export default function UploadPageClient({
blobId,
photoFormExif,
formDataFromExif,
uniqueTags,
hasAiTextGeneration,
textFieldsToAutoGenerate,
@ -20,7 +20,7 @@ export default function UploadPageClient({
shouldStripGpsData,
}: {
blobId?: string
photoFormExif: Partial<PhotoFormData>
formDataFromExif: Partial<PhotoFormData>
uniqueTags: Tags
hasAiTextGeneration?: boolean
textFieldsToAutoGenerate?: AiAutoGeneratedField[],
@ -41,10 +41,10 @@ export default function UploadPageClient({
});
const initialPhotoForm = useMemo(() => ({
...photoFormExif,
...formDataFromExif,
// Generate missing dates on client to avoid timezone issues
...generateTakenAtFields(photoFormExif),
}), [photoFormExif]);
...generateTakenAtFields(formDataFromExif),
}), [formDataFromExif]);
return (
<AdminChildPage

View File

@ -13,6 +13,7 @@ import {
} from '@/photo/db/query';
import { GetPhotosOptions, areOptionsSensitive } from './db';
import {
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
PhotoFormData,
convertFormDataToPhotoDbInsert,
convertPhotoToFormData,
@ -115,7 +116,7 @@ export const addAllUploadsAction = async ({
streamUpdate('Parsing EXIF data');
const {
photoFormExif,
formDataFromExif,
imageResizedBase64,
shouldStripGpsData,
fileBytes,
@ -125,7 +126,7 @@ export const addAllUploadsAction = async ({
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
});
if (photoFormExif) {
if (formDataFromExif) {
if (AI_TEXT_GENERATION_ENABLED) {
streamUpdate('Generating AI text');
}
@ -141,13 +142,13 @@ export const addAllUploadsAction = async ({
);
const form: Partial<PhotoFormData> = {
...photoFormExif,
...formDataFromExif,
title,
caption,
tags: tags || aiTags,
semanticDescription,
takenAt: photoFormExif.takenAt || takenAtLocal,
takenAtNaive: photoFormExif.takenAtNaive || takenAtNaiveLocal,
takenAt: formDataFromExif.takenAt || takenAtLocal,
takenAtNaive: formDataFromExif.takenAtNaive || takenAtNaiveLocal,
};
streamUpdate('Transferring to photo storage');
@ -302,9 +303,9 @@ export const getExifDataAction = async (
url: string,
): Promise<Partial<PhotoFormData>> =>
runAuthenticatedAdminServerAction(async () => {
const { photoFormExif } = await extractImageDataFromBlobPath(url);
if (photoFormExif) {
return photoFormExif;
const { formDataFromExif } = await extractImageDataFromBlobPath(url);
if (formDataFromExif) {
return formDataFromExif;
} else {
return {};
}
@ -322,7 +323,7 @@ export const syncPhotoAction = async (photoId: string) =>
if (photo) {
const {
photoFormExif,
formDataFromExif,
imageResizedBase64,
shouldStripGpsData,
fileBytes,
@ -333,7 +334,7 @@ export const syncPhotoAction = async (photoId: string) =>
});
let urlToDelete: string | undefined;
if (photoFormExif) {
if (formDataFromExif) {
if (photo.url.includes(photo.id) || shouldStripGpsData) {
// Anonymize storage url on update if necessary by
// re-running image upload transfer logic
@ -359,11 +360,18 @@ export const syncPhotoAction = async (photoId: string) =>
AI_TEXT_AUTO_GENERATED_FIELDS,
);
const formDataFromPhoto = convertPhotoToFormData(photo);
// Don't overwrite manually configured fujifilm meta with null data
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC.forEach(field => {
if (!formDataFromExif[field] && formDataFromPhoto[field]) {
delete formDataFromExif[field];
}
});
const photoFormDbInsert = convertFormDataToPhotoDbInsert({
...convertPhotoToFormData(photo),
...photoFormExif,
// Don't overwrite manually configured film simulations
...photo.filmSimulation && { filmSimulation: photo.filmSimulation },
...formDataFromPhoto,
...formDataFromExif,
...!BLUR_ENABLED && { blurData: undefined },
...!photo.title && { title: atTitle },
...!photo.caption && { caption: aiCaption },

View File

@ -305,6 +305,7 @@ export default function PhotoForm({
readOnly,
validate,
validateStringMaxLength,
spellCheck,
capitalize,
hideIfEmpty,
shouldHide,
@ -346,6 +347,7 @@ export default function PhotoForm({
tagOptions={tagOptions}
required={required}
readOnly={readOnly}
spellCheck={spellCheck}
capitalize={capitalize}
placeholder={loadingMessage && !formData[key]
? loadingMessage

View File

@ -50,6 +50,7 @@ type FormMeta = {
readOnly?: boolean
validate?: (value?: string) => string | undefined
validateStringMaxLength?: number
spellCheck?: boolean
capitalize?: boolean
hide?: boolean
hideIfEmpty?: boolean
@ -59,6 +60,7 @@ type FormMeta = {
selectOptions?: { value: string, label: string }[]
selectOptionsDefaultLabel?: string
tagOptions?: AnnotatedTag[]
shouldNotOverwriteWithNullDataOnSync?: boolean
};
const STRING_MAX_LENGTH_SHORT = 255;
@ -107,12 +109,26 @@ const FORM_METADATA = (
selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS,
selectOptionsDefaultLabel: 'Unknown',
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
shouldNotOverwriteWithNullDataOnSync: true,
},
fujifilmRecipe: {
type: 'textarea',
label: 'fujifilm recipe',
spellCheck: false,
capitalize: false,
validate: value => {
let validationMessage = undefined;
if (value) {
try {
JSON.parse(value);
} catch {
validationMessage = 'Invalid JSON';
}
}
return validationMessage;
},
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
readOnly: true,
shouldNotOverwriteWithNullDataOnSync: true,
},
focalLength: { label: 'focal length' },
focalLengthIn35MmFormat: { label: 'focal length 35mm-equivalent' },
@ -138,6 +154,11 @@ const FORM_METADATA = (
hidden: { label: 'hidden', type: 'checkbox' },
});
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>
) =>
@ -175,9 +196,8 @@ export const formHasTextContent = ({
// CREATE FORM DATA: FROM PHOTO
export const convertPhotoToFormData = (
photo: Photo,
): PhotoFormData => {
export const convertPhotoToFormData = (photo: Photo): PhotoFormData => {
console.log('convertPhotoToFormData', photo);
const valueForKey = (key: keyof Photo, value: any) => {
switch (key) {
case 'tags':

View File

@ -31,7 +31,7 @@ export const extractImageDataFromBlobPath = async (
},
): Promise<{
blobId?: string
photoFormExif?: Partial<PhotoFormData>
formDataFromExif?: Partial<PhotoFormData>
imageResizedBase64?: string
shouldStripGpsData?: boolean
fileBytes?: ArrayBuffer
@ -108,7 +108,7 @@ export const extractImageDataFromBlobPath = async (
return {
blobId,
...exifData && {
photoFormExif: {
formDataFromExif: {
...includeInitialPhotoFields && {
hidden: 'false',
favorite: 'false',