witchie/toot/tui/utils.py

191 lines
5.3 KiB
Python
Raw Normal View History

import base64
import math
import os
2019-08-24 12:14:46 +00:00
import re
2019-08-29 09:47:44 +00:00
import shutil
import subprocess
2023-06-26 14:58:11 +00:00
from typing import List
import urwid
2019-08-24 12:14:46 +00:00
from datetime import datetime, timezone
from functools import reduce
from html.parser import HTMLParser
2019-08-27 12:34:51 +00:00
2019-08-24 12:14:46 +00:00
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
SECOND = 1
MINUTE = SECOND * 60
HOUR = MINUTE * 60
DAY = HOUR * 24
WEEK = DAY * 7
2019-08-24 12:14:46 +00:00
2019-08-27 12:34:51 +00:00
def parse_datetime(value):
"""Returns an aware datetime in local timezone"""
2019-09-04 06:36:12 +00:00
# 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)
2019-09-04 06:36:12 +00:00
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)
2019-09-04 06:36:12 +00:00
return dttm.astimezone()
2019-08-28 13:32:57 +00:00
2023-06-24 08:04:40 +00:00
def time_ago(value: datetime) -> str:
now = datetime.now().astimezone()
delta = now.timestamp() - value.timestamp()
2023-02-15 03:21:04 +00:00
if delta < 1:
return "now"
2023-02-15 03:21:04 +00:00
if delta < 8 * DAY:
if delta < MINUTE:
return f"{math.floor(delta / SECOND)}".rjust(2, " ") + "s"
2023-02-15 03:21:04 +00:00
if delta < HOUR:
return f"{math.floor(delta / MINUTE)}".rjust(2, " ") + "m"
2023-02-15 03:21:04 +00:00
if delta < DAY:
return f"{math.floor(delta / HOUR)}".rjust(2, " ") + "h"
return f"{math.floor(delta / DAY)}".rjust(2, " ") + "d"
2023-02-15 03:21:04 +00:00
if delta < 53 * WEEK: # not exactly correct but good enough as a boundary
return f"{math.floor(delta / WEEK)}".rjust(2, " ") + "w"
return ">1y"
2019-08-28 13:32:57 +00:00
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())
2023-01-02 13:24:39 +00:00
def highlight_hashtags(line, followed_tags, attr="hashtag", followed_attr="followed_hashtag"):
2022-12-20 21:28:24 +00:00
hline = []
2023-01-02 13:24:39 +00:00
2022-12-20 21:28:24 +00:00
for p in re.split(HASHTAG_PATTERN, line):
if p.startswith("#"):
if p[1:].lower() in (t.lower() for t in followed_tags):
2023-01-02 13:24:39 +00:00
hline.append((followed_attr, p))
2022-12-20 21:28:24 +00:00
else:
2023-01-02 13:24:39 +00:00
hline.append((attr, p))
2022-12-20 21:28:24 +00:00
else:
hline.append(p)
2023-01-02 13:24:39 +00:00
2022-12-20 21:28:24 +00:00
return hline
2019-08-29 09:47:44 +00:00
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
2023-06-26 14:58:11 +00:00
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
)