Replit-Commit-Author: Deployment Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 2b7650db-b0d4-4415-b9bb-cc11e719c2b7 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/ncMMRQ9 Replit-Commit-Deployment-Build-Id: 55db64a6-94a6-4850-b7bf-6b5712a636c3 Replit-Helium-Checkpoint-Created: true
401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
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<DropboxTokens> {
|
|
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<DropboxTokens> {
|
|
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<string | null> {
|
|
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<any[]> {
|
|
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<Map<string, string>> {
|
|
const results = new Map<string, string>();
|
|
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<GalleryImage[]> {
|
|
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<string, string>();
|
|
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<string, string>();
|
|
for (const file of largeFiles) {
|
|
const link = largeLinks.get(file.path_lower) || "";
|
|
if (link) largeMap.set(file.name.toLowerCase(), link);
|
|
}
|
|
|
|
const mobileMap = new Map<string, string>();
|
|
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 };
|
|
}
|