#!/usr/bin/env node import { readFileSync } from "node:fs"; import { createClient } from "@supabase/supabase-js"; import { z } from "zod"; const SOURCE = "unsplash-wallpapers"; const DEFAULT_PAGE = 1; const DEFAULT_PER_PAGE = 20; const UNSPLASH_WALLPAPERS_URL = "https://api.unsplash.com/topics/wallpapers/photos"; const env = loadEnvLocal(); const photoSchema = z .object({ id: z.string(), slug: z.string().nullish(), description: z.string().nullish(), alt_description: z.string().nullish(), width: z.number().int().nullish(), height: z.number().int().nullish(), color: z.string().nullish(), blur_hash: z.string().nullish(), likes: z.number().int().nullish(), created_at: z.string().nullish(), updated_at: z.string().nullish(), urls: z.record(z.string(), z.unknown()).nullish(), links: z.record(z.string(), z.unknown()).nullish(), user: z.record(z.string(), z.unknown()).nullish(), }) .passthrough(); function loadEnvLocal() { const envMap = { ...process.env }; try { const file = readFileSync(".env.local", "utf8"); for (const line of file.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) { continue; } const [key, ...valueParts] = trimmed.split("="); const value = valueParts.join("=").trim(); envMap[key.trim()] = value.replace(/^["']|["']$/g, ""); } } catch { // Environment variables can also be provided by the process manager. } return envMap; } function requireEnv(key) { const value = env[key]; if (!value) { throw new Error(`${key} is not configured.`); } return value; } function normalizeDate(value) { if (!value) { return null; } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } function getColorFamily(color) { if (!color) { return null; } const normalized = color.trim().replace(/^#/, ""); if (!/^[0-9a-f]{6}$/i.test(normalized)) { return null; } const r = parseInt(normalized.slice(0, 2), 16); const g = parseInt(normalized.slice(2, 4), 16); const b = parseInt(normalized.slice(4, 6), 16); const max = Math.max(r, g, b); const min = Math.min(r, g, b); const lightness = (max + min) / 510; if (lightness > 0.78) { return "light"; } if (lightness < 0.2) { return "black"; } const delta = max - min; if (delta < 24) { return lightness > 0.52 ? "light" : "black"; } let hue = 0; if (max === r) { hue = ((g - b) / delta) % 6; } else if (max === g) { hue = (b - r) / delta + 2; } else { hue = (r - g) / delta + 4; } hue = Math.round(hue * 60); if (hue < 0) { hue += 360; } if (hue < 18 || hue >= 345) { return "red"; } if (hue < 45) { return "orange"; } if (hue < 70) { return "yellow"; } if (hue < 170) { return "green"; } if (hue < 255) { return "blue"; } if (hue < 345) { return "purple"; } return null; } function mapPhotoToRow(photo) { return { unsplash_id: photo.id, slug: photo.slug ?? null, description: photo.description ?? null, alt_description: photo.alt_description ?? null, width: photo.width ?? null, height: photo.height ?? null, color: photo.color ?? null, color_family: getColorFamily(photo.color), blur_hash: photo.blur_hash ?? null, likes: photo.likes ?? null, photo_created_at: normalizeDate(photo.created_at), photo_updated_at: normalizeDate(photo.updated_at), urls: photo.urls ?? {}, links: photo.links ?? {}, user_info: photo.user ?? {}, raw: photo, source: SOURCE, }; } async function fetchPhotos({ page, perPage }) { const accessKey = requireEnv("UNSPLASH_ACCESS_KEY"); const url = new URL(UNSPLASH_WALLPAPERS_URL); url.searchParams.set("page", String(page)); url.searchParams.set("per_page", String(perPage)); const response = await fetch(url, { headers: { accept: "application/json", authorization: `Client-ID ${accessKey}`, "user-agent": "WalloraGallerySync/1.0", }, method: "GET", }); const contentType = response.headers.get("content-type") ?? ""; if (!response.ok) { throw new Error(`Unsplash API request failed with ${response.status}.`); } if (!contentType.includes("application/json")) { throw new Error( `Unsplash returned non-JSON content: ${contentType || "unknown"}.`, ); } const parsed = z.array(photoSchema).safeParse(await response.json()); if (!parsed.success) { throw new Error("Unsplash response did not match the expected array."); } return parsed.data; } async function main() { const supabase = createClient( requireEnv("NEXT_PUBLIC_SUPABASE_URL"), requireEnv("SUPABASE_SERVICE_ROLE_KEY"), { auth: { persistSession: false, autoRefreshToken: false, }, }, ); const { data: state, error: stateError } = await supabase .from("wg_gallery_sync_state") .select("source,next_page,per_page") .eq("source", SOURCE) .maybeSingle(); if (stateError) { throw stateError; } const page = state?.next_page ?? DEFAULT_PAGE; const perPage = state?.per_page ?? DEFAULT_PER_PAGE; try { const photos = await fetchPhotos({ page, perPage }); const rows = photos.map(mapPhotoToRow); let insertedCount = 0; if (rows.length > 0) { const { data, error } = await supabase .from("wg_gallery_photos") .upsert(rows, { onConflict: "unsplash_id", ignoreDuplicates: true, }) .select("unsplash_id"); if (error) { throw error; } insertedCount = data?.length ?? 0; } const nextPage = rows.length === 0 ? DEFAULT_PAGE : page + 1; const { error: updateError } = await supabase .from("wg_gallery_sync_state") .upsert( { source: SOURCE, next_page: nextPage, per_page: perPage, last_synced_at: new Date().toISOString(), last_inserted_count: insertedCount, last_seen_count: photos.length, last_error: null, }, { onConflict: "source" }, ); if (updateError) { throw updateError; } console.log( JSON.stringify({ ok: true, source: SOURCE, page, nextPage, seenCount: photos.length, insertedCount, }), ); } catch (error) { const message = error instanceof Error ? error.message : "Unknown sync error."; await supabase.from("wg_gallery_sync_state").upsert( { source: SOURCE, next_page: page, per_page: perPage, last_synced_at: new Date().toISOString(), last_inserted_count: 0, last_seen_count: 0, last_error: message, }, { onConflict: "source" }, ); throw error; } } main().catch((error) => { console.error(error); process.exitCode = 1; });