Allow users to opt out of global recipe titling

This commit is contained in:
Sam Becker 2025-03-13 17:47:39 -05:00
parent 3b39a1b62e
commit 8b6ea0da6d
5 changed files with 131 additions and 45 deletions

View File

@ -12,6 +12,7 @@ import {
getUniqueTags,
deletePhotoRecipeGlobally,
renamePhotoRecipeGlobally,
getPhotosNeedingRecipeTitleCount,
} from '@/photo/db/query';
import { GetPhotosOptions, areOptionsSensitive } from './db';
import {
@ -305,6 +306,13 @@ export const renamePhotoTagGloballyAction = async (formData: FormData) =>
}
});
export const getPhotosNeedingRecipeTitleCountAction = async (
recipeData: string,
) =>
runAuthenticatedAdminServerAction(async () =>
await getPhotosNeedingRecipeTitleCount(recipeData),
);
export const deletePhotoRecipeGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const recipe = formData.get('recipe') as string;

View File

@ -359,6 +359,14 @@ export const getRecipeTitleForData = async (data: string | object) =>
.then(({ rows }) => rows[0]?.recipe_title as string | undefined)
, 'getRecipeTitleForData');
export const getPhotosNeedingRecipeTitleCount = async (data: string) =>
safelyQueryPhotos(() => sql`
SELECT COUNT(*)
FROM photos
WHERE recipe_title IS NULL AND recipe_data = ${data}
`.then(({ rows }) => parseInt(rows[0].count, 10))
, 'getPhotosNeedingRecipeTitleCount');
export const updateAllMatchingRecipeTitles = (
title: string,
data: string,

View File

@ -0,0 +1,34 @@
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
import { ComponentProps, useEffect, useState } from 'react';
import { getPhotosNeedingRecipeTitleCountAction } from '../actions';
export default function ApplyRecipeTitleGloballyCheckbox({
recipeTitle,
hasRecipeTitleChanged,
recipeData,
...props
}: ComponentProps<typeof FieldSetWithStatus> & {
recipeTitle?: string
hasRecipeTitleChanged?: boolean
recipeData?: string
}) {
const [matchingPhotosCount, setMatchingPhotosCount] = useState<number>();
useEffect(() => {
if (recipeTitle && hasRecipeTitleChanged && recipeData) {
setMatchingPhotosCount(undefined);
getPhotosNeedingRecipeTitleCountAction(recipeData)
.then(setMatchingPhotosCount);
} else {
setMatchingPhotosCount(0);
}
}, [recipeTitle, hasRecipeTitleChanged, recipeData]);
return (
<FieldSetWithStatus {...{
...props,
label: `Apply title to ${matchingPhotosCount} photos`,
type: 'checkbox',
}} />
);
}

View File

@ -1,9 +1,11 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { ComponentProps, useEffect, useMemo, useState } from 'react';
import {
FIELDS_WITH_JSON,
FORM_METADATA_ENTRIES,
FormFields,
FormMeta,
PhotoFormData,
convertFormKeysToLabels,
formHasTextContent,
@ -29,10 +31,10 @@ import { useAppState } from '@/state/AppState';
import UpdateBlurDataButton from '../UpdateBlurDataButton';
import { getNextImageUrlForManipulation } from '@/platforms/next-image';
import { BLUR_ENABLED, IS_PREVIEW } from '@/app/config';
import { PhotoDbInsert } from '..';
import ErrorNote from '@/components/ErrorNote';
import { convertRecipesForForm, Recipes } from '@/recipe';
import deepEqual from 'fast-deep-equal/es6/react';
import ApplyRecipeTitleGloballyCheckbox from './ApplyRecipesGloballyCheckbox';
const THUMBNAIL_SIZE = 300;
@ -223,9 +225,9 @@ export default function PhotoForm({
};
const shouldHideField = (
key: keyof PhotoDbInsert | 'favorite',
key: FormFields,
hideIfEmpty?: boolean,
shouldHide?: (formData: Partial<PhotoFormData>) => boolean,
shouldHide?: FormMeta['shouldHide'],
) => {
if (
key === 'blurData' &&
@ -237,7 +239,7 @@ export default function PhotoForm({
} else {
return (
(hideIfEmpty && !formData[key]) ||
shouldHide?.(formData)
shouldHide?.(formData, changedFormKeys)
);
}
};
@ -326,25 +328,27 @@ export default function PhotoForm({
shouldHide,
loadingMessage,
type,
}]) =>
!shouldHideField(key, hideIfEmpty, shouldHide) &&
<FieldSetWithStatus
key={key}
id={key}
label={label + (
}]) => {
if (!shouldHideField(key, hideIfEmpty, shouldHide)) {
const fieldProps: ComponentProps<typeof FieldSetWithStatus> = {
id: key,
label: label + (
key === 'blurData' && shouldDebugImageFallbacks
? ` (${(formData[key] ?? '').length} chars.)`
: ''
)}
note={note}
error={formErrors[key]}
value={formData[key] ?? ''}
isModified={changedFormKeys.includes(key)}
onChange={value => {
),
note,
error: formErrors[key],
value: formData[key] ?? '',
isModified: changedFormKeys.includes(key),
onChange: value => {
const formUpdated = { ...formData, [key]: value };
setFormData(formUpdated);
if (validate) {
setFormErrors({ ...formErrors, [key]: validate(value) });
setFormErrors({
...formErrors, [key]:
validate(value),
});
} else if (validateStringMaxLength !== undefined) {
setFormErrors({
...formErrors,
@ -356,26 +360,41 @@ export default function PhotoForm({
if (key === 'title') {
onTitleChange?.(value.trim());
}
}}
selectOptions={selectOptions}
selectOptionsDefaultLabel={selectOptionsDefaultLabel}
tagOptions={tagOptions}
tagOptionsLimit={tagOptionsLimit}
// eslint-disable-next-line max-len
tagOptionsLimitValidationMessage={tagOptionsLimitValidationMessage}
required={required}
readOnly={readOnly}
spellCheck={spellCheck}
capitalize={capitalize}
placeholder={loadingMessage && !formData[key]
},
selectOptions,
selectOptionsDefaultLabel: selectOptionsDefaultLabel,
tagOptions,
tagOptionsLimit,
tagOptionsLimitValidationMessage,
required,
readOnly,
spellCheck,
capitalize,
placeholder: loadingMessage && !formData[key]
? loadingMessage
: undefined}
loading={
: undefined,
loading: (
(loadingMessage && !formData[key] ? true : false) ||
isFieldGeneratingAi(key)}
type={type}
accessory={accessoryForField(key)}
/>)}
isFieldGeneratingAi(key)
),
type,
accessory: accessoryForField(key),
};
return key === 'applyRecipeTitleGlobally'
? <ApplyRecipeTitleGloballyCheckbox
key={key}
recipeTitle={formData.recipeTitle}
recipeData={formData.recipeData}
hasRecipeTitleChanged={
changedFormKeys.includes('recipeTitle')}
{...fieldProps}
/>
: <FieldSetWithStatus
key={key}
{...fieldProps}
/>;
}
})}
<input
type="hidden"
name="shouldStripGpsData"

View File

@ -25,9 +25,13 @@ import { TAG_FAVS, getValidationMessageForTags } from '@/tag';
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
type VirtualFields = 'favorite';
type VirtualFields =
'favorite' |
'applyRecipeTitleGlobally';
export type PhotoFormData = Record<keyof PhotoDbInsert | VirtualFields, string>
export type FormFields = keyof PhotoDbInsert | VirtualFields;
export type PhotoFormData = Record<FormFields, string>
export type FieldSetType =
'text' |
@ -42,7 +46,7 @@ export type AnnotatedTag = {
annotationAria?: string,
};
type FormMeta = {
export type FormMeta = {
label: string
note?: string
required?: boolean
@ -52,9 +56,11 @@ type FormMeta = {
validateStringMaxLength?: number
spellCheck?: boolean
capitalize?: boolean
hide?: boolean
hideIfEmpty?: boolean
shouldHide?: (formData: Partial<PhotoFormData>) => boolean
shouldHide?: (
formData: Partial<PhotoFormData>,
changedFormKeys?: (keyof PhotoFormData)[],
) => boolean
loadingMessage?: string
type?: FieldSetType
selectOptions?: { value: string, label: string }[]
@ -96,7 +102,7 @@ const FORM_METADATA = (
label: 'semantic description (not visible)',
capitalize: true,
validateStringMaxLength: STRING_MAX_LENGTH_LONG,
hide: !aiTextGeneration,
shouldHide: () => !aiTextGeneration,
},
id: { label: 'id', readOnly: true, hideIfEmpty: true },
blurData: {
@ -124,6 +130,18 @@ const FORM_METADATA = (
capitalize: false,
shouldHide: ({ make }) => make !== MAKE_FUJIFILM,
},
applyRecipeTitleGlobally: {
label: 'apply recipe title globally',
type: 'checkbox',
excludeFromInsert: true,
shouldHide: ({ make, recipeTitle, recipeData }, changedFormKeys) =>
!(
make === MAKE_FUJIFILM &&
recipeData &&
recipeTitle &&
changedFormKeys?.includes('recipeTitle')
),
},
recipeData: {
type: 'textarea',
label: 'recipe data',
@ -152,7 +170,7 @@ const FORM_METADATA = (
iso: { label: 'ISO' },
exposureTime: { label: 'exposure time' },
exposureCompensation: { label: 'exposure compensation' },
locationName: { label: 'location name', hide: true },
locationName: { label: 'location name', shouldHide: () => true },
latitude: { label: 'latitude' },
longitude: { label: 'longitude' },
takenAt: {
@ -180,8 +198,7 @@ export const FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC =
export const FORM_METADATA_ENTRIES = (
...args: Parameters<typeof FORM_METADATA>
) =>
(Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][])
.filter(([_, meta]) => !meta.hide);
(Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][]);
export const convertFormKeysToLabels = (keys: (keyof PhotoFormData)[]) =>
keys.map(key => FORM_METADATA()[key].label.toUpperCase());