/
pkopachevsky
/
langboard
Обзор
Документация
Войти
/
pkopachevsky
/
langboard
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/data/loadReaderModel.ts
350 строк
11 KB
pkopachevsky
Release 0.20.0 with work deletion and model docs
10 авг 2026, 09:28
10 авг 2026, 09:28
380ba8c
Код
Авторство
О чём код?
import type { AnnotationDetailDto, CodexAvailabilityDto, CodexJobDto, CodexPromptHistoryDto, CodexPromptPreviewDto, CreateCodexJobDto, CreateImportJobDto, ConceptDto, ConceptsListDto, EntityHistoryEntityType, EntityHistoryListDto, GlobalTextSearchDto, ImportJobDto, ReaderDto, SegmentDto, TtsAvailabilityDto, VocalizeWorkDto, VocalizeWorkResultDto, VocalizeSegmentDto, WordConstituentsListDto, WordConstituentReferenceDto, WordDto, WordPartOfSpeech, WordsListDto, WorksListDto, } from "../../shared/contracts"; import type { AnalysisFragment, ReaderModel, ReaderSegment, TermEntry, TextRange, WorkDto } from "../types"; const API_ROOT = import.meta.env.VITE_API_ROOT ?? "/api"; async function fetchJson<T>(path: string): Promise<T> { const response = await fetch(`${API_ROOT}${path}`); if (!response.ok) { throw new Error(`Could not load ${path}: ${response.status}`); } return response.json() as Promise<T>; } export class ApiRequestError extends Error { constructor( message: string, readonly status: number, readonly code?: string, ) { super(message); } } async function request(path: string, init?: RequestInit): Promise<Response> { const response = await fetch(`${API_ROOT}${path}`, init); if (!response.ok) { let payload: { error?: string; message?: string } = {}; try { payload = (await response.json()) as typeof payload; } catch { // The status text remains a useful fallback for non-JSON proxy errors. } throw new ApiRequestError(payload.message ?? response.statusText, response.status, payload.error); } return response; } async function requestJson<T>(path: string, init?: RequestInit): Promise<T> { const response = await request(path, init); return response.json() as Promise<T>; } export function loadCodexStatus(): Promise<CodexAvailabilityDto> { return requestJson("/codex/status"); } export function loadTtsStatus(): Promise<TtsAvailabilityDto> { return requestJson("/tts/status"); } export function loadCodexPromptHistory(): Promise<CodexPromptHistoryDto> { return requestJson("/codex/prompts"); } export function createCodexJob(input: CreateCodexJobDto): Promise<CodexJobDto> { return requestJson("/codex/jobs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), }); } export function loadCodexPromptPreview(input: CreateCodexJobDto): Promise<CodexPromptPreviewDto> { return requestJson("/codex/prompt-preview", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), }); } export function loadCodexJob(id: string): Promise<CodexJobDto> { return requestJson(`/codex/jobs/${encodeURIComponent(id)}`); } export function createImportJob(input: CreateImportJobDto): Promise<ImportJobDto> { return requestJson("/codex/import-jobs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), }); } export function loadImportJob(id: string): Promise<ImportJobDto> { return requestJson(`/codex/import-jobs/${encodeURIComponent(id)}`); } export function applyImportJob(id: string): Promise<ImportJobDto> { return requestJson(`/codex/import-jobs/${encodeURIComponent(id)}/apply`, { method: "POST" }); } export function retryImportJob(id: string): Promise<ImportJobDto> { return requestJson(`/codex/import-jobs/${encodeURIComponent(id)}/retry`, { method: "POST" }); } export function selfHealImportJob(id: string): Promise<ImportJobDto> { return requestJson(`/codex/import-jobs/${encodeURIComponent(id)}/self-heal`, { method: "POST" }); } export function loadReaderStructure(slug: string): Promise<ReaderDto> { return fetchJson(`/works/${encodeURIComponent(slug)}/reader`); } function segmentToReaderSegment(segment: SegmentDto): ReaderSegment { return { id: segment.id, sectionId: segment.sectionId, sourceText: segment.sourceText, translation: segment.translation ?? "", voices: segment.voices, }; } export function vocalizeSegment(id: string, input: VocalizeSegmentDto = {}): Promise<ReaderSegment> { return requestJson<SegmentDto>(`/segments/${encodeURIComponent(id)}/vocalize`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), }).then(segmentToReaderSegment); } export function vocalizeWork(slug: string, input: VocalizeWorkDto): Promise<ReaderSegment[]> { return requestJson<VocalizeWorkResultDto>(`/works/${encodeURIComponent(slug)}/vocalize`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), }).then((result) => result.segments.map(segmentToReaderSegment)); } export function deleteSegmentVoice(segmentId: string, voiceId: string): Promise<ReaderSegment> { return requestJson<SegmentDto>(`/segments/${encodeURIComponent(segmentId)}/voice/${encodeURIComponent(voiceId)}`, { method: "DELETE", }).then(segmentToReaderSegment); } export function segmentVoiceUrl(segmentId: string, voiceId: string) { return `${API_ROOT}/segments/${encodeURIComponent(segmentId)}/voice/${encodeURIComponent(voiceId)}`; } function textForRange(segments: ReaderSegment[], range: TextRange) { if (range.lineStart === range.lineEnd) { return segments[range.lineStart].sourceText.slice(range.colStart, range.colEnd); } return segments .slice(range.lineStart, range.lineEnd + 1) .map((segment, index, selectedSegments) => { if (index === 0) { return segment.sourceText.slice(range.colStart); } if (index === selectedSegments.length - 1) { return segment.sourceText.slice(0, range.colEnd); } return segment.sourceText; }) .join("\n"); } export async function loadWorks(): Promise<WorkDto[]> { const response = await fetchJson<WorksListDto>("/works"); return response.works; } export async function deleteWork(id: string): Promise<void> { await request(`/works/${encodeURIComponent(id)}`, { method: "DELETE" }); } export function searchAllTextSegments(query: string): Promise<GlobalTextSearchDto> { const params = new URLSearchParams({ q: query, scope: "segments", limit: "200" }); return fetchJson<GlobalTextSearchDto>(`/text-search?${params.toString()}`); } export async function loadEntityHistory( options: { type?: EntityHistoryEntityType; types?: EntityHistoryEntityType[]; cursor?: string; limit?: number; } = {}, ) { const query = new URLSearchParams(); if (options.type) query.set("type", options.type); if (options.types?.length) query.set("types", options.types.join(",")); if (options.cursor) query.set("cursor", options.cursor); if (options.limit) query.set("limit", String(options.limit)); const suffix = query.size > 0 ? `?${query.toString()}` : ""; return fetchJson<EntityHistoryListDto>(`/history${suffix}`); } export async function loadReaderModel(slug: string): Promise<ReaderModel> { const reader = await fetchJson<ReaderDto>(`/works/${encodeURIComponent(slug)}/reader`); const segments = [...reader.segments].sort((a, b) => a.order - b.order); const lineIndexBySegmentId = new Map(segments.map((segment, index) => [segment.id, index])); const readerSegments: ReaderSegment[] = segments.map(segmentToReaderSegment); const analysisFragments: AnalysisFragment[] = reader.annotations.flatMap((annotation) => { const lineStart = lineIndexBySegmentId.get(annotation.target.start.segmentId); const lineEnd = lineIndexBySegmentId.get(annotation.target.end.segmentId); if (lineStart === undefined || lineEnd === undefined) { return []; } const range = { lineStart, colStart: annotation.target.start.offset, lineEnd, colEnd: annotation.target.end.offset, }; return [ { ...range, id: annotation.id, sectionId: segments[lineStart]?.sectionId ?? "", text: annotation.selectedText || textForRange(readerSegments, range), title: annotation.title, hint: annotation.hint, tags: annotation.tags, links: annotation.links, word: annotation.word, kind: annotation.kind === "section" ? "verse" : annotation.kind, } satisfies AnalysisFragment, ]; }); const byline = reader.work.contributors.find((contributor) => contributor.role === "artist" || contributor.role === "author") ?.name ?? ""; return { work: { id: reader.work.id, slug: reader.work.slug, type: reader.work.type, byline, title: reader.work.title, }, segments: readerSegments, analysisFragments, }; } export async function loadAnnotationMarkdown(annotationId: string): Promise<string> { const annotation = await fetchJson<AnnotationDetailDto>(`/annotations/${encodeURIComponent(annotationId)}`); return annotation.bodyMarkdown; } function conceptToTermEntry(concept: ConceptDto): TermEntry { return { id: concept.id, title: concept.title, hint: concept.hint, body: concept.bodyMarkdown, tags: concept.tags, links: concept.links, linkType: "concept", entityType: concept.type, }; } export async function loadConcept(conceptId: string): Promise<TermEntry> { const concept = await fetchJson<ConceptDto>(`/concepts/${encodeURIComponent(conceptId)}`); return conceptToTermEntry(concept); } export async function loadAnalysisTerms(): Promise<TermEntry[]> { const response = await fetchJson<ConceptsListDto>("/concepts?type=analysis&limit=200"); return response.concepts.map(conceptToTermEntry); } export async function loadConceptsByTag(tag: string): Promise<TermEntry[]> { const response = await fetchJson<ConceptsListDto>(`/concepts?tag=${encodeURIComponent(tag)}&limit=200`); return response.concepts.map(conceptToTermEntry); } function wordToTermEntry(word: WordDto): TermEntry { return { id: word.id, createdAt: word.createdAt, title: word.title, hint: word.hint, body: word.bodyMarkdown, tags: word.tags, links: word.links, linkType: "word", entityType: "word", partOfSpeech: word.partOfSpeech, }; } export async function loadWord(wordId: string): Promise<TermEntry> { const word = await fetchJson<WordDto>(`/words/${encodeURIComponent(wordId)}`); return wordToTermEntry(word); } async function loadWords(partOfSpeech?: WordPartOfSpeech): Promise<TermEntry[]> { const words: TermEntry[] = []; let cursor: string | undefined; do { const query = new URLSearchParams({ limit: "200" }); if (partOfSpeech) query.set("partOfSpeech", partOfSpeech); if (cursor) query.set("cursor", cursor); const response = await fetchJson<WordsListDto>(`/words?${query.toString()}`); words.push(...response.words.map(wordToTermEntry)); cursor = response.nextCursor; } while (cursor); return words; } export async function loadWordsByPartOfSpeech(partOfSpeech: WordPartOfSpeech): Promise<TermEntry[]> { return loadWords(partOfSpeech); } export async function loadAllWords(): Promise<TermEntry[]> { return loadWords(); } export async function loadWordConstituents(wordId: string): Promise<WordConstituentReferenceDto[]> { const response = await fetchJson<WordConstituentsListDto>(`/words/${encodeURIComponent(wordId)}/constituents`); return response.constituents; }