// Name: Grammar and Spelling Checker // Description: Check grammar and spelling of selected text using Harper.js with UI // Author: benhaotang // GitHub: benhaotang import "@johnlindquist/kit"; interface LintInfo { lint: any; index: number; span: { start: number; end: number }; } interface Correction { start: number; end: number; replacement: string; } const appInfo = await getActiveAppInfo(); const previousWindow = appInfo.windowTitle; const selectedText = await getSelectedText(); if (!selectedText || selectedText.trim() === "") { await div( md( "## No Text Selected\n\nPlease select some text first, then run this script.", ), ); exit(); } let harper: any; let linter: any; try { harper = await import("harper.js"); // Create linter for American English linter = new harper.LocalLinter({ binary: harper.binary, dialect: harper.Dialect.American, }); } catch (error) { await div( md(`## ❌ Error\n\nFailed to initialize Harper.js: ${error.message}`), ); exit(); } // Global variable to store lints let globalLints: any[] = []; // Show loading message and process const result = await div({ html: md( "## Checking Grammar and Spelling...\n\nAnalyzing your selected text...", ), shortcuts: [ { name: "Start Corrections", key: `${cmd}+enter`, onPress: async () => { submit("start-corrections"); }, bar: "right", visible: false, // Initially hidden until analysis is complete }, ], onInit: async () => { try { const lints = await linter.lint(selectedText); globalLints = lints; // Store lints globally if (lints.length === 0) { setDiv( md( "## ✅ No Issues Found!\n\nYour text looks great - no grammar or spelling issues detected.", ), ); return; } let resultsHtml = `## 📝 Grammar & Spelling Results\n\n**Found ${lints.length} issue${lints.length > 1 ? "s" : ""}:**\n\n`; for (let i = 0; i < lints.length; i++) { const lint = lints[i]; const span = lint.span(); const problemText = selectedText.slice(span.start, span.end); resultsHtml += `### ${i + 1}. "${problemText}"\n`; resultsHtml += `**Issue:** ${lint.message()}\n`; resultsHtml += `**Position:** Characters ${span.start} to ${span.end}\n`; if (lint.suggestion_count() > 0) { resultsHtml += `**Suggestions:**\n`; for (const suggestion of lint.suggestions()) { const action = suggestion.kind() === 1 ? "Remove" : "Replace with"; const replacement = suggestion.get_replacement_text(); resultsHtml += `- ${action}: "${replacement}"\n`; } } resultsHtml += "\n---\n\n"; } resultsHtml += `### Original Text:\n\`\`\`\n${selectedText}\n\`\`\`\n\n**Press ${cmd}+enter to start making corrections**`; setDiv(md(resultsHtml)); // Show the shortcut button now that analysis is complete setShortcuts([ { name: "Start Corrections", key: `${cmd}+enter`, onPress: async () => { submit("start-corrections"); }, bar: "right", visible: true, }, ]); } catch (error) { setDiv(md(`## ❌ Error\n\nFailed to check text: ${error.message}`)); } }, }); // If user pressed cmd+enter, start the correction process if (result === "start-corrections" && globalLints.length > 0) { await startCorrectionProcess(globalLints); } /** * Helper function to escape HTML characters for safe rendering */ function escapeHtml(text: string): string { return text .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#39;"); } /** * Creates HTML for the text widget with highlighted issues */ function createTextWidget( text: string, lints: any[], currentIssueIndex: number = -1, ): string { // Sort lints by position for proper rendering const sortedLints: LintInfo[] = lints .map((lint, index) => ({ lint, index, span: lint.span(), })) .sort((a, b) => a.span.start - b.span.start); let html = ` <div style=" padding: 16px; font-family: monospace; font-size: 14px; line-height: 1.6; white-space: pre-wrap; background-color: white; color: black; overflow-y: auto; height: 100%; ">`; let lastIndex = 0; for (const { lint, index, span } of sortedLints) { // Add text before this issue if (lastIndex < span.start) { html += escapeHtml(text.slice(lastIndex, span.start)); } // Add the issue text with appropriate styling const issueText = text.slice(span.start, span.end); const isCurrentIssue = index === currentIssueIndex; const message = escapeHtml(lint.message()); if (isCurrentIssue) { html += `<span style=" background-color: #fef08a; border-bottom: 2px solid #3b82f6; padding: 0 2px; " title="${message}">${escapeHtml(issueText)}</span>`; } else { html += `<span style=" border-bottom: 2px solid #3b82f6; " title="${message}">${escapeHtml(issueText)}</span>`; } lastIndex = span.end; } // Add remaining text if (lastIndex < text.length) { html += escapeHtml(text.slice(lastIndex)); } html += "</div>"; return html; } /** * Handles the interactive correction process with visual context widget */ async function startCorrectionProcess(lints: any[]): Promise<void> { const corrections: Correction[] = []; let textWidget: any = null; try { // Create the text widget for visual context const { workArea } = await getActiveScreen(); textWidget = await widget(createTextWidget(selectedText, lints), { width: Math.min(800, workArea.width - 100), height: Math.min(600, workArea.height - 100), x: workArea.x + 50, y: workArea.y + 50, alwaysOnTop: false, title: "Grammar Check - Text Context", resizable: true, }); // Process each lint issue for (let i = 0; i < lints.length; i++) { const lint = lints[i]; const span = lint.span(); const problemText = selectedText.slice(span.start, span.end); // Update widget to highlight current issue try { const updatedHtml = createTextWidget(selectedText, lints, i); await textWidget.executeJavaScript(` document.body.innerHTML = \`${updatedHtml.replace(/`/g, "\\`")}\`; `); } catch (widgetError) { console.warn("Failed to update widget:", widgetError.message); } // Build choices for this issue const choices: { name: string; value: string; description?: string }[] = [ { name: "Keep original (no change)", value: "", description: `Keep "${problemText}" as is`, }, ]; // Add suggestions if available if (lint.suggestion_count() > 0) { for (const suggestion of lint.suggestions()) { const action = suggestion.kind() === 1 ? "Remove" : "Replace with"; const replacement = suggestion.get_replacement_text(); choices.push({ name: `${action}: "${replacement}"`, value: replacement, description: lint.message(), }); } } // Show the choice dialog const choice = await arg( { placeholder: `Issue ${i + 1}/${lints.length}: "${problemText}"`, hint: lint.message(), }, choices, ); // If user selected a correction, add it to our list if (choice && choice.trim() !== "") { corrections.push({ start: span.start, end: span.end, replacement: choice, }); } } } catch (error) { console.error("Error during correction process:", error); await div( md(`## ❌ Error\n\nFailed during correction process: ${error.message}`), ); } finally { textWidget.hide(); await applyCorrections(corrections); // Always close the text widget when done if (textWidget) { try { textWidget.close(); } catch (closeError) { console.warn("Failed to close widget:", closeError.message); } } } } /** * Applies the selected corrections to the text */ async function applyCorrections(corrections: Correction[]): Promise<void> { if (corrections.length === 0) { await div(md("## â„šī¸ No Changes\n\nNo corrections were selected.")); return; } // Sort corrections by position (reverse order to maintain indices) corrections.sort((a, b) => b.start - a.start); let correctedText = selectedText; for (const correction of corrections) { correctedText = correctedText.slice(0, correction.start) + correction.replacement + correctedText.slice(correction.end); } const windows = await getWindows(); const selectedWindow = windows.find((w) => w.title === previousWindow); if (selectedWindow) { await focusWindow(selectedWindow.process, selectedWindow.title); await setSelectedText(correctedText); await div( md( `## ✅ Corrections applied!\n\n${corrections.length} correction${corrections.length > 1 ? "s" : ""} applied successfully. \n\n**Corrected Text:**\n\`\`\`\n${correctedText}\n\`\`\``, ), ); } else { // Fallback to clipboard if window focusing fails await clipboard.writeText(correctedText); await div( md( `## ✅ Corrections added to clipboard!\n\n${corrections.length} correction${corrections.length > 1 ? "s" : ""} applied successfully. \n\n**Corrected Text:**\n\`\`\`\n${correctedText}\n\`\`\``, ), ); } }