"""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