retronews.py (52938B)
1 #!/usr/bin/env python3 2 # 3 # Copyright (c) luke8086 4 # 5 # This program is free software: you can redistribute it and/or modify 6 # it under the terms of the GNU General Public License version 2 as published by 7 # the Free Software Foundation. 8 # 9 10 import sys 11 12 # if sys.version_info < (3, 9): 13 # sys.stderr.write("Python 3.9 or newer is required.\n") 14 # sys.exit(1) 15 16 import argparse 17 import curses 18 import curses.textpad 19 import html.parser 20 import json 21 import logging 22 import os 23 import re 24 import sqlite3 25 import traceback 26 import unicodedata 27 import urllib.request 28 import webbrowser 29 import time 30 from collections import defaultdict 31 from datetime import datetime 32 from functools import partial, reduce 33 from textwrap import wrap 34 35 USER_AGENT = "retronews" 36 37 KEY_BINDINGS = { 38 ord("q"): lambda app: cmd_quit(app), 39 ord("?"): lambda app: cmd_help(app), 40 ord("\n"): lambda app: cmd_open(app), 41 ord(" "): lambda app: cmd_open(app), 42 ord("o"): lambda app: cmd_show_links(app), 43 ord("x"): lambda app: cmd_close(app), 44 ord("s"): lambda app: cmd_star(app), 45 ord("S"): lambda app: cmd_star_thread(app), 46 ord("u"): lambda app: cmd_set_unread(app), 47 ord("D"): lambda app: cmd_dump(app), 48 ord("r"): lambda app: cmd_toggle_raw_mode(app), 49 ord("k"): lambda app: cmd_up(app), 50 ord("j"): lambda app: cmd_down(app), 51 ord("p"): lambda app: cmd_prev(app), 52 ord("n"): lambda app: cmd_next(app), 53 ord("N"): lambda app: cmd_next_unread(app), 54 ord("P"): lambda app: cmd_parent(app), 55 ord(";"): lambda app: cmd_mark_set(app), 56 ord(","): lambda app: cmd_mark_jump(app), 57 ord("R"): lambda app: cmd_reload_page(app), 58 ord("<"): lambda app: cmd_load_prev_page(app), 59 ord(">"): lambda app: cmd_load_next_page(app), 60 ord("g"): lambda app: cmd_load_page(app), 61 curses.KEY_UP: lambda app: cmd_prev(app), 62 curses.KEY_DOWN: lambda app: cmd_next(app), 63 curses.KEY_PPAGE: lambda app: cmd_page_up(app), 64 curses.KEY_NPAGE: lambda app: cmd_page_down(app), 65 curses.KEY_RESIZE: lambda app: cmd_resize(app), 66 } 67 KEY_BINDINGS.update({ord(str(i)): lambda app, i=i: cmd_load_tab(app, i) for i in range(1, 10)}) 68 69 HELP_MENU = "q:Quit ?:Help p:Prev n:Next N:Next-Unread j:Down k:Up x:Close s:Star" 70 71 HELP_SCREEN = """\ 72 q Quit retronews 73 UP, DOWN Go up / down by one message / pager line 74 PG UP, PG DOWN Gp up / down by one page of messages / pager lines 75 p, n Go to previous / next message 76 N Go to next unread message 77 P Go to parent message 78 ; , Set mark, jump to mark & swap (valid within thread) 79 RETURN, SPACE Open selected message 80 x Close current message / thread 81 o Select link and open in browser 82 1 - 9 Change group 83 R Refresh current page 84 < > Go to previous / next page 85 g Go to specific page 86 k j Scroll pager up / down by one line 87 s Star / unstar current message 88 S Star / unstar current thread 89 u Mark current message as unread 90 r Toggle raw HTML mode 91 92 See https://github.com/luke8086/retronews for more information.""" 93 94 COLORS = { 95 "author": (curses.COLOR_YELLOW, -1), 96 "code": (curses.COLOR_GREEN, -1), 97 "cursor": (curses.COLOR_BLACK, curses.COLOR_CYAN), 98 "date": (curses.COLOR_CYAN, -1), 99 "default": (curses.COLOR_WHITE, -1), 100 "empty_pager_line": (curses.COLOR_GREEN, -1), 101 "deleted_message_pager_line": (curses.COLOR_RED, -1), 102 "menu": (curses.COLOR_GREEN, curses.COLOR_BLUE), 103 "menu_active": (curses.COLOR_YELLOW, curses.COLOR_BLUE), 104 "nested_quote": (curses.COLOR_CYAN, -1), 105 "quote": (curses.COLOR_YELLOW, -1), 106 "starred_subject": (curses.COLOR_CYAN, -1), 107 "header_subject": (curses.COLOR_GREEN, -1), 108 "tree": (curses.COLOR_RED, -1), 109 "unread_comments": (curses.COLOR_GREEN, -1), 110 "url": (curses.COLOR_MAGENTA, -1), 111 } 112 113 PREFERRED_PAGE_SIZE = 30 114 UNREAD_SIZE = 3 115 UNREAD_MANY_CHAR = '!' 116 AUTHOR_SIZE = 10 117 COLUMN_SPACING = 2 118 DATECOL_SIZE = 16 119 DATECOL_FUNC = lambda date: date.strftime("%Y-%m-%d %H:%M") 120 121 REQUEST_TIMEOUT = 10 122 123 # Recognize ">text", "> text", ">>text", ">> text", etc. 124 QUOTE_REX = re.compile(r"^(> ?)+") 125 126 # Recognize "[n] link", "[n]: link", "[n] - link", etc. 127 REFERENCE_REX = re.compile(r"^\[\d+\][ :-]*https?://[^ ]*$") 128 129 # Recognize http/https URLs 130 URL_REX = re.compile(r"(https?://[^\s\)\"<,]+[^\s\)\"<,\.])") 131 132 # Recognize HN message URLs 133 HN_URL_REX = re.compile(r"^https://news\.ycombinator\.com/item\?id=(\d+)$") 134 LB_URL_REX = re.compile(r"^https://lobste\.rs/s/([a-z0-9]{6}).*$") 135 136 TRUNCATE = lambda s,l: s[:l] 137 138 139 class ExitException(Exception): 140 def __init__(self, code = 0, message = ""): 141 self.code = code 142 self.message = message 143 144 super().__init__(message) 145 146 147 class Provider: 148 def __init__(self, fetch_thread, fetch_threads_by_id): 149 self.fetch_thread = fetch_thread 150 self.fetch_threads_by_id = fetch_threads_by_id 151 152 PROVIDERS = { 153 "hn": Provider( 154 fetch_thread=lambda msg_id: hn_fetch_thread(msg_id), 155 fetch_threads_by_id=lambda msg_ids: hn_fetch_threads_by_id(msg_ids), 156 ), 157 "lb": Provider( 158 fetch_thread=lambda msg_id: lb_fetch_thread(msg_id), 159 fetch_threads_by_id=lambda msg_ids: [lb_fetch_thread(x) for x in msg_ids], 160 ), 161 } 162 163 164 class Group: 165 def __init__(self, label, fetch, page=0): 166 self.label = label 167 self.fetch = fetch 168 self.page = page 169 170 171 GROUP_TABS = [ 172 Group(label="Front HN", fetch=lambda db, page: hn_fetch_threads("news", page)), 173 Group(label="New HN", fetch=lambda db, page: hn_fetch_new_threads(page)), 174 Group(label="Ask HN", fetch=lambda db, page: hn_fetch_threads("ask", page)), 175 Group(label="Show HN", fetch=lambda db, page: hn_fetch_threads("show", page)), 176 Group(label="Active HN", fetch=lambda db, page: hn_fetch_threads("active", page)), 177 Group(label="Front LB", fetch=lambda db, page: lb_fetch_threads("", page)), 178 Group(label="New LB", fetch=lambda db, page: lb_fetch_threads("newest", page)), 179 Group(label="Starred", fetch=lambda db, page: group_fetch_starred_threads(db, page)), 180 ] 181 182 183 class MessageFlags: 184 def __init__(self, read=False, starred=False): 185 self.read = False 186 self.starred = False 187 188 189 class Message: 190 def __init__(self, msg_id, thread_id, content_location, date, author, title, 191 body=None, children=None, total_comments=0, parent=None, url=None): 192 self.msg_id = msg_id 193 self.thread_id = thread_id 194 self.content_location = content_location 195 self.date = date 196 self.author = author 197 self.title = title 198 self.body = body 199 self.children = children 200 self.total_comments = total_comments 201 self.parent = parent 202 self.lines = [] 203 self.flags = MessageFlags() 204 self.read_comments = 0 205 self.index_position = 0 206 self.index_tree = "" 207 self.url = url 208 209 @property 210 def is_read(self): 211 return self.flags.read 212 213 @property 214 def is_shown_as_read(self): 215 # If the message is an unloaded thread, check if all comments are read 216 return self.read_comments >= self.total_comments if self.is_thread and self.children is None else self.is_read 217 218 @property 219 def is_thread(self): 220 return self.msg_id == self.thread_id 221 222 @property 223 def is_deleted(self): 224 return self.author is None 225 226 227 class Layout: 228 def __init__(self): 229 self.lines = 0 230 self.cols = 0 231 self.top_menu_row = 0 232 self.index_start = 1 233 self.index_height = 0 234 self.middle_menu_row = None 235 self.pager_start = None 236 self.pager_height = None 237 self.bottom_menu_row = 0 238 self.flash_menu_row = 0 239 240 241 class AppState: 242 def __init__(self, screen, db, group, ascii=False, monochrome=False): 243 self.screen = screen 244 self.db = db 245 self.group = group 246 self.ascii = ascii 247 self.monochrome = monochrome 248 self.colors = {} 249 self.messages = [] 250 self.messages_by_id = {} 251 self.selected_message = None 252 self.marked_message_id = "" 253 self.layout = Layout() 254 self.pager_visible = False 255 self.pager_offset = 0 256 self.raw_mode = False 257 self.flash = None 258 259 260 HTML_BLOCK_TAGS = set(("root", "p", "pre", "blockquote", "ul", "ol", "li", "hr")) 261 HTML_INLINE_TAGS = set(("code", "a", "em", "strong", "b", "br")) 262 HTML_KNOWN_TAGS = HTML_BLOCK_TAGS.union(HTML_INLINE_TAGS) 263 HTML_AUTOCLOSE_TAGS = set(("hr", "br")) 264 265 266 class HTMLNode: 267 def __init__(self, tag, attrs=None, text="", pre=False): 268 self.tag = tag 269 self.text = text 270 self.pre = pre 271 self.attrs = {} if attrs is None else attrs 272 self.parent = None 273 self.prev_sibling = None 274 self.next_sibling = None 275 self.first_child = None 276 self.last_child = None 277 278 279 class HTMLParser(html.parser.HTMLParser): 280 def __init__(self): 281 super().__init__() 282 self.pre_level = 0 283 self.root_node = self.current_node = HTMLNode(tag="root") 284 285 def handle_data(self, data): 286 if self.current_node.tag in HTML_AUTOCLOSE_TAGS: 287 self.handle_endtag(self.current_node.tag) 288 289 node = HTMLNode(tag="text", text=data, pre=self.pre_level > 0) 290 html_node_append(self.current_node, node) 291 292 def handle_starttag(self, tag, attrs): 293 if self.current_node.tag in HTML_AUTOCLOSE_TAGS: 294 self.handle_endtag(self.current_node.tag) 295 296 if tag not in HTML_KNOWN_TAGS: 297 return 298 299 if tag == "pre": 300 self.pre_level += 1 301 302 node = HTMLNode(tag=tag, attrs=dict(attrs), pre=self.pre_level > 0) 303 html_node_append(self.current_node, node) 304 self.current_node = node 305 306 def handle_endtag(self, tag): 307 if tag not in HTML_KNOWN_TAGS: 308 return 309 310 if tag == "pre": 311 self.pre_level = max(0, self.pre_level - 1) 312 313 while True: 314 node = self.current_node 315 316 if node.parent is None: 317 break 318 319 self.current_node = node.parent 320 321 if node.tag == tag: 322 break 323 324 325 def text_wrap(text, width=70): 326 if len(text) == 0: 327 # Preserve empty lines 328 return "" 329 330 if text.startswith(" "): 331 # Preserve code indentation 332 return text 333 334 if REFERENCE_REX.match(text): 335 # Keep reference numbers with long links in the same line 336 return text 337 338 indent = "" 339 340 match = QUOTE_REX.match(text) 341 if match is not None: 342 # Preserve quotation symbols in subsequent lines 343 indent = match[0] 344 345 lines = wrap(text, width, subsequent_indent=indent, break_on_hyphens=False, break_long_words=False) 346 lines = [line.rstrip() for line in lines] 347 348 return "\n".join(lines) 349 350 351 def text_clean(text, ascii = False): 352 """Cleanup text for rendering, currently only removes non-ascii characters in ascii mode""" 353 354 if ascii: 355 text = text.encode("ascii", "replace").decode("ascii") 356 357 return text 358 359 360 def text_unindent(text): 361 lines = text.split("\n") 362 363 while all(line.startswith(" ") or line == "" for line in lines): 364 lines = [line[1:] for line in lines] 365 366 return "\n".join(lines) 367 368 369 def text_sanitize(text): 370 # For safety, remove any control characters except for \n and \t 371 # At least on HN some messages contain \x00 characters 372 373 text = text or "" 374 allowed_cc = set(("\n", "\t")) 375 chars = (c for c in text if c in allowed_cc or unicodedata.category(c) != "Cc") 376 text = "".join(chars) 377 378 return text 379 380 381 def text_split_urls(text): 382 return [p for p in URL_REX.split(text) if p != ""] 383 384 385 def html_node_children(parent): 386 ret = [] 387 node = parent.first_child 388 389 while node is not None: 390 ret.append(node) 391 node = node.next_sibling 392 393 return ret 394 395 396 def html_node_append(parent, child): 397 if parent.first_child is None: 398 parent.first_child = child 399 400 if parent.last_child is not None: 401 parent.last_child.next_sibling = child 402 child.prev_sibling = parent.last_child 403 404 parent.last_child = child 405 child.parent = parent 406 407 408 def html_node_unlink(node): 409 if node.prev_sibling: 410 node.prev_sibling.next_sibling = node.next_sibling 411 412 if node.next_sibling: 413 node.next_sibling.prev_sibling = node.prev_sibling 414 415 if node.parent and node.parent.first_child is node: 416 node.parent.first_child = node.next_sibling 417 418 if node.parent and node.parent.last_child is node: 419 node.parent.last_child = node.prev_sibling 420 421 node.parent = node.prev_sibling = node.next_sibling = None 422 423 424 def html_node_dump(node): 425 lines = [] 426 lines.append("{} {}".format(node.tag, repr(node.attrs))) 427 428 for child in html_node_children(node): 429 if child.tag == "text": 430 lines.append(" text " + repr(child.text)) 431 else: 432 lines += [" " + line for line in html_node_dump(child).split("\n")] 433 434 return "\n".join(lines) 435 436 437 def html_node_trim_whitespace(node): 438 if node.pre: 439 node.text = node.text.rstrip("\r\n\t ") 440 return 441 442 text = node.text.strip("\r\n\t ") 443 text = re.sub(r"[\r\n\t ]+", " ", text) 444 text = text.replace("\x00", "\n") 445 text = re.sub(r" *\n *", "\n", text) 446 447 node.text = text 448 449 450 def html_node_process_inline(node, inline=False): 451 """Traverse tree flattening all inline nodes into text nodes""" 452 453 if node.tag not in HTML_BLOCK_TAGS: 454 # Never disable inline if already enabled 455 inline = True 456 457 text = "".join(html_node_process_inline(c, inline) for c in html_node_children(node)) 458 459 if not inline: 460 return "" 461 462 if node.tag == "text": 463 text = node.text 464 elif node.tag == "br": 465 text = "\x00" 466 elif node.tag == "em" or node.tag == "i": 467 text = "/{}/".format(text) 468 elif node.tag == "strong" or node.tag == "b": 469 text = "*{}*".format(text) 470 elif node.tag == "code" and not node.pre: 471 text = "`{}`".format(text) 472 elif node.tag == "a": 473 href = node.attrs.get("href", "") or "" 474 if text.endswith("...") and href.startswith(text[:-3]): 475 # Workaround for link formatting on HN 476 text = href 477 elif text != href: 478 text = "{} {}".format(text, href) 479 480 node.tag = "text" 481 node.text = text 482 node.first_child = node.last_child = None 483 484 return text 485 486 487 def html_node_process_text(node): 488 """Traverse tree merging, trimming and pruning text nodes""" 489 490 for child in html_node_children(node): 491 html_node_process_text(child) 492 493 # Merge adjacent text nodes 494 for child in html_node_children(node): 495 prev = child.prev_sibling 496 if child.tag == "text" and prev is not None and prev.tag == "text" and child.pre == prev.pre: 497 child.text = prev.text + child.text 498 html_node_unlink(prev) 499 500 # Trim whitespace from text nodes 501 for child in html_node_children(node): 502 if child.tag == "text": 503 html_node_trim_whitespace(child) 504 505 # Remove empty text nodes 506 for child in html_node_children(node): 507 if child.tag == "text" and child.text == "": 508 html_node_unlink(child) 509 510 511 def html_node_render_block(node, width=70): 512 if node.tag == "blockquote" or node.tag == "li" or node.tag == "pre": 513 width -= 2 514 515 parts = [] 516 517 if node.tag == "hr": 518 parts.append("-" * width) 519 520 for child in html_node_children(node): 521 if child.tag == "text" and child.pre: 522 parts.append(child.text) 523 524 elif child.tag == "text" and not child.pre: 525 subparts = [text_wrap(p, width) for p in child.text.split("\n")] 526 parts.append("\n".join(subparts)) 527 528 else: 529 parts.append(html_node_render_block(child, width)) 530 531 if child.next_sibling is not None and child.tag != "li": 532 parts.append("") 533 534 text = "\n".join(parts) 535 536 if node.tag == "blockquote": 537 text = "\n".join((">" if line.startswith("> ") else "> ") + line for line in text.split("\n")) 538 elif node.tag == "pre": 539 text = text_unindent(text) 540 text = "\n".join("| " + line for line in text.split("\n")) 541 elif node.tag == "li": 542 lines = text.split("\n") 543 lines = [("- " + lines[0]).rstrip()] + [(" " + line).rstrip() for line in lines[1:]] 544 text = "\n".join(lines) 545 546 return text 547 548 549 def html_render(html): 550 # This renderer works well for HN messages because their markup is simple, and it can do 551 # some custom optimizations, like expanding ellipsis-shortened links, preserving quote 552 # symbols in wrapped lines, and preventing references with long urls from being broken 553 # into separate lines. For other backends it may make more sense to use an external app 554 # (links, w3m, etc) 555 556 html = text_sanitize(html) 557 558 parser = HTMLParser() 559 parser.feed(html) 560 parser.close() 561 562 node = parser.root_node 563 564 log_sep = "\n" + "-" * 80 + "\n" 565 logging.debug("Initial HTML tree{}{}{}".format(log_sep, html_node_dump(node), log_sep)) 566 567 html_node_process_inline(node) 568 html_node_process_text(node) 569 570 logging.debug("Processed HTML tree{}{}{}".format(log_sep, html_node_dump(node), log_sep)) 571 572 return html_node_render_block(node) 573 574 575 def fetch(url): 576 logging.debug("Fetching '{}'...".format(url)) 577 578 headers = {} 579 if USER_AGENT is not None: 580 headers["User-Agent"] = USER_AGENT 581 582 req = urllib.request.Request(url, headers=headers) 583 resp = urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT).read().decode() 584 585 return resp 586 587 588 def list_get(lst, index, default=None): 589 return lst[index] if 0 <= index < len(lst) else default 590 591 592 def list_chunk(lst, size): 593 # Flake8 conflicts with Black here - https://github.com/PyCQA/pycodestyle/issues/373 594 return [lst[i : i + size] for i in range(0, len(lst), size)] # noqa: E203 595 596 597 def cmd_quit(_): 598 raise ExitException() 599 600 601 def cmd_help(app): 602 app_show_help_screen(app) 603 604 605 def cmd_show_links(app): 606 app_show_links_screen(app) 607 608 609 def cmd_up(app): 610 cmd_pager_up(app) if app.pager_visible else cmd_prev(app) 611 612 613 def cmd_down(app): 614 cmd_pager_down(app) if app.pager_visible else cmd_next(app) 615 616 617 def cmd_prev(app): 618 pos = app.selected_message.index_position - 1 if app.selected_message else 0 619 app_select_message(app, list_get(app.messages, pos, app.selected_message)) 620 621 622 def cmd_next(app): 623 pos = app.selected_message.index_position + 1 if app.selected_message else 0 624 app_select_message(app, list_get(app.messages, pos, app.selected_message)) 625 626 627 def cmd_next_unread(app): 628 pos = app.selected_message.index_position + 1 if app.selected_message else 0 629 message = next((msg for msg in app.messages[pos:] if not msg.is_shown_as_read), None) 630 if message is not None: 631 app_select_message(app, message) 632 633 634 def cmd_next_sibling(app): 635 msg = app.selected_message 636 if msg is None: 637 return 638 parent_msg = msg.parent 639 if parent_msg and parent_msg.children: 640 # find the next sibling 641 try: 642 idx = parent_msg.children.index(msg) 643 if idx < len(parent_msg.children): 644 app_select_message(app, parent_msg.children[idx + 1]) 645 except IndexError: 646 pass 647 648 649 def cmd_prev_sibling(app): 650 msg = app.selected_message 651 if msg is None: 652 return 653 parent_msg = msg.parent 654 if parent_msg and parent_msg.children: 655 # find the previous sibling 656 try: 657 idx = parent_msg.children.index(msg) 658 if idx > 0: 659 app_select_message(app, parent_msg.children[idx - 1]) 660 except IndexError: 661 pass 662 663 664 def cmd_mark_thread_as_read(app): 665 # recursively mark us and all children as read 666 # then jump to the next sibling 667 def iterate(message): 668 if message.children: 669 for child in message.children: 670 child.flags.read = True 671 db_save_message(app.db, child) 672 iterate(child) 673 674 msg = app.selected_message 675 if msg is not None: 676 iterate(msg) 677 # jump to the next sibling 678 cmd_next_sibling(app) 679 680 681 def cmd_parent(app): 682 if app.selected_message is not None and app.selected_message.parent is not None: 683 app_select_message(app, app.selected_message.parent) 684 685 686 def cmd_mark_set(app): 687 if app.selected_message is not None: 688 app.marked_message_id = app.selected_message.msg_id 689 app_show_flash(app, "Mark set") 690 691 692 def cmd_mark_jump(app): 693 marked_msg = app.messages_by_id.get(app.marked_message_id) if app.marked_message_id else None 694 cmd_mark_set(app) 695 if marked_msg is not None: 696 app_select_message(app, marked_msg) 697 app_show_flash(app, "Mark swapped") 698 699 700 def cmd_pager_up(app): 701 app.pager_offset = max(0, app.pager_offset - 1) 702 703 704 def cmd_pager_down(app): 705 if app.selected_message is not None and app.layout.pager_height is not None: 706 app.pager_offset = min(app.pager_offset + 1, max(0, len(app.selected_message.lines) - app.layout.pager_height)) 707 708 709 def cmd_page_up(app): 710 cmd_pager_page_up(app) if app.pager_visible else cmd_index_page_up(app) 711 712 713 def cmd_page_down(app): 714 cmd_pager_page_down(app) if app.pager_visible else cmd_index_page_down(app) 715 716 717 def cmd_index_page_up(app): 718 pos = app.selected_message.index_position - app.layout.index_height if app.selected_message else 0 719 pos = max(pos, 0) 720 app_select_message(app, list_get(app.messages, pos, app.selected_message)) 721 722 723 def cmd_index_page_down(app): 724 pos = app.selected_message.index_position + app.layout.index_height if app.selected_message else 0 725 pos = min(pos, len(app.messages) - 1) 726 app_select_message(app, list_get(app.messages, pos, app.selected_message)) 727 728 729 def cmd_pager_page_up(app): 730 if app.layout.pager_height is not None: 731 app.pager_offset = max(0, app.pager_offset - app.layout.pager_height) 732 733 734 def cmd_pager_page_down(app): 735 message = app.selected_message 736 pager_height = app.layout.pager_height 737 if message is not None and pager_height is not None: 738 app.pager_offset = min(app.pager_offset + pager_height, max(0, len(message.lines) - pager_height)) 739 740 741 def cmd_load_tab(app, tab): 742 group = list_get(GROUP_TABS, tab - 1) 743 if group: 744 app_load_group(app, group) 745 746 747 def cmd_reload_page(app): 748 app_load_group(app, app.group) 749 750 751 def cmd_load_prev_page(app): 752 app_load_group(app, group_advance_page(app.group, -1)) 753 754 755 def cmd_load_next_page(app): 756 app_load_group(app, group_advance_page(app.group, 1)) 757 758 759 def cmd_load_page(app): 760 user_input = app_prompt(app, "Go to page (empty to cancel): ") 761 762 if not user_input.isnumeric() or int(user_input) < 1: 763 app_show_flash(app, "Invalid page number") 764 else: 765 app_load_group(app, group_set_page(app.group, int(user_input))) 766 767 def make_group(name, f): 768 return Group(label=name, fetch=lambda db, page: f(page)) 769 770 def cmd_lb_see_tags(app): 771 user_input = app_prompt(app, "Stories for tag(s) (comma to combine): ") 772 app_load_group(app, make_group(TRUNCATE(user_input, 25), 773 lambda page: lb_fetch_threads("t/"+user_input, page))) 774 775 def cmd_lb_see_user(app): 776 user_input = app_prompt(app, "Stories posted by user: ") 777 app_load_group(app, make_group(TRUNCATE(user_input, 25), 778 lambda page: lb_fetch_threads("~{}/stories".format(user_input), page))) 779 780 def cmd_hn_see_user(app): 781 user_input = app_prompt(app, "Stories posted by user: ") 782 app_load_group(app, make_group(TRUNCATE(user_input, 25), 783 lambda page: hn_fetch_user_threads(user_input, page))) 784 785 def cmd_open(app): 786 if app.selected_message is None: 787 return 788 789 if app.selected_message.is_thread: 790 app_open_thread(app, app.selected_message) 791 else: 792 app_select_message(app, app.selected_message, show_pager=True) 793 794 795 def cmd_close(app): 796 if app.pager_visible: 797 app.pager_visible = False 798 else: 799 app_close_thread(app) 800 801 802 def cmd_star(app): 803 msg = app.selected_message 804 if msg is not None: 805 msg.flags.starred = not msg.flags.starred 806 db_save_message(app.db, msg) 807 cmd_next(app) 808 809 810 def cmd_star_thread(app): 811 msg = app.selected_message 812 if msg is None: 813 return 814 815 thread_msg = app.messages_by_id.get(msg.thread_id) 816 if thread_msg is None: 817 return 818 819 thread_msg.flags.starred = not thread_msg.flags.starred 820 db_save_message(app.db, thread_msg) 821 cmd_next(app) 822 823 824 def cmd_set_unread(app): 825 if app.selected_message is not None: 826 app.selected_message.flags.read = False 827 db_save_message(app.db, app.selected_message) 828 cmd_next(app) 829 830 831 def cmd_dump(app): 832 if app.selected_message is None: 833 return 834 835 filename = "{}.html".format(app.selected_message.msg_id) 836 837 with open(filename, "w") as fp: 838 fp.write(app.selected_message.body or "") 839 840 app_show_flash(app, "Message body dumped to {}".format(filename)) 841 842 843 def cmd_toggle_raw_mode(app): 844 app.raw_mode = not app.raw_mode 845 app_select_message(app, app.selected_message) 846 847 848 def cmd_resize(app): 849 app_refresh_message(app) 850 851 852 def cmd_unknown(app): 853 app.flash = "Unknown key" 854 855 856 def db_init(path): 857 path = os.path.expanduser(path) 858 create_table_sql = """ 859 CREATE TABLE IF NOT EXISTS messages ( 860 msg_id TEXT NOT NULL PRIMARY KEY, 861 thread_id TEXT NOT NULL, 862 date INTEGER NOT NULL, 863 starred BOOLEAN NOT NULL, 864 read BOOLEAN NOT NULL 865 ); 866 867 CREATE INDEX IF NOT EXISTS messages_starred_date ON messages (starred, date); 868 """ 869 870 db = sqlite3.connect(path) 871 db.row_factory = sqlite3.Row 872 db.executescript(create_table_sql) 873 db.commit() 874 875 return db 876 877 878 def db_save_message(db, message): 879 sql = """INSERT OR REPLACE INTO messages (msg_id, thread_id, date, starred, read) VALUES (?, ?, ?, ?, ?)""" 880 date = int(time.mktime(message.date.timetuple())) 881 db.execute(sql, (message.msg_id, message.thread_id, date, message.flags.starred, message.flags.read)) 882 db.commit() 883 884 885 def db_load_message_flags(db, messages_by_id): 886 message_ids = list(messages_by_id.keys()) 887 sql = "SELECT * FROM messages WHERE msg_id IN ({})".format(','.join('?' for _ in message_ids)) 888 889 for row in db.execute(sql, message_ids): 890 flags = MessageFlags() 891 flags.starred = row["starred"] 892 flags.read = row["read"] 893 messages_by_id[row["msg_id"]].flags = flags 894 895 896 def db_load_read_comments(db, messages_by_id): 897 threads_by_id = {msg.msg_id: msg for msg in messages_by_id.values() if msg.is_thread} 898 thread_ids = list(threads_by_id.keys()) 899 900 sql = """ 901 SELECT thread_id, COUNT(*) AS count 902 FROM messages 903 WHERE thread_id IN ({}) AND read 904 GROUP BY thread_id 905 """.format(','.join('?' for _ in thread_ids)) 906 907 for row in db.execute(sql, thread_ids): 908 threads_by_id[row["thread_id"]].read_comments = row["count"] 909 910 911 def db_load_starred_thread_ids(db, page = 1): 912 page_size = 30 913 offset = (page - 1) * page_size 914 sql = """ 915 SELECT thread_id 916 FROM messages 917 WHERE starred 918 GROUP BY thread_id 919 ORDER BY date DESC 920 LIMIT ? 921 OFFSET ? 922 """ 923 924 return [row["thread_id"] for row in db.execute(sql, (page_size, offset))] 925 926 927 def msg_populate_total_count(msg): 928 children = msg.children or [] 929 msg.total_count = 0 930 931 if len(children) == 0: 932 return 933 934 for child in children: 935 msg_populate_total_count(child) 936 msg.total_count += 1 + child.total_count 937 938 def msg_flatten_thread( 939 msg, prefix = "", is_last_child = False, ascii = False 940 ): 941 blcorner = "'-" if ascii else "└─" 942 ltee = "|-" if ascii else "├─" 943 vline = "| " if ascii else "│ " 944 945 msg.index_tree = "" if msg.is_thread else "{}{}> ".format(prefix, blcorner if is_last_child else ltee) 946 yield msg 947 948 children = msg.children or [] 949 950 child_count = len(children) 951 child_prefix = "" if msg.is_thread else "{}{}".format(prefix, ' ' if is_last_child else vline) 952 953 for i, child_node in enumerate(children): 954 child_is_last = i == child_count - 1 955 for child in msg_flatten_thread(child_node, prefix=child_prefix, is_last_child=child_is_last, ascii=ascii): 956 yield child 957 958 959 def msg_build_raw_lines(msg): 960 text = text_sanitize(msg.body) 961 962 # Unescape selected entities for better readability 963 repl = {"&#x2F;": "/", "&#x27;": "'", "&quot;": '"'} 964 for k, v in repl.items(): 965 text = text.replace(k, v) 966 967 return reduce(lambda acc, line: acc + wrap(line, width=120, replace_whitespace=False), text.split("\n"), []) 968 969 970 def msg_build_lines(msg): 971 lines = [ 972 "Content-Location: {}".format(msg.content_location), 973 "Date: {}".format(msg.date.strftime('%Y-%m-%d %H:%M')), 974 "From: {}".format(msg.author or '<unknown>'), 975 "Subject: {}".format(msg.title), 976 "", 977 ] 978 979 lines += html_render(msg.body or "").split("\n") if not msg.is_deleted else ["<deleted>"] 980 981 return lines 982 983 984 def msg_unload(msg): 985 msg.children = None 986 msg.body = None 987 return msg 988 989 990 def hn_parse_search_hit(hit): 991 return Message( 992 msg_id="{}@hn".format(hit['objectID']), 993 thread_id="{}@hn".format(hit['objectID']), 994 content_location="https://news.ycombinator.com/item?id={}".format(hit['objectID']), 995 date=datetime.fromtimestamp(hit["created_at_i"]), 996 author=hit["author"], 997 title=html_unescape(hit["title"]), 998 total_comments=(hit["num_comments"] or 0) + 1, 999 url=hit["url"] if "url" in hit else None, 1000 ) 1001 1002 1003 def hn_parse_entry(entry, thread_id="", parent=None, parent_title=""): 1004 thread_id = thread_id or str(entry["id"]) 1005 1006 my_title = html_unescape(entry["title"]) if entry["title"] else None 1007 1008 parent_title = parent.title if parent else parent_title 1009 parent_title = parent_title if parent_title.startswith("Re: ") else "Re: {}".format(parent_title) 1010 1011 body = "<p>{}</p>".format(entry['url']) if entry["url"] else "" 1012 body = "{}{}".format(body, entry['text']) if entry["text"] else body 1013 1014 msg = Message( 1015 msg_id="{}@hn".format(entry['id']), 1016 thread_id="{}@hn".format(thread_id), 1017 content_location="https://news.ycombinator.com/item?id={}".format(entry['id']), 1018 date=datetime.fromtimestamp(entry["created_at_i"]), 1019 author=entry["author"], 1020 title=my_title or parent_title, 1021 body=body, 1022 parent=parent, 1023 url=entry["url"] if "url" in entry else None, 1024 ) 1025 1026 msg.children = [hn_parse_entry(child, thread_id, msg) for child in entry["children"]] 1027 1028 return msg 1029 1030 1031 def hn_fetch_threads_by_id(thread_ids): 1032 story_tags = ",".join("story_{}".format(x) for x in thread_ids) 1033 url = "https://hn.algolia.com/api/v1/search_by_date?hitsPerPage={}&tags=story,({})".format(len(thread_ids), story_tags) 1034 hits = json.loads(fetch(url))["hits"] 1035 hits_by_id = {hit["objectID"]: hit for hit in hits} 1036 threads = [hn_parse_search_hit(hits_by_id[tid]) for tid in thread_ids if tid in hits_by_id] 1037 1038 return threads 1039 1040 def hn_fetch_user_threads(username, page = 1): 1041 url = "https://hn.algolia.com/api/v1/search?hitsPerPage={}&page={}&tags=story,author_{}".format(PREFERRED_PAGE_SIZE, page-1, username) 1042 print(url) 1043 hits = json.loads(fetch(url))["hits"] 1044 return [hn_parse_search_hit(hit) for hit in hits] 1045 1046 def hn_fetch_threads(group = "news", page = 1): 1047 rex = re.compile(r'href="item\?id=(\d+)"') 1048 1049 url = "https://news.ycombinator.com/{}".format(group) 1050 # HN seems to be trigger-happy about sending 429s to some paginated requests with weird user agents 1051 if page != 1: 1052 url += "?p={}".format(page) 1053 1054 html = fetch(url) 1055 thread_ids = list(dict.fromkeys(match.group(1) for match in rex.finditer(html))) 1056 1057 return hn_fetch_threads_by_id(thread_ids) 1058 1059 1060 def hn_fetch_new_threads(page = 1): 1061 url = "https://hn.algolia.com/api/v1/search_by_date?tags=story&hitsPerPage=30&page={}".format(page-1) 1062 hits = json.loads(fetch(url))["hits"] 1063 1064 return [hn_parse_search_hit(hit) for hit in hits] 1065 1066 1067 def hn_fetch_thread(entry_id): 1068 resp = fetch("http://hn.algolia.com/api/v1/items/{}".format(entry_id)) 1069 entry = json.loads(resp) 1070 1071 parent_title = "" 1072 if not entry["title"] and "story_id" in entry: 1073 resp = fetch("http://hn.algolia.com/api/v1/items/{}".format(entry["story_id"])) 1074 parent_title = json.loads(resp)["title"] 1075 1076 return hn_parse_entry(entry, parent_title=parent_title) 1077 1078 1079 def datetime_from_iso(iso): 1080 # strptime()'s supported UTC offset format string requires it be 1081 # HHMM not HH:MM before python 3.12... 1082 iso = iso.replace(":", "") 1083 return datetime.strptime(iso, "%Y-%m-%dT%H%M%S.%f%z") 1084 1085 def html_unescape(string, mapping={}): 1086 # gross hack exploiting how python only evaluates the default argument once 1087 if len(mapping) == 0: 1088 print("requesting...") 1089 req = urllib.request.Request("https://html.spec.whatwg.org/entities.json") 1090 resp = urllib.request.urlopen(req).read().decode() 1091 mapping.update(json.loads(resp)) 1092 for replacement in mapping: 1093 string = string.replace(replacement, mapping[replacement]["characters"]) 1094 return string 1095 1096 def lb_parse_thread(thread): 1097 comments = {} 1098 1099 thread_body = thread['description'] 1100 thread_body = "<p>{}</p>{}".format(thread['url'], thread_body) if thread["url"] else thread_body 1101 1102 ret = Message( 1103 msg_id="{}@lb".format(thread['short_id']), 1104 thread_id="{}@lb".format(thread['short_id']), 1105 content_location=thread["short_id_url"], 1106 date=datetime_from_iso(thread["created_at"]), 1107 author=thread["submitter_user"], 1108 title=thread["title"], 1109 body=thread_body, 1110 children=None if thread.get("comments") is None else [], 1111 total_comments=thread["comment_count"] + 1, 1112 url=thread["url"] if "url" in thread else None, 1113 ) 1114 1115 for comment in thread.get("comments", []) or []: 1116 comments[comment["short_id"]] = Message( 1117 msg_id="{}@lb".format(comment['short_id']), 1118 thread_id="{}@lb".format(thread['short_id']), 1119 content_location=comment["url"], 1120 date=datetime_from_iso(comment["created_at"]), 1121 author=comment["commenting_user"], 1122 title="Re: {}".format(thread['title']), 1123 body=comment["comment"], 1124 children=[] 1125 ) 1126 1127 for comment in thread.get("comments", []) or []: 1128 msg = comments[comment["short_id"]] 1129 parent_msg = comments[comment["parent_comment"]] if comment["parent_comment"] else ret 1130 msg.parent = parent_msg 1131 if parent_msg.children is not None: 1132 parent_msg.children.append(msg) 1133 1134 return ret 1135 1136 1137 def lb_fetch_threads(group = "", page = 1): 1138 group_path = group + "/" if group else "" 1139 resp = fetch("https://lobste.rs/{}page/{}.json".format(group_path, page)) 1140 threads = json.loads(resp) 1141 1142 return [lb_parse_thread(thread) for thread in threads] 1143 1144 1145 def lb_fetch_thread(entry_id): 1146 resp = fetch("https://lobste.rs/s/{}.json".format(entry_id)) 1147 thread = json.loads(resp) 1148 1149 return lb_parse_thread(thread) 1150 1151 1152 def group_set_page(group, page): 1153 return Group(group.label, group.fetch, page=page) 1154 1155 1156 def group_advance_page(group, offset = 1): 1157 return group_set_page(group, page=max(1, group.page + offset)) 1158 1159 1160 def group_fetch_starred_threads(db, page = 1): 1161 thread_ids = db_load_starred_thread_ids(db, page) 1162 threads_by_provider_id = {} 1163 threads = [] 1164 1165 for source_id, provider_id in (t.split("@") for t in thread_ids): 1166 threads_by_provider_id.setdefault(provider_id, list()).append(source_id) 1167 1168 for provider_id, thread_ids in threads_by_provider_id.items(): 1169 provider = PROVIDERS[provider_id] 1170 threads += provider.fetch_threads_by_id(thread_ids) 1171 1172 threads.sort(key=lambda x: x.date, reverse=True) 1173 1174 return threads 1175 1176 1177 def group_fetch_thread(thread_id): 1178 (msg_id, provider_id) = thread_id.split("@") 1179 provider = PROVIDERS[provider_id] 1180 1181 return provider.fetch_thread(msg_id) 1182 1183 1184 def group_for_msg_url(url): 1185 match = HN_URL_REX.match(url) 1186 if match is not None: 1187 msg_id = match[1] 1188 return Group(label=msg_id, fetch=lambda *x: [hn_fetch_thread(msg_id)]) 1189 1190 match = LB_URL_REX.match(url) 1191 if match is not None: 1192 msg_id = match[1] 1193 return Group(label=msg_id, fetch=lambda *x: [lb_fetch_thread(msg_id)]) 1194 return None 1195 1196 1197 def app_safe_run(app, fn, flash): 1198 if flash is not None: 1199 app_show_flash(app, flash) 1200 1201 ret = None 1202 1203 try: 1204 ret = fn() 1205 except Exception as e: 1206 logging.debug("\n".join(traceback.format_exception(type(e), e, e.__traceback__))) 1207 app_show_flash(app, "Error: " + str(e)) 1208 else: 1209 if flash is not None: 1210 app_show_flash(app, None) 1211 1212 return ret 1213 1214 1215 def app_refresh_message(app): 1216 app.pager_offset = 0 1217 1218 # Converting html to lines lazily on render for easier debugging 1219 msg = app.selected_message 1220 if msg is not None: 1221 msg.lines = msg_build_raw_lines(msg) if app.raw_mode else msg_build_lines(msg) 1222 1223 1224 def app_select_message(app, message, show_pager = False): 1225 app.selected_message = message 1226 1227 app_refresh_message(app) 1228 1229 if message is None or message.body is None: 1230 app.pager_visible = False 1231 return 1232 1233 if show_pager: 1234 app.pager_visible = True 1235 1236 if app.pager_visible: 1237 message.flags.read = True 1238 db_save_message(app.db, message) 1239 db_load_read_comments(app.db, {message.thread_id: app.messages_by_id[message.thread_id]}) 1240 1241 1242 def app_load_messages( 1243 app, messages, selected_message_id = None, show_pager = False 1244 ): 1245 if selected_message_id is None and app.selected_message is not None: 1246 selected_message_id = app.selected_message.msg_id 1247 1248 selected_message = None 1249 1250 for i, message in enumerate(messages): 1251 message.index_position = i 1252 1253 if message.msg_id == selected_message_id: 1254 selected_message = message 1255 1256 if selected_message is None and len(messages) > 0: 1257 selected_message = messages[0] 1258 1259 app.messages = messages 1260 app.messages_by_id = {msg.msg_id: msg for msg in messages} 1261 1262 db_load_message_flags(app.db, app.messages_by_id) 1263 db_load_read_comments(app.db, app.messages_by_id) 1264 1265 app_select_message(app, selected_message, show_pager) 1266 1267 1268 def app_load_group(app, group): 1269 fn = partial(group.fetch, app.db, group.page) 1270 flash = "Fetching stories from '{}' (page {})...".format(group.label, group.page) 1271 1272 messages = app_safe_run(app, fn, flash=flash) 1273 if messages is None: 1274 return 1275 1276 app_load_messages(app, messages) 1277 app.group = group 1278 1279 1280 def app_close_thread(app): 1281 selected_thread_id = app.selected_message.thread_id if app.selected_message else None 1282 filtered_messages = [msg_unload(msg) for msg in app.messages if msg.is_thread] 1283 1284 app_load_messages(app, filtered_messages, selected_message_id=selected_thread_id) 1285 1286 1287 def app_open_thread(app, thread_message): 1288 fn = partial(group_fetch_thread, thread_message.thread_id) 1289 flash = "Fetching thread '{}'...".format(thread_message.thread_id) 1290 1291 new_thread_message = app_safe_run(app, fn, flash=flash) 1292 if new_thread_message is None: 1293 return 1294 1295 app_close_thread(app) 1296 1297 index_pos = thread_message.index_position 1298 thread_messages = list(msg_flatten_thread(new_thread_message, ascii=app.ascii)) 1299 new_thread_message.total_comments = len(thread_messages) 1300 messages = app.messages[:index_pos] + thread_messages + app.messages[index_pos + 1 :] # noqa: E203 1301 1302 app_load_messages(app, messages, selected_message_id=thread_message.msg_id, show_pager=True) 1303 1304 1305 def app_update_layout(app): 1306 lt = app.layout 1307 1308 (lt.lines, lt.cols) = app.screen.getmaxyx() 1309 1310 if lt.lines < 12 or lt.cols < 80: 1311 raise ExitException(1, "At least 80x12 terminal is required") 1312 1313 max_index_height = lt.lines - 3 1314 lt.index_height = (max_index_height // 3) if app.pager_visible else max_index_height 1315 1316 lt.middle_menu_row = lt.index_start + lt.index_height if app.pager_visible else None 1317 lt.pager_start = lt.index_start + lt.index_height + 1 if app.pager_visible else None 1318 lt.pager_height = lt.lines - lt.pager_start - 2 if lt.pager_start is not None else None 1319 1320 lt.bottom_menu_row = lt.lines - 2 1321 lt.flash_menu_row = lt.lines - 1 1322 1323 1324 def app_show_help_screen(app): 1325 max_lines = app.layout.lines - 4 1326 1327 help_lines = HELP_SCREEN.split("\n") 1328 help_pages = ["\n".join(lines) for lines in list_chunk(help_lines, max_lines)] 1329 1330 for page in help_pages: 1331 app.screen.erase() 1332 app.screen.addstr(0, 0, "Available commands:\n\n") 1333 app.screen.addstr(page) 1334 app.screen.addstr("\n\nPress any key to continue...") 1335 app.screen.refresh() 1336 app.screen.getch() 1337 1338 1339 def app_show_links_screen(app): 1340 lines = app.selected_message.lines if app.selected_message is not None else [] 1341 1342 urls = set(URL_REX.findall(" ".join(lines[1:]))) 1343 if app.selected_message is not None and app.selected_message.url is not None: 1344 urls.add(app.selected_message.url) 1345 1346 if len(urls) == 0: 1347 return app_show_flash(app, "No links available for opening") 1348 elif len(urls) == 1: 1349 url = urls.pop() 1350 else: 1351 # Max amount of keys is 21 to fit on 25-line terminals 1352 keys = "1234567890abcdefghijk" 1353 items = dict(zip((ord(k) for k in keys), urls)) 1354 1355 app.screen.erase() 1356 app.screen.addstr(0, 0, "Select link to open:") 1357 1358 for i, (key, url) in enumerate(items.items()): 1359 app.screen.addstr(i + 2, 0, "{} - {}".format(chr(key), url)) 1360 1361 app.screen.addstr(i + 4, 0, "To change browser run: BROWSER='firefox %s' ./retronews.py") 1362 app.screen.refresh() 1363 1364 key = app.screen.getch() 1365 1366 if key not in items.keys(): 1367 return app_show_flash(app, "Unknown key") 1368 1369 url = items[key] 1370 1371 group = group_for_msg_url(url) 1372 if group is not None: 1373 app_load_group(app, group) 1374 else: 1375 app_show_flash(app, "Opening " + url) 1376 webbrowser.open(url) 1377 1378 # Refresh window in case a terminal browser was used 1379 app.screen.clearok(True) 1380 1381 1382 def app_show_flash(app, flash): 1383 app.flash = flash 1384 app_render(app) 1385 1386 1387 def app_prompt(app, prompt): 1388 lt = app.layout 1389 1390 app.screen.insstr(lt.flash_menu_row, 0, prompt.ljust(lt.cols)) 1391 app.screen.refresh() 1392 1393 curses.curs_set(1) 1394 win = curses.newwin(1, lt.cols - len(prompt), lt.flash_menu_row, len(prompt)) 1395 1396 textbox = curses.textpad.Textbox(win) 1397 textbox.stripspaces = True 1398 ret = textbox.edit().strip() 1399 1400 del win 1401 curses.curs_set(0) 1402 1403 return ret 1404 1405 def app_chgat(app, row, start, size, color): 1406 app.screen.chgat(row, start, size, color) 1407 return start + size 1408 1409 def app_render_index_row(app, row, message): 1410 cols = app.layout.cols 1411 date = TRUNCATE(DATECOL_FUNC(message.date), DATECOL_SIZE).rjust(DATECOL_SIZE) 1412 author = TRUNCATE(message.author or "<unknown>", AUTHOR_SIZE).ljust(AUTHOR_SIZE) 1413 1414 is_response = message.title.startswith("Re:") and not message.is_thread 1415 is_selected = message == app.selected_message 1416 hide_title = is_response and row > app.layout.index_start and not message.flags.starred and not is_selected 1417 title = "" if hide_title else text_clean(message.title, ascii=app.ascii) 1418 1419 unread_count = max(message.total_comments - message.read_comments, 0) 1420 unread_repr = str(unread_count) if unread_count < (10**UNREAD_SIZE - 1) else (UNREAD_MANY_CHAR * UNREAD_SIZE) 1421 unread = unread_repr.rjust(UNREAD_SIZE) if message.is_thread else (' ' * UNREAD_SIZE) 1422 1423 spacing = ' ' * COLUMN_SPACING 1424 app.screen.insstr(row, 0, "[{}]{}[{}]{}[{}]{}{}{}".format(date, spacing, author, spacing, unread, spacing, message.index_tree, title)) 1425 1426 if is_selected: 1427 cursor_attr = curses.A_REVERSE if app.monochrome else 0 1428 app.screen.chgat(row, 0, cols, app.colors["cursor"] | cursor_attr) 1429 else: 1430 read_attr = 0 if message.is_shown_as_read else curses.A_BOLD 1431 subject_attr = app.colors["starred_subject"] if message.flags.starred else app.colors["default"] 1432 subject_attr = subject_attr | read_attr 1433 1434 start = 1 1435 start = app_chgat(app, row, start, len(date), app.colors["date"] | read_attr) 1436 start = app_chgat(app, row, start + 2 + COLUMN_SPACING, AUTHOR_SIZE, app.colors["author"] | read_attr) 1437 start = app_chgat(app, row, start + 2 + COLUMN_SPACING, UNREAD_SIZE, app.colors["unread_comments"] | read_attr) 1438 start = app_chgat(app, row, start + 1 + COLUMN_SPACING, len(message.index_tree), app.colors["tree"]) 1439 app_chgat(app, row, start, cols - start, subject_attr) 1440 1441 1442 def app_render_index(app): 1443 height = app.layout.index_height 1444 1445 offset = app.selected_message.index_position - height // 2 if app.selected_message else 0 1446 offset = min(offset, len(app.messages) - height) 1447 offset = max(offset, 0) 1448 1449 rows_to_render = min(height, len(app.messages) - offset) 1450 1451 for i in range(rows_to_render): 1452 app_render_index_row(app, app.layout.index_start + i, app.messages[i + offset]) 1453 1454 1455 def app_get_pager_line_attr(app, line): 1456 if line.startswith("Content-Location: "): 1457 return app.colors["tree"] 1458 elif line.startswith("Date: "): 1459 return app.colors["date"] 1460 elif line.startswith("From: "): 1461 return app.colors["author"] 1462 elif line.startswith("Subject: "): 1463 return app.colors["header_subject"] 1464 elif line.startswith(">>") or line.startswith("> >"): 1465 return app.colors["nested_quote"] 1466 elif line.startswith(">"): 1467 return app.colors["quote"] 1468 elif line.startswith("| "): 1469 return app.colors["code"] 1470 elif line == "~": 1471 return app.colors["empty_pager_line"] 1472 elif line == "<deleted>" or line == "[dead]": 1473 return app.colors["deleted_message_pager_line"] 1474 else: 1475 return 0 1476 1477 1478 def app_render_pager_line(app, row, line): 1479 hl_lines = not app.raw_mode 1480 line_attr = app_get_pager_line_attr(app, line) if hl_lines else 0 1481 hl_urls = line_attr == 0 and hl_lines 1482 1483 line = text_clean(line, ascii=app.ascii) 1484 1485 app.screen.move(row, 0) 1486 app.screen.clrtoeol() 1487 app.screen.move(row, 0) 1488 1489 for part in text_split_urls(line): 1490 re_match = URL_REX.match(part) 1491 is_url = re_match.end() == len(part) if re_match is not None else False 1492 part_attr = app.colors["url"] if is_url and hl_urls else line_attr 1493 app.screen.addstr(part, part_attr) 1494 1495 1496 def app_render_pager(app): 1497 message = app.selected_message 1498 start = app.layout.pager_start 1499 height = app.layout.pager_height 1500 1501 if message is None or start is None or height is None: 1502 return 1503 1504 for i in range(height): 1505 line = list_get(message.lines, i + app.pager_offset, "~") 1506 app_render_pager_line(app, i + start, line) 1507 1508 1509 def app_render_top_menu(app): 1510 lt = app.layout 1511 cols = lt.cols 1512 base_attr = curses.A_REVERSE if app.monochrome else curses.A_BOLD 1513 app.screen.insstr(lt.top_menu_row, 0, HELP_MENU[:cols].ljust(cols), app.colors["menu"] | base_attr) 1514 1515 1516 def app_render_middle_menu(app): 1517 row = app.layout.middle_menu_row 1518 message = app.selected_message 1519 if row is None or message is None: 1520 return 1521 1522 thread_message = app.messages_by_id.get(message.thread_id) 1523 if thread_message is None: 1524 return 1525 1526 cols = app.layout.cols 1527 total = thread_message.total_comments 1528 unread = total - thread_message.read_comments 1529 1530 text = "--({}/{} unread)".format(unread, total) 1531 if thread_message.flags.starred: 1532 text += "--(starred thread)" 1533 if app.raw_mode: 1534 text += "--(raw mode on)" 1535 text = text[:cols].ljust(cols, "-") 1536 1537 base_attr = curses.A_REVERSE if app.monochrome else curses.A_BOLD 1538 app.screen.insstr(row, 0, text, app.colors["menu"] | base_attr) 1539 1540 1541 def app_render_bottom_menu(app): 1542 lt = app.layout 1543 base_attr = curses.A_REVERSE if app.monochrome else curses.A_BOLD 1544 1545 app.screen.chgat(lt.bottom_menu_row, 0, lt.cols, app.colors["menu"] | base_attr) 1546 app.screen.move(lt.bottom_menu_row, 0) 1547 1548 for i, group in enumerate(GROUP_TABS): 1549 is_active = group.label == app.group.label 1550 item_attr = app.colors["menu_active"] | curses.A_BOLD if is_active else app.colors["menu"] 1551 item_attr = item_attr | base_attr 1552 app.screen.addstr("{}:{}".format(i+1, group.label), item_attr) 1553 app.screen.addstr(" ", app.colors["menu"] | base_attr) 1554 1555 page_text = "page: {}".format(app.group.page) 1556 app.screen.insstr(lt.bottom_menu_row, lt.cols - len(page_text), page_text, app.colors["menu"] | base_attr) 1557 1558 1559 def app_render(app): 1560 app_update_layout(app) 1561 app.screen.erase() 1562 app_render_index(app) 1563 app_render_pager(app) 1564 app_render_top_menu(app) 1565 app_render_middle_menu(app) 1566 app_render_bottom_menu(app) 1567 app.screen.insstr(app.layout.flash_menu_row, 0, app.flash or "") 1568 app.screen.refresh() 1569 1570 1571 def app_init_colors(app): 1572 if app.monochrome: 1573 app.colors = defaultdict(lambda: 0) 1574 return 1575 1576 try: 1577 curses.use_default_colors() 1578 for i, (name, (fg, bg)) in enumerate(COLORS.items()): 1579 curses.init_pair(i + 1, fg, bg) 1580 app.colors[name] = curses.color_pair(i + 1) 1581 except curses.error: 1582 app.colors = defaultdict(lambda: 0) 1583 app.monochrome = True 1584 1585 1586 def app_main(screen, db, group, ascii, monochrome, default_open=False): 1587 curses.curs_set(0) 1588 1589 app = AppState(screen=screen, db=db, group=group, ascii=ascii, monochrome=monochrome) 1590 1591 app_init_colors(app) 1592 app_load_group(app, app.group) 1593 1594 if default_open and app.selected_message is not None: 1595 app_open_thread(app, app.selected_message) 1596 1597 while True: 1598 app_render(app) 1599 app.flash = "" 1600 c = app.screen.getch() 1601 KEY_BINDINGS.get(c, cmd_unknown)(app) 1602 1603 1604 def setup_logging(path): 1605 if path is None: 1606 return logging.disable(logging.CRITICAL) 1607 1608 format = "%(asctime)s %(levelname)s: %(message)s" 1609 stream = sys.stderr if path == "-" else open(path, "a") 1610 logging.basicConfig(format=format, level="DEBUG", stream=stream) 1611 logging.debug("Session started") 1612 1613 1614 def run_rcfile(path): 1615 path = os.path.expanduser(path) 1616 1617 if not os.path.isfile(path): 1618 return 1619 1620 code = compile(open(path).read(), path, "exec") 1621 exec(code, {"retronews": sys.modules[__name__]}) 1622 1623 1624 if __name__ == "__main__": 1625 tab_choices = range(1, len(GROUP_TABS) + 1) 1626 1627 ap = argparse.ArgumentParser( 1628 formatter_class=lambda prog: argparse.ArgumentDefaultsHelpFormatter(prog, max_help_position=32) 1629 ) 1630 ap.add_argument("--ascii", action="store_true", help="show only ascii characters") 1631 ap.add_argument("--monochrome", action="store_true", help="disable colors") 1632 ap.add_argument("-c", "--rcfile", metavar="PATH", default="~/.retronewsrc.py", help="optional startup code path") 1633 ap.add_argument("-d", "--db", metavar="PATH", default="~/.retronews.db", help="database path") 1634 ap.add_argument("-l", "--logfile", metavar="PATH", default=None, help="debug logfile path") 1635 ap.add_argument("-t", "--tab", metavar="TAB", type=int, default=1, choices=tab_choices, help="initial tab") 1636 ap.add_argument("-r", "--render", metavar="PATH", default=None, help="render raw html message and quit") 1637 ap.add_argument("-m", "--msg", metavar="URL", default=None, help="render message from URL") 1638 args = ap.parse_args() 1639 1640 setup_logging(args.logfile) 1641 run_rcfile(args.rcfile) 1642 1643 if args.render is not None: 1644 with open(args.render) as fp: 1645 print(html_render(fp.read())) 1646 sys.exit(0) 1647 1648 try: 1649 db = db_init(args.db) 1650 1651 if args.msg: 1652 group = group_for_msg_url(args.msg) 1653 if group is None: 1654 msg = "Unknown URL, available patterns: \n" + "\n".join( 1655 "- {}".format(r.pattern) for r in [HN_URL_REX, LB_URL_REX] 1656 ) 1657 raise ExitException(1, msg) 1658 else: 1659 group = GROUP_TABS[args.tab - 1] 1660 1661 ascii = args.ascii 1662 monochrome = args.monochrome or "NO_COLOR" in os.environ 1663 1664 ret = curses.wrapper(app_main, db=db, group=group, ascii=ascii, 1665 monochrome=monochrome, default_open=(args.msg is not None)) 1666 except ExitException as e: 1667 if e.message: 1668 sys.stderr.write(e.message + "\n") 1669 ret = e.code 1670 except BaseException as e: 1671 sys.stderr.write("\n".join(traceback.format_exception(type(e), e, e.__traceback__))) 1672 ret = 1 1673 finally: 1674 db.close() 1675 sys.exit(ret)