mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(C231): EXIF do acervo volta ao mapper e ganha backfill --refresh-metadata
A API do Flickr responde raw/clean como { _content } e o mapper só aceitava
string — as 6.492 fotos do backfill gravaram exif: []. archivePhotoExifEntries
desembrulha os dois shapes (contentScalar também substitui o unwrap ad-hoc do
description) e o CLI ganha o modo --refresh-metadata, que re-busca o EXIF das
linhas existentes e atualiza só o que mudou: write-only no campo exif, sem S3,
guardas de confirmação/TEQO_ENV e recibo atualizadas/inalteradas/falhas.
Runbook §C231 documenta a operação. Closes #1383.
This commit is contained in:
parent
59a6480df8
commit
ad2b165e5e
10 changed files with 375 additions and 41 deletions
1
docs/changelog/2026-09-29-c231-exif.md
Normal file
1
docs/changelog/2026-09-29-c231-exif.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**EXIF do acervo do Flickr (2026-09-29):** o aceite do C231 pedia EXIF "quando houver" e a API do Flickr responde `getExif` com `raw`/`clean` embrulhados em `{ _content: "…" }` (herança XML) — o mapper só aceitava string e descartou todo mundo: as 6.492 fotos foram ingeridas com `exif: []`. `archivePhotoExifEntries` (`src/lib/archivePhoto.ts`) agora desembrulha os dois shapes (o helper `contentScalar` também substituiu o desembrulho ad-hoc do `description`, que já aceitava `_content`), e o CLI ganhou o modo `--refresh-metadata` — o backfill antes deferido: re-busca o EXIF das linhas existentes via API (pacing ~1 req/s), atualiza só o que mudou (`updateArchivePhotoExif`, write-only no campo `exif`, bypass de access documentado do CLI) e grava recibo com atualizadas/inalteradas/falhas nomeadas. Guardas: `--refresh-metadata` é mutuamente exclusivo com `--apply`/`--verify`, exige `FLICKR_IMPORT_CONFIRM=1` e o `TEQO_ENV` casando o banco exato fora de teste — mas **não** exige S3 (não sobe mídia). O geotag segue vazio porque a conta de fato não tem geo (`flickr.photos.search&has_geo=1` → total 0), não por bug. A operação de backfill em produção roda pelo runbook (`docs/ops/teqo-1313-deploy.md`, §C231) e o `--verify` passa a mostrar a cobertura de EXIF.
|
||||
|
|
@ -1363,13 +1363,36 @@ docker compose --profile maintenance run --rm \
|
|||
```
|
||||
|
||||
Os recibos JSON ficam em `data/flickr/reports/` do container — monte um volume
|
||||
(`-v /srv/flickr-reports:/app/data/flickr`) para preservá-los. Guardas: fora de
|
||||
(`-v $HOME/flickr-reports:/app/data/flickr`) para preservá-los. Guardas: fora de
|
||||
teste o `TEQO_ENV=staging|production` é obrigatório com o **nome exato** do
|
||||
banco (`teqo_staging`/`teqo_1313`); `ALLOW_REMOTE_DB` é recusado; sem as 4
|
||||
`S3_*` o `--apply` recusa (o container gravaria em disco efêmero); e
|
||||
`FLICKR_IMPORT_CONFIRM=1` é exigido em alvo não-local. A chave da API nunca
|
||||
entra em recibo/log.
|
||||
|
||||
### Backfill de EXIF (`--refresh-metadata`)
|
||||
|
||||
Fotos ingeridas antes da correção do mapper gravaram `exif: []` (a API devolve
|
||||
`raw`/`clean` como `{ _content }`). O modo re-busca o EXIF das linhas
|
||||
existentes, atualiza só o que mudou e escreve apenas no banco — exige
|
||||
`FLICKR_API_KEY` e as guardas de escrita, mas **não** exige S3:
|
||||
|
||||
```bash
|
||||
# no homeserver (canary e depois o lote completo):
|
||||
docker compose --profile maintenance run --rm \
|
||||
-v $HOME/flickr-reports:/app/data/flickr \
|
||||
-e TEQO_ENV=production -e FLICKR_IMPORT_CONFIRM=1 \
|
||||
teqo-1313-migrate pnpm flickr:import --refresh-metadata --limit 5
|
||||
docker compose --profile maintenance run --rm \
|
||||
-v $HOME/flickr-reports:/app/data/flickr \
|
||||
-e TEQO_ENV=production -e FLICKR_IMPORT_CONFIRM=1 \
|
||||
teqo-1313-migrate pnpm flickr:import --refresh-metadata
|
||||
```
|
||||
|
||||
O recibo (`flickr-*-refresh.json`) conta atualizadas/inalteradas/falhas com
|
||||
motivo; o `--verify` mostra a cobertura (`EXIF: N`). O modo é idempotente:
|
||||
reexecutar converge e linhas inalteradas não são reescritas.
|
||||
|
||||
Rollback: a migration `add_archive_photo` é aditiva. O rollback funcional é
|
||||
apagar as linhas do `archivePhoto` no admin (o objeto correspondente no bucket
|
||||
pode ser removido à parte); reexecutar a ingestão recria as mesmas chaves de
|
||||
|
|
|
|||
|
|
@ -14,17 +14,24 @@
|
|||
* dev and the complete S3_* envs
|
||||
* pnpm flickr:import --verify read-only inventory of the stored
|
||||
* archive (no Flickr, no bucket)
|
||||
* pnpm flickr:import --refresh-metadata
|
||||
* re-fetches the EXIF of the rows already
|
||||
* in the archive and updates only what
|
||||
* changed (backfill; DB-only write,
|
||||
* needs FLICKR_API_KEY but no S3)
|
||||
*
|
||||
* Options: --limit <n> (canary), --page <n> (listing start page), --out <dir>
|
||||
* (receipts, default `data/flickr`), --help. `--limit` caps the photos
|
||||
* processed; the album walk still covers every album so a canary never loses
|
||||
* memberships.
|
||||
* memberships. In the refresh mode `--limit` caps the rows visited.
|
||||
*
|
||||
* Guards: --apply refuses a non-local/production target without the intent
|
||||
* flag (C155) and demands the declared TEQO_ENV matching the exact database
|
||||
* name (C195/C231); a remote target also demands the complete S3_* set. The
|
||||
* API key lives in FLICKR_API_KEY (env only, never the repo) and the account
|
||||
* NSID in FLICKR_USER_ID.
|
||||
* Guards: --apply and --refresh-metadata refuse a non-local/production target
|
||||
* without the intent flag (C155) and demand the declared TEQO_ENV matching the
|
||||
* exact database name (C195/C231); --apply on a remote target also demands the
|
||||
* complete S3_* set (the original never lands on ephemeral disk) while the
|
||||
* metadata-only refresh writes just the database. The API key lives in
|
||||
* FLICKR_API_KEY (env only, never the repo) and the account NSID in
|
||||
* FLICKR_USER_ID.
|
||||
*
|
||||
* Runbook: on the homeserver, inside the compose maintenance service with the
|
||||
* stack env file — see docs/ops/teqo-1313-deploy.md.
|
||||
|
|
@ -36,6 +43,7 @@ import { dirname, join } from 'node:path'
|
|||
import { getPayload } from 'payload'
|
||||
|
||||
import {
|
||||
archivePhotoExifEntries,
|
||||
archivePhotoImportFromFlickr,
|
||||
archivePhotoStorageFilename,
|
||||
} from '../src/lib/archivePhoto.ts'
|
||||
|
|
@ -57,11 +65,13 @@ import {
|
|||
collectArchiveListing,
|
||||
formatArchiveBytes,
|
||||
formatArchiveInventory,
|
||||
formatArchiveRefresh,
|
||||
formatArchiveReport,
|
||||
parseArchiveCliArgs,
|
||||
planArchiveEntries,
|
||||
summarizeArchiveInventory,
|
||||
summarizeArchivePlan,
|
||||
summarizeArchiveRefresh,
|
||||
summarizeArchiveResults,
|
||||
} from './lib/flickrPlan.mjs'
|
||||
|
||||
|
|
@ -90,21 +100,24 @@ Modos (mutuamente exclusivos; o default é o plano):
|
|||
(sem flag) plano/dry-run — lista, mapeia e reporta; sem download nem escrita
|
||||
--apply baixa os originais e cria as linhas/objetos no alvo
|
||||
--verify inventário read-only do que já está no acervo (sem Flickr)
|
||||
--refresh-metadata re-busca o EXIF das linhas existentes e atualiza o que mudou
|
||||
(backfill; escreve só no banco, exige FLICKR_API_KEY)
|
||||
|
||||
Opções:
|
||||
--limit <n> processa no máximo n fotos (a varredura de álbuns continua completa)
|
||||
--page <n> começa a listagem na página n (retomada)
|
||||
--limit <n> processa no máximo n fotos; no refresh, no máximo n linhas
|
||||
--page <n> começa a listagem na página n (retomada; não vale no refresh)
|
||||
--out <dir> diretório dos recibos (default ${ARCHIVE_PHOTO_DEFAULT_OUT_DIR})
|
||||
--help esta ajuda
|
||||
|
||||
Ambiente:
|
||||
FLICKR_API_KEY chave da API do Flickr (nunca no repo)
|
||||
FLICKR_USER_ID NSID da conta (ex.: 12345678@N00)
|
||||
FLICKR_USER_ID NSID da conta (ex.: 12345678@N00; não exigido no --refresh-metadata)
|
||||
DATABASE_URL alvo (obrigatório em todos os modos)
|
||||
|
||||
--apply em alvo não-local/produção exige ${WRITE_CONFIRM_FLAG}=1, TEQO_ENV=${envLines}
|
||||
casando o banco exato e as quatro envs S3_* (o original nunca vai para disco
|
||||
efêmero). O dry-run e o --verify são read-only.
|
||||
--apply e --refresh-metadata em alvo não-local/produção exigem ${WRITE_CONFIRM_FLAG}=1
|
||||
e TEQO_ENV=${envLines} casando o banco exato; o --apply exige também as quatro
|
||||
envs S3_* (o original nunca vai para disco efêmero). O dry-run e o --verify são
|
||||
read-only.
|
||||
`
|
||||
|
||||
class StageError extends Error {
|
||||
|
|
@ -238,39 +251,49 @@ async function main() {
|
|||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) die('DATABASE_URL não definida; recusando continuar.')
|
||||
const mode = options.apply ? 'apply' : options.verify ? 'verify' : 'plan'
|
||||
const mode = options.apply
|
||||
? 'apply'
|
||||
: options.verify
|
||||
? 'verify'
|
||||
: options.refreshMetadata
|
||||
? 'refresh-metadata'
|
||||
: 'plan'
|
||||
const runAt = new Date().toISOString()
|
||||
console.log(`[flickr:import] alvo: ${databaseTarget()} | modo: ${mode}`)
|
||||
|
||||
if (mode === 'apply') {
|
||||
if (mode === 'apply' || mode === 'refresh-metadata') {
|
||||
assertWriteConfirm({
|
||||
label: 'flickr:import',
|
||||
flag: WRITE_CONFIRM_FLAG,
|
||||
command: 'pnpm flickr:import --apply',
|
||||
command: `pnpm flickr:import --${mode === 'apply' ? 'apply' : 'refresh-metadata'}`,
|
||||
})
|
||||
const target = assertEnvironmentDatabaseTarget()
|
||||
const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts')
|
||||
const storage = resolveS3StorageEnv(process.env)
|
||||
if (mirroredMediaRequired({ s3Enabled: storage.enabled })) {
|
||||
die(
|
||||
`escrita em produção/remoto (${target.environment}) exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o original iria para disco local efêmero.`,
|
||||
)
|
||||
if (mode === 'apply') {
|
||||
const { resolveS3StorageEnv } = await import('../src/utilities/mediaStorage.ts')
|
||||
const storage = resolveS3StorageEnv(process.env)
|
||||
if (mirroredMediaRequired({ s3Enabled: storage.enabled })) {
|
||||
die(
|
||||
`escrita em produção/remoto (${target.environment}) exige mídia espelhada: configure S3_BUCKET, S3_ENDPOINT, S3_ACCESS_KEY_ID e S3_SECRET_ACCESS_KEY; sem elas o original iria para disco local efêmero.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode !== 'verify') {
|
||||
if (!process.env.FLICKR_API_KEY?.trim()) {
|
||||
die('FLICKR_API_KEY ausente — configure a chave da API do Flickr (veja .env.example).')
|
||||
}
|
||||
if (!process.env.FLICKR_USER_ID?.trim()) {
|
||||
die('FLICKR_USER_ID ausente — informe o NSID da conta (ex.: 12345678@N00).')
|
||||
}
|
||||
if (mode !== 'verify' && !process.env.FLICKR_API_KEY?.trim()) {
|
||||
die('FLICKR_API_KEY ausente — configure a chave da API do Flickr (veja .env.example).')
|
||||
}
|
||||
if ((mode === 'plan' || mode === 'apply') && !process.env.FLICKR_USER_ID?.trim()) {
|
||||
die('FLICKR_USER_ID ausente — informe o NSID da conta (ex.: 12345678@N00).')
|
||||
}
|
||||
|
||||
const config = (await import('../src/payload.config.ts')).default
|
||||
const { downloadUrlToFile } = await import('../src/utilities/media/downloadToFile.ts')
|
||||
const { findArchivePhotoByFlickrId, ingestArchivePhoto, listArchivePhotos } =
|
||||
await import('../src/utilities/flickr/archivePhotoIngest.ts')
|
||||
const {
|
||||
findArchivePhotoByFlickrId,
|
||||
ingestArchivePhoto,
|
||||
listArchivePhotos,
|
||||
updateArchivePhotoExif,
|
||||
} = await import('../src/utilities/flickr/archivePhotoIngest.ts')
|
||||
const payload = await getPayload({ config })
|
||||
const startedAt = Date.now()
|
||||
|
||||
|
|
@ -295,6 +318,57 @@ async function main() {
|
|||
process.exit(0)
|
||||
}
|
||||
|
||||
if (mode === 'refresh-metadata') {
|
||||
const client = createFlickrClient({ apiKey: process.env.FLICKR_API_KEY })
|
||||
const rows = await listArchivePhotos(payload)
|
||||
const selected = options.limit === null ? rows : rows.slice(0, options.limit)
|
||||
console.log(
|
||||
`[flickr:import] refresh de metadados (EXIF) de ${selected.length} linha(s) do acervo...`,
|
||||
)
|
||||
const results = []
|
||||
for (const row of selected) {
|
||||
const flickrId = String(row.flickrId)
|
||||
try {
|
||||
const rawExif = await withStage('metadata', () => client.getExif(flickrId))
|
||||
const exif = archivePhotoExifEntries(rawExif)
|
||||
const current = Array.isArray(row.exif) ? row.exif : []
|
||||
if (JSON.stringify(current) === JSON.stringify(exif)) {
|
||||
results.push({ flickrId, status: 'unchanged', entries: exif.length })
|
||||
continue
|
||||
}
|
||||
const updated = await updateArchivePhotoExif(payload, { id: row.id, flickrId, exif })
|
||||
if (updated.status === 'failed') {
|
||||
results.push({ flickrId, status: 'failed', error: updated.error })
|
||||
console.log(`[flickr:import] falha refresh ${flickrId}: ${updated.error}`)
|
||||
continue
|
||||
}
|
||||
results.push({ flickrId, status: 'updated', entries: exif.length })
|
||||
console.log(`[flickr:import] atualizada ${flickrId} (${exif.length} entrada(s) EXIF)`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
results.push({ flickrId, status: 'failed', error: message })
|
||||
console.log(`[flickr:import] falha metadata ${flickrId}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = summarizeArchiveRefresh(results)
|
||||
const report = {
|
||||
runAt,
|
||||
mode,
|
||||
target: databaseTarget(),
|
||||
total: selected.length,
|
||||
summary,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
const reportPath = await writeReport(options, report, '-refresh')
|
||||
for (const line of formatArchiveRefresh(report)) console.log(line)
|
||||
console.log(`\n[flickr:import] recibo JSON: ${reportPath}`)
|
||||
if (summary.updated + summary.unchanged === 0 && results.length > 0) {
|
||||
die('nenhuma linha foi refrescada — falhas listadas no recibo.')
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const userId = process.env.FLICKR_USER_ID.trim()
|
||||
const client = createFlickrClient({ apiKey: process.env.FLICKR_API_KEY })
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const VIDEO_MEDIA = 'video'
|
|||
const BYTES_PER_MIB = 1024 * 1024
|
||||
|
||||
const USAGE =
|
||||
'uso: `pnpm flickr:import [--apply|--verify] [--limit <n>] [--page <n>] [--out <dir>]`'
|
||||
'uso: `pnpm flickr:import [--apply|--verify|--refresh-metadata] [--limit <n>] [--page <n>] [--out <dir>]`'
|
||||
|
||||
export const ARCHIVE_PHOTO_DEFAULT_OUT_DIR = 'data/flickr'
|
||||
|
||||
|
|
@ -43,12 +43,13 @@ export const archivePhotoReportStamp = (runAt) => runAt.replace(/[:.]/g, '-')
|
|||
* errors carry the usage line.
|
||||
*
|
||||
* @param {string[]} [argv]
|
||||
* @returns {{ apply: boolean, verify: boolean, limit: number | null, page: number, out: string, help: boolean }}
|
||||
* @returns {{ apply: boolean, verify: boolean, refreshMetadata: boolean, limit: number | null, page: number, out: string, help: boolean }}
|
||||
*/
|
||||
export const parseArchiveCliArgs = (argv = process.argv.slice(2)) => {
|
||||
const options = {
|
||||
apply: false,
|
||||
verify: false,
|
||||
refreshMetadata: false,
|
||||
limit: null,
|
||||
page: 1,
|
||||
out: ARCHIVE_PHOTO_DEFAULT_OUT_DIR,
|
||||
|
|
@ -67,6 +68,7 @@ export const parseArchiveCliArgs = (argv = process.argv.slice(2)) => {
|
|||
if (arg === '--help' || arg === '-h') options.help = true
|
||||
else if (arg === '--apply') options.apply = true
|
||||
else if (arg === '--verify') options.verify = true
|
||||
else if (arg === '--refresh-metadata') options.refreshMetadata = true
|
||||
else if (arg === '--limit') options.limit = Number(value())
|
||||
else if (arg === '--page') options.page = Number(value())
|
||||
else if (arg === '--out') options.out = value()
|
||||
|
|
@ -74,8 +76,9 @@ export const parseArchiveCliArgs = (argv = process.argv.slice(2)) => {
|
|||
}
|
||||
|
||||
if (options.help) return options
|
||||
if (options.apply && options.verify) {
|
||||
throw new Error(`--apply e --verify são mutuamente exclusivos — ${USAGE}.`)
|
||||
const modes = [options.apply, options.verify, options.refreshMetadata].filter(Boolean).length
|
||||
if (modes > 1) {
|
||||
throw new Error(`--apply, --verify e --refresh-metadata são mutuamente exclusivos — ${USAGE}.`)
|
||||
}
|
||||
if (options.limit !== null && (!Number.isInteger(options.limit) || options.limit < 1)) {
|
||||
throw new Error('--limit deve ser >= 1.')
|
||||
|
|
@ -83,6 +86,11 @@ export const parseArchiveCliArgs = (argv = process.argv.slice(2)) => {
|
|||
if (!Number.isInteger(options.page) || options.page < 1) {
|
||||
throw new Error('--page deve ser >= 1.')
|
||||
}
|
||||
if (options.refreshMetadata && options.page !== 1) {
|
||||
throw new Error(
|
||||
`--page não se aplica a --refresh-metadata (a varredura é das linhas do acervo) — ${USAGE}.`,
|
||||
)
|
||||
}
|
||||
if (options.out.split(/[\\/]/).includes('..')) {
|
||||
throw new Error('--out não pode escapar do diretório do repo (sem "..").')
|
||||
}
|
||||
|
|
@ -274,6 +282,22 @@ export const summarizeArchiveResults = (results) => ({
|
|||
})),
|
||||
})
|
||||
|
||||
/**
|
||||
* The `--refresh-metadata` results: rows updated/unchanged, the EXIF entries
|
||||
* written and the named failures — no row is silently dropped.
|
||||
*
|
||||
* @param {Array<{ status: string, flickrId: string, entries?: number | null, error?: string | null }>} results
|
||||
*/
|
||||
export const summarizeArchiveRefresh = (results) => ({
|
||||
updated: results.filter((result) => result.status === 'updated').length,
|
||||
unchanged: results.filter((result) => result.status === 'unchanged').length,
|
||||
failed: results.filter((result) => result.status === 'failed').length,
|
||||
entries: results.reduce((total, result) => total + (Number(result.entries) || 0), 0),
|
||||
failures: results
|
||||
.filter((result) => result.status === 'failed')
|
||||
.map((result) => ({ flickrId: result.flickrId, error: result.error ?? null })),
|
||||
})
|
||||
|
||||
/**
|
||||
* The `--verify` inventory over the stored rows (read-only, no Flickr, no
|
||||
* bucket): counts, bytes, per-album coverage, metadata gaps and rows without
|
||||
|
|
@ -388,3 +412,19 @@ export const formatArchiveInventory = (inventory) => {
|
|||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/** `--refresh-metadata` human lines (rows touched + named failures). */
|
||||
export const formatArchiveRefresh = (report) => {
|
||||
const label = REPORT_LABEL
|
||||
const lines = [
|
||||
`${label} refresh (metadados): atualizadas: ${report.summary.updated} · inalteradas: ${report.summary.unchanged} · falhas: ${report.summary.failed} · entradas EXIF: ${report.summary.entries}`,
|
||||
`${label} tempo: ${(report.durationMs / 1000).toFixed(1)}s`,
|
||||
]
|
||||
if (report.summary.failures.length > 0) {
|
||||
lines.push(`${label} falhas:`)
|
||||
for (const failure of report.summary.failures) {
|
||||
lines.push(` - ${failure.flickrId}: ${failure.error ?? 'erro'}`)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,6 +175,19 @@ export const archivePhotoGeoFrom = (
|
|||
return { latitude: lat, longitude: lon }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `clean`/`raw` leaves of the JSON API answer arrive wrapped as
|
||||
* `{ _content: "…" }` (Flickr's XML heritage); plain strings appear in older
|
||||
* shapes and fixtures. Both are accepted, everything else drops.
|
||||
*/
|
||||
const contentScalar = (value: unknown): string | null => {
|
||||
if (typeof value === 'string') return trimmed(value)
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return trimmed((value as Record<string, unknown>)._content)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes `flickr.photos.getExif` (when it answered) into the stored json:
|
||||
* one entry per tag, empty values dropped, deterministic order.
|
||||
|
|
@ -187,7 +200,7 @@ export const archivePhotoExifEntries = (value: unknown): ArchivePhotoExifEntry[]
|
|||
const entry = raw as Record<string, unknown>
|
||||
const tag = trimmed(entry.tag) ?? trimmed(entry.tagspace)
|
||||
const label = trimmed(entry.label) ?? tag
|
||||
const value = trimmed(entry.clean) ?? trimmed(entry.raw)
|
||||
const value = contentScalar(entry.clean) ?? contentScalar(entry.raw)
|
||||
if (tag === null || label === null || value === null) continue
|
||||
entries.push({ tag, label, value })
|
||||
}
|
||||
|
|
@ -250,10 +263,7 @@ export const archivePhotoImportFromFlickr = ({
|
|||
const originalUrl = original ?? fallback
|
||||
if (originalUrl === null) return { ok: false, reason: 'original' }
|
||||
|
||||
const description =
|
||||
item.description !== null && typeof item.description === 'object'
|
||||
? trimmed((item.description as Record<string, unknown>)._content)
|
||||
: trimmed(item.description)
|
||||
const description = contentScalar(item.description)
|
||||
|
||||
const license = licenseOrNull(item.license)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import 'server-only'
|
|||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG, archivePhotoAlt, type ArchivePhotoImport } from '@/lib/archivePhoto'
|
||||
import {
|
||||
ARCHIVE_PHOTO_SLUG,
|
||||
archivePhotoAlt,
|
||||
type ArchivePhotoExifEntry,
|
||||
type ArchivePhotoImport,
|
||||
} from '@/lib/archivePhoto'
|
||||
import type { ArchivePhoto } from '@/payload-types'
|
||||
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
||||
|
||||
|
|
@ -142,3 +147,38 @@ export const ingestArchivePhoto = async (
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ArchivePhotoExifUpdateResult =
|
||||
| { status: 'updated'; flickrId: string; id: number; entries: number }
|
||||
| { status: 'failed'; flickrId: string; error: string }
|
||||
|
||||
/**
|
||||
* C231 — rewrites ONLY the stored `exif` of one existing row: the
|
||||
* `--refresh-metadata` backfill for rows ingested before the mapper understood
|
||||
* the API's `{ _content }` leaves. The Flickr answer is the source of truth (an
|
||||
* empty answer clears the field), the deterministic order of
|
||||
* `archivePhotoExifEntries` makes an unchanged row comparable by the caller,
|
||||
* and every failure is returned, never thrown — the caller owns the receipt.
|
||||
* Same documented CLI bypass as the ingestion (trusted actor, no session).
|
||||
*/
|
||||
export const updateArchivePhotoExif = async (
|
||||
payload: Payload,
|
||||
{ id, flickrId, exif }: { id: number; flickrId: string; exif: ArchivePhotoExifEntry[] },
|
||||
): Promise<ArchivePhotoExifUpdateResult> => {
|
||||
try {
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id,
|
||||
data: { exif },
|
||||
// Intentional bypass: the maintenance CLI is a trusted actor with no session.
|
||||
overrideAccess: true,
|
||||
})
|
||||
return { status: 'updated', flickrId, id, entries: exif.length }
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'failed',
|
||||
flickrId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
findArchivePhotoByFlickrId,
|
||||
ingestArchivePhoto,
|
||||
listArchivePhotos,
|
||||
updateArchivePhotoExif,
|
||||
} from '@/utilities/flickr/archivePhotoIngest'
|
||||
|
||||
import { ARCHIVE_PHOTO_JPEG_BYTES } from '../helpers/archivePhotoFixture'
|
||||
|
|
@ -156,6 +157,47 @@ describe('ingestArchivePhoto (C231)', () => {
|
|||
expect(await findArchivePhotoByFlickrId(payload, item.flickrId)).toBeNull()
|
||||
})
|
||||
|
||||
it('refreshes only the stored exif of an existing row (C231 backfill)', async () => {
|
||||
const item = record('53123456794', { exif: [] })
|
||||
const created = await ingestArchivePhoto(payload, item, {
|
||||
filePath: await writeOriginal(item.flickrId),
|
||||
})
|
||||
if (created.status !== 'created') throw new Error('archive photo fixture was not created')
|
||||
|
||||
const refreshed = await updateArchivePhotoExif(payload, {
|
||||
id: created.id,
|
||||
flickrId: item.flickrId,
|
||||
exif: [
|
||||
{ tag: 'Make', label: 'Fabricante', value: 'Canon' },
|
||||
{ tag: 'Model', label: 'Modelo', value: 'Canon EOS R6' },
|
||||
],
|
||||
})
|
||||
expect(refreshed).toMatchObject({ status: 'updated', entries: 2 })
|
||||
|
||||
const found = await payload.find({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: item.flickrId } },
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(found.docs[0]?.exif).toEqual([
|
||||
{ tag: 'Make', label: 'Fabricante', value: 'Canon' },
|
||||
{ tag: 'Model', label: 'Modelo', value: 'Canon EOS R6' },
|
||||
])
|
||||
// Only the exif changed: the metadata ingested with the original stays.
|
||||
expect(found.docs[0]?.title).toBe(item.title)
|
||||
|
||||
const failed = await updateArchivePhotoExif(payload, {
|
||||
id: 9_999_999,
|
||||
flickrId: '53123456799',
|
||||
exif: [],
|
||||
})
|
||||
expect(failed).toMatchObject({ status: 'failed', flickrId: '53123456799' })
|
||||
if (failed.status !== 'failed') return
|
||||
expect(failed.error.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reads only with the communication roles', async () => {
|
||||
const item = record('53123456792')
|
||||
await ingestArchivePhoto(payload, item, { filePath: await writeOriginal(item.flickrId) })
|
||||
|
|
|
|||
|
|
@ -139,6 +139,27 @@ describe('archivePhotoExifEntries (C231)', () => {
|
|||
])
|
||||
expect(archivePhotoExifEntries(null)).toEqual([])
|
||||
})
|
||||
|
||||
it("unwraps the JSON API's { _content } leaves and still accepts plain strings", () => {
|
||||
expect(
|
||||
archivePhotoExifEntries([
|
||||
{ tag: 'Make', label: 'Fabricante', raw: { _content: 'Canon' } },
|
||||
{
|
||||
tag: 'Model',
|
||||
label: 'Modelo',
|
||||
raw: { _content: 'Canon EOS' },
|
||||
clean: { _content: 'Canon EOS R6' },
|
||||
},
|
||||
{ tag: 'Artist', label: 'Autor', raw: { _content: ' ' } },
|
||||
{ tag: 'Unknown', label: 'Desconhecido', raw: { outro: 'x' } },
|
||||
{ tag: 'Software', label: 'Software', clean: 'Tezza' },
|
||||
]),
|
||||
).toEqual([
|
||||
{ tag: 'Make', label: 'Fabricante', value: 'Canon' },
|
||||
{ tag: 'Model', label: 'Modelo', value: 'Canon EOS R6' },
|
||||
{ tag: 'Software', label: 'Software', value: 'Tezza' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('archivePhotoImportFromFlickr (C231)', () => {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,34 @@ describe('flickr:import write guards (C231)', () => {
|
|||
expect(output(result)).toContain('S3_BUCKET')
|
||||
})
|
||||
|
||||
it('refuses --refresh-metadata without the intent flag, before the key check', () => {
|
||||
const result = run(['--refresh-metadata'])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('FLICKR_IMPORT_CONFIRM=1')
|
||||
expect(output(result)).toContain('--refresh-metadata')
|
||||
expect(output(result)).not.toContain('FLICKR_API_KEY')
|
||||
})
|
||||
|
||||
it('requires the declared TEQO_ENV for --refresh-metadata once the intent flag is set', () => {
|
||||
const result = run(['--refresh-metadata'], { FLICKR_IMPORT_CONFIRM: '1' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('TEQO_ENV')
|
||||
})
|
||||
|
||||
it('does not demand S3 for the metadata-only refresh (dies on the missing key instead)', () => {
|
||||
const result = run(['--refresh-metadata'], {
|
||||
FLICKR_IMPORT_CONFIRM: '1',
|
||||
TEQO_ENV: 'production',
|
||||
FLICKR_API_KEY: '',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(output(result)).toContain('FLICKR_API_KEY')
|
||||
expect(output(result)).not.toContain('S3_BUCKET')
|
||||
})
|
||||
|
||||
it('refuses an unknown argument before touching anything', () => {
|
||||
const result = run(['--force'])
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import {
|
|||
collectArchiveListing,
|
||||
formatArchiveBytes,
|
||||
formatArchiveInventory,
|
||||
formatArchiveRefresh,
|
||||
formatArchiveReport,
|
||||
parseArchiveCliArgs,
|
||||
planArchiveEntries,
|
||||
summarizeArchiveInventory,
|
||||
summarizeArchivePlan,
|
||||
summarizeArchiveRefresh,
|
||||
summarizeArchiveResults,
|
||||
} from '../../scripts/lib/flickrPlan.mjs'
|
||||
|
||||
|
|
@ -209,6 +211,23 @@ describe('summaries (C231)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('summarizes the refresh results with the entries written and failures', () => {
|
||||
expect(
|
||||
summarizeArchiveRefresh([
|
||||
{ flickrId: '1', status: 'updated', entries: 42 },
|
||||
{ flickrId: '2', status: 'unchanged', entries: 0 },
|
||||
{ flickrId: '3', status: 'failed', error: 'HTTP 500' },
|
||||
{ flickrId: '4', status: 'updated', entries: 7 },
|
||||
]),
|
||||
).toEqual({
|
||||
updated: 2,
|
||||
unchanged: 1,
|
||||
failed: 1,
|
||||
entries: 49,
|
||||
failures: [{ flickrId: '3', error: 'HTTP 500' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('summarizes the stored inventory per album and per metadata gap', () => {
|
||||
const inventory = summarizeArchiveInventory([
|
||||
{
|
||||
|
|
@ -310,6 +329,25 @@ describe('receipt formatting (C231)', () => {
|
|||
expect(formatArchiveBytes(2 * 1024 * 1024)).toBe('2.0 MiB')
|
||||
})
|
||||
|
||||
it('formats the refresh report with the touched rows and the failures', () => {
|
||||
const lines = formatArchiveRefresh({
|
||||
durationMs: 2500,
|
||||
summary: {
|
||||
updated: 120,
|
||||
unchanged: 10,
|
||||
failed: 1,
|
||||
entries: 4200,
|
||||
failures: [{ flickrId: '9', error: 'HTTP 500' }],
|
||||
},
|
||||
})
|
||||
const text = lines.join('\n')
|
||||
expect(text).toContain('refresh (metadados): atualizadas: 120')
|
||||
expect(text).toContain('inalteradas: 10')
|
||||
expect(text).toContain('entradas EXIF: 4200')
|
||||
expect(text).toContain('tempo: 2.5s')
|
||||
expect(text).toContain('- 9: HTTP 500')
|
||||
})
|
||||
|
||||
it('stamps the report file name', () => {
|
||||
expect(archivePhotoReportStamp('2026-09-26T06:20:39.123Z')).toBe('2026-09-26T06-20-39-123Z')
|
||||
})
|
||||
|
|
@ -320,6 +358,7 @@ describe('parseArchiveCliArgs (C231)', () => {
|
|||
expect(parseArchiveCliArgs([])).toEqual({
|
||||
apply: false,
|
||||
verify: false,
|
||||
refreshMetadata: false,
|
||||
limit: null,
|
||||
page: 1,
|
||||
out: 'data/flickr',
|
||||
|
|
@ -332,11 +371,27 @@ describe('parseArchiveCliArgs (C231)', () => {
|
|||
parseArchiveCliArgs(['--apply', '--limit', '5', '--page', '3', '--out', 'data/fotos']),
|
||||
).toMatchObject({ apply: true, verify: false, limit: 5, page: 3, out: 'data/fotos' })
|
||||
expect(parseArchiveCliArgs(['--verify'])).toMatchObject({ verify: true, apply: false })
|
||||
expect(parseArchiveCliArgs(['--refresh-metadata'])).toMatchObject({
|
||||
refreshMetadata: true,
|
||||
apply: false,
|
||||
verify: false,
|
||||
})
|
||||
expect(parseArchiveCliArgs(['--refresh-metadata', '--limit', '5'])).toMatchObject({
|
||||
refreshMetadata: true,
|
||||
limit: 5,
|
||||
})
|
||||
expect(parseArchiveCliArgs(['--help'])).toMatchObject({ help: true })
|
||||
})
|
||||
|
||||
it('refuses exclusive modes, invalid numbers, escaping --out and unknown flags', () => {
|
||||
expect(() => parseArchiveCliArgs(['--apply', '--verify'])).toThrow(/mutuamente exclusivos/)
|
||||
expect(() => parseArchiveCliArgs(['--apply', '--refresh-metadata'])).toThrow(
|
||||
/mutuamente exclusivos/,
|
||||
)
|
||||
expect(() => parseArchiveCliArgs(['--verify', '--refresh-metadata'])).toThrow(
|
||||
/mutuamente exclusivos/,
|
||||
)
|
||||
expect(() => parseArchiveCliArgs(['--refresh-metadata', '--page', '2'])).toThrow(/--page/)
|
||||
expect(() => parseArchiveCliArgs(['--limit', '0'])).toThrow(/--limit/)
|
||||
expect(() => parseArchiveCliArgs(['--limit', 'abc'])).toThrow(/--limit/)
|
||||
expect(() => parseArchiveCliArgs(['--page', '0'])).toThrow(/--page/)
|
||||
|
|
|
|||
Loading…
Reference in a new issue