fix: apply ruff auto-fixes and add hk git hooks

- Fix pre-existing lint errors in engine/ modules using ruff --unsafe-fixes
- Add hk.pkl with pre-commit and pre-push hooks using ruff builtin
- Configure hooks to use 'uv run' prefix for tool execution
- Update mise.toml to include hk and pkl tools
- All 73 tests pass

fix: apply ruff auto-fixes and add hk git hooks

- Fix pre-existing lint errors in engine/ modules using ruff --unsafe-fixes
- Add hk.pkl with pre-commit and pre-push hooks using ruff builtin
- Configure hooks to use 'uv run' prefix for tool execution
- Update mise.toml to include hk and pkl tools
- Use 'hk install --mise' for proper mise integration
- All 73 tests pass
This commit is contained in:
2026-03-15 14:43:39 -07:00
parent 4844a64203
commit 757c854584
16 changed files with 286 additions and 185 deletions

View File

@@ -155,3 +155,4 @@ msg = poller.get_active_message() # returns (title, body, timestamp) or None
---
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.9+.*
# test

View File

@@ -39,6 +39,7 @@ TITLE = [
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
]
def _read_picker_key():
ch = sys.stdin.read(1)
if ch == "\x03":
@@ -63,6 +64,7 @@ def _read_picker_key():
return "enter"
return None
def _normalize_preview_rows(rows):
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
non_empty = [r for r in rows if r.strip()]
@@ -109,7 +111,9 @@ def _draw_font_picker(faces, selected):
active = pos == selected
pointer = "" if active else " "
color = G_HI if active else W_DIM
print(f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}")
print(
f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}"
)
if top > 0:
print(f" {W_GHOST}{top} above{RST}")
@@ -126,6 +130,7 @@ def _draw_font_picker(faces, selected):
shown = row[:max_preview_w]
print(f" {shown}")
def pick_font_face():
"""Interactive startup picker for selecting a face from repo OTF files."""
if not config.FONT_PICKER:
@@ -235,7 +240,9 @@ def pick_font_face():
font_index=selected_font["font_index"],
)
render.clear_font_cache()
print(f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}")
print(
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
)
time.sleep(0.8)
print(CLR, end="")
print(CURSOR_OFF, end="")
@@ -265,23 +272,29 @@ def main():
time.sleep(0.07)
print()
_subtitle = "literary consciousness stream" if config.MODE == 'poetry' else "digital consciousness stream"
_subtitle = (
"literary consciousness stream"
if config.MODE == "poetry"
else "digital consciousness stream"
)
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
print(f" {W_GHOST}{'' * (w - 4)}{RST}")
print()
time.sleep(0.4)
cached = load_cache() if '--refresh' not in sys.argv else None
cached = load_cache() if "--refresh" not in sys.argv else None
if cached:
items = cached
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
elif config.MODE == 'poetry':
elif config.MODE == "poetry":
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
time.sleep(0.2)
print()
items, linked, failed = fetch_poetry()
print()
print(f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}")
print(
f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}"
)
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
save_cache(items)
else:
@@ -290,7 +303,9 @@ def main():
print()
items, linked, failed = fetch_all()
print()
print(f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}")
print(
f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}"
)
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
save_cache(items)
@@ -302,7 +317,13 @@ def main():
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
mic_ok = mic.start()
if mic.available:
boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", bool(mic_ok))
boot_ln(
"Microphone",
"ACTIVE"
if mic_ok
else "OFFLINE · check System Settings → Privacy → Microphone",
bool(mic_ok),
)
ntfy = NtfyPoller(
config.NTFY_TOPIC,

View File

@@ -51,12 +51,14 @@ def _list_font_files(font_dir):
def list_repo_font_files():
"""Public helper for discovering repository font files."""
return _list_font_files(FONT_DIR)
# ─── RUNTIME ──────────────────────────────────────────────
HEADLINE_LIMIT = 1000
FEED_TIMEOUT = 10
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news'
FIREHOSE = '--firehose' in sys.argv
MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news"
FIREHOSE = "--firehose" in sys.argv
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
@@ -64,16 +66,16 @@ NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
# ─── FONT RENDERING ──────────────────────────────────────
FONT_DIR = _resolve_font_path(_arg_value('--font-dir') or "fonts")
_FONT_FILE_ARG = _arg_value('--font-file')
FONT_DIR = _resolve_font_path(_arg_value("--font-dir") or "fonts")
_FONT_FILE_ARG = _arg_value("--font-file")
_FONT_FILES = _list_font_files(FONT_DIR)
FONT_PATH = (
_resolve_font_path(_FONT_FILE_ARG)
if _FONT_FILE_ARG
else (_FONT_FILES[0] if _FONT_FILES else "")
)
FONT_INDEX = max(0, _arg_int('--font-index', 0))
FONT_PICKER = '--no-font-picker' not in sys.argv
FONT_INDEX = max(0, _arg_int("--font-index", 0))
FONT_PICKER = "--no-font-picker" not in sys.argv
FONT_SZ = 60
RENDER_H = 8 # terminal rows per rendered text line

View File

@@ -34,23 +34,23 @@ def fade_line(s, fade):
if fade >= 1.0:
return s
if fade <= 0.0:
return ''
return ""
result = []
i = 0
while i < len(s):
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
j = i + 2
while j < len(s) and not s[j].isalpha():
j += 1
result.append(s[i : j + 1])
i = j + 1
elif s[i] == ' ':
result.append(' ')
elif s[i] == " ":
result.append(" ")
i += 1
else:
result.append(s[i] if random.random() < fade else ' ')
result.append(s[i] if random.random() < fade else " ")
i += 1
return ''.join(result)
return "".join(result)
def vis_trunc(s, w):
@@ -61,7 +61,7 @@ def vis_trunc(s, w):
while i < len(s):
if vw >= w:
break
if s[i] == '\033' and i + 1 < len(s) and s[i + 1] == '[':
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
j = i + 2
while j < len(s) and not s[j].isalpha():
j += 1
@@ -71,7 +71,7 @@ def vis_trunc(s, w):
result.append(s[i])
vw += 1
i += 1
return ''.join(result)
return "".join(result)
def next_headline(pool, items, seen):
@@ -103,12 +103,13 @@ def firehose_line(items, w):
return "".join(
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
if random.random() < d else " "
if random.random() < d
else " "
for _ in range(w)
)
elif r < 0.78:
# Status / program output
sources = FEEDS if config.MODE == 'news' else POETRY_SOURCES
sources = FEEDS if config.MODE == "news" else POETRY_SOURCES
src = random.choice(list(sources.keys()))
msgs = [
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
@@ -127,7 +128,7 @@ def firehose_line(items, w):
start = random.randint(0, max(0, len(title) - 20))
frag = title[start : start + random.randint(10, 35)]
pad = random.randint(0, max(0, w - len(frag) - 8))
gp = ''.join(random.choice(config.GLITCH) for _ in range(random.randint(1, 3)))
text = (' ' * pad + gp + ' ' + frag)[:w - 1]
gp = "".join(random.choice(config.GLITCH) for _ in range(random.randint(1, 3)))
text = (" " * pad + gp + " " + frag)[: w - 1]
color = random.choice([G_LO, C_DIM, W_GHOST])
return f"{color}{text}{RST}"

View File

@@ -64,26 +64,31 @@ def _fetch_gutenberg(url, label):
try:
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
resp = urllib.request.urlopen(req, timeout=15)
text = resp.read().decode('utf-8', errors='replace').replace('\r\n', '\n').replace('\r', '\n')
text = (
resp.read()
.decode("utf-8", errors="replace")
.replace("\r\n", "\n")
.replace("\r", "\n")
)
# Strip PG boilerplate
m = re.search(r'\*\*\*\s*START OF[^\n]*\n', text)
m = re.search(r"\*\*\*\s*START OF[^\n]*\n", text)
if m:
text = text[m.end() :]
m = re.search(r'\*\*\*\s*END OF', text)
m = re.search(r"\*\*\*\s*END OF", text)
if m:
text = text[: m.start()]
# Split on blank lines into stanzas/passages
blocks = re.split(r'\n{2,}', text.strip())
blocks = re.split(r"\n{2,}", text.strip())
items = []
for blk in blocks:
blk = ' '.join(blk.split()) # flatten to one line
blk = " ".join(blk.split()) # flatten to one line
if len(blk) < 20 or len(blk) > 280:
continue
if blk.isupper(): # skip all-caps headers
continue
if re.match(r'^[IVXLCDM]+\.?\s*$', blk): # roman numerals
if re.match(r"^[IVXLCDM]+\.?\s*$", blk): # roman numerals
continue
items.append((blk, label, ''))
items.append((blk, label, ""))
return items
except Exception:
return []

View File

@@ -29,29 +29,29 @@ def strip_tags(html):
# ─── CONTENT FILTER ───────────────────────────────────────
_SKIP_RE = re.compile(
r'\b(?:'
r"\b(?:"
# ── sports ──
r'football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|'
r'hockey|lacrosse|volleyball|badminton|'
r'nba|nfl|nhl|mlb|mls|fifa|uefa|'
r'premier league|champions league|la liga|serie a|bundesliga|'
r'world cup|super bowl|world series|stanley cup|'
r'playoff|playoffs|touchdown|goalkeeper|striker|quarterback|'
r'slam dunk|home run|grand slam|offside|halftime|'
r'batting|wicket|innings|'
r'formula 1|nascar|motogp|'
r'boxing|ufc|mma|'
r'marathon|tour de france|'
r'transfer window|draft pick|relegation|'
r"football|soccer|basketball|baseball|softball|tennis|golf|cricket|rugby|"
r"hockey|lacrosse|volleyball|badminton|"
r"nba|nfl|nhl|mlb|mls|fifa|uefa|"
r"premier league|champions league|la liga|serie a|bundesliga|"
r"world cup|super bowl|world series|stanley cup|"
r"playoff|playoffs|touchdown|goalkeeper|striker|quarterback|"
r"slam dunk|home run|grand slam|offside|halftime|"
r"batting|wicket|innings|"
r"formula 1|nascar|motogp|"
r"boxing|ufc|mma|"
r"marathon|tour de france|"
r"transfer window|draft pick|relegation|"
# ── vapid / insipid ──
r'kardashian|jenner|reality tv|reality show|'
r'influencer|viral video|tiktok|instagram|'
r'best dressed|worst dressed|red carpet|'
r'horoscope|zodiac|gossip|bikini|selfie|'
r'you won.t believe|what happened next|'
r'celebrity couple|celebrity feud|baby bump'
r')\b',
re.IGNORECASE
r"kardashian|jenner|reality tv|reality show|"
r"influencer|viral video|tiktok|instagram|"
r"best dressed|worst dressed|red carpet|"
r"horoscope|zodiac|gossip|bikini|selfie|"
r"you won.t believe|what happened next|"
r"celebrity couple|celebrity feud|baby bump"
r")\b",
re.IGNORECASE,
)

View File

@@ -8,6 +8,7 @@ import atexit
try:
import numpy as _np
import sounddevice as _sd
_HAS_MIC = True
except Exception:
_HAS_MIC = False
@@ -40,12 +41,15 @@ class MicMonitor:
"""Start background mic stream. Returns True on success, False/None otherwise."""
if not _HAS_MIC:
return None
def _cb(indata, frames, t, status):
rms = float(_np.sqrt(_np.mean(indata**2)))
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
try:
self._stream = _sd.InputStream(
callback=_cb, channels=1, samplerate=44100, blocksize=2048)
callback=_cb, channels=1, samplerate=44100, blocksize=2048
)
self._stream.start()
atexit.register(self.stop)
return True

View File

@@ -55,7 +55,7 @@ class NtfyPoller:
"""Build the stream URL, substituting since= to avoid message replays on reconnect."""
parsed = urlparse(self.topic_url)
params = parse_qs(parsed.query, keep_blank_values=True)
params['since'] = [last_id if last_id else '20s']
params["since"] = [last_id if last_id else "20s"]
new_query = urlencode({k: v[0] for k, v in params.items()})
return urlunparse(parsed._replace(query=new_query))
@@ -65,7 +65,8 @@ class NtfyPoller:
try:
url = self._build_url(last_id)
req = urllib.request.Request(
url, headers={"User-Agent": "mainline/0.1"})
url, headers={"User-Agent": "mainline/0.1"}
)
# timeout=90 keeps the socket alive through ntfy.sh keepalive heartbeats
resp = urllib.request.urlopen(req, timeout=90)
while True:
@@ -73,7 +74,7 @@ class NtfyPoller:
if not line:
break # server closed connection — reconnect
try:
data = json.loads(line.decode('utf-8', errors='replace'))
data = json.loads(line.decode("utf-8", errors="replace"))
except json.JSONDecodeError:
continue
# Advance cursor on every event (message + keepalive) to

