From e6260faaeb3b477f0edfa9c82b0f2bb4b4115270 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 15 Nov 2025 17:50:19 +0000 Subject: [PATCH] Add thread building support. --- README.md | 4 +- gen_photo_alt_text/cache.py | 3 +- gen_photo_alt_text/mastodon.py | 67 ++++++++++++++++++++++++---------- gen_photo_alt_text/routes.py | 49 ++++++++++++++++++++++++- static/styles.css | 26 +++++++++++++ templates/base.html | 6 +-- templates/compose_draft.html | 17 +++++++++ 7 files changed, 145 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 8c70c2f..83d30f3 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ A small Flask app that fetches recent Immich photos and helps generate high-qual ## Mastodon workflow * Use **Compose Mastodon post** to select up to four photos from the last few days (200 entries are shown for convenience). -* The draft view auto-fills cached alt text (or generates it if missing), lets you edit each description, enter post text, and provide optional ChatGPT instructions. +* The draft view auto-fills cached alt text (or generates it if missing), lets you edit each description, enter post text, and provide optional ChatGPT instructions. It also shows your most recent Mastodon post so you can tick a box to continue a thread. * Hit **Improve text with ChatGPT** as many times as you like; the model polishes grammar, tone, and UK English spellings while respecting your extra notes. -* When ready, choose **Post to Mastodon** to upload the selected media (with their alt text) and publish the refined caption via the configured access token. +* When ready, choose **Post to Mastodon** to upload the selected media (with their alt text) and publish the refined caption (optionally as a reply) via the configured access token. The app stores alt text in `instance/alt_text_cache.db`. Remove this file to clear the cache. diff --git a/gen_photo_alt_text/cache.py b/gen_photo_alt_text/cache.py index e2c2b53..f11be6c 100644 --- a/gen_photo_alt_text/cache.py +++ b/gen_photo_alt_text/cache.py @@ -51,7 +51,8 @@ class AltTextCache: INSERT INTO alt_text(asset_id, alt_text, updated_at) VALUES(?, ?, ?) ON CONFLICT(asset_id) - DO UPDATE SET alt_text = excluded.alt_text, updated_at = excluded.updated_at + DO UPDATE SET alt_text = excluded.alt_text, + updated_at = excluded.updated_at """, (asset_id, alt_text, timestamp), ) diff --git a/gen_photo_alt_text/mastodon.py b/gen_photo_alt_text/mastodon.py index bf7c261..884b3c3 100644 --- a/gen_photo_alt_text/mastodon.py +++ b/gen_photo_alt_text/mastodon.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import List, Sequence +from typing import Any, Dict, Optional, Sequence import requests @@ -29,6 +29,7 @@ class MastodonClient: "User-Agent": "ImmichAltTextHelper/0.1 (+https://photos.4angle.com)", } ) + self._account_id: Optional[str] = None def _raise_for_error(self, response: requests.Response) -> None: try: @@ -38,10 +39,21 @@ class MastodonClient: message = f"{response.status_code} {response.reason}: {payload}" raise MastodonError(message) + def _request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]: + url = f"{self.base_url}{path}" + try: + response = self.session.request(method, url, timeout=30, **kwargs) + except requests.RequestException as exc: # pragma: no cover - network failure + raise MastodonError(str(exc)) from exc + if not response.ok: + self._raise_for_error(response) + if response.content: + return response.json() + return {} + def upload_media( self, filename: str, data: bytes, mime_type: str, alt_text: str ) -> str: - url = f"{self.base_url}/api/v2/media" files = { "file": ( filename or "photo.jpg", @@ -52,37 +64,54 @@ class MastodonClient: form = {} if alt_text: form["description"] = alt_text - try: - response = self.session.post(url, files=files, data=form, timeout=30) - except requests.RequestException as exc: # pragma: no cover - network failure - raise MastodonError(str(exc)) from exc - if not response.ok: - self._raise_for_error(response) - payload = response.json() + payload = self._request("POST", "/api/v2/media", files=files, data=form) media_id = payload.get("id") if not media_id: raise MastodonError("Mastodon response missing media id") return media_id - def create_status(self, text: str, media_ids: Sequence[str]) -> str: + def create_status( + self, text: str, media_ids: Sequence[str], in_reply_to_id: Optional[str] = None + ) -> Dict[str, str]: if not text.strip(): raise MastodonError("Post text cannot be empty") - url = f"{self.base_url}/api/v1/statuses" payload = { "status": text, "language": "en", "media_ids": list(media_ids), } - try: - response = self.session.post(url, json=payload, timeout=30) - except requests.RequestException as exc: # pragma: no cover - raise MastodonError(str(exc)) from exc - if not response.ok: - self._raise_for_error(response) - data = response.json() + if in_reply_to_id: + payload["in_reply_to_id"] = in_reply_to_id + data = self._request("POST", "/api/v1/statuses", json=payload) status_id = data.get("id") + status_url = data.get("url") if not status_id: raise MastodonError("Mastodon response missing status id") - return status_id + return {"id": status_id, "url": status_url} + + def _get_account_id(self) -> str: + if not self._account_id: + data = self._request("GET", "/api/v1/accounts/verify_credentials") + account_id = data.get("id") + if not account_id: + raise MastodonError("Unable to determine account id") + self._account_id = str(account_id) + return self._account_id + + def get_latest_status(self) -> Optional[Dict[str, Any]]: + account_id = self._get_account_id() + params = {"limit": 1, "exclude_reblogs": True} + items = self._request( + "GET", f"/api/v1/accounts/{account_id}/statuses", params=params + ) + if isinstance(items, list) and items: + status = items[0] + return { + "id": status.get("id"), + "content": status.get("content"), + "created_at": status.get("created_at"), + "url": status.get("url"), + } + return None diff --git a/gen_photo_alt_text/routes.py b/gen_photo_alt_text/routes.py index d2bf916..6f46a86 100644 --- a/gen_photo_alt_text/routes.py +++ b/gen_photo_alt_text/routes.py @@ -242,14 +242,19 @@ def compose_draft(): error_message = None post_text = "" instructions = "" + reply_to_latest = False if request.method == "POST": asset_ids = _unique_asset_ids(request.form.getlist("asset_ids")) post_text = request.form.get("post_text", "") instructions = request.form.get("post_instructions", "") + reply_values = request.form.getlist("reply_to_latest") + reply_to_latest = any(value in {"1", "on", "true"} for value in reply_values) + latest_status_id_form = request.form.get("latest_status_id") else: ids_param = request.args.get("ids", "") asset_ids = _unique_asset_ids(ids_param.split(",") if ids_param else []) + latest_status_id_form = None if not asset_ids: flash("Choose photos before composing a post.") @@ -259,6 +264,14 @@ def compose_draft(): flash(f"Select at most {MAX_MEDIA_ATTACHMENTS} photos.") return redirect(url_for("main.compose_select")) + latest_status_raw = None + latest_status_error = None + if mastodon_client: + try: + latest_status_raw = mastodon_client.get_latest_status() + except MastodonError as exc: + latest_status_error = str(exc) + assets: list[ImmichAsset] = [] try: for asset_id in asset_ids: @@ -273,6 +286,22 @@ def compose_draft(): mastodon_ready=bool(mastodon_client), ) + latest_status = None + if latest_status_raw: + latest_status = { + "id": latest_status_raw.get("id"), + "content": latest_status_raw.get("content", ""), + "created_display": _humanize_timestamp(latest_status_raw.get("created_at")), + "url": latest_status_raw.get("url"), + } + elif latest_status_id_form: + latest_status = { + "id": latest_status_id_form, + "content": "", + "created_display": None, + "url": None, + } + asset_entries = [] for asset in assets: if request.method == "POST": @@ -327,8 +356,21 @@ def compose_draft(): asset.file_name, content, mime_type, alt_value ) media_ids.append(media_id) - mastodon_client.create_status(post_text.strip(), media_ids) - flash("Post sent to Mastodon.") + reply_target_id = latest_status_id_form or ( + latest_status["id"] if latest_status else None + ) + reply_id = reply_target_id if reply_to_latest else None + status = mastodon_client.create_status( + post_text.strip(), media_ids, in_reply_to_id=reply_id + ) + status_url = status.get("url") + if status_url: + flash( + f'Post sent to Mastodon: {status_url}', + "success", + ) + else: + flash("Post sent to Mastodon.", "success") return redirect(url_for("main.index")) except ValueError as exc: error_message = str(exc) @@ -347,6 +389,9 @@ def compose_draft(): instructions=instructions, mastodon_ready=bool(mastodon_client), max_photos=MAX_MEDIA_ATTACHMENTS, + latest_status=latest_status, + latest_status_error=latest_status_error, + reply_to_latest=reply_to_latest, ) diff --git a/static/styles.css b/static/styles.css index e37ef6c..471c0c2 100644 --- a/static/styles.css +++ b/static/styles.css @@ -56,6 +56,10 @@ body { border-radius: 4px; } +.flash-success a { + color: #9dd4ff; +} + .error { background: #401f1f; border: 1px solid #6b2727; @@ -72,6 +76,28 @@ body { margin-bottom: 1rem; } +.latest-status { + border: 1px solid #2d2d2d; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + background: #191919; +} + +.latest-status .status-content { + margin: 0.5rem 0; + padding: 0.5rem; + border-radius: 6px; + background: #101010; +} + +.reply-checkbox { + display: flex; + gap: 0.4rem; + align-items: center; + font-size: 0.95rem; +} + .button-link { display: inline-block; padding: 0.5rem 1.1rem; diff --git a/templates/base.html b/templates/base.html index 383d558..0708980 100644 --- a/templates/base.html +++ b/templates/base.html @@ -19,11 +19,11 @@
- {% with messages = get_flashed_messages() %} + {% with messages = get_flashed_messages(with_categories=True) %} {% if messages %}
- {% for message in messages %} -
{{ message }}
+ {% for category, message in messages %} +
{{ message|safe }}
{% endfor %}
{% endif %} diff --git a/templates/compose_draft.html b/templates/compose_draft.html index 945f91b..8e742e9 100644 --- a/templates/compose_draft.html +++ b/templates/compose_draft.html @@ -15,6 +15,23 @@ {% endif %}
+ {% if latest_status %} +
+

Most recent Mastodon post

+ + {% if latest_status.created_display %} +

Posted {{ latest_status.created_display }}

+ {% endif %} +
{{ latest_status.content|safe }}
+ +
+ {% elif latest_status_error %} +
{{ latest_status_error }}
+ {% endif %}
{% for asset in assets %}