kopia lustrzana https://github.com/ihabunek/toot
Porównaj commity
81 Commity
Autor | SHA1 | Data |
---|---|---|
Ivan Habunek | 31501a84a0 | |
Ivan Habunek | 6ea13db2a5 | |
Daniel Schwarz | c5414a8e21 | |
Daniel Schwarz | c622cbbccd | |
AnonymouX47 | 1ea2e29e25 | |
AnonymouX47 | b264927da9 | |
AnonymouX47 | efab6cf556 | |
Daniel Schwarz | c3b6dfd74a | |
Ivan Habunek | b97a995dc4 | |
Ivan Habunek | 5cd25e2ce2 | |
Ivan Habunek | aa1f2a5bc8 | |
Ivan Habunek | 4996da61e5 | |
Ivan Habunek | 87acfb8ef4 | |
Ivan Habunek | 927fdc3026 | |
Ivan Habunek | 2ba90fc2d2 | |
Ivan Habunek | 8243dbab34 | |
Ivan Habunek | 597dddf76d | |
Ivan Habunek | b482dc20b4 | |
Ivan Habunek | 211e501fbc | |
Ivan Habunek | b9c671b5a8 | |
Ivan Habunek | 77d8e7d7b5 | |
Ivan Habunek | 880848fae3 | |
Ivan Habunek | f54b6ac9d7 | |
Ivan Habunek | f925199781 | |
Daniel Schwarz | 0fc2ec12f5 | |
Ivan Habunek | 07ad41960f | |
Sandra Snan | 07beba8c68 | |
Sandra Snan | 7244b2718f | |
Ivan Habunek | 968a516f76 | |
Ivan Habunek | 38eca67905 | |
Luca Matei Pintilie | 1d48e64853 | |
Ivan Habunek | bf12dbff70 | |
Ivan Habunek | 4b17e2e586 | |
Daniel Schwarz | 20968fe87f | |
Ivan Habunek | 3bac9b2fb6 | |
Ivan Habunek | 3420f1466a | |
Ivan Habunek | 3eebbe35c9 | |
Ivan Habunek | 4d5ac3cc4e | |
Ivan Habunek | ee98ce3746 | |
Ivan Habunek | 0cbb8863b3 | |
Ivan Habunek | 1709a416b3 | |
Ivan Habunek | f324aa119d | |
Ivan Habunek | 43f51cbbb9 | |
Ivan Habunek | 225dfbfb2e | |
Ivan Habunek | 9ae205c548 | |
Ivan Habunek | 9875209b30 | |
Ivan Habunek | 965ffa1312 | |
Ivan Habunek | e1be3a68bb | |
Daniel Schwarz | 0cb2355973 | |
Ivan Habunek | a34831a02b | |
Daniel Schwarz | 593c95ea62 | |
Ivan Habunek | fb36561923 | |
Ivan Habunek | fcc7f3b017 | |
Kian-Meng Ang | 2d0089893f | |
Ivan Habunek | d3d1b0d9a1 | |
Ivan Habunek | fda498d793 | |
Ivan Habunek | b4cbeeedeb | |
Ivan Habunek | 964efc5b4c | |
Ivan Habunek | 081bc0459e | |
Ivan Habunek | 5a26ab4940 | |
Ivan Habunek | db266c563d | |
Ivan Habunek | 03035c31a0 | |
Ivan Habunek | 7f0692891e | |
Lexi Winter | ec48e8eed8 | |
Ivan Habunek | 724f27f860 | |
Ivan Habunek | d1fe0ca92d | |
Ivan Habunek | 301c8d21df | |
Ivan Habunek | 3a147a5ea0 | |
Ivan Habunek | 84e75347e0 | |
Lexi Winter | 1ed129f5dd | |
Lexi Winter | f394d78c1e | |
Ivan Habunek | 2e55ddbe7e | |
Lexi Winter | 5dd53b1b9c | |
Ivan Habunek | 4e55fba15e | |
Ivan Habunek | 5a2f19a04a | |
Lexi Winter | d0f05c7ad9 | |
Lexi Winter | 741a306c69 | |
Ivan Habunek | 09b29d2b93 | |
Ivan Habunek | 11aaa1dc29 | |
Ivan Habunek | 2e2945822a | |
Denis Laxalde | 2199ca18b5 |
|
@ -7,12 +7,13 @@ jobs:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
|
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v4
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
2
.vermin
2
.vermin
|
@ -1,4 +1,4 @@
|
||||||
[vermin]
|
[vermin]
|
||||||
only_show_violations = yes
|
only_show_violations = yes
|
||||||
show_tips = no
|
show_tips = no
|
||||||
targets = 3.7
|
targets = 3.8
|
44
CHANGELOG.md
44
CHANGELOG.md
|
@ -3,6 +3,40 @@ Changelog
|
||||||
|
|
||||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||||
|
|
||||||
|
**0.43.0 (2024-04-13)**
|
||||||
|
|
||||||
|
* TUI: Support displaying images (thanks Dan Schwarz)
|
||||||
|
* Improve GoToSocial compatibility (thanks Luca Matei Pintilie)
|
||||||
|
* Show visibility in timeline (thanks Sandra Snan)
|
||||||
|
* Flag `notifications --clear` no longer requires an argument (thanks Sandra
|
||||||
|
Snan)
|
||||||
|
* TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)
|
||||||
|
* Migrated to pyproject.toml finally
|
||||||
|
|
||||||
|
**0.42.0 (2024-03-09)**
|
||||||
|
|
||||||
|
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)
|
||||||
|
* TUI: Document missing shortcuts (thanks Denis Laxalde)
|
||||||
|
* TUI: Use rounded boxes for nicer visuals (thanks Dan Schwarz)
|
||||||
|
* TUI: Don't break if edited_at status field does not exist
|
||||||
|
|
||||||
|
**0.41.1 (2024-01-02)**
|
||||||
|
|
||||||
|
* Fix a crash in settings parsing code
|
||||||
|
|
||||||
|
**0.41.0 (2024-01-02)**
|
||||||
|
|
||||||
|
* Honour user's default visibility set in Mastodon preferences instead of always
|
||||||
|
defaulting to public visibility (thanks Lexi Winter)
|
||||||
|
* TUI: Add editing toots (thanks Lexi Winter)
|
||||||
|
* TUI: Fix a bug which made palette config in settings not work
|
||||||
|
* TUI: Show edit datetime in status detail (thanks Lexi Winter)
|
||||||
|
|
||||||
|
**0.40.2 (2023-12-28)**
|
||||||
|
|
||||||
|
* Reinstate `toot post --using` option.
|
||||||
|
* Add shell completion for instances.
|
||||||
|
|
||||||
**0.40.1 (2023-12-28)**
|
**0.40.1 (2023-12-28)**
|
||||||
|
|
||||||
* Add `toot --as` option to replace `toot post --using`. This now works for all
|
* Add `toot --as` option to replace `toot post --using`. This now works for all
|
||||||
|
@ -21,8 +55,6 @@ mostly preserved, except for cases noted below. Please report any issues.
|
||||||
* BREAKING: Options `--debug` and `--color` must be specified after `toot` but
|
* BREAKING: Options `--debug` and `--color` must be specified after `toot` but
|
||||||
before the command
|
before the command
|
||||||
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
|
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
|
||||||
* BREAKING: Removed `toot post --using` option. Use `toot --as <account> post`
|
|
||||||
instead.
|
|
||||||
* Add passing parameters via environment variables, see:
|
* Add passing parameters via environment variables, see:
|
||||||
https://toot.bezdomni.net/environment_variables.html
|
https://toot.bezdomni.net/environment_variables.html
|
||||||
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
|
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
|
||||||
|
@ -34,7 +66,7 @@ mostly preserved, except for cases noted below. Please report any issues.
|
||||||
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
|
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
|
||||||
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
|
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
|
||||||
* Add `--json` option to tags and lists commands
|
* Add `--json` option to tags and lists commands
|
||||||
* Add `toot --width` option for setting your prefered terminal width
|
* Add `toot --width` option for setting your preferred terminal width
|
||||||
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
|
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
|
||||||
previously accessible only via settings.
|
previously accessible only via settings.
|
||||||
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
|
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
|
||||||
|
@ -129,7 +161,7 @@ mostly preserved, except for cases noted below. Please report any issues.
|
||||||
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
|
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
|
||||||
Daniel Schwarz)
|
Daniel Schwarz)
|
||||||
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
|
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
|
||||||
* TUI: Show status visiblity (thanks Lim Ding Wen)
|
* TUI: Show status visibility (thanks Lim Ding Wen)
|
||||||
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
|
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
|
||||||
Wen)
|
Wen)
|
||||||
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
|
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
|
||||||
|
@ -157,7 +189,7 @@ mostly preserved, except for cases noted below. Please report any issues.
|
||||||
|
|
||||||
**0.30.1 (2022-11-30)**
|
**0.30.1 (2022-11-30)**
|
||||||
|
|
||||||
* Remove usage of depreacted `text_url` status field. Fixes posting media
|
* Remove usage of deprecated `text_url` status field. Fixes posting media
|
||||||
without text.
|
without text.
|
||||||
|
|
||||||
**0.30.0 (2022-11-29)**
|
**0.30.0 (2022-11-29)**
|
||||||
|
@ -210,7 +242,7 @@ mostly preserved, except for cases noted below. Please report any issues.
|
||||||
(#168)
|
(#168)
|
||||||
* Add `--reverse` option to `toot notifications` (#151)
|
* Add `--reverse` option to `toot notifications` (#151)
|
||||||
* Fix `toot timeline` to respect `--instance` option
|
* Fix `toot timeline` to respect `--instance` option
|
||||||
* TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)
|
* TUI: Add option to pin/save tag timelines (#163, thanks @dlax)
|
||||||
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
|
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
|
||||||
|
|
||||||
**0.26.0 (2020-04-15)**
|
**0.26.0 (2020-04-15)**
|
||||||
|
|
|
@ -111,7 +111,7 @@ these rules for you.
|
||||||
|
|
||||||
#### Run tests before submitting
|
#### Run tests before submitting
|
||||||
|
|
||||||
You can run code and sytle tests by running:
|
You can run code and style tests by running:
|
||||||
|
|
||||||
```
|
```
|
||||||
make test
|
make test
|
||||||
|
|
5
Makefile
5
Makefile
|
@ -1,8 +1,7 @@
|
||||||
.PHONY: clean publish test docs
|
.PHONY: clean publish test docs
|
||||||
|
|
||||||
dist :
|
dist:
|
||||||
python setup.py sdist --formats=gztar,zip
|
python -m build
|
||||||
python setup.py bdist_wheel --python-tag=py3
|
|
||||||
|
|
||||||
publish :
|
publish :
|
||||||
twine upload dist/*.tar.gz dist/*.whl
|
twine upload dist/*.tar.gz dist/*.whl
|
||||||
|
|
|
@ -1,3 +1,45 @@
|
||||||
|
0.44.0:
|
||||||
|
date: TBA
|
||||||
|
changes:
|
||||||
|
- "**BREAKING:** Require Python 3.8+"
|
||||||
|
|
||||||
|
0.43.0:
|
||||||
|
date: 2024-04-13
|
||||||
|
changes:
|
||||||
|
- "TUI: Support displaying images (thanks Dan Schwarz)"
|
||||||
|
- "Improve GoToSocial compatibility (thanks Luca Matei Pintilie)"
|
||||||
|
- "Show visibility in timeline (thanks Sandra Snan)"
|
||||||
|
- "Flag `notifications --clear` no longer requires an argument (thanks Sandra Snan)"
|
||||||
|
- "TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)"
|
||||||
|
- "Migrated to pyproject.toml finally"
|
||||||
|
|
||||||
|
0.42.0:
|
||||||
|
date: 2024-03-09
|
||||||
|
changes:
|
||||||
|
- "TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)"
|
||||||
|
- "TUI: Document missing shortcuts (thanks Denis Laxalde)"
|
||||||
|
- "TUI: Use rounded boxes for nicer visuals (thanks Dan Schwarz)"
|
||||||
|
- "TUI: Don't break if edited_at status field does not exist"
|
||||||
|
|
||||||
|
0.41.1:
|
||||||
|
date: 2024-01-02
|
||||||
|
changes:
|
||||||
|
- "Fix a crash in settings parsing code"
|
||||||
|
|
||||||
|
0.41.0:
|
||||||
|
date: 2024-01-02
|
||||||
|
changes:
|
||||||
|
- "Honour user's default visibility set in Mastodon preferences instead of always defaulting to public visibility (thanks Lexi Winter)"
|
||||||
|
- "TUI: Add editing toots (thanks Lexi Winter)"
|
||||||
|
- "TUI: Fix a bug which made palette config in settings not work"
|
||||||
|
- "TUI: Show edit datetime in status detail (thanks Lexi Winter)"
|
||||||
|
|
||||||
|
0.40.2:
|
||||||
|
date: 2023-12-28
|
||||||
|
changes:
|
||||||
|
- "Reinstate `toot post --using` option."
|
||||||
|
- "Add shell completion for instances."
|
||||||
|
|
||||||
0.40.1:
|
0.40.1:
|
||||||
date: 2023-12-28
|
date: 2023-12-28
|
||||||
changes:
|
changes:
|
||||||
|
@ -16,14 +58,13 @@
|
||||||
- "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead"
|
- "BREAKING: Remove deprecated `--disable-https` option for `login` and `login_cli`, pass the base URL instead"
|
||||||
- "BREAKING: Options `--debug` and `--color` must be specified after `toot` but before the command"
|
- "BREAKING: Options `--debug` and `--color` must be specified after `toot` but before the command"
|
||||||
- "BREAKING: Option `--quiet` has been removed. Redirect output instead."
|
- "BREAKING: Option `--quiet` has been removed. Redirect output instead."
|
||||||
- "BREAKING: Removed `toot post --using` option. Use `toot --as <account> post` instead."
|
|
||||||
- "Add passing parameters via environment variables, see: https://toot.bezdomni.net/environment_variables.html"
|
- "Add passing parameters via environment variables, see: https://toot.bezdomni.net/environment_variables.html"
|
||||||
- "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html"
|
- "Add shell completion, see: https://toot.bezdomni.net/shell_completion.html"
|
||||||
- "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands"
|
- "Add `tags info`, `tags featured`, `tags feature`, and `tags unfeature` commands"
|
||||||
- "Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`"
|
- "Add `tags followed`, `tags follow`, and `tags unfollow` sub-commands, deprecate `tags_followed`, `tags_follow`, and `tags tags_unfollow`"
|
||||||
- "Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands."
|
- "Add `lists accounts`, `lists add`, `lists create`, `lists delete`, `lists list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`, `lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands."
|
||||||
- "Add `--json` option to tags and lists commands"
|
- "Add `--json` option to tags and lists commands"
|
||||||
- "Add `toot --width` option for setting your prefered terminal width"
|
- "Add `toot --width` option for setting your preferred terminal width"
|
||||||
- "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings."
|
- "Add `--media-viewer` and `--colors` options to `toot tui`. These were previously accessible only via settings."
|
||||||
- "TUI: Fix issue where UI did not render until first input (thanks Urwid devs)"
|
- "TUI: Fix issue where UI did not render until first input (thanks Urwid devs)"
|
||||||
|
|
||||||
|
@ -115,7 +156,7 @@
|
||||||
- "TUI: Show an error if attemptint to boost a private status (thanks Lim Ding Wen)"
|
- "TUI: Show an error if attemptint to boost a private status (thanks Lim Ding Wen)"
|
||||||
- "TUI: Hide polls, cards and media attachments for sensitive posts (thanks Daniel Schwarz)"
|
- "TUI: Hide polls, cards and media attachments for sensitive posts (thanks Daniel Schwarz)"
|
||||||
- "TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)"
|
- "TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)"
|
||||||
- "TUI: Show status visiblity (thanks Lim Ding Wen)"
|
- "TUI: Show status visibility (thanks Lim Ding Wen)"
|
||||||
- "TUI: Reply to original account instead of boosting account (thanks Lim Ding Wen)"
|
- "TUI: Reply to original account instead of boosting account (thanks Lim Ding Wen)"
|
||||||
- "TUI: Refresh screen after exiting browser, required for text browsers (thanks Daniel Schwarz)"
|
- "TUI: Refresh screen after exiting browser, required for text browsers (thanks Daniel Schwarz)"
|
||||||
- "TUI: Highlight followed tags (thanks Daniel Schwarz)"
|
- "TUI: Highlight followed tags (thanks Daniel Schwarz)"
|
||||||
|
@ -143,7 +184,7 @@
|
||||||
0.30.1:
|
0.30.1:
|
||||||
date: 2022-11-30
|
date: 2022-11-30
|
||||||
changes:
|
changes:
|
||||||
- "Remove usage of depreacted `text_url` status field. Fixes posting media without text."
|
- "Remove usage of deprecated `text_url` status field. Fixes posting media without text."
|
||||||
|
|
||||||
0.30.0:
|
0.30.0:
|
||||||
date: 2022-11-29
|
date: 2022-11-29
|
||||||
|
@ -190,7 +231,7 @@
|
||||||
- "TUI: Fix access to public and tag timelines when on private mastodon instances (#168)"
|
- "TUI: Fix access to public and tag timelines when on private mastodon instances (#168)"
|
||||||
- "Add `--reverse` option to `toot notifications` (#151)"
|
- "Add `--reverse` option to `toot notifications` (#151)"
|
||||||
- "Fix `toot timeline` to respect `--instance` option"
|
- "Fix `toot timeline` to respect `--instance` option"
|
||||||
- "TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)"
|
- "TUI: Add option to pin/save tag timelines (#163, thanks @dlax)"
|
||||||
- "TUI: Fixed crash on empty timeline (#138, thanks ecs)"
|
- "TUI: Fixed crash on empty timeline (#138, thanks ecs)"
|
||||||
|
|
||||||
0.26.0:
|
0.26.0:
|
||||||
|
|
|
@ -3,6 +3,40 @@ Changelog
|
||||||
|
|
||||||
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
<!-- Do not edit. This file is automatically generated from changelog.yaml.-->
|
||||||
|
|
||||||
|
**0.43.0 (2024-04-13)**
|
||||||
|
|
||||||
|
* TUI: Support displaying images (thanks Dan Schwarz)
|
||||||
|
* Improve GoToSocial compatibility (thanks Luca Matei Pintilie)
|
||||||
|
* Show visibility in timeline (thanks Sandra Snan)
|
||||||
|
* Flag `notifications --clear` no longer requires an argument (thanks Sandra
|
||||||
|
Snan)
|
||||||
|
* TUI: Fix crash when rendering invalid URLs (thanks Dan Schwarz)
|
||||||
|
* Migrated to pyproject.toml finally
|
||||||
|
|
||||||
|
**0.42.0 (2024-03-09)**
|
||||||
|
|
||||||
|
* TUI: Add `toot tui --always-show-sensitive` option (thanks Lexi Winter)
|
||||||
|
* TUI: Document missing shortcuts (thanks Denis Laxalde)
|
||||||
|
* TUI: Use rounded boxes for nicer visuals (thanks Dan Schwarz)
|
||||||
|
* TUI: Don't break if edited_at status field does not exist
|
||||||
|
|
||||||
|
**0.41.1 (2024-01-02)**
|
||||||
|
|
||||||
|
* Fix a crash in settings parsing code
|
||||||
|
|
||||||
|
**0.41.0 (2024-01-02)**
|
||||||
|
|
||||||
|
* Honour user's default visibility set in Mastodon preferences instead of always
|
||||||
|
defaulting to public visibility (thanks Lexi Winter)
|
||||||
|
* TUI: Add editing toots (thanks Lexi Winter)
|
||||||
|
* TUI: Fix a bug which made palette config in settings not work
|
||||||
|
* TUI: Show edit datetime in status detail (thanks Lexi Winter)
|
||||||
|
|
||||||
|
**0.40.2 (2023-12-28)**
|
||||||
|
|
||||||
|
* Reinstate `toot post --using` option.
|
||||||
|
* Add shell completion for instances.
|
||||||
|
|
||||||
**0.40.1 (2023-12-28)**
|
**0.40.1 (2023-12-28)**
|
||||||
|
|
||||||
* Add `toot --as` option to replace `toot post --using`. This now works for all
|
* Add `toot --as` option to replace `toot post --using`. This now works for all
|
||||||
|
@ -21,8 +55,6 @@ mostly preserved, except for cases noted below. Please report any issues.
|
||||||
* BREAKING: Options `--debug` and `--color` must be specified after `toot` but
|
* BREAKING: Options `--debug` and `--color` must be specified after `toot` but
|
||||||
before the command
|
before the command
|
||||||
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
|
* BREAKING: Option `--quiet` has been removed. Redirect output instead.
|
||||||
* BREAKING: Removed `toot post --using` option. Use `toot --as <account> post`
|
|
||||||
instead.
|
|
||||||
* Add passing parameters via environment variables, see:
|
* Add passing parameters via environment variables, see:
|
||||||
https://toot.bezdomni.net/environment_variables.html
|
https://toot.bezdomni.net/environment_variables.html
|
||||||
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
|
* Add shell completion, see: https://toot.bezdomni.net/shell_completion.html
|
||||||
|
@ -34,7 +66,7 @@ mostly preserved, except for cases noted below. Please report any issues.
|
||||||
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
|
list`, `lists remove` subcommands, deprecate `lists`, `lists_accounts`,
|
||||||
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
|
`lists_add`, `lists_create`, `lists_delete`, `lists_remove` commands.
|
||||||
* Add `--json` option to tags and lists commands
|
* Add `--json` option to tags and lists commands
|
||||||
* Add `toot --width` option for setting your prefered terminal width
|
* Add `toot --width` option for setting your preferred terminal width
|
||||||
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
|
* Add `--media-viewer` and `--colors` options to `toot tui`. These were
|
||||||
previously accessible only via settings.
|
previously accessible only via settings.
|
||||||
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
|
* TUI: Fix issue where UI did not render until first input (thanks Urwid devs)
|
||||||
|
@ -129,7 +161,7 @@ mostly preserved, except for cases noted below. Please report any issues.
|
||||||
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
|
* TUI: Hide polls, cards and media attachments for sensitive posts (thanks
|
||||||
Daniel Schwarz)
|
Daniel Schwarz)
|
||||||
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
|
* TUI: Add bookmarking and bookmark timeline (thanks Daniel Schwarz)
|
||||||
* TUI: Show status visiblity (thanks Lim Ding Wen)
|
* TUI: Show status visibility (thanks Lim Ding Wen)
|
||||||
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
|
* TUI: Reply to original account instead of boosting account (thanks Lim Ding
|
||||||
Wen)
|
Wen)
|
||||||
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
|
* TUI: Refresh screen after exiting browser, required for text browsers (thanks
|
||||||
|
@ -157,7 +189,7 @@ mostly preserved, except for cases noted below. Please report any issues.
|
||||||
|
|
||||||
**0.30.1 (2022-11-30)**
|
**0.30.1 (2022-11-30)**
|
||||||
|
|
||||||
* Remove usage of depreacted `text_url` status field. Fixes posting media
|
* Remove usage of deprecated `text_url` status field. Fixes posting media
|
||||||
without text.
|
without text.
|
||||||
|
|
||||||
**0.30.0 (2022-11-29)**
|
**0.30.0 (2022-11-29)**
|
||||||
|
@ -210,7 +242,7 @@ mostly preserved, except for cases noted below. Please report any issues.
|
||||||
(#168)
|
(#168)
|
||||||
* Add `--reverse` option to `toot notifications` (#151)
|
* Add `--reverse` option to `toot notifications` (#151)
|
||||||
* Fix `toot timeline` to respect `--instance` option
|
* Fix `toot timeline` to respect `--instance` option
|
||||||
* TUI: Add opton to pin/save tag timelines (#163, thanks @dlax)
|
* TUI: Add option to pin/save tag timelines (#163, thanks @dlax)
|
||||||
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
|
* TUI: Fixed crash on empty timeline (#138, thanks ecs)
|
||||||
|
|
||||||
**0.26.0 (2020-04-15)**
|
**0.26.0 (2020-04-15)**
|
||||||
|
|
|
@ -80,9 +80,7 @@ source _env/bin/activate
|
||||||
# On Windows
|
# On Windows
|
||||||
_env\bin\activate.bat
|
_env\bin\activate.bat
|
||||||
|
|
||||||
pip install --editable .
|
pip install --editable ".[dev,test]"
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
pip install -r requirements-test.txt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
While the virtual env is active, running `toot` will execute the one you checked
|
While the virtual env is active, running `toot` will execute the one you checked
|
||||||
|
@ -118,7 +116,7 @@ these rules for you.
|
||||||
|
|
||||||
#### Run tests before submitting
|
#### Run tests before submitting
|
||||||
|
|
||||||
You can run code and sytle tests by running:
|
You can run code and style tests by running:
|
||||||
|
|
||||||
```
|
```
|
||||||
make test
|
make test
|
||||||
|
|
|
@ -5,13 +5,11 @@ This document is a checklist for creating a toot release.
|
||||||
|
|
||||||
Currently the process is pretty manual and would benefit from automatization.
|
Currently the process is pretty manual and would benefit from automatization.
|
||||||
|
|
||||||
Bump & tag version
|
Make docs and tag version
|
||||||
------------------
|
-------------------------
|
||||||
|
|
||||||
* Update the version number in `setup.py`
|
|
||||||
* Update the version number in `toot/__init__.py`
|
|
||||||
* Update `changelog.yaml` with the release notes & date
|
* Update `changelog.yaml` with the release notes & date
|
||||||
* Run `make changelog` to generate a human readable changelog
|
* Run `make docs` to generate changelog and update docs
|
||||||
* Commit the changes
|
* Commit the changes
|
||||||
* Run `./scripts/tag_version <version>` to tag a release in git
|
* Run `./scripts/tag_version <version>` to tag a release in git
|
||||||
* Run `git push --follow-tags` to upload changes and tag to GitHub
|
* Run `git push --follow-tags` to upload changes and tag to GitHub
|
||||||
|
|
|
@ -11,7 +11,7 @@ Toot will look for the settings file at:
|
||||||
* `~/.config/toot/settings.toml` (Linux & co.)
|
* `~/.config/toot/settings.toml` (Linux & co.)
|
||||||
* `%APPDATA%\toot\settings.toml` (Windows)
|
* `%APPDATA%\toot\settings.toml` (Windows)
|
||||||
|
|
||||||
Toot will respect the `XDG_CONFIG_HOME` environement variable if it's set and
|
Toot will respect the `XDG_CONFIG_HOME` environment variable if it's set and
|
||||||
look for the settings file in `$XDG_CONFIG_HOME/toot` instead of
|
look for the settings file in `$XDG_CONFIG_HOME/toot` instead of
|
||||||
`~/.config/toot`.
|
`~/.config/toot`.
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ By default, TUI operates in 16-color mode which can be changed by setting the
|
||||||
* `16777216` (24 bit)
|
* `16777216` (24 bit)
|
||||||
|
|
||||||
TUI defines a list of colors which can be customized, currently they can be seen
|
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.
|
[in the source code](https://github.com/ihabunek/toot/blob/master/toot/tui/constants.py). They can be overridden in the `[tui.palette]` section.
|
||||||
|
|
||||||
Each color is defined as a list of upto 5 values:
|
Each color is defined as a list of upto 5 values:
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Running toot tests
|
||||||
|
|
||||||
|
## Mastodon
|
||||||
|
|
||||||
|
Clone mastodon repo and check out the tag you want to test:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/mastodon/mastodon
|
||||||
|
cd mastodon
|
||||||
|
git checkout v4.2.8
|
||||||
|
```
|
||||||
|
|
||||||
|
Set up the required Ruby version using [ASDF](https://asdf-vm.com/). The
|
||||||
|
required version is listed in `.ruby-version`.
|
||||||
|
|
||||||
|
```
|
||||||
|
asdf install ruby 3.2.3
|
||||||
|
asdf local ruby 3.2.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Install and set up database:
|
||||||
|
|
||||||
|
```
|
||||||
|
bundle install
|
||||||
|
yarn install
|
||||||
|
rails db:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Patch code so users are auto-approved:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl https://paste.sr.ht/blob/7c6e08bbacf3da05366b3496b3f24dd03d60bd6d | git am
|
||||||
|
```
|
||||||
|
|
||||||
|
Open registrations:
|
||||||
|
|
||||||
|
```
|
||||||
|
bin/tootctl settings registration open
|
||||||
|
```
|
||||||
|
|
||||||
|
Install foreman to run the thing:
|
||||||
|
|
||||||
|
```
|
||||||
|
gem install foreman
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the server:
|
||||||
|
|
||||||
|
```
|
||||||
|
foreman start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pleroma
|
||||||
|
|
||||||
|
https://docs-develop.pleroma.social/backend/development/setting_up_pleroma_dev/
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=64", "setuptools_scm>=8"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "toot"
|
||||||
|
authors = [{ name="Ivan Habunek", email="ivan@habunek.com" }]
|
||||||
|
description = "Mastodon CLI client"
|
||||||
|
readme = "README.rst"
|
||||||
|
license = { file="LICENSE" }
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
dynamic = ["version"]
|
||||||
|
|
||||||
|
classifiers = [
|
||||||
|
"Environment :: Console :: Curses",
|
||||||
|
"Environment :: Console",
|
||||||
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"beautifulsoup4>=4.5.0,<5.0",
|
||||||
|
"click~=8.1",
|
||||||
|
"requests>=2.13,<3.0",
|
||||||
|
"tomlkit>=0.10.0,<1.0",
|
||||||
|
"urwid>=2.0.0,<3.0",
|
||||||
|
"wcwidth>=0.1.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# Required to display images in the TUI
|
||||||
|
images = [
|
||||||
|
"pillow>=9.5.0",
|
||||||
|
"term-image>=0.7.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Required to display rich text in the TUI
|
||||||
|
richtext = [
|
||||||
|
"urwidgets>=0.2,<0.3"
|
||||||
|
]
|
||||||
|
|
||||||
|
test = [
|
||||||
|
"flake8",
|
||||||
|
"pytest",
|
||||||
|
"pytest-xdist[psutil]",
|
||||||
|
"setuptools",
|
||||||
|
"vermin",
|
||||||
|
"typing-extensions",
|
||||||
|
"pillow>=9.5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
dev = [
|
||||||
|
"build",
|
||||||
|
"flake8",
|
||||||
|
"mypy",
|
||||||
|
"pyright",
|
||||||
|
"pyyaml",
|
||||||
|
"textual-dev",
|
||||||
|
"twine",
|
||||||
|
"types-beautifulsoup4",
|
||||||
|
"vermin",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
"Homepage" = "https://toot.bezdomni.net"
|
||||||
|
"Source" = "https://github.com/ihabunek/toot/"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
toot = "toot.cli:cli"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages=[
|
||||||
|
"toot",
|
||||||
|
"toot.cli",
|
||||||
|
"toot.tui",
|
||||||
|
"toot.tui.richtext",
|
||||||
|
"toot.utils"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools_scm]
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
typeCheckingMode = "strict"
|
||||||
|
pythonVersion = "3.8"
|
|
@ -16,7 +16,6 @@ import toot
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from os import path
|
from os import path
|
||||||
from pkg_resources import get_distribution
|
|
||||||
|
|
||||||
path = path.join(path.dirname(path.dirname(path.abspath(__file__))), "changelog.yaml")
|
path = path.join(path.dirname(path.dirname(path.abspath(__file__))), "changelog.yaml")
|
||||||
with open(path, "r") as f:
|
with open(path, "r") as f:
|
||||||
|
@ -33,15 +32,6 @@ if not changelog_item:
|
||||||
print(f"Version `{version}` not found in changelog.", file=sys.stderr)
|
print(f"Version `{version}` not found in changelog.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if toot.__version__ != version:
|
|
||||||
print(f"toot.__version__ is `{toot.__version__}`, expected {version}.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
dist_version = get_distribution('toot').version
|
|
||||||
if dist_version != version:
|
|
||||||
print(f"Version in setup.py is `{dist_version}`, expected {version}.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
release_date = changelog_item["date"]
|
release_date = changelog_item["date"]
|
||||||
description = changelog_item.get("description")
|
description = changelog_item.get("description")
|
||||||
changes = changelog_item["changes"]
|
changes = changelog_item["changes"]
|
||||||
|
|
70
setup.py
70
setup.py
|
@ -1,70 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
from setuptools import setup
|
|
||||||
|
|
||||||
long_description = """
|
|
||||||
Toot is a CLI and TUI tool for interacting with Mastodon instances from the
|
|
||||||
command line.
|
|
||||||
|
|
||||||
Allows posting text and media to the timeline, searching, following, muting
|
|
||||||
and blocking accounts and other actions.
|
|
||||||
"""
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name='toot',
|
|
||||||
version='0.40.1',
|
|
||||||
description='Mastodon CLI client',
|
|
||||||
long_description=long_description.strip(),
|
|
||||||
author='Ivan Habunek',
|
|
||||||
author_email='ivan@habunek.com',
|
|
||||||
url='https://github.com/ihabunek/toot/',
|
|
||||||
project_urls={
|
|
||||||
'Documentation': 'https://toot.bezdomni.net/',
|
|
||||||
'Issue tracker': 'https://github.com/ihabunek/toot/issues/',
|
|
||||||
},
|
|
||||||
keywords='mastodon toot',
|
|
||||||
license='GPLv3',
|
|
||||||
classifiers=[
|
|
||||||
'Development Status :: 4 - Beta',
|
|
||||||
'Environment :: Console :: Curses',
|
|
||||||
'Environment :: Console',
|
|
||||||
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
|
|
||||||
'Programming Language :: Python :: 3',
|
|
||||||
],
|
|
||||||
packages=['toot', 'toot.cli', 'toot.tui', 'toot.tui.richtext', 'toot.utils'],
|
|
||||||
python_requires=">=3.7",
|
|
||||||
install_requires=[
|
|
||||||
"click~=8.1",
|
|
||||||
"requests>=2.13,<3.0",
|
|
||||||
"beautifulsoup4>=4.5.0,<5.0",
|
|
||||||
"wcwidth>=0.1.7",
|
|
||||||
"urwid>=2.0.0,<3.0",
|
|
||||||
"tomlkit>=0.10.0,<1.0"
|
|
||||||
],
|
|
||||||
extras_require={
|
|
||||||
# Required to display rich text in the TUI
|
|
||||||
"richtext": [
|
|
||||||
"urwidgets>=0.1,<0.2"
|
|
||||||
],
|
|
||||||
"dev": [
|
|
||||||
"coverage",
|
|
||||||
"pyyaml",
|
|
||||||
"twine",
|
|
||||||
"wheel",
|
|
||||||
],
|
|
||||||
"test": [
|
|
||||||
"flake8",
|
|
||||||
"psycopg2-binary",
|
|
||||||
"pytest",
|
|
||||||
"pytest-xdist[psutil]",
|
|
||||||
"setuptools",
|
|
||||||
"vermin",
|
|
||||||
"typing-extensions",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
entry_points={
|
|
||||||
'console_scripts': [
|
|
||||||
'toot=toot.cli:cli',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
Testing toot
|
||||||
|
============
|
||||||
|
|
||||||
|
This document is WIP.
|
||||||
|
|
||||||
|
Mastodon
|
||||||
|
--------
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
Pleroma
|
||||||
|
-------
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
Akkoma
|
||||||
|
------
|
||||||
|
|
||||||
|
Install using the guide here:
|
||||||
|
https://docs.akkoma.dev/stable/installation/docker_en/
|
||||||
|
|
||||||
|
Disable captcha and throttling by adding this to `config/prod.exs`:
|
||||||
|
|
||||||
|
```ex
|
||||||
|
# Disable captcha for testing
|
||||||
|
config :pleroma, Pleroma.Captcha,
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# Disable rate limiting for testing
|
||||||
|
config :pleroma, :rate_limit,
|
||||||
|
authentication: nil,
|
||||||
|
timeline: nil,
|
||||||
|
search: nil,
|
||||||
|
app_account_creation: nil,
|
||||||
|
relations_actions: nil,
|
||||||
|
relation_id_action: nil,
|
||||||
|
statuses_actions: nil,
|
||||||
|
status_id_action: nil,
|
||||||
|
password_reset: nil,
|
||||||
|
account_confirmation_resend: nil,
|
||||||
|
ap_routes: nil
|
||||||
|
```
|
|
@ -9,15 +9,14 @@ your test server and database:
|
||||||
|
|
||||||
```
|
```
|
||||||
export TOOT_TEST_BASE_URL="localhost:3000"
|
export TOOT_TEST_BASE_URL="localhost:3000"
|
||||||
export TOOT_TEST_DATABASE_DSN="dbname=mastodon_development"
|
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
import os
|
import os
|
||||||
import psycopg2
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import re
|
||||||
|
import typing as t
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from click.testing import CliRunner, Result
|
from click.testing import CliRunner, Result
|
||||||
|
@ -31,8 +30,10 @@ def pytest_configure(config):
|
||||||
toot.settings.DISABLE_SETTINGS = True
|
toot.settings.DISABLE_SETTINGS = True
|
||||||
|
|
||||||
|
|
||||||
|
# Type alias for run commands
|
||||||
|
Run = t.Callable[..., Result]
|
||||||
|
|
||||||
# Mastodon database name, used to confirm user registration without having to click the link
|
# Mastodon database name, used to confirm user registration without having to click the link
|
||||||
DATABASE_DSN = os.getenv("TOOT_TEST_DATABASE_DSN")
|
|
||||||
TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL")
|
TOOT_TEST_BASE_URL = os.getenv("TOOT_TEST_BASE_URL")
|
||||||
|
|
||||||
# Toot logo used for testing image upload
|
# Toot logo used for testing image upload
|
||||||
|
@ -40,6 +41,8 @@ TRUMPET = str(Path(__file__).parent.parent.parent / "trumpet.png")
|
||||||
|
|
||||||
ASSETS_DIR = str(Path(__file__).parent.parent / "assets")
|
ASSETS_DIR = str(Path(__file__).parent.parent / "assets")
|
||||||
|
|
||||||
|
PASSWORD = "83dU29170rjKilKQQwuWhJv3PKnSW59bWx0perjP6i7Nu4rkeh4mRfYuvVLYM3fM"
|
||||||
|
|
||||||
|
|
||||||
def create_app(base_url):
|
def create_app(base_url):
|
||||||
instance = api.get_instance(base_url).json()
|
instance = api.get_instance(base_url).json()
|
||||||
|
@ -51,18 +54,10 @@ def register_account(app: App):
|
||||||
username = str(uuid.uuid4())[-10:]
|
username = str(uuid.uuid4())[-10:]
|
||||||
email = f"{username}@example.com"
|
email = f"{username}@example.com"
|
||||||
|
|
||||||
response = api.register_account(app, username, email, "password", "en")
|
response = api.register_account(app, username, email, PASSWORD, "en")
|
||||||
confirm_user(email)
|
|
||||||
return User(app.instance, username, response["access_token"])
|
return User(app.instance, username, response["access_token"])
|
||||||
|
|
||||||
|
|
||||||
def confirm_user(email):
|
|
||||||
conn = psycopg2.connect(DATABASE_DSN)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("UPDATE users SET confirmed_at = now() WHERE email = %s;", (email,))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Fixtures
|
# Fixtures
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
@ -160,3 +155,8 @@ def posted_status_id(out):
|
||||||
_, _, status_id = match.groups()
|
_, _, status_id = match.groups()
|
||||||
|
|
||||||
return status_id
|
return status_id
|
||||||
|
|
||||||
|
|
||||||
|
def assert_ok(result: Result):
|
||||||
|
if result.exit_code != 0:
|
||||||
|
raise AssertionError(f"Command failed with exit code {result.exit_code}\nStderr: {result.stderr}")
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import json
|
import json
|
||||||
|
from tests.integration.conftest import register_account
|
||||||
|
|
||||||
from toot import App, User, api, cli
|
from toot import App, User, api, cli
|
||||||
from toot.entities import Account, Relationship, from_dict
|
from toot.entities import Account, Relationship, from_dict
|
||||||
|
@ -35,9 +36,8 @@ def test_whois(app: App, friend: User, run):
|
||||||
assert f"@{friend.username}" in result.stdout
|
assert f"@{friend.username}" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_following(app: App, user: User, friend: User, friend_id, run):
|
def test_following(app: App, user: User, run):
|
||||||
# Make sure we're not initally following friend
|
friend = register_account(app)
|
||||||
api.unfollow(app, user, friend_id)
|
|
||||||
|
|
||||||
result = run(cli.accounts.following, user.username)
|
result = run(cli.accounts.following, user.username)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
@ -84,9 +84,8 @@ def test_following_not_found(run):
|
||||||
assert result.stderr.strip() == "Error: Account not found"
|
assert result.stderr.strip() == "Error: Account not found"
|
||||||
|
|
||||||
|
|
||||||
def test_following_json(app: App, user: User, friend: User, user_id, friend_id, run_json):
|
def test_following_json(app: App, user: User, user_id, run_json):
|
||||||
# Make sure we're not initally following friend
|
friend = register_account(app)
|
||||||
api.unfollow(app, user, friend_id)
|
|
||||||
|
|
||||||
result = run_json(cli.accounts.following, user.username, "--json")
|
result = run_json(cli.accounts.following, user.username, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
@ -96,24 +95,26 @@ def test_following_json(app: App, user: User, friend: User, user_id, friend_id,
|
||||||
|
|
||||||
result = run_json(cli.accounts.follow, friend.username, "--json")
|
result = run_json(cli.accounts.follow, friend.username, "--json")
|
||||||
relationship = from_dict(Relationship, result)
|
relationship = from_dict(Relationship, result)
|
||||||
assert relationship.id == friend_id
|
|
||||||
assert relationship.following is True
|
assert relationship.following is True
|
||||||
|
|
||||||
[result] = run_json(cli.accounts.following, user.username, "--json")
|
[result] = run_json(cli.accounts.following, user.username, "--json")
|
||||||
relationship = from_dict(Relationship, result)
|
account = from_dict(Account, result)
|
||||||
assert relationship.id == friend_id
|
assert account.acct == friend.username
|
||||||
|
|
||||||
# If no account is given defaults to logged in user
|
# If no account is given defaults to logged in user
|
||||||
[result] = run_json(cli.accounts.following, user.username, "--json")
|
[result] = run_json(cli.accounts.following, "--json")
|
||||||
relationship = from_dict(Relationship, result)
|
account = from_dict(Account, result)
|
||||||
assert relationship.id == friend_id
|
assert account.acct == friend.username
|
||||||
|
|
||||||
|
assert relationship.following is True
|
||||||
|
|
||||||
[result] = run_json(cli.accounts.followers, friend.username, "--json")
|
[result] = run_json(cli.accounts.followers, friend.username, "--json")
|
||||||
assert result["id"] == user_id
|
account = from_dict(Account, result)
|
||||||
|
assert account.acct == user.username
|
||||||
|
|
||||||
result = run_json(cli.accounts.unfollow, friend.username, "--json")
|
result = run_json(cli.accounts.unfollow, friend.username, "--json")
|
||||||
assert result["id"] == friend_id
|
relationship = from_dict(Relationship, result)
|
||||||
assert result["following"] is False
|
assert relationship.following is False
|
||||||
|
|
||||||
result = run_json(cli.accounts.following, user.username, "--json")
|
result = run_json(cli.accounts.following, user.username, "--json")
|
||||||
assert result == []
|
assert result == []
|
||||||
|
@ -200,9 +201,8 @@ def test_mute_json(app: App, user: User, friend: User, run_json, friend_id):
|
||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
def test_block(app, user, friend, friend_id, run):
|
def test_block(app, user, run):
|
||||||
# Make sure we're not initially blocking friend
|
friend = register_account(app)
|
||||||
api.unblock(app, user, friend_id)
|
|
||||||
|
|
||||||
result = run(cli.accounts.blocked)
|
result = run(cli.accounts.blocked)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
|
|
@ -3,7 +3,7 @@ from unittest import mock
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from toot import User, cli
|
from toot import User, cli
|
||||||
from toot.cli import Run
|
from tests.integration.conftest import PASSWORD, Run
|
||||||
|
|
||||||
# TODO: figure out how to test login
|
# TODO: figure out how to test login
|
||||||
|
|
||||||
|
@ -89,7 +89,7 @@ def test_login_cli(
|
||||||
cli.auth.login_cli,
|
cli.auth.login_cli,
|
||||||
"--instance", "http://localhost:3000",
|
"--instance", "http://localhost:3000",
|
||||||
"--email", f"{user.username}@example.com",
|
"--email", f"{user.username}@example.com",
|
||||||
"--password", "password",
|
"--password", PASSWORD,
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "✓ Successfully logged in." in result.stdout
|
assert "✓ Successfully logged in." in result.stdout
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from tests.utils import run_with_retries
|
||||||
from toot import api, cli
|
from toot import api, cli
|
||||||
from toot.exceptions import NotFoundError
|
from toot.exceptions import NotFoundError
|
||||||
|
|
||||||
|
@ -46,11 +46,11 @@ def test_favourite(app, user, run):
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert result.stdout.strip() == "✓ Status unfavourited"
|
assert result.stdout.strip() == "✓ Status unfavourited"
|
||||||
|
|
||||||
# A short delay is required before the server returns new data
|
def test_favourited():
|
||||||
time.sleep(0.2)
|
nonlocal status
|
||||||
|
status = api.fetch_status(app, user, status["id"]).json()
|
||||||
status = api.fetch_status(app, user, status["id"]).json()
|
assert not status["favourited"]
|
||||||
assert not status["favourited"]
|
run_with_retries(test_favourited)
|
||||||
|
|
||||||
|
|
||||||
def test_favourite_json(app, user, run):
|
def test_favourite_json(app, user, run):
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from time import sleep
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from tests.utils import run_with_retries
|
||||||
|
|
||||||
from toot import api, cli
|
from toot import api, cli
|
||||||
from toot.entities import from_dict, Status
|
from toot.entities import from_dict, Status
|
||||||
from tests.integration.conftest import TOOT_TEST_BASE_URL, register_account
|
from tests.integration.conftest import TOOT_TEST_BASE_URL, register_account
|
||||||
|
|
||||||
|
|
||||||
# TODO: If fixture is not overriden here, tests fail, not sure why, figure it out
|
# TODO: If fixture is not overridden here, tests fail, not sure why, figure it out
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def user(app):
|
def user(app):
|
||||||
return register_account(app)
|
return register_account(app)
|
||||||
|
@ -40,16 +40,14 @@ def test_timelines(app, user, other_user, friend_user, friend_list, run):
|
||||||
status2 = _post_status(app, other_user, "#bar")
|
status2 = _post_status(app, other_user, "#bar")
|
||||||
status3 = _post_status(app, friend_user, "#foo #bar")
|
status3 = _post_status(app, friend_user, "#foo #bar")
|
||||||
|
|
||||||
# Give mastodon time to process things :/
|
|
||||||
# Tests fail if this is removed, required delay depends on server speed
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
# Home timeline
|
# Home timeline
|
||||||
result = run(cli.timelines.timeline)
|
def test_home():
|
||||||
assert result.exit_code == 0
|
result = run(cli.timelines.timeline)
|
||||||
assert status1.id in result.stdout
|
assert result.exit_code == 0
|
||||||
assert status2.id not in result.stdout
|
assert status1.id in result.stdout
|
||||||
assert status3.id in result.stdout
|
assert status2.id not in result.stdout
|
||||||
|
assert status3.id in result.stdout
|
||||||
|
run_with_retries(test_home)
|
||||||
|
|
||||||
# Public timeline
|
# Public timeline
|
||||||
result = run(cli.timelines.timeline, "--public")
|
result = run(cli.timelines.timeline, "--public")
|
||||||
|
@ -166,13 +164,14 @@ def test_notifications(app, user, other_user, run):
|
||||||
|
|
||||||
text = f"Paging doctor @{user.username}"
|
text = f"Paging doctor @{user.username}"
|
||||||
status = _post_status(app, other_user, text)
|
status = _post_status(app, other_user, text)
|
||||||
sleep(0.5) # grr
|
|
||||||
|
|
||||||
result = run(cli.timelines.notifications)
|
def test_notifications():
|
||||||
assert result.exit_code == 0
|
result = run(cli.timelines.notifications)
|
||||||
assert f"@{other_user.username} mentioned you" in result.stdout
|
assert result.exit_code == 0
|
||||||
assert status.id in result.stdout
|
assert f"@{other_user.username} mentioned you" in result.stdout
|
||||||
assert text in result.stdout
|
assert status.id in result.stdout
|
||||||
|
assert text in result.stdout
|
||||||
|
run_with_retries(test_notifications)
|
||||||
|
|
||||||
result = run(cli.timelines.notifications, "--mentions")
|
result = run(cli.timelines.notifications, "--mentions")
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
|
@ -186,7 +185,6 @@ def test_notifications_follow(app, user, friend_user, run_as):
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert f"@{user.username} now follows you" in result.stdout
|
assert f"@{user.username} now follows you" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
result = run_as(friend_user, cli.timelines.notifications, "--mentions")
|
result = run_as(friend_user, cli.timelines.notifications, "--mentions")
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "now follows you" not in result.stdout
|
assert "now follows you" not in result.stdout
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
import click
|
import click
|
||||||
import pytest
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
from toot.cli.validators import validate_duration
|
from toot.cli.validators import validate_duration
|
||||||
from toot.wcstring import wc_wrap, trunc, pad, fit_text
|
from toot.wcstring import wc_wrap, trunc, pad, fit_text
|
||||||
|
from toot.tui.utils import LRUCache
|
||||||
|
from PIL import Image
|
||||||
|
from collections import namedtuple
|
||||||
from toot.utils import urlencode_url
|
from toot.utils import urlencode_url
|
||||||
|
|
||||||
|
|
||||||
|
@ -207,6 +211,111 @@ def test_duration():
|
||||||
duration("banana")
|
duration("banana")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_null():
|
||||||
|
"""Null dict is null."""
|
||||||
|
cache = LRUCache(cache_max_bytes=1024)
|
||||||
|
assert cache.__len__() == 0
|
||||||
|
|
||||||
|
|
||||||
|
Case = namedtuple("Case", ["cache_len", "len", "init"])
|
||||||
|
|
||||||
|
img = Image.new('RGB', (100, 100))
|
||||||
|
img_size = sys.getsizeof(img.tobytes())
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"case",
|
||||||
|
[
|
||||||
|
Case(9, 0, []),
|
||||||
|
Case(9, 1, [("one", img)]),
|
||||||
|
Case(9, 2, [("one", img), ("two", img)]),
|
||||||
|
Case(2, 2, [("one", img), ("two", img)]),
|
||||||
|
Case(1, 1, [("one", img), ("two", img)]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("method", ["assign", "init"])
|
||||||
|
def test_cache_init(case, method):
|
||||||
|
"""Check that the # of elements is right, given # given and cache_len."""
|
||||||
|
if method == "init":
|
||||||
|
cache = LRUCache(case.init, cache_max_bytes=img_size * case.cache_len)
|
||||||
|
elif method == "assign":
|
||||||
|
cache = LRUCache(cache_max_bytes=img_size * case.cache_len)
|
||||||
|
for (key, val) in case.init:
|
||||||
|
cache[key] = val
|
||||||
|
else:
|
||||||
|
assert False
|
||||||
|
|
||||||
|
# length is max(#entries, cache_len)
|
||||||
|
assert cache.__len__() == case.len
|
||||||
|
|
||||||
|
# make sure the first entry is the one ejected
|
||||||
|
if case.cache_len > 1 and case.init:
|
||||||
|
assert "one" in cache.keys()
|
||||||
|
else:
|
||||||
|
assert "one" not in cache.keys()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["init", "assign"])
|
||||||
|
def test_cache_overflow_default(method):
|
||||||
|
"""Test default overflow logic."""
|
||||||
|
if method == "init":
|
||||||
|
cache = LRUCache([("one", img), ("two", img), ("three", img)], cache_max_bytes=img_size * 2)
|
||||||
|
elif method == "assign":
|
||||||
|
cache = LRUCache(cache_max_bytes=img_size * 2)
|
||||||
|
cache["one"] = img
|
||||||
|
cache["two"] = img
|
||||||
|
cache["three"] = img
|
||||||
|
else:
|
||||||
|
assert False
|
||||||
|
|
||||||
|
assert "one" not in cache.keys()
|
||||||
|
assert "two" in cache.keys()
|
||||||
|
assert "three" in cache.keys()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["get", "set"])
|
||||||
|
@pytest.mark.parametrize("add_third", [False, True])
|
||||||
|
def test_cache_lru_overflow(mode, add_third):
|
||||||
|
img = Image.new('RGB', (100, 100))
|
||||||
|
img_size = sys.getsizeof(img.tobytes())
|
||||||
|
|
||||||
|
"""Test that key access resets LRU logic."""
|
||||||
|
|
||||||
|
cache = LRUCache([("one", img), ("two", img)], cache_max_bytes=img_size * 2)
|
||||||
|
|
||||||
|
if mode == "get":
|
||||||
|
dummy = cache["one"]
|
||||||
|
elif mode == "set":
|
||||||
|
cache["one"] = img
|
||||||
|
else:
|
||||||
|
assert False
|
||||||
|
|
||||||
|
if add_third:
|
||||||
|
cache["three"] = img
|
||||||
|
|
||||||
|
assert "one" in cache.keys()
|
||||||
|
assert "two" not in cache.keys()
|
||||||
|
assert "three" in cache.keys()
|
||||||
|
else:
|
||||||
|
assert "one" in cache.keys()
|
||||||
|
assert "two" in cache.keys()
|
||||||
|
assert "three" not in cache.keys()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_keyerror():
|
||||||
|
cache = LRUCache()
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
cache["foo"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_miss_doesnt_eject():
|
||||||
|
cache = LRUCache([("one", img), ("two", img)], cache_max_bytes=img_size * 3)
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
cache["foo"]
|
||||||
|
|
||||||
|
assert len(cache) == 2
|
||||||
|
assert "one" in cache.keys()
|
||||||
|
assert "two" in cache.keys()
|
||||||
|
|
||||||
def test_urlencode_url():
|
def test_urlencode_url():
|
||||||
assert urlencode_url("https://www.example.com") == "https://www.example.com"
|
assert urlencode_url("https://www.example.com") == "https://www.example.com"
|
||||||
assert urlencode_url("https://www.example.com/url%20with%20spaces") == "https://www.example.com/url%20with%20spaces"
|
assert urlencode_url("https://www.example.com/url%20with%20spaces") == "https://www.example.com/url%20with%20spaces"
|
||||||
|
|
||||||
|
|
|
@ -2,20 +2,28 @@
|
||||||
Helpers for testing.
|
Helpers for testing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
class MockResponse:
|
from typing import Callable, TypeVar
|
||||||
def __init__(self, response_data={}, ok=True, is_redirect=False):
|
|
||||||
self.response_data = response_data
|
|
||||||
self.content = response_data
|
|
||||||
self.ok = ok
|
|
||||||
self.is_redirect = is_redirect
|
|
||||||
|
|
||||||
def raise_for_status(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return self.response_data
|
|
||||||
|
|
||||||
|
|
||||||
def retval(val):
|
T = TypeVar("T")
|
||||||
return lambda *args, **kwargs: val
|
|
||||||
|
|
||||||
|
def run_with_retries(fn: Callable[..., T]) -> T:
|
||||||
|
"""
|
||||||
|
Run the the given function repeatedly until it finishes without raising an
|
||||||
|
AssertionError. Sleep a bit between attempts. If the function doesn't
|
||||||
|
succeed in the given number of tries raises the AssertionError. Used for
|
||||||
|
tests which should eventually succeed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Wait upto 6 seconds with incrementally longer sleeps
|
||||||
|
delays = [0.1, 0.2, 0.3, 0.4, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]
|
||||||
|
|
||||||
|
for delay in delays:
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except AssertionError:
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
return fn()
|
||||||
|
|
|
@ -3,8 +3,13 @@ import sys
|
||||||
|
|
||||||
from os.path import join, expanduser
|
from os.path import join, expanduser
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
from importlib import metadata
|
||||||
|
|
||||||
__version__ = '0.40.1'
|
|
||||||
|
try:
|
||||||
|
__version__ = metadata.version("toot")
|
||||||
|
except metadata.PackageNotFoundError:
|
||||||
|
__version__ = "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
class App(NamedTuple):
|
class App(NamedTuple):
|
||||||
|
|
61
toot/api.py
61
toot/api.py
|
@ -183,7 +183,7 @@ def post_status(
|
||||||
app,
|
app,
|
||||||
user,
|
user,
|
||||||
status,
|
status,
|
||||||
visibility='public',
|
visibility=None,
|
||||||
media_ids=None,
|
media_ids=None,
|
||||||
sensitive=False,
|
sensitive=False,
|
||||||
spoiler_text=None,
|
spoiler_text=None,
|
||||||
|
@ -230,6 +230,52 @@ def post_status(
|
||||||
return http.post(app, user, '/api/v1/statuses', json=data, headers=headers)
|
return http.post(app, user, '/api/v1/statuses', json=data, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def edit_status(
|
||||||
|
app,
|
||||||
|
user,
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
visibility='public',
|
||||||
|
media_ids=None,
|
||||||
|
sensitive=False,
|
||||||
|
spoiler_text=None,
|
||||||
|
in_reply_to_id=None,
|
||||||
|
language=None,
|
||||||
|
content_type=None,
|
||||||
|
poll_options=None,
|
||||||
|
poll_expires_in=None,
|
||||||
|
poll_multiple=None,
|
||||||
|
poll_hide_totals=None,
|
||||||
|
) -> Response:
|
||||||
|
"""
|
||||||
|
Edit an existing status
|
||||||
|
https://docs.joinmastodon.org/methods/statuses/#edit
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Strip keys for which value is None
|
||||||
|
# Sending null values doesn't bother Mastodon, but it breaks Pleroma
|
||||||
|
data = drop_empty_values({
|
||||||
|
'status': status,
|
||||||
|
'media_ids': media_ids,
|
||||||
|
'visibility': visibility,
|
||||||
|
'sensitive': sensitive,
|
||||||
|
'in_reply_to_id': in_reply_to_id,
|
||||||
|
'language': language,
|
||||||
|
'content_type': content_type,
|
||||||
|
'spoiler_text': spoiler_text,
|
||||||
|
})
|
||||||
|
|
||||||
|
if poll_options:
|
||||||
|
data["poll"] = {
|
||||||
|
"options": poll_options,
|
||||||
|
"expires_in": poll_expires_in,
|
||||||
|
"multiple": poll_multiple,
|
||||||
|
"hide_totals": poll_hide_totals,
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.put(app, user, f"/api/v1/statuses/{id}", json=data)
|
||||||
|
|
||||||
|
|
||||||
def fetch_status(app, user, id):
|
def fetch_status(app, user, id):
|
||||||
"""
|
"""
|
||||||
Fetch a single status
|
Fetch a single status
|
||||||
|
@ -238,6 +284,15 @@ def fetch_status(app, user, id):
|
||||||
return http.get(app, user, f"/api/v1/statuses/{id}")
|
return http.get(app, user, f"/api/v1/statuses/{id}")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_status_source(app, user, id):
|
||||||
|
"""
|
||||||
|
Fetch the source (original text) for a single status.
|
||||||
|
This only works on local toots.
|
||||||
|
https://docs.joinmastodon.org/methods/statuses/#source
|
||||||
|
"""
|
||||||
|
return http.get(app, user, f"/api/v1/statuses/{id}/source")
|
||||||
|
|
||||||
|
|
||||||
def scheduled_statuses(app, user):
|
def scheduled_statuses(app, user):
|
||||||
"""
|
"""
|
||||||
List scheduled statuses
|
List scheduled statuses
|
||||||
|
@ -618,6 +673,10 @@ def get_instance(base_url: str) -> Response:
|
||||||
return http.anon_get(url)
|
return http.anon_get(url)
|
||||||
|
|
||||||
|
|
||||||
|
def get_preferences(app, user) -> Response:
|
||||||
|
return http.get(app, user, '/api/v1/preferences')
|
||||||
|
|
||||||
|
|
||||||
def get_lists(app, user):
|
def get_lists(app, user):
|
||||||
return http.get(app, user, "/api/v1/lists").json()
|
return http.get(app, user, "/api/v1/lists").json()
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,6 @@ import sys
|
||||||
import typing as t
|
import typing as t
|
||||||
|
|
||||||
from click.shell_completion import CompletionItem
|
from click.shell_completion import CompletionItem
|
||||||
from click.testing import Result
|
|
||||||
from click.types import StringParamType
|
from click.types import StringParamType
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
|
@ -23,7 +22,7 @@ T = t.TypeVar("T")
|
||||||
|
|
||||||
PRIVACY_CHOICES = ["public", "unlisted", "private"]
|
PRIVACY_CHOICES = ["public", "unlisted", "private"]
|
||||||
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
|
VISIBILITY_CHOICES = ["public", "unlisted", "private", "direct"]
|
||||||
|
IMAGE_FORMAT_CHOICES = ["block", "iterm", "kitty"]
|
||||||
TUI_COLORS = {
|
TUI_COLORS = {
|
||||||
"1": 1,
|
"1": 1,
|
||||||
"16": 16,
|
"16": 16,
|
||||||
|
@ -39,10 +38,6 @@ DURATION_EXAMPLES = """e.g. "1 day", "2 hours 30 minutes", "5 minutes 30
|
||||||
seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
|
seconds" or any combination of above. Shorthand: "1d", "2h30m", "5m30s\""""
|
||||||
|
|
||||||
|
|
||||||
# Type alias for run commands
|
|
||||||
Run = t.Callable[..., Result]
|
|
||||||
|
|
||||||
|
|
||||||
def get_default_visibility() -> str:
|
def get_default_visibility() -> str:
|
||||||
return os.getenv("TOOT_POST_VISIBILITY", "public")
|
return os.getenv("TOOT_POST_VISIBILITY", "public")
|
||||||
|
|
||||||
|
@ -53,13 +48,11 @@ def get_default_map():
|
||||||
commands = settings.get("commands", {})
|
commands = settings.get("commands", {})
|
||||||
|
|
||||||
# TODO: remove in version 1.0
|
# TODO: remove in version 1.0
|
||||||
tui_old = settings.get("tui", {})
|
tui_old = settings.get("tui", {}).copy()
|
||||||
|
|
||||||
# Remove palette to avoid triggering warning for still valid [tui.palette] section
|
|
||||||
if "palette" in tui_old:
|
if "palette" in tui_old:
|
||||||
del tui_old["palette"]
|
del tui_old["palette"]
|
||||||
|
|
||||||
if tui_old:
|
if tui_old:
|
||||||
|
# TODO: don't show the warning for [toot.palette]
|
||||||
print_warning("Settings section [tui] has been deprecated in favour of [commands.tui].")
|
print_warning("Settings section [tui] has been deprecated in favour of [commands.tui].")
|
||||||
tui_new = commands.get("tui", {})
|
tui_new = commands.get("tui", {})
|
||||||
commands["tui"] = {**tui_old, **tui_new}
|
commands["tui"] = {**tui_old, **tui_new}
|
||||||
|
@ -110,6 +103,20 @@ class AccountParamType(StringParamType):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceParamType(StringParamType):
|
||||||
|
"""Custom type to add shell completion for instance domains"""
|
||||||
|
name = "instance"
|
||||||
|
|
||||||
|
def shell_complete(self, ctx, param, incomplete: str):
|
||||||
|
apps = config.load_config()["apps"]
|
||||||
|
|
||||||
|
return [
|
||||||
|
CompletionItem(i)
|
||||||
|
for i in apps.keys()
|
||||||
|
if i.lower().startswith(incomplete.lower())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]":
|
def pass_context(f: "t.Callable[te.Concatenate[Context, P], R]") -> "t.Callable[P, R]":
|
||||||
"""Pass the toot Context as first argument."""
|
"""Pass the toot Context as first argument."""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
|
|
|
@ -3,6 +3,7 @@ import json as pyjson
|
||||||
|
|
||||||
from toot import api, config
|
from toot import api, config
|
||||||
from toot.cli import Context, cli, pass_context, json_option
|
from toot.cli import Context, cli, pass_context, json_option
|
||||||
|
from toot.entities import from_dict_list, List
|
||||||
from toot.output import print_list_accounts, print_lists, print_warning
|
from toot.output import print_list_accounts, print_lists, print_warning
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,7 +19,8 @@ def lists(ctx: click.Context):
|
||||||
if not user or not app:
|
if not user or not app:
|
||||||
raise click.ClickException("This command requires you to be logged in.")
|
raise click.ClickException("This command requires you to be logged in.")
|
||||||
|
|
||||||
lists = api.get_lists(app, user)
|
data = api.get_lists(app, user)
|
||||||
|
lists = from_dict_list(List, data)
|
||||||
if lists:
|
if lists:
|
||||||
print_lists(lists)
|
print_lists(lists)
|
||||||
else:
|
else:
|
||||||
|
@ -30,12 +32,13 @@ def lists(ctx: click.Context):
|
||||||
@pass_context
|
@pass_context
|
||||||
def list(ctx: Context, json: bool):
|
def list(ctx: Context, json: bool):
|
||||||
"""List all your lists"""
|
"""List all your lists"""
|
||||||
lists = api.get_lists(ctx.app, ctx.user)
|
data = api.get_lists(ctx.app, ctx.user)
|
||||||
|
|
||||||
if json:
|
if json:
|
||||||
click.echo(pyjson.dumps(lists))
|
click.echo(pyjson.dumps(data))
|
||||||
else:
|
else:
|
||||||
if lists:
|
if data:
|
||||||
|
lists = from_dict_list(List, data)
|
||||||
print_lists(lists)
|
print_lists(lists)
|
||||||
else:
|
else:
|
||||||
click.echo("You have no lists defined.")
|
click.echo("You have no lists defined.")
|
||||||
|
|
|
@ -6,8 +6,8 @@ from datetime import datetime, timedelta, timezone
|
||||||
from time import sleep, time
|
from time import sleep, time
|
||||||
from typing import BinaryIO, Optional, Tuple
|
from typing import BinaryIO, Optional, Tuple
|
||||||
|
|
||||||
from toot import api
|
from toot import api, config
|
||||||
from toot.cli import cli, json_option, pass_context, Context
|
from toot.cli import AccountParamType, cli, json_option, pass_context, Context
|
||||||
from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES
|
from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES
|
||||||
from toot.cli.validators import validate_duration, validate_language
|
from toot.cli.validators import validate_duration, validate_language
|
||||||
from toot.entities import MediaAttachment, from_dict
|
from toot.entities import MediaAttachment, from_dict
|
||||||
|
@ -40,7 +40,6 @@ from toot.utils.datetime import parse_datetime
|
||||||
"--visibility", "-v",
|
"--visibility", "-v",
|
||||||
help="Post visibility",
|
help="Post visibility",
|
||||||
type=click.Choice(VISIBILITY_CHOICES),
|
type=click.Choice(VISIBILITY_CHOICES),
|
||||||
default="public",
|
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--sensitive", "-s",
|
"--sensitive", "-s",
|
||||||
|
@ -106,6 +105,11 @@ from toot.utils.datetime import parse_datetime
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"-u", "--using",
|
||||||
|
type=AccountParamType(),
|
||||||
|
help="The account to use, overrides the active account.",
|
||||||
|
)
|
||||||
@json_option
|
@json_option
|
||||||
@pass_context
|
@pass_context
|
||||||
def post(
|
def post(
|
||||||
|
@ -114,7 +118,7 @@ def post(
|
||||||
media: Tuple[str],
|
media: Tuple[str],
|
||||||
descriptions: Tuple[str],
|
descriptions: Tuple[str],
|
||||||
thumbnails: Tuple[str],
|
thumbnails: Tuple[str],
|
||||||
visibility: str,
|
visibility: Optional[str],
|
||||||
sensitive: bool,
|
sensitive: bool,
|
||||||
spoiler_text: Optional[str],
|
spoiler_text: Optional[str],
|
||||||
reply_to: Optional[str],
|
reply_to: Optional[str],
|
||||||
|
@ -127,13 +131,21 @@ def post(
|
||||||
poll_expires_in: int,
|
poll_expires_in: int,
|
||||||
poll_multiple: bool,
|
poll_multiple: bool,
|
||||||
poll_hide_totals: bool,
|
poll_hide_totals: bool,
|
||||||
json: bool
|
json: bool,
|
||||||
|
using: str
|
||||||
):
|
):
|
||||||
"""Post a new status"""
|
"""Post a new status"""
|
||||||
if len(media) > 4:
|
if len(media) > 4:
|
||||||
raise click.ClickException("Cannot attach more than 4 files.")
|
raise click.ClickException("Cannot attach more than 4 files.")
|
||||||
|
|
||||||
media_ids = _upload_media(ctx.app, ctx.user, media, descriptions, thumbnails)
|
if using:
|
||||||
|
user, app = config.get_user_app(using)
|
||||||
|
if not user or not app:
|
||||||
|
raise click.ClickException(f"Account '{using}' not found. Run `toot auth` to see available accounts.")
|
||||||
|
else:
|
||||||
|
user, app = ctx.user, ctx.app
|
||||||
|
|
||||||
|
media_ids = _upload_media(app, user, media, descriptions, thumbnails)
|
||||||
status_text = _get_status_text(text, editor, media)
|
status_text = _get_status_text(text, editor, media)
|
||||||
scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in)
|
scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in)
|
||||||
|
|
||||||
|
@ -141,8 +153,8 @@ def post(
|
||||||
raise click.ClickException("You must specify either text or media to post.")
|
raise click.ClickException("You must specify either text or media to post.")
|
||||||
|
|
||||||
response = api.post_status(
|
response = api.post_status(
|
||||||
ctx.app,
|
app,
|
||||||
ctx.user,
|
user,
|
||||||
status_text,
|
status_text,
|
||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
media_ids=media_ids,
|
media_ids=media_ids,
|
||||||
|
|
|
@ -9,7 +9,7 @@ from toot.cli.validators import validate_instance
|
||||||
from toot.entities import Instance, Status, from_dict, Account
|
from toot.entities import Instance, Status, from_dict, Account
|
||||||
from toot.exceptions import ApiError, ConsoleError
|
from toot.exceptions import ApiError, ConsoleError
|
||||||
from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline
|
from toot.output import print_account, print_instance, print_search_results, print_status, print_timeline
|
||||||
from toot.cli import cli, get_context, json_option, pass_context, Context
|
from toot.cli import InstanceParamType, cli, get_context, json_option, pass_context, Context
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
@ -43,7 +43,7 @@ def whois(ctx: Context, account: str, json: bool):
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.argument("instance", callback=validate_instance, required=False)
|
@click.argument("instance", type=InstanceParamType(), callback=validate_instance, required=False)
|
||||||
@json_option
|
@json_option
|
||||||
def instance(instance: Optional[str], json: bool):
|
def instance(instance: Optional[str], json: bool):
|
||||||
"""Display instance details
|
"""Display instance details
|
||||||
|
|
|
@ -2,7 +2,7 @@ import sys
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from toot import api
|
from toot import api
|
||||||
from toot.cli import cli, get_context, pass_context, Context
|
from toot.cli import InstanceParamType, cli, get_context, pass_context, Context
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from toot.cli.validators import validate_instance
|
from toot.cli.validators import validate_instance
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ from toot.output import print_notifications, print_timeline
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option(
|
@click.option(
|
||||||
"--instance", "-i",
|
"--instance", "-i",
|
||||||
|
type=InstanceParamType(),
|
||||||
callback=validate_instance,
|
callback=validate_instance,
|
||||||
help="""Domain or base URL of the instance from which to read,
|
help="""Domain or base URL of the instance from which to read,
|
||||||
e.g. 'mastodon.social' or 'https://mastodon.social'""",
|
e.g. 'mastodon.social' or 'https://mastodon.social'""",
|
||||||
|
@ -110,7 +111,10 @@ def bookmarks(
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@click.option("--clear", help="Dismiss all notifications and exit")
|
@click.option(
|
||||||
|
"--clear", is_flag=True,
|
||||||
|
help="Dismiss all notifications and exit"
|
||||||
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--reverse", "-r", is_flag=True,
|
"--reverse", "-r", is_flag=True,
|
||||||
help="Reverse the order of the shown notifications (newest on top)"
|
help="Reverse the order of the shown notifications (newest on top)"
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from toot.cli import TUI_COLORS, Context, cli, pass_context
|
from toot.cli import TUI_COLORS, VISIBILITY_CHOICES, IMAGE_FORMAT_CHOICES, Context, cli, pass_context
|
||||||
from toot.cli.validators import validate_tui_colors
|
from toot.cli.validators import validate_tui_colors, validate_cache_size
|
||||||
from toot.tui.app import TUI, TuiOptions
|
from toot.tui.app import TUI, TuiOptions
|
||||||
|
|
||||||
COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
|
COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
|
||||||
|
@ -24,12 +24,37 @@ COLOR_OPTIONS = ", ".join(TUI_COLORS.keys())
|
||||||
help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if
|
help=f"""Number of colors to use, one of {COLOR_OPTIONS}, defaults to 16 if
|
||||||
using --color, and 1 if using --no-color."""
|
using --color, and 1 if using --no-color."""
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"-s", "--cache-size",
|
||||||
|
callback=validate_cache_size,
|
||||||
|
help="""Specify the image cache maximum size in megabytes. Default: 10MB.
|
||||||
|
Minimum: 1MB."""
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-v", "--default-visibility",
|
||||||
|
type=click.Choice(VISIBILITY_CHOICES),
|
||||||
|
help="Default visibility when posting new toots; overrides the server-side preference"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-s", "--always-show-sensitive",
|
||||||
|
is_flag=True,
|
||||||
|
help="Expand toots with content warnings automatically"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-f", "--image-format",
|
||||||
|
type=click.Choice(IMAGE_FORMAT_CHOICES),
|
||||||
|
help="Image output format; support varies across terminals. Default: block"
|
||||||
|
)
|
||||||
@pass_context
|
@pass_context
|
||||||
def tui(
|
def tui(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
colors: Optional[int],
|
colors: Optional[int],
|
||||||
media_viewer: Optional[str],
|
media_viewer: Optional[str],
|
||||||
|
always_show_sensitive: bool,
|
||||||
relative_datetimes: bool,
|
relative_datetimes: bool,
|
||||||
|
cache_size: Optional[int],
|
||||||
|
default_visibility: Optional[str],
|
||||||
|
image_format: Optional[str]
|
||||||
):
|
):
|
||||||
"""Launches the toot terminal user interface"""
|
"""Launches the toot terminal user interface"""
|
||||||
if colors is None:
|
if colors is None:
|
||||||
|
@ -39,6 +64,10 @@ def tui(
|
||||||
colors=colors,
|
colors=colors,
|
||||||
media_viewer=media_viewer,
|
media_viewer=media_viewer,
|
||||||
relative_datetimes=relative_datetimes,
|
relative_datetimes=relative_datetimes,
|
||||||
|
cache_size=cache_size,
|
||||||
|
default_visibility=default_visibility,
|
||||||
|
always_show_sensitive=always_show_sensitive,
|
||||||
|
image_format=image_format,
|
||||||
)
|
)
|
||||||
tui = TUI.create(ctx.app, ctx.user, options)
|
tui = TUI.create(ctx.app, ctx.user, options)
|
||||||
tui.run()
|
tui.run()
|
||||||
|
|
|
@ -73,3 +73,21 @@ def validate_tui_colors(ctx, param, value) -> Optional[int]:
|
||||||
return TUI_COLORS[value]
|
return TUI_COLORS[value]
|
||||||
|
|
||||||
raise click.BadParameter(f"Invalid value: {value}. Expected one of: {', '.join(TUI_COLORS)}")
|
raise click.BadParameter(f"Invalid value: {value}. Expected one of: {', '.join(TUI_COLORS)}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_cache_size(ctx: click.Context, param: str, value: Optional[str]) -> Optional[int]:
|
||||||
|
"""validates the cache size parameter"""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return 1024 * 1024 * 10 # default 10MB
|
||||||
|
else:
|
||||||
|
if value.isdigit():
|
||||||
|
size = int(value)
|
||||||
|
else:
|
||||||
|
raise click.BadParameter("Cache size must be numeric.")
|
||||||
|
|
||||||
|
if size > 1024:
|
||||||
|
raise click.BadParameter("Cache size too large: 1024MB maximum.")
|
||||||
|
elif size < 1:
|
||||||
|
raise click.BadParameter("Cache size too small: 1MB minimum.")
|
||||||
|
return size
|
||||||
|
|
|
@ -17,11 +17,11 @@ def get_config_file_path():
|
||||||
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
|
return join(get_config_dir(), TOOT_CONFIG_FILE_NAME)
|
||||||
|
|
||||||
|
|
||||||
def user_id(user):
|
def user_id(user: User):
|
||||||
return "{}@{}".format(user.username, user.instance)
|
return "{}@{}".format(user.username, user.instance)
|
||||||
|
|
||||||
|
|
||||||
def make_config(path):
|
def make_config(path: str):
|
||||||
"""Creates an empty toot configuration file."""
|
"""Creates an empty toot configuration file."""
|
||||||
config = {
|
config = {
|
||||||
"apps": {},
|
"apps": {},
|
||||||
|
@ -58,7 +58,7 @@ def save_config(config):
|
||||||
return json.dump(config, f, indent=True, sort_keys=True)
|
return json.dump(config, f, indent=True, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
def extract_user_app(config, user_id):
|
def extract_user_app(config, user_id: str):
|
||||||
if user_id not in config['users']:
|
if user_id not in config['users']:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ def get_active_user_app():
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
def get_user_app(user_id):
|
def get_user_app(user_id: str):
|
||||||
"""Returns (User, App) for given user ID or (None, None) if user is not logged in."""
|
"""Returns (User, App) for given user ID or (None, None) if user is not logged in."""
|
||||||
return extract_user_app(load_config(), user_id)
|
return extract_user_app(load_config(), user_id)
|
||||||
|
|
||||||
|
@ -93,7 +93,7 @@ def load_app(instance: str) -> Optional[App]:
|
||||||
return App(**config['apps'][instance])
|
return App(**config['apps'][instance])
|
||||||
|
|
||||||
|
|
||||||
def load_user(user_id, throw=False):
|
def load_user(user_id: str, throw=False):
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
if user_id in config['users']:
|
if user_id in config['users']:
|
||||||
|
@ -120,7 +120,7 @@ def save_app(app: App):
|
||||||
config['apps'][app.instance] = app._asdict()
|
config['apps'][app.instance] = app._asdict()
|
||||||
|
|
||||||
|
|
||||||
def delete_app(config, app):
|
def delete_app(config, app: App):
|
||||||
with edit_config() as config:
|
with edit_config() as config:
|
||||||
config['apps'].pop(app.instance, None)
|
config['apps'].pop(app.instance, None)
|
||||||
|
|
||||||
|
|
126
toot/entities.py
126
toot/entities.py
|
@ -9,17 +9,23 @@ different versions of the Mastodon API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import typing as t
|
||||||
|
|
||||||
from dataclasses import dataclass, is_dataclass
|
from dataclasses import dataclass, is_dataclass
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union
|
from typing import Any, Dict, NamedTuple, Optional, Type, TypeVar, Union
|
||||||
from typing import get_type_hints
|
from typing import get_args, get_origin, get_type_hints
|
||||||
|
|
||||||
from toot.typing_compat import get_args, get_origin
|
|
||||||
from toot.utils import get_text
|
from toot.utils import get_text
|
||||||
from toot.utils.datetime import parse_datetime
|
from toot.utils.datetime import parse_datetime
|
||||||
|
|
||||||
|
# Generic data class instance
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
# A dict decoded from JSON
|
||||||
|
Data = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AccountField:
|
class AccountField:
|
||||||
|
@ -59,8 +65,8 @@ class Account:
|
||||||
header: str
|
header: str
|
||||||
header_static: str
|
header_static: str
|
||||||
locked: bool
|
locked: bool
|
||||||
fields: List[AccountField]
|
fields: t.List[AccountField]
|
||||||
emojis: List[CustomEmoji]
|
emojis: t.List[CustomEmoji]
|
||||||
bot: bool
|
bot: bool
|
||||||
group: bool
|
group: bool
|
||||||
discoverable: Optional[bool]
|
discoverable: Optional[bool]
|
||||||
|
@ -76,7 +82,7 @@ class Account:
|
||||||
source: Optional[dict]
|
source: Optional[dict]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __toot_prepare__(obj: Dict) -> Dict:
|
def __toot_prepare__(obj: Data) -> Data:
|
||||||
# Pleroma has not yet converted last_status_at from datetime to date
|
# Pleroma has not yet converted last_status_at from datetime to date
|
||||||
# so trim it here so it doesn't break when converting to date.
|
# so trim it here so it doesn't break when converting to date.
|
||||||
# See: https://git.pleroma.social/pleroma/pleroma/-/issues/1470
|
# See: https://git.pleroma.social/pleroma/pleroma/-/issues/1470
|
||||||
|
@ -154,10 +160,10 @@ class Poll:
|
||||||
multiple: bool
|
multiple: bool
|
||||||
votes_count: int
|
votes_count: int
|
||||||
voters_count: Optional[int]
|
voters_count: Optional[int]
|
||||||
options: List[PollOption]
|
options: t.List[PollOption]
|
||||||
emojis: List[CustomEmoji]
|
emojis: t.List[CustomEmoji]
|
||||||
voted: Optional[bool]
|
voted: Optional[bool]
|
||||||
own_votes: Optional[List[int]]
|
own_votes: Optional[t.List[int]]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -207,11 +213,11 @@ class Filter:
|
||||||
"""
|
"""
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
context: List[str]
|
context: t.List[str]
|
||||||
expires_at: Optional[datetime]
|
expires_at: Optional[datetime]
|
||||||
filter_action: str
|
filter_action: str
|
||||||
keywords: List[FilterKeyword]
|
keywords: t.List[FilterKeyword]
|
||||||
statuses: List[FilterStatus]
|
statuses: t.List[FilterStatus]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -220,7 +226,7 @@ class FilterResult:
|
||||||
https://docs.joinmastodon.org/entities/FilterResult/
|
https://docs.joinmastodon.org/entities/FilterResult/
|
||||||
"""
|
"""
|
||||||
filter: Filter
|
filter: Filter
|
||||||
keyword_matches: Optional[List[str]]
|
keyword_matches: Optional[t.List[str]]
|
||||||
status_matches: Optional[str]
|
status_matches: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
@ -237,11 +243,11 @@ class Status:
|
||||||
visibility: str
|
visibility: str
|
||||||
sensitive: bool
|
sensitive: bool
|
||||||
spoiler_text: str
|
spoiler_text: str
|
||||||
media_attachments: List[MediaAttachment]
|
media_attachments: t.List[MediaAttachment]
|
||||||
application: Optional[Application]
|
application: Optional[Application]
|
||||||
mentions: List[StatusMention]
|
mentions: t.List[StatusMention]
|
||||||
tags: List[StatusTag]
|
tags: t.List[StatusTag]
|
||||||
emojis: List[CustomEmoji]
|
emojis: t.List[CustomEmoji]
|
||||||
reblogs_count: int
|
reblogs_count: int
|
||||||
favourites_count: int
|
favourites_count: int
|
||||||
replies_count: int
|
replies_count: int
|
||||||
|
@ -259,14 +265,14 @@ class Status:
|
||||||
muted: Optional[bool]
|
muted: Optional[bool]
|
||||||
bookmarked: Optional[bool]
|
bookmarked: Optional[bool]
|
||||||
pinned: Optional[bool]
|
pinned: Optional[bool]
|
||||||
filtered: Optional[List[FilterResult]]
|
filtered: Optional[t.List[FilterResult]]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def original(self) -> "Status":
|
def original(self) -> "Status":
|
||||||
return self.reblog or self
|
return self.reblog or self
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __toot_prepare__(obj: Dict) -> Dict:
|
def __toot_prepare__(obj: Data) -> Data:
|
||||||
# Pleroma has a bug where created_at is set to an empty string.
|
# Pleroma has a bug where created_at is set to an empty string.
|
||||||
# To avoid marking created_at as optional, which would require work
|
# To avoid marking created_at as optional, which would require work
|
||||||
# because we count on it always existing, set it to current datetime.
|
# because we count on it always existing, set it to current datetime.
|
||||||
|
@ -289,8 +295,8 @@ class Report:
|
||||||
comment: str
|
comment: str
|
||||||
forwarded: bool
|
forwarded: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
status_ids: Optional[List[str]]
|
status_ids: Optional[t.List[str]]
|
||||||
rule_ids: Optional[List[str]]
|
rule_ids: Optional[t.List[str]]
|
||||||
target_account: Account
|
target_account: Account
|
||||||
|
|
||||||
|
|
||||||
|
@ -328,7 +334,7 @@ class InstanceConfigurationStatuses:
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class InstanceConfigurationMediaAttachments:
|
class InstanceConfigurationMediaAttachments:
|
||||||
supported_mime_types: List[str]
|
supported_mime_types: t.List[str]
|
||||||
image_size_limit: int
|
image_size_limit: int
|
||||||
image_matrix_limit: int
|
image_matrix_limit: int
|
||||||
video_size_limit: int
|
video_size_limit: int
|
||||||
|
@ -377,13 +383,13 @@ class Instance:
|
||||||
urls: InstanceUrls
|
urls: InstanceUrls
|
||||||
stats: InstanceStats
|
stats: InstanceStats
|
||||||
thumbnail: Optional[str]
|
thumbnail: Optional[str]
|
||||||
languages: List[str]
|
languages: t.List[str]
|
||||||
registrations: bool
|
registrations: bool
|
||||||
approval_required: bool
|
approval_required: bool
|
||||||
invites_enabled: bool
|
invites_enabled: bool
|
||||||
configuration: InstanceConfiguration
|
configuration: InstanceConfiguration
|
||||||
contact_account: Optional[Account]
|
contact_account: Optional[Account]
|
||||||
rules: List[Rule]
|
rules: t.List[Rule]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -397,7 +403,7 @@ class Relationship:
|
||||||
following: bool
|
following: bool
|
||||||
showing_reblogs: bool
|
showing_reblogs: bool
|
||||||
notifying: bool
|
notifying: bool
|
||||||
languages: List[str]
|
languages: t.List[str]
|
||||||
followed_by: bool
|
followed_by: bool
|
||||||
blocking: bool
|
blocking: bool
|
||||||
blocked_by: bool
|
blocked_by: bool
|
||||||
|
@ -428,7 +434,7 @@ class Tag:
|
||||||
"""
|
"""
|
||||||
name: str
|
name: str
|
||||||
url: str
|
url: str
|
||||||
history: List[TagHistory]
|
history: t.List[TagHistory]
|
||||||
following: Optional[bool]
|
following: Optional[bool]
|
||||||
|
|
||||||
|
|
||||||
|
@ -445,26 +451,37 @@ class FeaturedTag:
|
||||||
last_status_at: datetime
|
last_status_at: datetime
|
||||||
|
|
||||||
|
|
||||||
# Generic data class instance
|
@dataclass
|
||||||
T = TypeVar("T")
|
class List:
|
||||||
|
"""
|
||||||
|
Represents a list of some users that the authenticated user follows.
|
||||||
|
https://docs.joinmastodon.org/entities/List/
|
||||||
|
"""
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
# This is a required field on Mastodon, but not supported on Pleroma/Akkoma
|
||||||
|
# see: https://git.pleroma.social/pleroma/pleroma/-/issues/2918
|
||||||
|
replies_policy: Optional[str]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class Field(NamedTuple):
|
||||||
|
name: str
|
||||||
|
type: Any
|
||||||
|
default: Any
|
||||||
|
|
||||||
|
|
||||||
class ConversionError(Exception):
|
class ConversionError(Exception):
|
||||||
"""Raised when conversion fails from JSON value to data class field."""
|
"""Raised when conversion fails from JSON value to data class field."""
|
||||||
def __init__(
|
def __init__(self, data_class: type, field: Field, field_value: Optional[str]):
|
||||||
self,
|
|
||||||
data_class: Type,
|
|
||||||
field_name: str,
|
|
||||||
field_type: Type,
|
|
||||||
field_value: Optional[str]
|
|
||||||
):
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"Failed converting field `{data_class.__name__}.{field_name}` "
|
f"Failed converting field `{data_class.__name__}.{field.name}` "
|
||||||
+ f"of type `{field_type.__name__}` from value {field_value!r}"
|
+ f"of type `{field.type.__name__}` from value {field_value!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def from_dict(cls: Type[T], data: Dict) -> T:
|
def from_dict(cls: Type[T], data: Data) -> T:
|
||||||
"""Convert a nested dict into an instance of `cls`."""
|
"""Convert a nested dict into an instance of `cls`."""
|
||||||
# Apply __toot_prepare__ if it exists
|
# Apply __toot_prepare__ if it exists
|
||||||
prepare = getattr(cls, '__toot_prepare__', None)
|
prepare = getattr(cls, '__toot_prepare__', None)
|
||||||
|
@ -472,19 +489,19 @@ def from_dict(cls: Type[T], data: Dict) -> T:
|
||||||
data = prepare(data)
|
data = prepare(data)
|
||||||
|
|
||||||
def _fields():
|
def _fields():
|
||||||
for name, type, default in get_fields(cls):
|
for field in _get_fields(cls):
|
||||||
value = data.get(name, default)
|
value = data.get(field.name, field.default)
|
||||||
converted = _convert_with_error_handling(cls, name, type, value)
|
converted = _convert_with_error_handling(cls, field, value)
|
||||||
yield name, converted
|
yield field.name, converted
|
||||||
|
|
||||||
return cls(**dict(_fields()))
|
return cls(**dict(_fields()))
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=100)
|
@lru_cache
|
||||||
def get_fields(cls: Type) -> List[Tuple[str, Type, Any]]:
|
def _get_fields(cls: type) -> t.List[Field]:
|
||||||
hints = get_type_hints(cls)
|
hints = get_type_hints(cls)
|
||||||
return [
|
return [
|
||||||
(
|
Field(
|
||||||
field.name,
|
field.name,
|
||||||
_prune_optional(hints[field.name]),
|
_prune_optional(hints[field.name]),
|
||||||
_get_default_value(field)
|
_get_default_value(field)
|
||||||
|
@ -493,11 +510,11 @@ def get_fields(cls: Type) -> List[Tuple[str, Type, Any]]:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def from_dict_list(cls: Type[T], data: List[Dict]) -> List[T]:
|
def from_dict_list(cls: Type[T], data: t.List[Data]) -> t.List[T]:
|
||||||
return [from_dict(cls, x) for x in data]
|
return [from_dict(cls, x) for x in data]
|
||||||
|
|
||||||
|
|
||||||
def _get_default_value(field):
|
def _get_default_value(field: dataclasses.Field):
|
||||||
if field.default is not dataclasses.MISSING:
|
if field.default is not dataclasses.MISSING:
|
||||||
return field.default
|
return field.default
|
||||||
|
|
||||||
|
@ -507,21 +524,16 @@ def _get_default_value(field):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _convert_with_error_handling(
|
def _convert_with_error_handling(data_class: type, field: Field, field_value: Any) -> Any:
|
||||||
data_class: Type,
|
|
||||||
field_name: str,
|
|
||||||
field_type: Type,
|
|
||||||
field_value: Optional[str]
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
return _convert(field_type, field_value)
|
return _convert(field.type, field_value)
|
||||||
except ConversionError:
|
except ConversionError:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ConversionError(data_class, field_name, field_type, field_value)
|
raise ConversionError(data_class, field, field_value)
|
||||||
|
|
||||||
|
|
||||||
def _convert(field_type, value):
|
def _convert(field_type: Any, value: Any) -> Any:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -544,7 +556,7 @@ def _convert(field_type, value):
|
||||||
raise ValueError(f"Not implemented for type '{field_type}'")
|
raise ValueError(f"Not implemented for type '{field_type}'")
|
||||||
|
|
||||||
|
|
||||||
def _prune_optional(field_type: Type) -> Type:
|
def _prune_optional(field_type: type) -> type:
|
||||||
"""For `Optional[<type>]` returns the encapsulated `<type>`."""
|
"""For `Optional[<type>]` returns the encapsulated `<type>`."""
|
||||||
if get_origin(field_type) == Union:
|
if get_origin(field_type) == Union:
|
||||||
args = get_args(field_type)
|
args = get_args(field_type)
|
||||||
|
|
18
toot/http.py
18
toot/http.py
|
@ -38,7 +38,7 @@ def _get_error_message(response):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return "Unknown error"
|
return f"Unknown error: {response.status_code} {response.reason}"
|
||||||
|
|
||||||
|
|
||||||
def process_response(response):
|
def process_response(response):
|
||||||
|
@ -81,6 +81,22 @@ def post(app, user, path, headers=None, files=None, data=None, json=None, allow_
|
||||||
return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
|
return anon_post(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
|
||||||
|
|
||||||
|
|
||||||
|
def anon_put(url, headers=None, files=None, data=None, json=None, allow_redirects=True):
|
||||||
|
request = Request(method="PUT", url=url, headers=headers, files=files, data=data, json=json)
|
||||||
|
response = send_request(request, allow_redirects)
|
||||||
|
|
||||||
|
return process_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
def put(app, user, path, headers=None, files=None, data=None, json=None, allow_redirects=True):
|
||||||
|
url = app.base_url + path
|
||||||
|
|
||||||
|
headers = headers or {}
|
||||||
|
headers["Authorization"] = f"Bearer {user.access_token}"
|
||||||
|
|
||||||
|
return anon_put(url, headers=headers, files=files, data=data, json=json, allow_redirects=allow_redirects)
|
||||||
|
|
||||||
|
|
||||||
def patch(app, user, path, headers=None, files=None, data=None, json=None):
|
def patch(app, user, path, headers=None, files=None, data=None, json=None):
|
||||||
url = app.base_url + path
|
url = app.base_url + path
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import click
|
import click
|
||||||
import re
|
import re
|
||||||
import textwrap
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import textwrap
|
||||||
|
import typing as t
|
||||||
|
|
||||||
from toot.entities import Account, Instance, Notification, Poll, Status
|
from toot.entities import Account, Instance, Notification, Poll, Status, List
|
||||||
from toot.utils import get_text, html_to_paragraphs
|
from toot.utils import get_text, html_to_paragraphs
|
||||||
from toot.wcstring import wc_wrap
|
from toot.wcstring import wc_wrap
|
||||||
from typing import Any, Generator, Iterable, List
|
|
||||||
from wcwidth import wcswidth
|
from wcwidth import wcswidth
|
||||||
|
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ def instance_to_text(instance: Instance, width: int) -> str:
|
||||||
return "\n".join(instance_lines(instance, width))
|
return "\n".join(instance_lines(instance, width))
|
||||||
|
|
||||||
|
|
||||||
def instance_lines(instance: Instance, width: int) -> Generator[str, None, None]:
|
def instance_lines(instance: Instance, width: int) -> t.Generator[str, None, None]:
|
||||||
yield f"{green(instance.title)}"
|
yield f"{green(instance.title)}"
|
||||||
yield f"{blue(instance.uri)}"
|
yield f"{blue(instance.uri)}"
|
||||||
yield f"running Mastodon {instance.version}"
|
yield f"running Mastodon {instance.version}"
|
||||||
|
@ -78,7 +78,7 @@ def account_to_text(account: Account, width: int) -> str:
|
||||||
return "\n".join(account_lines(account, width))
|
return "\n".join(account_lines(account, width))
|
||||||
|
|
||||||
|
|
||||||
def account_lines(account: Account, width: int) -> Generator[str, None, None]:
|
def account_lines(account: Account, width: int) -> t.Generator[str, None, None]:
|
||||||
acct = f"@{account.acct}"
|
acct = f"@{account.acct}"
|
||||||
since = account.created_at.strftime("%Y-%m-%d")
|
since = account.created_at.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
@ -119,13 +119,13 @@ def print_tag_list(tags):
|
||||||
click.echo(f"* {format_tag_name(tag)}\t{tag['url']}")
|
click.echo(f"* {format_tag_name(tag)}\t{tag['url']}")
|
||||||
|
|
||||||
|
|
||||||
def print_lists(lists):
|
def print_lists(lists: t.List[List]):
|
||||||
headers = ["ID", "Title", "Replies"]
|
headers = ["ID", "Title", "Replies"]
|
||||||
data = [[lst["id"], lst["title"], lst["replies_policy"]] for lst in lists]
|
data = [[lst.id, lst.title, lst.replies_policy or ""] for lst in lists]
|
||||||
print_table(headers, data)
|
print_table(headers, data)
|
||||||
|
|
||||||
|
|
||||||
def print_table(headers: List[str], data: List[List[str]]):
|
def print_table(headers: t.List[str], data: t.List[t.List[str]]):
|
||||||
widths = [[len(cell) for cell in row] for row in data + [headers]]
|
widths = [[len(cell) for cell in row] for row in data + [headers]]
|
||||||
widths = [max(width) for width in zip(*widths)]
|
widths = [max(width) for width in zip(*widths)]
|
||||||
|
|
||||||
|
@ -178,7 +178,7 @@ def status_to_text(status: Status, width: int) -> str:
|
||||||
return "\n".join(status_lines(status))
|
return "\n".join(status_lines(status))
|
||||||
|
|
||||||
|
|
||||||
def status_lines(status: Status) -> Generator[str, None, None]:
|
def status_lines(status: Status) -> t.Generator[str, None, None]:
|
||||||
width = get_width()
|
width = get_width()
|
||||||
status_id = status.id
|
status_id = status.id
|
||||||
in_reply_to_id = status.in_reply_to_id
|
in_reply_to_id = status.in_reply_to_id
|
||||||
|
@ -219,10 +219,10 @@ def status_lines(status: Status) -> Generator[str, None, None]:
|
||||||
|
|
||||||
reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
|
reply = f"↲ In reply to {yellow(in_reply_to_id)} " if in_reply_to_id else ""
|
||||||
boost = f"↻ {blue(reblogged_by_acct)} boosted " if reblogged_by else ""
|
boost = f"↻ {blue(reblogged_by_acct)} boosted " if reblogged_by else ""
|
||||||
yield f"ID {yellow(status_id)} {reply} {boost}"
|
yield f"ID {yellow(status_id)} Visibility: {status.visibility} {reply} {boost}"
|
||||||
|
|
||||||
|
|
||||||
def html_lines(html: str, width: int) -> Generator[str, None, None]:
|
def html_lines(html: str, width: int) -> t.Generator[str, None, None]:
|
||||||
first = True
|
first = True
|
||||||
for paragraph in html_to_paragraphs(html):
|
for paragraph in html_to_paragraphs(html):
|
||||||
if not first:
|
if not first:
|
||||||
|
@ -233,7 +233,7 @@ def html_lines(html: str, width: int) -> Generator[str, None, None]:
|
||||||
first = False
|
first = False
|
||||||
|
|
||||||
|
|
||||||
def poll_lines(poll: Poll) -> Generator[str, None, None]:
|
def poll_lines(poll: Poll) -> t.Generator[str, None, None]:
|
||||||
for idx, option in enumerate(poll.options):
|
for idx, option in enumerate(poll.options):
|
||||||
perc = (round(100 * option.votes_count / poll.votes_count)
|
perc = (round(100 * option.votes_count / poll.votes_count)
|
||||||
if poll.votes_count and option.votes_count is not None else 0)
|
if poll.votes_count and option.votes_count is not None else 0)
|
||||||
|
@ -258,7 +258,7 @@ def poll_lines(poll: Poll) -> Generator[str, None, None]:
|
||||||
yield poll_footer
|
yield poll_footer
|
||||||
|
|
||||||
|
|
||||||
def print_timeline(items: Iterable[Status]):
|
def print_timeline(items: t.Iterable[Status]):
|
||||||
print_divider()
|
print_divider()
|
||||||
for item in items:
|
for item in items:
|
||||||
print_status(item)
|
print_status(item)
|
||||||
|
@ -272,7 +272,7 @@ def print_notification(notification: Notification):
|
||||||
print_status(notification.status)
|
print_status(notification.status)
|
||||||
|
|
||||||
|
|
||||||
def print_notifications(notifications: List[Notification]):
|
def print_notifications(notifications: t.List[Notification]):
|
||||||
for notification in notifications:
|
for notification in notifications:
|
||||||
if notification.type not in ['pleroma:emoji_reaction']:
|
if notification.type not in ['pleroma:emoji_reaction']:
|
||||||
print_divider()
|
print_divider()
|
||||||
|
@ -316,25 +316,25 @@ def format_account_name(account: Account) -> str:
|
||||||
|
|
||||||
# Shorthand functions for coloring output
|
# Shorthand functions for coloring output
|
||||||
|
|
||||||
def blue(text: Any) -> str:
|
def blue(text: t.Any) -> str:
|
||||||
return click.style(text, fg="blue")
|
return click.style(text, fg="blue")
|
||||||
|
|
||||||
|
|
||||||
def bold(text: Any) -> str:
|
def bold(text: t.Any) -> str:
|
||||||
return click.style(text, bold=True)
|
return click.style(text, bold=True)
|
||||||
|
|
||||||
|
|
||||||
def cyan(text: Any) -> str:
|
def cyan(text: t.Any) -> str:
|
||||||
return click.style(text, fg="cyan")
|
return click.style(text, fg="cyan")
|
||||||
|
|
||||||
|
|
||||||
def dim(text: Any) -> str:
|
def dim(text: t.Any) -> str:
|
||||||
return click.style(text, dim=True)
|
return click.style(text, dim=True)
|
||||||
|
|
||||||
|
|
||||||
def green(text: Any) -> str:
|
def green(text: t.Any) -> str:
|
||||||
return click.style(text, fg="green")
|
return click.style(text, fg="green")
|
||||||
|
|
||||||
|
|
||||||
def yellow(text: Any) -> str:
|
def yellow(text: t.Any) -> str:
|
||||||
return click.style(text, fg="yellow")
|
return click.style(text, fg="yellow")
|
||||||
|
|
134
toot/tui/app.py
134
toot/tui/app.py
|
@ -2,22 +2,27 @@ import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import urwid
|
import urwid
|
||||||
|
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import NamedTuple, Optional
|
from typing import NamedTuple, Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from toot import api, config, __version__, settings
|
from toot import api, config, __version__, settings
|
||||||
from toot import App, User
|
from toot import App, User
|
||||||
from toot.cli import get_default_visibility
|
from toot.cli import get_default_visibility
|
||||||
from toot.exceptions import ApiError
|
from toot.exceptions import ApiError
|
||||||
|
from toot.utils.datetime import parse_datetime
|
||||||
|
|
||||||
from .compose import StatusComposer
|
from .compose import StatusComposer
|
||||||
from .constants import PALETTE
|
from .constants import PALETTE
|
||||||
from .entities import Status
|
from .entities import Status
|
||||||
|
from .images import TuiScreen, load_image
|
||||||
from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
|
from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusLinks, StatusZoom
|
||||||
from .overlays import StatusDeleteConfirmation, Account
|
from .overlays import StatusDeleteConfirmation, Account
|
||||||
from .poll import Poll
|
from .poll import Poll
|
||||||
from .timeline import Timeline
|
from .timeline import Timeline
|
||||||
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard
|
from .utils import get_max_toot_chars, parse_content_links, copy_to_clipboard, LRUCache
|
||||||
|
from .widgets import ModalBox, RoundedLineBox
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -30,7 +35,11 @@ DEFAULT_MAX_TOOT_CHARS = 500
|
||||||
class TuiOptions(NamedTuple):
|
class TuiOptions(NamedTuple):
|
||||||
colors: int
|
colors: int
|
||||||
media_viewer: Optional[str]
|
media_viewer: Optional[str]
|
||||||
|
always_show_sensitive: bool
|
||||||
relative_datetimes: bool
|
relative_datetimes: bool
|
||||||
|
cache_size: int
|
||||||
|
default_visibility: Optional[str]
|
||||||
|
image_format: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
class Header(urwid.WidgetWrap):
|
class Header(urwid.WidgetWrap):
|
||||||
|
@ -90,7 +99,7 @@ class TUI(urwid.Frame):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(app: App, user: User, args: TuiOptions):
|
def create(app: App, user: User, args: TuiOptions):
|
||||||
"""Factory method, sets up TUI and an event loop."""
|
"""Factory method, sets up TUI and an event loop."""
|
||||||
screen = urwid.raw_display.Screen()
|
screen = TuiScreen()
|
||||||
screen.set_terminal_properties(args.colors)
|
screen.set_terminal_properties(args.colors)
|
||||||
|
|
||||||
tui = TUI(app, user, screen, args)
|
tui = TUI(app, user, screen, args)
|
||||||
|
@ -137,11 +146,18 @@ class TUI(urwid.Frame):
|
||||||
self.can_translate = False
|
self.can_translate = False
|
||||||
self.account = None
|
self.account = None
|
||||||
self.followed_accounts = []
|
self.followed_accounts = []
|
||||||
|
self.preferences = {}
|
||||||
|
|
||||||
|
if self.options.cache_size:
|
||||||
|
self.cache_max = 1024 * 1024 * self.options.cache_size
|
||||||
|
else:
|
||||||
|
self.cache_max = 1024 * 1024 * 10 # default 10MB
|
||||||
|
|
||||||
super().__init__(self.body, header=self.header, footer=self.footer)
|
super().__init__(self.body, header=self.header, footer=self.footer)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
self.loop.set_alarm_in(0, lambda *args: self.async_load_instance())
|
self.loop.set_alarm_in(0, lambda *args: self.async_load_instance())
|
||||||
|
self.loop.set_alarm_in(0, lambda *args: self.async_load_preferences())
|
||||||
self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline(
|
self.loop.set_alarm_in(0, lambda *args: self.async_load_timeline(
|
||||||
is_initial=True, timeline_name="home"))
|
is_initial=True, timeline_name="home"))
|
||||||
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_accounts())
|
self.loop.set_alarm_in(0, lambda *args: self.async_load_followed_accounts())
|
||||||
|
@ -320,12 +336,27 @@ class TUI(urwid.Frame):
|
||||||
# get the major version number of the server
|
# get the major version number of the server
|
||||||
# this works for Mastodon and Pleroma version strings
|
# this works for Mastodon and Pleroma version strings
|
||||||
# Mastodon versions < 4 do not have translation service
|
# Mastodon versions < 4 do not have translation service
|
||||||
|
# If the version is missing, assume 0 as a fallback
|
||||||
# Revisit this logic if Pleroma implements translation
|
# Revisit this logic if Pleroma implements translation
|
||||||
ch = instance["version"][0]
|
version = instance["version"]
|
||||||
|
ch = "0" if not version else version[0]
|
||||||
self.can_translate = int(ch) > 3 if ch.isnumeric() else False
|
self.can_translate = int(ch) > 3 if ch.isnumeric() else False
|
||||||
|
|
||||||
return self.run_in_thread(_load_instance, done_callback=_done)
|
return self.run_in_thread(_load_instance, done_callback=_done)
|
||||||
|
|
||||||
|
def async_load_preferences(self):
|
||||||
|
"""
|
||||||
|
Attempt to update user preferences from instance.
|
||||||
|
https://docs.joinmastodon.org/methods/preferences/
|
||||||
|
"""
|
||||||
|
def _load_preferences():
|
||||||
|
return api.get_preferences(self.app, self.user).json()
|
||||||
|
|
||||||
|
def _done(preferences):
|
||||||
|
self.preferences = preferences
|
||||||
|
|
||||||
|
return self.run_in_thread(_load_preferences, done_callback=_done)
|
||||||
|
|
||||||
def async_load_followed_accounts(self):
|
def async_load_followed_accounts(self):
|
||||||
def _load_accounts():
|
def _load_accounts():
|
||||||
try:
|
try:
|
||||||
|
@ -400,11 +431,45 @@ class TUI(urwid.Frame):
|
||||||
def _post(timeline, *args):
|
def _post(timeline, *args):
|
||||||
self.post_status(*args)
|
self.post_status(*args)
|
||||||
|
|
||||||
composer = StatusComposer(self.max_toot_chars, self.user.username, in_reply_to)
|
# If the user specified --default-visibility, use that; otherwise,
|
||||||
|
# try to use the server-side default visibility. If that fails, fall
|
||||||
|
# back to get_default_visibility().
|
||||||
|
visibility = (self.options.default_visibility or
|
||||||
|
self.preferences.get('posting:default:visibility',
|
||||||
|
get_default_visibility()))
|
||||||
|
|
||||||
|
composer = StatusComposer(self.max_toot_chars, self.user.username,
|
||||||
|
visibility, in_reply_to)
|
||||||
urwid.connect_signal(composer, "close", _close)
|
urwid.connect_signal(composer, "close", _close)
|
||||||
urwid.connect_signal(composer, "post", _post)
|
urwid.connect_signal(composer, "post", _post)
|
||||||
self.open_overlay(composer, title="Compose status")
|
self.open_overlay(composer, title="Compose status")
|
||||||
|
|
||||||
|
def async_edit(self, status):
|
||||||
|
def _fetch_source():
|
||||||
|
return api.fetch_status_source(self.app, self.user, status.id).json()
|
||||||
|
|
||||||
|
def _done(source):
|
||||||
|
self.close_overlay()
|
||||||
|
self.show_edit(status, source)
|
||||||
|
|
||||||
|
please_wait = ModalBox("Loading status...")
|
||||||
|
self.open_overlay(please_wait)
|
||||||
|
|
||||||
|
self.run_in_thread(_fetch_source, done_callback=_done)
|
||||||
|
|
||||||
|
def show_edit(self, status, source):
|
||||||
|
def _close(*args):
|
||||||
|
self.close_overlay()
|
||||||
|
|
||||||
|
def _edit(timeline, *args):
|
||||||
|
self.edit_status(status, *args)
|
||||||
|
|
||||||
|
composer = StatusComposer(self.max_toot_chars, self.user.username,
|
||||||
|
visibility=None, edit=status, source=source)
|
||||||
|
urwid.connect_signal(composer, "close", _close)
|
||||||
|
urwid.connect_signal(composer, "post", _edit)
|
||||||
|
self.open_overlay(composer, title="Edit status")
|
||||||
|
|
||||||
def show_goto_menu(self):
|
def show_goto_menu(self):
|
||||||
user_timelines = self.config.get("timelines", {})
|
user_timelines = self.config.get("timelines", {})
|
||||||
user_lists = api.get_lists(self.app, self.user) or []
|
user_lists = api.get_lists(self.app, self.user) or []
|
||||||
|
@ -552,11 +617,47 @@ class TUI(urwid.Frame):
|
||||||
self.footer.set_message("Status posted {} \\o/".format(status.id))
|
self.footer.set_message("Status posted {} \\o/".format(status.id))
|
||||||
self.close_overlay()
|
self.close_overlay()
|
||||||
|
|
||||||
|
def edit_status(self, status, content, warning, visibility, in_reply_to_id):
|
||||||
|
# We don't support editing polls (yet), so to avoid losing the poll
|
||||||
|
# data from the original toot, copy it to the edit request.
|
||||||
|
poll_args = {}
|
||||||
|
poll = status.original.data.get('poll', None)
|
||||||
|
|
||||||
|
if poll is not None:
|
||||||
|
poll_args['poll_options'] = [o['title'] for o in poll['options']]
|
||||||
|
poll_args['poll_multiple'] = poll['multiple']
|
||||||
|
|
||||||
|
# Convert absolute expiry time into seconds from now.
|
||||||
|
expires_at = parse_datetime(poll['expires_at'])
|
||||||
|
expires_in = int((expires_at - datetime.now(timezone.utc)).total_seconds())
|
||||||
|
poll_args['poll_expires_in'] = expires_in
|
||||||
|
|
||||||
|
if 'hide_totals' in poll:
|
||||||
|
poll_args['poll_hide_totals'] = poll['hide_totals']
|
||||||
|
|
||||||
|
data = api.edit_status(
|
||||||
|
self.app,
|
||||||
|
self.user,
|
||||||
|
status.id,
|
||||||
|
content,
|
||||||
|
spoiler_text=warning,
|
||||||
|
visibility=visibility,
|
||||||
|
**poll_args
|
||||||
|
).json()
|
||||||
|
|
||||||
|
new_status = self.make_status(data)
|
||||||
|
|
||||||
|
self.footer.set_message("Status edited {} \\o/".format(status.id))
|
||||||
|
self.close_overlay()
|
||||||
|
|
||||||
|
if self.timeline is not None:
|
||||||
|
self.timeline.update_status(new_status)
|
||||||
|
|
||||||
def show_account(self, account_id):
|
def show_account(self, account_id):
|
||||||
account = api.whois(self.app, self.user, account_id)
|
account = api.whois(self.app, self.user, account_id)
|
||||||
relationship = api.get_relationship(self.app, self.user, account_id)
|
relationship = api.get_relationship(self.app, self.user, account_id)
|
||||||
self.open_overlay(
|
self.open_overlay(
|
||||||
widget=Account(self.app, self.user, account, relationship),
|
widget=Account(self.app, self.user, account, relationship, self.options),
|
||||||
title="Account",
|
title="Account",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -665,6 +766,27 @@ class TUI(urwid.Frame):
|
||||||
|
|
||||||
return self.run_in_thread(_delete, done_callback=_done)
|
return self.run_in_thread(_delete, done_callback=_done)
|
||||||
|
|
||||||
|
def async_load_image(self, timeline, status, path, placeholder_index):
|
||||||
|
def _load():
|
||||||
|
# don't bother loading images for statuses we are not viewing now
|
||||||
|
if timeline.get_focused_status().id != status.id:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not hasattr(timeline, "images"):
|
||||||
|
timeline.images = LRUCache(cache_max_bytes=self.cache_max)
|
||||||
|
|
||||||
|
img = load_image(path)
|
||||||
|
if img:
|
||||||
|
timeline.images[str(hash(path))] = img
|
||||||
|
|
||||||
|
def _done(loop):
|
||||||
|
# don't bother loading images for statuses we are not viewing now
|
||||||
|
if timeline.get_focused_status().id != status.id:
|
||||||
|
return
|
||||||
|
timeline.update_status_image(status, path, placeholder_index)
|
||||||
|
|
||||||
|
return self.run_in_thread(_load, done_callback=_done)
|
||||||
|
|
||||||
def copy_status(self, status):
|
def copy_status(self, status):
|
||||||
# TODO: copy a better version of status content
|
# TODO: copy a better version of status content
|
||||||
# including URLs
|
# including URLs
|
||||||
|
@ -679,7 +801,7 @@ class TUI(urwid.Frame):
|
||||||
)
|
)
|
||||||
|
|
||||||
def open_overlay(self, widget, options={}, title=""):
|
def open_overlay(self, widget, options={}, title=""):
|
||||||
top_widget = urwid.LineBox(widget, title=title)
|
top_widget = RoundedLineBox(widget, title=title)
|
||||||
bottom_widget = self.body
|
bottom_widget = self.body
|
||||||
|
|
||||||
_options = self.default_overlay_options.copy()
|
_options = self.default_overlay_options.copy()
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import urwid
|
import urwid
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from toot.cli import get_default_visibility
|
|
||||||
|
|
||||||
from .constants import VISIBILITY_OPTIONS
|
from .constants import VISIBILITY_OPTIONS
|
||||||
from .widgets import Button, EditBox
|
from .widgets import Button, EditBox
|
||||||
|
|
||||||
|
@ -11,21 +9,22 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class StatusComposer(urwid.Frame):
|
class StatusComposer(urwid.Frame):
|
||||||
"""
|
"""
|
||||||
UI for compose and posting a status message.
|
UI for composing or editing a status message.
|
||||||
|
|
||||||
|
To edit a status, provide the original status in 'edit', and optionally
|
||||||
|
provide the status source (from the /status/:id/source API endpoint) in
|
||||||
|
'source'; this should have at least a 'text' member, and optionally
|
||||||
|
'spoiler_text'. If source is not provided, the formatted HTML will be
|
||||||
|
presented to the user for editing.
|
||||||
"""
|
"""
|
||||||
signals = ["close", "post"]
|
signals = ["close", "post"]
|
||||||
|
|
||||||
def __init__(self, max_chars, username, in_reply_to=None):
|
def __init__(self, max_chars, username, visibility, in_reply_to=None,
|
||||||
|
edit=None, source=None):
|
||||||
self.in_reply_to = in_reply_to
|
self.in_reply_to = in_reply_to
|
||||||
self.max_chars = max_chars
|
self.max_chars = max_chars
|
||||||
self.username = username
|
self.username = username
|
||||||
|
self.edit = edit
|
||||||
text = self.get_initial_text(in_reply_to)
|
|
||||||
self.content_edit = EditBox(
|
|
||||||
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
|
|
||||||
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
|
|
||||||
|
|
||||||
self.char_count = urwid.Text(["0/{}".format(max_chars)])
|
|
||||||
|
|
||||||
self.cw_edit = None
|
self.cw_edit = None
|
||||||
self.cw_add_button = Button("Add content warning",
|
self.cw_add_button = Button("Add content warning",
|
||||||
|
@ -33,13 +32,34 @@ class StatusComposer(urwid.Frame):
|
||||||
self.cw_remove_button = Button("Remove content warning",
|
self.cw_remove_button = Button("Remove content warning",
|
||||||
on_press=self.remove_content_warning)
|
on_press=self.remove_content_warning)
|
||||||
|
|
||||||
self.visibility = (
|
if edit:
|
||||||
in_reply_to.visibility if in_reply_to else get_default_visibility()
|
if source is None:
|
||||||
)
|
text = edit.data["content"]
|
||||||
|
else:
|
||||||
|
text = source.get("text", edit.data["content"])
|
||||||
|
|
||||||
|
if 'spoiler_text' in source:
|
||||||
|
self.cw_edit = EditBox(multiline=True, allow_tab=True,
|
||||||
|
edit_text=source['spoiler_text'])
|
||||||
|
|
||||||
|
self.visibility = edit.data["visibility"]
|
||||||
|
|
||||||
|
else: # not edit
|
||||||
|
text = self.get_initial_text(in_reply_to)
|
||||||
|
self.visibility = (
|
||||||
|
in_reply_to.visibility if in_reply_to else visibility
|
||||||
|
)
|
||||||
|
|
||||||
|
self.content_edit = EditBox(
|
||||||
|
edit_text=text, edit_pos=len(text), multiline=True, allow_tab=True)
|
||||||
|
urwid.connect_signal(self.content_edit.edit, "change", self.text_changed)
|
||||||
|
|
||||||
|
self.char_count = urwid.Text(["0/{}".format(max_chars)])
|
||||||
|
|
||||||
self.visibility_button = Button("Visibility: {}".format(self.visibility),
|
self.visibility_button = Button("Visibility: {}".format(self.visibility),
|
||||||
on_press=self.choose_visibility)
|
on_press=self.choose_visibility)
|
||||||
|
|
||||||
self.post_button = Button("Post", on_press=self.post)
|
self.post_button = Button("Edit" if edit else "Post", on_press=self.post)
|
||||||
self.cancel_button = Button("Cancel", on_press=self.close)
|
self.cancel_button = Button("Cancel", on_press=self.close)
|
||||||
|
|
||||||
contents = list(self.generate_list_items())
|
contents = list(self.generate_list_items())
|
||||||
|
|
|
@ -46,7 +46,7 @@ PALETTE = [
|
||||||
('shortcut_highlight', 'white,bold', '', 'bold'),
|
('shortcut_highlight', 'white,bold', '', 'bold'),
|
||||||
('warning', 'light red', ''),
|
('warning', 'light red', ''),
|
||||||
|
|
||||||
# Visiblity
|
# Visibility
|
||||||
('visibility_public', 'dark gray', ''),
|
('visibility_public', 'dark gray', ''),
|
||||||
('visibility_unlisted', 'white', ''),
|
('visibility_unlisted', 'white', ''),
|
||||||
('visibility_private', 'dark cyan', ''),
|
('visibility_private', 'dark cyan', ''),
|
||||||
|
|
|
@ -53,6 +53,10 @@ class Status:
|
||||||
self.id = self.data["id"]
|
self.id = self.data["id"]
|
||||||
self.account = self._get_account()
|
self.account = self._get_account()
|
||||||
self.created_at = parse_datetime(data["created_at"])
|
self.created_at = parse_datetime(data["created_at"])
|
||||||
|
if data.get("edited_at"):
|
||||||
|
self.edited_at = parse_datetime(data["edited_at"])
|
||||||
|
else:
|
||||||
|
self.edited_at = None
|
||||||
self.author = self._get_author()
|
self.author = self._get_author()
|
||||||
self.favourited = data.get("favourited", False)
|
self.favourited = data.get("favourited", False)
|
||||||
self.reblogged = data.get("reblogged", False)
|
self.reblogged = data.get("reblogged", False)
|
||||||
|
|
|
@ -0,0 +1,116 @@
|
||||||
|
import urwid
|
||||||
|
import math
|
||||||
|
import requests
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
# If term_image is loaded use their screen implementation which handles images
|
||||||
|
try:
|
||||||
|
from term_image.widget import UrwidImageScreen, UrwidImage
|
||||||
|
from term_image.image import BaseImage, KittyImage, ITerm2Image, BlockImage
|
||||||
|
from term_image import disable_queries # prevent phantom keystrokes
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
_IMAGE_PIXEL_FORMATS = frozenset({'kitty', 'iterm'})
|
||||||
|
_ImageCls = None
|
||||||
|
|
||||||
|
TuiScreen = UrwidImageScreen
|
||||||
|
disable_queries()
|
||||||
|
|
||||||
|
def image_support_enabled():
|
||||||
|
return True
|
||||||
|
|
||||||
|
def can_render_pixels(image_format):
|
||||||
|
return image_format in _IMAGE_PIXEL_FORMATS
|
||||||
|
|
||||||
|
def get_base_image(image, image_format, colors) -> BaseImage:
|
||||||
|
# we don't autodetect kitty, iterm; we choose based on option switches
|
||||||
|
|
||||||
|
global _ImageCls
|
||||||
|
|
||||||
|
if not _ImageCls:
|
||||||
|
_ImageCls = (
|
||||||
|
KittyImage
|
||||||
|
if image_format == 'kitty'
|
||||||
|
else ITerm2Image
|
||||||
|
if image_format == 'iterm'
|
||||||
|
else BlockImage
|
||||||
|
)
|
||||||
|
_ImageCls.forced_support = True
|
||||||
|
if colors == 256 and not can_render_pixels(image_format):
|
||||||
|
_ImageCls.set_render_method("INDEXED")
|
||||||
|
|
||||||
|
return _ImageCls(image)
|
||||||
|
|
||||||
|
def resize_image(basewidth: int, baseheight: int, img: Image.Image) -> Image.Image:
|
||||||
|
if baseheight and not basewidth:
|
||||||
|
hpercent = baseheight / float(img.size[1])
|
||||||
|
width = math.ceil(img.size[0] * hpercent)
|
||||||
|
img = img.resize((width, baseheight), Image.Resampling.LANCZOS)
|
||||||
|
elif basewidth and not baseheight:
|
||||||
|
wpercent = (basewidth / float(img.size[0]))
|
||||||
|
hsize = int((float(img.size[1]) * float(wpercent)))
|
||||||
|
img = img.resize((basewidth, hsize), Image.Resampling.LANCZOS)
|
||||||
|
else:
|
||||||
|
img = img.resize((basewidth, baseheight), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
if img.mode != 'P':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
return img
|
||||||
|
|
||||||
|
def add_corners(img, rad):
|
||||||
|
circle = Image.new('L', (rad * 2, rad * 2), 0)
|
||||||
|
draw = ImageDraw.Draw(circle)
|
||||||
|
draw.ellipse((0, 0, rad * 2, rad * 2), fill=255)
|
||||||
|
alpha = Image.new('L', img.size, "white")
|
||||||
|
w, h = img.size
|
||||||
|
alpha.paste(circle.crop((0, 0, rad, rad)), (0, 0))
|
||||||
|
alpha.paste(circle.crop((0, rad, rad, rad * 2)), (0, h - rad))
|
||||||
|
alpha.paste(circle.crop((rad, 0, rad * 2, rad)), (w - rad, 0))
|
||||||
|
alpha.paste(circle.crop((rad, rad, rad * 2, rad * 2)), (w - rad, h - rad))
|
||||||
|
img.putalpha(alpha)
|
||||||
|
return img
|
||||||
|
|
||||||
|
def load_image(url):
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore") # suppress "corrupt exif" output from PIL
|
||||||
|
try:
|
||||||
|
img = Image.open(requests.get(url, stream=True).raw)
|
||||||
|
if img.format == 'PNG' and img.mode != 'RGBA':
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
return img
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def graphics_widget(img, image_format="block", corner_radius=0, colors=16777216) -> urwid.Widget:
|
||||||
|
if not img:
|
||||||
|
return urwid.SolidFill(fill_char=" ")
|
||||||
|
|
||||||
|
if can_render_pixels(image_format) and corner_radius > 0:
|
||||||
|
render_img = add_corners(img, 10)
|
||||||
|
else:
|
||||||
|
render_img = img
|
||||||
|
|
||||||
|
return UrwidImage(get_base_image(render_img, image_format, colors), '<', upscale=True)
|
||||||
|
# "<" means left-justify the image
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
from urwid.raw_display import Screen
|
||||||
|
TuiScreen = Screen
|
||||||
|
|
||||||
|
def image_support_enabled():
|
||||||
|
return False
|
||||||
|
|
||||||
|
def can_render_pixels(image_format: str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_base_image(image, image_format: str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_corners(img, rad):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_image(url):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def graphics_widget(img, image_format="block", corner_radius=0) -> urwid.Widget:
|
||||||
|
return urwid.SolidFill(fill_char=" ")
|
|
@ -5,7 +5,9 @@ import webbrowser
|
||||||
|
|
||||||
from toot import __version__
|
from toot import __version__
|
||||||
from toot import api
|
from toot import api
|
||||||
|
|
||||||
from toot.tui.utils import highlight_keys
|
from toot.tui.utils import highlight_keys
|
||||||
|
from toot.tui.images import image_support_enabled, load_image, graphics_widget
|
||||||
from toot.tui.widgets import Button, EditBox, SelectableText
|
from toot.tui.widgets import Button, EditBox, SelectableText
|
||||||
from toot.tui.richtext import html_to_widgets
|
from toot.tui.richtext import html_to_widgets
|
||||||
|
|
||||||
|
@ -226,6 +228,7 @@ class Help(urwid.Padding):
|
||||||
yield urwid.Text(h(" [N] - Translate status if possible (toggle)"))
|
yield urwid.Text(h(" [N] - Translate status if possible (toggle)"))
|
||||||
yield urwid.Text(h(" [R] - Reply to current status"))
|
yield urwid.Text(h(" [R] - Reply to current status"))
|
||||||
yield urwid.Text(h(" [S] - Show text marked as sensitive"))
|
yield urwid.Text(h(" [S] - Show text marked as sensitive"))
|
||||||
|
yield urwid.Text(h(" [M] - Show status media"))
|
||||||
yield urwid.Text(h(" [T] - Show status thread (replies)"))
|
yield urwid.Text(h(" [T] - Show status thread (replies)"))
|
||||||
yield urwid.Text(h(" [L] - Show the status links"))
|
yield urwid.Text(h(" [L] - Show the status links"))
|
||||||
yield urwid.Text(h(" [U] - Show the status data in JSON as received from the server"))
|
yield urwid.Text(h(" [U] - Show the status data in JSON as received from the server"))
|
||||||
|
@ -241,11 +244,12 @@ class Help(urwid.Padding):
|
||||||
|
|
||||||
class Account(urwid.ListBox):
|
class Account(urwid.ListBox):
|
||||||
"""Shows account data and provides various actions"""
|
"""Shows account data and provides various actions"""
|
||||||
def __init__(self, app, user, account, relationship):
|
def __init__(self, app, user, account, relationship, options):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.user = user
|
self.user = user
|
||||||
self.account = account
|
self.account = account
|
||||||
self.relationship = relationship
|
self.relationship = relationship
|
||||||
|
self.options = options
|
||||||
self.last_action = None
|
self.last_action = None
|
||||||
self.setup_listbox()
|
self.setup_listbox()
|
||||||
|
|
||||||
|
@ -254,6 +258,32 @@ class Account(urwid.ListBox):
|
||||||
walker = urwid.SimpleListWalker(actions)
|
walker = urwid.SimpleListWalker(actions)
|
||||||
super().__init__(walker)
|
super().__init__(walker)
|
||||||
|
|
||||||
|
def account_header(self, account):
|
||||||
|
if image_support_enabled() and account['avatar'] and not account["avatar"].endswith("missing.png"):
|
||||||
|
img = load_image(account['avatar'])
|
||||||
|
aimg = urwid.BoxAdapter(
|
||||||
|
graphics_widget(img, image_format=self.options.image_format, corner_radius=10,
|
||||||
|
colors=self.options.colors), 10)
|
||||||
|
else:
|
||||||
|
aimg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
|
||||||
|
|
||||||
|
if image_support_enabled() and account['header'] and not account["header"].endswith("missing.png"):
|
||||||
|
img = load_image(account['header'])
|
||||||
|
|
||||||
|
himg = (urwid.BoxAdapter(
|
||||||
|
graphics_widget(img, image_format=self.options.image_format, corner_radius=10,
|
||||||
|
colors=self.options.colors), 10))
|
||||||
|
else:
|
||||||
|
himg = urwid.BoxAdapter(urwid.SolidFill(" "), 10)
|
||||||
|
|
||||||
|
atxt = urwid.Pile([urwid.Divider(),
|
||||||
|
(urwid.Text(("account", account["display_name"]))),
|
||||||
|
(urwid.Text(("highlight", "@" + self.account['acct'])))])
|
||||||
|
columns = urwid.Columns([aimg, ("weight", 9999, himg)], dividechars=2, min_width=20)
|
||||||
|
|
||||||
|
header = urwid.Pile([columns, urwid.Divider(), atxt])
|
||||||
|
return header
|
||||||
|
|
||||||
def generate_contents(self, account, relationship=None, last_action=None):
|
def generate_contents(self, account, relationship=None, last_action=None):
|
||||||
if self.last_action and not self.last_action.startswith("Confirm"):
|
if self.last_action and not self.last_action.startswith("Confirm"):
|
||||||
yield Button(f"Confirm {self.last_action}", on_press=take_action, user_data=self)
|
yield Button(f"Confirm {self.last_action}", on_press=take_action, user_data=self)
|
||||||
|
@ -275,11 +305,11 @@ class Account(urwid.ListBox):
|
||||||
|
|
||||||
yield urwid.Divider("─")
|
yield urwid.Divider("─")
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
yield urwid.Text([("account", f"@{account['acct']}"), f" {account['display_name']}"])
|
|
||||||
|
yield self.account_header(account)
|
||||||
|
|
||||||
if account["note"]:
|
if account["note"]:
|
||||||
yield urwid.Divider()
|
yield urwid.Divider()
|
||||||
|
|
||||||
widgetlist = html_to_widgets(account["note"])
|
widgetlist = html_to_widgets(account["note"])
|
||||||
for line in widgetlist:
|
for line in widgetlist:
|
||||||
yield (line)
|
yield (line)
|
||||||
|
|
|
@ -3,7 +3,7 @@ import urwid
|
||||||
from toot import api
|
from toot import api
|
||||||
from toot.exceptions import ApiError
|
from toot.exceptions import ApiError
|
||||||
from toot.utils.datetime import parse_datetime
|
from toot.utils.datetime import parse_datetime
|
||||||
from .widgets import Button, CheckBox, RadioButton
|
from .widgets import Button, CheckBox, RadioButton, RoundedLineBox
|
||||||
from .richtext import html_to_widgets
|
from .richtext import html_to_widgets
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ class Poll(urwid.ListBox):
|
||||||
def build_linebox(self, contents):
|
def build_linebox(self, contents):
|
||||||
contents = urwid.Pile(list(contents))
|
contents = urwid.Pile(list(contents))
|
||||||
contents = urwid.Padding(contents, left=1, right=1)
|
contents = urwid.Padding(contents, left=1, right=1)
|
||||||
return urwid.LineBox(contents)
|
return RoundedLineBox(contents)
|
||||||
|
|
||||||
def vote(self, button_widget):
|
def vote(self, button_widget):
|
||||||
poll = self.status.original.data.get("poll")
|
poll = self.status.original.data.get("poll")
|
||||||
|
|
|
@ -60,7 +60,10 @@ def html_to_widgets(html, recovery_attempt=False) -> List[urwid.Widget]:
|
||||||
|
|
||||||
|
|
||||||
def url_to_widget(url: str):
|
def url_to_widget(url: str):
|
||||||
widget = len(url), urwid.Filler(Hyperlink(url, "link", url))
|
try:
|
||||||
|
widget = len(url), urwid.Filler(Hyperlink(url, "link", url))
|
||||||
|
except ValueError:
|
||||||
|
widget = len(url), urwid.Filler(urwid.Text(url)) # don't style as link
|
||||||
return TextEmbed(widget)
|
return TextEmbed(widget)
|
||||||
|
|
||||||
|
|
||||||
|
@ -98,10 +101,16 @@ def text_to_widget(attr, markup) -> urwid.Widget:
|
||||||
if match:
|
if match:
|
||||||
label, url = match.groups()
|
label, url = match.groups()
|
||||||
anchor_attr = get_best_anchor_attr(attr_list)
|
anchor_attr = get_best_anchor_attr(attr_list)
|
||||||
markup_list.append((
|
try:
|
||||||
len(label),
|
markup_list.append((
|
||||||
urwid.Filler(Hyperlink(url, anchor_attr, label)),
|
len(label),
|
||||||
))
|
urwid.Filler(Hyperlink(url, anchor_attr, label)),
|
||||||
|
))
|
||||||
|
except ValueError:
|
||||||
|
markup_list.append((
|
||||||
|
len(label),
|
||||||
|
urwid.Filler(urwid.Text(url)), # don't style as link
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
markup_list.append(run)
|
markup_list.append(run)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
# scroll.py
|
|
||||||
#
|
|
||||||
# Copied from the stig project by rndusr@github
|
|
||||||
# https://github.com/rndusr/stig
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU General Public License as published by
|
# it under the terms of the GNU General Public License as published by
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
@ -36,12 +31,12 @@ class Scrollable(urwid.WidgetDecoration):
|
||||||
def selectable(self):
|
def selectable(self):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def __init__(self, widget):
|
def __init__(self, widget, force_forward_keypress = False):
|
||||||
"""Box widget that makes a fixed or flow widget vertically scrollable
|
"""Box widget that makes a fixed or flow widget vertically scrollable
|
||||||
|
|
||||||
TODO: Focusable widgets are handled, including switching focus, but
|
TODO: Focusable widgets are handled, including switching focus, but
|
||||||
possibly not intuitively, depending on the arrangement of widgets. When
|
possibly not intuitively, depending on the arrangement of widgets. When
|
||||||
switching focus to a widget that is outside of the visible part of the
|
switching focus to a widget that is ouside of the visible part of the
|
||||||
original widget, the canvas scrolls up/down to the focused widget. It
|
original widget, the canvas scrolls up/down to the focused widget. It
|
||||||
would be better to scroll until the next focusable widget is in sight
|
would be better to scroll until the next focusable widget is in sight
|
||||||
first. But for that to work we must somehow obtain a list of focusable
|
first. But for that to work we must somehow obtain a list of focusable
|
||||||
|
@ -54,6 +49,7 @@ class Scrollable(urwid.WidgetDecoration):
|
||||||
self._forward_keypress = None
|
self._forward_keypress = None
|
||||||
self._old_cursor_coords = None
|
self._old_cursor_coords = None
|
||||||
self._rows_max_cached = 0
|
self._rows_max_cached = 0
|
||||||
|
self.force_forward_keypress = force_forward_keypress
|
||||||
self.__super.__init__(widget)
|
self.__super.__init__(widget)
|
||||||
|
|
||||||
def render(self, size, focus=False):
|
def render(self, size, focus=False):
|
||||||
|
@ -111,6 +107,51 @@ class Scrollable(urwid.WidgetDecoration):
|
||||||
if canv_full.cursor is not None:
|
if canv_full.cursor is not None:
|
||||||
# Full canvas contains the cursor, but scrolled out of view
|
# Full canvas contains the cursor, but scrolled out of view
|
||||||
self._forward_keypress = False
|
self._forward_keypress = False
|
||||||
|
|
||||||
|
# Reset cursor position on page/up down scrolling
|
||||||
|
try:
|
||||||
|
if hasattr(ow, "automove_cursor_on_scroll") and ow.automove_cursor_on_scroll:
|
||||||
|
pwi = 0
|
||||||
|
ch = 0
|
||||||
|
last_hidden = False
|
||||||
|
first_visible = False
|
||||||
|
for w,o in ow.contents:
|
||||||
|
wcanv = w.render((maxcol,))
|
||||||
|
wh = wcanv.rows()
|
||||||
|
if wh:
|
||||||
|
ch += wh
|
||||||
|
|
||||||
|
if not last_hidden and ch >= self._trim_top:
|
||||||
|
last_hidden = True
|
||||||
|
|
||||||
|
elif last_hidden:
|
||||||
|
if not first_visible:
|
||||||
|
first_visible = True
|
||||||
|
|
||||||
|
if w.selectable():
|
||||||
|
ow.focus_item = pwi
|
||||||
|
|
||||||
|
st = None
|
||||||
|
nf = ow.get_focus()
|
||||||
|
if hasattr(nf, "key_timeout"):
|
||||||
|
st = nf
|
||||||
|
elif hasattr(nf, "original_widget"):
|
||||||
|
no = nf.original_widget
|
||||||
|
if hasattr(no, "original_widget"):
|
||||||
|
st = no.original_widget
|
||||||
|
else:
|
||||||
|
if hasattr(no, "key_timeout"):
|
||||||
|
st = no
|
||||||
|
|
||||||
|
if st and hasattr(st, "key_timeout") and hasattr(st, "keypress") and callable(st.keypress):
|
||||||
|
st.keypress(None, None)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
pwi += 1
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Original widget does not have a cursor, but may be selectable
|
# Original widget does not have a cursor, but may be selectable
|
||||||
|
|
||||||
|
@ -132,7 +173,7 @@ class Scrollable(urwid.WidgetDecoration):
|
||||||
|
|
||||||
def keypress(self, size, key):
|
def keypress(self, size, key):
|
||||||
# Maybe offer key to original widget
|
# Maybe offer key to original widget
|
||||||
if self._forward_keypress:
|
if self._forward_keypress or self.force_forward_keypress:
|
||||||
ow = self._original_widget
|
ow = self._original_widget
|
||||||
ow_size = self._get_original_widget_size(size)
|
ow_size = self._get_original_widget_size(size)
|
||||||
|
|
||||||
|
@ -216,7 +257,7 @@ class Scrollable(urwid.WidgetDecoration):
|
||||||
# If the cursor was moved by the most recent keypress, adjust trim_top
|
# If the cursor was moved by the most recent keypress, adjust trim_top
|
||||||
# so that the new cursor position is within the displayed canvas part.
|
# so that the new cursor position is within the displayed canvas part.
|
||||||
# But don't do this if the cursor is at the top/bottom edge so we can still scroll out
|
# But don't do this if the cursor is at the top/bottom edge so we can still scroll out
|
||||||
if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor:
|
if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor != None:
|
||||||
self._old_cursor_coords = None
|
self._old_cursor_coords = None
|
||||||
curscol, cursrow = canv.cursor
|
curscol, cursrow = canv.cursor
|
||||||
if cursrow < self._trim_top:
|
if cursrow < self._trim_top:
|
||||||
|
@ -227,10 +268,10 @@ class Scrollable(urwid.WidgetDecoration):
|
||||||
def _get_original_widget_size(self, size):
|
def _get_original_widget_size(self, size):
|
||||||
ow = self._original_widget
|
ow = self._original_widget
|
||||||
sizing = ow.sizing()
|
sizing = ow.sizing()
|
||||||
if FIXED in sizing:
|
if FLOW in sizing:
|
||||||
return ()
|
|
||||||
elif FLOW in sizing:
|
|
||||||
return (size[0],)
|
return (size[0],)
|
||||||
|
elif FIXED in sizing:
|
||||||
|
return ()
|
||||||
|
|
||||||
def get_scrollpos(self, size=None, focus=False):
|
def get_scrollpos(self, size=None, focus=False):
|
||||||
"""Current scrolling position
|
"""Current scrolling position
|
||||||
|
@ -416,7 +457,10 @@ class ScrollBar(urwid.WidgetDecoration):
|
||||||
if not handled and hasattr(ow, 'set_scrollpos'):
|
if not handled and hasattr(ow, 'set_scrollpos'):
|
||||||
if button == 4: # scroll wheel up
|
if button == 4: # scroll wheel up
|
||||||
pos = ow.get_scrollpos(ow_size)
|
pos = ow.get_scrollpos(ow_size)
|
||||||
ow.set_scrollpos(pos - 1)
|
newpos = pos - 1
|
||||||
|
if newpos < 0:
|
||||||
|
newpos = 0
|
||||||
|
ow.set_scrollpos(newpos)
|
||||||
return True
|
return True
|
||||||
elif button == 5: # scroll wheel down
|
elif button == 5: # scroll wheel down
|
||||||
pos = ow.get_scrollpos(ow_size)
|
pos = ow.get_scrollpos(ow_size)
|
||||||
|
|
|
@ -1,26 +1,33 @@
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import urwid
|
import urwid
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from toot.tui import app
|
from toot.tui import app
|
||||||
|
|
||||||
from toot.tui.richtext import html_to_widgets, url_to_widget
|
from toot.tui.richtext import html_to_widgets, url_to_widget
|
||||||
from toot.utils.datetime import parse_datetime, time_ago
|
from toot.utils.datetime import parse_datetime, time_ago
|
||||||
from toot.utils.language import language_name
|
from toot.utils.language import language_name
|
||||||
|
|
||||||
from toot.entities import Status
|
from toot.entities import Status
|
||||||
from toot.tui.scroll import Scrollable, ScrollBar
|
from toot.tui.scroll import Scrollable, ScrollBar
|
||||||
|
|
||||||
from toot.tui.utils import highlight_keys
|
from toot.tui.utils import highlight_keys
|
||||||
from toot.tui.widgets import SelectableText, SelectableColumns
|
from toot.tui.images import image_support_enabled, graphics_widget, can_render_pixels
|
||||||
|
from toot.tui.widgets import SelectableText, SelectableColumns, RoundedLineBox
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("toot")
|
logger = logging.getLogger("toot")
|
||||||
|
screen = urwid.raw_display.Screen()
|
||||||
|
|
||||||
|
|
||||||
class Timeline(urwid.Columns):
|
class Timeline(urwid.Columns):
|
||||||
"""
|
"""
|
||||||
Displays a list of statuses to the left, and status details on the right.
|
Displays a list of statuses to the left, and status details on the right.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
signals = [
|
signals = [
|
||||||
"close", # Close thread
|
"close", # Close thread
|
||||||
"focus", # Focus changed
|
"focus", # Focus changed
|
||||||
|
@ -41,6 +48,7 @@ class Timeline(urwid.Columns):
|
||||||
self.is_thread = is_thread
|
self.is_thread = is_thread
|
||||||
self.statuses = statuses
|
self.statuses = statuses
|
||||||
self.status_list = self.build_status_list(statuses, focus=focus)
|
self.status_list = self.build_status_list(statuses, focus=focus)
|
||||||
|
self.can_render_pixels = can_render_pixels(self.tui.options.image_format)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
focused_status = statuses[focus]
|
focused_status = statuses[focus]
|
||||||
|
@ -85,7 +93,7 @@ class Timeline(urwid.Columns):
|
||||||
return urwid.AttrMap(item, None, focus_map={
|
return urwid.AttrMap(item, None, focus_map={
|
||||||
"status_list_account": "status_list_selected",
|
"status_list_account": "status_list_selected",
|
||||||
"status_list_timestamp": "status_list_selected",
|
"status_list_timestamp": "status_list_selected",
|
||||||
"highligh": "status_list_selected",
|
"highlight": "status_list_selected",
|
||||||
"dim": "status_list_selected",
|
"dim": "status_list_selected",
|
||||||
None: "status_list_selected",
|
None: "status_list_selected",
|
||||||
})
|
})
|
||||||
|
@ -101,6 +109,7 @@ class Timeline(urwid.Columns):
|
||||||
"[A]ccount" if not status.is_mine else "",
|
"[A]ccount" if not status.is_mine else "",
|
||||||
"[B]oost",
|
"[B]oost",
|
||||||
"[D]elete" if status.is_mine else "",
|
"[D]elete" if status.is_mine else "",
|
||||||
|
"[E]dit" if status.is_mine else "",
|
||||||
"B[o]okmark",
|
"B[o]okmark",
|
||||||
"[F]avourite",
|
"[F]avourite",
|
||||||
"[V]iew",
|
"[V]iew",
|
||||||
|
@ -140,6 +149,16 @@ class Timeline(urwid.Columns):
|
||||||
def modified(self):
|
def modified(self):
|
||||||
"""Called when the list focus switches to a new status"""
|
"""Called when the list focus switches to a new status"""
|
||||||
status, index, count = self.get_focused_status_with_counts()
|
status, index, count = self.get_focused_status_with_counts()
|
||||||
|
|
||||||
|
if image_support_enabled:
|
||||||
|
clear_op = getattr(self.tui.screen, "clear_images", None)
|
||||||
|
# term-image's screen implementation has clear_images(),
|
||||||
|
# urwid's implementation does not.
|
||||||
|
# TODO: it would be nice not to check this each time thru
|
||||||
|
|
||||||
|
if callable(clear_op):
|
||||||
|
self.tui.screen.clear_images()
|
||||||
|
|
||||||
self.draw_status_details(status)
|
self.draw_status_details(status)
|
||||||
self._emit("focus")
|
self._emit("focus")
|
||||||
|
|
||||||
|
@ -189,6 +208,11 @@ class Timeline(urwid.Columns):
|
||||||
self.tui.show_delete_confirmation(status)
|
self.tui.show_delete_confirmation(status)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if key in ("e", "E"):
|
||||||
|
if status.is_mine:
|
||||||
|
self.tui.async_edit(status)
|
||||||
|
return
|
||||||
|
|
||||||
if key in ("f", "F"):
|
if key in ("f", "F"):
|
||||||
self.tui.async_toggle_favourite(self, status)
|
self.tui.async_toggle_favourite(self, status)
|
||||||
return
|
return
|
||||||
|
@ -276,7 +300,7 @@ class Timeline(urwid.Columns):
|
||||||
|
|
||||||
def get_status_index(self, id):
|
def get_status_index(self, id):
|
||||||
# TODO: This is suboptimal, consider a better way
|
# TODO: This is suboptimal, consider a better way
|
||||||
for n, status in enumerate(self.statuses):
|
for n, status in enumerate(self.statuses.copy()):
|
||||||
if status.id == id:
|
if status.id == id:
|
||||||
return n
|
return n
|
||||||
raise ValueError("Status with ID {} not found".format(id))
|
raise ValueError("Status with ID {} not found".format(id))
|
||||||
|
@ -300,6 +324,28 @@ class Timeline(urwid.Columns):
|
||||||
if index == self.status_list.body.focus:
|
if index == self.status_list.body.focus:
|
||||||
self.draw_status_details(status)
|
self.draw_status_details(status)
|
||||||
|
|
||||||
|
def update_status_image(self, status, path, placeholder_index):
|
||||||
|
"""Replace image placeholder with image widget and redraw"""
|
||||||
|
index = self.get_status_index(status.id)
|
||||||
|
assert self.statuses[index].id == status.id # Sanity check
|
||||||
|
|
||||||
|
# get the image and replace the placeholder with a graphics widget
|
||||||
|
img = None
|
||||||
|
if hasattr(self, "images"):
|
||||||
|
try:
|
||||||
|
img = self.images[(str(hash(path)))]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
if img:
|
||||||
|
try:
|
||||||
|
status.placeholders[placeholder_index]._set_original_widget(
|
||||||
|
graphics_widget(img, image_format=self.tui.options.image_format, corner_radius=10,
|
||||||
|
colors=self.tui.options.colors))
|
||||||
|
|
||||||
|
except IndexError:
|
||||||
|
# ignore IndexErrors.
|
||||||
|
pass
|
||||||
|
|
||||||
def remove_status(self, status):
|
def remove_status(self, status):
|
||||||
index = self.get_status_index(status.id)
|
index = self.get_status_index(status.id)
|
||||||
assert self.statuses[index].id == status.id # Sanity check
|
assert self.statuses[index].id == status.id # Sanity check
|
||||||
|
@ -312,24 +358,95 @@ class Timeline(urwid.Columns):
|
||||||
class StatusDetails(urwid.Pile):
|
class StatusDetails(urwid.Pile):
|
||||||
def __init__(self, timeline: Timeline, status: Optional[Status]):
|
def __init__(self, timeline: Timeline, status: Optional[Status]):
|
||||||
self.status = status
|
self.status = status
|
||||||
|
self.timeline = timeline
|
||||||
|
if self.status:
|
||||||
|
self.status.placeholders = []
|
||||||
self.followed_accounts = timeline.tui.followed_accounts
|
self.followed_accounts = timeline.tui.followed_accounts
|
||||||
|
self.options = timeline.tui.options
|
||||||
|
|
||||||
reblogged_by = status.author if status and status.reblog else None
|
reblogged_by = status.author if status and status.reblog else None
|
||||||
widget_list = list(self.content_generator(status.original, reblogged_by)
|
widget_list = list(self.content_generator(status.original, reblogged_by)
|
||||||
if status else ())
|
if status else ())
|
||||||
return super().__init__(widget_list)
|
return super().__init__(widget_list)
|
||||||
|
|
||||||
|
def image_widget(self, path, rows=None, aspect=None) -> urwid.Widget:
|
||||||
|
"""Returns a widget capable of displaying the image
|
||||||
|
|
||||||
|
path is required; URL to image
|
||||||
|
rows, if specfied, sets a fixed number of rows. Or:
|
||||||
|
aspect, if specified, calculates rows based on pane width
|
||||||
|
and the aspect ratio provided"""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
if not aspect:
|
||||||
|
aspect = 3 / 2 # reasonable default
|
||||||
|
|
||||||
|
screen_rows = screen.get_cols_rows()[1]
|
||||||
|
if self.timeline.can_render_pixels:
|
||||||
|
# for pixel-rendered images,
|
||||||
|
# image rows should be 33% of the available screen
|
||||||
|
# but in no case fewer than 10
|
||||||
|
rows = max(10, math.floor(screen_rows * .33))
|
||||||
|
else:
|
||||||
|
# for cell-rendered images,
|
||||||
|
# use the max available columns
|
||||||
|
# and calculate rows based on the image
|
||||||
|
# aspect ratio
|
||||||
|
cols = math.floor(0.55 * screen.get_cols_rows()[0])
|
||||||
|
rows = math.ceil((cols / 2) / aspect)
|
||||||
|
# if the calculated rows are more than will
|
||||||
|
# fit on one screen, reduce to one screen of rows
|
||||||
|
rows = min(screen_rows - 6, rows)
|
||||||
|
|
||||||
|
# but in no case fewer than 10 rows
|
||||||
|
rows = max(rows, 10)
|
||||||
|
|
||||||
|
img = None
|
||||||
|
if hasattr(self.timeline, "images"):
|
||||||
|
try:
|
||||||
|
img = self.timeline.images[(str(hash(path)))]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
if img:
|
||||||
|
return (urwid.BoxAdapter(
|
||||||
|
graphics_widget(img, image_format=self.timeline.tui.options.image_format, corner_radius=10,
|
||||||
|
colors=self.timeline.tui.options.colors), rows))
|
||||||
|
else:
|
||||||
|
placeholder = urwid.BoxAdapter(urwid.SolidFill(fill_char=" "), rows)
|
||||||
|
self.status.placeholders.append(placeholder)
|
||||||
|
if image_support_enabled():
|
||||||
|
self.timeline.tui.async_load_image(self.timeline, self.status, path, len(self.status.placeholders) - 1)
|
||||||
|
return placeholder
|
||||||
|
|
||||||
|
def author_header(self, reblogged_by):
|
||||||
|
avatar_url = self.status.original.data["account"]["avatar"]
|
||||||
|
|
||||||
|
if avatar_url and image_support_enabled():
|
||||||
|
aimg = self.image_widget(avatar_url, 2)
|
||||||
|
|
||||||
|
account_color = ("highlight" if self.status.original.author.account in
|
||||||
|
self.timeline.tui.followed_accounts else "account")
|
||||||
|
|
||||||
|
atxt = urwid.Pile([("pack", urwid.Text(("bold", self.status.original.author.display_name))),
|
||||||
|
("pack", urwid.Text((account_color, self.status.original.author.account)))])
|
||||||
|
|
||||||
|
if image_support_enabled():
|
||||||
|
columns = urwid.Columns([aimg, ("weight", 9999, atxt)], dividechars=1, min_width=5)
|
||||||
|
else:
|
||||||
|
columns = urwid.Columns([("weight", 9999, atxt)], dividechars=1, min_width=5)
|
||||||
|
|
||||||
|
return columns
|
||||||
|
|
||||||
def content_generator(self, status, reblogged_by):
|
def content_generator(self, status, reblogged_by):
|
||||||
if reblogged_by:
|
if reblogged_by:
|
||||||
text = "♺ {} boosted".format(reblogged_by.display_name or reblogged_by.username)
|
reblogger_name = (reblogged_by.display_name
|
||||||
yield ("pack", urwid.Text(("dim", text)))
|
if reblogged_by.display_name
|
||||||
|
else reblogged_by.username)
|
||||||
|
text = f"♺ {reblogger_name} boosted"
|
||||||
|
yield urwid.Text(("dim", text))
|
||||||
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
|
yield ("pack", urwid.AttrMap(urwid.Divider("-"), "dim"))
|
||||||
|
|
||||||
if status.author.display_name:
|
yield self.author_header(reblogged_by)
|
||||||
yield ("pack", urwid.Text(("bold", status.author.display_name)))
|
|
||||||
|
|
||||||
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())
|
yield ("pack", urwid.Divider())
|
||||||
|
|
||||||
if status.data["spoiler_text"]:
|
if status.data["spoiler_text"]:
|
||||||
|
@ -337,9 +454,12 @@ class StatusDetails(urwid.Pile):
|
||||||
yield ("pack", urwid.Divider())
|
yield ("pack", urwid.Divider())
|
||||||
|
|
||||||
# Show content warning
|
# Show content warning
|
||||||
if status.data["spoiler_text"] and not status.show_sensitive:
|
if status.data["spoiler_text"] and not status.show_sensitive and not self.options.always_show_sensitive:
|
||||||
yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view.")))
|
yield ("pack", urwid.Text(("content_warning", "Marked as sensitive. Press S to view.")))
|
||||||
else:
|
else:
|
||||||
|
if status.data["spoiler_text"]:
|
||||||
|
yield ("pack", urwid.Text(("content_warning", "Marked as sensitive.")))
|
||||||
|
|
||||||
content = status.original.translation if status.original.show_translation else status.data["content"]
|
content = status.original.translation if status.original.show_translation else status.data["content"]
|
||||||
widgetlist = html_to_widgets(content)
|
widgetlist = html_to_widgets(content)
|
||||||
|
|
||||||
|
@ -353,7 +473,27 @@ class StatusDetails(urwid.Pile):
|
||||||
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
|
yield ("pack", urwid.Text([("bold", "Media attachment"), " (", m["type"], ")"]))
|
||||||
if m["description"]:
|
if m["description"]:
|
||||||
yield ("pack", urwid.Text(m["description"]))
|
yield ("pack", urwid.Text(m["description"]))
|
||||||
yield ("pack", url_to_widget(m["url"]))
|
if m["url"]:
|
||||||
|
if m["url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
|
||||||
|
yield urwid.Text("")
|
||||||
|
try:
|
||||||
|
aspect = float(m["meta"]["original"]["aspect"])
|
||||||
|
except Exception:
|
||||||
|
aspect = None
|
||||||
|
if image_support_enabled():
|
||||||
|
yield self.image_widget(m["url"], aspect=aspect)
|
||||||
|
yield urwid.Divider()
|
||||||
|
# video media may include a preview URL, show that as a fallback
|
||||||
|
elif m["preview_url"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
|
||||||
|
yield urwid.Text("")
|
||||||
|
try:
|
||||||
|
aspect = float(m["meta"]["small"]["aspect"])
|
||||||
|
except Exception:
|
||||||
|
aspect = None
|
||||||
|
if image_support_enabled():
|
||||||
|
yield self.image_widget(m["preview_url"], aspect=aspect)
|
||||||
|
yield urwid.Divider()
|
||||||
|
yield ("pack", url_to_widget(m["url"]))
|
||||||
|
|
||||||
poll = status.original.data.get("poll")
|
poll = status.original.data.get("poll")
|
||||||
if poll:
|
if poll:
|
||||||
|
@ -388,6 +528,8 @@ class StatusDetails(urwid.Pile):
|
||||||
|
|
||||||
yield ("pack", urwid.Text([
|
yield ("pack", urwid.Text([
|
||||||
("status_detail_timestamp", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
|
("status_detail_timestamp", f"{status.created_at.strftime('%Y-%m-%d %H:%M')} "),
|
||||||
|
("status_detail_timestamp",
|
||||||
|
f"(edited {status.edited_at.strftime('%Y-%m-%d %H:%M')}) " if status.edited_at else ""),
|
||||||
("status_detail_bookmarked" if status.bookmarked else "dim", "b "),
|
("status_detail_bookmarked" if status.bookmarked else "dim", "b "),
|
||||||
("dim", f"⤶ {status.data['replies_count']} "),
|
("dim", f"⤶ {status.data['replies_count']} "),
|
||||||
("highlight" if status.reblogged else "dim", f"♺ {status.data['reblogs_count']} "),
|
("highlight" if status.reblogged else "dim", f"♺ {status.data['reblogs_count']} "),
|
||||||
|
@ -403,7 +545,7 @@ class StatusDetails(urwid.Pile):
|
||||||
def build_linebox(self, contents):
|
def build_linebox(self, contents):
|
||||||
contents = urwid.Pile(list(contents))
|
contents = urwid.Pile(list(contents))
|
||||||
contents = urwid.Padding(contents, left=1, right=1)
|
contents = urwid.Padding(contents, left=1, right=1)
|
||||||
return urwid.LineBox(contents)
|
return RoundedLineBox(contents)
|
||||||
|
|
||||||
def card_generator(self, card):
|
def card_generator(self, card):
|
||||||
yield urwid.Text(("card_title", card["title"].strip()))
|
yield urwid.Text(("card_title", card["title"].strip()))
|
||||||
|
@ -415,6 +557,15 @@ class StatusDetails(urwid.Pile):
|
||||||
yield urwid.Text("")
|
yield urwid.Text("")
|
||||||
yield url_to_widget(card["url"])
|
yield url_to_widget(card["url"])
|
||||||
|
|
||||||
|
if card["image"] and image_support_enabled():
|
||||||
|
if card["image"].lower().endswith(('.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp')):
|
||||||
|
yield urwid.Text("")
|
||||||
|
try:
|
||||||
|
aspect = int(card["width"]) / int(card["height"])
|
||||||
|
except Exception:
|
||||||
|
aspect = None
|
||||||
|
yield self.image_widget(card["image"], aspect=aspect)
|
||||||
|
|
||||||
def poll_generator(self, poll):
|
def poll_generator(self, poll):
|
||||||
for idx, option in enumerate(poll["options"]):
|
for idx, option in enumerate(poll["options"]):
|
||||||
perc = (round(100 * option["votes_count"] / poll["votes_count"])
|
perc = (round(100 * option["votes_count"] / poll["votes_count"])
|
||||||
|
@ -442,10 +593,10 @@ class StatusDetails(urwid.Pile):
|
||||||
|
|
||||||
class StatusListItem(SelectableColumns):
|
class StatusListItem(SelectableColumns):
|
||||||
def __init__(self, status, relative_datetimes):
|
def __init__(self, status, relative_datetimes):
|
||||||
edited_at = status.data.get("edited_at")
|
edited_at = status.original.edited_at
|
||||||
|
|
||||||
# TODO: hacky implementation to avoid creating conflicts for existing
|
# TODO: hacky implementation to avoid creating conflicts for existing
|
||||||
# pull reuqests, refactor when merged.
|
# pull requests, refactor when merged.
|
||||||
created_at = (
|
created_at = (
|
||||||
time_ago(status.created_at).ljust(3, " ")
|
time_ago(status.created_at).ljust(3, " ")
|
||||||
if relative_datetimes
|
if relative_datetimes
|
||||||
|
@ -456,7 +607,7 @@ class StatusListItem(SelectableColumns):
|
||||||
favourited = ("highlight", "★") if status.original.favourited else " "
|
favourited = ("highlight", "★") if status.original.favourited else " "
|
||||||
reblogged = ("highlight", "♺") if status.original.reblogged else " "
|
reblogged = ("highlight", "♺") if status.original.reblogged else " "
|
||||||
is_reblog = ("dim", "♺") if status.reblog else " "
|
is_reblog = ("dim", "♺") if status.reblog else " "
|
||||||
is_reply = ("dim", "⤶") if status.original.in_reply_to else " "
|
is_reply = ("dim", "⤶ ") if status.original.in_reply_to else " "
|
||||||
|
|
||||||
return super().__init__([
|
return super().__init__([
|
||||||
("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")),
|
("pack", SelectableText(("status_list_timestamp", created_at), wrap="clip")),
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import urwid
|
import urwid
|
||||||
|
from collections import OrderedDict
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from typing import List
|
from typing import List
|
||||||
|
@ -109,3 +110,33 @@ def deep_get(adict: dict, path: List[str], default=None):
|
||||||
path,
|
path,
|
||||||
adict
|
adict
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LRUCache(OrderedDict):
|
||||||
|
"""Dict with a limited size, ejecting LRUs as needed.
|
||||||
|
Default max size = 10Mb"""
|
||||||
|
|
||||||
|
def __init__(self, *args, cache_max_bytes: int = 1024 * 1024 * 10, **kwargs):
|
||||||
|
assert cache_max_bytes > 0
|
||||||
|
self.total_value_size = 0
|
||||||
|
self.cache_max_bytes = cache_max_bytes
|
||||||
|
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def __setitem__(self, key: str, value):
|
||||||
|
if key in self:
|
||||||
|
self.total_value_size -= sys.getsizeof(super().__getitem__(key).tobytes())
|
||||||
|
self.total_value_size += sys.getsizeof(value.tobytes())
|
||||||
|
super().__setitem__(key, value)
|
||||||
|
super().move_to_end(key)
|
||||||
|
|
||||||
|
while self.total_value_size > self.cache_max_bytes:
|
||||||
|
old_key, value = next(iter(self.items()))
|
||||||
|
sz = sys.getsizeof(value.tobytes())
|
||||||
|
super().__delitem__(old_key)
|
||||||
|
self.total_value_size -= sz
|
||||||
|
|
||||||
|
def __getitem__(self, key: str):
|
||||||
|
val = super().__getitem__(key)
|
||||||
|
super().move_to_end(key)
|
||||||
|
return val
|
||||||
|
|
|
@ -67,3 +67,41 @@ class RadioButton(urwid.AttrWrap):
|
||||||
button = urwid.RadioButton(*args, **kwargs)
|
button = urwid.RadioButton(*args, **kwargs)
|
||||||
padding = urwid.Padding(button, width=len(args[1]) + 4)
|
padding = urwid.Padding(button, width=len(args[1]) + 4)
|
||||||
return super().__init__(padding, "button", "button_focused")
|
return super().__init__(padding, "button", "button_focused")
|
||||||
|
|
||||||
|
|
||||||
|
class ModalBox(urwid.Frame):
|
||||||
|
def __init__(self, message):
|
||||||
|
text = urwid.Text(message)
|
||||||
|
filler = urwid.Filler(text, valign='top', top=1, bottom=1)
|
||||||
|
padding = urwid.Padding(filler, left=1, right=1)
|
||||||
|
return super().__init__(padding)
|
||||||
|
|
||||||
|
|
||||||
|
class RoundedLineBox(urwid.LineBox):
|
||||||
|
"""LineBox that defaults to rounded corners."""
|
||||||
|
def __init__(self,
|
||||||
|
original_widget,
|
||||||
|
title="",
|
||||||
|
title_align="center",
|
||||||
|
title_attr=None,
|
||||||
|
tlcorner="\u256d",
|
||||||
|
tline="─",
|
||||||
|
lline="│",
|
||||||
|
trcorner="\u256e",
|
||||||
|
blcorner="\u2570",
|
||||||
|
rline="│",
|
||||||
|
bline="─",
|
||||||
|
brcorner="\u256f",
|
||||||
|
) -> None:
|
||||||
|
return super().__init__(original_widget,
|
||||||
|
title,
|
||||||
|
title_align,
|
||||||
|
title_attr,
|
||||||
|
tlcorner,
|
||||||
|
tline,
|
||||||
|
lline,
|
||||||
|
trcorner,
|
||||||
|
blcorner,
|
||||||
|
rline,
|
||||||
|
bline,
|
||||||
|
brcorner)
|
||||||
|
|
|
@ -1,147 +0,0 @@
|
||||||
# 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)
|
|
|
@ -1,26 +1,22 @@
|
||||||
|
import click
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import socket
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, Generator, List, Optional
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from toot.exceptions import ConsoleError
|
|
||||||
from urllib.parse import urlparse, urlencode, quote, unquote
|
from urllib.parse import urlparse, urlencode, quote, unquote
|
||||||
|
|
||||||
|
|
||||||
def str_bool(b):
|
def str_bool(b: bool) -> str:
|
||||||
"""Convert boolean to string, in the way expected by the API."""
|
"""Convert boolean to string, in the way expected by the API."""
|
||||||
return "true" if b else "false"
|
return "true" if b else "false"
|
||||||
|
|
||||||
|
|
||||||
def str_bool_nullable(b):
|
def str_bool_nullable(b: Optional[bool]) -> Optional[str]:
|
||||||
"""Similar to str_bool, but leave None as None"""
|
"""Similar to str_bool, but leave None as None"""
|
||||||
return None if b is None else str_bool(b)
|
return None if b is None else str_bool(b)
|
||||||
|
|
||||||
|
@ -34,7 +30,7 @@ def parse_html(html: str) -> BeautifulSoup:
|
||||||
return BeautifulSoup(html.replace("'", "'"), "html.parser")
|
return BeautifulSoup(html.replace("'", "'"), "html.parser")
|
||||||
|
|
||||||
|
|
||||||
def get_text(html):
|
def get_text(html: str) -> str:
|
||||||
"""Converts html to text, strips all tags."""
|
"""Converts html to text, strips all tags."""
|
||||||
text = parse_html(html).get_text()
|
text = parse_html(html).get_text()
|
||||||
return unicodedata.normalize("NFKC", text)
|
return unicodedata.normalize("NFKC", text)
|
||||||
|
@ -53,7 +49,7 @@ def html_to_paragraphs(html: str) -> List[List[str]]:
|
||||||
return [[get_text(line) for line in p] for p in paragraphs]
|
return [[get_text(line) for line in p] for p in paragraphs]
|
||||||
|
|
||||||
|
|
||||||
def format_content(content):
|
def format_content(content: str) -> Generator[str, None, None]:
|
||||||
"""Given a Status contents in HTML, converts it into lines of plain text.
|
"""Given a Status contents in HTML, converts it into lines of plain text.
|
||||||
|
|
||||||
Returns a generator yielding lines of content.
|
Returns a generator yielding lines of content.
|
||||||
|
@ -73,25 +69,12 @@ def format_content(content):
|
||||||
first = False
|
first = False
|
||||||
|
|
||||||
|
|
||||||
def domain_exists(name):
|
|
||||||
try:
|
|
||||||
socket.gethostbyname(name)
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def assert_domain_exists(domain):
|
|
||||||
if not domain_exists(domain):
|
|
||||||
raise ConsoleError("Domain {} not found".format(domain))
|
|
||||||
|
|
||||||
|
|
||||||
EOF_KEY = "Ctrl-Z" if os.name == 'nt' else "Ctrl-D"
|
EOF_KEY = "Ctrl-Z" if os.name == 'nt' else "Ctrl-D"
|
||||||
|
|
||||||
|
|
||||||
def multiline_input():
|
def multiline_input() -> str:
|
||||||
"""Lets user input multiple lines of text, terminated by EOF."""
|
"""Lets user input multiple lines of text, terminated by EOF."""
|
||||||
lines = []
|
lines: List[str] = []
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
lines.append(input())
|
lines.append(input())
|
||||||
|
|
|
@ -4,7 +4,7 @@ import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
def parse_datetime(value):
|
def parse_datetime(value: str) -> datetime:
|
||||||
"""Returns an aware datetime in local timezone"""
|
"""Returns an aware datetime in local timezone"""
|
||||||
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
|
dttm = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z")
|
||||||
|
|
||||||
|
|
|
@ -173,5 +173,5 @@ LANGUAGES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def language_name(code):
|
def language_name(code: str) -> str:
|
||||||
return LANGUAGES.get(code, code)
|
return LANGUAGES.get(code, code)
|
||||||
|
|
Ładowanie…
Reference in New Issue