#!/usr/bin/env python3 """ One-shot repair for docs/index.html + website/index.html after Windows-1252 round-trips destroyed emojis. What this does: 1. Reads docs/index.html as raw bytes. 2. Replaces literal '?' / '??' / '???' placeholders left over from the cp1252 round-trip with the actual emoji that was there originally. 3. Replaces any lingering 0x97 byte (cp1252 em-dash) with proper UTF-8 em-dash. 4. Writes BOTH docs/index.html and website/index.html as UTF-8 *with BOM*. The BOM is intentional: it forces every editor (including PS5 Set-Content and Notepad) to detect UTF-8 on open, so a later round-trip cannot silently re-encode to cp1252 and re-destroy the emojis. This is paired with .editorconfig + .gitattributes to make UTF-8 sticky repo-wide. """ from __future__ import annotations import pathlib import sys ROOT = pathlib.Path(__file__).resolve().parent.parent DOCS = ROOT / "docs" / "index.html" WEB = ROOT / "website" / "index.html" # Replacements are keyed on the FULL line text (after stripping trailing # whitespace) so we never accidentally rewrite a different occurrence of '??'. # Left side: exact current line content (one line, no newline) # Right side: replacement (one line, no newline) LINE_FIXES: list[tuple[str, str]] = [ # favicon SVG ( " ?\">", " \u2B21\">", ), # NAV brand: hexagon + HAVEN ("
? HAVEN
", "
\u2B21 HAVEN
"), # Mobile menu toggle: hamburger (" ", " "), # Hero: Download Haven (" ? Download Haven", " \u2B07 Download Haven"), # Hero: Try it Live (" ? Try it Live", " \u25B6 Try it Live"), # Community banner heading ("

?? Try Haven — No Download Required

", "

\U0001F310 Try Haven — No Download Required

"), # Join Community Server button (" ? Join Community Server", " \U0001F310 Join Community Server"), # Channel-code hint ("

?? After signing up, enter this channel code to join:

", "

\U0001F511 After signing up, enter this channel code to join:

"), # Discord import heading ("

?? NEW — Import your Discord history

", "

\U0001F4E5 NEW — Import your Discord history

"), # Discord feature pills (" ?? Text channels", " \U0001F4AC Text channels"), (" ?? Announcements", " \U0001F4E2 Announcements"), (" ?? Forums & tags", " \U0001F5C2\uFE0F Forums & tags"), (" ?? Threads", " \U0001F9F5 Threads"), (" ?? Reactions", " \U0001F60A Reactions"), (" ?? Pins", " \U0001F4CC Pins"), (" ?? Attachments", " \U0001F4CE Attachments"), (" ??? Avatars", " \U0001F5BC\uFE0F Avatars"), (" ?? Replies", " \u21A9\uFE0F Replies"), # Desktop heading ("

??? NEW — Haven Desktop BETA

", "

\U0001F5A5\uFE0F NEW — Haven Desktop BETA

"), # Desktop feature pills (" ?? Per-App Audio", " \U0001F3A7 Per-App Audio"), (" ?? Device Switching", " \U0001F500 Device Switching"), (" ?? Native Notifications", " \U0001F514 Native Notifications"), (" ?? System Tray", " \U0001F4CD System Tray"), (" ? One-Click Install", " \u26A1 One-Click Install"), (" ??? Windows & Linux", " \U0001F5A5\uFE0F Windows & Linux"), # Desktop download buttons (" ? Windows Installer", " \U0001FA9F Windows Installer"), (" ? Linux AppImage", " \U0001F427 Linux AppImage"), (" ? Linux .deb", " \U0001F4E6 Linux .deb"), # Desktop beta footnote (must include leading whitespace) (" ?? Beta release — feedback & bug reports are greatly appreciated. Requires a Haven server to connect to.", " \U0001F9EA Beta release — feedback & bug reports are greatly appreciated. Requires a Haven server to connect to."), # Android heading ("

?? Haven — Now on Google Play

", "

