JustToThePoint English Website Version
JustToThePoint en español

Bible GUI. A Desktop Devotional Companion 2

Life is a math equation. In order to gain the most, you have to know how to convert negatives into positives, Anonymous.

This Python script, bible_gui.py, serves as a comprehensive desktop prayer tool, built with the power of PySide6 (Qt for Python). It is designed to centralize your daily devotional workflow into a single, elegantly styled window featuring four distinct tabs.

Verse Tab: This is your daily bread. It fetches a Bible verse of the day, prioritizing offline sources first (your local reading plan daily_plan.txt combined with the diatheke CLI tool and freebible library) to ensure you always have a verse even without an internet connection. If offline lookups fail, it gracefully falls back to online APIs like YouVersion and OurManna.

Videos Tab: A dedicated player for curated YouTube prayer videos. To circumvent the common “Refused to display…” error (Error 152) that plagues desktop apps trying to embed YouTube directly, this tab implements a clever local HTTP server strategy. It spins up a tiny server on localhost to serve the embed page, satisfying YouTube’s strict X-Frame-Options requirements and allowing seamless playback within the interface.

Online Tab: This tab extends your prayer resources by embedding external websites directly into the application. It loads a curated list of online prayer sites (such as Sacred Space, Pray As You Go, and Universalis) from a configuration file and displays them using a built-in browser widget. It intelligently handles site restrictions; if a site refuses to be embedded due to security headers (X-Frame-Options), the app detects the failure and provides a prominent button to open the content in your default system browser instead.

Diary Tab: A Markdown-based prayer journal. It allows you to write your daily prayers, gratitude, intentions, and reflections. It includes a live preview mode (rendering Markdown to HTML) and manages your entries as individual files sorted by date, creating a searchable history of your spiritual journey.

The application is built with user experience in mind: it auto-creates configuration files if they are missing, handles missing dependencies (like bible_verse.py) with functional fallbacks, and uses a consistent, dark-themed color palette.

Geometrical analysis

# ══════════════════════════════════════════════════════════════════════════════
# HTML TEMPLATES FOR THE VIDEO PLAYER
# ══════════════════════════════════════════════════════════════════════════════
#
# When you embed a YouTube video on a website, YouTube checks the "origin" (the domain name of the website)
# to make sure embedding is allowed.
# Desktop apps use browser widgets (like QWebEngineView in Qt) to display web content. If you
# try to directly inject a YouTube iframe into this widget using a command like setHtml(), the
# underlying Chromium browser engine reports the origin as "null"
# Because "null" is not a real web address, YouTube’s strict security rules (Content-Security-Policy)
# block the video to prevent piracy and malicious embedding. You end up with a black screen that says
# "Video unavailable" (Error 152).
#
# NOTE: some videos have embedding disabled by the uploader regardless of
#   origin.
# To trick YouTube into thinking the video is being played on a legitimate website, this code spins up a tiny,
# invisible web server right inside your application.
# Instead of injecting the HTML directly, the app tells the browser widget to visit http://127.0.0.1 (your local machine).
# Because this is a standard HTTP web address, YouTube's security check passes, and the video plays perfectly.

# Placeholder page shown when no video is selected.
# The message gently guides the user to pick a video from the list.
_PLACEHOLDER = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
  body {
    background: #111827;
    color: #9ca3af;
    font-family: "Noto Sans", sans-serif;
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100vh;
    margin: 0;
    text-align: center;
  }
  .msg { opacity: 0.8; font-size: 17px; line-height: 1.6; }
  .icon { font-size: 40px; margin-bottom: 14px; }
</style>
</head>
<body>
  <div class="msg">
    <div class="icon">🙏</div>
    Select a video from the list<br>to begin prayer.
  </div>
