190 lines
5.3 KiB
Python
190 lines
5.3 KiB
Python
import base64
|
|
import math
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from typing import List
|
|
import urwid
|
|
|
|
from datetime import datetime, timezone
|
|
from functools import reduce
|
|
from html.parser import HTMLParser
|
|
|
|
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
|
|
SECOND = 1
|
|
MINUTE = SECOND * 60
|
|
HOUR = MINUTE * 60
|
|
DAY = HOUR * 24
|
|
WEEK = DAY * 7
|
|
|
|
|
|
def parse_datetime(value):
|
|
"""Returns an aware datetime in local timezone"""
|
|
|
|
# In Python < 3.7, `%z` does not match `Z` offset
|
|
# https://docs.python.org/3.7/library/datetime.html#strftime-and-strptime-behavior
|
|
if value.endswith("Z"):
|
|
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
|
|
else:
|
|
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
|
|
|
|
# When running tests return datetime in UTC so that tests don't depend on
|
|
# the local timezone
|
|
if "PYTEST_CURRENT_TEST" in os.environ:
|
|
return dttm.astimezone(timezone.utc)
|
|
|
|
return dttm.astimezone()
|
|
|
|
|
|
def time_ago(value: datetime) -> str:
|
|
now = datetime.now().astimezone()
|
|
delta = now.timestamp() - value.timestamp()
|
|
|
|
if delta < 1:
|
|
return "now"
|
|
|
|
if delta < 8 * DAY:
|
|
if delta < MINUTE:
|
|
return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s"
|
|
if delta < HOUR:
|
|
return f"{math.floor(delta / MINUTE)}".rjust(2, " ") + "m"
|
|
if delta < DAY:
|
|
return f"{math.floor(delta / HOUR)}".rjust(2, " ") + "h"
|
|
return f"{math.floor(delta / DAY)}".rjust(2, " ") + "d"
|
|
|
|
if delta < 53 * WEEK: # not exactly correct but good enough as a boundary
|
|
return f"{math.floor(delta / WEEK)}".rjust(2, " ") + "w"
|
|
|
|
return ">1y"
|
|
|
|
|
|
def highlight_keys(text, high_attr, low_attr=""):
|
|
"""
|
|
Takes a string and adds high_attr attribute to parts in square brackets,
|
|
and optionally low_attr attribute to parts outside square brackets.
|
|
|
|
The result can be rendered using a urwid.Text widget.
|
|
|
|
For example:
|
|
|
|
>>> highlight_keys("[P]rint [V]iew", "blue")
|
|
>>> [('blue', 'P'), 'rint ', ('blue', 'V'), 'iew']
|
|
"""
|
|
def _gen():
|
|
highlighted = False
|
|
for part in re.split("\\[|\\]", text):
|
|
if part:
|
|
if highlighted:
|
|
yield (high_attr, part) if high_attr else part
|
|
else:
|
|
yield (low_attr, part) if low_attr else part
|
|
highlighted = not highlighted
|
|
return list(_gen())
|
|
|
|
|
|
def highlight_hashtags(line, followed_tags, attr="hashtag", followed_attr="followed_hashtag"):
|
|
hline = []
|
|
|
|
for p in re.split(HASHTAG_PATTERN, line):
|
|
if p.startswith("#"):
|
|
if p[1:].lower() in (t.lower() for t in followed_tags):
|
|
hline.append((followed_attr, p))
|
|
else:
|
|
hline.append((attr, p))
|
|
else:
|
|
hline.append(p)
|
|
|
|
return hline
|
|
|
|
|
|
def show_media(paths):
|
|
"""
|
|
Attempt to open an image viewer to show given media files.
|
|
|
|
FIXME: This is not very thought out, but works for me.
|
|
Once settings are implemented, add an option for the user to configure their
|
|
prefered media viewer.
|
|
"""
|
|
viewer = None
|
|
potential_viewers = [
|
|
"feh",
|
|
"eog",
|
|
"display"
|
|
]
|
|
for v in potential_viewers:
|
|
viewer = shutil.which(v)
|
|
if viewer:
|
|
break
|
|
|
|
if not viewer:
|
|
raise Exception("Cannot find an image viewer")
|
|
|
|
subprocess.run([viewer] + paths)
|
|
|
|
|
|
class LinkParser(HTMLParser):
|
|
|
|
def reset(self):
|
|
super().reset()
|
|
self.links = []
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
if tag == "a":
|
|
href, title = None, None
|
|
for name, value in attrs:
|
|
if name == "href":
|
|
href = value
|
|
if name == "title":
|
|
title = value
|
|
if href:
|
|
self.links.append((href, title))
|
|
|
|
|
|
def parse_content_links(content):
|
|
"""Parse <a> tags from status's `content` and return them as a list of
|
|
(href, title), where `title` may be None.
|
|
"""
|
|
parser = LinkParser()
|
|
parser.feed(content)
|
|
return parser.links[:]
|
|
|
|
|
|
def copy_to_clipboard(screen: urwid.raw_display.Screen, text: str):
|
|
""" copy text to clipboard using OSC 52
|
|
This escape sequence is documented
|
|
here https://iterm2.com/documentation-escape-codes.html
|
|
It has wide support - XTerm, Windows Terminal,
|
|
Kitty, iTerm2, others. Some terminals may require a setting to be
|
|
enabled in order to use OSC 52 clipboard functions.
|
|
"""
|
|
|
|
text_bytes = text.encode("utf-8")
|
|
b64_bytes = base64.b64encode(text_bytes)
|
|
b64_text = b64_bytes.decode("utf-8")
|
|
|
|
screen.write(f"\033]52;c;{b64_text}\a")
|
|
screen.flush()
|
|
|
|
|
|
def get_max_toot_chars(instance, default=500):
|
|
# Mastodon
|
|
# https://docs.joinmastodon.org/entities/Instance/#max_characters
|
|
max_toot_chars = deep_get(instance, ["configuration", "statuses", "max_characters"])
|
|
if isinstance(max_toot_chars, int):
|
|
return max_toot_chars
|
|
|
|
# Pleroma
|
|
max_toot_chars = instance.get("max_toot_chars")
|
|
if isinstance(max_toot_chars, int):
|
|
return max_toot_chars
|
|
|
|
return default
|
|
|
|
|
|
def deep_get(adict: dict, path: List[str], default=None):
|
|
return reduce(
|
|
lambda d, key: d.get(key, default) if isinstance(d, dict) else default,
|
|
path,
|
|
adict
|
|
)
|