Add thread building support.

This commit is contained in:
Edward Betts 2025-11-15 17:50:19 +00:00
parent 7ca62bfbb3
commit e6260faaeb
7 changed files with 145 additions and 27 deletions

View file

@ -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.

View file

@ -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),
)

View file

@ -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

View file

@ -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: <a href="{status_url}" target="_blank" rel="noopener">{status_url}</a>',
"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,
)

View file

@ -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;

View file

@ -19,11 +19,11 @@
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages() %}
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
<div class="flash-messages">
{% for message in messages %}
<div class="flash">{{ message }}</div>
{% for category, message in messages %}
<div class="flash {% if category %}flash-{{ category }}{% endif %}">{{ message|safe }}</div>
{% endfor %}
</div>
{% endif %}

View file

@ -15,6 +15,23 @@
{% endif %}
<form method="post" class="compose-form">
{% if latest_status %}
<section class="latest-status">
<h3>Most recent Mastodon post</h3>
<input type="hidden" name="latest_status_id" value="{{ latest_status.id }}">
{% if latest_status.created_display %}
<p class="date">Posted {{ latest_status.created_display }}</p>
{% endif %}
<div class="status-content">{{ latest_status.content|safe }}</div>
<label class="reply-checkbox">
<input type="hidden" name="reply_to_latest" value="0">
<input type="checkbox" name="reply_to_latest" value="1" {% if reply_to_latest %}checked{% endif %} {% if not mastodon_ready %}disabled{% endif %}>
Reply to this post to continue the thread
</label>
</section>
{% elif latest_status_error %}
<div class="warning">{{ latest_status_error }}</div>
{% endif %}
<div class="asset-list">
{% for asset in assets %}
<article class="compose-asset" data-asset-id="{{ asset.id }}">