Files
makelore/src/lib/learning.ts

157 lines
4.9 KiB
TypeScript

import { invokeIpc } from '@/lib/api-client';
import { AppError } from '@/lib/error-model';
import { hostApiFetch } from '@/lib/host-api';
import {
LEARNING_API_PATH,
LEARNING_MEDIA_MAX_BYTES,
type LearningProjectDetail,
type LearningProjectDownloadResult,
type LearningProjectListQuery,
type LearningProjectPage,
} from '../../shared/learning';
type LearningEnvelope<T> = {
success?: boolean;
status?: number;
code?: string;
error?: string;
data?: T;
};
type LearningProjectMedia = {
dataBase64: string;
mimeType: string;
};
const PROJECT_MEDIA_URL_PATTERN = /^\/api\/learning\/projects\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\/media\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const PROJECT_MEDIA_MIME_TYPES = new Set([
'image/avif',
'image/gif',
'image/jpeg',
'image/png',
'image/webp',
]);
const MAX_MEDIA_BASE64_LENGTH = Math.ceil(LEARNING_MEDIA_MAX_BYTES / 3) * 4;
export class LearningApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
this.name = 'LearningApiError';
}
}
function errorStatus(error: unknown): number {
return error instanceof AppError && typeof error.details?.status === 'number'
? error.details.status
: 502;
}
function errorCode(error: unknown, status: number): string {
if (error instanceof AppError && typeof error.details?.backendCode === 'string') {
return error.details.backendCode;
}
if (status === 401) return 'LEARNING_AUTH_REQUIRED';
if (status === 404) return 'LEARNING_PROJECT_NOT_FOUND';
return 'LEARNING_REQUEST_FAILED';
}
async function requestData<T>(path: string, init?: RequestInit): Promise<T> {
let response: LearningEnvelope<T>;
try {
response = await hostApiFetch<LearningEnvelope<T>>(path, init);
} catch (error) {
const status = errorStatus(error);
throw new LearningApiError(
status,
errorCode(error, status),
error instanceof Error ? error.message : '学习项目请求失败',
);
}
if (!response.success || response.data === undefined) {
throw new LearningApiError(
response.status ?? 502,
response.code ?? 'LEARNING_REQUEST_FAILED',
response.error ?? '学习项目请求失败',
);
}
return response.data;
}
function listQuery(query: LearningProjectListQuery): string {
const params = new URLSearchParams();
if (query.cursor?.trim()) params.set('cursor', query.cursor.trim());
if (query.limit !== undefined) params.set('limit', String(query.limit));
const encoded = params.toString();
return encoded ? `?${encoded}` : '';
}
export function fetchLearningProjects(
query: LearningProjectListQuery = {},
): Promise<LearningProjectPage> {
return requestData(`${LEARNING_API_PATH}/projects${listQuery(query)}`);
}
export function fetchLearningProject(projectId: string): Promise<LearningProjectDetail> {
return requestData(`${LEARNING_API_PATH}/projects/${encodeURIComponent(projectId)}`);
}
export function downloadLearningProject(
projectId: string,
): Promise<LearningProjectDownloadResult> {
return requestData(`${LEARNING_API_PATH}/projects/${encodeURIComponent(projectId)}/download`, {
method: 'POST',
});
}
export async function fetchLearningProjectMedia(mediaUrl: string): Promise<string> {
if (!PROJECT_MEDIA_URL_PATTERN.test(mediaUrl)) {
throw new LearningApiError(400, 'LEARNING_INVALID_MEDIA_URL', '项目图片地址无效');
}
const localPath = mediaUrl.replace('/api/learning/', `${LEARNING_API_PATH}/`);
const payload = await hostApiFetch<LearningProjectMedia>(localPath);
const mimeType = typeof payload?.mimeType === 'string' ? payload.mimeType.trim().toLowerCase() : '';
const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : '';
if (
!PROJECT_MEDIA_MIME_TYPES.has(mimeType)
|| !dataBase64
|| dataBase64.length > MAX_MEDIA_BASE64_LENGTH
|| !/^[A-Za-z0-9+/]*={0,2}$/.test(dataBase64)
|| dataBase64.length % 4 === 1
) {
throw new LearningApiError(502, 'LEARNING_INVALID_MEDIA_RESPONSE', '项目图片返回了无效数据');
}
return `data:${mimeType};base64,${dataBase64}`;
}
export function isLearningProjectMediaUrl(value: string): boolean {
return PROJECT_MEDIA_URL_PATTERN.test(value);
}
export function isSafeLearningImageUrl(value: string): boolean {
if (PROJECT_MEDIA_URL_PATTERN.test(value)) return true;
try {
const url = new URL(value);
return url.protocol === 'https:' && !url.username && !url.password;
} catch {
return false;
}
}
export async function openLearningExternalLink(value: string): Promise<void> {
let url: URL;
try {
url = new URL(value);
} catch {
throw new LearningApiError(400, 'LEARNING_INVALID_LINK', '项目链接无效');
}
if (url.protocol !== 'https:' || url.username || url.password) {
throw new LearningApiError(400, 'LEARNING_INVALID_LINK', '项目链接无效');
}
await invokeIpc('shell:openExternal', url.toString());
}