mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
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:
@@ -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()
|
||||
|
||||
|
||||
10
be/apps/core/src/modules/cache/cache.module.ts
vendored
Normal file
10
be/apps/core/src/modules/cache/cache.module.ts
vendored
Normal 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 {}
|
||||
92
be/apps/core/src/modules/cache/cache.service.ts
vendored
Normal file
92
be/apps/core/src/modules/cache/cache.service.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
56
be/apps/core/src/modules/feed/feed.controller.ts
Normal file
56
be/apps/core/src/modules/feed/feed.controller.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
14
be/apps/core/src/modules/feed/feed.module.ts
Normal file
14
be/apps/core/src/modules/feed/feed.module.ts
Normal 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 {}
|
||||
231
be/apps/core/src/modules/feed/feed.service.ts
Normal file
231
be/apps/core/src/modules/feed/feed.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
8
be/apps/core/src/modules/manifest/manifest.module.ts
Normal file
8
be/apps/core/src/modules/manifest/manifest.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
|
||||
import { ManifestService } from './manifest.service'
|
||||
|
||||
@Module({
|
||||
providers: [ManifestService],
|
||||
})
|
||||
export class ManifestModule {}
|
||||
@@ -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> {
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"./*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@afilmory/builder": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
|
||||
@@ -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
495
packages/utils/src/rss.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user