Merge branch 'master' into rt-hyperlinks-urwidgets-stub
This commit is contained in:
commit
c74539f147
|
@ -25,7 +25,7 @@ jobs:
|
|||
pytest
|
||||
- name: Validate minimum required version
|
||||
run: |
|
||||
vermin --target=3.6 --no-tips .
|
||||
vermin --target=3.7 --no-tips .
|
||||
- name: Check style
|
||||
run: |
|
||||
flake8
|
||||
|
|
20
CHANGELOG.md
20
CHANGELOG.md
|
@ -3,11 +3,29 @@ Changelog
|
|||
|
||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||
|
||||
**0.37.0 (TBA)**
|
||||
**0.38.1 (2023-07-25)**
|
||||
|
||||
* Fix relative datetimes option in TUI
|
||||
|
||||
**0.38.0 (2023-07-25)**
|
||||
|
||||
* Add `toot muted` and `toot blocked` commands (thanks Florian Obser)
|
||||
* Add settings file, allows setting common options, defining defaults for
|
||||
command arguments, and the TUI palette
|
||||
* TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks
|
||||
Dan Schwarz)
|
||||
|
||||
**0.37.0 (2023-06-28)**
|
||||
|
||||
* **BREAKING:** Require Python 3.7+
|
||||
* Add `timeline --account` option to show the account timeline (thanks Dan
|
||||
Schwarz)
|
||||
* Add `toot status` command to show a single status
|
||||
* TUI: Add personal timeline (thanks Dan Schwarz)
|
||||
* TUI: Highlight followed accounts in status details (thanks Dan Schwarz)
|
||||
* TUI: Restructured goto menu (thanks Dan Schwarz)
|
||||
* TUI: Fix boosting boosted statuses (thanks Dan Schwarz)
|
||||
* TUI: Add support for list timelines (thanks Dan Schwarz)
|
||||
|
||||
**0.36.0 (2023-03-09)**
|
||||
|
||||
|
|
2
Makefile
2
Makefile
|
@ -10,7 +10,7 @@ publish :
|
|||
test:
|
||||
pytest tests/*.py -v
|
||||
flake8
|
||||
vermin --target=3.6 --no-tips --violations --exclude-regex venv/.* .
|
||||
vermin --target=3.7 --no-tips --violations --exclude-regex venv/.* .
|
||||
|
||||
coverage:
|
||||
coverage erase
|
||||
|
|
|
@ -1,9 +1,26 @@
|
|||
0.38.1:
|
||||
date: 2023-07-25
|
||||
changes:
|
||||
- "Fix relative datetimes option in TUI"
|
||||
|
||||
0.38.0:
|
||||
date: 2023-07-25
|
||||
changes:
|
||||
- "Add `toot muted` and `toot blocked` commands (thanks Florian Obser)"
|
||||
- "Add settings file, allows setting common options, defining defaults for command arguments, and the TUI palette"
|
||||
- "TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks Dan Schwarz)"
|
||||
|
||||
0.37.0:
|
||||
date: "TBA"
|
||||
date: 2023-06-28
|
||||
changes:
|
||||
- "**BREAKING:** Require Python 3.7+"
|
||||
- "Add `timeline --account` option to show the account timeline (thanks Dan Schwarz)"
|
||||
- "Add `toot status` command to show a single status"
|
||||
- "TUI: Add personal timeline (thanks Dan Schwarz)"
|
||||
- "TUI: Highlight followed accounts in status details (thanks Dan Schwarz)"
|
||||
- "TUI: Restructured goto menu (thanks Dan Schwarz)"
|
||||
- "TUI: Fix boosting boosted statuses (thanks Dan Schwarz)"
|
||||
- "TUI: Add support for list timelines (thanks Dan Schwarz)"
|
||||
|
||||
0.36.0:
|
||||
date: 2023-03-09
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
- [Installation](installation.md)
|
||||
- [Usage](usage.md)
|
||||
- [Advanced](advanced.md)
|
||||
- [Settings](settings.md)
|
||||
- [TUI](tui.md)
|
||||
- [Contributing](contributing.md)
|
||||
- [Documentation](documentation.md)
|
||||
|
|
|
@ -21,10 +21,10 @@ through the specified server.
|
|||
|
||||
For example:
|
||||
|
||||
.. code-block:: sh
|
||||
|
||||
export HTTPS_PROXY="http://1.2.3.4:5678"
|
||||
toot login --instance mastodon.social
|
||||
```sh
|
||||
export HTTPS_PROXY="http://1.2.3.4:5678"
|
||||
toot login --instance mastodon.social
|
||||
```
|
||||
|
||||
**NB:** This feature is provided by
|
||||
[requests](http://docs.python-requests.org/en/master/user/advanced/#proxies>)
|
||||
|
|
|
@ -3,11 +3,29 @@ Changelog
|
|||
|
||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||
|
||||
**0.37.0 (TBA)**
|
||||
**0.38.1 (2023-07-25)**
|
||||
|
||||
* Fix relative datetimes option in TUI
|
||||
|
||||
**0.38.0 (2023-07-25)**
|
||||
|
||||
* Add `toot muted` and `toot blocked` commands (thanks Florian Obser)
|
||||
* Add settings file, allows setting common options, defining defaults for
|
||||
command arguments, and the TUI palette
|
||||
* TUI: Remap shortcuts so they don't override HJKL used for navigation (thanks
|
||||
Dan Schwarz)
|
||||
|
||||
**0.37.0 (2023-06-28)**
|
||||
|
||||
* **BREAKING:** Require Python 3.7+
|
||||
* Add `timeline --account` option to show the account timeline (thanks Dan
|
||||
Schwarz)
|
||||
* Add `toot status` command to show a single status
|
||||
* TUI: Add personal timeline (thanks Dan Schwarz)
|
||||
* TUI: Highlight followed accounts in status details (thanks Dan Schwarz)
|
||||
* TUI: Restructured goto menu (thanks Dan Schwarz)
|
||||
* TUI: Fix boosting boosted statuses (thanks Dan Schwarz)
|
||||
* TUI: Add support for list timelines (thanks Dan Schwarz)
|
||||
|
||||
**0.36.0 (2023-03-09)**
|
||||
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
# Settings
|
||||
|
||||
Toot can be configured via a [TOML](https://toml.io/en/) settings file.
|
||||
|
||||
> Introduced in toot 0.37.0
|
||||
|
||||
> **Warning:** Settings are experimental and things may change without warning.
|
||||
|
||||
Toot will look for the settings file at:
|
||||
|
||||
* `~/.config/toot/settings.toml` (Linux & co.)
|
||||
* `%APPDATA%\toot\settings.toml` (Windows)
|
||||
|
||||
Toot will respect the `XDG_CONFIG_HOME` environement variable if it's set and
|
||||
look for the settings file in `$XDG_CONFIG_HOME/toot` instead of
|
||||
`~/.config/toot`.
|
||||
|
||||
## Common options
|
||||
|
||||
The `[common]` section includes common options which are applied to all commands.
|
||||
|
||||
```toml
|
||||
[common]
|
||||
# Whether to use ANSI color in output
|
||||
color = true
|
||||
|
||||
# Enable debug logging, shows HTTP requests
|
||||
debug = true
|
||||
|
||||
# Redirect debug log to the given file
|
||||
debug_file = "/tmp/toot.log"
|
||||
|
||||
# Log request and response bodies in the debug log
|
||||
verbose = false
|
||||
|
||||
# Do not write to output
|
||||
quiet = false
|
||||
```
|
||||
|
||||
## Overriding command defaults
|
||||
|
||||
Defaults for command arguments can be override by specifying a `[commands.<name>]` section.
|
||||
|
||||
For example, to override `toot post`.
|
||||
|
||||
```toml
|
||||
[commands.post]
|
||||
editor = "vim"
|
||||
sensitive = true
|
||||
visibility = "unlisted"
|
||||
scheduled_in = "30 minutes"
|
||||
```
|
||||
|
||||
## TUI color palette
|
||||
|
||||
TUI uses Urwid which provides several color modes. See
|
||||
[Urwid documentation](https://urwid.org/manual/displayattributes.html)
|
||||
for more details.
|
||||
|
||||
By default, TUI operates in 16-color mode which can be changed by setting the
|
||||
`color` setting in the `[tui]` section to one of the following values:
|
||||
|
||||
* `1` (monochrome)
|
||||
* `16` (default)
|
||||
* `88`
|
||||
* `256`
|
||||
* `16777216` (24 bit)
|
||||
|
||||
TUI defines a list of colors which can be customized, currently they can be seen
|
||||
[in the source code](https://github.com/ihabunek/toot/blob/master/toot/tui/constants.py). They can be overriden in the `[tui.palette]` section.
|
||||
|
||||
Each color is defined as a list of upto 5 values:
|
||||
|
||||
* foreground color (16 color mode)
|
||||
* background color (16 color mode)
|
||||
* monochrome color (monochrome mode)
|
||||
* foreground color (high-color mode)
|
||||
* background color (high-color mode)
|
||||
|
||||
Any colors which are not used by your desired color mode can be skipped or set
|
||||
to an empty string.
|
||||
|
||||
For example, to change the button colors in 16 color mode:
|
||||
|
||||
```toml
|
||||
[tui.palette]
|
||||
button = ["dark red,bold", ""]
|
||||
button_focused = ["light gray", "green"]
|
||||
```
|
||||
|
||||
In monochrome mode:
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
colors = 1
|
||||
|
||||
[tui.palette]
|
||||
button = ["", "", "bold"]
|
||||
button_focused = ["", "", "italics"]
|
||||
```
|
||||
|
||||
In 256 color mode:
|
||||
|
||||
```toml
|
||||
[tui]
|
||||
colors = 256
|
||||
|
||||
[tui.palette]
|
||||
button = ["", "", "", "#aaa", "#bbb"]
|
||||
button_focused = ["", "", "", "#aaa", "#bbb"]
|
||||
```
|
3
setup.py
3
setup.py
|
@ -12,7 +12,7 @@ and blocking accounts and other actions.
|
|||
|
||||
setup(
|
||||
name='toot',
|
||||
version='0.36.0',
|
||||
version='0.38.1',
|
||||
description='Mastodon CLI client',
|
||||
long_description=long_description.strip(),
|
||||
author='Ivan Habunek',
|
||||
|
@ -39,6 +39,7 @@ setup(
|
|||
"wcwidth>=0.1.7",
|
||||
"urwid>=2.0.0,<3.0",
|
||||
"urwidgets>=0.1,<0.2",
|
||||
"tomlkit>=0.10.0,<1.0"
|
||||
],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
|
|
|
@ -8,7 +8,7 @@ To enable integration tests, export the following environment variables to match
|
|||
your test server and database:
|
||||
|
||||
```
|
||||
export TOOT_TEST_HOSTNAME="localhost:3000"
|
||||
export TOOT_TEST_BASE_URL="localhost:3000"
|
||||
export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development"
|
||||
```
|
||||
"""
|
||||
|
@ -26,6 +26,11 @@ from toot.exceptions import ApiError, ConsoleError
|
|||
from toot.output import print_out
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
import toot.settings
|
||||
toot.settings.DISABLE_SETTINGS = True
|
||||
|
||||
|
||||
# Mastodon database name, used to confirm user registration without having to click the link
|
||||
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import re
|
||||
from uuid import uuid4
|
||||
import pytest
|
||||
|
||||
from toot import api
|
||||
|
@ -80,3 +82,35 @@ def test_tags(run, base_url):
|
|||
|
||||
out = run("tags_followed")
|
||||
assert out == f"* #bar\t{base_url}/tags/bar"
|
||||
|
||||
|
||||
def test_status(app, user, run):
|
||||
uuid = str(uuid4())
|
||||
response = api.post_status(app, user, uuid)
|
||||
|
||||
out = run("status", response["id"])
|
||||
assert uuid in out
|
||||
assert user.username in out
|
||||
assert response["id"] in out
|
||||
|
||||
|
||||
def test_thread(app, user, run):
|
||||
uuid = str(uuid4())
|
||||
s1 = api.post_status(app, user, uuid + "1")
|
||||
s2 = api.post_status(app, user, uuid + "2", in_reply_to_id=s1["id"])
|
||||
s3 = api.post_status(app, user, uuid + "3", in_reply_to_id=s2["id"])
|
||||
|
||||
for status in [s1, s2, s3]:
|
||||
out = run("thread", status["id"])
|
||||
bits = re.split(r"─+", out)
|
||||
bits = [b for b in bits if b]
|
||||
|
||||
assert len(bits) == 3
|
||||
|
||||
assert s1["id"] in bits[0]
|
||||
assert s2["id"] in bits[1]
|
||||
assert s3["id"] in bits[2]
|
||||
|
||||
assert f"{uuid}1" in bits[0]
|
||||
assert f"{uuid}2" in bits[1]
|
||||
assert f"{uuid}3" in bits[2]
|
||||
|
|
|
@ -161,6 +161,7 @@ def test_timeline_with_re(mock_get, monkeypatch, capsys):
|
|||
'acct': 'fz'
|
||||
},
|
||||
'reblog': {
|
||||
'created_at': '2017-04-12T15:53:18.174Z',
|
||||
'account': {
|
||||
'display_name': 'Johnny Cash',
|
||||
'acct': 'jc'
|
||||
|
@ -179,8 +180,8 @@ def test_timeline_with_re(mock_get, monkeypatch, capsys):
|
|||
out, err = capsys.readouterr()
|
||||
lines = uncolorize(out).split("\n")
|
||||
|
||||
assert "Frank Zappa" in lines[1]
|
||||
assert "@fz" in lines[1]
|
||||
assert "Johnny Cash" in lines[1]
|
||||
assert "@jc" in lines[1]
|
||||
assert "2017-04-12 15:53 UTC" in lines[1]
|
||||
|
||||
assert (
|
||||
|
@ -188,7 +189,7 @@ def test_timeline_with_re(mock_get, monkeypatch, capsys):
|
|||
"exact mathematical design, but\nwhat's missing is the eyebrows." in out)
|
||||
|
||||
assert "111111111111111111" in lines[-3]
|
||||
assert "↻ Reblogged @jc" in lines[-3]
|
||||
assert "↻ @fz boosted" in lines[-3]
|
||||
|
||||
assert err == ""
|
||||
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
from os.path import join, expanduser
|
||||
from collections import namedtuple
|
||||
|
||||
__version__ = '0.36.0'
|
||||
__version__ = '0.38.1'
|
||||
|
||||
App = namedtuple('App', ['instance', 'base_url', 'client_id', 'client_secret'])
|
||||
User = namedtuple('User', ['instance', 'username', 'access_token'])
|
||||
|
@ -9,3 +13,22 @@ DEFAULT_INSTANCE = 'https://mastodon.social'
|
|||
|
||||
CLIENT_NAME = 'toot - a Mastodon CLI client'
|
||||
CLIENT_WEBSITE = 'https://github.com/ihabunek/toot'
|
||||
|
||||
TOOT_CONFIG_DIR_NAME = "toot"
|
||||
|
||||
|
||||
def get_config_dir():
|
||||
"""Returns the path to toot config directory"""
|
||||
|
||||
# On Windows, store the config in roaming appdata
|
||||
if sys.platform == "win32" and "APPDATA" in os.environ:
|
||||
return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME)
|
||||
|
||||
# Respect XDG_CONFIG_HOME env variable if set
|
||||
# https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
if "XDG_CONFIG_HOME" in os.environ:
|
||||
config_home = expanduser(os.environ["XDG_CONFIG_HOME"])
|
||||
return join(config_home, TOOT_CONFIG_DIR_NAME)
|
||||
|
||||
# Default to ~/.config/toot/
|
||||
return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME)
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
from .console import main
|
||||
|
||||
main()
|
|
@ -521,6 +521,10 @@ def unmute(app, user, account):
|
|||
return _account_action(app, user, account, 'unmute')
|
||||
|
||||
|
||||
def muted(app, user):
|
||||
return _get_response_list(app, user, "/api/v1/mutes")
|
||||
|
||||
|
||||
def block(app, user, account):
|
||||
return _account_action(app, user, account, 'block')
|
||||
|
||||
|
@ -529,6 +533,10 @@ def unblock(app, user, account):
|
|||
return _account_action(app, user, account, 'unblock')
|
||||
|
||||
|
||||
def blocked(app, user):
|
||||
return _get_response_list(app, user, "/api/v1/blocks")
|
||||
|
||||
|
||||
def verify_credentials(app, user):
|
||||
return http.get(app, user, '/api/v1/accounts/verify_credentials').json()
|
||||
|
||||
|
|
|
@ -6,12 +6,13 @@ from datetime import datetime, timedelta, timezone
|
|||
from time import sleep, time
|
||||
from toot import api, config, __version__
|
||||
from toot.auth import login_interactive, login_browser_interactive, create_app_interactive
|
||||
from toot.entities import Instance, Notification, Status, from_dict
|
||||
from toot.exceptions import ApiError, ConsoleError
|
||||
from toot.output import (print_lists, print_out, print_instance, print_account, print_acct_list,
|
||||
print_search_results, print_timeline, print_notifications, print_tag_list,
|
||||
print_search_results, print_status, print_timeline, print_notifications, print_tag_list,
|
||||
print_list_accounts, print_user_list)
|
||||
from toot.tui.utils import parse_datetime
|
||||
from toot.utils import args_get_instance, delete_tmp_status_file, editor_input, multiline_input, EOF_KEY
|
||||
from toot.utils.datetime import parse_datetime
|
||||
|
||||
|
||||
def get_timeline_generator(app, user, args):
|
||||
|
@ -56,7 +57,8 @@ def timeline(app, user, args, generator=None):
|
|||
if args.reverse:
|
||||
items = reversed(items)
|
||||
|
||||
print_timeline(items)
|
||||
statuses = [from_dict(Status, item) for item in items]
|
||||
print_timeline(statuses)
|
||||
|
||||
if args.once or not sys.stdout.isatty():
|
||||
break
|
||||
|
@ -66,6 +68,12 @@ def timeline(app, user, args, generator=None):
|
|||
break
|
||||
|
||||
|
||||
def status(app, user, args):
|
||||
status = api.single_status(app, user, args.status_id)
|
||||
status = from_dict(Status, status)
|
||||
print_status(status)
|
||||
|
||||
|
||||
def thread(app, user, args):
|
||||
toot = api.single_status(app, user, args.status_id)
|
||||
context = api.context(app, user, args.status_id)
|
||||
|
@ -78,7 +86,8 @@ def thread(app, user, args):
|
|||
for item in context['descendants']:
|
||||
thread.append(item)
|
||||
|
||||
print_timeline(thread)
|
||||
statuses = [from_dict(Status, s) for s in thread]
|
||||
print_timeline(statuses)
|
||||
|
||||
|
||||
def post(app, user, args):
|
||||
|
@ -484,6 +493,11 @@ def unmute(app, user, args):
|
|||
print_out("<green>✓ {} is no longer muted</green>".format(args.account))
|
||||
|
||||
|
||||
def muted(app, user, args):
|
||||
response = api.muted(app, user)
|
||||
print_acct_list(response)
|
||||
|
||||
|
||||
def block(app, user, args):
|
||||
account = api.find_account(app, user, args.account)
|
||||
api.block(app, user, account['id'])
|
||||
|
@ -496,6 +510,11 @@ def unblock(app, user, args):
|
|||
print_out("<green>✓ {} is no longer blocked</green>".format(args.account))
|
||||
|
||||
|
||||
def blocked(app, user, args):
|
||||
response = api.blocked(app, user)
|
||||
print_acct_list(response)
|
||||
|
||||
|
||||
def whoami(app, user, args):
|
||||
account = api.verify_credentials(app, user)
|
||||
print_account(account)
|
||||
|
@ -515,6 +534,7 @@ def instance(app, user, args):
|
|||
|
||||
try:
|
||||
instance = api.get_instance(base_url)
|
||||
instance = from_dict(Instance, instance)
|
||||
print_instance(instance)
|
||||
except ApiError:
|
||||
raise ConsoleError(
|
||||
|
@ -542,6 +562,7 @@ def notifications(app, user, args):
|
|||
if args.reverse:
|
||||
notifications = reversed(notifications)
|
||||
|
||||
notifications = [from_dict(Notification, n) for n in notifications]
|
||||
print_notifications(notifications)
|
||||
|
||||
|
||||
|
|
|
@ -1,44 +1,22 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from functools import wraps
|
||||
from os.path import dirname, join, expanduser
|
||||
from os.path import dirname, join
|
||||
|
||||
from toot import User, App
|
||||
from toot import User, App, get_config_dir
|
||||
from toot.exceptions import ConsoleError
|
||||
from toot.output import print_out
|
||||
|
||||
|
||||
TOOT_CONFIG_DIR_NAME = "toot"
|
||||
TOOT_CONFIG_FILE_NAME = "config.json"
|
||||
|
||||
|
||||
def get_config_dir():
|
||||
"""Returns the path to toot config directory"""
|
||||
|
||||
# On Windows, store the config in roaming appdata
|
||||
if sys.platform == "win32" and "APPDATA" in os.environ:
|
||||
return join(os.getenv("APPDATA"), TOOT_CONFIG_DIR_NAME)
|
||||
|
||||
# Respect XDG_CONFIG_HOME env variable if set
|
||||
# https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
|
||||
if "XDG_CONFIG_HOME" in os.environ:
|
||||
config_home = expanduser(os.environ["XDG_CONFIG_HOME"])
|
||||
return join(config_home, TOOT_CONFIG_DIR_NAME)
|
||||
|
||||
# Default to ~/.config/toot/
|
||||
return join(expanduser("~"), ".config", TOOT_CONFIG_DIR_NAME)
|
||||
|
||||
|
||||
def get_config_file_path():
|
||||
"""Returns the path to toot config file."""
|
||||
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
|
||||
|
||||
|
||||
CONFIG_FILE = get_config_file_path()
|
||||
|
||||
|
||||
def user_id(user):
|
||||
return "{}@{}".format(user.username, user.instance)
|
||||
|
||||
|
@ -63,15 +41,18 @@ def make_config(path):
|
|||
|
||||
|
||||
def load_config():
|
||||
if not os.path.exists(CONFIG_FILE):
|
||||
make_config(CONFIG_FILE)
|
||||
path = get_config_file_path()
|
||||
|
||||
with open(CONFIG_FILE) as f:
|
||||
if not os.path.exists(path):
|
||||
make_config(path)
|
||||
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_config(config):
|
||||
with open(CONFIG_FILE, 'w') as f:
|
||||
path = get_config_file_path()
|
||||
with open(path, "w") as f:
|
||||
return json.dump(config, f, indent=True, sort_keys=True)
|
||||
|
||||
|
||||
|
|
|
@ -7,9 +7,10 @@ import sys
|
|||
from argparse import ArgumentParser, FileType, ArgumentTypeError, Action
|
||||
from collections import namedtuple
|
||||
from itertools import chain
|
||||
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__
|
||||
from toot import config, commands, CLIENT_NAME, CLIENT_WEBSITE, __version__, settings
|
||||
from toot.exceptions import ApiError, ConsoleError
|
||||
from toot.output import print_out, print_err
|
||||
from toot.settings import get_setting
|
||||
|
||||
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
|
||||
VISIBILITY_CHOICES_STR = ", ".join(f"'{v}'" for v in VISIBILITY_CHOICES)
|
||||
|
@ -459,6 +460,16 @@ READ_COMMANDS = [
|
|||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="status",
|
||||
description="Show a single status",
|
||||
arguments=[
|
||||
(["status_id"], {
|
||||
"help": "ID of the status to show.",
|
||||
}),
|
||||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="timeline",
|
||||
description="Show recent items in a timeline (home by default)",
|
||||
|
@ -693,6 +704,12 @@ ACCOUNTS_COMMANDS = [
|
|||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="muted",
|
||||
description="List muted accounts",
|
||||
arguments=[],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="block",
|
||||
description="Block an account",
|
||||
|
@ -709,6 +726,12 @@ ACCOUNTS_COMMANDS = [
|
|||
],
|
||||
require_auth=True,
|
||||
),
|
||||
Command(
|
||||
name="blocked",
|
||||
description="List blocked accounts",
|
||||
arguments=[],
|
||||
require_auth=True,
|
||||
),
|
||||
]
|
||||
|
||||
TAG_COMMANDS = [
|
||||
|
@ -872,12 +895,24 @@ def get_argument_parser(name, command):
|
|||
if command.require_auth:
|
||||
combined_args += common_auth_args
|
||||
|
||||
defaults = get_setting(f"commands.{name}", dict, {})
|
||||
|
||||
for args, kwargs in combined_args:
|
||||
# Set default value from settings if exists
|
||||
default = get_default_value(defaults, args)
|
||||
if default is not None:
|
||||
kwargs["default"] = default
|
||||
parser.add_argument(*args, **kwargs)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def get_default_value(defaults, args):
|
||||
# Hacky way to determine command name from argparse args
|
||||
name = args[-1].lstrip("-").replace("-", "_")
|
||||
return defaults.get(name)
|
||||
|
||||
|
||||
def run_command(app, user, name, args):
|
||||
command = next((c for c in COMMANDS if c.name == name), None)
|
||||
|
||||
|
@ -909,9 +944,8 @@ def run_command(app, user, name, args):
|
|||
|
||||
|
||||
def main():
|
||||
# Enable debug logging if --debug is in args
|
||||
if "--debug" in sys.argv:
|
||||
filename = os.getenv("TOOT_LOG_FILE")
|
||||
if settings.get_debug():
|
||||
filename = settings.get_debug_file()
|
||||
logging.basicConfig(level=logging.DEBUG, filename=filename)
|
||||
logging.getLogger("urllib3").setLevel(logging.INFO)
|
||||
|
||||
|
|
|
@ -0,0 +1,416 @@
|
|||
"""
|
||||
Dataclasses which represent entities returned by the Mastodon API.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
|
||||
from dataclasses import dataclass, is_dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Dict, List, Optional, Type, TypeVar, Union
|
||||
from typing import get_type_hints
|
||||
|
||||
from toot.typing_compat import get_args, get_origin
|
||||
from toot.utils import get_text
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountField:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Account/#Field
|
||||
"""
|
||||
name: str
|
||||
value: str
|
||||
verified_at: Optional[datetime]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomEmoji:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/CustomEmoji/
|
||||
"""
|
||||
shortcode: str
|
||||
url: str
|
||||
static_url: str
|
||||
visible_in_picker: bool
|
||||
category: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Account:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Account/
|
||||
"""
|
||||
id: str
|
||||
username: str
|
||||
acct: str
|
||||
url: str
|
||||
display_name: str
|
||||
note: str
|
||||
avatar: str
|
||||
avatar_static: str
|
||||
header: str
|
||||
header_static: str
|
||||
locked: bool
|
||||
fields: List[AccountField]
|
||||
emojis: List[CustomEmoji]
|
||||
bot: bool
|
||||
group: bool
|
||||
discoverable: Optional[bool]
|
||||
noindex: Optional[bool]
|
||||
moved: Optional["Account"]
|
||||
suspended: Optional[bool]
|
||||
limited: Optional[bool]
|
||||
created_at: datetime
|
||||
last_status_at: Optional[date]
|
||||
statuses_count: int
|
||||
followers_count: int
|
||||
following_count: int
|
||||
|
||||
@property
|
||||
def note_plaintext(self) -> str:
|
||||
return get_text(self.note)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Application:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Status/#application
|
||||
"""
|
||||
name: str
|
||||
website: Optional[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaAttachment:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/MediaAttachment/
|
||||
"""
|
||||
id: str
|
||||
type: str
|
||||
url: str
|
||||
preview_url: str
|
||||
remote_url: Optional[str]
|
||||
meta: dict
|
||||
description: str
|
||||
blurhash: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusMention:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Status/#Mention
|
||||
"""
|
||||
id: str
|
||||
username: str
|
||||
url: str
|
||||
acct: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatusTag:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Status/#Tag
|
||||
"""
|
||||
name: str
|
||||
url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollOption:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Poll/#Option
|
||||
"""
|
||||
title: str
|
||||
votes_count: Optional[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Poll:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Poll/
|
||||
"""
|
||||
id: str
|
||||
expires_at: Optional[datetime]
|
||||
expired: bool
|
||||
multiple: bool
|
||||
votes_count: int
|
||||
voters_count: Optional[int]
|
||||
options: List[PollOption]
|
||||
emojis: List[CustomEmoji]
|
||||
voted: Optional[bool]
|
||||
own_votes: Optional[List[int]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreviewCard:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/PreviewCard/
|
||||
"""
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
type: str
|
||||
author_name: str
|
||||
author_url: str
|
||||
provider_name: str
|
||||
provider_url: str
|
||||
html: str
|
||||
width: int
|
||||
height: int
|
||||
image: Optional[str]
|
||||
embed_url: str
|
||||
blurhash: Optional[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterKeyword:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/FilterKeyword/
|
||||
"""
|
||||
id: str
|
||||
keyword: str
|
||||
whole_word: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterStatus:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/FilterStatus/
|
||||
"""
|
||||
id: str
|
||||
status_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Filter:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Filter/
|
||||
"""
|
||||
id: str
|
||||
title: str
|
||||
context: List[str]
|
||||
expires_at: Optional[datetime]
|
||||
filter_action: str
|
||||
keywords: List[FilterKeyword]
|
||||
statuses: List[FilterStatus]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterResult:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/FilterResult/
|
||||
"""
|
||||
filter: Filter
|
||||
keyword_matches: Optional[List[str]]
|
||||
status_matches: Optional[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Status:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Status/
|
||||
"""
|
||||
id: str
|
||||
uri: str
|
||||
created_at: datetime
|
||||
account: Account
|
||||
content: str
|
||||
visibility: str
|
||||
sensitive: bool
|
||||
spoiler_text: str
|
||||
media_attachments: List[MediaAttachment]
|
||||
application: Optional[Application]
|
||||
mentions: List[StatusMention]
|
||||
tags: List[StatusTag]
|
||||
emojis: List[CustomEmoji]
|
||||
reblogs_count: int
|
||||
favourites_count: int
|
||||
replies_count: int
|
||||
url: Optional[str]
|
||||
in_reply_to_id: Optional[str]
|
||||
in_reply_to_account_id: Optional[str]
|
||||
reblog: Optional["Status"]
|
||||
poll: Optional[Poll]
|
||||
card: Optional[PreviewCard]
|
||||
language: Optional[str]
|
||||
text: Optional[str]
|
||||
edited_at: Optional[datetime]
|
||||
favourited: Optional[bool]
|
||||
reblogged: Optional[bool]
|
||||
muted: Optional[bool]
|
||||
bookmarked: Optional[bool]
|
||||
pinned: Optional[bool]
|
||||
filtered: Optional[List[FilterResult]]
|
||||
|
||||
@property
|
||||
def original(self) -> "Status":
|
||||
return self.reblog or self
|
||||
|
||||
|
||||
@dataclass
|
||||
class Report:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Report/
|
||||
"""
|
||||
id: str
|
||||
action_taken: bool
|
||||
action_taken_at: Optional[datetime]
|
||||
category: str
|
||||
comment: str
|
||||
forwarded: bool
|
||||
created_at: datetime
|
||||
status_ids: Optional[List[str]]
|
||||
rule_ids: Optional[List[str]]
|
||||
target_account: Account
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notification:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Notification/
|
||||
"""
|
||||
id: str
|
||||
type: str
|
||||
created_at: datetime
|
||||
account: Account
|
||||
status: Optional[Status]
|
||||
report: Optional[Report]
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceUrls:
|
||||
streaming_api: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceStats:
|
||||
user_count: int
|
||||
status_count: int
|
||||
domain_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceConfigurationStatuses:
|
||||
max_characters: int
|
||||
max_media_attachments: int
|
||||
characters_reserved_per_url: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceConfigurationMediaAttachments:
|
||||
supported_mime_types: List[str]
|
||||
image_size_limit: int
|
||||
image_matrix_limit: int
|
||||
video_size_limit: int
|
||||
video_frame_rate_limit: int
|
||||
video_matrix_limit: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceConfigurationPolls:
|
||||
max_options: int
|
||||
max_characters_per_option: int
|
||||
min_expiration: int
|
||||
max_expiration: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceConfiguration:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/V1_Instance/#configuration
|
||||
"""
|
||||
statuses: InstanceConfigurationStatuses
|
||||
media_attachments: InstanceConfigurationMediaAttachments
|
||||
polls: InstanceConfigurationPolls
|
||||
|
||||
|
||||
@dataclass
|
||||
class Rule:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/Rule/
|
||||
"""
|
||||
id: str
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Instance:
|
||||
"""
|
||||
https://docs.joinmastodon.org/entities/V1_Instance/
|
||||
"""
|
||||
uri: str
|
||||
title: str
|
||||
short_description: str
|
||||
description: str
|
||||
email: str
|
||||
version: str
|
||||
urls: InstanceUrls
|
||||
stats: InstanceStats
|
||||
thumbnail: Optional[str]
|
||||
languages: List[str]
|
||||
registrations: bool
|
||||
approval_required: bool
|
||||
invites_enabled: bool
|
||||
configuration: InstanceConfiguration
|
||||
contact_account: Optional[Account]
|
||||
rules: List[Rule]
|
||||
|
||||
|
||||
# Generic data class instance
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def from_dict(cls: Type[T], data: Dict) -> T:
|
||||
"""Convert a nested dict into an instance of `cls`."""
|
||||
def _fields():
|
||||
hints = get_type_hints(cls)
|
||||
for field in dataclasses.fields(cls):
|
||||
field_type = _prune_optional(hints[field.name])
|
||||
default_value = _get_default_value(field)
|
||||
value = data.get(field.name, default_value)
|
||||
yield field.name, _convert(field_type, value)
|
||||
|
||||
return cls(**dict(_fields()))
|
||||
|
||||
|
||||
def _get_default_value(field):
|
||||
if field.default is not dataclasses.MISSING:
|
||||
return field.default
|
||||
|
||||
if field.default_factory is not dataclasses.MISSING:
|
||||
return field.default_factory()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _convert(field_type, value):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if field_type in [str, int, bool, dict]:
|
||||
return value
|
||||
|
||||
if field_type == datetime:
|
||||
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
|
||||
|
||||
if field_type == date:
|
||||
return date.fromisoformat(value)
|
||||
|
||||
if get_origin(field_type) == list:
|
||||
(inner_type,) = get_args(field_type)
|
||||
return [_convert(inner_type, x) for x in value]
|
||||
|
||||
if is_dataclass(field_type):
|
||||
return from_dict(field_type, value)
|
||||
|
||||
raise ValueError(f"Not implemented for type '{field_type}'")
|
||||
|
||||
|
||||
def _prune_optional(field_type):
|
||||
"""For `Optional[<type>]` returns the encapsulated `<type>`."""
|
||||
if get_origin(field_type) == Union:
|
||||
args = get_args(field_type)
|
||||
if len(args) == 2 and args[1] == type(None): # noqa
|
||||
return args[0]
|
||||
|
||||
return field_type
|
124
toot/output.py
124
toot/output.py
|
@ -3,12 +3,13 @@ import re
|
|||
import sys
|
||||
import textwrap
|
||||
|
||||
from typing import List
|
||||
from wcwidth import wcswidth
|
||||
|
||||
from toot.tui.utils import parse_datetime
|
||||
from functools import lru_cache
|
||||
from toot import settings
|
||||
from toot.entities import Instance, Notification, Poll, Status
|
||||
from toot.utils import get_text, parse_html
|
||||
from toot.wcstring import wc_wrap
|
||||
from typing import List
|
||||
from wcwidth import wcswidth
|
||||
|
||||
|
||||
STYLES = {
|
||||
|
@ -100,6 +101,7 @@ def strip_tags(message):
|
|||
return re.sub(STYLE_TAG_PATTERN, "", message)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def use_ansi_color():
|
||||
"""Returns True if ANSI color codes should be used."""
|
||||
|
||||
|
@ -116,45 +118,44 @@ def use_ansi_color():
|
|||
if "--no-color" in sys.argv:
|
||||
return False
|
||||
|
||||
# Check in settings
|
||||
color = settings.get_setting("common.color", bool)
|
||||
if color is not None:
|
||||
return color
|
||||
|
||||
# Use color by default
|
||||
return True
|
||||
|
||||
|
||||
USE_ANSI_COLOR = use_ansi_color()
|
||||
|
||||
QUIET = "--quiet" in sys.argv
|
||||
|
||||
|
||||
def print_out(*args, **kwargs):
|
||||
if not QUIET:
|
||||
args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args]
|
||||
if not settings.get_quiet():
|
||||
args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
|
||||
print(*args, **kwargs)
|
||||
|
||||
|
||||
def print_err(*args, **kwargs):
|
||||
args = [f"<red>{a}</red>" for a in args]
|
||||
args = [colorize(a) if USE_ANSI_COLOR else strip_tags(a) for a in args]
|
||||
args = [colorize(a) if use_ansi_color() else strip_tags(a) for a in args]
|
||||
print(*args, file=sys.stderr, **kwargs)
|
||||
|
||||
|
||||
def print_instance(instance):
|
||||
print_out(f"<green>{instance['title']}</green>")
|
||||
print_out(f"<blue>{instance['uri']}</blue>")
|
||||
print_out(f"running Mastodon {instance['version']}")
|
||||
def print_instance(instance: Instance):
|
||||
print_out(f"<green>{instance.title}</green>")
|
||||
print_out(f"<blue>{instance.uri}</blue>")
|
||||
print_out(f"running Mastodon {instance.version}")
|
||||
print_out()
|
||||
|
||||
description = instance.get("description")
|
||||
if description:
|
||||
for paragraph in re.split(r"[\r\n]+", description.strip()):
|
||||
if instance.description:
|
||||
for paragraph in re.split(r"[\r\n]+", instance.description.strip()):
|
||||
paragraph = get_text(paragraph)
|
||||
print_out(textwrap.fill(paragraph, width=80))
|
||||
print_out()
|
||||
|
||||
rules = instance.get("rules")
|
||||
if rules:
|
||||
if instance.rules:
|
||||
print_out("Rules:")
|
||||
for ordinal, rule in enumerate(rules):
|
||||
for ordinal, rule in enumerate(instance.rules):
|
||||
ordinal = f"{ordinal + 1}."
|
||||
lines = textwrap.wrap(rule["text"], 80 - len(ordinal))
|
||||
lines = textwrap.wrap(rule.text, 80 - len(ordinal))
|
||||
first = True
|
||||
for line in lines:
|
||||
if first:
|
||||
|
@ -162,6 +163,11 @@ def print_instance(instance):
|
|||
first = False
|
||||
else:
|
||||
print_out(f"{' ' * len(ordinal)} {line}")
|
||||
print_out()
|
||||
|
||||
contact = instance.contact_account
|
||||
if contact:
|
||||
print_out(f"Contact: {contact.display_name} @{contact.acct}")
|
||||
|
||||
|
||||
def print_account(account):
|
||||
|
@ -269,20 +275,18 @@ def print_search_results(results):
|
|||
print_out("<yellow>Nothing found</yellow>")
|
||||
|
||||
|
||||
def print_status(status, width):
|
||||
reblog = status['reblog']
|
||||
content = reblog['content'] if reblog else status['content']
|
||||
media_attachments = reblog['media_attachments'] if reblog else status['media_attachments']
|
||||
in_reply_to = status['in_reply_to_id']
|
||||
poll = reblog.get('poll') if reblog else status.get('poll')
|
||||
def print_status(status: Status, width: int = 80):
|
||||
status_id = status.id
|
||||
in_reply_to_id = status.in_reply_to_id
|
||||
reblogged_by = status.account if status.reblog else None
|
||||
|
||||
time = parse_datetime(status['created_at'])
|
||||
time = time.strftime('%Y-%m-%d %H:%M %Z')
|
||||
status = status.original
|
||||
|
||||
username = "@" + status['account']['acct']
|
||||
time = status.created_at.strftime('%Y-%m-%d %H:%M %Z')
|
||||
username = "@" + status.account.acct
|
||||
spacing = width - wcswidth(username) - wcswidth(time) - 2
|
||||
|
||||
display_name = status['account']['display_name']
|
||||
display_name = status.account.display_name
|
||||
if display_name:
|
||||
spacing -= wcswidth(display_name) + 1
|
||||
|
||||
|
@ -294,23 +298,24 @@ def print_status(status, width):
|
|||
)
|
||||
|
||||
print_out("")
|
||||
print_html(content, width)
|
||||
print_html(status.content, width)
|
||||
|
||||
if media_attachments:
|
||||
if status.media_attachments:
|
||||
print_out("\nMedia:")
|
||||
for attachment in media_attachments:
|
||||
url = attachment["url"]
|
||||
for attachment in status.media_attachments:
|
||||
url = attachment.url
|
||||
for line in wc_wrap(url, width):
|
||||
print_out(line)
|
||||
|
||||
if poll:
|
||||
print_poll(poll)
|
||||
if status.poll:
|
||||
print_poll(status.poll)
|
||||
|
||||
print_out()
|
||||
|
||||
print_out(
|
||||
f"ID <yellow>{status['id']}</yellow> ",
|
||||
f"↲ In reply to <yellow>{in_reply_to}</yellow> " if in_reply_to else "",
|
||||
f"↻ Reblogged <blue>@{reblog['account']['acct']}</blue> " if reblog else "",
|
||||
f"ID <yellow>{status_id}</yellow> ",
|
||||
f"↲ In reply to <yellow>{in_reply_to_id}</yellow> " if in_reply_to_id else "",
|
||||
f"↻ <blue>@{reblogged_by.acct}</blue> boosted " if reblogged_by else "",
|
||||
)
|
||||
|
||||
|
||||
|
@ -325,33 +330,33 @@ def print_html(text, width=80):
|
|||
first = False
|
||||
|
||||
|
||||
def print_poll(poll):
|
||||
def print_poll(poll: Poll):
|
||||
print_out()
|
||||
for idx, option in enumerate(poll["options"]):
|
||||
perc = (round(100 * option["votes_count"] / poll["votes_count"])
|
||||
if poll["votes_count"] else 0)
|
||||
for idx, option in enumerate(poll.options):
|
||||
perc = (round(100 * option.votes_count / poll.votes_count)
|
||||
if poll.votes_count and option.votes_count is not None else 0)
|
||||
|
||||
if poll["voted"] and poll["own_votes"] and idx in poll["own_votes"]:
|
||||
if poll.voted and poll.own_votes and idx in poll.own_votes:
|
||||
voted_for = " <yellow>✓</yellow>"
|
||||
else:
|
||||
voted_for = ""
|
||||
|
||||
print_out(f'{option["title"]} - {perc}% {voted_for}')
|
||||
print_out(f'{option.title} - {perc}% {voted_for}')
|
||||
|
||||
poll_footer = f'Poll · {poll["votes_count"]} votes'
|
||||
poll_footer = f'Poll · {poll.votes_count} votes'
|
||||
|
||||
if poll["expired"]:
|
||||
if poll.expired:
|
||||
poll_footer += " · Closed"
|
||||
|
||||
if poll["expires_at"]:
|
||||
expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M")
|
||||
if poll.expires_at:
|
||||
expires_at = poll.expires_at.strftime("%Y-%m-%d %H:%M")
|
||||
poll_footer += f" · Closes on {expires_at}"
|
||||
|
||||
print_out()
|
||||
print_out(poll_footer)
|
||||
|
||||
|
||||
def print_timeline(items, width=100):
|
||||
def print_timeline(items: List[Status], width=100):
|
||||
print_out("─" * width)
|
||||
for item in items:
|
||||
print_status(item, width)
|
||||
|
@ -366,20 +371,19 @@ notification_msgs = {
|
|||
}
|
||||
|
||||
|
||||
def print_notification(notification, width=100):
|
||||
account = "{display_name} @{acct}".format(**notification["account"])
|
||||
msg = notification_msgs.get(notification["type"])
|
||||
def print_notification(notification: Notification, width=100):
|
||||
account = f"{notification.account.display_name} @{notification.account.acct}"
|
||||
msg = notification_msgs.get(notification.type)
|
||||
if msg is None:
|
||||
return
|
||||
|
||||
print_out("─" * width)
|
||||
print_out(msg.format(account=account))
|
||||
status = notification.get("status")
|
||||
if status is not None:
|
||||
print_status(status, width)
|
||||
if notification.status:
|
||||
print_status(notification.status, width)
|
||||
|
||||
|
||||
def print_notifications(notifications, width=100):
|
||||
def print_notifications(notifications: List[Notification], width=100):
|
||||
for notification in notifications:
|
||||
print_notification(notification)
|
||||
print_out("─" * width)
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
from functools import lru_cache
|
||||
from os.path import exists, join
|
||||
from tomlkit import parse
|
||||
from toot import get_config_dir
|
||||
from typing import Optional, Type, TypeVar
|
||||
|
||||
|
||||
DISABLE_SETTINGS = False
|
||||
|
||||
TOOT_SETTINGS_FILE_NAME = "settings.toml"
|
||||
|
||||
|
||||
def get_settings_path():
|
||||
return join(get_config_dir(), TOOT_SETTINGS_FILE_NAME)
|
||||
|
||||
|
||||
def load_settings() -> dict:
|
||||
# Used for testing without config file
|
||||
if DISABLE_SETTINGS:
|
||||
return {}
|
||||
|
||||
path = get_settings_path()
|
||||
|
||||
if not exists(path):
|
||||
return {}
|
||||
|
||||
with open(path) as f:
|
||||
return parse(f.read())
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_settings():
|
||||
return load_settings()
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def get_setting(key: str, type: Type[T], default: Optional[T] = None) -> Optional[T]:
|
||||
"""
|
||||
Get a setting value. The key should be a dot-separated string,
|
||||
e.g. "commands.post.editor" which will correspond to the "editor" setting
|
||||
inside the `[commands.post]` section.
|
||||
"""
|
||||
settings = get_settings()
|
||||
return _get_setting(settings, key.split("."), type, default)
|
||||
|
||||
|
||||
def _get_setting(dct, keys, type: Type, default=None):
|
||||
if len(keys) == 0:
|
||||
if isinstance(dct, type):
|
||||
return dct
|
||||
else:
|
||||
# TODO: warn? cast? both?
|
||||
return default
|
||||
|
||||
key = keys[0]
|
||||
if isinstance(dct, dict) and key in dct:
|
||||
return _get_setting(dct[key], keys[1:], type, default)
|
||||
|
||||
return default
|
||||
|
||||
|
||||
def get_debug() -> bool:
|
||||
if "--debug" in sys.argv:
|
||||
return True
|
||||
|
||||
return get_setting("common.debug", bool, False)
|
||||
|
||||
|
||||
def get_debug_file() -> Optional[str]:
|
||||
from_env = os.getenv("TOOT_LOG_FILE")
|
||||
if from_env:
|
||||
return from_env
|
||||
|
||||
return get_setting("common.debug_file", str)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_quiet():
|
||||
if "--quiet" in sys.argv:
|
||||
return True
|
||||
|
||||
return get_setting("common.quiet", str, False)
|
|
@ -3,7 +3,7 @@ import urwid
|
|||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from toot import api, config, __version__
|
||||
from toot import api, config, __version__, settings
|
||||
from toot.console import get_default_visibility
|
||||
from toot.exceptions import ApiError
|
||||
|
||||
|
@ -14,13 +14,16 @@ from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusL
|
|||
from .overlays import StatusDeleteConfirmation, Account
|
||||
from .poll import Poll
|
||||
from .timeline import Timeline
|
||||
from .utils import parse_content_links, show_media, copy_to_clipboard
|
||||
from .utils import get_max_toot_chars, parse_content_links, show_media, copy_to_clipboard
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
urwid.set_encoding('UTF-8')
|
||||
|
||||
|
||||
DEFAULT_MAX_TOOT_CHARS = 500
|
||||
|
||||
|
||||
class Header(urwid.WidgetWrap):
|
||||
def __init__(self, app, user):
|
||||
self.app = app
|
||||
|
@ -72,29 +75,51 @@ class Footer(urwid.Pile):
|
|||
|
||||
class TUI(urwid.Frame):
|
||||
"""Main TUI frame."""
|
||||
loop: urwid.MainLoop
|
||||
screen: urwid.BaseScreen
|
||||
|
||||
@classmethod
|
||||
def create(cls, app, user, args):
|
||||
@staticmethod
|
||||
def create(app, user, args):
|
||||
"""Factory method, sets up TUI and an event loop."""
|
||||
screen = TUI.create_screen(args)
|
||||
tui = TUI(app, user, screen, args)
|
||||
|
||||
palette = PALETTE.copy()
|
||||
overrides = settings.get_setting("tui.palette", dict, {})
|
||||
for name, styles in overrides.items():
|
||||
palette.append(tuple([name] + styles))
|
||||
|
||||
tui = cls(app, user, args)
|
||||
loop = urwid.MainLoop(
|
||||
tui,
|
||||
palette=PALETTE,
|
||||
palette=palette,
|
||||
event_loop=urwid.AsyncioEventLoop(),
|
||||
unhandled_input=tui.unhandled_input,
|
||||
screen=screen,
|
||||
)
|
||||
tui.loop = loop
|
||||
|
||||
return tui
|
||||
|
||||
def __init__(self, app, user, args):
|
||||
@staticmethod
|
||||
def create_screen(args):
|
||||
screen = urwid.raw_display.Screen()
|
||||
|
||||
# Determine how many colors to use
|
||||
default_colors = 1 if args.no_color else 16
|
||||
colors = settings.get_setting("tui.colors", int, default_colors)
|
||||
logger.debug(f"Setting colors to {colors}")
|
||||
screen.set_terminal_properties(colors)
|
||||
|
||||
return screen
|
||||
|
||||
def __init__(self, app, user, screen, args):
|
||||
self.app = app
|
||||
self.user = user
|
||||
self.args = args
|
||||
self.config = config.load_config()
|
||||
|
||||
self.loop = None # set in `create`
|
||||
self.loop = None # late init, set in `create`
|
||||
self.screen = screen
|
||||
self.executor = ThreadPoolExecutor(max_workers=1)
|
||||
self.timeline_generator = api.home_timeline_generator(app, user, limit=40)
|
||||
|
||||
|
@ -105,13 +130,12 @@ class TUI(urwid.Frame):
|
|||
self.footer.set_status("Loading...")
|
||||
|
||||
# Default max status length, updated on startup
|
||||
self.max_toot_chars = 500
|
||||
self.max_toot_chars = DEFAULT_MAX_TOOT_CHARS
|
||||
|
||||
self.timeline = None
|
||||
self.overlay = None
|
||||
self.exception = None
|
||||
self.can_translate = False
|
||||
self.screen = urwid.raw_display.Screen()
|
||||
self.account = None
|
||||
|
||||
super().__init__(self.body, header=self.header, footer=self.footer)
|
||||
|
@ -282,8 +306,8 @@ class TUI(urwid.Frame):
|
|||
return api.get_instance(self.app.base_url)
|
||||
|
||||
def _done(instance):
|
||||
if "max_toot_chars" in instance:
|
||||
self.max_toot_chars = instance["max_toot_chars"]
|
||||
self.max_toot_chars = get_max_toot_chars(instance, DEFAULT_MAX_TOOT_CHARS)
|
||||
logger.info(f"Max toot chars set to: {self.max_toot_chars}")
|
||||
|
||||
if "translation" in instance:
|
||||
# instance is advertising translation service
|
||||
|
@ -329,7 +353,7 @@ class TUI(urwid.Frame):
|
|||
)
|
||||
|
||||
def clear_screen(self):
|
||||
self.loop.screen.clear()
|
||||
self.screen.clear()
|
||||
|
||||
def show_links(self, status):
|
||||
links = parse_content_links(status.original.data["content"]) if status else []
|
||||
|
@ -492,8 +516,8 @@ class TUI(urwid.Frame):
|
|||
urwid.connect_signal(widget, "close", _close)
|
||||
urwid.connect_signal(widget, "delete", _delete)
|
||||
self.open_overlay(widget, title="Delete status?", options=dict(
|
||||
align="center", width=("relative", 60),
|
||||
valign="middle", height=5,
|
||||
align="center", width=30,
|
||||
valign="middle", height=4,
|
||||
))
|
||||
|
||||
def post_status(self, content, warning, visibility, in_reply_to_id):
|
||||
|
@ -518,11 +542,9 @@ class TUI(urwid.Frame):
|
|||
|
||||
def async_toggle_favourite(self, timeline, status):
|
||||
def _favourite():
|
||||
logger.info("Favouriting {}".format(status))
|
||||
api.favourite(self.app, self.user, status.id)
|
||||
|
||||
def _unfavourite():
|
||||
logger.info("Unfavouriting {}".format(status))
|
||||
api.unfavourite(self.app, self.user, status.id)
|
||||
|
||||
def _done(loop):
|
||||
|
@ -539,11 +561,9 @@ class TUI(urwid.Frame):
|
|||
|
||||
def async_toggle_reblog(self, timeline, status):
|
||||
def _reblog():
|
||||
logger.info("Reblogging {}".format(status))
|
||||
api.reblog(self.app, self.user, status.original.id, visibility=get_default_visibility())
|
||||
|
||||
def _unreblog():
|
||||
logger.info("Unreblogging {}".format(status))
|
||||
api.unreblog(self.app, self.user, status.original.id)
|
||||
|
||||
def _done(loop):
|
||||
|
@ -567,7 +587,6 @@ class TUI(urwid.Frame):
|
|||
|
||||
def async_translate(self, timeline, status):
|
||||
def _translate():
|
||||
logger.info("Translating {}".format(status))
|
||||
self.footer.set_message("Translating status {}".format(status.original.id))
|
||||
|
||||
try:
|
||||
|
@ -600,11 +619,9 @@ class TUI(urwid.Frame):
|
|||
|
||||
def async_toggle_bookmark(self, timeline, status):
|
||||
def _bookmark():
|
||||
logger.info("Bookmarking {}".format(status))
|
||||
api.bookmark(self.app, self.user, status.id)
|
||||
|
||||
def _unbookmark():
|
||||
logger.info("Unbookmarking {}".format(status))
|
||||
api.unbookmark(self.app, self.user, status.id)
|
||||
|
||||
def _done(loop):
|
||||
|
@ -705,7 +722,7 @@ class TUI(urwid.Frame):
|
|||
if not self.overlay:
|
||||
self.show_goto_menu()
|
||||
|
||||
elif key in ('h', 'H'):
|
||||
elif key == '?':
|
||||
if not self.overlay:
|
||||
self.show_help()
|
||||
|
||||
|
|
|
@ -66,8 +66,8 @@ class StatusComposer(urwid.Frame):
|
|||
|
||||
def generate_list_items(self):
|
||||
if self.in_reply_to:
|
||||
yield urwid.Text(("gray", "Replying to {}".format(self.in_reply_to.original.account)))
|
||||
yield urwid.AttrWrap(urwid.Divider("-"), "gray")
|
||||
yield urwid.Text(("dim", "Replying to {}".format(self.in_reply_to.original.account)))
|
||||
yield urwid.AttrWrap(urwid.Divider("-"), "dim")
|
||||
|
||||
yield urwid.Text("Status message")
|
||||
yield self.content_edit
|
||||
|
|
|
@ -1,8 +1,21 @@
|
|||
# name, fg, bg, mono, fg_h, bg_h
|
||||
# Color definitions are tuples of:
|
||||
# - name
|
||||
# - foreground (normal mode)
|
||||
# - background (normal mode)
|
||||
# - foreground (monochrome mode)
|
||||
# - foreground (high color mode)
|
||||
# - background (high color mode)
|
||||
#
|
||||
# See:
|
||||
# http://urwid.org/tutorial/index.html#display-attributes
|
||||
# http://urwid.org/manual/displayattributes.html#using-display-attributes
|
||||
|
||||
PALETTE = [
|
||||
# Components
|
||||
('button', 'white', 'black'),
|
||||
('button_focused', 'light gray', 'dark magenta'),
|
||||
('button_focused', 'light gray', 'dark magenta', 'bold,underline'),
|
||||
('card_author', 'yellow', ''),
|
||||
('card_title', 'dark green', ''),
|
||||
('columns_divider', 'white', 'dark blue'),
|
||||
('content_warning', 'white', 'dark magenta'),
|
||||
('editbox', 'white', 'black'),
|
||||
|
@ -12,54 +25,61 @@ PALETTE = [
|
|||
('footer_status', 'white', 'dark blue'),
|
||||
('footer_status_bold', 'white, bold', 'dark blue'),
|
||||
('header', 'white', 'dark blue'),
|
||||
('header_bold', 'white,bold', 'dark blue'),
|
||||
('header_bold', 'white,bold', 'dark blue', 'bold'),
|
||||
('intro_bigtext', 'yellow', ''),
|
||||
('intro_smalltext', 'light blue', ''),
|
||||
('poll_bar', 'white', 'dark blue'),
|
||||
('status_detail_account', 'dark green', ''),
|
||||
('status_detail_bookmarked', 'light red', ''),
|
||||
('status_detail_timestamp', 'light blue', ''),
|
||||
('status_list_account', 'dark green', ''),
|
||||
('status_list_selected', 'white,bold', 'dark green', 'bold,underline'),
|
||||
('status_list_timestamp', 'light blue', ''),
|
||||
|
||||
# Functional
|
||||
('hashtag', 'light cyan,bold', ''),
|
||||
('followed_hashtag', 'yellow,bold', ''),
|
||||
('link', ',italics', ''),
|
||||
('link_focused', ',italics', 'dark magenta'),
|
||||
|
||||
# Colors
|
||||
('bold', ',bold', ''),
|
||||
('blue', 'light blue', ''),
|
||||
('blue_bold', 'light blue, bold', ''),
|
||||
('blue_selected', 'white', 'dark blue'),
|
||||
('cyan', 'dark cyan', ''),
|
||||
('cyan_bold', 'dark cyan,bold', ''),
|
||||
('gray', 'dark gray', ''),
|
||||
('green', 'dark green', ''),
|
||||
('green_selected', 'white,bold', 'dark green'),
|
||||
('yellow', 'yellow', ''),
|
||||
('yellow_bold', 'yellow,bold', ''),
|
||||
('red', 'dark red', ''),
|
||||
('account', 'dark green', ''),
|
||||
('hashtag', 'light cyan,bold', '', 'bold'),
|
||||
('hashtag_followed', 'yellow,bold', '', 'bold'),
|
||||
('link', ',italics', '', ',italics'),
|
||||
('link_focused', ',italics', 'dark magenta', "underline,italics"),
|
||||
('shortcut', 'light blue', ''),
|
||||
('shortcut_highlight', 'white,bold', '', 'bold'),
|
||||
('warning', 'light red', ''),
|
||||
('white_bold', 'white,bold', ''),
|
||||
|
||||
# Visiblity
|
||||
('visibility_public', 'dark gray', ''),
|
||||
('visibility_unlisted', 'white', ''),
|
||||
('visibility_private', 'dark cyan', ''),
|
||||
('visibility_direct', 'yellow', ''),
|
||||
|
||||
# Styles
|
||||
('bold', ',bold', ''),
|
||||
('dim', 'dark gray', ''),
|
||||
('highlight', 'yellow', ''),
|
||||
('success', 'dark green', ''),
|
||||
|
||||
# HTML tag styling
|
||||
('a', ',italics', ''),
|
||||
('a', ',italics', '', 'italics'),
|
||||
# em tag is mapped to i
|
||||
('i', ',italics', ''),
|
||||
('i', ',italics', '', 'italics'),
|
||||
# strong tag is mapped to b
|
||||
('b', ',bold', ''),
|
||||
('b', ',bold', '', 'bold'),
|
||||
# special case for bold + italic nested tags
|
||||
('bi', ',bold,italics', ''),
|
||||
('u', ',underline', ''),
|
||||
('del', ',strikethrough', ''),
|
||||
('code', 'light gray, standout', ''),
|
||||
('pre', 'light gray, standout', ''),
|
||||
('blockquote', 'light gray', ''),
|
||||
('h1', ',bold', ''),
|
||||
('h2', ',bold', ''),
|
||||
('h3', ',bold', ''),
|
||||
('h4', ',bold', ''),
|
||||
('h5', ',bold', ''),
|
||||
('h6', ',bold', ''),
|
||||
('class_mention_hashtag', 'light cyan,bold', ''),
|
||||
('class_hashtag', 'light cyan,bold', ''),
|
||||
('bi', ',bold,italics', '', ',bold,italics'),
|
||||
('u', ',underline', '', ',underline'),
|
||||
('del', ',strikethrough', '', ',strikethrough'),
|
||||
('code', 'light gray, standout', '', ',standout'),
|
||||
('pre', 'light gray, standout', '', ',standout'),
|
||||
('blockquote', 'light gray', '', ''),
|
||||
('h1', ',bold', '', ',bold'),
|
||||
('h2', ',bold', '', ',bold'),
|
||||
('h3', ',bold', '', ',bold'),
|
||||
('h4', ',bold', '', ',bold'),
|
||||
('h5', ',bold', '', ',bold'),
|
||||
('h6', ',bold', '', ',bold'),
|
||||
('class_mention_hashtag', 'light cyan,bold', '', ',bold'),
|
||||
('class_hashtag', 'light cyan,bold', '', ',bold'),
|
||||
|
||||
]
|
||||
|
||||
VISIBILITY_OPTIONS = [
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from collections import namedtuple
|
||||
|
||||
from .utils import parse_datetime
|
||||
from toot.utils.datetime import parse_datetime
|
||||
|
||||
Author = namedtuple("Author", ["account", "display_name", "username"])
|
||||
|
||||
|
|
|
@ -81,15 +81,15 @@ class StatusDeleteConfirmation(urwid.ListBox):
|
|||
signals = ["delete", "close"]
|
||||
|
||||
def __init__(self, status):
|
||||
yes = SelectableText("Yes, send it to heck")
|
||||
no = SelectableText("No, I'll spare it for now")
|
||||
def _delete(_):
|
||||
self._emit("delete")
|
||||
|
||||
urwid.connect_signal(yes, "click", lambda *args: self._emit("delete"))
|
||||
urwid.connect_signal(no, "click", lambda *args: self._emit("close"))
|
||||
def _close(_):
|
||||
self._emit("close")
|
||||
|
||||
walker = urwid.SimpleFocusListWalker([
|
||||
urwid.AttrWrap(yes, "", "blue_selected"),
|
||||
urwid.AttrWrap(no, "", "blue_selected"),
|
||||
Button("Yes, delete", on_press=_delete),
|
||||
Button("No, cancel", on_press=_close),
|
||||
])
|
||||
super().__init__(walker)
|
||||
|
||||
|
@ -196,9 +196,9 @@ class Help(urwid.Padding):
|
|||
|
||||
def generate_contents(self):
|
||||
def h(text):
|
||||
return highlight_keys(text, "cyan")
|
||||
return highlight_keys(text, "shortcut")
|
||||
|
||||
yield urwid.Text(("yellow_bold", "toot {}".format(__version__)))
|
||||
yield urwid.Text(("bold", "toot {}".format(__version__)))
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text(("bold", "General usage"))
|
||||
yield urwid.Divider()
|
||||
|
@ -211,9 +211,9 @@ class Help(urwid.Padding):
|
|||
yield urwid.Divider()
|
||||
yield urwid.Text(h(" [Q] - quit toot"))
|
||||
yield urwid.Text(h(" [G] - go to - switch timelines"))
|
||||
yield urwid.Text(h(" [P] - save/unsave (pin) current timeline"))
|
||||
yield urwid.Text(h(" [E] - save/unsave (pin) current timeline"))
|
||||
yield urwid.Text(h(" [,] - refresh current timeline"))
|
||||
yield urwid.Text(h(" [H] - show this help"))
|
||||
yield urwid.Text(h(" [?] - show this help"))
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text(("bold", "Status keys"))
|
||||
yield urwid.Divider()
|
||||
|
@ -262,10 +262,10 @@ class Account(urwid.ListBox):
|
|||
yield Button("Cancel", on_press=cancel_action, user_data=self)
|
||||
else:
|
||||
if self.user.username == account["acct"]:
|
||||
yield urwid.Text(("light gray", "This is your account"))
|
||||
yield urwid.Text(("dim", "This is your account"))
|
||||
else:
|
||||
if relationship['requested']:
|
||||
yield urwid.Text(("light gray", "< Follow request is pending >"))
|
||||
yield urwid.Text(("dim", "< Follow request is pending >"))
|
||||
else:
|
||||
yield Button("Unfollow" if relationship['following'] else "Follow",
|
||||
on_press=confirm_action, user_data=self)
|
||||
|
@ -277,7 +277,7 @@ class Account(urwid.ListBox):
|
|||
|
||||
yield urwid.Divider("─")
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text([('green', f"@{account['acct']}"), f" {account['display_name']}"])
|
||||
yield urwid.Text([("account", f"@{account['acct']}"), f" {account['display_name']}"])
|
||||
|
||||
if account["note"]:
|
||||
yield urwid.Divider()
|
||||
|
@ -287,12 +287,12 @@ class Account(urwid.ListBox):
|
|||
yield (line)
|
||||
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text(["ID: ", ("green", f"{account['id']}")])
|
||||
yield urwid.Text(["Since: ", ("green", f"{account['created_at'][:10]}")])
|
||||
yield urwid.Text(["ID: ", ("highlight", f"{account['id']}")])
|
||||
yield urwid.Text(["Since: ", ("highlight", f"{account['created_at'][:10]}")])
|
||||
yield urwid.Divider()
|
||||
|
||||
if account["bot"]:
|
||||
yield urwid.Text([("green", "Bot \N{robot face}")])
|
||||
yield urwid.Text([("highlight", "Bot \N{robot face}")])
|
||||
yield urwid.Divider()
|
||||
if account["locked"]:
|
||||
yield urwid.Text([("warning", "Locked \N{lock}")])
|
||||
|
@ -301,28 +301,28 @@ class Account(urwid.ListBox):
|
|||
yield urwid.Text([("warning", "Suspended \N{cross mark}")])
|
||||
yield urwid.Divider()
|
||||
if relationship["followed_by"]:
|
||||
yield urwid.Text(("green", "Follows you \N{busts in silhouette}"))
|
||||
yield urwid.Text(("highlight", "Follows you \N{busts in silhouette}"))
|
||||
yield urwid.Divider()
|
||||
if relationship["blocked_by"]:
|
||||
yield urwid.Text(("warning", "Blocks you \N{no entry}"))
|
||||
yield urwid.Divider()
|
||||
|
||||
yield urwid.Text(["Followers: ", ("yellow", f"{account['followers_count']}")])
|
||||
yield urwid.Text(["Following: ", ("yellow", f"{account['following_count']}")])
|
||||
yield urwid.Text(["Statuses: ", ("yellow", f"{account['statuses_count']}")])
|
||||
yield urwid.Text(["Followers: ", ("highlight", f"{account['followers_count']}")])
|
||||
yield urwid.Text(["Following: ", ("highlight", f"{account['following_count']}")])
|
||||
yield urwid.Text(["Statuses: ", ("highlight", f"{account['statuses_count']}")])
|
||||
|
||||
if account["fields"]:
|
||||
for field in account["fields"]:
|
||||
name = field["name"].title()
|
||||
yield urwid.Divider()
|
||||
yield urwid.Text([("yellow", f"{name.rstrip(':')}"), ":"])
|
||||
yield urwid.Text([("bold", f"{name.rstrip(':')}"), ":"])
|
||||
|
||||
widgetlist = parser.html_to_widgets(field["value"])
|
||||
for line in widgetlist:
|
||||
yield (line)
|
||||
|
||||
if field["verified_at"]:
|
||||
yield urwid.Text(("green", "✓ Verified"))
|
||||
yield urwid.Text(("success", "✓ Verified"))
|
||||
|
||||
yield urwid.Divider()
|
||||
yield link("", account["url"])
|
||||
|
|
|
@ -2,7 +2,7 @@ import urwid
|
|||
|
||||
from toot import api
|
||||
from toot.exceptions import ApiError
|
||||
from .utils import parse_datetime
|
||||
from toot.utils.datetime import parse_datetime
|
||||
from .widgets import Button, CheckBox, RadioButton
|
||||
from .richtext import ContentParser
|
||||
|
||||
|
@ -59,7 +59,7 @@ class Poll(urwid.ListBox):
|
|||
|
||||
if poll["voted"] or poll["expired"]:
|
||||
prefix = " ✓ " if voted_for else " "
|
||||
yield urwid.Text(("gray", prefix + f'{option["title"]}'))
|
||||
yield urwid.Text(("dim", prefix + f'{option["title"]}'))
|
||||
else:
|
||||
if poll["multiple"]:
|
||||
checkbox = CheckBox(f'{option["title"]}')
|
||||
|
@ -81,7 +81,7 @@ class Poll(urwid.ListBox):
|
|||
)
|
||||
poll_detail += " · Closes on {}".format(expires_at)
|
||||
|
||||
yield urwid.Text(("gray", poll_detail))
|
||||
yield urwid.Text(("dim", poll_detail))
|
||||
|
||||
def generate_contents(self, status):
|
||||
yield urwid.Divider()
|
||||
|
|
|
@ -6,16 +6,17 @@ import webbrowser
|
|||
|
||||
from typing import List, Optional
|
||||
|
||||
from .entities import Status
|
||||
from .scroll import Scrollable, ScrollBar
|
||||
from .utils import parse_datetime, highlight_keys
|
||||
from .widgets import SelectableText, SelectableColumns
|
||||
from .richtext import ContentParser
|
||||
from toot.tui import app
|
||||
from toot.tui.utils import time_ago
|
||||
from toot.utils.datetime import parse_datetime, time_ago
|
||||
from toot.utils.language import language_name
|
||||
|
||||
from toot.entities import Status
|
||||
from toot.tui.scroll import Scrollable, ScrollBar
|
||||
from toot.tui.utils import highlight_keys
|
||||
from toot.tui.widgets import SelectableText, SelectableColumns
|
||||
from toot.tui.richtext import ContentParser
|
||||
from toot.utils import urlencode_url
|
||||
from .stubs.urwidgets import Hyperlink, TextEmbed, parse_text, has_urwidgets
|
||||
from toot.tui.stubs.urwidgets import Hyperlink, TextEmbed, parse_text, has_urwidgets
|
||||
|
||||
logger = logging.getLogger("toot")
|
||||
|
||||
|
@ -55,7 +56,7 @@ class Timeline(urwid.Columns):
|
|||
|
||||
super().__init__([
|
||||
("weight", 40, self.status_list),
|
||||
("weight", 0, urwid.AttrWrap(urwid.SolidFill("│"), "blue_selected")),
|
||||
("weight", 0, urwid.AttrWrap(urwid.SolidFill("│"), "columns_divider")),
|
||||
("weight", 60, status_widget),
|
||||
])
|
||||
|
||||
|
@ -82,16 +83,15 @@ class Timeline(urwid.Columns):
|
|||
return urwid.ListBox(walker)
|
||||
|
||||
def build_list_item(self, status):
|
||||
item = StatusListItem(status)
|
||||
item = StatusListItem(status, self.tui.args.relative_datetimes)
|
||||
urwid.connect_signal(item, "click", lambda *args:
|
||||
self.tui.show_context_menu(status))
|
||||
return urwid.AttrMap(item, None, focus_map={
|
||||
"blue": "green_selected",
|
||||
"green": "green_selected",
|
||||
"yellow": "green_selected",
|
||||
"cyan": "green_selected",
|
||||
"red": "green_selected",
|
||||
None: "green_selected",
|
||||
"status_list_account": "status_list_selected",
|
||||
"status_list_timestamp": "status_list_selected",
|
||||
"highligh": "status_list_selected",
|
||||
"dim": "status_list_selected",
|
||||
None: "status_list_selected",
|
||||
})
|
||||
|
||||
def get_option_text(self, status: Optional[Status]) -> Optional[urwid.Text]:
|
||||
|
@ -108,17 +108,17 @@ class Timeline(urwid.Columns):
|
|||
"[F]avourite",
|
||||
"[V]iew",
|
||||
"[T]hread" if not self.is_thread else "",
|
||||
"[L]inks",
|
||||
"L[i]nks",
|
||||
"[R]eply",
|
||||
"[P]oll" if poll and not poll["expired"] else "",
|
||||
"So[u]rce",
|
||||
"[Z]oom",
|
||||
"Tra[n]slate" if self.tui.can_translate else "",
|
||||
"Cop[y]",
|
||||
"[H]elp",
|
||||
"Help([?])",
|
||||
]
|
||||
options = "\n" + " ".join(o for o in options if o)
|
||||
options = highlight_keys(options, "white_bold", "cyan")
|
||||
options = highlight_keys(options, "shortcut_highlight", "shortcut")
|
||||
return urwid.Text(options)
|
||||
|
||||
def get_focused_status(self):
|
||||
|
@ -220,7 +220,7 @@ class Timeline(urwid.Columns):
|
|||
self.tui.async_toggle_bookmark(self, status)
|
||||
return
|
||||
|
||||
if key in ("l", "L"):
|
||||
if key in ("i", "I"):
|
||||
self.tui.show_links(status)
|
||||
return
|
||||
|
||||
|
@ -338,13 +338,13 @@ class StatusDetails(urwid.Pile):
|
|||
def content_generator(self, status, reblogged_by):
|
||||
if reblogged_by:
|
||||
text = "♺ {} boosted".format(reblogged_by.display_name or reblogged_by.username)
|
||||
yield ("pack", urwid.Text(("gray", text)))
|
||||
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
|
||||
yield ("pack", urwid.Text(("dim", text)))
|
||||
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
|
||||
|
||||
if status.author.display_name:
|
||||
yield ("pack", urwid.Text(("green", status.author.display_name)))
|
||||
yield ("pack", urwid.Text(("bold", status.author.display_name)))
|
||||
|
||||
account_color = "yellow" if status.author.account in self.followed_accounts else "gray"
|
||||
account_color = "highlight" if status.author.account in self.followed_accounts else "account"
|
||||
yield ("pack", urwid.Text((account_color, status.author.account)))
|
||||
yield ("pack", urwid.Divider())
|
||||
|
||||
|
@ -367,7 +367,7 @@ class StatusDetails(urwid.Pile):
|
|||
media = status.data["media_attachments"]
|
||||
if media:
|
||||
for m in media:
|
||||
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "gray"))
|
||||
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
|
||||
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
|
||||
if m["description"]:
|
||||
yield ("pack", urwid.Text(m["description"]))
|
||||
|
@ -386,7 +386,7 @@ class StatusDetails(urwid.Pile):
|
|||
application = status.data.get("application") or {}
|
||||
application = application.get("name")
|
||||
|
||||
yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "gray"))
|
||||
yield ("pack", urwid.AttrWrap(urwid.Divider("-"), "dim"))
|
||||
|
||||
translated_from = (
|
||||
language_name(status.original.translated_from)
|
||||
|
@ -395,24 +395,24 @@ class StatusDetails(urwid.Pile):
|
|||
)
|
||||
|
||||
visibility_colors = {
|
||||
"public": "gray",
|
||||
"unlisted": "white",
|
||||
"private": "cyan",
|
||||
"direct": "yellow"
|
||||
"public": "visibility_public",
|
||||
"unlisted": "visibility_unlisted",
|
||||
"private": "visibility_private",
|
||||
"direct": "visibility_direct"
|
||||
}
|
||||
|
||||
visibility = status.visibility.title()
|
||||
visibility_color = visibility_colors.get(status.visibility, "gray")
|
||||
visibility_color = visibility_colors.get(status.visibility, "dim")
|
||||
|
||||
yield ("pack", urwid.Text([
|
||||
("blue", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
|
||||
("red" if status.bookmarked else "gray", "b "),
|
||||
("gray", f"⤶ {status.data['replies_count']} "),
|
||||
("yellow" if status.reblogged else "gray", f"♺ {status.data['reblogs_count']} "),
|
||||
("yellow" if status.favourited else "gray", f"★ {status.data['favourites_count']}"),
|
||||
("status_detail_timestamp", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
|
||||
("status_detail_bookmarked" if status.bookmarked else "dim", "b "),
|
||||
("dim", f"⤶ {status.data['replies_count']} "),
|
||||
("highlight" if status.reblogged else "dim", f"♺ {status.data['reblogs_count']} "),
|
||||
("highlight" if status.favourited else "dim", f"★ {status.data['favourites_count']}"),
|
||||
(visibility_color, f" · {visibility}"),
|
||||
("yellow", f" · Translated from {translated_from} " if translated_from else ""),
|
||||
("gray", f" · {application}" if application else ""),
|
||||
("highlight", f" · Translated from {translated_from} " if translated_from else ""),
|
||||
("dim", f" · {application}" if application else ""),
|
||||
]))
|
||||
|
||||
# Push things to bottom
|
||||
|
@ -424,9 +424,9 @@ class StatusDetails(urwid.Pile):
|
|||
return urwid.LineBox(contents)
|
||||
|
||||
def card_generator(self, card):
|
||||
yield urwid.Text(("green", card["title"].strip()))
|
||||
yield urwid.Text(("card_title", card["title"].strip()))
|
||||
if card.get("author_name"):
|
||||
yield urwid.Text(["by ", ("yellow", card["author_name"].strip())])
|
||||
yield urwid.Text(["by ", ("card_author", card["author_name"].strip())])
|
||||
yield urwid.Text("")
|
||||
if card["description"]:
|
||||
yield urwid.Text(card["description"].strip())
|
||||
|
@ -455,36 +455,36 @@ class StatusDetails(urwid.Pile):
|
|||
expires_at = parse_datetime(poll["expires_at"]).strftime("%Y-%m-%d %H:%M")
|
||||
status += " · Closes on {}".format(expires_at)
|
||||
|
||||
yield urwid.Text(("gray", status))
|
||||
yield urwid.Text(("dim", status))
|
||||
|
||||
|
||||
class StatusListItem(SelectableColumns):
|
||||
def __init__(self, status):
|
||||
def __init__(self, status, relative_datetimes):
|
||||
edited_at = status.data.get("edited_at")
|
||||
|
||||
# TODO: hacky implementation to avoid creating conflicts for existing
|
||||
# pull reuqests, refactor when merged.
|
||||
created_at = (
|
||||
time_ago(status.created_at).ljust(3, " ")
|
||||
if "--relative-datetimes" in sys.argv
|
||||
if relative_datetimes
|
||||
else status.created_at.strftime("%Y-%m-%d %H:%M")
|
||||
)
|
||||
|
||||
edited_flag = "*" if edited_at else " "
|
||||
favourited = ("yellow", "★") if status.original.favourited else " "
|
||||
reblogged = ("yellow", "♺") if status.original.reblogged else " "
|
||||
is_reblog = ("cyan", "♺") if status.reblog else " "
|
||||
is_reply = ("cyan", "⤶") if status.original.in_reply_to else " "
|
||||
favourited = ("highlight", "★") if status.original.favourited else " "
|
||||
reblogged = ("highlight", "♺") if status.original.reblogged else " "
|
||||
is_reblog = ("dim", "♺") if status.reblog else " "
|
||||
is_reply = ("dim", "⤶") if status.original.in_reply_to else " "
|
||||
|
||||
return super().__init__([
|
||||
("pack", SelectableText(("blue", created_at), wrap="clip")),
|
||||
("pack", urwid.Text(("blue", edited_flag))),
|
||||
("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")),
|
||||
("pack", urwid.Text(("status_list_timestamp", edited_flag))),
|
||||
("pack", urwid.Text(" ")),
|
||||
("pack", urwid.Text(favourited)),
|
||||
("pack", urwid.Text(" ")),
|
||||
("pack", urwid.Text(reblogged)),
|
||||
("pack", urwid.Text(" ")),
|
||||
urwid.Text(("green", status.original.account), wrap="clip"),
|
||||
urwid.Text(("status_list_account", status.original.account), wrap="clip"),
|
||||
("pack", urwid.Text(is_reply)),
|
||||
("pack", urwid.Text(is_reblog)),
|
||||
("pack", urwid.Text(" ")),
|
||||
|
|
|
@ -1,60 +1,14 @@
|
|||
import base64
|
||||
import urwid
|
||||
from html.parser import HTMLParser
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import urwid
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from functools import reduce
|
||||
from html.parser import HTMLParser
|
||||
from typing import List
|
||||
|
||||
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) -> datetime:
|
||||
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=""):
|
||||
|
@ -148,3 +102,26 @@ def copy_to_clipboard(screen: urwid.raw_display.Screen, text: str):
|
|||
|
||||
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
|
||||
)
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
# Taken from https://github.com/rossmacarthur/typing-compat/
|
||||
# TODO: Remove once the minimum python version is increased to 3.8
|
||||
#
|
||||
# Licensed under the MIT license
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
#
|
||||
# flake8: noqa
|
||||
|
||||
import collections
|
||||
import typing
|
||||
|
||||
|
||||
__all__ = ['get_args', 'get_origin']
|
||||
__title__ = 'typing-compat'
|
||||
__version__ = '0.1.0'
|
||||
__url__ = 'https://github.com/rossmacarthur/typing-compat'
|
||||
__author__ = 'Ross MacArthur'
|
||||
__author_email__ = 'ross@macarthur.io'
|
||||
__description__ = 'Python typing compatibility library'
|
||||
|
||||
|
||||
try:
|
||||
# Python >=3.8 should have these functions already
|
||||
from typing import get_args as _get_args # novermin
|
||||
from typing import get_origin as _get_origin # novermin
|
||||
except ImportError:
|
||||
if hasattr(typing, '_GenericAlias'): # Python 3.7
|
||||
|
||||
def _get_origin(tp):
|
||||
"""Copied from the Python 3.8 typing module"""
|
||||
if isinstance(tp, typing._GenericAlias):
|
||||
return tp.__origin__
|
||||
if tp is typing.Generic:
|
||||
return typing.Generic
|
||||
return None
|
||||
|
||||
def _get_args(tp):
|
||||
"""Copied from the Python 3.8 typing module"""
|
||||
if isinstance(tp, typing._GenericAlias):
|
||||
res = tp.__args__
|
||||
if (
|
||||
get_origin(tp) is collections.abc.Callable
|
||||
and res[0] is not Ellipsis
|
||||
):
|
||||
res = (list(res[:-1]), res[-1])
|
||||
return res
|
||||
return ()
|
||||
|
||||
else: # Python <3.7
|
||||
|
||||
def _resolve_via_mro(tp):
|
||||
if hasattr(tp, '__mro__'):
|
||||
for t in tp.__mro__:
|
||||
if t.__module__ in ('builtins', '__builtin__') and t is not object:
|
||||
return t
|
||||
return tp
|
||||
|
||||
def _get_origin(tp):
|
||||
"""Emulate the behaviour of Python 3.8 typing module"""
|
||||
if isinstance(tp, typing._ClassVar):
|
||||
return typing.ClassVar
|
||||
elif isinstance(tp, typing._Union):
|
||||
return typing.Union
|
||||
elif isinstance(tp, typing.GenericMeta):
|
||||
if hasattr(tp, '_gorg'):
|
||||
return _resolve_via_mro(tp._gorg)
|
||||
else:
|
||||
while tp.__origin__ is not None:
|
||||
tp = tp.__origin__
|
||||
return _resolve_via_mro(tp)
|
||||
elif hasattr(typing, '_Literal') and isinstance(tp, typing._Literal): # novermin
|
||||
return typing.Literal # novermin
|
||||
|
||||
def _normalize_arg(args):
|
||||
if isinstance(args, tuple) and len(args) > 1:
|
||||
base, rest = args[0], tuple(_normalize_arg(arg) for arg in args[1:])
|
||||
if isinstance(base, typing.CallableMeta):
|
||||
return typing.Callable[list(rest[:-1]), rest[-1]]
|
||||
elif isinstance(base, (typing.GenericMeta, typing._Union)):
|
||||
return base[rest]
|
||||
return args
|
||||
|
||||
def _get_args(tp):
|
||||
"""Emulate the behaviour of Python 3.8 typing module"""
|
||||
if isinstance(tp, typing._ClassVar):
|
||||
return (tp.__type__,)
|
||||
elif hasattr(tp, '_subs_tree'):
|
||||
tree = tp._subs_tree()
|
||||
if isinstance(tree, tuple) and len(tree) > 1:
|
||||
if isinstance(tree[0], typing.CallableMeta) and len(tree) == 2:
|
||||
return ([], _normalize_arg(tree[1]))
|
||||
return tuple(_normalize_arg(arg) for arg in tree[1:])
|
||||
return ()
|
||||
|
||||
|
||||
def get_origin(tp):
|
||||
"""
|
||||
Get the unsubscripted version of a type.
|
||||
|
||||
This supports generic types, Callable, Tuple, Union, Literal, Final and
|
||||
ClassVar. Returns None for unsupported types.
|
||||
|
||||
Examples:
|
||||
|
||||
get_origin(Literal[42]) is Literal
|
||||
get_origin(int) is None
|
||||
get_origin(ClassVar[int]) is ClassVar
|
||||
get_origin(Generic) is Generic
|
||||
get_origin(Generic[T]) is Generic
|
||||
get_origin(Union[T, int]) is Union
|
||||
get_origin(List[Tuple[T, T]][int]) == list
|
||||
"""
|
||||
return _get_origin(tp)
|
||||
|
||||
|
||||
def get_args(tp):
|
||||
"""
|
||||
Get type arguments with all substitutions performed.
|
||||
|
||||
For unions, basic simplifications used by Union constructor are performed.
|
||||
|
||||
Examples:
|
||||
|
||||
get_args(Dict[str, int]) == (str, int)
|
||||
get_args(int) == ()
|
||||
get_args(Union[int, Union[T, int], str][int]) == (int, str)
|
||||
get_args(Union[int, Tuple[T, int]][str]) == (int, Tuple[str, int])
|
||||
get_args(Callable[[], T][int]) == ([], int)
|
||||
"""
|
||||
return _get_args(tp)
|
|
@ -0,0 +1,45 @@
|
|||
import math
|
||||
import os
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def parse_datetime(value):
|
||||
"""Returns an aware datetime in local timezone"""
|
||||
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()
|
||||
|
||||
|
||||
SECOND = 1
|
||||
MINUTE = SECOND * 60
|
||||
HOUR = MINUTE * 60
|
||||
DAY = HOUR * 24
|
||||
WEEK = DAY * 7
|
||||
|
||||
|
||||
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"
|
Loading…
Reference in New Issue