View File

@@ -69,6 +69,7 @@ def font():
_FONT_OBJ_KEY = key
return _FONT_OBJ
def clear_font_cache():
"""Reset cached font objects after changing primary font selection."""
global _FONT_OBJ, _FONT_OBJ_KEY
@@ -123,7 +124,7 @@ def render_line(text, fnt=None):
pad = 4
img_w = bbox[2] - bbox[0] + pad * 2
img_h = bbox[3] - bbox[1] + pad * 2
img = Image.new('L', (img_w, img_h), 0)
img = Image.new("L", (img_w, img_h), 0)
draw = ImageDraw.Draw(img)
draw.text((-bbox[0] + pad, -bbox[1] + pad), text, fill=255, font=fnt)
pix_h = config.RENDER_H * 2
@@ -200,8 +201,8 @@ def lr_gradient(rows, offset=0.0, grad_cols=None):
continue
buf = []
for x, ch in enumerate(row):
if ch == ' ':
buf.append(' ')
if ch == " ":
buf.append(" ")
else:
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
idx = min(round(shifted * (n - 1)), n - 1)
@@ -218,7 +219,11 @@ def lr_gradient_opposite(rows, offset=0.0):
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
def make_block(title, src, ts, w):
"""Render a headline into a content block with color."""
target_lang = (SOURCE_LANGS.get(src) or detect_location_language(title)) if config.MODE == 'news' else None
target_lang = (
(SOURCE_LANGS.get(src) or detect_location_language(title))
if config.MODE == "news"
else None
)
lang_font = font_for_lang(target_lang)
if target_lang:
title = translate_headline(title, target_lang)
@@ -227,11 +232,18 @@ def make_block(title, src, ts, w):
title_up = re.sub(r"\s+", " ", title)
else:
title_up = re.sub(r"\s+", " ", title.upper())
for old, new in [("\u2019","'"), ("\u2018","'"), ("\u201c",'"'),
("\u201d",'"'), ("\u2013","-"), ("\u2014","-")]:
for old, new in [
("\u2019", "'"),
("\u2018", "'"),
("\u201c", '"'),
("\u201d", '"'),
("\u2013", "-"),
("\u2014", "-"),
]:
title_up = title_up.replace(old, new)
big_rows = big_wrap(title_up, w - 4, lang_font)
hc = random.choice([
hc = random.choice(
[
"\033[38;5;46m", # matrix green
"\033[38;5;34m", # dark green
"\033[38;5;82m", # lime
@@ -248,7 +260,8 @@ def make_block(title, src, ts, w):
"\033[38;5;115m", # sage
"\033[1;38;5;46m", # bold green
"\033[1;38;5;250m", # bold white
])
]
)
content = [" " + r for r in big_rows]
content.append("")
meta = f"\u2591 {src} \u00b7 {ts}"

