Bilingua

import streamlit as st
import os
import pickle
import yaml
from pathlib import Path
import re
from datetime import datetime

Print banner

st.set_page_config(
    page_title="Bilingua",
    layout="wide"
)

@st.cache_data
def print_banner():
    print("""
     _     _ _ _
    | |__ (_) (_)_ __   __ _ _   _  __ _
    | '_ \\| | | | '_ \\ / _` | | | |/ _` |
    | |_) | | | | | | | (_| | |_| | (_| |
    |_.__/|_|_|_|_| |_|\\__, |\\__,_|\\__,_|
                       |___/
    """)
    return 1

print_banner()

Init state

DEFAULTS = {
    "page": 0,
    "ptr": 0,
}

state_file = "bi.state"
config_file = "bi.yml"
page_len = 5

Serialize selected Streamlit session state variables to a pickle file.

def save_session_state(file_path: str = state_file) -> None:
    data = {k: st.session_state.get(k, DEFAULTS[k]) for k in DEFAULTS}
    with open(file_path, "wb") as f:
        pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)

Load session state variables from a pickle file if it exists. If not found or on error, assign default values.

def load_session_state(file_path: str = state_file) -> None:
    loaded = {}
    if os.path.exists(file_path):
        try:
            with open(file_path, "rb") as f:
                obj = pickle.load(f)
            if isinstance(obj, dict):
                loaded = obj
        except Exception:
            loaded = {}

    for key, default in DEFAULTS.items():
        st.session_state[key] = loaded.get(key, default)

Load YML file with configurations

def load_yaml_config(yaml_path: Path) -> dict:
    with yaml_path.open("r", encoding="utf-8") as f:
        return yaml.safe_load(f)

Normalize newlines and split on blank lines

def split_paragraphs(text: str) -> list[str]:
    text = text.replace("\r\n", "\n").replace("\r", "\n").strip()
    if not text:
        return []
    parts = re.split(r"\n\s*\n+", text)
    return [p.strip() for p in parts]

def read_text(path: Path) -> str:
    # Print current time in hh:mm:ss format
    current_time = datetime.now().strftime("%H:%M:%S")
    # print(f"[{current_time}] Reading {path}")
    return path.read_text(encoding="utf-8")

Main

load_session_state()
cfg = load_yaml_config(Path(config_file))
obsidian_path = Path(cfg["book"])
left_name = cfg["left"]
right_name = cfg["right"]
left_path = obsidian_path / left_name
right_path = obsidian_path / right_name

Check if paths to original Obsidian files exist

if not left_path.exists():
    st.error(f"Left file not found: {left_path}")
    st.stop()
if not right_path.exists():
    st.error(f"Right file not found: {right_path}")
    st.stop()

Check if there are markdown files with the same names in current folder. If yes, then use them

if os.path.exists(left_name):
    left_path = Path(left_name)
if os.path.exists(right_name):
    right_path = Path(right_name)

Load markdown files

left_text = read_text(left_path)
right_text = read_text(right_path)

left_pars = split_paragraphs(left_text)
right_pars = split_paragraphs(right_text)
ptr = st.session_state.ptr
max_page = max(len(left_pars), len(right_pars)) // page_len

Page transitions

def prev_page():
    if st.session_state.page > 0:
        st.session_state.page -= 1
        st.session_state.ptr = page_len - 1
        save_session_state()
        st.rerun()

def next_page():
    if st.session_state.page < max_page-1:
        st.session_state.page += 1
        st.session_state.ptr = 0
        save_session_state()
        st.rerun()

def prev_par():
    if st.session_state.ptr > 0:
        st.session_state.ptr -= 1
        save_session_state()
        st.rerun()
    else:
        prev_page()

def next_par():
    if st.session_state.ptr < page_len-1:
        st.session_state.ptr += 1
        save_session_state()
        st.rerun()
    else:
        next_page()

Top row of buttons

col_1, col_2, col_3 = st.columns(3)
with col_1:
    if st.button("Prev", width="stretch", icon=":material/step_out:"):
        prev_par()

with col_2:
    if st.button("Next", width="stretch", icon=":material/step_into:"):
        next_par()

with col_3:
    style = "text-align: right; font-weight: bold; font-size: small; text-decoration: underline; margin-right: 20px;"
    st.html(f"<div style='{style}'>Page {st.session_state.page+1}</div>")

Show 2 columns of bilingua text

page_start = st.session_state.page * page_len
for i in range(page_start, page_start + page_len):
    col_left, col_right = st.columns(2)
    lp = left_pars[i] if i < len(left_pars) else ""
    rp = right_pars[i] if i < len(right_pars) else ""
    with col_left:
        if i==ptr+page_start:
            st.session_state.left_text = st.text_area("lp", lp, height="content", label_visibility="hidden")
        else:
            st.markdown(lp)
    with col_right:
        if i==ptr+page_start:
            st.session_state.right_text = st.text_area("rp", rp, height="content", label_visibility="hidden")
        else:
            st.markdown("> " + rp)

Bottom row of buttons

st.divider()

col_1, col_2, col_3 = st.columns(3)

with col_1:
    if st.button("Prev Page", width="stretch", icon=":material/step_out:"):
        prev_page()

with col_2:
    if st.button("Next Page", width="stretch", icon=":material/step_into:"):
        next_page()

with col_3:
    if st.button("Save", type="primary", width="stretch"):
        # Save left_pars to left_name file
        left_pars[ptr+page_start] = st.session_state.left_text
        with open(left_name, "w", encoding="utf-8") as f:
            f.write("\n\n".join(left_pars))
        # Save right_pars to right_name file
        right_pars[ptr+page_start] = st.session_state.right_text
        with open(right_name, "w", encoding="utf-8") as f:
            f.write("\n\n".join(right_pars))
        st.rerun()