</body>
</html>"""
# Template for the actual YouTube embed. It contains an IFRAME pointing to YouTube.
# {vid} is replaced at runtime with the YouTube video ID.
# autoplay=1 starts playback immediately when the video is selected.
# modestbranding=1 hides the YouTube logo in the player controls.
# rel=0 suppresses related-video recommendations at the end of playback.
# controls=1 shows the standard playback bar (play/pause, volume, etc.).
_EMBED = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
  * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  body {{
    background: #000;
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
  }}
  iframe {{
    width: 100%;
    height: 100%;
    border: none;
  }}
</style>
</head>
<body>
  <iframe
    src="https://www.youtube.com/embed/{vid}?autoplay=1&modestbranding=1&rel=0&controls=1"
    allow="autoplay; encrypted-media; fullscreen"
    allowfullscreen>
  </iframe>
</body>
</html>"""
def load_videos() -> list:
    """
    Read VIDEOS_FILE and return n a list of video dictionaries, [{id, title}, ...].

    File format — one video per line:
        VIDEO_ID  Title of the video
        # comment lines are skipped

    The video ID is the part after  youtube.com/watch?v=  in the URL.
    Example:
        lHIrBNqE6nI  Peaceful Morning Prayer Music - 1 Hour
        xSGnxQBPyBg  10 Minute Morning Prayer

    If VIDEOS_FILE does not exist, a sample file is created so the user
    can see the expected format immediately. This is a helpful first‑run
    behaviour: the app never fails just because a config file is missing.

    Returns:
        A list of dictionaries, each with keys:
            - 'id'    (str) the YouTube video ID
            - 'title' (str) the human‑readable title
        Returns an empty list if the file is empty or contains only comments.
        [
            {"id":"Ac_nVJASVfc", "title":"Peaceful Morning Prayer Music - 1 Hour"},
            {"id":"yyyyy", "title":"another title"},
        ]

    """
    # If the file doesn't exist, write a sample one so the app is usable.
    # Instead of: "Error: file missing", the app says: "No problem. I'll create an example."
    if not VIDEOS_FILE.exists():
        VIDEOS_FILE.parent.mkdir(parents=True, exist_ok=True)
        VIDEOS_FILE.write_text(
            "# prayer_videos.txt — YouTube video IDs for the Prayer tab\n"
            "# Format:  VIDEO_ID  Title of the video\n"
            "# Get the ID from: youtube.com/watch?v=VIDEO_ID\n"
            "#\n"
            "Ac_nVJASVfc  Peaceful Morning Prayer Music - 1 Hour\n"
            "eYdmcZUSt0s  10 Minute Morning Prayer with Scripture\n"
            "3ixV9raPKMo  Gregorian Chant - Ave Maria\n"
            "nQWFzMvCfAU  The Rosary - Joyful Mysteries\n"
            "JkFbYoFJiSY  Evening Prayer - Taizé Community\n",
            encoding="utf-8",
        )

    videos = []
    for line in VIDEOS_FILE.read_text("utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        # Split on first whitespace to separate ID from title.
        # None means: split on ANY whitespace, 1 means split only once.
        # "abc123 My Beautiful Prayer Video" becomes [ "abc123",  "My Beautiful Prayer Video" ]
        # Not ["abc123","My","Beautiful"...]
        # If there is no title, the ID is used as the title as a fallback.
        parts = line.split(None, 1)
        if parts:
            videos.append({
                "id":    parts[0].strip(),
                "title": parts[1].strip() if len(parts) > 1 else parts[0],
            })
    return videos

# ══════════════════════════════════════════════════════════════════════════════
# LOCAL HTTP SERVER FOR YOUTUBE EMBEDS
# ══════════════════════════════════════════════════════════════════════════════
#
# THE PROBLEM
# -----------
# YouTube embeds (youtube.com/embed/ID) only work when the *parent page* has
# a real HTTP origin.  Qt's setHtml() loads content with origin "null" (a
# data: URL internally), and Chromium enforces YouTube's X-Frame-Options /
# Content-Security-Policy, blocking the embed with error 152/153.
#
# Running a Hugo server in local host works because Hugo serves it over HTTP — the iframe lives
# inside a page with origin http://localhost:1313 or https://yoursite.com.
# Navigating directly to the embed URL in a browser also fails (error 153)
# for the same reason — the embed URL is not meant to be a top-level page.
#
# THE FIX
# -------
# QWebEngineView                    Python thread
#      │                                 │
#      │  GET /player.html ────────────►│  _VideoHTMLHandler
#      │◄──── 200 OK + HTML ────────────│  (serves _EMBED with {vid} filled in)
#      │                                 │
#      │  renders the page               │
#      │  iframe src=youtube.com/embed/  │
#      │  origin = http://127.0.0.1:N    │  ← real HTTP origin ✅
#      │  YouTube allows it              │
#
# We start a tiny Python HTTP server on a random localhost port
# QWebEngineView loads http://localhost:PORT/player.html — a real HTTP URL.
# The embed iframe now has origin http://localhost:PORT, which satisfies
# YouTube's embedding requirements identically to your Hugo page.
#
# The server runs in a daemon thread so it exits automatically when the
# app closes.  It serves a single in-memory HTML page that is rebuilt each
# time the user selects a video.

import threading
import http.server
import urllib.parse
import socketserver

# class _VideoHTMLHandler(http.server.BaseHTTPRequestHandler) means when browser makes HTTP request, use this class.
# Like Flask router handler.
class _VideoHTMLHandler(http.server.BaseHTTPRequestHandler):
    """
    Minimal HTTP handler that serves one HTML page at /player.html.

    The page content is stored as a class variable so VideoServer can
    update it without restarting the server.  This is thread‑safe because
    the write happens before a request comes in; during a request the
    content is read only.

    This is the "traffic cop" for our tiny local server.
    """
    # Class variable. Shared by all instances.
    html_content: bytes = b""

    def do_GET(self) -> None:
        """
        It intercepts GET requests!

        Serve the embedded page at /player.html (and optionally at /).
        All other paths return a plain 404.
        """
        # When the Qt widget navigates to [http://] + [127.0.0.1:PORT| + /player.html, this handler intercepts the GET request.
        # Accept / or /prayer.html.
        if self.path in ("/player.html", "/"):
            # It responds with a 200 OK success message HTTP/1.1 200 OK ...
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8") # Tell browser HTML page
            self.send_header("Content-Length", str(len(self.html_content)))
            # Allow the embedded iframe to autoplay and use encrypted media.
            # Without this header, Chromium might block autoplay on localhost.
            self.send_header(
                "Permissions-Policy",
                "autoplay=*, encrypted-media=*",
            )
            self.end_headers()
            # ... and spits out the HTML from the _EMBED template.
            self.wfile.write(self.html_content)
        else:
            self.send_response(404)
            self.end_headers()

    def log_message(self, *args):
        """
        Suppress access log output to keep the console clean.
        The default handler prints every request; we don't need that noise.
        """
        pass

class VideoServer:
    """
    Singleton local HTTP server that serves the YouTube embed page.

    Because YouTube requires a real HTTP origin, we cannot simply use
    QWebEngineView.setHtml(). Instead, we run a tiny server on localhost
    and point the web view at http://127.0.0.1:/player.html.

    Usage:
        server = VideoServer.get()          # start on first call (once)
        server.serve(video_id)              # update page content
        url = server.url()                  # http://localhost:PORT/player.html
    """
    _instance: "VideoServer | None" = None

    def __init__(self) -> None:
        """
        Create a TCP server on a random port and start it in a daemon thread.

        The server listens only on 127.0.0.1 (localhost) so it cannot be
        accessed from other machines.  Port 0 tells the OS to pick a free one.
        """
        # Creates actual TCP server. 127.0.0.1 = localmachine only; 0 = Let the OS pick a free port (port=0)
        self._httpd = socketserver.TCPServer(("127.0.0.1", 0), _VideoHTMLHandler)
        # Then, we save the port.
        self._port  = self._httpd.server_address[1]
        # daemon=True; Daemon Thread: It runs the server on a separate "daemon thread."
        # This means the server runs in the background and won't freeze the app's user interface.
        # Because it's a daemon, it will automatically shut itself down when the user closes the main app.
        # target=self._httpd.serve_forever = Infinite server loop.
        t = threading.Thread(target=self._httpd.serve_forever, daemon=True)
        # Launch background thread. Now server runs independently.
        t.start()

    # @classmethod means method belongs to class.
    @classmethod
    def get(cls) -> "VideoServer":
        """
        Return the single VideoServer instance, starting it if necessary.
        This guarantees only one server is running, no matter how many
        times the video tab is used.
        """
        # First time?
        if cls._instance is None:
            # Create
            cls._instance = cls()
        return cls._instance

    def serve(self, video_id: str) -> None:
        """
        When you want to play a new video: server.serve(video_id)

        Build the embed HTML for `video_id` and store it for the next GET.

        This should be called *before* loading the URL so the page is
        ready immediately.  The video ID is URL‑encoded to be safe inside
        the iframe src attribute.
        """
        # Builds the HTML from our _EMBED template
        # URL‑encodes the video ID. urllib.parse.quote escapes unsafe chars.
        html = _EMBED.format(vid=urllib.parse.quote(video_id, safe=""))
        # Stores the HTML in the handler’s class variable
        _VideoHTMLHandler.html_content = html.encode("utf-8")
        # No request has happened yet — we’re just preparing the page.
        # Now next browser request gets new page.

    def url(self) -> str:
        """Return the local URL that the web view should navigate to.
        Example: [http://] + 127.0.0.1:55321 + [/player.html]
        """
        return f"http://127.0.0.1:{self._port}/player.html"

# ── VideoTab ──────────────────────────────────────────────────────────────────

class VideoTab(QWidget):
    """
    Left panel  — list of videos from prayer_videos.txt.
    Right panel — YouTube embed via QWebEngineView served from localhost.

    We use a local HTTP server (VideoServer) so the embed page has a real
    HTTP origin (http://127.0.0.1:PORT), which satisfies YouTube's
    X-Frame-Options and CSP requirements — the same reason a Hugo‑powered
    local site works but `setHtml()` does not.

    The server starts once on first use (VideoServer.get()) and stays
    alive for the lifetime of the application.
    """

    def __init__(self):
        # Initialize QWidget.
        super().__init__()

        # Load the list of videos from the config file (auto‑creates if missing)
        self.videos = load_videos()

        # Horizontal root layout: left list, right player
        # +---------+-------------------+
        # | left    | right             |
        # +---------+-------------------+
        root = QHBoxLayout(self)
        root.setContentsMargins(14, 14, 14, 14)
        root.setSpacing(14)

        # ── Left: video list ──────────────────────────────────────────────
        # Vertical stack.
        left = QVBoxLayout()

        # Heading label
        cap = QLabel("Prayer Videos")
        cap.setStyleSheet(f"color:{ACCENT};font-weight:bold;font-size:12px;")
        left.addWidget(cap)

        # List widget displaying video titles
        self.lst = QListWidget()
        # Populate
        self.lst.setFixedWidth(240) # fixed width for consistent layout
        for v in self.videos:
            self.lst.addItem(v["title"]) # Display title.
        # Clicking an item triggers playback of the corresponding video
        self.lst.itemClicked.connect(
            lambda _: self._play(self.lst.currentRow())
        )
        left.addWidget(self.lst, 1) # stretch factor 1: takes available vertical space

        # Reload button: re-reads prayer_videos.txt without restarting the app
        reload_btn = QPushButton("↺  Reload list")
        reload_btn.setToolTip(str(VIDEOS_FILE)) # show file path (~/dotfiles/myArch/config/bible/prayer_videos.txt) on hover
        reload_btn.clicked.connect(self._reload_list)
        left.addWidget(reload_btn)

        # "Edit video list" button – opens the file in the default text editor
        edit_btn = QPushButton("✏  Edit video list")
        edit_btn.setToolTip(str(VIDEOS_FILE))
        edit_btn.clicked.connect(self._open_list_file)
        left.addWidget(edit_btn)
        root.addLayout(left)

        # ── Right: WebEngine player ───────────────────────────────────────
        right = QVBoxLayout()
        right.setSpacing(6)

        # Status label shows the currently playing video title and server port
        self.status_lbl = QLabel("Select a video to begin prayer.")
        self.status_lbl.setStyleSheet(f"color:{MUTED};font-size:12px;")
        right.addWidget(self.status_lbl)

        # Web engine view – the actual YouTube player will be loaded here
        self.web = QWebEngineView()

        # Prepare the placeholder page on the local server and load it
        srv = VideoServer.get() # Start the server once (on first access)
        _VideoHTMLHandler.html_content = _PLACEHOLDER.encode("utf-8") # Initial page.
        self.web.load(QUrl(srv.url())) # load /player.html from the local server
        # Browser requests: GET /player.html, Server responds
        right.addWidget(self.web, 1) # the web view fills the remaining space
        root.addLayout(right, 1)

    def _play(self, idx: int) -> None:
        """
        Play the video at index idx.

        Sequence:
            1. Update the server's HTML content with the new embed.
            2. Call web.reload() — the browser re-fetches /player.html
               from localhost with the new embed src.

        WHY load() instead of setHtml():
            load() fetches http://127.0.0.1:PORT/player.html — a real HTTP
            URL with a real origin.  YouTube's embedding checks pass because
            the referrer/origin is an HTTP address, not "null".

        WHY reload() instead of load(url) again:
            We already updated the server content before triggering the
            browser.  A reload() re-fetches the same URL without creating
            a new navigation entry in the back/forward stack.
        """
        # Bounds check. We want to avoid crashes.
        if not (0 <= idx < len(self.videos)):
            return

        # Get selected video.
        vid   = self.videos[idx]["id"]
        title = self.videos[idx]["title"]

        # Update the local server
        srv = VideoServer.get()
        srv.serve(vid)          # Build the embed HTML page and store it for the next GET.
        self.web.reload()       # tell the browser to get the new page (refresh browser)
        # Browser asks GET /prayer.html and gets updated page.

        # Update status. Show the video title and the localhost port (local server HTTP) for debugging
        self.status_lbl.setText(f"▶  {title}  (localhost:{srv._port})")

    def _reload_list(self) -> None:
        """
        Re‑read `prayer_videos.txt` and refresh the list widget.

        This allows the user to add/remove videos in the text file without
        restarting the application.  The status label is updated to confirm
        the action.
        """
        self.videos = load_videos() # re‑read the file
        self.lst.clear()
        for v in self.videos:
            self.lst.addItem(v["title"])
        self.status_lbl.setText("List reloaded from file.")

    def _open_list_file(self) -> None:
        """
        Open `prayer_videos.txt` in the default text editor.

        Attempts to use `kate`, `gedit`, or `xdg-open` (which respects the
        system's default application).  The editor process is detached so
        it runs independently of the application.
        """
        # Choose a known editor or fall back to xdg-open
        editor = shutil.which("kate") or shutil.which("gedit") or "xdg-open"
        # start_new_session=True detaches the editor from this process
        subprocess.Popen([editor, str(VIDEOS_FILE)], start_new_session=True)

# ══════════════════════════════════════════════════════════════════════════════
# PRAYER ONLINE HELPERS + TAB
# File‑based site list and automatic first‑time setup
# ══════════════════════════════════════════════════════════════════════════════

def load_prayer_sites() -> list:
    """
    Read PRAYER_FILE and return a list of site dicts.

    File format — one site per line:
        url:name:description
        # lines starting with # are comments

    Example:
        https://sacredspace.com:Sacred Space:Daily Ignatian prayer by the Irish Jesuits
        https://pray-as-you-go.org:Pray As You Go:Daily audio prayer from Jesuit Media

    If the file does not exist a sample is created automatically.
    """
    # The first run of the application will never throw a FileNotFoundError.
    # The directory is created if missing, and a sensible default set of prayer sites is written into the file.
    # This makes the app immediately usable.
    if not PRAYER_FILE.exists():
        PRAYER_FILE.parent.mkdir(parents=True, exist_ok=True)
        # The default sites are curated but user‑friendly: Sacred Space, Pray As You Go, Universalis, etc.
        # All are highly relevant for a prayer app.
        PRAYER_FILE.write_text(
            "# prayeronline.txt — online prayer sites\n"
            "# Format:  url:name:description\n"
            "#\n"
            "https://www.sacredspace.com:Sacred Space:"
                "Daily Ignatian prayer by the Irish Jesuits (Loyola Press)\n"
            "https://pray-as-you-go.org:Pray As You Go:"
                "Daily audio prayer from Jesuit Media — 10 minutes with music\n"
            "https://www.universalis.com:Universalis:"
                "Liturgy of the Hours — Morning, Evening and Night Prayer\n"
            "https://www.pray.com:Pray.com:"
                "Guided prayers, bedtime stories and devotionals\n"
            "https://www.biblegateway.com:Bible Gateway:"
                "Full Bible in hundreds of translations with daily reading plans\n"
            "https://www.olivetree.com:Olive Tree:"
                "Bible study resources and daily devotionals\n"
            "https://www.jesuitprayer.org:Jesuit Prayer:"
                "Daily Examen and Ignatian prayer resources\n"
            "https://www.catholic.org/prayers:Catholic Online:"
                "Large library of Catholic prayers for all occasions\n",
            # The file is encoded in UTF‑8, which matches the Qt environment and guarantees
            # that any special characters (e.g., accented names) appear correctly.
            encoding="utf-8",
        )

    # Parsing the prayer‑sites file
    sites = []
    for line in PRAYER_FILE.read_text("utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        # The tricky URL reconstruction
        parts = line.split(":", 3)      # at most 4 parts; URL may contain ://
        # parts after splitting "https://example.com:Name:Desc" on ":" gives
        # ["https", "//example.com", "Name", "Desc"]
        # We reconstruct the URL from parts[0]+":"+parts[1] because the first two pieces together
        # always contain the scheme (https) and the domain‑path portion (// + domain.com + / + something).
        if len(parts) >= 2:
            url  = parts[0] + ":" + parts[1]          # https://...
            name = parts[2].strip() if len(parts) > 2 else url
            desc = parts[3].strip() if len(parts) > 3 else ""
            sites.append({"url": url, "name": name, "desc": desc})
    return sites
    # Potential edge case
    # If a site’s description itself contains a colon, the description will be split further because
    # split(":", 3) only limits the number of splits, not the characters inside the resulting strings.
    # https://example.com:Name:Desc: extra colon here
    # would produce parts = [..., "Desc", " extra colon here"].
    # The code takes parts[3] as the description, so the final text would be "Desc" only,
    # losing everything after the second colon.

class PrayerOnlineTab(QWidget):
    """
    Tab 4 — Online Prayer Sites

    Layout:
        Left pane  (fixed 230 px)  — list of sites from prayeronline.txt.
        A static‑width QListWidget makes the navigation clear and always accessible.
        The fixed width (230 px) prevents the list from eating too much space on large screens.

        Buttons below the site list
            ↺ Reload list – re‑reads the text file and repopulates the list widget.
            This is useful after manually editing the file.
            ✏ Edit sites file – launches the system’s default text editor (or kate, gedit, or xdg-open
            as fallback) to modify the file directly. The tooltip displays the full file path.

        Right pane (stretch)       — split vertically: uses all remaining horizontal space.
            Top half    :  a “card” showing the site name, description, and the URL,
                            along with an “Open in browser” button.
            Bottom half : QWebEngineView embedded browser,
                            which expands to fill the rest of the window height.

    Embedding strategy:
        We load the URL directly in QWebEngineView.  Most modern prayer
        sites work fine when loaded in an embedded browser because
        QWebEngineView uses a full Chromium engine that respects cookies,
        JavaScript, and HTTPS.

        Some sites send  X-Frame-Options: DENY  which prevents them from
        being embedded.  We detect the loadFinished signal and check the
        URL:
            • If the final URL matches what we requested → page loaded ✅
            • If Chromium blocked it (ERR_BLOCKED_BY_RESPONSE) → we show
              an "Open in browser" button so the user can continue praying
              in their default browser.

    The embedded browser is intentionally minimal — no address bar, no
    back/forward buttons — so the tab feels like part of the prayer app
    rather than a general browser.  The "Open externally" button is always
    visible so the user can jump out at any time.
    """

    def __init__(self):
        super().__init__()
        self.sites   = load_prayer_sites()
        self._current_url = ""

        root = QHBoxLayout(self)
        root.setContentsMargins(14, 14, 14, 14)
        root.setSpacing(14)

        # ── Left: site list ───────────────────────────────────────────────
        left = QVBoxLayout()
        cap = QLabel("Prayer Sites")
        cap.setStyleSheet(f"color:{ACCENT};font-weight:bold;font-size:12px;")
        left.addWidget(cap)

        self.lst = QListWidget()
        self.lst.setFixedWidth(230)
        for s in self.sites:
            # Site name
            self.lst.addItem(s["name"])
        self.lst.itemClicked.connect(
            lambda _: self._select(self.lst.currentRow())
        )
        left.addWidget(self.lst, 1)

        reload_btn = QPushButton("↺  Reload list")
        reload_btn.clicked.connect(self._reload)
        left.addWidget(reload_btn)

        edit_btn = QPushButton("✏  Edit sites file")
        edit_btn.setToolTip(str(PRAYER_FILE))
        edit_btn.clicked.connect(self._edit_file)
        left.addWidget(edit_btn)
        root.addLayout(left)

        # ── Right: info card + browser ────────────────────────────────────
        right = QVBoxLayout()
        right.setSpacing(10)

        # Site name
        self.name_lbl = QLabel("Select a prayer site")
        # Name – large, bold, gold‑coloured (GOLD). This makes it visually stand out as the primary heading.
        self.name_lbl.setStyleSheet(
            f"color:{GOLD};font-size:19px;font-weight:bold;"
        )
        self.name_lbl.setWordWrap(True)
        right.addWidget(self.name_lbl)

        # Description
        self.desc_lbl = QLabel("")
        # Description – subtle, muted colour, one line below. The label wraps automatically so longer descriptions don’t cause layout problems.
        self.desc_lbl.setStyleSheet(f"color:{MUTED};font-size:13px;")
        self.desc_lbl.setWordWrap(True)
        right.addWidget(self.desc_lbl)

        # URL + open-externally button in one row
        # URL row – combines the actual URL label with the “Open in browser” button.
        url_row = QHBoxLayout()
        self.url_lbl = QLabel("")
        # The URL uses the accent colour (likely a blue) to indicate it is clickable (though it is not directly interactive; the button beside it handles the action).
        self.url_lbl.setStyleSheet(f"color:{ACCENT};font-size:12px;")
        self.url_lbl.setWordWrap(True)
        url_row.addWidget(self.url_lbl, 1)

        self.ext_btn = QPushButton("↗  Open in browser")
        # The button is fixed at 170 px to prevent it from stretching.
        self.ext_btn.setFixedWidth(170)
        self.ext_btn.setEnabled(False)
        self.ext_btn.clicked.connect(self._open_external)
        url_row.addWidget(self.ext_btn)
        right.addLayout(url_row)

        # The horizontal rule (QFrame.HLine) separates the static information from the live browser below.
        hr = QFrame(); hr.setFrameShape(QFrame.HLine)
        hr.setStyleSheet(f"background:{BORDER};max-height:1px;")
        right.addWidget(hr)

        # Embedded browser
        # QWebEngineView is a full Chromium‑based browser. It respects HTTPS, cookies, and JavaScript
        # – exactly what is needed for modern web‑based prayer resources.
        self.web = QWebEngineView()
        # Initial placeholder – a minimal HTML page that instructs the user to select a site.
        # This is stored as a static method for cleanliness.
        self.web.setHtml(self._placeholder_html())
        # Connect "loadFinished" signal to _on_load_finished so we can detect blocked pages.
        # This is normally because the target site disallows embedding.
        self.web.loadFinished.connect(self._on_load_finished)
        right.addWidget(self.web, 1)

        root.addLayout(right, 1)

    # ── Helpers ───────────────────────────────────────────────────────────

    @staticmethod
    def _placeholder_html() -> str:
        return (
            f"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
            f"<style>body{{background:{BG};color:{MUTED};"
            f"font:16px sans-serif;display:flex;align-items:center;"
            f"justify-content:center;height:100vh;margin:0;text-align:center;}}"
            f".msg{{opacity:.8;line-height:1.6;}}</style></head>"
            f"<body><div class=\"msg\">Select a prayer site<br>"
            f"from the list to begin.</div></body></html>"
        )
    # Selecting a site
    def _select(self, idx: int) -> None:
        """Load the site at index idx into the embedded browser.
        """
        if not (0 <= idx < len(self.sites)):
            return
        site = self.sites[idx]
        self._current_url = site["url"]

        # The method updates the information card...
        self.name_lbl.setText(site["name"])
        self.desc_lbl.setText(site["desc"])
        self.url_lbl.setText(site["url"])
        # ... enables the external‑open button,
        self.ext_btn.setEnabled(True)
        # ... and triggers a full navigation in the web view.
        self.web.load(QUrl(site["url"]))

    # Handling blocked embeds – the _on_load_finished callback
    def _on_load_finished(self, ok: bool) -> None:
        """
        Called by Qt when the page finishes loading.

        If ok=False the load was blocked (ERR_BLOCKED_BY_RESPONSE,
        ERR_ABORTED, X-Frame-Options DENY, network errors, etc.).  We show a friendly
        fallback page that explains the situation and offers a button to
        open the site externally.

        Most prayer sites DO allow embedding.
        """
        if not ok and self._current_url:
            # When a page fails to load, ok will be False, e.g., ERR_BLOCKED_BY_RESPONSE, ERR_ABORTED, etc.
            # Show a helpful blocked page ...
            # Repeats the site name
            name = self.name_lbl.text()
            url  = self._current_url
            # Explains that embedding is not allowed
            html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
  body {{
    background: {BG};
    color: {TEXT};
    font: 15px "Noto Sans", sans-serif;
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100vh;
    margin: 0;
    text-align: center;
  }}
  .card {{
    max-width: 460px;
    padding: 32px;
    background: {PANEL};
    border-radius: 12px;
    border: 1px solid {BORDER};
  }}
  .icon {{ font-size: 36px; margin-bottom: 16px; }}
  h2 {{ color: {GOLD}; margin-bottom: 8px; }}
  p  {{ color: {MUTED}; line-height: 1.6; margin-bottom: 20px; }}
  a  {{
    display: inline-block;
    background: {ACCENT};
    color: white;
    padding: 10px 24px;
    border-radius: 6px;
    text-decoration: none;
    font-size: 14px;
  }}
  a:hover {{ background: #7aaee0; }}
</style>
</head>
<body>
  <div class="card">
    <div class="icon">🔒</div>
    <h2>{name}</h2>
    <p>
      This site does not allow embedding in external applications.<br>
      Press the button below to open it in your browser.
    </p>
Provides **a prominent link** that opens the site in the external browser** (using target="_blank" inside the injected HTML).
This gives the user a second way to reach the site without having to find the external button above.
  <a href="{url}" target="_blank">Open {name} in browser</a>
  </div>
</body>
</html>"""
            self.web.setHtml(html)
            # Highlight the external button
            self.ext_btn.setStyleSheet(
                f"background:{GOLD};color:#1a1f2e;font-weight:bold;"
            )

    def _open_external(self) -> None:
        """Open the current URL in the system default browser."""
        if self._current_url:
            import subprocess as _sp
            # xdg-open is the standard desktop‑independent command on Linux (and similar on other Unix‑like systems)
            # to launch the user’s preferred browser.
            # start_new_session=True detaches the child process from the prayer app so that
            # closing the application does not accidentally kill the user’s browser window.
            _sp.Popen(
                ["xdg-open", self._current_url],
                start_new_session=True,
            )

    def _reload(self) -> None:
        # Completely refreshes the internal site list from the file
        self.sites = load_prayer_sites()
        # Then, it rebuilds the list widget. Any newly added sites appear immediately.
        self.lst.clear()
        for s in self.sites:
            self.lst.addItem(s["name"])

    def _edit_file(self) -> None:
        # Tries to use a known graphical editor first (kate on KDE, gedit on GNOME).
        # If neither is available, it falls back to xdg-open, which will use the system’s file‑type association to open the text file (likely a plain‑text editor).
        editor = shutil.which("kate") or shutil.which("gedit") or "xdg-open"
        subprocess.Popen([editor, str(PRAYER_FILE)], start_new_session=True)
Bitcoin donation

JustToThePoint Copyright © 2011 - 2026 Anawim. ALL RIGHTS RESERVED. Bilingual e-books, articles, and videos to help your child and your entire family succeed, develop a healthy lifestyle, and have a lot of fun. Social Issues, Join us.

This website uses cookies to improve your navigation experience.
By continuing, you are consenting to our use of cookies, in accordance with our Cookies Policy and Website Terms and Conditions of use.