diff --git a/tests/integration/test_post.py b/tests/integration/test_post.py index bd45960..86f6931 100644 --- a/tests/integration/test_post.py +++ b/tests/integration/test_post.py @@ -7,6 +7,7 @@ from os import path from tests.integration.conftest import ASSETS_DIR, Run, assert_ok, posted_status_id from toot import CLIENT_NAME, CLIENT_WEBSITE, api, cli +from toot.cache import clear_last_post_id, get_last_post_id from toot.utils import get_text from unittest import mock @@ -340,7 +341,7 @@ def test_media_attachment_without_text(mock_read, mock_ml, app, user, run): assert attachment["meta"]["original"]["size"] == "50x50" -def test_reply_thread(app, user, friend, run): +def test_reply_to(app, user, friend, run): status = api.post_status(app, friend, "This is the status").json() result = run(cli.post.post, "--reply-to", status["id"], "This is the reply") @@ -362,3 +363,38 @@ def test_reply_thread(app, user, friend, run): assert user.username in s2 assert status["id"] in s1 assert reply["id"] in s2 + + +def test_reply_last(app, user, run): + result_1 = run(cli.post.post, "one") + status_id_1 = posted_status_id(result_1.stdout) + assert get_last_post_id(app, user) == status_id_1 + + result_2 = run(cli.post.post, "two", "--reply-last") + status_id_2 = posted_status_id(result_2.stdout) + assert get_last_post_id(app, user) == status_id_2 + + result_3 = run(cli.post.post, "two", "--reply-last") + status_id_3 = posted_status_id(result_3.stdout) + assert get_last_post_id(app, user) == status_id_3 + + status_1 = api.fetch_status(app, user, status_id_1).json() + status_2 = api.fetch_status(app, user, status_id_2).json() + status_3 = api.fetch_status(app, user, status_id_3).json() + + assert status_1["in_reply_to_id"] is None + assert status_2["in_reply_to_id"] == status_id_1 + assert status_3["in_reply_to_id"] == status_id_2 + + +def test_reply_last_fails_if_no_last_id(app, user, run: Run): + clear_last_post_id(app, user) + result = run(cli.post.post, "one", "--reply-last") + assert result.exit_code == 1 + assert result.stderr.strip() == f"Error: Cannot reply-last, no previous post ID found for {user.username}@{app.instance}" + + +def test_reply_last_and_reply_to_are_exclusive(app, user, run: Run): + result = run(cli.post.post, "one", "--reply-last", "--reply-to", "123") + assert result.exit_code == 1 + assert result.stderr.strip() == f"Error: --reply-last and --reply-to are mutually exclusive" diff --git a/toot/cache.py b/toot/cache.py new file mode 100644 index 0000000..7687448 --- /dev/null +++ b/toot/cache.py @@ -0,0 +1,61 @@ +import os +import sys + +from pathlib import Path +from typing import Optional + +from toot import App, User + +CACHE_SUBFOLDER = "toot" + + +def save_last_post_id(app: App, user: User, id: str) -> None: + """Save ID of the last post posted to this instance""" + path = _last_post_id_path(app, user) + with open(path, "w") as f: + f.write(id) + + +def get_last_post_id(app: App, user: User) -> Optional[str]: + """Retrieve ID of the last post posted to this instance""" + path = _last_post_id_path(app, user) + if path.exists(): + with open(path, "r") as f: + return f.read() + + +def clear_last_post_id(app: App, user: User) -> None: + """Delete the cached last post ID for this instance""" + path = _last_post_id_path(app, user) + path.unlink(missing_ok=True) + + +def _last_post_id_path(app: App, user: User): + return get_cache_dir("last_post_ids") / f"{user.username}_{app.instance}" + + +def get_cache_dir(subdir: Optional[str] = None) -> Path: + path = _cache_dir_path() + if subdir: + path = path / subdir + path.mkdir(parents=True, exist_ok=True) + return path + + +def _cache_dir_path() -> Path: + """Returns the path to the cache directory""" + + # Windows + if sys.platform == "win32" and "LOCALAPPDATA" in os.environ: + return Path(os.environ["LOCALAPPDATA"], CACHE_SUBFOLDER) + + # Mac OS + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" / CACHE_SUBFOLDER + + # Respect XDG_CONFIG_HOME env variable if set + # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + if "XDG_CACHE_HOME" in os.environ: + return Path(os.environ["XDG_CACHE_HOME"], CACHE_SUBFOLDER) + + return Path.home() / ".cache" / CACHE_SUBFOLDER diff --git a/toot/cli/post.py b/toot/cli/post.py index 820aab6..e82c402 100644 --- a/toot/cli/post.py +++ b/toot/cli/post.py @@ -7,6 +7,7 @@ from time import sleep, time from typing import BinaryIO, Optional, Tuple from toot import api, config +from toot.cache import get_last_post_id, save_last_post_id from toot.cli import AccountParamType, cli, json_option, pass_context, Context from toot.cli import DURATION_EXAMPLES, VISIBILITY_CHOICES from toot.tui.constants import VISIBILITY_OPTIONS # move to top-level ? @@ -60,6 +61,12 @@ from toot.utils.datetime import parse_datetime "--reply-to", "-r", help="ID of the status being replied to, if status is a reply.", ) +@click.option( + "--reply-last", "-R", + help="Reply to the last posted status to continue the thread.", + is_flag=True, + default=False, +) @click.option( "--language", "-l", help="ISO 639-1 language code of the toot, to skip automatic detection.", @@ -137,7 +144,8 @@ def post( poll_multiple: bool, poll_hide_totals: bool, json: bool, - using: str + using: str, + reply_last: bool, ): """Post a new status""" if len(media) > 4: @@ -153,6 +161,7 @@ def post( media_ids = _upload_media(app, user, media, descriptions, thumbnails) status_text = _get_status_text(text, editor, media) scheduled_at = _get_scheduled_at(scheduled_at, scheduled_in) + reply_to = _get_reply_to(app, user, reply_to, reply_last) if not status_text and not media_ids: raise click.ClickException("You must specify either text or media to post.") @@ -186,6 +195,8 @@ def post( else: click.echo(f"Toot posted: {status['url']}") + save_last_post_id(app, user, status["id"]) + delete_tmp_status_file() @@ -245,6 +256,20 @@ def _get_scheduled_at(scheduled_at, scheduled_in): return None +def _get_reply_to(app, user, reply_to, reply_last): + if reply_last and reply_to: + raise click.ClickException("--reply-last and --reply-to are mutually exclusive") + + if reply_last: + last_id = get_last_post_id(app, user) + if last_id: + return last_id + else: + raise click.ClickException(f"Cannot reply-last, no previous post ID found for {user.username}@{app.instance}") + + return reply_to + + def _upload_media(app, user, media, descriptions, thumbnails): # Match media to corresponding descriptions and thumbnail media = media or []