diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d9a41f2..b13d2a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d5fad39..c0f07aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,29 @@ Changelog -**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)** diff --git a/Makefile b/Makefile index c413d73..ed56227 100644 --- a/Makefile +++ b/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 diff --git a/changelog.yaml b/changelog.yaml index 4cd030b..303e9f6 100644 --- a/changelog.yaml +++ b/changelog.yaml @@ -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 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index da4170b..5c67529 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -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) diff --git a/docs/advanced.md b/docs/advanced.md index a8ee244..6b4c5f2 100644 --- a/docs/advanced.md +++ b/docs/advanced.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>) diff --git a/docs/changelog.md b/docs/changelog.md index d5fad39..c0f07aa 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,11 +3,29 @@ Changelog -**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)** diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..baff676 --- /dev/null +++ b/docs/settings.md @@ -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.]` 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"] +``` diff --git a/setup.py b/setup.py index b30ad55..2815739 100644 --- a/setup.py +++ b/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': [ diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index d9421a8..f09c0fe 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -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") diff --git a/tests/integration/test_read.py b/tests/integration/test_read.py index abdd295..da18769 100644 --- a/tests/integration/test_read.py +++ b/tests/integration/test_read.py @@ -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] diff --git a/tests/test_console.py b/tests/test_console.py index ffe1d12..9f3b835 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -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 == "" diff --git a/toot/__init__.py b/toot/__init__.py index 24be0af..440dba9 100644 --- a/toot/__init__.py +++ b/toot/__init__.py @@ -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) diff --git a/toot/__main__.py b/toot/__main__.py new file mode 100644 index 0000000..abbb9e2 --- /dev/null +++ b/toot/__main__.py @@ -0,0 +1,3 @@ +from .console import main + +main() diff --git a/toot/api.py b/toot/api.py index f0857a9..a5d5a14 100644 --- a/toot/api.py +++ b/toot/api.py @@ -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() diff --git a/toot/commands.py b/toot/commands.py index dbb5eb6..57a9756 100644 --- a/toot/commands.py +++ b/toot/commands.py @@ -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("✓ {} is no longer muted".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("✓ {} is no longer blocked".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) diff --git a/toot/config.py b/toot/config.py index cff9856..077e098 100644 --- a/toot/config.py +++ b/toot/config.py @@ -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) diff --git a/toot/console.py b/toot/console.py index da4f3ce..fee4356 100644 --- a/toot/console.py +++ b/toot/console.py @@ -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) diff --git a/toot/entities.py b/toot/entities.py new file mode 100644 index 0000000..739b962 --- /dev/null +++ b/toot/entities.py @@ -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[]` returns the encapsulated ``.""" + 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 diff --git a/toot/output.py b/toot/output.py index 3414cdb..6fd59a2 100644 --- a/toot/output.py +++ b/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"{a}" 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"{instance['title']}") - print_out(f"{instance['uri']}") - print_out(f"running Mastodon {instance['version']}") +def print_instance(instance: Instance): + print_out(f"{instance.title}") + print_out(f"{instance.uri}") + 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("Nothing found") -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 {status['id']} ", - f"↲ In reply to {in_reply_to} " if in_reply_to else "", - f"↻ Reblogged @{reblog['account']['acct']} " if reblog else "", + f"ID {status_id} ", + f"↲ In reply to {in_reply_to_id} " if in_reply_to_id else "", + f"↻ @{reblogged_by.acct} 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 = " " 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) diff --git a/toot/settings.py b/toot/settings.py new file mode 100644 index 0000000..90d4443 --- /dev/null +++ b/toot/settings.py @@ -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) diff --git a/toot/tui/app.py b/toot/tui/app.py index 4029f91..6909d79 100644 --- a/toot/tui/app.py +++ b/toot/tui/app.py @@ -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() diff --git a/toot/tui/compose.py b/toot/tui/compose.py index 74b915d..05bfaaf 100644 --- a/toot/tui/compose.py +++ b/toot/tui/compose.py @@ -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 diff --git a/toot/tui/constants.py b/toot/tui/constants.py index 0039de8..845e24c 100644 --- a/toot/tui/constants.py +++ b/toot/tui/constants.py @@ -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 = [ diff --git a/toot/tui/entities.py b/toot/tui/entities.py index a30bcb6..165ca77 100644 --- a/toot/tui/entities.py +++ b/toot/tui/entities.py @@ -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"]) diff --git a/toot/tui/overlays.py b/toot/tui/overlays.py index 1be7c8c..bd85a2d 100644 --- a/toot/tui/overlays.py +++ b/toot/tui/overlays.py @@ -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"]) diff --git a/toot/tui/poll.py b/toot/tui/poll.py index 8ad649c..c92cc07 100644 --- a/toot/tui/poll.py +++ b/toot/tui/poll.py @@ -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() diff --git a/toot/tui/timeline.py b/toot/tui/timeline.py index 4df1e5d..d971155 100644 --- a/toot/tui/timeline.py +++ b/toot/tui/timeline.py @@ -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(" ")), diff --git a/toot/tui/utils.py b/toot/tui/utils.py index 84cb7da..0ccff9d 100644 --- a/toot/tui/utils.py +++ b/toot/tui/utils.py @@ -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'(? 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 + ) diff --git a/toot/typing_compat.py b/toot/typing_compat.py new file mode 100644 index 0000000..0c6fe5d --- /dev/null +++ b/toot/typing_compat.py @@ -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) diff --git a/toot/utils/datetime.py b/toot/utils/datetime.py new file mode 100644 index 0000000..2a214a0 --- /dev/null +++ b/toot/utils/datetime.py @@ -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"