chris 55375d2ff0 Built-in spell & grammar checker (LanguageTool)
Server:
- New POST /api/lint/check proxies to LanguageTool API (public free
  endpoint by default; override with LANGUAGETOOL_URL env var for
  self-hosted instance)
- Returns trimmed match list: message, offset, length, replacements,
  kind (spelling|grammar), ruleId
- Disables cosmetic rules that don't suit creative writing
- 20-second timeout; falls back with 502 on error

Frontend:
- LintMark: transient TipTap Mark extension (never saved to DB —
  stripLintMarks() removes them from getJSON() before onChange fires)
- buildTextMap(): walks ProseMirror doc tree, builds parallel plain-
  text string + position array so LanguageTool char offsets map back
  to exact PM positions even across paragraphs / headings
- clearLintMarks() + runLint(): check → clear old marks → apply new
  marks in one transaction, no flicker
- Click on underlined text → fixed-position popover showing the error
  message + up to 6 one-click replacement buttons + Ignore / ✕
- Applying a replacement triggers an automatic re-check after 600 ms
- lintStatus: idle → checking → done → stale (when user edits)
- ABC toolbar button shows count badge when errors found, ✓ when clean
- Wavy red underline = spelling, wavy blue = grammar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 21:35:38 -04:00

94 lines
3.7 KiB
JavaScript

const TOKEN_KEY = 'sw-token'
export function getToken() { return localStorage.getItem(TOKEN_KEY) }
export function setToken(t) { localStorage.setItem(TOKEN_KEY, t) }
export function clearToken() { localStorage.removeItem(TOKEN_KEY) }
export function getUser() {
const token = getToken()
if (!token) return null
try {
const payload = JSON.parse(atob(token.split('.')[1]))
if (payload.exp * 1000 < Date.now()) { clearToken(); return null }
return payload
} catch { return null }
}
// Support tokens that still carry `email` from before the username migration
export function getUsername() {
const u = getUser()
return u?.username ?? u?.email ?? ''
}
async function req(method, url, body, extraHeaders = {}) {
const token = getToken()
const isForm = body instanceof FormData
const headers = {
...(!isForm && body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...extraHeaders,
}
const res = await fetch(url, {
method,
headers,
body: isForm ? body : body ? JSON.stringify(body) : undefined,
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status })
return data
}
async function download(url) {
const token = getToken()
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const fname = res.headers.get('content-disposition')?.match(/filename="([^"]+)"/)?.[1] || 'export'
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = fname
a.click()
URL.revokeObjectURL(a.href)
}
export const api = {
login: async (username, password) => {
const data = await req('POST', '/api/auth/login', { username, password })
setToken(data.token)
return data.user
},
logout: clearToken,
changePassword: (current, newPassword) => req('PUT', '/api/auth/password', { current, newPassword }),
getStories: () => req('GET', '/api/stories'),
getStory: (id) => req('GET', `/api/stories/${id}`),
createStory: (data) => req('POST', '/api/stories', data),
updateStory: (id, data) => req('PUT', `/api/stories/${id}`, data),
deleteStory: (id) => req('DELETE', `/api/stories/${id}`),
uploadImage: async (file) => {
const form = new FormData()
form.append('file', file)
const data = await req('POST', '/api/images', form)
return data.url
},
getNotes: (storyId) => req('GET', `/api/stories/${storyId}/notes`),
createNote: (storyId) => req('POST', `/api/stories/${storyId}/notes`),
updateNote: (storyId, noteId, data) => req('PUT', `/api/stories/${storyId}/notes/${noteId}`, data),
deleteNote: (storyId, noteId) => req('DELETE', `/api/stories/${storyId}/notes/${noteId}`),
lintCheck: (text, language = 'en-US') => req('POST', '/api/lint/check', { text, language }),
getPrompt: () => req('GET', '/api/prompts'),
exportEpub: (id) => download(`/api/stories/${id}/export/epub`),
exportOdt: (id) => download(`/api/stories/${id}/export/odt`),
admin: {
getUsers: (pw) => req('GET', '/api/admin/users', null, { 'x-admin-password': pw }),
createUser: (pw, data) => req('POST', '/api/admin/users', data, { 'x-admin-password': pw }),
resetPassword: (pw, id, pass) => req('PUT', `/api/admin/users/${id}/password`, { password: pass }, { 'x-admin-password': pw }),
deleteUser: (pw, id) => req('DELETE', `/api/admin/users/${id}`, null, { 'x-admin-password': pw }),
},
}