// Thin fetch wrappers for the Petal backend. Everything lives under /api, which // Vite proxies to the Go server in dev and the binary serves directly in prod. // A tag's palette key, mapped to a CSS color on the frontend. Mirrors the // backend TagColor* constants; unknown values render as rose. export type TagColor = 'rose' | 'mint' | 'peach' | 'lavender' | 'sky' | 'honey' export interface Tag { id: string name: string color: TagColor doc_count?: number // present only in the tag-roster listing } export interface DocSummary { id: string title: string word_count: number updated_at: string tags: Tag[] } // One cross-document search hit. `snippet` is plain text with the matched span // wrapped in the … sentinels (see splitSnippet) for highlighting. export interface SearchResult { id: string title: string word_count: number updated_at: string snippet: string tags: Tag[] } export interface Document { id: string user_id: string title: string content: string // Tiptap JSON (stringified) content_text: string // flattened plain text tone: string // target writing tone; steers LLM advice word_count: number created_at: string updated_at: string // When true, this document's automatic snapshots are never pruned, so its // full writing trail survives as authorship evidence (see the passport). preserve_history: boolean // Which language this document is written in, as decided server-side by the // checkpoint pass: '' | 'en' | 'pair' ('' reads as English). Read-only — the // editor never sends it. It is here so read-aloud can use the right voice on a // document written in her own language. doc_lang: DocLang } // The document-language verdict, shared by documents and garden cards. 'pair' // names the writer's own language rather than a language code, so changing her // pair re-reads her documents instead of stranding a stale name on them. export type DocLang = '' | 'en' | 'pair' // Fields the editor sends on auto-save. All optional so a rename can send title // alone; the editor sends the full set. export interface DocUpdate { title?: string content?: string content_text?: string tone?: string word_count?: number preserve_history?: boolean } // One sense of a word from the offline dictionary. export interface WordMeaning { part_of_speech: string definition: string example?: string } // The offline lookup for one word: a Chinese gloss, a few definition senses, and // a list of synonyms. Any of these may be empty when the word isn't a headword. export interface WordInfo { word: string gloss: string // translation into the writer's language; '' when absent phonetic: string // IPA for the English word; '' when absent definitions: WordMeaning[] synonyms: string[] // From DreamDict only; the embedded datasets leave them unknown. `frequency` // is 0 and `difficulty` is -1 when the dictionary has no score — see // wordBand, which turns the pair into a band or into nothing at all. frequency: number difficulty: number etymology: string // free-form, already trimmed to a line by the server; '' when absent // The same token read as a word of the writer's own language, when it is one. // Absent for a zh-pair writer and for almost every word in a Latin pair — see // lexicon.Reverse for why Petal asks both directions instead of guessing. reverse?: WordReverse } // A word looked up in the other direction: the writer's language -> English. export interface WordReverse { lang: string gloss: string definitions?: WordMeaning[] phonetic?: string } // The lightweight Chinese-only gloss behind the inline hover/select tooltip. export interface Gloss { word: string gloss: string // The English meaning of the token read as a word of her own language. // Present only on a collision (Portuguese *sale*, French *chat*). reverse?: string } // 'translate' is a span she wrote in her own language, rendered into English — // not a correction. The server decides the label from the span itself, never from // the model, so the client can trust it (see suggestions/language.go). export type SuggestionType = | 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'translate' | 'voice' | 'collocation' | 'mechanics' // One word in the vocabulary garden: a looked-up word with its gloss/phonetic, // the sentence it was met in, and its spaced-repetition state. `reps` drives how // "bloomed" its blossom looks; `due_at` decides when it next surfaces for review. export interface VocabWord { id: string word: string gloss: string definition: string // English fallback meaning shown in review when there's no gloss phonetic: string example: string doc_id: string | null // The language of the document this word was met in — the card's own language // (migration 0018). Read-aloud needs it: "comum" is unguessable from letters. lang: DocLang due_at: string interval_days: number ease: number reps: number lapses: number last_reviewed: string | null created_at: string } // The self-assessment grades a flashcard review can record. export type VocabGrade = 'again' | 'good' | 'easy' // A point-in-time snapshot of a document. List responses omit the heavy // content/content_text fields; they arrive only on getVersion (preview/restore). export interface DocumentVersion { id: string doc_id: string title: string content?: string content_text?: string word_count: number kind: 'auto' | 'manual' | 'pre_restore' created_at: string } // Downloadable export formats. PDF is handled client-side via the browser's // print dialog (CJK-safe, no server-side font embedding). export type ExportFormat = 'md' | 'html' | 'txt' | 'docx' // A single LLM-proposed edit. `original` is the source of truth for placement — // the editor re-anchors by matching this string in the live document (spec // Note #6); from_pos/to_pos are server-side advisory only. `replacement` is // empty for voice flags (awareness-only, no correction to apply). export interface Suggestion { id: string doc_id: string from_pos: number to_pos: number original: string replacement: string explanation: string type: SuggestionType status: 'pending' | 'accepted' | 'rejected' // Which engine proposed it — the offline rule pack or the model. The rail // deliberately renders both identically; this is here because the wire format // carries it, not because the writer is ever shown it. source?: 'llm' | 'local' created_at: string } // The growth journal (GET /api/suggestions/growth). `kept`/`kept_before` are // the last thirty days and the thirty before them — the only comparison Petal // draws is with her own past self. `stuck` is phrasing she was given that now // turns up across her own documents; `faded` is what she used to be corrected // on and hasn't been lately. Both lists are empty when the data isn't there: // nothing here is padded to fill a page. export interface GrowthJournal { kept: number kept_before: number stuck: { phrase: string; docs: number }[] faded: { pattern: string; times: number }[] } // A deterministic, rule-based fix detected client-side (see Companion/prose.ts). // The frontend owns offline detection; the backend only persists these. Spans // are exact plaintext offsets. `type` names the family the finding belongs to: // 'mechanics' for a fix to this sentence, 'collocation' for the miscollocation // rules, whose findings are chunks worth keeping and are filed — and planted in // the garden on accept — exactly like the LLM coach's. export interface MechanicsFinding { from: number to: number original: string replacement: string explanation: string type: 'mechanics' | 'collocation' } // One dictionary's worth of personal words — the ones she's excused from // spell-check. Keyed by the dictionary's language, not the writer's. export interface PersonalWords { lang: string words: string[] } // One pronunciation of a Chinese word, and what it means in that pronunciation. // A list, because 得 is dé "to obtain" and also the particle in 说得很好. export interface HanziReading { pinyin: string senses: string } // A Chinese word lookup. `readings` is empty for a word with no headword, in // which case `chars` may carry the character-by-character reading. export interface HanziInfo { word: string readings: HanziReading[] chars: { char: string; pinyin: string; senses: string }[] } // Who's writing. Mirrors the backend db.User. export interface Me { id: string email: string display_name: string created_at: string pair_lang: string // Which half of the pair is being learned: 'learning_en' (the writer is // native in pair_lang and practising English) or 'learning_pair' (the other // way round). Mirrors users.direction; the server refuses 'learning_pair' for // a pair it has no word list for. direction: string } // Thrown when the server says the session is gone. Callers can tell it apart // from a real failure — losing your session is not the same as a save going // wrong, and the auto-save has to treat them very differently. export class UnauthorizedError extends Error { constructor() { super('not signed in') this.name = 'UnauthorizedError' } } // Sessions expire, so *any* call can come back 401 — including the auto-save // that fires 1.5s after every keystroke. One place notices, and the app reacts // once, rather than each call site inventing its own answer. let unauthorizedHandler: (() => void) | null = null export function onUnauthorized(handler: () => void) { unauthorizedHandler = handler } function signedOut(): UnauthorizedError { unauthorizedHandler?.() return new UnauthorizedError() } // The document-language verdict is decided by the checkpoint pass, so the pass's // own response is the first moment the client can know it. It rides on a header // (the pass answers with a bare array of suggestions, and every caller reads it // as one), and reaches the app through a handler registered here — the same // shape onUnauthorized already uses, for the same reason: it is one fact from // deep inside a request that a component several layers up needs. // // Without it the editor learns the verdict only from a document save, which is // always one save behind the pass — and read-aloud is reached for precisely when // she has stopped typing and no further save is coming. let docLangHandler: ((docId: string, lang: DocLang) => void) | null = null export function onDocLang(handler: (docId: string, lang: DocLang) => void) { docLangHandler = handler } // `verdictFor` names the document whose language this response may announce. // Only the three pass endpoints pass it; everything else has no verdict to carry // and never touches the handler. async function req(path: string, init?: RequestInit, verdictFor?: string): Promise { const res = await fetch(`/api${path}`, { headers: { 'Content-Type': 'application/json' }, ...init, }) if (verdictFor && res.ok) { const lang = res.headers.get('X-Petal-Doc-Lang') if (lang === '' || lang === 'en' || lang === 'pair') docLangHandler?.(verdictFor, lang) } if (res.status === 401) throw signedOut() if (!res.ok) { const detail = await res.text().catch(() => '') throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`) } if (res.status === 204) return undefined as T return res.json() as Promise } export const api = { // The signed-in writer. With auth unconfigured (local development) this is // the hardcoded local user, so the frontend needs no separate mode for it. me: () => req('/me'), // Move to another (English + X) pair. Answers with the whole updated user, so // the caller re-reads the pair from the server rather than assuming its own // request took — a code the server won't ship comes back 400 and the app is // still on a language it can render. setPairLang: (lang: string) => req('/me', { method: 'PATCH', body: JSON.stringify({ pair_lang: lang }) }), // Turn the pair around. Same endpoint, same contract, and deliberately a // separate call: the two fields are validated together server-side, so a // client that wants to change both says both in one request rather than // sending two that each pass on their own. setDirection: (direction: string) => req('/me', { method: 'PATCH', body: JSON.stringify({ direction }) }), setPair: (lang: string, direction: string) => req('/me', { method: 'PATCH', body: JSON.stringify({ pair_lang: lang, direction }) }), listDocs: () => req('/docs'), createDoc: () => req('/docs', { method: 'POST' }), getDoc: (id: string) => req(`/docs/${id}`), updateDoc: (id: string, body: DocUpdate) => req(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }), deleteDoc: (id: string) => req(`/docs/${id}`, { method: 'DELETE' }), // Grammar checkpoint: runs an LLM pass and returns the fresh pending set. // Rate-limited per document server-side (returns the existing set if too soon). // Both passes return the UNIFIED pending set (grammar + voice), so the client // never drops one family's highlights when the other refreshes. checkDoc: (id: string) => req(`/docs/${id}/check`, { method: 'POST' }, id), // Voice-consistency pass: whole-document, explicit-action, slower. Returns the // unified pending set too. Rate-limited per document server-side. voiceDoc: (id: string) => req(`/docs/${id}/voice`, { method: 'POST' }, id), // Collocation coach: whole-document, explicit-action pass flagging non-native // word pairings ("do a decision" → "make a decision"). Returns the unified // pending set too. Rate-limited per document server-side. collocationDoc: (id: string) => req(`/docs/${id}/collocation`, { method: 'POST' }, id), // Mechanics pass: persist the client-detected deterministic fixes as the // 'mechanics' family and return the unified pending set. Not rate-limited (it's // free, local detection); runs alongside the grammar checkpoint. submitMechanics: (id: string, findings: MechanicsFinding[]) => req(`/docs/${id}/mechanics`, { method: 'POST', body: JSON.stringify({ findings }), }), // Pending suggestions for a doc, loaded when the editor opens it. listSuggestions: (id: string) => req(`/docs/${id}/suggestions`), // The spans she has already accepted or dismissed on this doc, normalized. The // server suppresses these itself; the client needs them so the instant rule-pack // pass doesn't hand back a dismissed card before the server can say otherwise — // or, with the server unreachable, at all. See lib/settled.ts. listSettled: (id: string) => req<{ originals: string[] }>(`/docs/${id}/settled`), acceptSuggestion: (id: string) => req(`/suggestions/${id}/accept`, { method: 'POST' }), dismissSuggestion: (id: string) => req(`/suggestions/${id}/dismiss`, { method: 'POST' }), // Simplified-Chinese rendering of a suggestion's explanation, for the Ask Petal // opening bubble (the explanation itself stays English in the card body). translateSuggestion: (id: string) => req<{ translation: string }>(`/suggestions/${id}/translate`, { method: 'POST' }), // The growth journal: her own accepted edits read back as patterns. Purely a // read-side view of a table Petal already keeps, computed locally with no // model call, so it costs nothing and leaves nothing. growth: () => req('/suggestions/growth'), // Version history. listVersions returns metadata only (no bodies); getVersion // loads one full snapshot for preview; snapshotDoc takes an explicit restore // point; restoreVersion copies a snapshot back onto the live doc (capturing a // pre_restore safety copy server-side first) and returns the restored doc. listVersions: (id: string) => req(`/docs/${id}/versions`), getVersion: (id: string, vid: string) => req(`/docs/${id}/versions/${vid}`), snapshotDoc: (id: string) => req(`/docs/${id}/versions`, { method: 'POST' }), restoreVersion: (id: string, vid: string) => req(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }), // Download URL for an exported document (md/html/txt/docx). Used as an target so the browser handles the file save. exportUrl: (id: string, format: ExportFormat) => `/api/docs/${id}/export?format=${format}`, // Download URL for a whole-corpus backup: a zip of every document rendered in // the given format. A one-click "download all my writing" safety net. exportAllUrl: (format: ExportFormat) => `/api/docs/export-all?format=${format}`, // Download URL for the writing passport: a standalone HTML report of how this // document was written (timeline, growth, sessions), for showing someone who // questions its authorship. Print to PDF from the browser to hand it over. passportUrl: (id: string) => `/api/docs/${id}/passport`, // Offline word lookup (gloss + definition + synonyms) for the right-click popover. lookupWord: (word: string) => req(`/word/${encodeURIComponent(word)}`), // Lightweight Chinese-only gloss for the inline hover/select tooltip — instant // and offline, so it fires on hover without spinning up the heavier lookup. glossWord: (word: string) => req(`/gloss/${encodeURIComponent(word)}`), // The same lookup pointing the other way: a Chinese word to its pinyin and // English senses, for an account learning the pair language rather than // English. A word the dictionary has no headword for comes back with empty // readings and — when its characters are known — a per-character reading // instead, which is a real second answer for a compound. hanziWord: (word: string) => req(`/hanzi/${encodeURIComponent(word)}`), // Tone-rewrite: rewrites a selected passage in the given style ('natural', // 'academic', …) and returns the rewritten text for an in-editor preview. Not // persisted — the editor applies it directly on accept. rewriteSelection: (docId: string, text: string, style: string) => req<{ rewrite: string }>(`/docs/${docId}/rewrite`, { method: 'POST', body: JSON.stringify({ text, style }), }), // Cross-document full-text search (title + body). Returns hits with a // highlighted snippet. Empty query returns []. Encodes the term for the URL. search: (q: string) => req(`/search?q=${encodeURIComponent(q)}`), // Image upload: posts a single image file as multipart form data and returns // its served URL (e.g. /api/images/.png). Stored on disk by the binary, // so the document JSON keeps a small URL reference instead of inlined base64. // Doesn't go through req() because the body is FormData, not JSON. uploadImage: async (file: File): Promise<{ url: string }> => { const form = new FormData() form.append('image', file) const res = await fetch('/api/images', { method: 'POST', body: form }) if (res.status === 401) throw signedOut() if (!res.ok) { const detail = await res.text().catch(() => '') throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`) } return res.json() as Promise<{ url: string }> }, // Tags. listTags returns the roster with per-tag document counts; createTag is // idempotent on name (returns the existing tag if it already exists); // updateTag recolors/renames; deleteTag removes it (assignments cascade). // assignTag/unassignTag link a tag to one document. listTags: () => req('/tags'), createTag: (name: string, color: TagColor) => req('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }), updateTag: (id: string, patch: { name?: string; color?: TagColor }) => req(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }), deleteTag: (id: string) => req(`/tags/${id}`, { method: 'DELETE' }), assignTag: (docId: string, tagId: string) => req(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }), unassignTag: (docId: string, tagId: string) => req(`/docs/${docId}/tags/${tagId}`, { method: 'DELETE' }), // Vocabulary garden. recordVocab captures (or refreshes) a looked-up word — // idempotent per word, fired automatically on lookup. listVocab is the whole // garden; dueVocab is just the cards ready for review; reviewVocab grades one // card and returns its rescheduled state; deleteVocab removes a word. recordVocab: (body: { word: string gloss?: string definition?: string phonetic?: string example?: string doc_id?: string | null }) => req('/vocab', { method: 'POST', body: JSON.stringify(body) }), listVocab: () => req('/vocab'), dueVocab: () => req('/vocab/due'), reviewVocab: (id: string, grade: VocabGrade) => req(`/vocab/${id}/review`, { method: 'POST', body: JSON.stringify({ grade }) }), deleteVocab: (id: string) => req(`/vocab/${id}`, { method: 'DELETE' }), // The personal spelling dictionary — words she's told Petal to stop flagging. // Server-side, so it belongs to her account and follows her between devices. // `lang` is the *dictionary's* language: an English exception must not silence // a pt-PT flag. Every call answers with the full resulting list, so the client // never has to merge two views of the same set. listPersonalWords: (lang: string) => req(`/spell/words?lang=${encodeURIComponent(lang)}`), addPersonalWords: (lang: string, words: string[]) => req('/spell/words', { method: 'POST', body: JSON.stringify({ lang, words }), }), removePersonalWord: (lang: string, word: string) => req( `/spell/words?lang=${encodeURIComponent(lang)}&word=${encodeURIComponent(word)}`, { method: 'DELETE' }, ), // Current deployed build id — changes whenever a new frontend ships. The // app polls this to offer a refresh. Bypasses any cache so the answer is live. version: () => req<{ version: string }>('/version', { cache: 'no-store' }), } // The sentinel characters the server wraps a search match in (\x01 … \x02). Used // by splitSnippet to render the matched span highlighted. export const SNIPPET_HL_START = String.fromCharCode(1) export const SNIPPET_HL_END = String.fromCharCode(2) // splitSnippet breaks a server snippet into ordered { text, hit } segments so the // UI can bold the matched span(s) without dangerously setting innerHTML. export function splitSnippet(snippet: string): { text: string; hit: boolean }[] { const out: { text: string; hit: boolean }[] = [] let rest = snippet for (;;) { const start = rest.indexOf(SNIPPET_HL_START) if (start < 0) { if (rest) out.push({ text: rest, hit: false }) break } if (start > 0) out.push({ text: rest.slice(0, start), hit: false }) const end = rest.indexOf(SNIPPET_HL_END, start + 1) if (end < 0) { // Unterminated (shouldn't happen) — emit the remainder plainly. out.push({ text: rest.slice(start + 1), hit: false }) break } out.push({ text: rest.slice(start + 1, end), hit: true }) rest = rest.slice(end + 1) } return out } // tagColorVar maps a tag color key to its CSS custom property. Unknown keys fall // back to the rose accent, matching the backend's coercion. export function tagColorVar(color: TagColor | string): string { switch (color) { case 'mint': return 'var(--color-mint)' case 'peach': return 'var(--color-peach)' case 'lavender': return 'var(--color-lavender)' case 'sky': return 'var(--color-sky)' case 'honey': return 'var(--color-honey)' default: return 'var(--color-accent)' } } // One turn in an Ask Petal conversation. History lives only in the component — // the server is stateless and re-injects the suggestion context every request. export interface ChatMessage { role: 'user' | 'assistant' content: string } // streamSuggestionChat POSTs the conversation to the SSE chat endpoint and // invokes onToken for each text chunk as it arrives. It uses fetch + a // ReadableStream reader (not EventSource, which can't POST) and resolves when // the stream ends. Abort via the optional signal to cancel mid-response. export async function streamSuggestionChat( suggestionId: string, messages: ChatMessage[], onToken: (text: string) => void, signal?: AbortSignal, ): Promise { const res = await fetch(`/api/suggestions/${suggestionId}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages }), signal, }) if (res.status === 401) throw signedOut() if (!res.ok || !res.body) { const detail = await res.text().catch(() => '') throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`) } const reader = res.body.getReader() const decoder = new TextDecoder() let buf = '' for (;;) { const { done, value } = await reader.read() if (done) break buf += decoder.decode(value, { stream: true }) // SSE events are separated by a blank line. Process every complete one and // keep the trailing partial in the buffer. let sep: number while ((sep = buf.indexOf('\n\n')) >= 0) { const event = parseSSE(buf.slice(0, sep)) buf = buf.slice(sep + 2) if (event.name === 'done') return if (event.name === 'token' && event.data) { const text = (JSON.parse(event.data) as { text: string }).text if (text) onToken(text) } } } } // parseSSE pulls the event name and data payload out of one raw SSE frame. function parseSSE(frame: string): { name: string; data: string } { let name = 'message' const dataLines: string[] = [] for (const line of frame.split('\n')) { if (line.startsWith('event:')) name = line.slice(6).trim() else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()) } return { name, data: dataLines.join('\n') } }