Consolidate outdated/needs AI text sync statuses

This commit is contained in:
Sam Becker 2025-04-19 15:00:24 -05:00
parent 00932b6687
commit f22d5f85a8
14 changed files with 123 additions and 137 deletions

View File

@ -1,11 +1,10 @@
import { getStoragePhotoUrlsNoStore } from '@/platforms/storage/cache';
import { getPhotos } from '@/photo/db/query';
import { getPhotos, getPhotosInNeedOfSyncCount } from '@/photo/db/query';
import { getPhotosMetaCached } from '@/photo/cache';
import AdminPhotosClient from '@/admin/AdminPhotosClient';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
import { TIMEZONE_COOKIE_NAME } from '@/utility/timezone';
import { getOutdatedPhotosCount } from '@/photo/db/query';
import {
AI_TEXT_GENERATION_ENABLED,
PRESERVE_ORIGINAL_UPLOADS,
@ -35,7 +34,7 @@ export default async function AdminPhotosPage() {
getPhotosMetaCached({ hidden: 'include'})
.then(({ count }) => count)
.catch(() => 0),
getOutdatedPhotosCount()
getPhotosInNeedOfSyncCount()
.catch(() => 0),
DEBUG_PHOTO_BLOBS
? getStoragePhotoUrlsNoStore()

View File

@ -1,11 +1,11 @@
import AdminPhotosSyncClient from '@/admin/AdminPhotosSyncClient';
import { AI_TEXT_GENERATION_ENABLED } from '@/app/config';
import { getOutdatedPhotos } from '@/photo/db/query';
import { getPhotosInNeedOfSync } from '@/photo/db/query';
export const maxDuration = 60;
export default async function AdminSyncPage() {
const photos = await getOutdatedPhotos()
const photos = await getPhotosInNeedOfSync()
.catch(() => []);
return (

View File

@ -78,7 +78,7 @@ export default function AdminPhotoMenu({
label: 'Sync',
labelComplex: <span className="inline-flex items-center gap-2">
<span>Sync</span>
{photo.needsSync &&
{(photo.syncStatus.isOutdated || photo.syncStatus.isMissingAiText) &&
<InsightsIndicatorDot
colorOverride="blue"
className="translate-y-[1.5px]"

View File

@ -13,6 +13,7 @@ import AdminUploadsTable from './AdminUploadsTable';
import { Timezone } from '@/utility/timezone';
import { useAppState } from '@/state/AppState';
import PhotoUploadWithStatus from '@/photo/PhotoUploadWithStatus';
import { pluralize } from '@/utility/string';
export default function AdminPhotosClient({
photos,
@ -58,9 +59,10 @@ export default function AdminPhotosClient({
size={18}
className="translate-y-[-1px]"
/>}
// TODO: Add tooltip
// TODO: Use LinkWithStatus
title={`${photosCountOutdated} Outdated Photos`}
tooltip={(
pluralize(photosCountOutdated, 'photo') +
' needs sync'
)}
className={clsx(
'text-blue-600 dark:text-blue-400',
'border border-blue-200 dark:border-blue-800/60',

View File

@ -67,9 +67,7 @@ export default function AdminPhotosSyncClient({
>
{arePhotoIdsSyncing
? 'Syncing'
: <ResponsiveText shortText={`Sync Next ${updateBatchSize}`}>
Sync Next {updateBatchSize} Photos
</ResponsiveText>}
: 'Sync All'}
</LoaderButton>}
>
<div className="space-y-6">

View File

@ -9,13 +9,13 @@ import {
} from '@/photo/db/query';
import AdminAppInsightsClient from './AdminAppInsightsClient';
import { getAllInsights, getGitHubMetaForCurrentApp } from '.';
import { getOutdatedPhotosCount } from '@/photo/db/query';
import { getPhotosInNeedOfSyncCount } from '@/photo/db/query';
export default async function AdminAppInsights() {
const [
{ count: photosCount, dateRange },
{ count: photosCountHidden },
photosCountOutdated,
photosCountNeedSync,
{ count: photosCountPortrait },
codeMeta,
cameras,
@ -27,7 +27,7 @@ export default async function AdminAppInsights() {
] = await Promise.all([
getPhotosMeta({ hidden: 'include' }),
getPhotosMeta({ hidden: 'only' }),
getOutdatedPhotosCount(),
getPhotosInNeedOfSyncCount(),
getPhotosMeta({ maximumAspectRatio: 0.9 }),
getGitHubMetaForCurrentApp(),
getUniqueCameras(),
@ -44,14 +44,14 @@ export default async function AdminAppInsights() {
insights={getAllInsights({
codeMeta,
photosCount,
photosCountOutdated,
photosCountNeedSync,
photosCountPortrait,
tagsCount: tags.length,
})}
photoStats={{
photosCount,
photosCountHidden,
photosCountOutdated,
photosCountNeedSync,
camerasCount: cameras.length,
lensesCount: lenses.length,
tagsCount: tags.length,

View File

@ -50,7 +50,7 @@ import { HiOutlineDocumentText } from 'react-icons/hi';
const DEBUG_COMMIT_SHA = '4cd29ed';
const DEBUG_COMMIT_MESSAGE = 'Long commit message for debugging purposes';
const DEBUG_BEHIND_BY = 9;
const DEBUG_PHOTOS_COUNT_OUTDATED = 7;
const DEBUG_PHOTOS_NEED_SYNC_COUNT = 7;
const TEXT_COLOR_WARNING = 'text-amber-600 dark:text-amber-500';
const TEXT_COLOR_BLUE = 'text-blue-600 dark:text-blue-500';
@ -91,7 +91,7 @@ export default function AdminAppInsightsClient({
photoStats: {
photosCount,
photosCountHidden,
photosCountOutdated,
photosCountNeedSync,
camerasCount,
lensesCount,
tagsCount,
@ -114,7 +114,7 @@ export default function AdminAppInsightsClient({
noAiRateLimiting,
noConfiguredDomain,
noConfiguredMeta,
outdatedPhotos,
photosNeedSync,
photoMatting,
camerasFirst,
gridFirst,
@ -417,7 +417,7 @@ export default function AdminAppInsightsClient({
</AdminEmptyState>}
</ScoreCard>
<ScoreCard title="Library Stats">
{(outdatedPhotos || debug) && <ScoreCardRow
{(photosNeedSync || debug) && <ScoreCardRow
icon={<LiaBroomSolid
size={19}
className={clsx(
@ -427,7 +427,7 @@ export default function AdminAppInsightsClient({
/>}
content={renderHighlightText(
pluralize(
photosCountOutdated || DEBUG_PHOTOS_COUNT_OUTDATED,
photosCountNeedSync || DEBUG_PHOTOS_NEED_SYNC_COUNT,
'photo',
) + ' need to be synced',
'blue',

View File

@ -39,7 +39,7 @@ const _INSIGHTS_TEMPLATE = [
type AdminAppInsightRecommendation = typeof _INSIGHTS_TEMPLATE[number];
const _INSIGHTS_LIBRARY = [
'outdatedPhotos',
'photosNeedSync',
] as const;
type AdminAppInsightLibrary = typeof _INSIGHTS_LIBRARY[number];
@ -58,7 +58,7 @@ export const hasTemplateRecommendations = (insights: AdminAppInsights) =>
export interface PhotoStats {
photosCount: number
photosCountHidden: number
photosCountOutdated: number
photosCountNeedSync: number
camerasCount: number
lensesCount: number
tagsCount: number
@ -80,10 +80,10 @@ export const getGitHubMetaForCurrentApp = () =>
export const getSignificantInsights = ({
codeMeta,
photosCountOutdated,
photosCountNeedSync,
}: {
codeMeta: Awaited<ReturnType<typeof getGitHubMetaForCurrentApp>>
photosCountOutdated: number
photosCountNeedSync: number
}) => {
const {
isAiTextGenerationEnabled,
@ -95,7 +95,7 @@ export const getSignificantInsights = ({
forkBehind: Boolean(codeMeta?.isBehind),
noAiRateLimiting: isAiTextGenerationEnabled && !hasRedisStorage,
noConfiguredDomain: !hasDomain,
outdatedPhotos: Boolean(photosCountOutdated),
photosNeedSync: Boolean(photosCountNeedSync),
};
};
@ -106,19 +106,19 @@ export const indicatorStatusForSignificantInsights = (
forkBehind,
noAiRateLimiting,
noConfiguredDomain,
outdatedPhotos,
photosNeedSync,
} = insights;
if (noAiRateLimiting || noConfiguredDomain) {
return 'yellow';
} else if (forkBehind || outdatedPhotos) {
} else if (forkBehind || photosNeedSync) {
return 'blue';
}
};
export const getAllInsights = ({
codeMeta,
photosCountOutdated,
photosCountNeedSync,
photosCount,
photosCountPortrait,
tagsCount,
@ -127,7 +127,7 @@ export const getAllInsights = ({
photosCountPortrait: number
tagsCount: number
}) => ({
...getSignificantInsights({ codeMeta, photosCountOutdated }),
...getSignificantInsights({ codeMeta, photosCountNeedSync }),
noFork: !codeMeta?.isForkedFromBase && !codeMeta?.isBaseRepo,
noAi: !AI_TEXT_GENERATION_ENABLED,
noConfiguredMeta:

View File

@ -1,4 +1,4 @@
import { getOutdatedPhotosCount } from '@/photo/db/query';
import { getPhotosInNeedOfSyncCount } from '@/photo/db/query';
import {
getSignificantInsights,
indicatorStatusForSignificantInsights,
@ -8,15 +8,15 @@ import { getGitHubMetaForCurrentApp } from '.';
export const getInsightsIndicatorStatus = async () => {
const [
codeMeta,
photosCountOutdated,
photosCountNeedSync,
] = await Promise.all([
getGitHubMetaForCurrentApp(),
getOutdatedPhotosCount(),
getPhotosInNeedOfSyncCount(),
]);
const significantInsights = getSignificantInsights({
codeMeta,
photosCountOutdated,
photosCountNeedSync,
});
return indicatorStatusForSignificantInsights(significantInsights);

View File

@ -2,7 +2,6 @@ import { BiCopy } from 'react-icons/bi';
import LoaderButton from './primitives/LoaderButton';
import clsx from 'clsx/lite';
import { toastSuccess } from '@/toast';
import Tooltip from './Tooltip';
import { ComponentProps } from 'react';
export default function CopyButton({
@ -10,20 +9,18 @@ export default function CopyButton({
text,
subtle,
iconSize = 15,
tooltip,
tooltipColor,
className,
...props
}: {
label: string
text?: string,
subtle?: boolean
iconSize?: number
tooltip?: string
tooltipColor?: ComponentProps<typeof Tooltip>['color']
className?: string
}) {
const button =
} & ComponentProps<typeof LoaderButton>) {
return (
<LoaderButton
{...props}
icon={<BiCopy size={iconSize} />}
className={clsx(
subtle && 'text-gray-300 dark:text-gray-700',
@ -37,13 +34,6 @@ export default function CopyButton({
: undefined}
styleAs="link"
disabled={!text}
/>;
return (
tooltip
? <Tooltip content={tooltip} color={tooltipColor}>
{button}
</Tooltip>
: button
/>
);
}

View File

@ -2,21 +2,10 @@
import Spinner, { SpinnerColor } from '@/components/Spinner';
import { clsx } from 'clsx/lite';
import { ButtonHTMLAttributes, ReactNode } from 'react';
import { ButtonHTMLAttributes, ComponentProps, ReactNode } from 'react';
import Tooltip from '../Tooltip';
export default function LoaderButton(props: {
isLoading?: boolean
icon?: ReactNode
spinnerColor?: SpinnerColor
spinnerClassName?: string
styleAs?: 'button' | 'link' | 'link-without-hover'
hideTextOnMobile?: boolean
confirmText?: string
shouldPreventDefault?: boolean
primary?: boolean
hideFocusOutline?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement>) {
const {
export default function LoaderButton({
children,
isLoading,
icon,
@ -32,10 +21,24 @@ export default function LoaderButton(props: {
onClick,
disabled,
className,
tooltip,
tooltipColor,
...rest
} = props;
return (
}: {
isLoading?: boolean
icon?: ReactNode
spinnerColor?: SpinnerColor
spinnerClassName?: string
styleAs?: 'button' | 'link' | 'link-without-hover'
hideTextOnMobile?: boolean
confirmText?: string
shouldPreventDefault?: boolean
primary?: boolean
hideFocusOutline?: boolean
tooltip?: string
tooltipColor?: ComponentProps<typeof Tooltip>['color']
} & ButtonHTMLAttributes<HTMLButtonElement>) {
const button =
<button
{...rest}
type={type}
@ -86,6 +89,13 @@ export default function LoaderButton(props: {
)}>
{children}
</span>}
</button>
</button>;
return (
tooltip
? <Tooltip content={tooltip} color={tooltipColor}>
{button}
</Tooltip>
: button
);
}

View File

@ -579,44 +579,22 @@ export const getPhoto = async (
// Sync queries
const outdatedWhereClause =
`WHERE updated_at < $1 OR (updated_at < $2 AND make = $3)`;
const outdatedWhereClauses = [
`updated_at < $1`,
`(updated_at < $2 AND make = $3)`,
];
const outdatedValues = [
const outdatedWhereValues = [
UPDATED_BEFORE_01.toISOString(),
UPDATED_BEFORE_02.toISOString(),
MAKE_FUJIFILM,
];
export const getOutdatedPhotos = () => safelyQueryPhotos(
() => query(`
SELECT * FROM photos
${outdatedWhereClause}
ORDER BY created_at DESC
LIMIT ${SYNC_QUERY_LIMIT}
`,
outdatedValues,
)
.then(({ rows }) => rows.map(parsePhotoFromDb)),
'getOutdatedPhotos',
);
export const getOutdatedPhotosCount = () => safelyQueryPhotos(
() => query(`
SELECT COUNT(*) FROM photos
${outdatedWhereClause}
`,
outdatedValues,
)
.then(({ rows }) => parseInt(rows[0].count, 10)),
'getOutdatedPhotosCount',
);
const photosThatNeedAiTextWhereClause = (
const needsAiTextWhereClauses = (
AI_TEXT_GENERATION_ENABLED &&
AI_TEXT_AUTO_GENERATED_FIELDS.length
)
? 'WHERE ' + AI_TEXT_AUTO_GENERATED_FIELDS
? AI_TEXT_AUTO_GENERATED_FIELDS
.map(field => {
switch (field) {
case 'title': return `(title <> '') IS NOT TRUE`;
@ -624,29 +602,32 @@ const photosThatNeedAiTextWhereClause = (
case 'tags': return `array_length(tags, 1) = 0`;
case 'semantic': return `(semantic_description <> '') IS NOT TRUE`;
}
}).join(' OR ')
: undefined;
})
: [];
export const getPhotosThatNeedAiText = () => safelyQueryPhotos(
async () => photosThatNeedAiTextWhereClause
? query(`
const needsSyncWhereStatement =
`WHERE ${outdatedWhereClauses.concat(needsAiTextWhereClauses).join(' OR ')}`;
export const getPhotosInNeedOfSync = () => safelyQueryPhotos(
() => query(`
SELECT * FROM photos
${photosThatNeedAiTextWhereClause}
${needsSyncWhereStatement}
ORDER BY created_at DESC
LIMIT ${SYNC_QUERY_LIMIT}
`)
.then(({ rows }) => rows.map(parsePhotoFromDb))
: [] as Photo[],
'getPhotosThatNeedAiText',
`,
outdatedWhereValues,
)
.then(({ rows }) => rows.map(parsePhotoFromDb)),
'getPhotosInNeedOfSync',
);
export const getPhotosThatNeedAiTextCount = () => safelyQueryPhotos(
async () => photosThatNeedAiTextWhereClause
? query(`
export const getPhotosInNeedOfSyncCount = () => safelyQueryPhotos(
() => query(`
SELECT COUNT(*) FROM photos
${photosThatNeedAiTextWhereClause}
`)
.then(({ rows }) => parseInt(rows[0].count, 10))
: 0,
'getPhotosThatNeedAiTextCount',
${needsSyncWhereStatement}
`,
outdatedWhereValues,
)
.then(({ rows }) => parseInt(rows[0].count, 10)),
'getPhotosInNeedOfSyncCount',
);

View File

@ -23,7 +23,7 @@ import { isBefore } from 'date-fns';
import type { Metadata } from 'next';
import { FujifilmRecipe } from '@/platforms/fujifilm/recipe';
import { FujifilmSimulation } from '@/platforms/fujifilm/simulation';
import { doesPhotoNeedSync } from './sync';
import { PhotoSyncStatus, generatePhotoSyncStatus } from './sync';
// INFINITE SCROLL: FEED
export const INFINITE_SCROLL_FEED_INITIAL =
@ -97,7 +97,7 @@ export interface PhotoDb extends
updatedAt: Date
createdAt: Date
takenAt: Date
tags: string[]
tags?: string[]
}
// Parsed db response
@ -109,15 +109,16 @@ export interface Photo extends Omit<PhotoDb, 'recipeData'> {
exposureTimeFormatted?: string
exposureCompensationFormatted?: string
takenAtNaiveFormatted: string
tags: string[]
recipeData?: FujifilmRecipe
needsSync?: boolean
syncStatus: PhotoSyncStatus
}
export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
const photoDb = camelcaseKeys(
photoDbRaw as unknown as Record<string, unknown>,
) as unknown as PhotoDb;
const photo: Photo ={
return {
...photoDb,
tags: photoDb.tags ?? [],
focalLengthFormatted:
@ -140,9 +141,8 @@ export const parsePhotoFromDb = (photoDbRaw: PhotoDb): Photo => {
? JSON.parse(photoDb.recipeData)
: photoDb.recipeData
: undefined,
};
photo.needsSync = doesPhotoNeedSync(photo);
return photo;
syncStatus: generatePhotoSyncStatus(photoDb),
} as Photo;
};
export const parseCachedPhotoDates = (photo: Photo) => ({

View File

@ -1,14 +1,19 @@
import { MAKE_FUJIFILM } from '@/platforms/fujifilm';
import { Photo } from '.';
import { PhotoDb } from '.';
import { AI_TEXT_AUTO_GENERATED_FIELDS } from '@/app/config';
export interface PhotoSyncStatus {
isOutdated: boolean;
isMissingAiText: boolean;
}
export const SYNC_QUERY_LIMIT = 1000;
export const UPDATED_BEFORE_01 = new Date('2024-06-16');
// UTC 2025-02-24 05:30:00
export const UPDATED_BEFORE_02 = new Date(Date.UTC(2025, 1, 24, 5, 30, 0));
const isPhotoOutdated = (photo: Photo) =>
const isPhotoOutdated = (photo: PhotoDb) =>
photo.updatedAt < UPDATED_BEFORE_01 || (
photo.updatedAt < UPDATED_BEFORE_02 &&
photo.make === MAKE_FUJIFILM
@ -19,12 +24,13 @@ const doesPhotoNeedAiText = ({
caption,
tags = [],
semanticDescription,
}: Photo) =>
}: PhotoDb) =>
(AI_TEXT_AUTO_GENERATED_FIELDS.includes('title') && !title) ||
(AI_TEXT_AUTO_GENERATED_FIELDS.includes('caption') && !caption) ||
(AI_TEXT_AUTO_GENERATED_FIELDS.includes('tags') && tags.length === 0) ||
(AI_TEXT_AUTO_GENERATED_FIELDS.includes('semantic') && !semanticDescription);
export const doesPhotoNeedSync = (photo: Photo) =>
isPhotoOutdated(photo) ||
doesPhotoNeedAiText(photo);
export const generatePhotoSyncStatus = (photo: PhotoDb): PhotoSyncStatus => ({
isOutdated: isPhotoOutdated(photo),
isMissingAiText: doesPhotoNeedAiText(photo),
});