diff --git a/README.md b/README.md index f16b987..0464d0b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,16 @@ split over two slides, text that is too long somewhere, picture slides, lines le 3. Opens the YouTube title and description in TextEdit, ready to paste into Restream. They're saved as `…/MessageNotes/youtube-.txt`. -Keys at the summary: `e` edit the slides in TextEdit · `t` `s` `n` `d` `f` change template / +**Typo check**: the summary's **Spelling** line says "no typos found" or how many were fixed, and +each fix is listed ("Slide 31: fixed “OBEDIANCE” → “OBEDIENCE”"). It uses macOS's own spelling +checker and autocorrect, including ALL-CAPS emphasis words and doubled words ("the the"). +- Names it doesn't know (Rahab, Chniouli) are left alone. +- Greek/Hebrew sermon words (dunamis, agape…) and Bible compounds (lampstand, firstfruits) are + never changed. +- `r` undoes all fixes or just the ones you pick. An undone word goes into + `…/MessageNotes/allowed-words.txt` and is never changed again. + +Keys at the summary: `r` undo typo fixes · `e` edit the slides in TextEdit · `t` `s` `n` `d` `f` change template / speaker / name / date / notes file · `v` view the description · `y` setup · `q` quit. **One-time setup (`y`)**, done at the ProPresenter Mac: @@ -85,6 +94,7 @@ from the rest of their box keep bold/italic. - `plantext.py` is the TextEdit format - `playlist.py` + `ppctl.py` handle the playlist link and the safe ProPresenter restart - `youtube.py` writes the YouTube text (Claude API over HTTPS); `secrets_store.py` keeps the key in the Keychain + - `spelling.py` is the typo check (macOS spelling checker via JXA) - `docxnum.py` keeps Word list numbers - `app.py` is the Terminal app - `apps-script/SaveSermonNotes.gs` is the Google Apps Script for email pickup. diff --git a/src/app.py b/src/app.py index ce5590e..0327d4c 100644 --- a/src/app.py +++ b/src/app.py @@ -6,6 +6,7 @@ Message section, and (when set up) updates the stream title and description. Sho short summary first; nothing is written until Enter. """ import argparse +import copy import datetime import glob import json @@ -27,6 +28,7 @@ import notes as notesmod import plantext import playlist as playlist_mod import ppctl +import spelling import themes import youtube @@ -557,6 +559,58 @@ def load_notes(st, path): st.title = parts["title"] or os.path.splitext(os.path.basename(path))[0] st.parts = parts st.email = email_text(path) + st.need_spell = True + + +def run_spelling(st): + """Fix typos in st.plan; keep a copy from before the fixes so they can be undone.""" + st.base_plan = copy.deepcopy(st.plan) + st.need_spell = False + try: + st.typos, st.spell_warn = spelling.check_and_fix(st.plan), None + except spelling.Unavailable as e: + st.typos, st.spell_warn = None, "Couldn't run the spelling check (%s)" % e + + +def slide_number_of(plan, tpl, slide, text=""): + nums = deck.slide_numbers(tpl, plan) + for s, n in zip(plan.slides, nums): + if s is slide: + return n + for s, n in zip(plan.slides, nums): # a long scripture may have been split since + if s.kind == slide.kind and s.ref == slide.ref and (not text or text in s.text): + return n + return None + + +def undo_typos(st, tpl): + fixes = st.typos.fixes if st.typos else [] + if not fixes: + say(" No typo fixes to undo.") + return False + if len(fixes) == 1: + pick = [0] + else: + for k, f in enumerate(fixes, 1): + n = slide_number_of(st.plan, tpl, f.slide, f.new) + say(" %d) slide %s: %s" % (k, n or "?", describe_fix(f))) + a = ask(" Undo which? (Enter = all, or e.g. 1,3)", "").replace(" ", "") + pick = list(range(len(fixes))) if not a else \ + [int(x) - 1 for x in a.split(",") if x.isdigit() and 1 <= int(x) <= len(fixes)] + if not pick: + return False + words = [fixes[k].word for k in pick] + spelling.allow(words) # never "fix" these again + st.plan = st.base_plan # back to the text from before the fixes... + run_spelling(st) # ...and fix the rest again + say(" Put back: %s" % ", ".join("\u201c%s\u201d" % w for w in words)) + return True + + +def describe_fix(f): + if f.kind == "double": + return "removed the doubled \u201c%s\u201d" % f.word + return "fixed \u201c%s\u201d \u2192 \u201c%s\u201d" % (f.word, f.new) def run(args): @@ -612,6 +666,9 @@ def run(args): yt_dirty = True while True: + if st.need_spell: + run_spelling(st) + yt_dirty = True deck.ensure_pictures(tpl, st.plan) try: layout = deck.fit(tpl, st.plan, measure_mod.measure, outputs) @@ -643,12 +700,17 @@ def run(args): setup_youtube() yt_dirty = True continue + if a == "r": + if undo_typos(st, tpl): + yt_dirty = True + continue if a in ("e", "s", "f"): yt_dirty = True if a == "e": newplan = edit_in_textedit(st.plan, final_name) newplan.skipped = st.plan.skipped if not newplan.skipped else newplan.skipped st.plan = newplan + st.need_spell = True elif a == "t": opts = [("Series " if p in series else "Generic ") + os.path.basename(p) for p in series + generic] k = choose("Template", opts, (series + generic).index(tpl_path) + 1) @@ -757,9 +819,15 @@ def show_summary(st, tpl, speaker, speaker_src, name, date, svc_date, svc_name, t, d_, problem = yt_job.result rows.append(("YouTube", ("%s %s(opens in TextEdit at the end)%s" % (t, DIM, R)) if t else "%s%s%s" % (YEL, problem or "-", R))) + if getattr(st, "spell_warn", None): + rows.append(("Spelling", "%s%s%s" % (YEL, st.spell_warn, R))) + elif getattr(st, "typos", None) is not None: + n = len(st.typos.fixes) + rows.append(("Spelling", ("%s%d typo%s fixed%s %s(r = undo)%s" % (YEL, n, "" if n == 1 else "s", R, DIM, R)) if n + else "%sno typos found%s" % (GRN, R))) for k, v in rows: say(" %-9s %s" % (k, v)) - notes = summary_notes(st.plan, tpl) + notes = typo_notes(st, tpl) + summary_notes(st.plan, tpl) if out_warn: notes.insert(0, out_warn) if notes: @@ -767,7 +835,25 @@ def show_summary(st, tpl, speaker, speaker_src, name, date, svc_date, svc_name, for n in notes: say(" %s\u2022%s %s" % (YEL, R, wrap(re.sub(r"\s+", " ", n), w - 6, 4))) say("") - say(" %sEnter = do it e = edit slides t/s/n/d/f = change template/speaker/name/date/notes v = view description q = quit%s" % (DIM, R)) + say(" %sEnter = do it e = edit slides r = undo typo fixes t/s/n/d/f = change template/speaker/name/date/notes v = view description q = quit%s" % (DIM, R)) + + +def typo_notes(st, tpl): + out = [] + rep = getattr(st, "typos", None) + if not rep: + return out + for f in rep.fixes: + n = slide_number_of(st.plan, tpl, f.slide, f.new) + out.append("Slide %s: %s" % (n or "?", describe_fix(f))) + seen = set() + for s, w in rep.unknown: + if w.lower() in seen: + continue + seen.add(w.lower()) + n = slide_number_of(st.plan, tpl, s, w) + out.append("Slide %s: \u201c%s\u201d isn't in the dictionary - left as is" % (n or "?", w)) + return out def summary_notes(plan, tpl): diff --git a/src/spelling.py b/src/spelling.py new file mode 100644 index 0000000..9d247ef --- /dev/null +++ b/src/spelling.py @@ -0,0 +1,192 @@ +"""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 diff --git a/tests/run_tests.py b/tests/run_tests.py index 92f65c6..fec550f 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -96,6 +96,22 @@ def main(): check("only shared references reused (%d)" % len(shared), all( x.decode() in open(SERIES, "rb").read().decode("latin-1") for x in shared) and len(shared) <= 12) + # 6. typo check (macOS's spelling checker, so this needs a Mac): planted typos are fixed, + # real notes are left alone + import spelling + try: + p = notes.parse(open(os.path.join(HERE, "typos", "Slides - Typo Test - Part 7.rtf"), "rb").read()) + rep = spelling.check_and_fix(p, skip=set()) + got = sorted((f.word, f.new) for f in rep.fixes) + want = sorted([("teh", "the"), ("the the", "the"), ("OBEDIANCE", "OBEDIENCE"), ("beleived", "believed")]) + check("typos: planted typos fixed", got == want, "got %s" % got) + for f in sorted(os.listdir(NOTES)): + p = notes.parse(open(os.path.join(NOTES, f), "rb").read()) + rep = spelling.check_and_fix(p) + check("typos: nothing changed in %s" % f, not rep.fixes, ", ".join("%s->%s" % (x.word, x.new) for x in rep.fixes)) + except spelling.Unavailable as e: + print("skip typo checks (%s)" % e) + print() print("FAILED: %s" % failures if failures else "all checks passed") return 1 if failures else 0 diff --git a/tests/typos/Slides - Typo Test - Part 7.rtf b/tests/typos/Slides - Typo Test - Part 7.rtf new file mode 100644 index 0000000..5b0f19a --- /dev/null +++ b/tests/typos/Slides - Typo Test - Part 7.rtf @@ -0,0 +1,219 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2870 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica-Bold;\f1\fswiss\fcharset0 Helvetica-BoldOblique;\f2\fnil\fcharset0 Verdana-Bold; +\f3\fnil\fcharset0 Verdana;\f4\fswiss\fcharset0 Helvetica;\f5\fswiss\fcharset0 Helvetica-Oblique; +} +{\colortbl;\red255\green255\blue255;\red0\green0\blue0;\red0\green0\blue241;\red251\green0\blue7; +\red0\green23\blue255;\red0\green0\blue255;} +{\*\expandedcolortbl;;\cssrgb\c0\c0\c0;\cspthree\c0\c4769\c91973;\cssrgb\c100000\c12195\c0; +\cssrgb\c1229\c23499\c100000;\cssrgb\c1680\c19835\c100000;} +\vieww9260\viewh20100\viewkind0 +\deftab720 +\pard\pardeftab720\partightenfactor0 + +\f0\b\fs54 \cf2 Where Is Your Faith - Part 7 \ +\pard\pardeftab720\partightenfactor0 + +\f1\i\fs32 \cf2 Faith That Gets You Out \ +\pard\pardeftab720\partightenfactor0 + +\f0\i0\fs28 \cf3 Hebrews 11:28-31\ +\ +\ +\pard\pardeftab720\partightenfactor0 + +\fs36 \cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f2\fs28 \cf4 John 17:15-16 ESV +\f3\b0 \cf4 I do not ask that you take them out of the world, but that you keep them from the evil one. 16 They are not of the world, just as I am not of the world.\ +\cf5 \ +\ +\ +\ + +\f2\b Galatians 1:4 ESV +\f3\b0 who gave himself for our sins to deliver us from the present evil age, according to the will of our God and Father.\ +\ +\ +\ +\pard\pardeftab720\partightenfactor0 + +\f0\b\fs36 \cf2 Everybody in this room is carrying 3 questions:\ +1. Am I okay with God?\ +2. Can I ever really get free of my old life?\ +3. Where do I belong?\ +\ +\pard\pardeftab720\partightenfactor0 + +\f3\b0\fs28 \cf5 \ +\ +\pard\pardeftab720\partightenfactor0 + +\f1\i\b\fs34 \cf2 How does our faith in God get us out of the world? +\f3\i0\b0\fs28 \cf5 \ +\pard\pardeftab720\partightenfactor0 + +\f0\b\fs36 \cf2 1. When God sees the BLOOD, judgment passes OVER. \ +\ +\pard\pardeftab720\partightenfactor0 + +\f2\fs28 \cf5 Exodus 12:12-13 ESV +\f3\b0 For I will pass through the land of Egypt that night, and I will strike all the firstborn in the land of Egypt, both man and beast; and on all the gods of Egypt I will execute judgments: I am the LORD. 13 The blood shall be a sign for you, on the houses where you are. And when I see the blood, I will pass over you, and no plague will befall you to destroy you, when I strike the land of Egypt. +\f2\b \ + +\f3\b0 \cf2 \ +\ +\ +\ +\pard\pardeftab720\partightenfactor0 + +\f0\b\fs34 \cf2 God doesn\'92t check the HOUSE. He checks teh DOOR.\ +\ +\pard\pardeftab720\partightenfactor0 + +\f3\b0\fs28 \cf2 \ +\pard\pardeftab560\partightenfactor0 + +\f2\b \cf6 Leviticus 17:11 ESV +\f3\b0 For the life of the flesh is in the blood, and I have given it for you on the altar to make atonement for your souls, for it is the blood that makes atonement by the life. +\f2\b \ +\ +Hebrews 9:22 ESV +\f3\b0 Indeed, under the law almost everything is purified with blood, and without the shedding of blood there is no forgiveness of sins. +\f2\b \ +\ +\cf4 John 1:29 ESV +\f3\b0 \cf4 The next day he saw Jesus coming toward him, and said, "Behold, the the Lamb of God, who takes away the sin of the world!" +\f2\b \cf4 \ +\cf6 \ +Romans 5:9 ESV +\f3\b0 Since, therefore, we have now been justified by his blood, much more shall we be saved by him from the wrath of God. +\f2\b \ +\ +Exodus 12:22 ESV +\f3\b0 ...None of you shall go out of the door of his house until the morning. +\f2\b \ +\ +Exodus 12:11 ESV +\f3\b0 In this manner you shall eat it: with your belt fastened, your sandals on your feet, and your staff in your hand. And you shall eat it in haste. It is the LORD's Passover. +\f2\b \ + +\f3\b0 \ +\ +\ +\ +\pard\pardeftab720\partightenfactor0 + +\f1\i\b\fs34 \cf2 How does our faith in God get us out of the world?\ + +\f0\i0 2. When I go through the WATER, my old master can't follow me THROUGH. \ +\pard\pardeftab720\partightenfactor0 + +\f2\fs28 \cf5 Exodus 14:7-8 NLT +\f3\b0 He took with him 600 of Egypt\'92s best chariots, along with the rest of the chariots of Egypt, each with its commander. 8\'a0The Lord hardened the heart of Pharaoh, the king of Egypt, so he chased after the people of Israel. +\f2\b \ +\ +Exodus 14:11-12 ESV +\f3\b0 Is it because there were no graves in Egypt that you have taken us away to die in the wilderness?\'8512 It would have been better for us to serve the Egyptians than to die in the wilderness. +\f2\b \ +\ +Exodus 14:13-14 ESV +\f3\b0 And Moses said to the people, "Fear not, stand firm, and see the salvation of the LORD, which he will work for you today. For the Egyptians whom you see today, you shall never see again. 14 The LORD will fight for you, and you have only to be silent." +\f2\b \ +\ +Exodus 14:30 ESV +\f3\b0 Thus the LORD saved Israel that day from the hand of the Egyptians, and Israel saw the Egyptians dead on the seashore.\cf2 \ +\ +\ +\ +\pard\pardeftab720\partightenfactor0 + +\f0\b\fs34 \cf2 The blood handled the judgment. The WATER cut them off from the old master.\ul \ +\ulnone \ +\ +\pard\pardeftab720\partightenfactor0 + +\f2\fs28 \cf5 1 Corinthians 10:1-2 ESV +\f3\b0 For I do not want you to be unaware, brothers, that our fathers were all under the cloud, and all passed through the sea, 2 and all were baptized into Moses in the cloud and in the sea. +\f2\b \ +\ +Colossians 2:12 ESV +\f3\b0 having been buried with him in baptism, in which you were also raised with him through faith in the powerful working of God, who raised him from the dead. +\f2\b \ +\ +Galatians 3:27 ESV +\f3\b0 For as many of you as were baptized into Christ have put on Christ.\cf2 \ +\ +\ +\ +\pard\pardeftab720\partightenfactor0 + +\f1\i\b\fs34 \cf2 How does our faith in God get us out of the world? +\f3\i0\b0\fs28 \ +\pard\pardeftab720\partightenfactor0 + +\f0\b\fs36 \cf2 3. When I come out, God's FAMILY welcomes me IN. \ +\pard\pardeftab720\partightenfactor0 + +\f2\fs28 \cf5 Joshua 2:15 ESV +\f3\b0 Then she let them down by a rope through the window, for her house was built into the city wall, so that she lived in the wall. +\f2\b \ +\ +Joshua 2:9-11 ESV +\f3\b0 I know that the LORD has given you the land... 10 For we have heard how the LORD dried up the water of the Red Sea before you when you came out of Egypt\'8511 for the LORD your God, he is God in the heavens above and on the earth beneath. +\f2\b \ +\ +Hebrews 11:31 ESV +\f3\b0 By faith Rahab the prostitute did not perish with those who were disobedient, because she had given a friendly welcome to the spies. +\f2\b \ +\ +Joshua 2:18 ESV +\f3\b0 Behold, when we come into the land, you shall tie this scarlet cord in the window through which you let us down, and you shall gather into your house your father and mother, your brothers, and all your father's household.\ +\ +\ +\ +\ +\pard\pardeftab720\partightenfactor0 + +\f0\b\fs34 \cf2 Walls FALL to OBEDIANCE that looks RIDICULOUS.\ +\ +\pard\pardeftab720\partightenfactor0 + +\f4\b0 \cf2 \ +\pard\pardeftab720\partightenfactor0 + +\f2\b\fs28 \cf5 Joshua 6:25 ESV +\f3\b0 But Rahab the prostitute and her father's household and all who belonged to her, Joshua saved alive. And she has lived in Israel to this day, because she hid the messengers whom Joshua sent to spy out Jericho. +\f2\b \ +\ +\cf4 Matthew 1:5 ESV +\f3\b0 \cf4 and Salmon the father of Boaz by Rahab, and Boaz the father of Obed by Ruth, and Obed the father of Jesse. +\f2\b \cf4 \ +\ +John 1:12 ESV +\f3\b0 \cf4 But to all who did receive him, who beleived in his name, he gave the right to become children of God. +\f2\b \cf5 \ +\ +Hebrews 10:24-25 ESV +\f3\b0 And let us consider how to stir up one another to love and good works, 25 not neglecting to meet together, as is the habit of some, but encouraging one another, and all the more as you see the Day drawing near. +\f2\b \ +\ +Hebrews 13:2 ESV +\f3\b0 Do not neglect to show hospitality to strangers, for thereby some have entertained angels unawares. +\f2\b \ +\ +Romans 15:7 ESV +\f3\b0 Therefore welcome one another as Christ has welcomed you, for the glory of God.\ +\ +\ +\ +\pard\pardeftab720\partightenfactor0 + +\f5\i\fs34 \cf2 Sermon in a Sentence: +\f0\i0\b \ +Faith doesn't just save me from the world, it walks me OUT of it: Jesus' blood passes judgment OVER me, the water brings me THROUGH and cuts off my old master, and God's family welcomes me IN.\ +\ +\ + +\f4\b0 \ +} \ No newline at end of file