import fs from "fs"; import path from "path"; import { uploadToCloudinary, generateImageUrls } from "./cloudinary"; const TOKEN_PATH = path.join(process.cwd(), "server/dropbox-token.json"); const GALLERY_ROOT = "/family room/photos/oddaje/izvajalci selekcija"; const CLOUDINARY_MAP_PATH = path.join(process.cwd(), "server/cloudinary-gallery-map.json"); interface DropboxTokens { access_token: string; refresh_token: string; expires_at: number; app_key?: string; app_secret?: string; } interface GalleryImage { folder: string; fileName: string; thumb: string; large: string; mobile: string; full: string; artist: string; } interface CloudinaryMap { [fileName: string]: string; } function loadCloudinaryMap(): CloudinaryMap { try { if (fs.existsSync(CLOUDINARY_MAP_PATH)) { return JSON.parse(fs.readFileSync(CLOUDINARY_MAP_PATH, "utf-8")); } } catch {} return {}; } function saveCloudinaryMap(map: CloudinaryMap): void { fs.writeFileSync(CLOUDINARY_MAP_PATH, JSON.stringify(map, null, 2)); } function extractArtistFromFileName(fileName: string): string { if (/^DSC\d/i.test(fileName) || /^IMG[\s_]\d/i.test(fileName)) return ""; const withoutExt = fileName.replace(/\.\w+$/, ""); const cleaned = withoutExt .replace(/\s*\(\d+\)\s*$/, "") .replace(/\s*\d+\s*$/, "") .replace(/\s*\(\d+\)\s*$/, ""); return cleaned.trim(); } function getAppKey(): string { const tokens = loadTokens(); return tokens?.app_key || process.env.DROPBOX_APP_KEY || ""; } function getAppSecret(): string { const tokens = loadTokens(); return tokens?.app_secret || process.env.DROPBOX_APP_SECRET || ""; } export function getAuthUrl(redirectUri: string): string { const params = new URLSearchParams({ client_id: getAppKey(), response_type: "code", redirect_uri: redirectUri, token_access_type: "offline", }); return `https://www.dropbox.com/oauth2/authorize?${params.toString()}`; } export async function exchangeCodeForTokens(code: string, redirectUri: string): Promise { const appKey = getAppKey(); const appSecret = getAppSecret(); const resp = await fetch("https://api.dropboxapi.com/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, grant_type: "authorization_code", redirect_uri: redirectUri, client_id: appKey, client_secret: appSecret, }), }); if (!resp.ok) { const err = await resp.text(); throw new Error(`Token exchange failed: ${err}`); } const data = await resp.json(); const tokens: DropboxTokens = { access_token: data.access_token, refresh_token: data.refresh_token, expires_at: Date.now() + data.expires_in * 1000, app_key: appKey, app_secret: appSecret, }; fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2)); return tokens; } function loadTokens(): DropboxTokens | null { try { if (fs.existsSync(TOKEN_PATH)) { return JSON.parse(fs.readFileSync(TOKEN_PATH, "utf-8")); } } catch {} return null; } async function refreshAccessToken(refreshToken: string): Promise { const appKey = getAppKey(); const appSecret = getAppSecret(); const resp = await fetch("https://api.dropboxapi.com/oauth2/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: appKey, client_secret: appSecret, }), }); if (!resp.ok) { const err = await resp.text(); throw new Error(`Token refresh failed: ${err}`); } const data = await resp.json(); const tokens: DropboxTokens = { access_token: data.access_token, refresh_token: refreshToken, expires_at: Date.now() + data.expires_in * 1000, app_key: appKey, app_secret: appSecret, }; fs.writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2)); return tokens; } export async function getValidAccessToken(): Promise { let tokens = loadTokens(); if (!tokens) return null; if (Date.now() > tokens.expires_at - 60000) { try { tokens = await refreshAccessToken(tokens.refresh_token); } catch (err: any) { console.error("Token refresh failed:", err.message); return null; } } return tokens.access_token; } export function isConnected(): boolean { return loadTokens() !== null; } async function listFolder(accessToken: string, folderPath: string, recursive = false): Promise { const entries: any[] = []; let cursor: string | null = null; let hasMore = true; while (hasMore) { const url = cursor ? "https://api.dropboxapi.com/2/files/list_folder/continue" : "https://api.dropboxapi.com/2/files/list_folder"; const body = cursor ? { cursor } : { path: folderPath, recursive, limit: 2000 }; const resp = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); if (!resp.ok) { const err = await resp.text(); throw new Error(`List folder failed: ${err}`); } const data = await resp.json(); entries.push(...data.entries); hasMore = data.has_more; cursor = data.cursor; } return entries; } async function getTemporaryLinks(accessToken: string, paths: string[]): Promise> { const results = new Map(); const batchSize = 10; for (let i = 0; i < paths.length; i += batchSize) { const batch = paths.slice(i, i + batchSize); const promises = batch.map(async (p) => { try { const resp = await fetch("https://api.dropboxapi.com/2/files/get_temporary_link", { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ path: p }), }); if (resp.ok) { const data = await resp.json(); results.set(p.toLowerCase(), data.link); } } catch {} }); await Promise.all(promises); } return results; } const galleryCache: { data: GalleryImage[]; timestamp: number } = { data: [], timestamp: 0 }; const CACHE_DURATION = 30 * 60 * 1000; const THUMB_FOLDER = GALLERY_ROOT + "/Foto 1x1"; const LARGE_FOLDER = GALLERY_ROOT + "/Foto 9x16"; const MOBILE_FOLDER = GALLERY_ROOT + "/Foto 16x9"; const ORIGINAL_FOLDER = GALLERY_ROOT + "/Foto All"; function normalizeForMatch(name: string): string { return name .toLowerCase() .replace(/\.\w+$/, "") .replace(/[^a-z0-9]/g, ""); } export async function fetchGalleryFromDropbox(): Promise { if (galleryCache.data.length > 0 && Date.now() - galleryCache.timestamp < CACHE_DURATION) { return galleryCache.data; } const cloudinaryMap = loadCloudinaryMap(); const hasCloudinaryImages = Object.keys(cloudinaryMap).length > 0; if (hasCloudinaryImages) { const images: GalleryImage[] = []; for (const [fileName, publicId] of Object.entries(cloudinaryMap)) { const urls = generateImageUrls(publicId); images.push({ folder: "Foto All", fileName, thumb: urls.thumb, large: urls.large, mobile: urls.mobile, full: urls.full, artist: extractArtistFromFileName(fileName), }); } galleryCache.data = images; galleryCache.timestamp = Date.now(); return images; } const accessToken = await getValidAccessToken(); if (!accessToken) return []; try { const [thumbEntries, largeEntries, mobileEntries, originalEntries] = await Promise.all([ listFolder(accessToken, THUMB_FOLDER.toLowerCase()), listFolder(accessToken, LARGE_FOLDER.toLowerCase()), listFolder(accessToken, MOBILE_FOLDER.toLowerCase()), listFolder(accessToken, ORIGINAL_FOLDER.toLowerCase()), ]); const imgFilter = (e: any) => e[".tag"] === "file" && /\.(jpg|jpeg|png|webp|gif)$/i.test(e.name); const thumbFiles = thumbEntries.filter(imgFilter); const largeFiles = largeEntries.filter(imgFilter); const mobileFiles = mobileEntries.filter(imgFilter); const originalFiles = originalEntries.filter(imgFilter); const originalNameMap = new Map(); for (const file of originalFiles) { originalNameMap.set(normalizeForMatch(file.name), file.name); } const [thumbLinks, largeLinks, mobileLinks] = await Promise.all([ getTemporaryLinks(accessToken, thumbFiles.map((f: any) => f.path_lower)), getTemporaryLinks(accessToken, largeFiles.map((f: any) => f.path_lower)), getTemporaryLinks(accessToken, mobileFiles.map((f: any) => f.path_lower)), ]); const largeMap = new Map(); for (const file of largeFiles) { const link = largeLinks.get(file.path_lower) || ""; if (link) largeMap.set(file.name.toLowerCase(), link); } const mobileMap = new Map(); for (const file of mobileFiles) { const link = mobileLinks.get(file.path_lower) || ""; if (link) mobileMap.set(file.name.toLowerCase(), link); } const images: GalleryImage[] = []; for (const file of thumbFiles) { const thumbLink = thumbLinks.get(file.path_lower) || ""; if (!thumbLink) continue; const largeLink = largeMap.get(file.name.toLowerCase()) || thumbLink; const mobileLink = mobileMap.get(file.name.toLowerCase()) || thumbLink; const originalName = originalNameMap.get(normalizeForMatch(file.name)) || file.name; images.push({ folder: "Foto All", fileName: originalName, thumb: thumbLink, large: largeLink, mobile: mobileLink, full: largeLink, artist: extractArtistFromFileName(originalName), }); } if (images.length > 0) { galleryCache.data = images; galleryCache.timestamp = Date.now(); const galleryPath = path.join(process.cwd(), "server/gallery-data.json"); fs.writeFileSync(galleryPath, JSON.stringify(images, null, 2)); } return images; } catch (err: any) { console.error("Dropbox gallery fetch error:", err.message); return []; } } export async function migrateGalleryToCloudinary(): Promise<{ uploaded: number; skipped: number; failed: number }> { const accessToken = await getValidAccessToken(); if (!accessToken) throw new Error("Dropbox not connected"); const originalEntries = await listFolder(accessToken, ORIGINAL_FOLDER.toLowerCase()); const imgFilter = (e: any) => e[".tag"] === "file" && /\.(jpg|jpeg|png|webp|gif)$/i.test(e.name); const originalFiles = originalEntries.filter(imgFilter); const cloudinaryMap = loadCloudinaryMap(); let uploaded = 0; let skipped = 0; let failed = 0; const batchSize = 5; for (let i = 0; i < originalFiles.length; i += batchSize) { const batch = originalFiles.slice(i, i + batchSize); const links = await getTemporaryLinks(accessToken, batch.map((f: any) => f.path_lower)); const uploadPromises = batch.map(async (file: any) => { const fileName = file.name; if (cloudinaryMap[fileName]) { skipped++; return; } const link = links.get(file.path_lower); if (!link) { failed++; return; } const publicId = await uploadToCloudinary(link, fileName); if (publicId) { cloudinaryMap[fileName] = publicId; uploaded++; console.log(`[cloudinary] Uploaded: ${fileName} -> ${publicId}`); } else { failed++; } }); await Promise.all(uploadPromises); saveCloudinaryMap(cloudinaryMap); } galleryCache.data = []; galleryCache.timestamp = 0; return { uploaded, skipped, failed }; }