feat: add show command with short-index cache
Persist last displayed tweet lists and allow opening a tweet by index. - Add twitter_cli/cache.py: stores a short-index cache (~/.twitter-cli/last_results.json) with a 1h TTL and helpers to resolve index->tweet-id and cache size. - Update twitter_cli/cli.py: save list results to cache, display a hint, and add `twitter show <N>` command which fetches a tweet by cached index and prints detail/replies (supports --full-text, --json, structured output, and max replies). - Update README.md and SKILL.md to document the new `show` usage. - Add .idea/ to .gitignore and bump package version in uv.lock to 0.8.0. This change makes it easy to open items from the last feed/search without copying IDs.
This commit is contained in:
62
twitter_cli/cache.py
Normal file
62
twitter_cli/cache.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Short-index cache: persist the last displayed tweet list for quick `show` access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from .models import Tweet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_DIR = Path.home() / ".twitter-cli"
|
||||
_CACHE_FILE = _CACHE_DIR / "last_results.json"
|
||||
_TTL = 3600 # seconds
|
||||
|
||||
|
||||
def save_tweet_cache(tweets: List[Tweet]) -> None:
|
||||
"""Persist tweet list so indices can be resolved by `show`."""
|
||||
try:
|
||||
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
entries = [
|
||||
{"index": i + 1, "id": t.id, "author": t.author.screen_name, "text": t.text[:80]}
|
||||
for i, t in enumerate(tweets)
|
||||
if t.id
|
||||
]
|
||||
payload = {"created_at": time.time(), "tweets": entries}
|
||||
_CACHE_FILE.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except OSError as exc:
|
||||
logger.debug("Failed to write tweet cache: %s", exc)
|
||||
|
||||
|
||||
def _load_cache() -> Optional[List[dict]]:
|
||||
"""Load and validate the cache file; return tweet entries or None if stale/missing."""
|
||||
try:
|
||||
if not _CACHE_FILE.exists():
|
||||
return None
|
||||
payload = json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
|
||||
if time.time() - payload.get("created_at", 0) > _TTL:
|
||||
return None
|
||||
return payload.get("tweets", [])
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def get_tweet_id_by_index(index: int) -> Optional[str]:
|
||||
"""Return tweet ID at *index* (1-based), or None if not found."""
|
||||
entries = _load_cache()
|
||||
if entries is None:
|
||||
return None
|
||||
for entry in entries:
|
||||
if entry.get("index") == index:
|
||||
return str(entry["id"])
|
||||
return None
|
||||
|
||||
|
||||
def get_cache_size() -> int:
|
||||
"""Return number of tweets in the current cache (0 if unavailable)."""
|
||||
entries = _load_cache()
|
||||
return len(entries) if entries is not None else 0
|
||||
@@ -45,6 +45,7 @@ from rich.console import Console
|
||||
|
||||
from . import __version__
|
||||
from .auth import get_cookies
|
||||
from .cache import get_cache_size, get_tweet_id_by_index, save_tweet_cache
|
||||
from .client import TwitterClient
|
||||
from .config import load_config
|
||||
from .filter import filter_tweets
|
||||
@@ -351,12 +352,14 @@ def _fetch_and_display(fetch_fn, label, emoji, max_count, as_json, as_yaml, outp
|
||||
if emit_structured(tweets_to_data(filtered), as_json=as_json, as_yaml=as_yaml):
|
||||
return
|
||||
|
||||
save_tweet_cache(filtered)
|
||||
print_tweet_table(
|
||||
filtered,
|
||||
console,
|
||||
title="%s %s — %d tweets" % (emoji, label, len(filtered)),
|
||||
full_text=full_text,
|
||||
)
|
||||
console.print("[dim]💡 Use `twitter show <N>` to view tweet #N from this list.[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -445,7 +448,9 @@ def feed(ctx, feed_type, max_count, as_json, as_yaml, input_file, output_file, d
|
||||
|
||||
title = "👥 Following" if feed_type == "following" else "📱 Twitter"
|
||||
title += " — %d tweets" % len(filtered)
|
||||
save_tweet_cache(filtered)
|
||||
print_tweet_table(filtered, console, title=title, full_text=full_text)
|
||||
console.print("[dim]💡 Use `twitter show <N>` to view tweet #N from this list.[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -714,6 +719,57 @@ def tweet(ctx, tweet_id, max_count, full_text, as_json, as_yaml):
|
||||
console.print()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("index", type=int)
|
||||
@click.option("--max", "-n", "max_count", type=int, default=None, help="Max replies to fetch.")
|
||||
@click.option("--full-text", is_flag=True, help="Show full reply text in table output.")
|
||||
@structured_output_options
|
||||
@click.pass_context
|
||||
def show(ctx, index, max_count, full_text, as_json, as_yaml):
|
||||
# type: (Any, int, Optional[int], bool, bool, bool) -> None
|
||||
"""View tweet #INDEX from the last feed/search results."""
|
||||
compact = ctx.obj.get("compact", False)
|
||||
|
||||
tweet_id = get_tweet_id_by_index(index)
|
||||
if tweet_id is None:
|
||||
cache_size = get_cache_size()
|
||||
if cache_size == 0:
|
||||
raise click.UsageError(
|
||||
"No cached results found. Run `twitter feed` or `twitter search` first."
|
||||
)
|
||||
raise click.UsageError(
|
||||
"Index %d is out of range (cache has %d tweets)." % (index, cache_size)
|
||||
)
|
||||
|
||||
config = load_config()
|
||||
rich_output = use_rich_output(as_json=as_json, as_yaml=as_yaml, compact=compact)
|
||||
try:
|
||||
client = _get_client_for_output(config, quiet=not rich_output)
|
||||
if rich_output:
|
||||
console.print("🐦 Fetching tweet #%d (id: %s)...\n" % (index, tweet_id))
|
||||
start = time.time()
|
||||
tweets = client.fetch_tweet_detail(tweet_id, _resolve_configured_count(config, max_count))
|
||||
elapsed = time.time() - start
|
||||
if rich_output:
|
||||
console.print("✅ Fetched %d tweets in %.1fs\n" % (len(tweets), elapsed))
|
||||
except RuntimeError as exc:
|
||||
_exit_with_error(exc)
|
||||
|
||||
if compact:
|
||||
click.echo(tweets_to_compact_json(tweets))
|
||||
return
|
||||
|
||||
if emit_structured(tweets_to_data(tweets), as_json=as_json, as_yaml=as_yaml):
|
||||
return
|
||||
|
||||
if tweets:
|
||||
print_tweet_detail(tweets[0], console)
|
||||
if len(tweets) > 1:
|
||||
console.print("\n💬 Replies:")
|
||||
print_tweet_table(tweets[1:], console, title="💬 Replies — %d" % (len(tweets) - 1), full_text=full_text)
|
||||
console.print()
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("tweet_id")
|
||||
@structured_output_options
|
||||
|
||||
Reference in New Issue
Block a user