Vercel/src/photo/query.ts
Sam Becker ee9f3f4dc2
Album upgrades (#326)
* Add tag-to-album upgrade, introduce tag/album ••• menus

* Refine entity ••• menus

* Add album tagging to "Select ..." mode

* Finalize batch select/upload add album

* Refine final tag/album interactions

* Refine upgradeTagToAlbum capitalization

* Fix batch album upload, z-index issues

* Refine readonly styles
2025-09-18 22:41:12 -05:00

638 lines
18 KiB
TypeScript

/* eslint-disable quotes */
import {
sql,
query,
} from '@/platforms/postgres';
import { convertArrayToPostgresString } from '@/db';
import {
PhotoDb,
PhotoDbInsert,
translatePhotoId,
parsePhotoFromDb,
Photo,
PhotoDateRangePostgres,
} from '@/photo';
import { Cameras, createCameraKey } from '@/camera';
import { Tags } from '@/tag';
import { Films } from '@/film';
import {
AI_TEXT_AUTO_GENERATED_FIELDS,
AI_CONTENT_GENERATION_ENABLED,
COLOR_SORT_ENABLED,
} from '@/app/config';
import {
PhotoQueryOptions,
getOrderByFromOptions,
getLimitAndOffsetFromOptions,
getWheresFromOptions,
getJoinsFromOptions,
} from '../db';
import { FocalLengths } from '@/focal';
import { Lenses, createLensKey } from '@/lens';
import {
UPDATE_QUERY_LIMIT,
OUTDATED_UPDATE_AT_THRESHOLD,
} from '@/photo/update';
import { Recipes } from '@/recipe';
import { Years } from '@/year';
import { PhotoColorData } from '@/photo/color/client';
import { safelyQuery } from '@/db/query';
export const createPhotosTable = () =>
sql`
CREATE TABLE IF NOT EXISTS photos (
id VARCHAR(8) PRIMARY KEY,
url VARCHAR(255) NOT NULL,
extension VARCHAR(255) NOT NULL,
aspect_ratio REAL DEFAULT 1.5,
blur_data TEXT,
title VARCHAR(255),
caption TEXT,
semantic_description TEXT,
tags VARCHAR(255)[],
make VARCHAR(255),
model VARCHAR(255),
focal_length SMALLINT,
focal_length_in_35mm_format SMALLINT,
lens_make VARCHAR(255),
lens_model VARCHAR(255),
f_number REAL,
iso SMALLINT,
exposure_time DOUBLE PRECISION,
exposure_compensation REAL,
location_name VARCHAR(255),
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
film VARCHAR(255),
recipe_title VARCHAR(255),
recipe_data JSONB,
color_data JSONB,
color_sort SMALLINT,
priority_order REAL,
taken_at TIMESTAMP WITH TIME ZONE NOT NULL,
taken_at_naive VARCHAR(255) NOT NULL,
exclude_from_feeds BOOLEAN DEFAULT FALSE,
hidden BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
`;
// Must provide id as 8-character nanoid
export const insertPhoto = (photo: PhotoDbInsert) =>
safelyQuery(() => sql`
INSERT INTO photos (
id,
url,
extension,
aspect_ratio,
blur_data,
title,
caption,
semantic_description,
tags,
make,
model,
focal_length,
focal_length_in_35mm_format,
lens_make,
lens_model,
f_number,
iso,
exposure_time,
exposure_compensation,
location_name,
latitude,
longitude,
film,
recipe_title,
recipe_data,
color_data,
color_sort,
priority_order,
exclude_from_feeds,
hidden,
taken_at,
taken_at_naive
) VALUES (
${photo.id},
${photo.url},
${photo.extension},
${photo.aspectRatio},
${photo.blurData},
${photo.title},
${photo.caption},
${photo.semanticDescription},
${convertArrayToPostgresString(photo.tags)},
${photo.make},
${photo.model},
${photo.focalLength},
${photo.focalLengthIn35MmFormat},
${photo.lensMake},
${photo.lensModel},
${photo.fNumber},
${photo.iso},
${photo.exposureTime},
${photo.exposureCompensation},
${photo.locationName},
${photo.latitude},
${photo.longitude},
${photo.film},
${photo.recipeTitle},
${photo.recipeData},
${photo.colorData},
${photo.colorSort},
${photo.priorityOrder},
${photo.excludeFromFeeds},
${photo.hidden},
${photo.takenAt},
${photo.takenAtNaive}
)
`, 'insertPhoto');
export const updatePhoto = (photo: PhotoDbInsert) =>
safelyQuery(() => sql`
UPDATE photos SET
url=${photo.url},
extension=${photo.extension},
aspect_ratio=${photo.aspectRatio},
blur_data=${photo.blurData},
title=${photo.title},
caption=${photo.caption},
semantic_description=${photo.semanticDescription},
tags=${convertArrayToPostgresString(photo.tags)},
make=${photo.make},
model=${photo.model},
focal_length=${photo.focalLength},
focal_length_in_35mm_format=${photo.focalLengthIn35MmFormat},
lens_make=${photo.lensMake},
lens_model=${photo.lensModel},
f_number=${photo.fNumber},
iso=${photo.iso},
exposure_time=${photo.exposureTime},
exposure_compensation=${photo.exposureCompensation},
location_name=${photo.locationName},
latitude=${photo.latitude},
longitude=${photo.longitude},
film=${photo.film},
recipe_title=${photo.recipeTitle},
recipe_data=${photo.recipeData},
color_data=${photo.colorData},
color_sort=${photo.colorSort},
priority_order=${photo.priorityOrder || null},
exclude_from_feeds=${photo.excludeFromFeeds},
hidden=${photo.hidden},
taken_at=${photo.takenAt},
taken_at_naive=${photo.takenAtNaive},
updated_at=${(new Date()).toISOString()}
WHERE id=${photo.id}
`, 'updatePhoto');
export const deletePhotoTagGlobally = (tag: string) =>
safelyQuery(() => sql`
UPDATE photos
SET tags=ARRAY_REMOVE(tags, ${tag})
WHERE ${tag}=ANY(tags)
`, 'deletePhotoTagGlobally');
export const renamePhotoTagGlobally = (tag: string, updatedTag: string) =>
safelyQuery(() => sql`
UPDATE photos
SET tags=ARRAY_REPLACE(tags, ${tag}, ${updatedTag})
WHERE ${tag}=ANY(tags)
`, 'renamePhotoTagGlobally');
export const addTagsToPhotos = (tags: string[], photoIds: string[]) =>
safelyQuery(() => query(`
UPDATE photos
SET tags = (
SELECT array_agg(DISTINCT elem)
FROM unnest(
array_cat(tags, $1)
) AS elem
)
WHERE id = ANY($2)
`, [
convertArrayToPostgresString(tags),
convertArrayToPostgresString(photoIds),
]), 'addTagsToPhotos');
export const deletePhotoRecipeGlobally = (recipe: string) =>
safelyQuery(() => sql`
UPDATE photos
SET recipe_title=NULL
WHERE recipe_title=${recipe}
`, 'deletePhotoRecipeGlobally');
export const renamePhotoRecipeGlobally = (
recipe: string,
updatedRecipe: string,
) =>
safelyQuery(() => sql`
UPDATE photos
SET recipe_title=${updatedRecipe}
WHERE recipe_title=${recipe}
`, 'renamePhotoRecipeGlobally');
export const deletePhoto = (id: string) =>
safelyQuery(() => sql`
DELETE FROM photos WHERE id=${id}
`, 'deletePhoto');
export const getPhotosMostRecentUpdate = async () =>
safelyQuery(() => sql`
SELECT updated_at FROM photos ORDER BY updated_at DESC LIMIT 1
`.then(({ rows }) => rows[0] ? rows[0].updated_at as Date : undefined)
, 'getPhotosMostRecentUpdate');
export const getUniqueCameras = async () =>
safelyQuery(() => sql`
SELECT DISTINCT make||' '||model as camera, make, model,
COUNT(*),
MAX(updated_at) as last_modified
FROM photos
WHERE hidden IS NOT TRUE
AND trim(make) <> ''
AND trim(model) <> ''
GROUP BY make, model
ORDER BY camera ASC
`.then(({ rows }): Cameras => rows.map(({
make, model, count, last_modified,
}) => ({
cameraKey: createCameraKey({ make, model }),
camera: { make, model },
count: parseInt(count, 10),
lastModified: last_modified as Date,
})))
, 'getUniqueCameras');
export const getUniqueLenses = async () =>
safelyQuery(() => sql`
SELECT DISTINCT lens_make||' '||lens_model as lens,
lens_make, lens_model,
COUNT(*),
MAX(updated_at) as last_modified
FROM photos
WHERE hidden IS NOT TRUE
AND trim(lens_model) <> ''
GROUP BY lens_make, lens_model
ORDER BY lens ASC
`.then(({ rows }): Lenses => rows
.map(({ lens_make: make, lens_model: model, count, last_modified }) => ({
lensKey: createLensKey({ make, model }),
lens: { make, model },
count: parseInt(count, 10),
lastModified: last_modified as Date,
})))
, 'getUniqueLenses');
export const getUniqueTags = async () =>
safelyQuery(() => sql`
SELECT DISTINCT unnest(tags) as tag,
COUNT(*),
MAX(updated_at) as last_modified
FROM photos
WHERE hidden IS NOT TRUE
GROUP BY tag
ORDER BY tag ASC
`.then(({ rows }): Tags => rows.map(({ tag, count, last_modified }) => ({
tag,
count: parseInt(count, 10),
lastModified: last_modified as Date,
})))
, 'getUniqueTags');
export const getUniqueRecipes = async () =>
safelyQuery(() => sql`
SELECT DISTINCT recipe_title,
COUNT(*),
MAX(updated_at) as last_modified
FROM photos
WHERE hidden IS NOT TRUE AND recipe_title IS NOT NULL
GROUP BY recipe_title
ORDER BY recipe_title ASC
`.then(({ rows }): Recipes => rows
.map(({ recipe_title, count, last_modified }) => ({
recipe: recipe_title,
count: parseInt(count, 10),
lastModified: last_modified as Date,
})))
, 'getUniqueRecipes');
export const getUniqueYears = async () =>
safelyQuery(() => sql`
SELECT
DISTINCT EXTRACT(YEAR FROM taken_at) AS year,
COUNT(*),
MAX(updated_at) as last_modified
FROM photos
WHERE hidden IS NOT TRUE
GROUP BY year
ORDER BY year DESC
`.then(({ rows }): Years => rows.map(({ year, count, last_modified }) => ({
year,
count: parseInt(count, 10),
lastModified: last_modified as Date,
}))), 'getUniqueYears');
export const getRecipeTitleForData = async (
data: string | object,
film: string,
) =>
// Includes legacy check on pre-stringified JSON
safelyQuery(() => sql`
SELECT recipe_title FROM photos
WHERE hidden IS NOT TRUE
AND recipe_data=${typeof data === 'string' ? data : JSON.stringify(data)}
AND film=${film}
LIMIT 1
`
.then(({ rows }) => rows[0]?.recipe_title as string | undefined)
, 'getRecipeTitleForData');
export const getPhotosNeedingRecipeTitleCount = async (
data: string,
film: string,
photoIdToExclude?: string,
) =>
safelyQuery(() => sql`
SELECT COUNT(*)
FROM photos
WHERE recipe_title IS NULL
AND recipe_data=${data}
AND film=${film}
AND id <> ${photoIdToExclude}
`.then(({ rows }) => parseInt(rows[0].count, 10))
, 'getPhotosNeedingRecipeTitleCount');
export const updateAllMatchingRecipeTitles = (
title: string,
data: string,
film: string,
) =>
safelyQuery(() => sql`
UPDATE photos
SET recipe_title=${title}
WHERE recipe_title IS NULL
AND recipe_data=${data}
AND film=${film}
`, 'updateAllMatchingRecipeTitles');
export const getUniqueFilms = async () =>
safelyQuery(() => sql`
SELECT DISTINCT film,
COUNT(*),
MAX(updated_at) as last_modified
FROM photos
WHERE hidden IS NOT TRUE AND film IS NOT NULL
GROUP BY film
ORDER BY film ASC
`.then(({ rows }): Films => rows
.map(({ film, count, last_modified }) => ({
film,
count: parseInt(count, 10),
lastModified: last_modified as Date,
})))
, 'getUniqueFilms');
export const getUniqueFocalLengths = async () =>
safelyQuery(() => sql`
SELECT DISTINCT focal_length,
COUNT(*),
MAX(updated_at) as last_modified
FROM photos
WHERE hidden IS NOT TRUE AND focal_length IS NOT NULL
GROUP BY focal_length
ORDER BY focal_length ASC
`.then(({ rows }): FocalLengths => rows
.map(({ focal_length, count, last_modified }) => ({
focal: parseInt(focal_length, 10),
count: parseInt(count, 10),
lastModified: last_modified as Date,
})))
, 'getUniqueFocalLengths');
export const getPhotos = async (options: PhotoQueryOptions = {}) =>
safelyQuery(async () => {
const sql = ['SELECT p.* FROM photos p'];
const values = [] as (string | number)[];
const joins = getJoinsFromOptions(options);
if (joins) { sql.push(joins); }
const {
wheres,
wheresValues,
lastValuesIndex,
} = getWheresFromOptions(options);
if (wheres) {
sql.push(wheres);
values.push(...wheresValues);
}
sql.push(getOrderByFromOptions(options));
const {
limitAndOffset,
limitAndOffsetValues,
} = getLimitAndOffsetFromOptions(options, lastValuesIndex);
// LIMIT + OFFSET
sql.push(limitAndOffset);
values.push(...limitAndOffsetValues);
return query(sql.join(' '), values)
.then(({ rows }) => rows.map(parsePhotoFromDb));
},
'getPhotos',
// Seemingly necessary to pass `options` for expected cache behavior
options,
);
export const getPhotosNearId = async (
photoId: string,
options: PhotoQueryOptions,
) =>
safelyQuery(async () => {
const { limit } = options;
const joins = getJoinsFromOptions(options);
const {
wheres,
wheresValues,
lastValuesIndex,
} = getWheresFromOptions(options);
let valuesIndex = lastValuesIndex;
return query(
`
WITH twi AS (
SELECT p.*, row_number()
OVER (${getOrderByFromOptions(options)}) as row_number
FROM photos p
${joins ? `${joins}` : ''}
${wheres}
),
current AS (SELECT row_number FROM twi WHERE id = $${valuesIndex++})
SELECT twi.*
FROM twi, current
WHERE twi.row_number >= current.row_number - 1
LIMIT $${valuesIndex++}
`,
[...wheresValues, photoId, limit],
)
.then(({ rows }) => {
const photo = rows.find(({ id }) => id === photoId);
const indexNumber = photo ? parseInt(photo.row_number) : undefined;
return {
photos: rows.map(parsePhotoFromDb),
indexNumber,
};
});
}, `getPhotosNearId: ${photoId}`);
export const getPhotosMeta = (options: PhotoQueryOptions = {}) =>
safelyQuery(async () => {
// eslint-disable-next-line max-len
let sql = 'SELECT COUNT(*), MIN(p.taken_at_naive) as start, MAX(p.taken_at_naive) as end FROM photos p';
const joins = getJoinsFromOptions(options);
if (joins) { sql += ` ${joins}`; }
const { wheres, wheresValues } = getWheresFromOptions(options);
if (wheres) { sql += ` ${wheres}`; }
return query(sql, wheresValues)
.then(({ rows }) => ({
count: parseInt(rows[0].count, 10),
...rows[0]?.start && rows[0]?.end
? { dateRange: {
start: rows[0].start as string,
end: rows[0].end as string,
} as PhotoDateRangePostgres }
: undefined,
}));
}, 'getPhotosMeta');
export const getPublicPhotoIds = async ({ limit }: { limit?: number }) =>
safelyQuery(() => (limit
? sql`SELECT id FROM photos WHERE hidden IS NOT TRUE LIMIT ${limit}`
: sql`SELECT id FROM photos WHERE hidden IS NOT TRUE`)
.then(({ rows }) => rows.map(({ id }) => id as string))
, 'getPublicPhotoIds');
export const getPhotoIdsAndUpdatedAt = async () =>
safelyQuery(() =>
sql`SELECT id, updated_at FROM photos WHERE hidden IS NOT TRUE`
.then(({ rows }) => rows.map(({ id, updated_at }) =>
({ id: id as string, updatedAt: updated_at as Date })))
, 'getPhotoIdsAndUpdatedAt');
export const getPhoto = async (
id: string,
includeHidden?: boolean,
): Promise<Photo | undefined> =>
safelyQuery(async () => {
// Check for photo id forwarding and convert short ids to uuids
const photoId = translatePhotoId(id);
return (includeHidden
? sql<PhotoDb>`SELECT * FROM photos WHERE id=${photoId} LIMIT 1`
// eslint-disable-next-line max-len
: sql<PhotoDb>`SELECT * FROM photos WHERE id=${photoId} AND hidden IS NOT TRUE LIMIT 1`)
.then(({ rows }) => rows.map(parsePhotoFromDb))
.then(photos => photos.length > 0 ? photos[0] : undefined);
}, 'getPhoto');
// Update queries
const outdatedWhereClauses = [
`updated_at < $1`,
];
const outdatedWhereValues = [
OUTDATED_UPDATE_AT_THRESHOLD.toISOString(),
];
const needsAiTextWhereClauses =
AI_CONTENT_GENERATION_ENABLED
? AI_TEXT_AUTO_GENERATED_FIELDS
.map(field => {
switch (field) {
case 'title': return `(title <> '') IS NOT TRUE`;
case 'caption': return `(caption <> '') IS NOT TRUE`;
case 'tags': return `(tags IS NULL OR array_length(tags, 1) = 0)`;
case 'semantic': return `(semantic_description <> '') IS NOT TRUE`;
}
})
: [];
const needsColorDataWhereClauses = COLOR_SORT_ENABLED
? [`(
color_data IS NULL OR
color_sort IS NULL
)`]
: [];
const needsSyncWhereStatement =
`WHERE ${[
...outdatedWhereClauses,
...needsAiTextWhereClauses,
...needsColorDataWhereClauses,
].join(' OR ')}`;
export const getPhotosInNeedOfUpdate = () =>
safelyQuery(
() => query(`
SELECT * FROM photos
${needsSyncWhereStatement}
ORDER BY created_at DESC
LIMIT ${UPDATE_QUERY_LIMIT}
`,
outdatedWhereValues,
)
.then(({ rows }) => rows.map(parsePhotoFromDb)),
'getPhotosInNeedOfUpdate',
);
export const getPhotosInNeedOfUpdateCount = () =>
safelyQuery(
() => query(`
SELECT COUNT(*) FROM photos
${needsSyncWhereStatement}
`,
outdatedWhereValues,
)
.then(({ rows }) => parseInt(rows[0].count, 10)),
'getPhotosInNeedOfUpdateCount',
);
// Backfills and experimentation
export const getColorDataForPhotos = () =>
safelyQuery(() => sql<{
id: string,
url: string,
color_data?: PhotoColorData,
}>`
SELECT id, url, color_data FROM photos
LIMIT ${UPDATE_QUERY_LIMIT}
`.then(({ rows }) => rows.map(({ id, url, color_data }) =>
({ id, url, colorData: color_data })))
, 'getColorDataForPhotos');
export const updateColorDataForPhoto = (
photoId: string,
colorData: string,
colorSort: number,
) =>
safelyQuery(
() => sql`
UPDATE photos SET
color_data=${colorData},
color_sort=${colorSort}
WHERE id=${photoId}
`,
'updateColorDataForPhoto',
);