import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { fetchGiteaSnapshot, type GeneratedGiteaActivity } from './fetch-gitea.ts'; import { fetchSeafileSnapshot } from './fetch-seafile.ts'; import { REBUILD_EXIT_CODES, getRebuildExitSymbol } from './rebuild-codes.ts'; import { generatedProjectListSchema, generatedShareListSchema, giteaActivitySchema, parseWithSchema, projectSeedListSchema, shareSeedListSchema, } from '../src/data/schema.ts'; type SyncConfig = { rootDir: string; outputDir: string; strictSync: boolean; gitea: { baseUrl: string; token: string; username: string; activityDays: number; activityPerDayLimit: number; requestConcurrency: number; recentItemLimit: number; requestTimeoutMs: number; }; seafile: { baseUrl: string; token: string; mirrorDownloads: boolean; downloadsOutputDir: string; requestTimeoutMs: number; }; }; async function main() { const config = loadConfig(process.cwd()); const syncedAt = new Date().toISOString(); console.log('[sync-content] start'); console.log(`[sync-content] output dir: ${config.outputDir}`); await mkdir(config.outputDir, { recursive: true }); if (config.seafile.mirrorDownloads) { console.warn( '[sync-content] SEAFILE_MIRROR_DOWNLOADS=true, but file mirroring is not implemented yet.', ); } const seedProjects = parseWithSchema( projectSeedListSchema, await readJson(path.join(config.rootDir, 'src/content/projects/index.json')), 'seed projects', ); const seedShares = parseWithSchema( shareSeedListSchema, await readJson(path.join(config.rootDir, 'src/content/shares/index.json')), 'seed shares', ); const giteaSnapshot = hasGiteaConfig(config) ? await fetchGiteaSnapshot({ config: { ...config.gitea, strict: config.strictSync, }, projectSeeds: seedProjects, syncedAt, }) : null; const baseProjects = giteaSnapshot?.projects ?? seedProjects.map((project) => ({ ...project, repo_url: project.gitea_repo && config.gitea.baseUrl ? `${trimTrailingSlash(config.gitea.baseUrl)}/${project.gitea_repo}` : undefined, updated_at: syncedAt, source: 'seed' as const, })); const seafileSnapshot = await fetchSeafileSnapshot({ config: { ...config.seafile, strict: config.strictSync, }, projects: baseProjects, seedShares, syncedAt, mappingFilePath: path.join(config.rootDir, 'src/content/seafile/index.json'), }); const giteaActivity = giteaSnapshot?.activity ?? ({ updatedAt: syncedAt, source: 'placeholder', itemCount: 0, days: [], recent: [], } satisfies GeneratedGiteaActivity); const validatedProjects = parseWithSchema( generatedProjectListSchema, seafileSnapshot.projects, 'generated projects', ); const validatedShares = parseWithSchema( generatedShareListSchema, seafileSnapshot.shares, 'generated shares', ); const validatedActivity = parseWithSchema( giteaActivitySchema, giteaActivity, 'generated gitea activity', ); await Promise.all([ writeJson(path.join(config.outputDir, 'projects.json'), validatedProjects), writeJson(path.join(config.outputDir, 'shares.json'), validatedShares), writeJson(path.join(config.outputDir, 'gitea-activity.json'), validatedActivity), ]); console.log(`[sync-content] wrote ${validatedProjects.length} projects`); console.log(`[sync-content] wrote ${validatedShares.length} shares`); console.log(`[sync-content] wrote ${validatedActivity.itemCount} activity items`); console.log('[sync-content] done'); } function loadConfig(rootDir: string): SyncConfig { return { rootDir, outputDir: resolveFromRoot(rootDir, process.env.SYNC_OUTPUT_DIR ?? 'src/data/generated'), strictSync: getBooleanEnv('STRICT_SYNC', false), gitea: { baseUrl: process.env.GITEA_BASE_URL?.trim() ?? '', token: process.env.GITEA_TOKEN?.trim() ?? '', username: process.env.GITEA_USERNAME?.trim() ?? '', activityDays: getNumberEnv('GITEA_ACTIVITY_DAYS', 70), activityPerDayLimit: getNumberEnv('GITEA_ACTIVITY_PER_DAY_LIMIT', 50), recentItemLimit: getNumberEnv('GITEA_RECENT_ITEM_LIMIT', 8), requestTimeoutMs: getNumberEnv('GITEA_REQUEST_TIMEOUT_MS', 15000), requestConcurrency: getNumberEnv('GITEA_REQUEST_CONCURRENCY', 5), }, seafile: { baseUrl: process.env.SEAFILE_BASE_URL?.trim() ?? '', token: process.env.SEAFILE_TOKEN?.trim() ?? '', mirrorDownloads: getBooleanEnv('SEAFILE_MIRROR_DOWNLOADS', false), downloadsOutputDir: resolveFromRoot( rootDir, process.env.DOWNLOADS_OUTPUT_DIR ?? 'public/downloads', ), requestTimeoutMs: getNumberEnv('SEAFILE_REQUEST_TIMEOUT_MS', 15000), }, }; } function hasGiteaConfig(config: SyncConfig) { return Boolean(config.gitea.baseUrl && config.gitea.token && config.gitea.username); } function getBooleanEnv(name: string, fallback: boolean) { const value = process.env[name]; if (value == null || value.trim() === '') { return fallback; } return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()); } function resolveFromRoot(rootDir: string, targetPath: string) { return path.isAbsolute(targetPath) ? targetPath : path.join(rootDir, targetPath); } function trimTrailingSlash(value: string) { return value.replace(/\/+$/, ''); } function getNumberEnv(name: string, fallback: number) { const value = process.env[name]; if (value == null || value.trim() === '') { return fallback; } const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } async function readJson(filePath: string): Promise { const raw = await readFile(filePath, 'utf-8'); return JSON.parse(raw) as T; } async function writeJson(filePath: string, data: unknown) { await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8'); } main().catch((error) => { const exitCode = classifySyncExitCode(error); console.error('[sync-content] failed'); console.error(error); console.error( `[sync-content] exit code ${exitCode} (${getRebuildExitSymbol(exitCode)})`, ); process.exitCode = exitCode; }); function classifySyncExitCode(error: unknown) { const message = error instanceof Error ? error.message : String(error); if (error instanceof SyntaxError || message.includes('[data-schema]')) { return REBUILD_EXIT_CODES.SCHEMA_VALIDATION_FAILED; } if (message.includes('[fetch-gitea]')) { return REBUILD_EXIT_CODES.GITEA_SYNC_FAILED; } if (message.includes('[fetch-seafile]')) { return REBUILD_EXIT_CODES.SEAFILE_SYNC_FAILED; } return REBUILD_EXIT_CODES.SYNC_FAILED; }