Vercel/src/photo/actions.ts
2025-04-04 18:09:47 -05:00

513 lines
14 KiB
TypeScript

'use server';
import {
deletePhoto,
insertPhoto,
deletePhotoTagGlobally,
updatePhoto,
renamePhotoTagGlobally,
getPhoto,
getPhotos,
addTagsToPhotos,
getUniqueTags,
deletePhotoRecipeGlobally,
renamePhotoRecipeGlobally,
getPhotosNeedingRecipeTitleCount,
} from '@/photo/db/query';
import { GetPhotosOptions, areOptionsSensitive } from './db';
import {
FIELDS_TO_NOT_OVERWRITE_WITH_NULL_DATA_ON_SYNC,
PhotoFormData,
convertPhotoToFormData,
} from './form';
import { redirect } from 'next/navigation';
import { deleteFile } from '@/platforms/storage';
import {
getPhotosCached,
revalidateAdminPaths,
revalidateAllKeysAndPaths,
revalidatePhoto,
revalidatePhotosKey,
revalidateRecipesKey,
revalidateTagsKey,
} from '@/photo/cache';
import {
PATH_ADMIN_PHOTOS,
PATH_ADMIN_RECIPES,
PATH_ADMIN_TAGS,
PATH_ROOT,
pathForPhoto,
} from '@/app/paths';
import {
blurImageFromUrl,
convertFormDataToPhotoDbInsertAndLookupRecipeTitle,
extractImageDataFromBlobPath,
propagateRecipeTitleIfNecessary,
} from './server';
import { TAG_FAVS, isTagFavs } from '@/tag';
import { convertPhotoToPhotoDbInsert, Photo } from '.';
import { runAuthenticatedAdminServerAction } from '@/auth/server';
import { AiImageQuery, getAiImageQuery } from './ai';
import { streamOpenAiImageQuery } from '@/platforms/openai';
import {
AI_TEXT_AUTO_GENERATED_FIELDS,
AI_TEXT_GENERATION_ENABLED,
BLUR_ENABLED,
} from '@/app/config';
import { generateAiImageQueries } from './ai/server';
import { createStreamableValue } from 'ai/rsc';
import { convertUploadToPhoto } from './storage';
import { UrlAddStatus } from '@/admin/AdminUploadsClient';
import { convertStringToArray } from '@/utility/string';
import { after } from 'next/server';
// Private actions
export const createPhotoAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const shouldStripGpsData = formData.get('shouldStripGpsData') === 'true';
const photo = await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(
formData,
);
const updatedUrl = await convertUploadToPhoto({
urlOrigin: photo.url,
shouldStripGpsData,
});
if (updatedUrl) {
photo.url = updatedUrl;
await insertPhoto(photo);
await propagateRecipeTitleIfNecessary(formData, photo);
revalidateAllKeysAndPaths();
redirect(PATH_ADMIN_PHOTOS);
}
});
export const addAllUploadsAction = async ({
uploadUrls,
tags,
favorite,
hidden,
takenAtLocal,
takenAtNaiveLocal,
shouldRevalidateAllKeysAndPaths = true,
}: {
uploadUrls: string[]
tags?: string
favorite?: string
hidden?: string
takenAtLocal: string
takenAtNaiveLocal: string
shouldRevalidateAllKeysAndPaths?: boolean
}) =>
runAuthenticatedAdminServerAction(async () => {
const PROGRESS_TASK_COUNT = AI_TEXT_GENERATION_ENABLED ? 5 : 4;
const addedUploadUrls: string[] = [];
let currentUploadUrl = '';
let progress = 0;
const stream = createStreamableValue<Omit<UrlAddStatus, 'fileName'>>();
const streamUpdate = (
statusMessage: string,
status: UrlAddStatus['status'] = 'adding',
) =>
stream.update({
url: currentUploadUrl,
status,
statusMessage,
progress: ++progress / PROGRESS_TASK_COUNT,
});
(async () => {
try {
for (const url of uploadUrls) {
currentUploadUrl = url;
progress = 0;
streamUpdate('Parsing EXIF data');
const {
formDataFromExif,
imageResizedBase64,
shouldStripGpsData,
fileBytes,
} = await extractImageDataFromBlobPath(url, {
includeInitialPhotoFields: true,
generateBlurData: BLUR_ENABLED,
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
});
if (formDataFromExif) {
if (AI_TEXT_GENERATION_ENABLED) {
streamUpdate('Generating AI text');
}
const {
title,
caption,
tags: aiTags,
semanticDescription,
} = await generateAiImageQueries(
imageResizedBase64,
AI_TEXT_AUTO_GENERATED_FIELDS,
);
const form: Partial<PhotoFormData> = {
...formDataFromExif,
title,
caption,
tags: tags || aiTags,
hidden,
favorite,
semanticDescription,
takenAt: formDataFromExif.takenAt || takenAtLocal,
takenAtNaive: formDataFromExif.takenAtNaive || takenAtNaiveLocal,
};
streamUpdate('Transferring to photo storage');
const updatedUrl = await convertUploadToPhoto({
urlOrigin: url,
fileBytes,
shouldStripGpsData,
});
if (updatedUrl) {
const subheadFinal = 'Adding to database';
streamUpdate(subheadFinal);
const photo =
await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(form);
photo.url = updatedUrl;
await insertPhoto(photo);
addedUploadUrls.push(url);
// Re-submit with updated url
streamUpdate(subheadFinal, 'added');
}
}
};
} catch (error: any) {
// eslint-disable-next-line max-len
stream.error(`${error.message} (${addedUploadUrls.length} of ${uploadUrls.length} photos successfully added)`);
}
stream.done();
})();
if (shouldRevalidateAllKeysAndPaths) {
after(revalidateAllKeysAndPaths);
}
return stream.value;
});
export const updatePhotoAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const photo =
await convertFormDataToPhotoDbInsertAndLookupRecipeTitle(formData);
let urlToDelete: string | undefined;
if (photo.hidden && photo.url.includes(photo.id)) {
// Backfill:
// Anonymize storage url on update if necessary by
// re-running image upload transfer logic
const url = await convertUploadToPhoto({
urlOrigin: photo.url,
shouldDeleteOrigin: false,
});
if (url) {
urlToDelete = photo.url;
photo.url = url;
}
}
await updatePhoto(photo)
.then(async () => {
if (urlToDelete) {
await deleteFile(urlToDelete);
}
await propagateRecipeTitleIfNecessary(formData, photo);
});
revalidatePhoto(photo.id);
redirect(PATH_ADMIN_PHOTOS);
});
export const tagMultiplePhotosAction = async (
tags: string,
photoIds: string[],
) =>
runAuthenticatedAdminServerAction(async () => {
await addTagsToPhotos(
convertStringToArray(tags, false) ?? [],
photoIds,
);
revalidateAllKeysAndPaths();
});
export const toggleFavoritePhotoAction = async (
photoId: string,
shouldRedirect?: boolean,
) =>
runAuthenticatedAdminServerAction(async () => {
const photo = await getPhoto(photoId);
if (photo) {
const { tags } = photo;
photo.tags = tags.some(tag => tag === TAG_FAVS)
? tags.filter(tag => !isTagFavs(tag))
: [...tags, TAG_FAVS];
await updatePhoto(convertPhotoToPhotoDbInsert(photo));
revalidateAllKeysAndPaths();
if (shouldRedirect) {
redirect(pathForPhoto({ photo: photoId }));
}
}
});
export const deletePhotosAction = async (photoIds: string[]) =>
runAuthenticatedAdminServerAction(async () => {
for (const photoId of photoIds) {
const photo = await getPhoto(photoId, true);
if (photo) {
await deletePhoto(photoId).then(() => deleteFile(photo.url));
}
}
revalidateAllKeysAndPaths();
});
export const deletePhotoAction = async (
photoId: string,
photoUrl: string,
shouldRedirect?: boolean,
) =>
runAuthenticatedAdminServerAction(async () => {
await deletePhoto(photoId).then(() => deleteFile(photoUrl));
revalidateAllKeysAndPaths();
if (shouldRedirect) {
redirect(PATH_ROOT);
}
});
export const deletePhotoTagGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const tag = formData.get('tag') as string;
await deletePhotoTagGlobally(tag);
revalidatePhotosKey();
revalidateAdminPaths();
});
export const renamePhotoTagGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const tag = formData.get('tag') as string;
const updatedTag = formData.get('updatedTag') as string;
if (tag && updatedTag && tag !== updatedTag) {
await renamePhotoTagGlobally(tag, updatedTag);
revalidatePhotosKey();
revalidateTagsKey();
redirect(PATH_ADMIN_TAGS);
}
});
export const getPhotosNeedingRecipeTitleCountAction = async (
recipeData: string,
film: string,
photoIdToExclude?: string,
) =>
runAuthenticatedAdminServerAction(async () =>
await getPhotosNeedingRecipeTitleCount(
recipeData,
film,
photoIdToExclude,
),
);
export const deletePhotoRecipeGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const recipe = formData.get('recipe') as string;
await deletePhotoRecipeGlobally(recipe);
revalidatePhotosKey();
revalidateAdminPaths();
});
export const renamePhotoRecipeGloballyAction = async (formData: FormData) =>
runAuthenticatedAdminServerAction(async () => {
const recipe = formData.get('recipe') as string;
const updatedRecipe = formData.get('updatedRecipe') as string;
if (recipe && updatedRecipe && recipe !== updatedRecipe) {
await renamePhotoRecipeGlobally(recipe, updatedRecipe);
revalidatePhotosKey();
revalidateRecipesKey();
redirect(PATH_ADMIN_RECIPES);
}
});
export const deleteUploadsAction = async (urls: string[]) =>
runAuthenticatedAdminServerAction(async () => {
await Promise.all(urls.map(url => deleteFile(url)));
revalidateAdminPaths();
});
// Accessed from admin photo edit page
// will not update blur data
export const getExifDataAction = async (
url: string,
): Promise<Partial<PhotoFormData>> =>
runAuthenticatedAdminServerAction(async () => {
const { formDataFromExif } = await extractImageDataFromBlobPath(url);
if (formDataFromExif) {
return formDataFromExif;
} else {
return {};
}
});
// Accessed from admin photo table, will:
// - update EXIF data
// - anonymize storage url if necessary
// - strip GPS data if necessary
// - update blur data (or destroy if blur is disabled)
// - generate AI text data, if enabled, and auto-generated fields are empty
export const syncPhotoAction = async (photoId: string) =>
runAuthenticatedAdminServerAction(async () => {
const photo = await getPhoto(photoId ?? '', true);
if (photo) {
const {
formDataFromExif,
imageResizedBase64,
shouldStripGpsData,
fileBytes,
} = await extractImageDataFromBlobPath(photo.url, {
includeInitialPhotoFields: false,
generateBlurData: BLUR_ENABLED,
generateResizedImage: AI_TEXT_GENERATION_ENABLED,
});
let urlToDelete: string | undefined;
if (formDataFromExif) {
if (photo.url.includes(photo.id) || shouldStripGpsData) {
// Anonymize storage url on update if necessary by
// re-running image upload transfer logic
const url = await convertUploadToPhoto({
urlOrigin: photo.url,
fileBytes,
shouldStripGpsData,
shouldDeleteOrigin: false,
});
if (url) {
urlToDelete = photo.url;
photo.url = url;
}
}
const {
title: atTitle,
caption: aiCaption,
tags: aiTags,
semanticDescription: aiSemanticDescription,
} = await generateAiImageQueries(
imageResizedBase64,
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 =
await convertFormDataToPhotoDbInsertAndLookupRecipeTitle({
...formDataFromPhoto,
...formDataFromExif,
...!BLUR_ENABLED && { blurData: undefined },
...!photo.title && { title: atTitle },
...!photo.caption && { caption: aiCaption },
...photo.tags.length === 0 && { tags: aiTags },
...!photo.semanticDescription &&
{ semanticDescription: aiSemanticDescription },
});
await updatePhoto(photoFormDbInsert)
.then(async () => {
if (urlToDelete) { await deleteFile(urlToDelete); }
});
revalidateAllKeysAndPaths();
}
}
});
export const syncPhotosAction = async (photoIds: string[]) =>
runAuthenticatedAdminServerAction(async () => {
for (const photoId of photoIds) {
await syncPhotoAction(photoId);
}
revalidateAllKeysAndPaths();
});
export const clearCacheAction = async () =>
runAuthenticatedAdminServerAction(revalidateAllKeysAndPaths);
export const streamAiImageQueryAction = async (
imageBase64: string,
query: AiImageQuery,
) =>
runAuthenticatedAdminServerAction(async () => {
const existingTags = await getUniqueTags();
return streamOpenAiImageQuery(
imageBase64,
getAiImageQuery(query, existingTags),
);
});
export const getImageBlurAction = async (url: string) =>
runAuthenticatedAdminServerAction(() => blurImageFromUrl(url));
// Public/Private actions
export const getPhotosAction = async (
options: GetPhotosOptions,
warmOnly?: boolean,
) => {
if (warmOnly) {
return [];
} else {
return areOptionsSensitive(options)
? runAuthenticatedAdminServerAction(() => getPhotos(options))
: getPhotos(options);
}
};
export const getPhotosCachedAction = async (
options: GetPhotosOptions,
warmOnly?: boolean,
) => {
if (warmOnly) {
return [];
} else {
return areOptionsSensitive(options)
? runAuthenticatedAdminServerAction(() => getPhotosCached(options))
: getPhotosCached(options);
}
};
// Public actions
export const searchPhotosAction = async (query: string) =>
getPhotos({ query, limit: 10 })
.catch(e => {
console.error('Could not query photos', e);
return [] as Photo[];
});