Correctness: - useCheckpoint: clear the busy flag unconditionally so overlapping explicit passes don't strand each other's spinner; explicit actions now also supersede a queued auto-check and clear the stranded "checking" dot. Deduped runVoice/runCollocation into runExplicitPass. - EditorCore: token-guard the auto-capture so a late capture can't resurrect a removed word; move toggleSaveWord side effects out of the setWordInfo updater (StrictMode double-fire); fix sentenceAround offset desync via shared exampleAt (textBetween + parentOffset, single resolve); optimistic saved state so the heart doesn't flash unsaved. - vocab capture: normalize word to lower+trim (matches lexicon) so "Apple"/"apple" don't make duplicate cards; check rows.Err() in queryList. - GardenPanel: Promise.allSettled so a /due failure doesn't blank the whole garden; scrim click during review ends the review (mirrors Esc); gate footer on !error; O(1) due lookup via a Set. Features requested in review: - Definition-only review card: add vocab_words.definition (migration 0007) as an English fallback meaning, threaded through capture and used by review/garden when there's no Chinese gloss. - Scheduler caps: maxEase 3.0 + maxInterval 365d so "easy" growth can't push a word out of rotation for years. Tests: TestCaptureCaseInsensitive, TestCaptureStoresDefinitionFallback, TestCapsBoundGrowth. go build/vet/test, tsc, vitest 51/51, vite build clean. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
382 lines
15 KiB
TypeScript
382 lines
15 KiB
TypeScript
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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 // Chinese translation; '' when the word isn't in the gloss set
|
||
phonetic: string // IPA for the English word; '' when absent
|
||
definitions: WordMeaning[]
|
||
synonyms: string[]
|
||
}
|
||
|
||
// The lightweight Chinese-only gloss behind the inline hover/select tooltip.
|
||
export interface Gloss {
|
||
word: string
|
||
gloss: string
|
||
}
|
||
|
||
export type SuggestionType = 'grammar' | 'phrasing' | 'idiom' | 'clarity' | 'voice' | 'collocation'
|
||
|
||
// 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
|
||
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'
|
||
created_at: string
|
||
}
|
||
|
||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||
const res = await fetch(`/api${path}`, {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
...init,
|
||
})
|
||
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<T>
|
||
}
|
||
|
||
export const api = {
|
||
listDocs: () => req<DocSummary[]>('/docs'),
|
||
createDoc: () => req<Document>('/docs', { method: 'POST' }),
|
||
getDoc: (id: string) => req<Document>(`/docs/${id}`),
|
||
updateDoc: (id: string, body: DocUpdate) =>
|
||
req<Document>(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||
deleteDoc: (id: string) => req<void>(`/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<Suggestion[]>(`/docs/${id}/check`, { method: 'POST' }),
|
||
// Voice-consistency pass: whole-document, explicit-action, slower. Returns the
|
||
// unified pending set too. Rate-limited per document server-side.
|
||
voiceDoc: (id: string) => req<Suggestion[]>(`/docs/${id}/voice`, { method: 'POST' }),
|
||
// 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<Suggestion[]>(`/docs/${id}/collocation`, { method: 'POST' }),
|
||
// Pending suggestions for a doc, loaded when the editor opens it.
|
||
listSuggestions: (id: string) => req<Suggestion[]>(`/docs/${id}/suggestions`),
|
||
acceptSuggestion: (id: string) =>
|
||
req<void>(`/suggestions/${id}/accept`, { method: 'POST' }),
|
||
dismissSuggestion: (id: string) =>
|
||
req<void>(`/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' }),
|
||
|
||
// 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<DocumentVersion[]>(`/docs/${id}/versions`),
|
||
getVersion: (id: string, vid: string) =>
|
||
req<DocumentVersion>(`/docs/${id}/versions/${vid}`),
|
||
snapshotDoc: (id: string) =>
|
||
req<DocumentVersion>(`/docs/${id}/versions`, { method: 'POST' }),
|
||
restoreVersion: (id: string, vid: string) =>
|
||
req<Document>(`/docs/${id}/versions/${vid}/restore`, { method: 'POST' }),
|
||
|
||
// Download URL for an exported document (md/html/txt/docx). Used as an <a
|
||
// href download> 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}`,
|
||
|
||
// Offline word lookup (gloss + definition + synonyms) for the right-click popover.
|
||
lookupWord: (word: string) => req<WordInfo>(`/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>(`/gloss/${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<SearchResult[]>(`/search?q=${encodeURIComponent(q)}`),
|
||
|
||
// Image upload: posts a single image file as multipart form data and returns
|
||
// its served URL (e.g. /api/images/<hash>.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.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<Tag[]>('/tags'),
|
||
createTag: (name: string, color: TagColor) =>
|
||
req<Tag>('/tags', { method: 'POST', body: JSON.stringify({ name, color }) }),
|
||
updateTag: (id: string, patch: { name?: string; color?: TagColor }) =>
|
||
req<Tag>(`/tags/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||
deleteTag: (id: string) => req<void>(`/tags/${id}`, { method: 'DELETE' }),
|
||
assignTag: (docId: string, tagId: string) =>
|
||
req<void>(`/docs/${docId}/tags`, { method: 'POST', body: JSON.stringify({ tag_id: tagId }) }),
|
||
unassignTag: (docId: string, tagId: string) =>
|
||
req<void>(`/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<VocabWord>('/vocab', { method: 'POST', body: JSON.stringify(body) }),
|
||
listVocab: () => req<VocabWord[]>('/vocab'),
|
||
dueVocab: () => req<VocabWord[]>('/vocab/due'),
|
||
reviewVocab: (id: string, grade: VocabGrade) =>
|
||
req<VocabWord>(`/vocab/${id}/review`, { method: 'POST', body: JSON.stringify({ grade }) }),
|
||
deleteVocab: (id: string) => req<void>(`/vocab/${id}`, { 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<void> {
|
||
const res = await fetch(`/api/suggestions/${suggestionId}/chat`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ messages }),
|
||
signal,
|
||
})
|
||
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') }
|
||
}
|