feat(feed): implement RSS feed generation and caching mechanism

- Introduced a new FeedModule with FeedService and FeedController for managing RSS feed generation.
- Added CacheModule to handle caching of feed data, improving performance and reducing load.
- Implemented generateRSSFeed function to create RSS XML from photo manifest data.
- Enhanced static web service to include site metadata and configuration in the generated documents.
- Refactored existing modules to integrate new feed functionalities and ensure proper dependency management.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-11-08 17:05:09 +08:00
parent 51f586705c
commit 44f9cbb376
17 changed files with 1048 additions and 456 deletions

View File

@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import type { PhotoManifestItem, PickedExif } from '@afilmory/builder'
import type { PhotoManifestItem } from '@afilmory/builder'
import { generateRSSFeed } from '@afilmory/utils'
import type { Plugin } from 'vite'
import type { SiteConfig } from '../../../../site.config'
@@ -48,429 +49,6 @@ export function createFeedSitemapPlugin(siteConfig: SiteConfig): Plugin {
}
}
function generateRSSFeed(photos: PhotoManifestItem[], config: SiteConfig): string {
const now = new Date().toUTCString()
const latestPhoto = photos[0]
const lastBuildDate = latestPhoto ? new Date(latestPhoto.dateTaken).toUTCString() : now
const rssItems = photos
.map((photo) => {
const photoUrl = `${config.url}/${photo.id}`
const pubDate = new Date(photo.dateTaken).toUTCString()
const tags = photo.tags.join(', ')
let description = photo.description || photo.title
if (tags) {
description += ` | Tags: ${tags}`
}
// Extract EXIF data for custom tags
const exifTags = generateExifTags(photo.exif, photo)
return ` <item>
<title><![CDATA[${photo.title}]]></title>
<link>${photoUrl}</link>
<guid isPermaLink="true">${photoUrl}</guid>
<description><![CDATA[${description}]]></description>
<pubDate>${pubDate}</pubDate>
${photo.tags.map((tag) => `<category><![CDATA[${tag}]]></category>`).join('\n ')}
<enclosure url="${photo.thumbnailUrl.startsWith('http') ? photo.thumbnailUrl : config.url + photo.thumbnailUrl}" type="image/jpeg" length="${photo.size}" />
${exifTags}
</item>`
})
.join('\n')
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:exif="https://afilmory.com/rss/exif/1.1">
<channel>
<title><![CDATA[${config.title}]]></title>
<link>${config.url}</link>
<description><![CDATA[${config.description}]]></description>
<language>zh-CN</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<pubDate>${now}</pubDate>
<ttl>60</ttl>
<copyright>Copyright ${config.author.name}</copyright>
<!-- Afilmory RSS EXIF Extension Protocol Metadata -->
<exif:version>1.1</exif:version>
<exif:protocol>afilmory-rss-exif</exif:protocol>
${
config.feed?.folo?.challenge
? `
<follow_challenge>
<feedId>${config.feed?.folo?.challenge.feedId}</feedId>
<userId>${config.feed?.folo?.challenge.userId}</userId>
</follow_challenge>
`
: ''
}
<atom:link href="${config.url}/feed.xml" rel="self" type="application/rss+xml" />
<managingEditor>${config.author.name}</managingEditor>
<webMaster>${config.author.name}</webMaster>
<generator>Vite RSS Generator</generator>
<image>
<url>${config.author.avatar || `${config.url}/favicon.ico`}</url>
<title><![CDATA[${config.title}]]></title>
<link>${config.url}</link>
</image>
${rssItems}
</channel>
</rss>`
}
function generateExifTags(exif: PickedExif | null | undefined, photo: PhotoManifestItem): string {
if (!exif) {
return ''
}
const tags: string[] = []
const aperture = isFiniteNumber(exif.FNumber) ? `f/${formatDecimal(exif.FNumber)}` : null
if (aperture) {
tags.push(` <exif:aperture>${aperture}</exif:aperture>`)
}
const shutterSpeed = formatShutterSpeed(exif)
if (shutterSpeed) {
tags.push(` <exif:shutterSpeed>${shutterSpeed}</exif:shutterSpeed>`)
}
const iso = getISOValue(exif)
if (iso !== null) {
tags.push(` <exif:iso>${iso}</exif:iso>`)
}
const exposureCompensation = getExposureCompensation(exif)
if (exposureCompensation) {
tags.push(` <exif:exposureCompensation>${exposureCompensation}</exif:exposureCompensation>`)
}
tags.push(
` <exif:imageWidth>${photo.width}</exif:imageWidth>`,
` <exif:imageHeight>${photo.height}</exif:imageHeight>`,
)
const dateTaken = formatDateTaken(exif, photo)
if (dateTaken) {
tags.push(` <exif:dateTaken>${dateTaken}</exif:dateTaken>`)
}
if (exif.Make && exif.Model) {
tags.push(` <exif:camera><![CDATA[${exif.Make} ${exif.Model}]]></exif:camera>`)
}
if (exif.Orientation !== undefined && exif.Orientation !== null) {
tags.push(` <exif:orientation>${exif.Orientation}</exif:orientation>`)
}
if (exif.LensModel) {
tags.push(` <exif:lens><![CDATA[${exif.LensModel}]]></exif:lens>`)
}
const focalLength = formatFocalLength(exif.FocalLength)
if (focalLength) {
tags.push(` <exif:focalLength>${focalLength}</exif:focalLength>`)
}
const focalLength35mm = formatFocalLength(exif.FocalLengthIn35mmFormat)
if (focalLength35mm) {
tags.push(` <exif:focalLength35mm>${focalLength35mm}</exif:focalLength35mm>`)
}
if (isFiniteNumber(exif.MaxApertureValue)) {
const maxAperture = Math.pow(2, exif.MaxApertureValue / 2)
tags.push(` <exif:maxAperture>f/${formatDecimal(maxAperture)}</exif:maxAperture>`)
}
const latitude = normalizeCoordinate(exif.GPSLatitude, exif.GPSLatitudeRef)
const longitude = normalizeCoordinate(exif.GPSLongitude, exif.GPSLongitudeRef)
if (latitude !== null && longitude !== null) {
tags.push(
` <exif:gpsLatitude>${latitude}</exif:gpsLatitude>`,
` <exif:gpsLongitude>${longitude}</exif:gpsLongitude>`,
)
}
if (isFiniteNumber(exif.GPSAltitude)) {
const altitude =
exif.GPSAltitudeRef && isBelowSeaLevel(exif.GPSAltitudeRef)
? -Math.abs(exif.GPSAltitude)
: Math.abs(exif.GPSAltitude)
tags.push(` <exif:altitude>${formatDecimal(altitude, 2)}m</exif:altitude>`)
}
const whiteBalance = normalizeStringValue(exif.WhiteBalance)
if (whiteBalance) {
tags.push(` <exif:whiteBalance>${whiteBalance}</exif:whiteBalance>`)
}
const meteringMode = normalizeStringValue(exif.MeteringMode)
if (meteringMode) {
tags.push(` <exif:meteringMode>${meteringMode}</exif:meteringMode>`)
}
const flashMode = formatFlashMode(exif.Flash)
if (flashMode) {
tags.push(` <exif:flashMode>${flashMode}</exif:flashMode>`)
}
const colorSpace = normalizeStringValue(exif.ColorSpace)
if (colorSpace) {
tags.push(` <exif:colorSpace>${colorSpace}</exif:colorSpace>`)
}
const exposureProgram = normalizeStringValue(exif.ExposureProgram)
if (exposureProgram) {
tags.push(` <exif:exposureProgram>${exposureProgram}</exif:exposureProgram>`)
}
const sceneMode = normalizeStringValue(exif.SceneCaptureType)
if (sceneMode) {
tags.push(` <exif:sceneMode><![CDATA[${sceneMode}]]></exif:sceneMode>`)
}
const brightness = toNumber(exif.BrightnessValue)
if (brightness !== null) {
tags.push(` <exif:brightness>${formatDecimal(brightness, 2)} EV</exif:brightness>`)
}
const lightValue = toNumber(exif.LightValue)
if (lightValue !== null) {
tags.push(` <exif:lightValue>${formatDecimal(lightValue, 2)}</exif:lightValue>`)
}
return tags.join('\n')
}
function formatDateTaken(exif: PickedExif, photo: PhotoManifestItem): string | null {
const rawDate = exif.DateTimeOriginal
if (rawDate) {
try {
return new Date(rawDate).toISOString()
} catch {
// fallthrough to photo date
}
}
return new Date(photo.dateTaken).toISOString()
}
function formatShutterSpeed(exif: PickedExif): string | null {
const raw = exif.ExposureTime ?? exif.ShutterSpeed ?? exif.ShutterSpeedValue
if (raw === null || raw === undefined) {
return null
}
if (typeof raw === 'number') {
if (!Number.isFinite(raw)) {
return null
}
return raw >= 1 ? `${stripTrailingZeros(raw)}s` : `1/${Math.round(1 / raw)}s`
}
const value = raw.toString().trim()
if (!value) {
return null
}
if (value.endsWith('s')) {
return value
}
return `${value}s`
}
function getISOValue(exif: PickedExif): number | null {
if (isFiniteNumber(exif.ISO)) {
return Math.round(exif.ISO)
}
const isoFromExif = (exif as unknown as Record<string, unknown>).ISOSpeedRatings
const iso = toNumber(isoFromExif)
return iso !== null ? Math.round(iso) : null
}
function getExposureCompensation(exif: PickedExif): string | null {
const value = toNumber(exif.ExposureCompensation ?? (exif as unknown as Record<string, unknown>).ExposureBiasValue)
if (value === null) {
return null
}
const formatted = formatDecimal(value, 2)
if (value > 0 && !formatted.startsWith('+')) {
return `+${formatted} EV`
}
return `${formatted} EV`
}
function formatFocalLength(value: unknown): string | null {
if (value === null || value === undefined) {
return null
}
if (typeof value === 'number') {
return `${formatDecimal(value)}mm`
}
const text = value.toString().trim()
if (!text) {
return null
}
const match = text.match(/-?\d+(?:\.\d+)?/)
if (!match) {
return text.endsWith('mm') ? text : `${text}mm`
}
const numeric = Number.parseFloat(match[0])
if (Number.isNaN(numeric)) {
return text.endsWith('mm') ? text : `${text}mm`
}
return `${formatDecimal(numeric)}mm`
}
function normalizeCoordinate(
value: PickedExif['GPSLatitude'] | PickedExif['GPSLongitude'],
ref: PickedExif['GPSLatitudeRef'] | PickedExif['GPSLongitudeRef'],
): number | null {
if (value === null || value === undefined) {
return null
}
if (Array.isArray(value)) {
return convertDMSToDD(value, ref)
}
if (typeof value === 'number') {
return applyGPSRef(value, ref)
}
const match = value.toString().match(/-?\d+(?:\.\d+)?/)
if (!match) {
return null
}
const numeric = Number.parseFloat(match[0])
if (Number.isNaN(numeric)) {
return null
}
return applyGPSRef(numeric, ref)
}
function convertDMSToDD(
dms: readonly number[],
ref: PickedExif['GPSLatitudeRef'] | PickedExif['GPSLongitudeRef'],
): number | null {
if (!dms || dms.length !== 3) return null
const [degrees, minutes, seconds] = dms
if ([degrees, minutes, seconds].some((value) => !Number.isFinite(value))) {
return null
}
const value = degrees + minutes / 60 + seconds / 3600
return applyGPSRef(value, ref)
}
function applyGPSRef(value: number, ref: PickedExif['GPSLatitudeRef'] | PickedExif['GPSLongitudeRef']): number {
if (!ref) {
return roundCoordinate(value)
}
const negativeTokens = ['S', 'W', 'South', 'West']
const shouldNegate = negativeTokens.some((token) => ref.toString().toLowerCase().includes(token.toLowerCase()))
const signed = shouldNegate ? -Math.abs(value) : Math.abs(value)
return roundCoordinate(signed)
}
function roundCoordinate(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000
}
function isBelowSeaLevel(ref: PickedExif['GPSAltitudeRef']): boolean {
if (!ref) return false
if (typeof ref === 'number') {
return ref === 1
}
const normalized = ref.toString().toLowerCase()
return normalized.includes('below') || normalized === '1'
}
function normalizeStringValue(value: unknown): string | null {
if (value === null || value === undefined) {
return null
}
const text = value.toString().trim()
return text ?? null
}
function formatFlashMode(value: PickedExif['Flash']): string | null {
if (value === null || value === undefined) {
return null
}
if (typeof value === 'number') {
// Bit mask from EXIF spec: bit 0 indicates flash fired
return (value & 0x01) !== 0 ? 'On' : 'Off'
}
const text = value.toString().toLowerCase()
if (!text) {
return null
}
if (text.includes('on')) {
return 'On'
}
if (text.includes('off') || text.includes('no flash')) {
return 'Off'
}
if (text.includes('auto')) {
return 'Auto'
}
if (text.includes('red-eye')) {
return 'Red-eye'
}
return value.toString()
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value === 'string') {
const numeric = Number.parseFloat(value)
return Number.isNaN(numeric) ? null : numeric
}
return null
}
function formatDecimal(value: number, fractionDigits = 1): string {
if (!Number.isFinite(value)) {
return '0'
}
const fixed = value.toFixed(fractionDigits)
return stripTrailingZeros(Number.parseFloat(fixed))
}
function stripTrailingZeros(value: number): string {
if (!Number.isFinite(value)) {
return '0'
}
const text = value.toString()
if (!text.includes('.')) {
return text
}
return text.replace(/\.0+$/, '').replace(/(\.\d*?[1-9])0+$/, '$1')
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function generateSitemap(photos: PhotoManifestItem[], config: SiteConfig): string {
const now = new Date().toISOString()

View File

@@ -0,0 +1,10 @@
import { Module } from '@afilmory/framework'
import { RedisModule } from '../../redis/redis.module'
import { CacheService } from './cache.service'
@Module({
imports: [RedisModule],
providers: [CacheService],
})
export class CacheModule {}

View File

@@ -0,0 +1,92 @@
import { createLogger } from '@afilmory/framework'
import { injectable } from 'tsyringe'
import { RedisAccessor } from '../../redis/redis.provider'
const log = createLogger('CacheService')
export interface CacheSetOptions {
ttlSeconds?: number
}
export interface CacheRememberOptions<T> extends CacheSetOptions {
skipCacheWhen?: (value: T) => boolean
}
@injectable()
export class CacheService {
constructor(private readonly redisAccessor: RedisAccessor) {}
async getString(key: string): Promise<string | null> {
try {
return await this.redisAccessor.get().get(key)
} catch (error) {
log.warn(`Failed to read cache key "${key}": ${String(error)}`)
return null
}
}
async setString(key: string, value: string, options?: CacheSetOptions): Promise<void> {
try {
const client = this.redisAccessor.get()
if (options?.ttlSeconds && options.ttlSeconds > 0) {
await client.set(key, value, 'EX', options.ttlSeconds)
} else {
await client.set(key, value)
}
} catch (error) {
log.warn(`Failed to write cache key "${key}": ${String(error)}`)
}
}
async delete(key: string): Promise<void> {
try {
await this.redisAccessor.get().del(key)
} catch (error) {
log.warn(`Failed to delete cache key "${key}": ${String(error)}`)
}
}
async getJson<T>(key: string): Promise<T | null> {
const raw = await this.getString(key)
if (raw === null) {
return null
}
try {
return JSON.parse(raw) as T
} catch (error) {
log.warn(`Failed to parse cache payload for key "${key}": ${String(error)}`)
return null
}
}
async setJson<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
try {
const payload = JSON.stringify(value)
if (payload === undefined) {
log.warn(`Cache payload for key "${key}" resolved to undefined, skipping write.`)
return
}
await this.setString(key, payload, options)
} catch (error) {
log.warn(`Failed to serialize cache payload for key "${key}": ${String(error)}`)
}
}
async rememberJson<T>(key: string, factory: () => Promise<T>, options?: CacheRememberOptions<T>): Promise<T> {
const cached = await this.getJson<T>(key)
if (cached !== null) {
return cached
}
const value = await factory()
if (options?.skipCacheWhen && options.skipCacheWhen(value)) {
return value
}
await this.setJson(key, value, options)
return value
}
}

View File

@@ -1,7 +1,7 @@
import type { BuilderConfig, PhotoManifestItem, StorageConfig, StorageManager, StorageObject } from '@afilmory/builder'
import type { PhotoAssetConflictPayload, PhotoAssetConflictSnapshot, PhotoAssetManifest } from '@afilmory/db'
import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } from '@afilmory/db'
import { createLogger } from '@afilmory/framework'
import { createLogger, EventEmitterService } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { PhotoBuilderService } from 'core/modules/photo/photo.service'
import { PhotoStorageService } from 'core/modules/photo/photo-storage.service'
@@ -65,11 +65,16 @@ interface SyncPreparation {
export class DataSyncService {
private readonly logger = createLogger('DataSyncService')
constructor(
private readonly eventEmitter: EventEmitterService,
private readonly dbAccessor: DbAccessor,
private readonly photoBuilderService: PhotoBuilderService,
private readonly photoStorageService: PhotoStorageService,
) {}
private async emitManifestChanged(tenantId: string): Promise<void> {
await this.eventEmitter.emit('photo.manifest.changed', { tenantId })
}
async runSync(options: DataSyncOptions, onProgress?: DataSyncProgressEmitter): Promise<DataSyncResult> {
const tenant = requireTenantContext()
const { builderConfig, storageConfig } = await this.resolveBuilderConfigForTenant(tenant.tenant.id, options)
@@ -157,6 +162,13 @@ export class DataSyncService {
await this.emitComplete(onProgress, result)
if (!options.dryRun) {
const mutated = actions.some((action) => action.applied)
if (mutated) {
await this.emitManifestChanged(tenant.tenant.id)
}
}
return result
}
@@ -198,10 +210,18 @@ export class DataSyncService {
const dryRun = options.dryRun ?? false
if (options.strategy === ConflictResolutionStrategy.PREFER_STORAGE) {
return await this.resolveByStorage(record, conflictPayload, options, dryRun, tenant.tenant.id, db)
const action = await this.resolveByStorage(record, conflictPayload, options, dryRun, tenant.tenant.id, db)
if (!dryRun && action.applied) {
await this.emitManifestChanged(tenant.tenant.id)
}
return action
}
return await this.resolveByDatabase(record, conflictPayload, dryRun, tenant.tenant.id, db)
const action = await this.resolveByDatabase(record, conflictPayload, dryRun, tenant.tenant.id, db)
if (!dryRun && action.applied) {
await this.emitManifestChanged(tenant.tenant.id)
}
return action
}
private async prepareSyncContext(

View File

@@ -0,0 +1,56 @@
import { ContextParam, Controller, Get } from '@afilmory/framework'
import type { Context } from 'hono'
import type { FeedDocument } from './feed.service'
import { FeedService } from './feed.service'
@Controller({ bypassGlobalPrefix: true })
export class FeedController {
constructor(private readonly feedService: FeedService) {}
@Get('/feed.xml')
async getFeed(@ContextParam() context: Context): Promise<Response> {
const document = await this.feedService.getFeedDocument()
if (this.shouldReturnNotModified(context, document)) {
return this.feedService.createNotModifiedResponse(document)
}
return this.feedService.createOkResponse(document)
}
private shouldReturnNotModified(context: Context, document: FeedDocument): boolean {
const currentEtag = document.etag
const ifNoneMatch = context.req.header('if-none-match')
if (ifNoneMatch && this.matchesEtag(ifNoneMatch, currentEtag)) {
return true
}
const lastModified = document.lastPublishedAt ?? document.generatedAt
const ifModifiedSince = context.req.header('if-modified-since')
if (ifModifiedSince && lastModified) {
const sinceTime = Date.parse(ifModifiedSince)
const lastModifiedTime = Date.parse(lastModified)
if (!Number.isNaN(sinceTime) && !Number.isNaN(lastModifiedTime) && lastModifiedTime <= sinceTime) {
return true
}
}
return false
}
private matchesEtag(headerValue: string, currentEtag: string): boolean {
const trimmed = headerValue.trim()
if (trimmed === '*') {
return true
}
const candidates = trimmed
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
return candidates.includes(currentEtag)
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@afilmory/framework'
import { CacheModule } from '../cache/cache.module'
import { ManifestModule } from '../manifest/manifest.module'
import { SiteSettingModule } from '../site-setting/site-setting.module'
import { FeedController } from './feed.controller'
import { FeedService } from './feed.service'
@Module({
imports: [CacheModule, SiteSettingModule, ManifestModule],
controllers: [FeedController],
providers: [FeedService],
})
export class FeedModule {}

View File

@@ -0,0 +1,231 @@
import { createHash } from 'node:crypto'
import type { OnModuleDestroy, OnModuleInit } from '@afilmory/framework'
import { createLogger, EventEmitterService, HttpContext } from '@afilmory/framework'
import type { FeedSiteConfig } from '@afilmory/utils'
import { generateRSSFeed } from '@afilmory/utils'
import type { Context } from 'hono'
import { injectable } from 'tsyringe'
import type { CacheRememberOptions } from '../cache/cache.service'
import { CacheService } from '../cache/cache.service'
import { ManifestService } from '../manifest/manifest.service'
import { SiteSettingService } from '../site-setting/site-setting.service'
import { SITE_SETTING_KEYS } from '../site-setting/site-setting.type'
import { requireTenantContext } from '../tenant/tenant.context'
const CACHE_PREFIX = 'core:feed:rss'
const CACHE_VERSION = 'v1'
const CACHE_TTL_SECONDS = 300
const CACHE_STALE_SECONDS = 120
const logger = createLogger('FeedService')
const SITE_SETTING_KEY_SET = new Set<string>(SITE_SETTING_KEYS)
export interface FeedDocument {
xml: string
generatedAt: string
lastPublishedAt: string | null
etag: string
itemCount: number
}
@injectable()
export class FeedService implements OnModuleInit, OnModuleDestroy {
constructor(
private readonly eventEmitter: EventEmitterService,
private readonly cacheService: CacheService,
private readonly siteSettingService: SiteSettingService,
private readonly manifestService: ManifestService,
) {}
async onModuleInit(): Promise<void> {
this.eventEmitter.on('setting.updated', this.handleSettingMutated)
this.eventEmitter.on('setting.deleted', this.handleSettingMutated)
this.eventEmitter.on('photo.manifest.changed', this.handleManifestChanged)
}
async onModuleDestroy(): Promise<void> {
this.eventEmitter.off('setting.updated', this.handleSettingMutated)
this.eventEmitter.off('setting.deleted', this.handleSettingMutated)
this.eventEmitter.off('photo.manifest.changed', this.handleManifestChanged)
}
private readonly handleSettingMutated = ({ tenantId, key }: { tenantId: string; key: string }) => {
if (!SITE_SETTING_KEY_SET.has(key)) {
return
}
void this.invalidateCacheForTenant(tenantId, 'site-setting-updated')
}
private readonly handleManifestChanged = ({ tenantId }: { tenantId: string }) => {
void this.invalidateCacheForTenant(tenantId, 'photo-manifest-changed')
}
async getFeedDocument(): Promise<FeedDocument> {
const tenant = requireTenantContext()
const cacheKey = this.createCacheKey(tenant.tenant.id)
const options: CacheRememberOptions<FeedDocument> = {
ttlSeconds: CACHE_TTL_SECONDS,
skipCacheWhen: (value) => !value || value.xml.length === 0,
}
return await this.cacheService.rememberJson(cacheKey, async () => await this.buildFeedDocument(), options)
}
createOkResponse(document: FeedDocument): Response {
const headers = this.createBaseHeaders(document)
headers.set('content-length', Buffer.byteLength(document.xml, 'utf8').toString())
return new Response(document.xml, {
status: 200,
headers,
})
}
createNotModifiedResponse(document: FeedDocument): Response {
const headers = this.createBaseHeaders(document)
headers.delete('content-length')
return new Response(null, {
status: 304,
headers,
})
}
private createCacheKey(tenantId: string): string {
return `${CACHE_PREFIX}:${CACHE_VERSION}:${tenantId}`
}
private async invalidateCacheForTenant(tenantId: string, reason: string): Promise<void> {
if (!tenantId) {
return
}
const cacheKey = this.createCacheKey(tenantId)
await this.cacheService.delete(cacheKey)
logger.debug('Invalidated feed cache for tenant', { tenantId, reason, cacheKey })
}
private async buildFeedDocument(): Promise<FeedDocument> {
const [manifest, siteConfig] = await Promise.all([
this.manifestService.getManifest(),
this.siteSettingService.getSiteConfig(),
])
const baseUrl = this.resolveBaseUrl(siteConfig.url)
const feedConfig: FeedSiteConfig = {
title: siteConfig.title,
description: siteConfig.description,
url: baseUrl,
author: {
name: siteConfig.author.name,
url: siteConfig.author.url,
avatar: siteConfig.author.avatar ?? null,
},
feed: siteConfig.feed,
}
const xml = generateRSSFeed(manifest.data, feedConfig)
const lastPublishedAt = this.resolveLastPublishedAt(manifest.data[0]?.dateTaken)
const generatedAt = new Date().toUTCString()
const etag = this.createEtag(xml)
return {
xml,
generatedAt,
lastPublishedAt,
etag,
itemCount: manifest.data.length,
}
}
private createBaseHeaders(document: FeedDocument): Headers {
const headers = new Headers()
headers.set('content-type', 'application/rss+xml; charset=utf-8')
headers.set('cache-control', `public, max-age=${CACHE_TTL_SECONDS}, stale-while-revalidate=${CACHE_STALE_SECONDS}`)
headers.set('etag', document.etag)
headers.set('x-feed-items', document.itemCount.toString())
headers.set('x-generated-at', document.generatedAt)
const lastModified = document.lastPublishedAt ?? document.generatedAt
headers.set('last-modified', lastModified)
headers.set('vary', 'accept, accept-encoding')
return headers
}
private resolveBaseUrl(candidate?: string | null): string {
const normalized = this.normalizeUrl(candidate)
if (normalized) {
return normalized
}
const context = this.getHttpContext()
const derived = context ? this.deriveOriginFromRequest(context) : null
if (derived) {
return derived
}
logger.warn('Site URL is not configured; falling back to localhost origin for feed generation.')
return 'http://localhost'
}
private createEtag(payload: string): string {
const hash = createHash('sha256').update(payload).digest('hex')
return `W/"${hash}"`
}
private resolveLastPublishedAt(dateTaken?: string | null): string | null {
if (!dateTaken) {
return null
}
const timestamp = Date.parse(dateTaken)
if (Number.isNaN(timestamp)) {
return null
}
return new Date(timestamp).toUTCString()
}
private normalizeUrl(url?: string | null): string | null {
if (!url) {
return null
}
const trimmed = url.trim()
if (!trimmed) {
return null
}
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
}
private getHttpContext(): Context | null {
try {
return (HttpContext.getValue('hono') as Context | undefined) ?? null
} catch {
return null
}
}
private deriveOriginFromRequest(context: Context): string | null {
const forwardedProto = context.req.header('x-forwarded-proto')
const forwardedHost = context.req.header('x-forwarded-host')
const host = this.normalizeHost(forwardedHost ?? context.req.header('host'))
if (host) {
const protocol = forwardedProto?.trim().toLowerCase() || (host.startsWith('localhost') ? 'http' : 'https')
return `${protocol}://${host}`
}
try {
const url = new URL(context.req.url)
return `${url.protocol}//${url.host}`
} catch {
return null
}
}
private normalizeHost(host: string | null | undefined): string | null {
if (!host) {
return null
}
return host.trim().replace(/\/+$/, '') || null
}
}

