304 lines
7.8 KiB
JavaScript
304 lines
7.8 KiB
JavaScript
#!/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;
|
|
});
|