personal-homepage/scripts/sync-content.ts

210 lines
6.1 KiB
TypeScript

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 {
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<unknown>(path.join(config.rootDir, 'src/content/projects/index.json')),
'seed projects',
);
const seedShares = parseWithSchema(
shareSeedListSchema,
await readJson<unknown>(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<T>(filePath: string): Promise<T> {
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) => {
console.error('[sync-content] failed');
console.error(error);
process.exitCode = 1;
});