\U0001F4F1 Haven — Now on Google Play

"), # Android feature pills (" ?? Native Android", " \U0001F916 Native Android"), (" ?? Push Notifications", " \U0001F514 Push Notifications"), (" ?? Full Chat Support", " \U0001F4AC Full Chat Support"), (" ??? Voice Chat", " \U0001F399\uFE0F Voice Chat"), (" ?? Built for Haven", " \u2B21 Built for Haven"), # Android credit ("Built with ?? by") ("

Built with ?? by Amnibro — thank you for your incredible work building the Haven Android app from the ground up.

", "

Built with \u2764\uFE0F by Amnibro — thank you for your incredible work building the Haven Android app from the ground up.

"), # Platform tags (" ??? Windows", " \U0001FA9F Windows"), (" ?? macOS", " \U0001F34F macOS"), (" ?? Linux", " \U0001F427 Linux"), (" ?? iOS Safari", " \U0001F4F1 iOS Safari"), (" ?? Android", " \U0001F916 Android"), (" ?? Any Browser", " \U0001F310 Any Browser"), # 3-step numbers ("
??
", # placeholder, will be replaced positionally below "
__STEPNUM__
"), # FAQ s-num ("
?
", "
?
"), # leave as literal ? (FAQ "What's a self-hosted server?") ("
??
", # security "
__SNUM__
"), # Selling-point cards (" ??", # placeholder, positional below " __CARDICON__"), (" ???", # screen sharing " \U0001F5A5\uFE0F"), # Feature cards (" ??", # positional " __FICON__"), (" ???", # positional - 3char " __FICON3__"), # Footer links (" ?? Setup Guide", " \U0001F4D6 Setup Guide"), (" ?? Full Changelog", " \U0001F4CB Full Changelog"), # Version history arrow (" ?", " \u25BC"), # v1.0.0 "Release notes ?" arrow (" Release notes ?", " Release notes \u2192"), # Second Desktop card (heading + button + alt links + footnote) ("

??? Haven Desktop BETA — v1.4.15

", "

\U0001F5A5\uFE0F Haven Desktop BETA — v1.4.15

"), (" ? Download Desktop v1.4.15", " \u2B07 Download Desktop v1.4.15"), (" ?? README", " \U0001F4D8 README"), (" ?? Report a Bug", " \U0001F41B Report a Bug"), (" ?? Beta software — feedback & bug reports are greatly appreciated.
", " \U0001F9EA Beta software — feedback & bug reports are greatly appreciated.
"), # Second Android card heading + credit ("

?? Haven Android App RELEASED

", "

\U0001F4F1 Haven Android App RELEASED

