193 lines
8.2 KiB
Python
193 lines
8.2 KiB
Python
"""Typo check for the slide text, using macOS's own spelling checker (the one TextEdit uses).
|
|||
|
|
|
||
|
|
A word is fixed only when macOS has a confident correction for it (the same one it would
|
||
|
|
auto-correct while typing). Names it doesn't know (Rahab, Chniouli) have none, so they are
|
||
|
|
left alone. ALL-CAPS emphasis words are checked too (macOS normally skips those), and
|
||
|
|
doubled small words ("in in") are fixed. Every fix is reported. To undo one, the word goes
|
||
|
|
into allowed-words.txt (never touched again) and the check is simply run again on the
|
||
|
|
text from before the fixes.
|
||
|
|
"""
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import subprocess
|
||
|
|
import tempfile
|
||
|
|
|
||
|
|
ALLOWED = os.path.expanduser("~/Library/Application Support/MessageNotes/allowed-words.txt")
|
||
|
|
# Greek/Hebrew words preachers use, which a dictionary would "correct" (dunamis -> dynamos)
|
||
|
|
SERMON_WORDS = {"dunamis", "agape", "phileo", "storge", "eros", "logos", "rhema", "koinonia", "ekklesia",
|
||
|
|
"metanoia", "charis", "pneuma", "sozo", "soteria", "kairos", "chronos", "shalom", "hesed",
|
||
|
|
"chesed", "ruach", "nephesh", "torah", "elohim", "adonai", "yahweh", "jehovah", "selah",
|
||
|
|
"hallelujah", "hosanna", "maranatha", "abba", "parakletos", "paraclete", "ekballo",
|
||
|
|
"anankazo", "anankaz\u014d", "dokimos", "teleios", "makarios", "pistis", "zoe", "bios",
|
||
|
|
"psuche", "sarx", "kosmos", "basileia", "euangelion", "apostolos", "diakonos", "doulos",
|
||
|
|
"proskuneo", "latreia", "hagios", "kardia", "nous", "dikaiosune", "hamartia", "aletheia",
|
||
|
|
"emunah", "tikvah", "shekinah", "kavod", "qadosh", "berakah", "mishpat", "tzedakah",
|
||
|
|
"nachash", "satan", "gehenna", "hades", "sheol", "abaddon", "nephilim", "seraphim", "cherubim"}
|
||
|
|
DOUBLE_OK_TO_FIX = {"the", "a", "an", "and", "to", "of", "in", "is", "it", "on", "for", "with", "be",
|
||
|
|
"we", "you", "i", "my", "his", "he", "god", "that"}
|
||
|
|
|
||
|
|
JXA = r"""
|
||
|
|
ObjC.import('AppKit'); ObjC.import('Foundation');
|
||
|
|
function run(argv) {
|
||
|
|
var data = $.NSData.dataWithContentsOfFile(argv[0]);
|
||
|
|
var items = JSON.parse($.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding).js);
|
||
|
|
var sc = $.NSSpellChecker.sharedSpellChecker;
|
||
|
|
return JSON.stringify(items.map(function (s) {
|
||
|
|
// macOS skips ALL-CAPS words; check emphasis words like OBEDIENCE in lower case instead
|
||
|
|
var t = s.replace(/[A-Z][A-Z'\u2019]{3,}/g, function (w) { return w.toLowerCase(); });
|
||
|
|
var str = $(t), flagged = {}, start = 0, guard = 0;
|
||
|
|
while (guard++ < 200) { // words macOS doesn't know
|
||
|
|
var r = sc.checkSpellingOfStringStartingAt(str, start);
|
||
|
|
var loc = Number(r.location), len = Number(r.length);
|
||
|
|
if (!len || loc < start || loc >= t.length) break; // nothing more (or wrapped around)
|
||
|
|
flagged[loc] = len;
|
||
|
|
start = loc + len;
|
||
|
|
}
|
||
|
|
// every word: macOS's own autocorrection (catches "teh", "dont" that the dictionary accepts)
|
||
|
|
var out = [], re = /[A-Za-z\u00c0-\u024f]+(?:['\u2019][A-Za-z]+)?/g, m;
|
||
|
|
while ((m = re.exec(t)) !== null) {
|
||
|
|
var l = m.index, n = m[0].length;
|
||
|
|
var c = sc.correctionForWordRangeInStringLanguageInSpellDocumentWithTag($.NSMakeRange(l, n), str, "en", 0);
|
||
|
|
var corr = (!c || c.isNil()) ? null : c.js;
|
||
|
|
if (corr || flagged[l] === n) out.push({loc: l, len: n, corr: corr, flagged: flagged[l] === n});
|
||
|
|
delete flagged[l];
|
||
|
|
}
|
||
|
|
for (var k in flagged) out.push({loc: Number(k), len: flagged[k], corr: null, flagged: true});
|
||
|
|
return out;
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
class Unavailable(Exception):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def _run_jxa(texts):
|
||
|
|
tmp = tempfile.mkdtemp(prefix="msgnotes-")
|
||
|
|
try:
|
||
|
|
src, inp = os.path.join(tmp, "s.js"), os.path.join(tmp, "in.json")
|
||
|
|
with open(src, "w") as f:
|
||
|
|
f.write(JXA)
|
||
|
|
with open(inp, "w") as f:
|
||
|
|
json.dump(texts, f)
|
||
|
|
p = subprocess.run(["/usr/bin/osascript", "-l", "JavaScript", src, inp],
|
||
|
|
capture_output=True, text=True, timeout=120)
|
||
|
|
if p.returncode != 0:
|
||
|
|
raise Unavailable(p.stderr.strip()[-300:])
|
||
|
|
out = json.loads(p.stdout)
|
||
|
|
if len(out) != len(texts):
|
||
|
|
raise Unavailable("unexpected answer from the spelling checker")
|
||
|
|
return out
|
||
|
|
except (OSError, ValueError, subprocess.TimeoutExpired) as e:
|
||
|
|
raise Unavailable(str(e))
|
||
|
|
finally:
|
||
|
|
for fn in ("s.js", "in.json"):
|
||
|
|
try:
|
||
|
|
os.remove(os.path.join(tmp, fn))
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
try:
|
||
|
|
os.rmdir(tmp)
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def allowed_words():
|
||
|
|
try:
|
||
|
|
with open(ALLOWED, encoding="utf-8") as f:
|
||
|
|
return {w.strip().lower() for w in f if w.strip()}
|
||
|
|
except OSError:
|
||
|
|
return set()
|
||
|
|
|
||
|
|
|
||
|
|
def allow(words):
|
||
|
|
os.makedirs(os.path.dirname(ALLOWED), exist_ok=True)
|
||
|
|
have = allowed_words()
|
||
|
|
with open(ALLOWED, "a", encoding="utf-8") as f:
|
||
|
|
for w in words:
|
||
|
|
if w.lower() not in have:
|
||
|
|
f.write(w + "\n")
|
||
|
|
have.add(w.lower())
|
||
|
|
|
||
|
|
|
||
|
|
class Fix:
|
||
|
|
"""One change in one text segment: segment text before -> after."""
|
||
|
|
__slots__ = ("slide", "line", "seg", "word", "new", "kind")
|
||
|
|
|
||
|
|
def __init__(self, slide, line, seg, word, new, kind):
|
||
|
|
self.slide, self.line, self.seg, self.word, self.new, self.kind = slide, line, seg, word, new, kind
|
||
|
|
|
||
|
|
|
||
|
|
class Report:
|
||
|
|
def __init__(self):
|
||
|
|
self.fixes = [] # applied
|
||
|
|
self.unknown = [] # (slide, word) - not in the dictionary, no confident correction
|
||
|
|
|
||
|
|
|
||
|
|
def match_case(word, corr):
|
||
|
|
if word.isupper() and len(word) > 1:
|
||
|
|
return corr.upper()
|
||
|
|
if word[:1].isupper():
|
||
|
|
return corr[:1].upper() + corr[1:]
|
||
|
|
return corr
|
||
|
|
|
||
|
|
|
||
|
|
def _segments(plan):
|
||
|
|
"""Every checkable text segment: (slide, line index, seg index, text)."""
|
||
|
|
for s in plan.slides:
|
||
|
|
if s.kind in ("picture", "clearpicture"):
|
||
|
|
continue
|
||
|
|
for li, ln in enumerate(s.lines):
|
||
|
|
for si, seg in enumerate(ln.segs):
|
||
|
|
if seg[0].strip():
|
||
|
|
yield s, li, si, seg[0]
|
||
|
|
|
||
|
|
|
||
|
|
def _starts_sentence(text, loc):
|
||
|
|
before = text[:loc].rstrip()
|
||
|
|
return not before or before[-1] in ".!?:\u201c\"(" or before.endswith("\u2014")
|
||
|
|
|
||
|
|
|
||
|
|
def check_and_fix(plan, skip=None):
|
||
|
|
"""Fix confident typos in the plan's slide text. Returns a Report.
|
||
|
|
skip: words (lowercase) never to change, on top of allowed-words.txt."""
|
||
|
|
rep = Report()
|
||
|
|
segs = list(_segments(plan))
|
||
|
|
if not segs:
|
||
|
|
return rep
|
||
|
|
results = _run_jxa([t for _, _, _, t in segs])
|
||
|
|
ok = allowed_words() | set(skip or ()) | SERMON_WORDS
|
||
|
|
for (s, li, si, text), found in zip(segs, results):
|
||
|
|
edits = [] # (start, end, replacement, word)
|
||
|
|
for f in found:
|
||
|
|
loc, ln = f["loc"], f["len"]
|
||
|
|
word = text[loc:loc + ln]
|
||
|
|
if not word or word.lower() in ok or any(ch.isdigit() for ch in word):
|
||
|
|
continue
|
||
|
|
corr = f["corr"]
|
||
|
|
name_like = word[:1].isupper() and not word.isupper() and not _starts_sentence(text, loc)
|
||
|
|
# "lampstand" -> "lamp stand", "firstfruits" -> "first fruits": Bible words, not typos
|
||
|
|
splits = corr and (" " in corr or "-" in corr) and not (" " in word or "-" in word)
|
||
|
|
if corr and not name_like and not splits and corr.lower() != word.lower():
|
||
|
|
edits.append((loc, loc + ln, match_case(word, corr), word))
|
||
|
|
elif not corr and word[:1].islower():
|
||
|
|
rep.unknown.append((s, word))
|
||
|
|
# doubled words: "in in" -> "in"
|
||
|
|
for m in re.finditer(r"\b(\w+)(\s+)(\1)\b", text, re.I):
|
||
|
|
w = m.group(1)
|
||
|
|
if w.lower() in DOUBLE_OK_TO_FIX and m.group(1) == m.group(3) and (w + " " + w).lower() not in ok:
|
||
|
|
edits.append((m.start(2), m.end(3), "", w + " " + w))
|
||
|
|
if not edits:
|
||
|
|
continue
|
||
|
|
new = text
|
||
|
|
for a, b, repl, word in sorted(edits, reverse=True):
|
||
|
|
new = new[:a] + repl + new[b:]
|
||
|
|
for a, b, repl, word in sorted(edits):
|
||
|
|
kind = "double" if repl == "" else "spelling"
|
||
|
|
rep.fixes.append(Fix(s, li, si, word, repl if repl else word.split(" ")[0], kind))
|
||
|
|
seg = s.lines[li].segs[si]
|
||
|
|
s.lines[li].segs[si] = (new,) + tuple(seg[1:])
|
||
|
|
return rep
|