personal-homepage/scripts/fetch-seafile.ts

373 lines
9.7 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type { GeneratedProject } from './fetch-gitea.ts';
export type SeafileResourceType = 'build' | 'demo' | 'document' | 'asset';
export type ProjectDownload = {
name: string;
description?: string;
url?: string;
type?: SeafileResourceType;
platform?: string;
size?: number;
updated_at?: string;
source?: 'seed' | 'seafile';
};
export type GeneratedShare = {
name: string;
description?: string;
url?: string;
type?: SeafileResourceType;
project?: string;
project_repo?: string;
platform?: string;
size?: number;
updated_at?: string;
time?: string;
source: 'seed' | 'seafile';
};
export type SeafileSyncConfig = {
baseUrl: string;
token: string;
requestTimeoutMs?: number;
strict?: boolean;
};
type SeedShare = {
name: string;
description?: string;
url?: string;
time?: string;
};
type SeafileMappingFile = {
projects?: ProjectResourceMapping[];
shares?: ShareResourceMapping[];
};
type ProjectResourceMapping = {
project_repo: string;
downloads?: ResourceMapping[];
};
type ShareResourceMapping = ResourceMapping & {
project?: string;
project_repo?: string;
};
type ResourceMapping = {
name: string;
description?: string;
url?: string;
repo_id?: string;
path?: string;
type?: SeafileResourceType;
platform?: string;
size?: number;
updated_at?: string;
time?: string;
};
type SeafileFileDetailResponse = {
name?: string;
size?: number;
mtime?: number | string;
modifier_email?: string;
type?: string;
};
export async function fetchSeafileSnapshot(input: {
config: SeafileSyncConfig;
projects: GeneratedProject[];
seedShares: SeedShare[];
syncedAt: string;
mappingFilePath: string;
}) {
const { config, projects, seedShares, syncedAt, mappingFilePath } = input;
const mapping = await readMappingFile(mappingFilePath);
const client = canUseSeafileApi(config) ? createSeafileClient(config) : null;
const mappedProjects = await Promise.all(
projects.map(async (project) => {
const resourceEntry = mapping.projects?.find((item) => item.project_repo === project.gitea_repo);
const downloads = await Promise.all(
(resourceEntry?.downloads ?? []).map((download) =>
resolveResource(download, {
client,
config,
syncedAt,
}),
),
);
const normalizedDownloads = downloads.filter(hasContent);
const firstResolvedUrl = normalizedDownloads.find((item) => item.url)?.url;
return {
...project,
download_link: project.download_link || firstResolvedUrl || undefined,
downloads: normalizedDownloads.length > 0 ? normalizedDownloads : undefined,
source:
normalizedDownloads.length > 0
? project.source === 'seed'
? 'gitea+seafile'
: 'gitea+seafile'
: project.source,
} satisfies GeneratedProject;
}),
);
const projectShares = mappedProjects.flatMap((project) =>
(project.downloads ?? []).map((download) => ({
name: download.name,
description: download.description,
url: download.url,
type: download.type,
project: project.name,
project_repo: project.gitea_repo,
platform: download.platform,
size: download.size,
updated_at: download.updated_at,
time: download.updated_at,
source: 'seafile' as const,
})),
);
const extraShares = await Promise.all(
(mapping.shares ?? []).map(async (share) => {
const resolved = await resolveResource(share, {
client,
config,
syncedAt,
});
return {
name: resolved.name,
description: resolved.description,
url: resolved.url,
type: resolved.type,
project: share.project,
project_repo: share.project_repo,
platform: resolved.platform,
size: resolved.size,
updated_at: resolved.updated_at,
time: share.time || resolved.updated_at || syncedAt,
source: 'seafile' as const,
} satisfies GeneratedShare;
}),
);
const normalizedSeedShares = seedShares.map((share) => ({
name: share.name,
description: share.description,
url: share.url,
time: share.time || syncedAt,
updated_at: share.time || syncedAt,
source: 'seed' as const,
}));
const shares = dedupeShares([
...normalizedSeedShares,
...projectShares,
...extraShares.filter(hasContent),
]).sort((a, b) => Date.parse(b.updated_at || b.time || '') - Date.parse(a.updated_at || a.time || ''));
return {
projects: mappedProjects,
shares,
};
}
async function readMappingFile(filePath: string): Promise<SeafileMappingFile> {
try {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw) as SeafileMappingFile;
} catch (error) {
if (isMissingFileError(error)) {
return {};
}
throw error;
}
}
async function resolveResource(
resource: ResourceMapping,
input: {
client: ReturnType<typeof createSeafileClient> | null;
config: SeafileSyncConfig;
syncedAt: string;
},
): Promise<ProjectDownload> {
const fallback: ProjectDownload = {
name: resource.name,
description: resource.description,
url: resource.url,
type: resource.type,
platform: resource.platform,
size: resource.size,
updated_at: resource.updated_at || resource.time || input.syncedAt,
source: resource.url || resource.repo_id || resource.path ? 'seafile' : 'seed',
};
if (resource.url || !resource.repo_id || !resource.path || !input.client) {
return fallback;
}
try {
const [detail, downloadUrl] = await Promise.all([
input.client.getJson<SeafileFileDetailResponse>(
`/api/v2.1/repos/${resource.repo_id}/file/detail/`,
{ path: resource.path },
),
input.client.getLink(`/api/v2.1/repos/${resource.repo_id}/file/`, {
path: resource.path,
}),
]);
return {
name: resource.name || detail.name || path.basename(resource.path),
description: resource.description,
url: downloadUrl || fallback.url,
type: resource.type,
platform: resource.platform,
size: detail.size ?? resource.size,
updated_at: resource.updated_at || toIsoFromMtime(detail.mtime) || input.syncedAt,
source: 'seafile',
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const logLine = `[fetch-seafile] failed to resolve ${resource.path || resource.name}: ${message}`;
if (input.config.strict) {
throw new Error(logLine);
}
console.warn(logLine);
return fallback;
}
}
function createSeafileClient(config: SeafileSyncConfig) {
const headers = new Headers({
accept: 'application/json',
});
if (config.token) {
headers.set('Authorization', `Token ${config.token}`);
}
return {
async getJson<T>(pathname: string, query?: Record<string, string>): Promise<T> {
const response = await fetchWithTimeout(config, pathname, query, headers);
return (await response.json()) as T;
},
async getLink(pathname: string, query?: Record<string, string>) {
const response = await fetchWithTimeout(config, pathname, query, headers);
const text = (await response.text()).trim();
if (!text) {
return '';
}
try {
const parsed = JSON.parse(text) as string | { url?: string; download_link?: string };
if (typeof parsed === 'string') {
return parsed;
}
return parsed.download_link || parsed.url || text;
} catch {
return text.replace(/^"|"$/g, '');
}
},
};
}
async function fetchWithTimeout(
config: SeafileSyncConfig,
pathname: string,
query: Record<string, string> | undefined,
headers: Headers,
) {
const url = new URL(pathname, `${trimTrailingSlash(config.baseUrl)}/`);
if (query) {
for (const [key, value] of Object.entries(query)) {
url.searchParams.set(key, value);
}
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.requestTimeoutMs ?? 15000);
try {
const response = await fetch(url, {
method: 'GET',
headers,
signal: controller.signal,
});
if (!response.ok) {
const body = await safeReadText(response);
throw new Error(`HTTP ${response.status} ${response.statusText}${body ? ` - ${body}` : ''}`);
}
return response;
} finally {
clearTimeout(timeout);
}
}
function hasContent(item: { name?: string; url?: string; description?: string }) {
return Boolean(item.name || item.url || item.description);
}
function dedupeShares(items: GeneratedShare[]) {
const seen = new Set<string>();
return items.filter((item) => {
const key = [item.project_repo, item.name, item.url, item.type, item.platform].join('|');
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function toIsoFromMtime(value?: number | string) {
if (typeof value === 'number') {
const milliseconds = value > 1_000_000_000_000 ? value : value * 1000;
return new Date(milliseconds).toISOString();
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
}
return '';
}
function canUseSeafileApi(config: SeafileSyncConfig) {
return Boolean(config.baseUrl && config.token);
}
function trimTrailingSlash(value: string) {
return value.replace(/\/+$/, '');
}
function isMissingFileError(error: unknown) {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}
async function safeReadText(response: Response) {
try {
return (await response.text()).slice(0, 300);
} catch {
return '';
}
}