"), (" Built with ?? by Amnibro — thank you for your incredible work building the Haven Android app from the ground up.", " Built with \u2764\uFE0F by Amnibro — thank you for your incredible work building the Haven Android app from the ground up."), # Support card (" ??", " \u2764\uFE0F"), (" ? Buy me a coffee on Ko-fi", " \u2615 Buy me a coffee on Ko-fi"), # Footer brand ("
? HAVEN
", "
\u2B21 HAVEN
"), # Changelog v3.15.0 entry: "new ?? button opens" → media-gallery icon (frames) ("
v3.15.0 — Channel Media Gallery: new ?? button opens a five-tab modal (Photos, Videos, Audio, Files, Links) showing every shared media item in the channel. Personas: create up to 25 named alter-egos in your profile and send messages as one by typing >>Name your message, with persona badges and full moderation
", "
v3.15.0 — Channel Media Gallery: new \U0001F5BC\uFE0F button opens a five-tab modal (Photos, Videos, Audio, Files, Links) showing every shared media item in the channel. Personas: create up to 25 named alter-egos in your profile and send messages as one by typing >>Name your message, with persona badges and full moderation
"), ] # Positional fixes (in document order): list of (placeholder, replacement_emoji) STEP_NUMS = ["1\uFE0F\u20E3", "2\uFE0F\u20E3", "3\uFE0F\u20E3"] SNUMS = ["\U0001F512", "\U0001F4BB"] # secure (lock), what do you need (laptop) CARDICONS = [ "\U0001F30D", # globe (host downloads, friends join from anywhere) "\U0001F399\uFE0F", # mic for voice "\U0001F512", # lock for private/E2E DMs "\U0001F4F1", # mobile ] FICONS_2CHAR = [ "\U0001F4E5", # Discord Import - inbox "\U0001F3B5", # Music streaming - musical note "\U0001F4AC", # Rich Chat - speech bubble "\U0001F512", # Encrypted DMs - lock "\U0001F3A8", # Themes - palette "\U0001F4C1", # File Sharing - folder "\U0001F517", # Link Previews - link "\u26A1", # Slash Commands - lightning "\U0001F50D", # Message Search - magnifying glass "\U0001F4CC", # Pinned - pushpin "\U0001F310", # Multi-Server - globe "\U0001F514", # Notification Sounds - bell "\U0001F3AE", # Games - game controller ] FICONS_3CHAR = [ "\U0001F399\uFE0F", # Voice Chat - mic "\U0001F5A5\uFE0F", # Screen Sharing - desktop "\U0001F39E\uFE0F", # GIF Search - film frames "\U0001F6E1\uFE0F", # Moderation - shield ] def apply_line_fixes(text: str) -> str: """Apply per-line substitutions. Lines are processed once each.""" lines = text.split("\n") # Strip trailing \r so we can compare line content cleanly had_cr = [ln.endswith("\r") for ln in lines] bare = [ln[:-1] if cr else ln for ln, cr in zip(lines, had_cr)] step_idx = 0 snum_idx = 0 card_idx = 0 f2_idx = 0 f3_idx = 0 out = [] for ln in bare: # Try the explicit table first matched = False for old, new in LINE_FIXES: if ln == old: ln = new matched = True break # Resolve positional placeholders if "__STEPNUM__" in ln: ln = ln.replace("__STEPNUM__", STEP_NUMS[step_idx]) step_idx += 1 if "__SNUM__" in ln: ln = ln.replace("__SNUM__", SNUMS[snum_idx]) snum_idx += 1 if "__CARDICON__" in ln: ln = ln.replace("__CARDICON__", CARDICONS[card_idx]) card_idx += 1 if "__FICON__" in ln: ln = ln.replace("__FICON__", FICONS_2CHAR[f2_idx]) f2_idx += 1 if "__FICON3__" in ln: ln = ln.replace("__FICON3__", FICONS_3CHAR[f3_idx]) f3_idx += 1 out.append(ln) # Re-attach \r where originals had it out = [ln + "\r" if cr else ln for ln, cr in zip(out, had_cr)] return "\n".join(out), step_idx, snum_idx, card_idx, f2_idx, f3_idx def main() -> int: raw = DOCS.read_bytes() if raw.startswith(b"\xEF\xBB\xBF"): text = raw[3:].decode("utf-8") else: # Original file had stray 0x97 (cp1252 em-dash) bytes; decode that way. text = raw.decode("cp1252") fixed, step_n, snum_n, card_n, f2_n, f3_n = apply_line_fixes(text) print(f"step numbers replaced: {step_n}/3") print(f"s-nums replaced: {snum_n}/{len(SNUMS)}") print(f"card icons replaced: {card_n}/{len(CARDICONS)}") print(f"f-icons (2ch): {f2_n}/{len(FICONS_2CHAR)}") print(f"f-icons (3ch): {f3_n}/{len(FICONS_3CHAR)}") # Write UTF-8 WITH BOM so future editor saves do not silently downgrade. out_bytes = b"\xEF\xBB\xBF" + fixed.encode("utf-8") DOCS.write_bytes(out_bytes) WEB.write_bytes(out_bytes) print(f"wrote {DOCS} ({len(out_bytes)} bytes)") print(f"wrote {WEB} ({len(out_bytes)} bytes)") return 0 if __name__ == "__main__": sys.exit(main())