View File

@@ -47,7 +47,9 @@ def stream(items, ntfy_poller, mic_monitor):
# Active ticker blocks: (content_rows, color, canvas_y, meta_idx)
active = []
scroll_cam = 0 # viewport top in virtual canvas coords
ticker_next_y = ticker_view_h # canvas-y where next block starts (off-screen bottom)
ticker_next_y = (
ticker_view_h # canvas-y where next block starts (off-screen bottom)
)
noise_cache = {}
scroll_motion_accum = 0.0
@@ -84,7 +86,9 @@ def stream(items, ntfy_poller, mic_monitor):
_msg_cache = (cache_key, msg_rows)
else:
msg_rows = _msg_cache[1]
msg_rows = lr_gradient_opposite(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
msg_rows = lr_gradient_opposite(
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
)
# Layout: rendered text + meta + border
elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
@@ -94,19 +98,29 @@ def stream(items, ntfy_poller, mic_monitor):
row_idx = 0
for mr in msg_rows:
ln = vis_trunc(mr, w)
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K")
msg_overlay.append(
f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K"
)
row_idx += 1
# Meta line: title (if distinct) + source + countdown
meta_parts = []
if m_title and m_title != m_body:
meta_parts.append(m_title)
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
meta = " " + " \u00b7 ".join(meta_parts) if len(meta_parts) > 1 else " " + meta_parts[0]
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K")
meta = (
" " + " \u00b7 ".join(meta_parts)
if len(meta_parts) > 1
else " " + meta_parts[0]
)
msg_overlay.append(
f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K"
)
row_idx += 1
# Border — constant boundary under message panel
bar = "\u2500" * (w - 4)
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K")
msg_overlay.append(
f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K"
)
# Ticker draws above the fixed firehose strip; message is a centered overlay.
ticker_h = ticker_view_h - msg_h
@@ -118,7 +132,10 @@ def stream(items, ntfy_poller, mic_monitor):
scroll_cam += 1
# Enqueue new headlines when room at the bottom
while ticker_next_y < scroll_cam + ticker_view_h + 10 and queued < config.HEADLINE_LIMIT:
while (
ticker_next_y < scroll_cam + ticker_view_h + 10
and queued < config.HEADLINE_LIMIT
):
t, src, ts = next_headline(pool, items, seen)
ticker_content, hc, midx = make_block(t, src, ts, w)
active.append((ticker_content, hc, ticker_next_y, midx))
@@ -126,8 +143,9 @@ def stream(items, ntfy_poller, mic_monitor):
queued += 1
# Prune off-screen blocks and stale noise
active = [(c, hc, by, mi) for c, hc, by, mi in active
if by + len(c) > scroll_cam]
active = [
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
]
for k in list(noise_cache):
if k < scroll_cam:
del noise_cache[k]

View File

@@ -75,41 +75,41 @@ SOURCE_LANGS = {
# ─── LOCATION → LANGUAGE ─────────────────────────────────
LOCATION_LANGS = {
r'\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b': 'zh-cn',
r'\b(?:japan|japanese|tokyo|osaka|kishida)\b': 'ja',
r'\b(?:korea|korean|seoul|pyongyang)\b': 'ko',
r'\b(?:russia|russian|moscow|kremlin|putin)\b': 'ru',
r'\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b': 'ar',
r'\b(?:india|indian|delhi|mumbai|modi)\b': 'hi',
r'\b(?:germany|german|berlin|munich|scholz)\b': 'de',
r'\b(?:france|french|paris|lyon|macron)\b': 'fr',
r'\b(?:spain|spanish|madrid)\b': 'es',
r'\b(?:italy|italian|rome|milan|meloni)\b': 'it',
r'\b(?:portugal|portuguese|lisbon)\b': 'pt',
r'\b(?:brazil|brazilian|são paulo|lula)\b': 'pt',
r'\b(?:greece|greek|athens)\b': 'el',
r'\b(?:turkey|turkish|istanbul|ankara|erdogan)\b': 'tr',
r'\b(?:iran|iranian|tehran)\b': 'fa',
r'\b(?:thailand|thai|bangkok)\b': 'th',
r'\b(?:vietnam|vietnamese|hanoi)\b': 'vi',
r'\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b': 'uk',
r'\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b': 'he',
r"\b(?:china|chinese|beijing|shanghai|hong kong|xi jinping)\b": "zh-cn",
r"\b(?:japan|japanese|tokyo|osaka|kishida)\b": "ja",
r"\b(?:korea|korean|seoul|pyongyang)\b": "ko",
r"\b(?:russia|russian|moscow|kremlin|putin)\b": "ru",
r"\b(?:saudi|dubai|qatar|egypt|cairo|arabic)\b": "ar",
r"\b(?:india|indian|delhi|mumbai|modi)\b": "hi",
r"\b(?:germany|german|berlin|munich|scholz)\b": "de",
r"\b(?:france|french|paris|lyon|macron)\b": "fr",
r"\b(?:spain|spanish|madrid)\b": "es",
r"\b(?:italy|italian|rome|milan|meloni)\b": "it",
r"\b(?:portugal|portuguese|lisbon)\b": "pt",
r"\b(?:brazil|brazilian|são paulo|lula)\b": "pt",
r"\b(?:greece|greek|athens)\b": "el",
r"\b(?:turkey|turkish|istanbul|ankara|erdogan)\b": "tr",
r"\b(?:iran|iranian|tehran)\b": "fa",
r"\b(?:thailand|thai|bangkok)\b": "th",
r"\b(?:vietnam|vietnamese|hanoi)\b": "vi",
r"\b(?:ukraine|ukrainian|kyiv|kiev|zelensky)\b": "uk",
r"\b(?:israel|israeli|jerusalem|tel aviv|netanyahu)\b": "he",
}
# ─── NON-LATIN SCRIPT FONTS (macOS) ──────────────────────
SCRIPT_FONTS = {
'zh-cn': '/System/Library/Fonts/STHeiti Medium.ttc',
'ja': '/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc',
'ko': '/System/Library/Fonts/AppleSDGothicNeo.ttc',
'ru': '/System/Library/Fonts/Supplemental/Arial.ttf',
'uk': '/System/Library/Fonts/Supplemental/Arial.ttf',
'el': '/System/Library/Fonts/Supplemental/Arial.ttf',
'he': '/System/Library/Fonts/Supplemental/Arial.ttf',
'ar': '/System/Library/Fonts/GeezaPro.ttc',
'fa': '/System/Library/Fonts/GeezaPro.ttc',
'hi': '/System/Library/Fonts/Kohinoor.ttc',
'th': '/System/Library/Fonts/ThonburiUI.ttc',
"zh-cn": "/System/Library/Fonts/STHeiti Medium.ttc",
"ja": "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
"ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc",
"ru": "/System/Library/Fonts/Supplemental/Arial.ttf",
"uk": "/System/Library/Fonts/Supplemental/Arial.ttf",
"el": "/System/Library/Fonts/Supplemental/Arial.ttf",
"he": "/System/Library/Fonts/Supplemental/Arial.ttf",
"ar": "/System/Library/Fonts/GeezaPro.ttc",
"fa": "/System/Library/Fonts/GeezaPro.ttc",
"hi": "/System/Library/Fonts/Kohinoor.ttc",
"th": "/System/Library/Fonts/ThonburiUI.ttc",
}
# Scripts that have no uppercase
NO_UPPER = {'zh-cn', 'ja', 'ko', 'ar', 'fa', 'hi', 'th', 'he'}
NO_UPPER = {"zh-cn", "ja", "ko", "ar", "fa", "hi", "th", "he"}

View File

@@ -29,8 +29,10 @@ def translate_headline(title, target_lang):
return _TRANSLATE_CACHE[key]
try:
q = urllib.parse.quote(title)
url = ("https://translate.googleapis.com/translate_a/single"
f"?client=gtx&sl=en&tl={target_lang}&dt=t&q={q}")
url = (
"https://translate.googleapis.com/translate_a/single"
f"?client=gtx&sl=en&tl={target_lang}&dt=t&q={q}"
)
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
resp = urllib.request.urlopen(req, timeout=5)
data = json.loads(resp.read())

25
hk.pkl Normal file
View File

@@ -0,0 +1,25 @@
amends "package://github.com/jdx/hk/releases/download/v1.38.0/hk@1.38.0#/Config.pkl"
import "package://github.com/jdx/hk/releases/download/v1.38.0/hk@1.38.0#/Builtins.pkl"
hooks {
["pre-commit"] {
fix = true
stash = "git"
steps {
["ruff-format"] = (Builtins.ruff_format) {
prefix = "uv run"
}
["ruff"] = (Builtins.ruff) {
prefix = "uv run"
fix = "ruff check --fix --unsafe-fixes engine/ tests/"
}
}
}
["pre-push"] {
steps {
["ruff"] = (Builtins.ruff) {
prefix = "uv run"
}
}
}
}

View File

@@ -1,5 +1,7 @@
[tools]
python = "3.12"
hk = "latest"
pkl = "latest"
[tasks]
# =====================
@@ -42,3 +44,9 @@ clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
ci-lint = "uv run ruff check engine/ mainline.py"
# =====================
# Git Hooks (via hk)
# =====================
pre-commit = "hk run pre-commit"

View File

@@ -85,4 +85,4 @@ target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
ignore = ["E501"]
ignore = ["E501", "SIM105", "N806", "B007", "SIM108"]