@novacraft-engineering/mailbox 0.4.25 → 0.4.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/mail/company-dictionary/route.ts +36 -0
- package/app/api/mail/company-signature/route.ts +1 -1
- package/app/api/mail/grammar/route.ts +52 -0
- package/app/mail/RichEditor.tsx +405 -6
- package/app/mail/page.module.css +275 -0
- package/app/mail/page.tsx +95 -4
- package/app/mail/tabKey.ts +6 -2
- package/app/mail/writing/WritingPanel.tsx +259 -0
- package/app/mail/writing/assist.ts +139 -0
- package/app/mail/writing/checks.test.ts +48 -0
- package/app/mail/writing/checks.ts +58 -0
- package/app/mail/writing/find.ts +52 -0
- package/app/mail/writing/grammar.ts +100 -0
- package/app/mail/writing/live.ts +34 -0
- package/app/mail/writing/paste.test.ts +17 -0
- package/app/mail/writing/paste.ts +30 -0
- package/app/mail/writing/settings.ts +98 -0
- package/app/mail/writing/spell.worker.ts +40 -0
- package/app/mail/writing/spelling.ts +164 -0
- package/app/mail/writing/templates.ts +40 -0
- package/app/mail/writing/text.test.ts +88 -0
- package/app/mail/writing/text.ts +106 -0
- package/package.json +3 -1
- package/public/dictionaries/en-GB.LICENSE +347 -0
- package/public/dictionaries/en-GB.aff +205 -0
- package/public/dictionaries/en-GB.dic +49602 -0
- package/public/dictionaries/en-US.LICENSE +347 -0
- package/public/dictionaries/en-US.aff +205 -0
- package/public/dictionaries/en-US.dic +49569 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import styles from '../page.module.css'
|
|
5
|
+
import MailSelect from '../MailSelect'
|
|
6
|
+
import type { WritingSettings } from './settings'
|
|
7
|
+
import { normaliseShortcut, type MailTemplate } from './templates'
|
|
8
|
+
|
|
9
|
+
type Tab = 'writing' | 'checks' | 'templates' | 'dictionary' | 'shortcuts'
|
|
10
|
+
|
|
11
|
+
const SHORTCUTS: Array<[string, string]> = [
|
|
12
|
+
['Tab', 'Indent, or nest a list item; jumps to the next {field} in a template'],
|
|
13
|
+
['Tab Tab (quickly)', 'Leave the message for the next control'],
|
|
14
|
+
['Shift Tab', 'Move a list item back out'],
|
|
15
|
+
['⌘/Ctrl B · I · U', 'Bold · italic · underline'],
|
|
16
|
+
['⌘/Ctrl K', 'Add or edit a link'],
|
|
17
|
+
['⌘/Ctrl Shift 7 · 8', 'Numbered list · bullet list'],
|
|
18
|
+
['⌘/Ctrl Shift V', 'Paste without formatting'],
|
|
19
|
+
['⌘/Ctrl F', 'Find and replace in this message'],
|
|
20
|
+
['⌘/Ctrl Z · Shift Z', 'Undo · redo'],
|
|
21
|
+
['⌘/Ctrl Enter', 'Send'],
|
|
22
|
+
['Backspace', 'Straight after an automatic change, puts back what you typed'],
|
|
23
|
+
[';shortcut then Space', 'Insert a saved template'],
|
|
24
|
+
['- · 1. · > then Space', 'Start a bullet list · numbered list · quote'],
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (next: boolean) => void }) {
|
|
28
|
+
return (
|
|
29
|
+
<label className={styles.wpRow}>
|
|
30
|
+
<span className={styles.wpText}>
|
|
31
|
+
<span className={styles.wpLabel}>{label}</span>
|
|
32
|
+
{hint && <span className={styles.wpHint}>{hint}</span>}
|
|
33
|
+
</span>
|
|
34
|
+
<input type="checkbox" className={styles.wpSwitch} checked={checked} onChange={event => onChange(event.target.checked)} />
|
|
35
|
+
</label>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function WritingPanel({
|
|
40
|
+
settings,
|
|
41
|
+
onChange,
|
|
42
|
+
templates,
|
|
43
|
+
onSaveTemplate,
|
|
44
|
+
onDeleteTemplate,
|
|
45
|
+
onInsertTemplate,
|
|
46
|
+
currentHtml,
|
|
47
|
+
companyWords,
|
|
48
|
+
onCompanyWords,
|
|
49
|
+
isAdmin,
|
|
50
|
+
grammarAvailable,
|
|
51
|
+
onClose,
|
|
52
|
+
}: {
|
|
53
|
+
settings: WritingSettings
|
|
54
|
+
onChange: (next: WritingSettings) => void
|
|
55
|
+
templates: MailTemplate[]
|
|
56
|
+
onSaveTemplate?: (template: MailTemplate) => void
|
|
57
|
+
onDeleteTemplate?: (id: string) => void
|
|
58
|
+
onInsertTemplate: (template: MailTemplate) => void
|
|
59
|
+
currentHtml: string
|
|
60
|
+
companyWords: string[]
|
|
61
|
+
onCompanyWords?: (words: string[]) => void
|
|
62
|
+
isAdmin: boolean
|
|
63
|
+
grammarAvailable: boolean
|
|
64
|
+
onClose: () => void
|
|
65
|
+
}) {
|
|
66
|
+
const [tab, setTab] = useState<Tab>('writing')
|
|
67
|
+
const [templateName, setTemplateName] = useState('')
|
|
68
|
+
const [templateShortcut, setTemplateShortcut] = useState('')
|
|
69
|
+
const [companyDraft, setCompanyDraft] = useState('')
|
|
70
|
+
const set = <K extends keyof WritingSettings>(key: K, value: WritingSettings[K]) => onChange({ ...settings, [key]: value })
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className={styles.wpPanel} role="dialog" aria-label="Writing tools">
|
|
74
|
+
<div className={styles.wpHead}>
|
|
75
|
+
<span className={styles.wpTitle}>Writing tools</span>
|
|
76
|
+
<button type="button" className={styles.wpClose} onClick={onClose} aria-label="Close">×</button>
|
|
77
|
+
</div>
|
|
78
|
+
<div className={styles.wpTabs} role="tablist">
|
|
79
|
+
{(['writing', 'checks', 'templates', 'dictionary', 'shortcuts'] as Tab[]).map(name => (
|
|
80
|
+
<button key={name} type="button" role="tab" aria-selected={tab === name} className={`${styles.wpTab} ${tab === name ? styles.wpTabOn : ''}`} onClick={() => setTab(name)}>
|
|
81
|
+
{name === 'checks' ? 'Before sending' : name[0].toUpperCase() + name.slice(1)}
|
|
82
|
+
</button>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<div className={styles.wpBody}>
|
|
87
|
+
{tab === 'writing' && (
|
|
88
|
+
<>
|
|
89
|
+
<Toggle label="Capitalise new sentences" hint="After . ? ! and at the start of a paragraph" checked={settings.autoCapitalize} onChange={value => set('autoCapitalize', value)} />
|
|
90
|
+
{settings.autoCapitalize && (
|
|
91
|
+
<div className={styles.wpIndent}>
|
|
92
|
+
<Toggle label="Add a missed space" hint="end.Next → end. Next, as you type" checked={settings.spaceAfterStop} onChange={value => set('spaceAfterStop', value)} />
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
<Toggle label='Capital "I"' checked={settings.capitalizeI} onChange={value => set('capitalizeI', value)} />
|
|
96
|
+
<Toggle label="Fix common typos" hint="teh → the, dont → don't" checked={settings.autocorrect} onChange={value => set('autocorrect', value)} />
|
|
97
|
+
<Toggle label="Check spelling" hint="On for new messages; the ✓Aa button turns it off for one" checked={settings.spellcheck} onChange={value => set('spellcheck', value)} />
|
|
98
|
+
{settings.spellcheck && (
|
|
99
|
+
<div className={styles.wpIndent}>
|
|
100
|
+
<label className={styles.wpRow}>
|
|
101
|
+
<span className={styles.wpLabel}>Dictionary</span>
|
|
102
|
+
<MailSelect
|
|
103
|
+
ariaLabel="Dictionary"
|
|
104
|
+
buttonClassName={styles.wpSelect}
|
|
105
|
+
value={settings.spellLanguage}
|
|
106
|
+
options={[
|
|
107
|
+
{ value: 'en-GB', label: 'British English' },
|
|
108
|
+
{ value: 'en-US', label: 'American English' },
|
|
109
|
+
]}
|
|
110
|
+
onChange={value => set('spellLanguage', value === 'en-US' ? 'en-US' : 'en-GB')}
|
|
111
|
+
/>
|
|
112
|
+
</label>
|
|
113
|
+
<Toggle label="Skip words in CAPITALS" hint="Acronyms and company names" checked={settings.ignoreCapitals} onChange={value => set('ignoreCapitals', value)} />
|
|
114
|
+
<Toggle label="Skip words with numbers" hint="Policy and vehicle numbers" checked={settings.ignoreWithNumbers} onChange={value => set('ignoreWithNumbers', value)} />
|
|
115
|
+
<Toggle label="Flag repeated words" hint='"the the"' checked={settings.repeatedWords} onChange={value => set('repeatedWords', value)} />
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
<Toggle
|
|
119
|
+
label="Check grammar"
|
|
120
|
+
hint={grammarAvailable ? 'Checked on this company’s own server' : 'Not set up on this mailbox yet'}
|
|
121
|
+
checked={settings.grammar && grammarAvailable}
|
|
122
|
+
onChange={value => set('grammar', value)}
|
|
123
|
+
/>
|
|
124
|
+
<Toggle label="Tab indents" hint="A quick second Tab leaves the message" checked={settings.tabIndent} onChange={value => set('tabIndent', value)} />
|
|
125
|
+
{settings.tabIndent && (
|
|
126
|
+
<label className={`${styles.wpRow} ${styles.wpIndent}`}>
|
|
127
|
+
<span className={styles.wpText}>
|
|
128
|
+
<span className={styles.wpLabel}>Double-Tab speed</span>
|
|
129
|
+
<span className={styles.wpHint}>{settings.doubleTabMs} ms between presses</span>
|
|
130
|
+
</span>
|
|
131
|
+
<input type="range" min={200} max={1000} step={50} value={settings.doubleTabMs} onChange={event => set('doubleTabMs', Number(event.target.value))} />
|
|
132
|
+
</label>
|
|
133
|
+
)}
|
|
134
|
+
<Toggle label="Template shortcuts" hint="Type ;name then Space" checked={settings.templateShortcuts} onChange={value => set('templateShortcuts', value)} />
|
|
135
|
+
<Toggle label="Clean pasted formatting" hint="Strips Word and Excel styling, keeps tables" checked={settings.cleanPaste} onChange={value => set('cleanPaste', value)} />
|
|
136
|
+
<Toggle label="Format amounts" hint="$2500 → $2,500 · USD 1500000 → USD 1,500,000 · ₦, £, €, GHS, KES…" checked={settings.amountFormat} onChange={value => set('amountFormat', value)} />
|
|
137
|
+
{settings.amountFormat && (
|
|
138
|
+
<div className={styles.wpIndent}>
|
|
139
|
+
<Toggle label="N means naira" hint="N1500000 → ₦1,500,000" checked={settings.nairaLetter} onChange={value => set('nairaLetter', value)} />
|
|
140
|
+
<Toggle label="Plain numbers too" hint="1500000 → 1,500,000 · years, phone and account numbers are left alone" checked={settings.numberFormat} onChange={value => set('numberFormat', value)} />
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
<Toggle label="Word count" checked={settings.wordCount} onChange={value => set('wordCount', value)} />
|
|
144
|
+
</>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{tab === 'checks' && (
|
|
148
|
+
<>
|
|
149
|
+
<p className={styles.wpNote}>Asked when you press Send. You can always send anyway.</p>
|
|
150
|
+
<Toggle label="Missing attachment" hint='"attached" or "enclosed" with nothing attached' checked={settings.checkAttachment} onChange={value => set('checkAttachment', value)} />
|
|
151
|
+
<Toggle label="Nothing written" hint="Only the signature or the quoted message" checked={settings.checkEmptyBody} onChange={value => set('checkEmptyBody', value)} />
|
|
152
|
+
<Toggle label="Unfilled placeholders" hint="[Name], {policy_no}, XXX" checked={settings.checkPlaceholders} onChange={value => set('checkPlaceholders', value)} />
|
|
153
|
+
<Toggle label="Greeting names someone else" hint='"Dear Mr Okafor" to a different person' checked={settings.checkGreeting} onChange={value => set('checkGreeting', value)} />
|
|
154
|
+
<Toggle label="Many outside recipients" checked={settings.checkExternal} onChange={value => set('checkExternal', value)} />
|
|
155
|
+
{settings.checkExternal && (
|
|
156
|
+
<label className={`${styles.wpRow} ${styles.wpIndent}`}>
|
|
157
|
+
<span className={styles.wpLabel}>Warn from</span>
|
|
158
|
+
<span>
|
|
159
|
+
<input type="number" min={1} max={100} className={styles.wpNumber} value={settings.externalThreshold} onChange={event => set('externalThreshold', Math.max(1, Math.min(100, Number(event.target.value) || 1)))} /> people
|
|
160
|
+
</span>
|
|
161
|
+
</label>
|
|
162
|
+
)}
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{tab === 'templates' && (
|
|
167
|
+
<>
|
|
168
|
+
{templates.length === 0 && <p className={styles.wpNote}>No templates yet. Write one below, then save it.</p>}
|
|
169
|
+
{templates.map(template => (
|
|
170
|
+
<div key={template.id} className={styles.wpTemplate}>
|
|
171
|
+
<button type="button" className={styles.wpTemplateName} onClick={() => onInsertTemplate(template)} title="Insert into this message">
|
|
172
|
+
{template.name}
|
|
173
|
+
{template.shortcut && <span className={styles.wpShortcut}>;{normaliseShortcut(template.shortcut)}</span>}
|
|
174
|
+
</button>
|
|
175
|
+
{onDeleteTemplate && (
|
|
176
|
+
<button type="button" className={styles.wpRemove} onClick={() => onDeleteTemplate(template.id)} aria-label={`Delete ${template.name}`}>×</button>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
))}
|
|
180
|
+
{onSaveTemplate && (
|
|
181
|
+
<div className={styles.wpSave}>
|
|
182
|
+
<span className={styles.wpLabel}>Save this message as a template</span>
|
|
183
|
+
<span className={styles.wpHint}>Write {'{first_name}'}, {'{date}'} or any {'{field}'}: known ones fill in, Tab jumps to the rest</span>
|
|
184
|
+
<input className={styles.wpInput} placeholder="Name, e.g. Renewal notice" value={templateName} onChange={event => setTemplateName(event.target.value)} />
|
|
185
|
+
<input className={styles.wpInput} placeholder="Shortcut, e.g. renewal" value={templateShortcut} onChange={event => setTemplateShortcut(event.target.value)} />
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
className={styles.wpButton}
|
|
189
|
+
disabled={!templateName.trim() || !currentHtml.replace(/<[^>]+>/g, '').trim()}
|
|
190
|
+
onClick={() => {
|
|
191
|
+
onSaveTemplate({ id: `t${Date.now().toString(36)}`, name: templateName.trim(), shortcut: normaliseShortcut(templateShortcut), html: currentHtml })
|
|
192
|
+
setTemplateName('')
|
|
193
|
+
setTemplateShortcut('')
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
Save template
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{tab === 'dictionary' && (
|
|
204
|
+
<>
|
|
205
|
+
<span className={styles.wpLabel}>Your words</span>
|
|
206
|
+
<span className={styles.wpHint}>Added from a red underline with “Add to dictionary”</span>
|
|
207
|
+
<div className={styles.wpChips}>
|
|
208
|
+
{settings.personalWords.length === 0 && <span className={styles.wpHint}>None yet</span>}
|
|
209
|
+
{settings.personalWords.map(word => (
|
|
210
|
+
<span key={word} className={styles.wpChip}>
|
|
211
|
+
{word}
|
|
212
|
+
<button type="button" onClick={() => set('personalWords', settings.personalWords.filter(entry => entry !== word))} aria-label={`Remove ${word}`}>×</button>
|
|
213
|
+
</span>
|
|
214
|
+
))}
|
|
215
|
+
</div>
|
|
216
|
+
<span className={styles.wpLabel}>Company words</span>
|
|
217
|
+
<span className={styles.wpHint}>{isAdmin ? 'Everyone in the company gets these' : 'Kept by an administrator'}</span>
|
|
218
|
+
<div className={styles.wpChips}>
|
|
219
|
+
{companyWords.length === 0 && <span className={styles.wpHint}>None yet</span>}
|
|
220
|
+
{companyWords.map(word => (
|
|
221
|
+
<span key={word} className={styles.wpChip}>
|
|
222
|
+
{word}
|
|
223
|
+
{isAdmin && onCompanyWords && (
|
|
224
|
+
<button type="button" onClick={() => onCompanyWords(companyWords.filter(entry => entry !== word))} aria-label={`Remove ${word}`}>×</button>
|
|
225
|
+
)}
|
|
226
|
+
</span>
|
|
227
|
+
))}
|
|
228
|
+
</div>
|
|
229
|
+
{isAdmin && onCompanyWords && (
|
|
230
|
+
<form
|
|
231
|
+
className={styles.wpSave}
|
|
232
|
+
onSubmit={event => {
|
|
233
|
+
event.preventDefault()
|
|
234
|
+
const words = companyDraft.split(/[,\s]+/).map(word => word.trim()).filter(Boolean)
|
|
235
|
+
if (words.length) onCompanyWords([...new Set([...companyWords, ...words])])
|
|
236
|
+
setCompanyDraft('')
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
<input className={styles.wpInput} placeholder="Add words, separated by commas" value={companyDraft} onChange={event => setCompanyDraft(event.target.value)} />
|
|
240
|
+
<button type="submit" className={styles.wpButton}>Add</button>
|
|
241
|
+
</form>
|
|
242
|
+
)}
|
|
243
|
+
</>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{tab === 'shortcuts' && (
|
|
247
|
+
<dl className={styles.wpKeys}>
|
|
248
|
+
{SHORTCUTS.map(([keys, what]) => (
|
|
249
|
+
<div key={keys} className={styles.wpKeyRow}>
|
|
250
|
+
<dt><kbd>{keys}</kbd></dt>
|
|
251
|
+
<dd>{what}</dd>
|
|
252
|
+
</div>
|
|
253
|
+
))}
|
|
254
|
+
</dl>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/react'
|
|
2
|
+
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
|
|
3
|
+
import type { EditorView } from '@tiptap/pm/view'
|
|
4
|
+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
5
|
+
import { correctWord, formatAmount, startsSentence } from './text'
|
|
6
|
+
import { TEMPLATE_FIELD, fillTemplate, normaliseShortcut } from './templates'
|
|
7
|
+
import { live } from './live'
|
|
8
|
+
|
|
9
|
+
type Revert = { doc: ProseMirrorNode; from: number; to: number; original: string }
|
|
10
|
+
|
|
11
|
+
/** What a word after a full stop turns out to be when the stop belonged to an address or a file name. */
|
|
12
|
+
const ADDRESS_ENDINGS = new Set([
|
|
13
|
+
'com', 'ng', 'org', 'net', 'io', 'co', 'uk', 'africa', 'info', 'gov', 'edu', 'biz', 'me', 'app', 'dev', 'us', 'za', 'gh', 'ke',
|
|
14
|
+
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif', 'zip', 'txt', 'html', 'eml', 'msg',
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
const BOUNDARY = /^[\s.,!?;:)\]"'”’]$/
|
|
18
|
+
|
|
19
|
+
function inCode(view: EditorView, pos: number): boolean {
|
|
20
|
+
const $pos = view.state.doc.resolve(pos)
|
|
21
|
+
if ($pos.parent.type.name === 'codeBlock') return true
|
|
22
|
+
return $pos.marks().some(mark => mark.type.name === 'code')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The next {field} after the caret, or the first one in the message. */
|
|
26
|
+
export function nextField(doc: ProseMirrorNode, after: number): { from: number; to: number } | null {
|
|
27
|
+
let first: { from: number; to: number } | null = null
|
|
28
|
+
let next: { from: number; to: number } | null = null
|
|
29
|
+
doc.descendants((node, pos) => {
|
|
30
|
+
if (next || !node.isText || !node.text) return !next
|
|
31
|
+
for (const match of node.text.matchAll(TEMPLATE_FIELD)) {
|
|
32
|
+
const range = { from: pos + (match.index ?? 0), to: pos + (match.index ?? 0) + match[0].length }
|
|
33
|
+
if (!first) first = range
|
|
34
|
+
if (range.from >= after && !next) next = range
|
|
35
|
+
}
|
|
36
|
+
return !next
|
|
37
|
+
})
|
|
38
|
+
return next ?? first
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The small corrections made while typing: a capital to start a sentence, "I" on its own,
|
|
43
|
+
* common typos, naira amounts, and template shortcuts. Each is one Backspace from undone.
|
|
44
|
+
*/
|
|
45
|
+
export const WritingAssist = Extension.create<Record<string, never>, { revert: Revert | null; spaced: number | null }>({
|
|
46
|
+
name: 'writingAssist',
|
|
47
|
+
addStorage() {
|
|
48
|
+
return { revert: null, spaced: null }
|
|
49
|
+
},
|
|
50
|
+
addKeyboardShortcuts() {
|
|
51
|
+
return {
|
|
52
|
+
Backspace: () => {
|
|
53
|
+
const revert = this.storage.revert
|
|
54
|
+
this.storage.revert = null
|
|
55
|
+
if (!revert || revert.doc !== this.editor.state.doc) return false
|
|
56
|
+
this.editor.view.dispatch(this.editor.state.tr.insertText(revert.original, revert.from, revert.to))
|
|
57
|
+
return true
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
addProseMirrorPlugins() {
|
|
62
|
+
const storage = this.storage
|
|
63
|
+
const editor = this.editor
|
|
64
|
+
const remember = (view: EditorView, from: number, to: number, original: string) => {
|
|
65
|
+
storage.revert = { doc: view.state.doc, from, to, original }
|
|
66
|
+
}
|
|
67
|
+
return [
|
|
68
|
+
new Plugin({
|
|
69
|
+
key: new PluginKey('writingAssist'),
|
|
70
|
+
props: {
|
|
71
|
+
handleTextInput: (view, from, to, text) => {
|
|
72
|
+
storage.revert = null
|
|
73
|
+
if (from !== to || text.length !== 1 || inCode(view, from)) return false
|
|
74
|
+
const state = live(editor)
|
|
75
|
+
if (!state.tools) return false
|
|
76
|
+
const settings = state.settings
|
|
77
|
+
const $from = view.state.doc.resolve(from)
|
|
78
|
+
const before = $from.parent.textBetween(0, $from.parentOffset, undefined, '')
|
|
79
|
+
|
|
80
|
+
// A letter straight after the stop: the space was missed, so it goes in with the capital.
|
|
81
|
+
if (settings.autoCapitalize && settings.spaceAfterStop && /[A-Za-z]/.test(text) && /[A-Za-z]{2}[.!?]$/.test(before) && startsSentence(`${before} `)) {
|
|
82
|
+
view.dispatch(view.state.tr.insertText(` ${text.toUpperCase()}`, from, to))
|
|
83
|
+
remember(view, from, from + 2, text)
|
|
84
|
+
storage.spaced = before.endsWith('.') ? from : null
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (settings.autoCapitalize && /[a-z]/.test(text) && startsSentence(before)) {
|
|
89
|
+
view.dispatch(view.state.tr.insertText(text.toUpperCase(), from, to))
|
|
90
|
+
remember(view, from, from + 1, text)
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!BOUNDARY.test(text)) return false
|
|
95
|
+
const word = before.match(/([^\s]+)$/)?.[1]
|
|
96
|
+
const spaced = storage.spaced
|
|
97
|
+
storage.spaced = null
|
|
98
|
+
if (!word) return false
|
|
99
|
+
const start = from - word.length
|
|
100
|
+
|
|
101
|
+
// "metroperil. Com" was an address after all: take the space and the capital back out.
|
|
102
|
+
if (spaced !== null && start === spaced + 1 && ADDRESS_ENDINGS.has(word.toLowerCase())) {
|
|
103
|
+
view.dispatch(view.state.tr.insertText(word.toLowerCase() + text, spaced, to))
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (settings.templateShortcuts && word.startsWith(';') && text === ' ') {
|
|
108
|
+
const shortcut = normaliseShortcut(word)
|
|
109
|
+
const template = state.templates.find(entry => normaliseShortcut(entry.shortcut) === shortcut)
|
|
110
|
+
if (template) {
|
|
111
|
+
editor.chain().focus().deleteRange({ from: start, to }).insertContent(fillTemplate(template.html, state.context)).run()
|
|
112
|
+
const field = nextField(editor.state.doc, start)
|
|
113
|
+
if (field) editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, field.from, field.to)))
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const bare = word.replace(/^["'“‘(\[]+/, '')
|
|
119
|
+
const offset = word.length - bare.length
|
|
120
|
+
let replacement: string | null = null
|
|
121
|
+
if (settings.capitalizeI && bare === 'i') replacement = 'I'
|
|
122
|
+
else if (settings.autocorrect) replacement = correctWord(bare)
|
|
123
|
+
if (!replacement && settings.amountFormat) {
|
|
124
|
+
const previous = before.slice(0, start).match(/(\S+)\s+$/)?.[1] ?? ''
|
|
125
|
+
replacement = formatAmount(bare, { nairaLetter: settings.nairaLetter, plainNumbers: settings.numberFormat }, previous)
|
|
126
|
+
}
|
|
127
|
+
if (!replacement) return false
|
|
128
|
+
|
|
129
|
+
const wordFrom = start + offset
|
|
130
|
+
const tr = view.state.tr.insertText(replacement + text, wordFrom, to)
|
|
131
|
+
view.dispatch(tr)
|
|
132
|
+
remember(view, wordFrom, wordFrom + replacement.length, bare)
|
|
133
|
+
return true
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { sendIssues } from './checks.ts'
|
|
4
|
+
import { WRITING_DEFAULTS } from './settings.ts'
|
|
5
|
+
|
|
6
|
+
const base: { recipients: Array<{ email: string; name?: string }>; attachmentCount: number; ownDomains: string[] } = {
|
|
7
|
+
recipients: [{ email: 'eric.samuel@coronation.ng', name: 'Eric Samuel' }],
|
|
8
|
+
attachmentCount: 0,
|
|
9
|
+
ownDomains: ['metroperilinsbrokers.com'],
|
|
10
|
+
}
|
|
11
|
+
const kinds = (body: string, extra: Partial<typeof base> = {}, settings = WRITING_DEFAULTS) =>
|
|
12
|
+
sendIssues({ ...base, ...extra, body }, settings).map(issue => issue.kind)
|
|
13
|
+
|
|
14
|
+
test('a promised attachment with none attached is caught', () => {
|
|
15
|
+
assert.deepEqual(kinds('Dear Eric,\nPlease find attached the schedule.'), ['attachment'])
|
|
16
|
+
assert.deepEqual(kinds('Dear Eric,\nPlease find attached the schedule.', { attachmentCount: 1 }), [])
|
|
17
|
+
assert.deepEqual(kinds('Dear Eric,\nThe documents are enclosed.'), ['attachment'])
|
|
18
|
+
assert.deepEqual(kinds('Dear Eric,\nPlease find below the details.'), [])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('empty body and leftover placeholders', () => {
|
|
22
|
+
assert.deepEqual(kinds(' '), ['empty'])
|
|
23
|
+
assert.deepEqual(kinds('Dear {first_name}, your renewal is due.'), ['placeholder'])
|
|
24
|
+
assert.deepEqual(kinds('Dear Eric, premium is N[amount].'), ['placeholder'])
|
|
25
|
+
assert.deepEqual(kinds('Dear Eric, see [cid:image001.png] below.'), [], 'an inline image marker is not a placeholder')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('the greeting has to match a recipient', () => {
|
|
29
|
+
assert.deepEqual(kinds('Dear Mr. Okafor,\nThanks.'), ['greeting'])
|
|
30
|
+
assert.deepEqual(kinds('Dear Mr. Samuel,\nThanks.'), [])
|
|
31
|
+
assert.deepEqual(kinds('Hi Eric,\nThanks.'), [])
|
|
32
|
+
assert.deepEqual(kinds('Dear Sir,\nThanks.'), [])
|
|
33
|
+
assert.deepEqual(kinds('Good morning all,\nThanks.'), [])
|
|
34
|
+
assert.deepEqual(kinds('Hello, the schedule is below.'), [], 'an ordinary word after the greeting is not a name')
|
|
35
|
+
assert.deepEqual(kinds('Hi there, thanks.'), [])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('many outside recipients are flagged, colleagues are not counted', () => {
|
|
39
|
+
const outsiders = Array.from({ length: 5 }, (_, index) => ({ email: `p${index}@insurer.com` }))
|
|
40
|
+
assert.deepEqual(kinds('Hello all, thanks.', { recipients: outsiders }), ['external'])
|
|
41
|
+
const colleagues = outsiders.map(entry => ({ email: entry.email.replace('insurer.com', 'metroperilinsbrokers.com') }))
|
|
42
|
+
assert.deepEqual(kinds('Hello all, thanks.', { recipients: colleagues }), [])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('every check can be switched off', () => {
|
|
46
|
+
const off = { ...WRITING_DEFAULTS, checkAttachment: false, checkEmptyBody: false, checkPlaceholders: false, checkGreeting: false, checkExternal: false }
|
|
47
|
+
assert.deepEqual(kinds('Dear {x} Okafor, attached', {}, off), [])
|
|
48
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { WritingSettings } from './settings'
|
|
2
|
+
|
|
3
|
+
export type SendCheckInput = {
|
|
4
|
+
/** What the sender wrote: no signature, no quoted message. */
|
|
5
|
+
body: string
|
|
6
|
+
recipients: Array<{ email: string; name?: string | null }>
|
|
7
|
+
attachmentCount: number
|
|
8
|
+
ownDomains: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type SendIssue = { kind: 'attachment' | 'empty' | 'placeholder' | 'greeting' | 'external'; message: string }
|
|
12
|
+
|
|
13
|
+
const PROMISES_ATTACHMENT =
|
|
14
|
+
/\b(attached|attachments?|enclosed|enclosing|herewith|I(?:'ve| have) (?:also )?(?:included|added) the (?:file|document|schedule|form|quote|policy|invoice))\b/i
|
|
15
|
+
const PLACEHOLDER = /\[(?![Cc]id:)[^\]\n]{1,40}\]|\{[A-Za-z_ ]{1,30}\}|<<[^>\n]{1,40}>>|\bX{3,}\b|\bTBD\b|\bTODO\b/
|
|
16
|
+
const GREETING = /^\s*(?:dear|hi|hello|hey|good (?:morning|afternoon|evening))[ ,]+(?:(?:mr|mrs|ms|miss|dr|prof|engr|chief|sir|madam|barr|alh(?:aji)?|hajia)\.?\s+)?([A-Za-z][A-Za-z'-]{1,30})/i
|
|
17
|
+
const GENERIC = new Set(['sir', 'madam', 'sirs', 'all', 'team', 'everyone', 'colleague', 'colleagues', 'gentlemen', 'there', 'friend', 'client', 'customer', 'valued', 'concerned', 'whom', 'both', 'you'])
|
|
18
|
+
|
|
19
|
+
const domainOf = (email: string) => email.trim().toLowerCase().split('@')[1] ?? ''
|
|
20
|
+
|
|
21
|
+
/** What looks wrong with a message before it goes, each on its own switch. */
|
|
22
|
+
export function sendIssues(input: SendCheckInput, settings: WritingSettings): SendIssue[] {
|
|
23
|
+
const issues: SendIssue[] = []
|
|
24
|
+
const body = input.body.trim()
|
|
25
|
+
|
|
26
|
+
if (settings.checkEmptyBody && !body) {
|
|
27
|
+
issues.push({ kind: 'empty', message: 'The message has no text of your own — only the signature or the quoted message.' })
|
|
28
|
+
}
|
|
29
|
+
if (settings.checkAttachment && input.attachmentCount === 0) {
|
|
30
|
+
const promise = body.match(PROMISES_ATTACHMENT)
|
|
31
|
+
if (promise) issues.push({ kind: 'attachment', message: `It says “${promise[0]}”, but nothing is attached.` })
|
|
32
|
+
}
|
|
33
|
+
if (settings.checkPlaceholders) {
|
|
34
|
+
const left = body.match(PLACEHOLDER)
|
|
35
|
+
if (left) issues.push({ kind: 'placeholder', message: `“${left[0]}” looks like a placeholder that was never filled in.` })
|
|
36
|
+
}
|
|
37
|
+
if (settings.checkGreeting) {
|
|
38
|
+
const greeted = body.split('\n').find(line => line.trim())?.match(GREETING)?.[1]
|
|
39
|
+
if (greeted && /^[A-Z]/.test(greeted) && !GENERIC.has(greeted.toLowerCase())) {
|
|
40
|
+
const target = greeted.toLowerCase()
|
|
41
|
+
const matches = input.recipients.some(recipient => {
|
|
42
|
+
const haystack = `${recipient.name ?? ''} ${recipient.email.split('@')[0]}`.toLowerCase()
|
|
43
|
+
return haystack.includes(target) || (target.length >= 4 && haystack.includes(target.slice(0, 4)))
|
|
44
|
+
})
|
|
45
|
+
if (!matches && input.recipients.length) {
|
|
46
|
+
issues.push({ kind: 'greeting', message: `The greeting names “${greeted}”, but no recipient looks like ${greeted}.` })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (settings.checkExternal) {
|
|
51
|
+
const own = new Set(input.ownDomains.map(domain => domain.toLowerCase()))
|
|
52
|
+
const outside = input.recipients.filter(recipient => !own.has(domainOf(recipient.email)))
|
|
53
|
+
if (outside.length >= settings.externalThreshold) {
|
|
54
|
+
issues.push({ kind: 'external', message: `It goes to ${outside.length} people outside the company.` })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return issues
|
|
58
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/react'
|
|
2
|
+
import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state'
|
|
3
|
+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
4
|
+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
5
|
+
|
|
6
|
+
export type FindState = { query: string; index: number }
|
|
7
|
+
export const findKey = new PluginKey<FindState>('find')
|
|
8
|
+
|
|
9
|
+
export function findMatches(doc: ProseMirrorNode, query: string): Array<{ from: number; to: number }> {
|
|
10
|
+
const needle = query.toLowerCase()
|
|
11
|
+
if (!needle) return []
|
|
12
|
+
const out: Array<{ from: number; to: number }> = []
|
|
13
|
+
doc.descendants((node, pos) => {
|
|
14
|
+
if (!node.isText || !node.text) return true
|
|
15
|
+
const haystack = node.text.toLowerCase()
|
|
16
|
+
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) {
|
|
17
|
+
out.push({ from: pos + at, to: pos + at + needle.length })
|
|
18
|
+
}
|
|
19
|
+
return false
|
|
20
|
+
})
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const findState = (state: EditorState): FindState => findKey.getState(state) ?? { query: '', index: 0 }
|
|
25
|
+
|
|
26
|
+
/** Highlights every match of the find bar's query, the current one more strongly. */
|
|
27
|
+
export const FindReplace = Extension.create({
|
|
28
|
+
name: 'findReplace',
|
|
29
|
+
addProseMirrorPlugins() {
|
|
30
|
+
return [
|
|
31
|
+
new Plugin<FindState>({
|
|
32
|
+
key: findKey,
|
|
33
|
+
state: {
|
|
34
|
+
init: () => ({ query: '', index: 0 }),
|
|
35
|
+
apply: (tr, value) => (tr.getMeta(findKey) as FindState | undefined) ?? value,
|
|
36
|
+
},
|
|
37
|
+
props: {
|
|
38
|
+
decorations: state => {
|
|
39
|
+
const { query, index } = findState(state)
|
|
40
|
+
const matches = findMatches(state.doc, query)
|
|
41
|
+
if (!matches.length) return DecorationSet.empty
|
|
42
|
+
const current = ((index % matches.length) + matches.length) % matches.length
|
|
43
|
+
return DecorationSet.create(
|
|
44
|
+
state.doc,
|
|
45
|
+
matches.map((match, position) => Decoration.inline(match.from, match.to, { class: position === current ? 'rteFindCurrent' : 'rteFind' })),
|
|
46
|
+
)
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/react'
|
|
2
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
3
|
+
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'
|
|
4
|
+
import { live } from './live'
|
|
5
|
+
|
|
6
|
+
type GrammarState = { set: DecorationSet; version: number }
|
|
7
|
+
export const grammarKey = new PluginKey<GrammarState>('grammar')
|
|
8
|
+
|
|
9
|
+
type Match = { offset: number; length: number; message: string; replacements: string[] }
|
|
10
|
+
|
|
11
|
+
/** Blue underlines from the company's own grammar server, fetched when typing pauses. */
|
|
12
|
+
export const grammarIgnoreKey = (text: string, message: string) => `${text.toLowerCase()}|${message}`
|
|
13
|
+
|
|
14
|
+
export const Grammar = Extension.create<Record<string, never>, { ignored: Set<string> }>({
|
|
15
|
+
name: 'grammar',
|
|
16
|
+
addStorage() {
|
|
17
|
+
return { ignored: new Set<string>() }
|
|
18
|
+
},
|
|
19
|
+
addProseMirrorPlugins() {
|
|
20
|
+
const editor = this.editor
|
|
21
|
+
const ignored = this.storage.ignored
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
23
|
+
let asked = 0
|
|
24
|
+
|
|
25
|
+
const run = async (view: EditorView) => {
|
|
26
|
+
const current = live(editor)
|
|
27
|
+
if (!current.tools || !current.grammarOn) {
|
|
28
|
+
view.dispatch(view.state.tr.setMeta(grammarKey, DecorationSet.empty))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const blocks: Array<{ start: number; text: string; at: number }> = []
|
|
32
|
+
let text = ''
|
|
33
|
+
view.state.doc.descendants((node, pos) => {
|
|
34
|
+
if (!node.isTextblock) return true
|
|
35
|
+
if (node.type.name === 'codeBlock') return false
|
|
36
|
+
const content = node.textBetween(0, node.content.size, undefined, '')
|
|
37
|
+
if (content.trim()) {
|
|
38
|
+
blocks.push({ start: pos + 1, text: content, at: text.length })
|
|
39
|
+
text += `${content}\n\n`
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
})
|
|
43
|
+
if (!text.trim()) return view.dispatch(view.state.tr.setMeta(grammarKey, DecorationSet.empty))
|
|
44
|
+
const ticket = ++asked
|
|
45
|
+
const doc = view.state.doc
|
|
46
|
+
const response = await fetch('/api/mail/grammar', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { ...Object.fromEntries(new Headers(current.headers()).entries()), 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ text: text.slice(0, 20000), language: current.settings.spellLanguage }),
|
|
50
|
+
}).catch(() => null)
|
|
51
|
+
const data = response?.ok ? ((await response.json().catch(() => null)) as { matches?: Match[] } | null) : null
|
|
52
|
+
if (ticket !== asked || view.isDestroyed || !view.state.doc.eq(doc)) return
|
|
53
|
+
const decorations = (data?.matches ?? []).flatMap(match => {
|
|
54
|
+
const block = blocks.find(entry => match.offset >= entry.at && match.offset + match.length <= entry.at + entry.text.length)
|
|
55
|
+
if (!block) return []
|
|
56
|
+
const from = block.start + (match.offset - block.at)
|
|
57
|
+
if (ignored.has(grammarIgnoreKey(view.state.doc.textBetween(from, from + match.length), match.message))) return []
|
|
58
|
+
return [
|
|
59
|
+
Decoration.inline(from, from + match.length, {
|
|
60
|
+
class: 'rteGrammar',
|
|
61
|
+
'data-kind': 'grammar',
|
|
62
|
+
'data-message': match.message,
|
|
63
|
+
'data-replacements': JSON.stringify(match.replacements.slice(0, 5)),
|
|
64
|
+
}),
|
|
65
|
+
]
|
|
66
|
+
})
|
|
67
|
+
view.dispatch(view.state.tr.setMeta(grammarKey, DecorationSet.create(view.state.doc, decorations)))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [
|
|
71
|
+
new Plugin<GrammarState>({
|
|
72
|
+
key: grammarKey,
|
|
73
|
+
state: {
|
|
74
|
+
init: () => ({ set: DecorationSet.empty, version: 0 }),
|
|
75
|
+
apply(tr, value) {
|
|
76
|
+
const meta = tr.getMeta(grammarKey) as DecorationSet | 'refresh' | undefined
|
|
77
|
+
if (meta instanceof DecorationSet) return { set: meta, version: value.version }
|
|
78
|
+
if (meta === 'refresh') return { set: value.set, version: value.version + 1 }
|
|
79
|
+
return { set: value.set.map(tr.mapping, tr.doc), version: value.version }
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
props: { decorations: state => grammarKey.getState(state)?.set },
|
|
83
|
+
view: view => {
|
|
84
|
+
timer = setTimeout(() => void run(view), 800)
|
|
85
|
+
return {
|
|
86
|
+
update: (next, previous) => {
|
|
87
|
+
const refreshed = grammarKey.getState(next.state)?.version !== grammarKey.getState(previous)?.version
|
|
88
|
+
if (!refreshed && next.state.doc.eq(previous.doc)) return
|
|
89
|
+
if (timer) clearTimeout(timer)
|
|
90
|
+
timer = setTimeout(() => void run(next), refreshed ? 0 : 1500)
|
|
91
|
+
},
|
|
92
|
+
destroy: () => {
|
|
93
|
+
if (timer) clearTimeout(timer)
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
})
|