Add thread building support.
This commit is contained in:
parent
7ca62bfbb3
commit
e6260faaeb
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 }}">
|
||||
|
|
|
|||
Loading…
Reference in a new issue