# -*- coding: utf-8 -*-
"""
:File: EuljiroBible/core/utils/utils_output.py
:Author: Benjamin Jaedon Choi - https://github.com/saintbenjamin
:Affiliated Church: The Eulji-ro Presbyterian Church [대한예수교장로회(통합) 을지로교회]
:Address: The Eulji-ro Presbyterian Church, 24-10, Eulji-ro 20-gil, Jung-gu, Seoul 04549, South Korea
:Telephone: +82-2-2266-3070
:E-mail: euljirochurch [at] G.M.A.I.L. (replace [at] with @ and G.M.A.I.L as you understood.)
:License: MIT License with Attribution Requirement (see LICENSE file for details); Copyright (c) 2025 The Eulji-ro Presbyterian Church.
Provides functions for formatting Bible verses and saving them atomically
to output files for EuljiroBible. Handles both GUI and CLI output logic.
"""
import os
import shutil
import time
import platform
from core.config import paths
from core.utils.logger import log_error
[docs]
def atomic_write(path, content, retries=5, delay=0.5):
"""
Atomically writes content to file using .tmp replacement pattern.
Prevents data corruption and handles permission issues gracefully.
Args:
path (str): Output file path.
content (str): Text content to write.
retries (int): Retry attempts on failure (default 5).
delay (float): Seconds between retries.
Raises:
Exception: Re-raised if final write attempt fails.
"""
try:
# Skip write if content hasn't changed
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
existing = f.read()
if existing == content:
return
tmp_path = path + ".tmp"
# Write to temporary file first
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
# Preserve original file permissions
if os.path.exists(path):
shutil.copymode(path, tmp_path)
# Attempt atomic replacement
for attempt in range(retries):
try:
os.replace(tmp_path, path)
return
except PermissionError:
if attempt < retries - 1:
time.sleep(delay)
else:
raise
except Exception as e:
log_error(e)
raise
[docs]
def resolve_output_path(settings, key="output_path"):
"""
Resolves and validates output path from user settings.
Ensures directory exists and avoids invalid platform-specific paths.
Args:
settings (dict): User/application settings.
key (str): Settings key to read path from.
Returns:
str: Absolute, safe output path.
"""
raw_path = settings.get(key)
fallback_path = os.path.join(paths.BASE_DIR, "verse_output.txt")
# Empty path fallback
if not raw_path:
settings[key] = fallback_path
return fallback_path
system = platform.system()
# Windows: check drive existence
if system == "Windows":
drive_letter = os.path.splitdrive(raw_path)[0]
if drive_letter and not os.path.exists(drive_letter + os.sep):
print(f"[WARNING] Drive {drive_letter} not found. Falling back to project root.")
settings[key] = fallback_path
return fallback_path
# Non-Windows: reject Windows-style paths
if system != "Windows" and raw_path.lower().startswith("c:/"):
print("[WARNING] Windows path detected on non-Windows system. Falling back.")
settings[key] = fallback_path
return fallback_path
abs_path = os.path.abspath(raw_path)
parent_dir = os.path.dirname(abs_path)
# Ensure output directory exists
if not os.path.exists(parent_dir):
os.makedirs(parent_dir, exist_ok=True)
return abs_path
[docs]
def save_to_files(merged, settings, parent=None):
"""
Saves the final merged text to disk using an atomic write strategy.
Ensures the file's modified timestamp is updated so that external
listeners (e.g., slide interruptor) can detect the change.
Args:
merged (str): Final display text to save.
settings (dict): Configuration containing output path.
parent (QWidget, optional): For GUI error dialogs. Defaults to None.
"""
output_path = resolve_output_path(settings)
try:
# Write content to disk atomically
atomic_write(output_path, merged)
# Force modified time update to trigger file system watchers
os.utime(output_path, None)
except Exception as e:
if parent:
from PySide6.QtWidgets import QMessageBox
rel_path = os.path.relpath(output_path, paths.BASE_DIR)
QMessageBox.critical(
parent,
parent.tr("error_saving_title"),
parent.tr("error_saving_msg_path").format(rel_path, e)
)
else:
log_error(e)