373 lines
9.7 KiB
TypeScript
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 '';
|
|
}
|
|
}
|