✨ grammar checker
This commit is contained in:
74
src/main/grammarService.ts
Normal file
74
src/main/grammarService.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import https from 'https'
|
||||
import querystring from 'querystring'
|
||||
|
||||
export interface GrammarMatch {
|
||||
offset: number
|
||||
length: number
|
||||
message: string
|
||||
shortMessage: string
|
||||
replacement: string | null
|
||||
ruleId: string
|
||||
categoryId: string
|
||||
}
|
||||
|
||||
const IGNORED_RULE_IDS = new Set([
|
||||
'WHITESPACE_RULE',
|
||||
'UNPAIRED_BRACKETS',
|
||||
'EN_QUOTES',
|
||||
'DASH_RULE',
|
||||
'WORD_CONTAINS_UNDERSCORE',
|
||||
])
|
||||
|
||||
export async function checkGrammar(text: string, language = 'en-US'): Promise<GrammarMatch[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = querystring.stringify({ text, language, enabledOnly: 'false' })
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: 'api.languagetool.org',
|
||||
path: '/v2/check',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = ''
|
||||
res.on('data', (chunk) => { data += chunk })
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
matches: Array<{
|
||||
offset: number
|
||||
length: number
|
||||
message: string
|
||||
shortMessage: string
|
||||
replacements: Array<{ value: string }>
|
||||
rule: { id: string; category: { id: string } }
|
||||
}>
|
||||
}
|
||||
const matches: GrammarMatch[] = parsed.matches
|
||||
.filter((m) => !IGNORED_RULE_IDS.has(m.rule.id))
|
||||
.map((m) => ({
|
||||
offset: m.offset,
|
||||
length: m.length,
|
||||
message: m.message,
|
||||
shortMessage: m.shortMessage || m.message,
|
||||
replacement: m.replacements[0]?.value ?? null,
|
||||
ruleId: m.rule.id,
|
||||
categoryId: m.rule.category.id,
|
||||
}))
|
||||
resolve(matches)
|
||||
} catch (e) {
|
||||
reject(new Error(`LanguageTool parse error: ${String(e)}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
req.on('error', reject)
|
||||
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Grammar check timed out')) })
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { tmpdir } from 'os'
|
||||
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, openPublisherPackFile, searchAcrossFiles, replaceInFiles, readAllDraftFiles, readProjectConfig, writeProjectConfig, PROJECT_CONFIG_FIELDS, readTelemetry, readSubmissions, writeSubmissions } from './fileSystem'
|
||||
import type { SearchOptions, ProjectConfig } from './fileSystem'
|
||||
import { streamMessage, resetClient } from './aiService'
|
||||
import { checkGrammar } from './grammarService'
|
||||
import type { GrammarMatch } from './grammarService'
|
||||
import { onWordSnapshot, flushTelemetry } from './telemetry'
|
||||
import type { AIPayload, Attachment, Submission } from '../renderer/types/editor'
|
||||
import { readGlobalConfig, writeGlobalConfig, getProjectTitle, addRecentProject, updateRecentProjectTitle } from './globalConfig'
|
||||
@@ -170,6 +172,10 @@ export function registerIpcHandlers(): void {
|
||||
await writeSubmissions(data)
|
||||
})
|
||||
|
||||
ipcMain.handle('grammar:check', async (_event, text: string): Promise<GrammarMatch[]> => {
|
||||
return await checkGrammar(text)
|
||||
})
|
||||
|
||||
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
|
||||
try {
|
||||
const storyBibleContent = (await readStoryBibleFile()) ?? undefined
|
||||
|
||||
@@ -145,4 +145,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
|
||||
writeSubmissions: (data: Submission[]): Promise<void> =>
|
||||
ipcRenderer.invoke('submissions:write', data),
|
||||
|
||||
checkGrammar: (text: string): Promise<import('../main/grammarService').GrammarMatch[]> =>
|
||||
ipcRenderer.invoke('grammar:check', text),
|
||||
})
|
||||
|
||||
@@ -563,6 +563,11 @@ function buildTheme(fontSize: number, dark: boolean, focusMode = false): ReturnT
|
||||
backgroundColor: 'rgba(240, 100, 180, 0.15)',
|
||||
borderBottom: '2px solid rgba(240, 100, 180, 0.65)',
|
||||
borderRadius: '2px'
|
||||
},
|
||||
'.annotation-grammar': {
|
||||
backgroundColor: 'rgba(220, 60, 60, 0.12)',
|
||||
borderBottom: '2px solid rgba(220, 60, 60, 0.75)',
|
||||
borderRadius: '2px'
|
||||
}
|
||||
},
|
||||
{ dark }
|
||||
|
||||
@@ -29,6 +29,7 @@ function badgeColor(type: TextAnnotation['type']): string {
|
||||
case 'user_comment': return 'rgba(240, 100, 180, 0.85)'
|
||||
case 'document_note': return 'rgba(80, 180, 240, 0.85)'
|
||||
case 'polish': return 'rgba(55, 138, 221, 0.75)'
|
||||
case 'grammar': return 'rgba(220, 60, 60, 0.8)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,13 +296,14 @@ function PolishMeter({ score }: { score: PolishScore }): JSX.Element {
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null)
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
|
||||
const { setAnnotations, clearAnnotations } = useEditorStore()
|
||||
const widgetRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const dims = Object.entries(score.dimensions) as [string, PolishDimension][]
|
||||
|
||||
function handleDimClick(key: string, dim: PolishDimension): void {
|
||||
if (activeKey === key) {
|
||||
setActiveKey(null)
|
||||
clearAnnotations()
|
||||
setAnnotations([])
|
||||
return
|
||||
}
|
||||
if (dim.matches.length === 0) return
|
||||
@@ -326,8 +328,21 @@ function PolishMeter({ score }: { score: PolishScore }): JSX.Element {
|
||||
}
|
||||
}, [score.overall])
|
||||
|
||||
// Hide highlights when clicking outside the polish meter
|
||||
useEffect(() => {
|
||||
if (!activeKey) return
|
||||
function handleOutsideClick(e: MouseEvent): void {
|
||||
if (widgetRef.current && !widgetRef.current.contains(e.target as Node)) {
|
||||
setActiveKey(null)
|
||||
setAnnotations([])
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick)
|
||||
}, [activeKey, setAnnotations])
|
||||
|
||||
return (
|
||||
<div className="pm-widget">
|
||||
<div ref={widgetRef} className="pm-widget">
|
||||
<div className="pm-header">
|
||||
<span className="pm-title">Polish</span>
|
||||
<span className="pm-overall" style={{ color: scoreColor(score.overall) }}>{score.overall}</span>
|
||||
|
||||
@@ -363,6 +363,34 @@ export function AnalysisToolbar(): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
const runGrammar = async (): Promise<void> => {
|
||||
if (!hasFile || isAILoading) return
|
||||
setAILoading(true)
|
||||
setAIError(null)
|
||||
setAnalysisMode('none')
|
||||
try {
|
||||
const matches = await window.api.checkGrammar(activeFileContent)
|
||||
const newAnnotations = matches.map((m) => {
|
||||
const matched = activeFileContent.slice(m.offset, m.offset + m.length)
|
||||
return {
|
||||
id: `grammar-${m.offset}-${m.ruleId}`,
|
||||
type: 'grammar' as const,
|
||||
from: m.offset,
|
||||
to: m.offset + m.length,
|
||||
matchedText: matched,
|
||||
message: m.message,
|
||||
suggestion: m.replacement ?? undefined,
|
||||
}
|
||||
})
|
||||
const existing = useEditorStore.getState().annotations.filter((a) => a.type !== 'grammar')
|
||||
setAnnotations([...existing, ...newAnnotations])
|
||||
} catch (err) {
|
||||
setAIError(err instanceof Error ? err.message : 'Grammar check failed')
|
||||
} finally {
|
||||
setAILoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const passiveCount = annotations.filter((a) => a.type === 'passive_voice').length
|
||||
const pastProgressiveCount = annotations.filter((a) => a.type === 'past_progressive').length
|
||||
const weakVerbsCount = annotations.filter((a) => a.type === 'weak_verbs').length
|
||||
@@ -371,7 +399,8 @@ export function AnalysisToolbar(): JSX.Element {
|
||||
const styleCount = annotations.filter((a) => a.type === 'style').length
|
||||
const showTellCount = annotations.filter((a) => a.type === 'show_tell').length
|
||||
const critiqueCount = annotations.filter((a) => a.type === 'critique').length
|
||||
const totalCount = passiveCount + pastProgressiveCount + weakVerbsCount + clichesCount + consistencyCount + styleCount + showTellCount + critiqueCount
|
||||
const grammarCount = annotations.filter((a) => a.type === 'grammar').length
|
||||
const totalCount = passiveCount + pastProgressiveCount + weakVerbsCount + clichesCount + consistencyCount + styleCount + showTellCount + critiqueCount + grammarCount
|
||||
const anyActive = Boolean(analysisMode)
|
||||
const sentenceStats = activeFileContent ? computeSentenceStats(activeFileContent) : null
|
||||
const paragraphRhythm = activeFileContent ? computeParagraphRhythm(activeFileContent) : []
|
||||
@@ -506,6 +535,14 @@ export function AnalysisToolbar(): JSX.Element {
|
||||
<span>Critique</span>
|
||||
{critiqueCount > 0 && <span className="toolbar-analyze-count">{critiqueCount}</span>}
|
||||
</button>
|
||||
<div className="context-menu-separator" />
|
||||
<button
|
||||
className="context-menu-item"
|
||||
onClick={() => { setAnalyzeOpen(false); void runGrammar() }}
|
||||
>
|
||||
<span>Grammar</span>
|
||||
{grammarCount > 0 && <span className="toolbar-analyze-count">{grammarCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})(),
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ChatSession {
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
export type AnnotationType = 'passive_voice' | 'past_progressive' | 'weak_verbs' | 'cliches' | 'consistency' | 'style' | 'show_tell' | 'critique' | 'custom' | 'user_comment' | 'document_note' | 'polish'
|
||||
export type AnnotationType = 'passive_voice' | 'past_progressive' | 'weak_verbs' | 'cliches' | 'consistency' | 'style' | 'show_tell' | 'critique' | 'custom' | 'user_comment' | 'document_note' | 'polish' | 'grammar'
|
||||
|
||||
export interface TextAnnotation {
|
||||
id: string
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user