View File

@@ -8,8 +8,10 @@ import { RedisAccessor } from 'core/redis/redis.provider'
import { DatabaseModule } from '../database/database.module'
import { RedisModule } from '../redis/redis.module'
import { AuthModule } from './auth/auth.module'
import { CacheModule } from './cache/cache.module'
import { DashboardModule } from './dashboard/dashboard.module'
import { DataSyncModule } from './data-sync/data-sync.module'
import { FeedModule } from './feed/feed.module'
import { OnboardingModule } from './onboarding/onboarding.module'
import { PhotoModule } from './photo/photo.module'
import { ReactionModule } from './reaction/reaction.module'
@@ -30,8 +32,13 @@ function createEventModuleOptions(redis: RedisAccessor) {
@Module({
imports: [
DatabaseModule,
EventModule.forRootAsync({
useFactory: createEventModuleOptions,
inject: [RedisAccessor],
}),
RedisModule,
AuthModule,
CacheModule,
SettingModule,
StorageSettingModule,
SiteSettingModule,
@@ -43,11 +50,10 @@ function createEventModuleOptions(redis: RedisAccessor) {
DashboardModule,
TenantModule,
DataSyncModule,
FeedModule,
// This must be last
StaticWebModule,
EventModule.forRootAsync({
useFactory: createEventModuleOptions,
inject: [RedisAccessor],
}),
],
providers: [
{

View File

@@ -0,0 +1,8 @@
import { Module } from '@afilmory/framework'
import { ManifestService } from './manifest.service'
@Module({
providers: [ManifestService],
})
export class ManifestModule {}

View File

@@ -7,7 +7,7 @@ import { DbAccessor } from '../../database/database.provider'
import { requireTenantContext } from '../tenant/tenant.context'
@injectable()
export class StaticWebManifestService {
export class ManifestService {
constructor(private readonly dbAccessor: DbAccessor) {}
async getManifest(): Promise<AfilmoryManifest> {

View File

@@ -8,6 +8,7 @@ import {
import { StorageManager } from '@afilmory/builder/storage/index.js'
import type { PhotoAssetManifest } from '@afilmory/db'
import { CURRENT_PHOTO_MANIFEST_VERSION, DATABASE_ONLY_PROVIDER, photoAssets } from '@afilmory/db'
import { EventEmitterService } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { PhotoBuilderService } from 'core/modules/photo/photo.service'
import { requireTenantContext } from 'core/modules/tenant/tenant.context'
@@ -59,14 +60,25 @@ type PreparedUploadPlan = {
isExisting?: boolean
}
declare module '@afilmory/framework' {
interface Events {
'photo.manifest.changed': { tenantId: string }
}
}
@injectable()
export class PhotoAssetService {
constructor(
private readonly eventEmitter: EventEmitterService,
private readonly dbAccessor: DbAccessor,
private readonly photoBuilderService: PhotoBuilderService,
private readonly photoStorageService: PhotoStorageService,
) {}
private async emitManifestChanged(tenantId: string): Promise<void> {
await this.eventEmitter.emit('photo.manifest.changed', { tenantId })
}
async listAssets(): Promise<PhotoAssetListItem[]> {
const tenant = requireTenantContext()
const db = this.dbAccessor.get()
@@ -198,6 +210,7 @@ export class PhotoAssetService {
}
await db.delete(photoAssets).where(and(eq(photoAssets.tenantId, tenant.tenant.id), inArray(photoAssets.id, ids)))
await this.emitManifestChanged(tenant.tenant.id)
}
async uploadAssets(inputs: readonly UploadAssetInput[]): Promise<PhotoAssetListItem[]> {
@@ -265,7 +278,13 @@ export class PhotoAssetService {
existingStorageMap,
})
return [...existingItems, ...processedItems]
const result = [...existingItems, ...processedItems]
if (processedItems.length > 0) {
await this.emitManifestChanged(tenant.tenant.id)
}
return result
}
private prepareUploadPlans(

View File

@@ -1,4 +1,4 @@
import { ContextParam, Controller, Get, Head } from '@afilmory/framework'
import { ContextParam, Controller, Get } from '@afilmory/framework'
import type { Context } from 'hono'
import { SkipTenant } from '../../decorators/skip-tenant.decorator'
@@ -6,7 +6,6 @@ import type { StaticAssetService } from './static-asset.service'
import { STATIC_DASHBOARD_BASENAME, StaticDashboardService } from './static-dashboard.service'
import { StaticWebService } from './static-web.service'
@SkipTenant()
@Controller({ bypassGlobalPrefix: true })
export class StaticWebController {
constructor(
@@ -14,22 +13,45 @@ export class StaticWebController {
private readonly staticDashboardService: StaticDashboardService,
) {}
@Get('/*')
async getAsset(@ContextParam() context: Context) {
const pathname = context.req.path
const service = this.resolveService(pathname)
const normalizedPath = this.normalizeRequestPath(pathname, service)
const response = await service.handleRequest(normalizedPath, false)
return response ?? new Response('Not Found', { status: 404 })
@Get('/static/web')
@Get('/static/dashboard')
async getStaticWebRoot(@ContextParam() context: Context) {
return await this.serve(context, this.staticWebService, false)
}
@Head('/*')
async headAsset(@ContextParam() context: Context) {
@Get(`/`)
@Get(`/photos/*`)
@Get(`/explory`)
async getStaticWebIndex(@ContextParam() context: Context) {
return await this.serve(context, this.staticWebService, false)
}
@Get(`${STATIC_DASHBOARD_BASENAME}`)
@Get(`${STATIC_DASHBOARD_BASENAME}/*`)
async getStaticDashboardIndexWithBasename(@ContextParam() context: Context) {
return await this.serve(context, this.staticDashboardService, false)
}
@SkipTenant()
@Get('/*')
async getAsset(@ContextParam() context: Context) {
return await this.handleRequest(context, false)
}
private async handleRequest(context: Context, headOnly: boolean): Promise<Response> {
const service = this.resolveService(context.req.path)
return await this.serve(context, service, headOnly)
}
private async serve(context: Context, service: StaticAssetService, headOnly: boolean): Promise<Response> {
const pathname = context.req.path
const service = this.resolveService(pathname)
const normalizedPath = this.normalizeRequestPath(pathname, service)
const response = await service.handleRequest(normalizedPath, true)
return response ?? new Response(null, { status: 404 })
const response = await service.handleRequest(normalizedPath, headOnly)
if (response) {
return response
}
return headOnly ? new Response(null, { status: 404 }) : new Response('Not Found', { status: 404 })
}
private resolveService(pathname: string): StaticAssetService {

View File

@@ -1,14 +1,14 @@
import { Module } from '@afilmory/framework'
import { ManifestModule } from '../manifest/manifest.module'
import { SiteSettingModule } from '../site-setting/site-setting.module'
import { StaticDashboardService } from './static-dashboard.service'
import { StaticWebController } from './static-web.controller'
import { StaticWebService } from './static-web.service'
import { StaticWebManifestService } from './static-web-manifest.service'
@Module({
imports: [SiteSettingModule],
imports: [SiteSettingModule, ManifestModule],
controllers: [StaticWebController],
providers: [StaticWebService, StaticWebManifestService, StaticDashboardService],
providers: [StaticWebService, StaticDashboardService],
})
export class StaticWebModule {}

View File

@@ -3,10 +3,10 @@ import { fileURLToPath } from 'node:url'
import { injectable } from 'tsyringe'
import { ManifestService } from '../manifest/manifest.service'
import { SiteSettingService } from '../site-setting/site-setting.service'
import type { StaticAssetDocument } from './static-asset.service'
import { StaticAssetService } from './static-asset.service'
import { StaticWebManifestService } from './static-web-manifest.service'
const STATIC_ROOT_ENV = process.env.STATIC_WEB_ROOT?.trim()
@@ -46,10 +46,12 @@ const STATIC_WEB_ASSET_LINK_RELS = [
'manifest',
]
type TenantSiteConfig = Awaited<ReturnType<SiteSettingService['getSiteConfig']>>
@injectable()
export class StaticWebService extends StaticAssetService {
constructor(
private readonly manifestService: StaticWebManifestService,
private readonly manifestService: ManifestService,
private readonly siteSettingService: SiteSettingService,
) {
super({
@@ -61,11 +63,13 @@ export class StaticWebService extends StaticAssetService {
}
protected override async decorateDocument(document: StaticAssetDocument): Promise<void> {
await this.injectConfigScript(document)
const siteConfig = await this.siteSettingService.getSiteConfig()
this.injectConfigScript(document, siteConfig)
this.injectSiteMetadata(document, siteConfig)
await this.injectManifestScript(document)
}
private async injectConfigScript(document: StaticAssetDocument): Promise<void> {
private injectConfigScript(document: StaticAssetDocument, siteConfig: TenantSiteConfig): void {
const configScript = document.head?.querySelector('#config')
if (!configScript) {
return
@@ -74,11 +78,46 @@ export class StaticWebService extends StaticAssetService {
const payload = JSON.stringify({
useCloud: true,
})
const tenantSiteConfig = await this.siteSettingService.getSiteConfig()
const siteConfigPayload = JSON.stringify(tenantSiteConfig)
const siteConfigPayload = JSON.stringify(siteConfig)
configScript.textContent = `window.__CONFIG__ = ${payload};window.__SITE_CONFIG__ = ${siteConfigPayload}`
}
private injectSiteMetadata(document: StaticAssetDocument, siteConfig: TenantSiteConfig): void {
const normalize = (value: string | undefined) => value?.trim() ?? ''
const title = normalize(siteConfig.title)
const description = normalize(siteConfig.description)
if (title) {
const titleElement = document.querySelector('title')
if (titleElement) {
titleElement.textContent = title
}
const appleTitleMeta = document.head?.querySelector('meta[name="apple-mobile-web-app-title"]')
if (appleTitleMeta) {
appleTitleMeta.setAttribute('content', title)
}
const splashTitle = document.querySelector('#splash-screen h1')
if (splashTitle) {
splashTitle.textContent = title
}
}
if (description) {
const descriptionMeta = document.head?.querySelector('meta[name="description"]')
if (descriptionMeta) {
descriptionMeta.setAttribute('content', description)
}
const splashDescription = document.querySelector('#splash-screen p')
if (splashDescription) {
splashDescription.textContent = description
}
}
}
private async injectManifestScript(document: StaticAssetDocument): Promise<void> {
const manifestScript = document.head?.querySelector('#manifest')
if (!manifestScript) {

View File

@@ -8,6 +8,7 @@
"./*": "./src/*"
},
"dependencies": {
"@afilmory/builder": "workspace:*",
"clsx": "^2.1.1",
"tailwind-merge": "^3.3.1"
},

View File

@@ -1,5 +1,6 @@
export * from './backoff'
export * from './cn'
export * from './rss'
export * from './semaphore'
export * from './spring'
export * from './storage-provider'

495
packages/utils/src/rss.ts Normal file
View File

@@ -0,0 +1,495 @@
import type { PhotoManifestItem, PickedExif } from '@afilmory/builder'
export interface FeedSiteAuthor {
name: string
url: string
avatar?: string | null
}
export interface FeedSiteConfig {
title: string
description: string
url: string
author: FeedSiteAuthor
feed?: {
folo?: {
challenge?: {
feedId?: string
userId?: string
} | null
} | null
}
}
export function generateRSSFeed(photos: readonly PhotoManifestItem[], config: FeedSiteConfig): string {
const now = new Date().toUTCString()
const latestPhoto = photos[0]
const lastBuildDate = latestPhoto ? new Date(latestPhoto.dateTaken).toUTCString() : now
const rssItems = photos
.map((photo) => createItemEntry(photo, config))
.filter((entry): entry is string => entry.length > 0)
.join('\n')
const serializedChallenge = serializeFollowChallenge(config)
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:exif="https://afilmory.com/rss/exif/1.1">
<channel>
<title><![CDATA[${config.title}]]></title>
<link>${config.url}</link>
<description><![CDATA[${config.description}]]></description>
<language>zh-CN</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<pubDate>${now}</pubDate>
<ttl>60</ttl>
<copyright>Copyright ${config.author.name}</copyright>
<!-- Afilmory RSS EXIF Extension Protocol Metadata -->
<exif:version>1.1</exif:version>
<exif:protocol>afilmory-rss-exif</exif:protocol>
${serializedChallenge}
<atom:link href="${config.url}/feed.xml" rel="self" type="application/rss+xml" />
<managingEditor>${config.author.name}</managingEditor>
<webMaster>${config.author.name}</webMaster>
<generator>Afilmory RSS Generator</generator>
<image>
<url>${config.author.avatar || `${config.url}/favicon.ico`}</url>
<title><![CDATA[${config.title}]]></title>
<link>${config.url}</link>
</image>
${rssItems}
</channel>
</rss>`
}
function createItemEntry(photo: PhotoManifestItem, config: FeedSiteConfig): string {
const photoUrl = buildAbsoluteUrl(config.url, photo.id)
const pubDate = toUTCDateString(photo.dateTaken)
const tags = Array.isArray(photo.tags) ? photo.tags.filter(Boolean) : []
let description = photo.description || photo.title || ''
if (!description) {
description = photo.tags?.join(', ') ?? ''
}
if (tags.length > 0) {
description += description ? ` | Tags: ${tags.join(', ')}` : `Tags: ${tags.join(', ')}`
}
const enclosureUrl = resolveAssetUrl(config.url, photo.thumbnailUrl)
const enclosureLength = typeof photo.size === 'number' ? photo.size : Number.parseInt(String(photo.size ?? 0), 10)
const exifTags = generateExifTags(photo.exif, photo)
const categoryEntries = tags.map((tag) => ` <category><![CDATA[${tag}]]></category>`).join('\n')
return ` <item>
<title><![CDATA[${photo.title}]]></title>
<link>${photoUrl}</link>
<guid isPermaLink="true">${photoUrl}</guid>
<description><![CDATA[${description}]]></description>
<pubDate>${pubDate}</pubDate>
${categoryEntries}
<enclosure url="${enclosureUrl}" type="image/jpeg" length="${Number.isFinite(enclosureLength) ? enclosureLength : 0}" />
${exifTags}
</item>`
}
function serializeFollowChallenge(config: FeedSiteConfig): string {
const feedId = config.feed?.folo?.challenge?.feedId
const userId = config.feed?.folo?.challenge?.userId
if (!feedId && !userId) {
return ''
}
const serializedFeedId = feedId ? `<feedId>${feedId}</feedId>` : ''
const serializedUserId = userId ? `<userId>${userId}</userId>` : ''
return ` <follow_challenge>
${serializedFeedId}
${serializedUserId}
</follow_challenge>`
}
function generateExifTags(exif: PickedExif | null | undefined, photo: PhotoManifestItem): string {
if (!exif) {
return ''
}
const tags: string[] = []
const aperture = isFiniteNumber(exif.FNumber) ? `f/${formatDecimal(exif.FNumber)}` : null
if (aperture) {
tags.push(` <exif:aperture>${aperture}</exif:aperture>`)
}
const shutterSpeed = formatShutterSpeed(exif)
if (shutterSpeed) {
tags.push(` <exif:shutterSpeed>${shutterSpeed}</exif:shutterSpeed>`)
}
const iso = getISOValue(exif)
if (iso !== null) {
tags.push(` <exif:iso>${iso}</exif:iso>`)
}
const exposureCompensation = getExposureCompensation(exif)
if (exposureCompensation) {
tags.push(` <exif:exposureCompensation>${exposureCompensation}</exif:exposureCompensation>`)
}
tags.push(
` <exif:imageWidth>${photo.width}</exif:imageWidth>`,
` <exif:imageHeight>${photo.height}</exif:imageHeight>`,
)
const dateTaken = formatDateTaken(exif, photo)
if (dateTaken) {
tags.push(` <exif:dateTaken>${dateTaken}</exif:dateTaken>`)
}
if (exif.Make && exif.Model) {
tags.push(` <exif:camera><![CDATA[${exif.Make} ${exif.Model}]]></exif:camera>`)
}
if (exif.Orientation !== undefined && exif.Orientation !== null) {
tags.push(` <exif:orientation>${exif.Orientation}</exif:orientation>`)
}
if (exif.LensModel) {
tags.push(` <exif:lens><![CDATA[${exif.LensModel}]]></exif:lens>`)
}
const focalLength = formatFocalLength(exif.FocalLength)
if (focalLength) {
tags.push(` <exif:focalLength>${focalLength}</exif:focalLength>`)
}
const focalLength35mm = formatFocalLength(exif.FocalLengthIn35mmFormat)
if (focalLength35mm) {
tags.push(` <exif:focalLength35mm>${focalLength35mm}</exif:focalLength35mm>`)
}
if (isFiniteNumber(exif.MaxApertureValue)) {
const maxAperture = Math.pow(2, exif.MaxApertureValue / 2)
tags.push(` <exif:maxAperture>f/${formatDecimal(maxAperture)}</exif:maxAperture>`)
}
const latitude = normalizeCoordinate(exif.GPSLatitude, exif.GPSLatitudeRef)
const longitude = normalizeCoordinate(exif.GPSLongitude, exif.GPSLongitudeRef)
if (latitude !== null && longitude !== null) {
tags.push(
` <exif:gpsLatitude>${latitude}</exif:gpsLatitude>`,
` <exif:gpsLongitude>${longitude}</exif:gpsLongitude>`,
)
}
if (isFiniteNumber(exif.GPSAltitude)) {
const altitude =
exif.GPSAltitudeRef && isBelowSeaLevel(exif.GPSAltitudeRef)
? -Math.abs(exif.GPSAltitude)
: Math.abs(exif.GPSAltitude)
tags.push(` <exif:altitude>${formatDecimal(altitude, 2)}m</exif:altitude>`)
}
const whiteBalance = normalizeStringValue(exif.WhiteBalance)
if (whiteBalance) {
tags.push(` <exif:whiteBalance>${whiteBalance}</exif:whiteBalance>`)
}
const meteringMode = normalizeStringValue(exif.MeteringMode)
if (meteringMode) {
tags.push(` <exif:meteringMode>${meteringMode}</exif:meteringMode>`)
}
const flashMode = formatFlashMode(exif.Flash)
if (flashMode) {
tags.push(` <exif:flashMode>${flashMode}</exif:flashMode>`)
}
const colorSpace = normalizeStringValue(exif.ColorSpace)
if (colorSpace) {
tags.push(` <exif:colorSpace>${colorSpace}</exif:colorSpace>`)
}
const exposureProgram = normalizeStringValue(exif.ExposureProgram)
if (exposureProgram) {
tags.push(` <exif:exposureProgram>${exposureProgram}</exif:exposureProgram>`)
}
const sceneMode = normalizeStringValue(exif.SceneCaptureType)
if (sceneMode) {
tags.push(` <exif:sceneMode><![CDATA[${sceneMode}]]></exif:sceneMode>`)
}
const brightness = toNumber(exif.BrightnessValue)
if (brightness !== null) {
tags.push(` <exif:brightness>${formatDecimal(brightness, 2)} EV</exif:brightness>`)
}
const lightValue = toNumber(exif.LightValue)
if (lightValue !== null) {
tags.push(` <exif:lightValue>${formatDecimal(lightValue, 2)}</exif:lightValue>`)
}
return tags.join('\n')
}
function formatDateTaken(exif: PickedExif, photo: PhotoManifestItem): string | null {
const rawDate = exif.DateTimeOriginal
if (rawDate) {
try {
return new Date(rawDate).toISOString()
} catch {
// fallthrough
}
}
return photo.dateTaken ? new Date(photo.dateTaken).toISOString() : null
}
function formatShutterSpeed(exif: PickedExif): string | null {
const raw = exif.ExposureTime ?? exif.ShutterSpeed ?? exif.ShutterSpeedValue
if (raw === null || raw === undefined) {
return null
}
if (typeof raw === 'number') {
if (!Number.isFinite(raw)) {
return null
}
return raw >= 1 ? `${stripTrailingZeros(raw)}s` : `1/${Math.round(1 / raw)}s`
}
const value = raw.toString().trim()
if (!value) {
return null
}
if (value.endsWith('s')) {
return value
}
return `${value}s`
}
function getISOValue(exif: PickedExif): number | null {
if (isFiniteNumber(exif.ISO)) {
return Math.round(exif.ISO)
}
const isoFromExif = (exif as unknown as Record<string, unknown>).ISOSpeedRatings
const iso = toNumber(isoFromExif)
return iso !== null ? Math.round(iso) : null
}
function getExposureCompensation(exif: PickedExif): string | null {
const value = toNumber(exif.ExposureCompensation ?? (exif as unknown as Record<string, unknown>).ExposureBiasValue)
if (value === null) {
return null
}
const formatted = formatDecimal(value, 2)
if (value > 0 && !formatted.startsWith('+')) {
return `+${formatted} EV`
}
return `${formatted} EV`
}
function formatFocalLength(value: unknown): string | null {
if (value === null || value === undefined) {
return null
}
if (typeof value === 'number') {
return `${formatDecimal(value)}mm`
}
const text = value.toString().trim()
if (!text) {
return null
}
const match = text.match(/-?\d+(?:\.\d+)?/)
if (!match) {
return text.endsWith('mm') ? text : `${text}mm`
}
const numeric = Number.parseFloat(match[0])
if (Number.isNaN(numeric)) {
return text.endsWith('mm') ? text : `${text}mm`
}
return `${formatDecimal(numeric)}mm`
}
function normalizeCoordinate(
value: PickedExif['GPSLatitude'] | PickedExif['GPSLongitude'],
ref: PickedExif['GPSLatitudeRef'] | PickedExif['GPSLongitudeRef'],
): number | null {
if (value === null || value === undefined) {
return null
}
if (Array.isArray(value)) {
return convertDMSToDD(value, ref)
}
if (typeof value === 'number') {
return applyGPSRef(value, ref)
}
const match = value.toString().match(/-?\d+(?:\.\d+)?/)
if (!match) {
return null
}
const numeric = Number.parseFloat(match[0])
if (Number.isNaN(numeric)) {
return null
}
return applyGPSRef(numeric, ref)
}
function convertDMSToDD(
dms: readonly number[],
ref: PickedExif['GPSLatitudeRef'] | PickedExif['GPSLongitudeRef'],
): number | null {
if (!dms || dms.length !== 3) return null
const [degrees, minutes, seconds] = dms
if ([degrees, minutes, seconds].some((value) => !Number.isFinite(value))) {
return null
}
const value = degrees + minutes / 60 + seconds / 3600
return applyGPSRef(value, ref)
}
function applyGPSRef(value: number, ref: PickedExif['GPSLatitudeRef'] | PickedExif['GPSLongitudeRef']): number {
if (!ref) {
return roundCoordinate(value)
}
const negativeTokens = ['S', 'W', 'South', 'West']
const shouldNegate = negativeTokens.some((token) => ref.toString().toLowerCase().includes(token.toLowerCase()))
const signed = shouldNegate ? -Math.abs(value) : Math.abs(value)
return roundCoordinate(signed)
}
function roundCoordinate(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000
}
function isBelowSeaLevel(ref: PickedExif['GPSAltitudeRef']): boolean {
if (!ref) return false
if (typeof ref === 'number') {
return ref === 1
}
const normalized = ref.toString().toLowerCase()
return normalized.includes('below') || normalized === '1'
}
function normalizeStringValue(value: unknown): string | null {
if (value === null || value === undefined) {
return null
}
const text = value.toString().trim()
return text ?? null
}
function formatFlashMode(value: PickedExif['Flash']): string | null {
if (value === null || value === undefined) {
return null
}
if (typeof value === 'number') {
return (value & 0x01) !== 0 ? 'On' : 'Off'
}
const text = value.toString().toLowerCase()
if (!text) {
return null
}
if (text.includes('on')) {
return 'On'
}
if (text.includes('off') || text.includes('no flash')) {
return 'Off'
}
if (text.includes('auto')) {
return 'Auto'
}
if (text.includes('red-eye')) {
return 'Red-eye'
}
return value.toString()
}
function toNumber(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value === 'string') {
const numeric = Number.parseFloat(value)
return Number.isNaN(numeric) ? null : numeric
}
return null
}
function formatDecimal(value: number, fractionDigits = 1): string {
if (!Number.isFinite(value)) {
return '0'
}
const fixed = value.toFixed(fractionDigits)
return stripTrailingZeros(Number.parseFloat(fixed))
}
function stripTrailingZeros(value: number): string {
if (!Number.isFinite(value)) {
return '0'
}
const text = value.toString()
if (!text.includes('.')) {
return text
}
return text.replace(/\.0+$/, '').replace(/(\.\d*?[1-9])0+$/, '$1')
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function buildAbsoluteUrl(baseUrl: string, id: string): string {
const normalizedBase = normalizeBaseUrl(baseUrl)
const normalizedId = id.startsWith('/') ? id.slice(1) : id
return `${normalizedBase}/${normalizedId}`
}
function resolveAssetUrl(baseUrl: string, candidate: string | null | undefined): string {
if (!candidate) {
return `${normalizeBaseUrl(baseUrl)}/favicon.ico`
}
if (/^https?:\/\//i.test(candidate)) {
return candidate
}
return `${normalizeBaseUrl(baseUrl)}${candidate.startsWith('/') ? candidate : `/${candidate}`}`
}
function normalizeBaseUrl(url: string): string {
if (!url) return ''
return url.endsWith('/') ? url.slice(0, -1) : url
}
function toUTCDateString(value: string | null | undefined): string {
if (!value) {
return new Date(0).toUTCString()
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return new Date(0).toUTCString()
}
return parsed.toUTCString()
}