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

View File

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