New mastodon post feature
This commit is contained in:
parent
1180c0817f
commit
eada9049fa
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ api_keys
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
__pycache__
|
__pycache__
|
||||||
instance
|
instance
|
||||||
|
.env
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -10,6 +10,8 @@ A small Flask app that fetches recent Immich photos and helps generate high-qual
|
||||||
IMMICH_API_URL=https://photos.4angle.com/
|
IMMICH_API_URL=https://photos.4angle.com/
|
||||||
IMMICH_API_KEY=your-immich-api-key
|
IMMICH_API_KEY=your-immich-api-key
|
||||||
OPENAI_API_KEY=your-openai-api-key
|
OPENAI_API_KEY=your-openai-api-key
|
||||||
|
MASTODON_BASE_URL=http://localhost:3000
|
||||||
|
MASTODON_ACCESS_TOKEN=your-mastodon-token
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Install dependencies and run the development server:
|
2. Install dependencies and run the development server:
|
||||||
|
|
@ -19,6 +21,13 @@ A small Flask app that fetches recent Immich photos and helps generate high-qual
|
||||||
flask --app app run --debug
|
flask --app app run --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Visit <http://127.0.0.1:5000> to browse recent Immich photos, view cached alt text, and request new alt text guided by optional notes.
|
3. Visit <http://127.0.0.1:5000> to browse recent Immich photos, view cached alt text, request new alt text guided by optional notes, or compose Mastodon posts.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
* 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.
|
||||||
|
|
||||||
The app stores alt text in `instance/alt_text_cache.db`. Remove this file to clear the cache.
|
The app stores alt text in `instance/alt_text_cache.db`. Remove this file to clear the cache.
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from .cache import AltTextCache
|
||||||
from .config import load_settings
|
from .config import load_settings
|
||||||
from .immich import ImmichClient
|
from .immich import ImmichClient
|
||||||
from .openai_client import AltTextGenerator
|
from .openai_client import AltTextGenerator
|
||||||
|
from .mastodon import MastodonClient
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
|
|
@ -42,6 +43,14 @@ def create_app() -> Flask:
|
||||||
api_key=app.config["OPENAI_API_KEY"],
|
api_key=app.config["OPENAI_API_KEY"],
|
||||||
model=app.config.get("OPENAI_MODEL", "gpt-4o-mini"),
|
model=app.config.get("OPENAI_MODEL", "gpt-4o-mini"),
|
||||||
)
|
)
|
||||||
|
mastodon_token = app.config.get("MASTODON_ACCESS_TOKEN")
|
||||||
|
if mastodon_token:
|
||||||
|
app.mastodon_client = MastodonClient(
|
||||||
|
base_url=app.config.get("MASTODON_BASE_URL", "http://localhost:3000"),
|
||||||
|
access_token=mastodon_token,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
app.mastodon_client = None
|
||||||
|
|
||||||
from . import routes # pragma: no cover
|
from . import routes # pragma: no cover
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,12 @@ def load_settings() -> Dict[str, str]:
|
||||||
"OPENAI_MODEL": os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
|
"OPENAI_MODEL": os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
|
||||||
"ALT_TEXT_DB": os.getenv("ALT_TEXT_DB", ""),
|
"ALT_TEXT_DB": os.getenv("ALT_TEXT_DB", ""),
|
||||||
"SECRET_KEY": os.getenv("SECRET_KEY", ""),
|
"SECRET_KEY": os.getenv("SECRET_KEY", ""),
|
||||||
|
"MASTODON_BASE_URL": os.getenv(
|
||||||
|
"MASTODON_BASE_URL", "http://localhost:3000"
|
||||||
|
).rstrip("/"),
|
||||||
|
"MASTODON_ACCESS_TOKEN": os.getenv("MASTODON_ACCESS_TOKEN", ""),
|
||||||
|
"MASTODON_CLIENT_KEY": os.getenv("MASTODON_CLIENT_KEY", ""),
|
||||||
|
"MASTODON_CLIENT_SECRET": os.getenv("MASTODON_CLIENT_SECRET", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
legacy_values = _load_legacy_api_keys(Path("api_keys"))
|
legacy_values = _load_legacy_api_keys(Path("api_keys"))
|
||||||
|
|
|
||||||
88
gen_photo_alt_text/mastodon.py
Normal file
88
gen_photo_alt_text/mastodon.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""Client helpers for posting to Mastodon."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List, Sequence
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class MastodonError(RuntimeError):
|
||||||
|
"""Raised when the Mastodon API indicates a failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class MastodonClient:
|
||||||
|
"""Minimal Mastodon API wrapper for uploading media and posting statuses."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, access_token: str) -> None:
|
||||||
|
if not base_url:
|
||||||
|
raise ValueError("base_url is required")
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError("access_token is required")
|
||||||
|
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update(
|
||||||
|
{
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "ImmichAltTextHelper/0.1 (+https://photos.4angle.com)",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _raise_for_error(self, response: requests.Response) -> None:
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except Exception: # pragma: no cover - fallback path
|
||||||
|
payload = response.text
|
||||||
|
message = f"{response.status_code} {response.reason}: {payload}"
|
||||||
|
raise MastodonError(message)
|
||||||
|
|
||||||
|
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",
|
||||||
|
data,
|
||||||
|
mime_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
|
||||||
|
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:
|
||||||
|
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()
|
||||||
|
|
||||||
|
status_id = data.get("id")
|
||||||
|
if not status_id:
|
||||||
|
raise MastodonError("Mastodon response missing status id")
|
||||||
|
return status_id
|
||||||
|
|
@ -7,10 +7,18 @@ from typing import Any, Dict, List, Optional
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
class AltTextGenerationError(RuntimeError):
|
class OpenAIClientError(RuntimeError):
|
||||||
|
"""Raised when the OpenAI API cannot fulfill a request."""
|
||||||
|
|
||||||
|
|
||||||
|
class AltTextGenerationError(OpenAIClientError):
|
||||||
"""Raised when the OpenAI API cannot generate alt text."""
|
"""Raised when the OpenAI API cannot generate alt text."""
|
||||||
|
|
||||||
|
|
||||||
|
class TextImprovementError(OpenAIClientError):
|
||||||
|
"""Raised when the OpenAI API cannot improve post text."""
|
||||||
|
|
||||||
|
|
||||||
class AltTextGenerator:
|
class AltTextGenerator:
|
||||||
"""Request alt text from a GPT-4o compatible OpenAI endpoint."""
|
"""Request alt text from a GPT-4o compatible OpenAI endpoint."""
|
||||||
|
|
||||||
|
|
@ -92,3 +100,43 @@ class AltTextGenerator:
|
||||||
raise AltTextGenerationError("OpenAI response missing content")
|
raise AltTextGenerationError("OpenAI response missing content")
|
||||||
|
|
||||||
return content_text.strip()
|
return content_text.strip()
|
||||||
|
|
||||||
|
def improve_post_text(
|
||||||
|
self, draft_text: str, instructions: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
if not draft_text or not draft_text.strip():
|
||||||
|
raise TextImprovementError("Post text cannot be empty")
|
||||||
|
|
||||||
|
prompt_parts = [
|
||||||
|
"You review Mastodon drafts and rewrite them in UK English.",
|
||||||
|
"Keep the tone warm, accessible, and descriptive without exaggeration.",
|
||||||
|
"Ensure clarity, fix spelling or grammar, and keep content suitable for social media.",
|
||||||
|
]
|
||||||
|
if instructions:
|
||||||
|
prompt_parts.append(f"Additional instructions: {instructions.strip()}")
|
||||||
|
prompt_parts.append("Return only the improved post text.")
|
||||||
|
user_content = f"Draft post:\\n{draft_text.strip()}"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"temperature": 0.4,
|
||||||
|
"max_tokens": 400,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "\n".join(prompt_parts)},
|
||||||
|
{"role": "user", "content": user_content},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = self.session.post(self.endpoint, json=payload, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except requests.RequestException as exc: # pragma: no cover
|
||||||
|
raise TextImprovementError(str(exc)) from exc
|
||||||
|
|
||||||
|
choices = data.get("choices") or []
|
||||||
|
if not choices:
|
||||||
|
raise TextImprovementError("OpenAI response did not include choices")
|
||||||
|
content_text = choices[0].get("message", {}).get("content")
|
||||||
|
if not content_text:
|
||||||
|
raise TextImprovementError("OpenAI response missing content")
|
||||||
|
return content_text.strip()
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ from flask import (
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .immich import ImmichError
|
from .immich import ImmichAsset, ImmichError
|
||||||
from .openai_client import AltTextGenerationError
|
from .mastodon import MastodonError
|
||||||
|
from .openai_client import AltTextGenerationError, TextImprovementError
|
||||||
|
|
||||||
bp = Blueprint("main", __name__)
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
@ -37,7 +38,7 @@ def _humanize_timestamp(raw: str | None) -> str | None:
|
||||||
dt = _parse_timestamp(raw)
|
dt = _parse_timestamp(raw)
|
||||||
if not dt:
|
if not dt:
|
||||||
return raw
|
return raw
|
||||||
return dt.strftime("%d %b %Y / %H:%M")
|
return dt.strftime("%d %b %Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
def _timestamp_with_timezone(raw: str | None) -> str | None:
|
def _timestamp_with_timezone(raw: str | None) -> str | None:
|
||||||
|
|
@ -57,6 +58,53 @@ def _timestamp_with_timezone(raw: str | None) -> str | None:
|
||||||
return f"{label} (timezone unknown)"
|
return f"{label} (timezone unknown)"
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinates_text(asset: ImmichAsset) -> str | None:
|
||||||
|
if asset.latitude is None or asset.longitude is None:
|
||||||
|
return None
|
||||||
|
return f"{asset.latitude:.5f}, {asset.longitude:.5f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_alt_text_for_asset(asset: ImmichAsset, notes: str | None = None) -> str:
|
||||||
|
immich_client = current_app.immich_client
|
||||||
|
generator = current_app.alt_text_generator
|
||||||
|
content, mime_type = immich_client.fetch_asset_content(asset.id, "preview")
|
||||||
|
data_url = "data:{};base64,{}".format(
|
||||||
|
mime_type, base64.b64encode(content).decode("ascii")
|
||||||
|
)
|
||||||
|
return generator.generate_alt_text(
|
||||||
|
data_url,
|
||||||
|
notes,
|
||||||
|
captured_at=_timestamp_with_timezone(asset.captured_at),
|
||||||
|
location=asset.location_label,
|
||||||
|
coordinates=_coordinates_text(asset),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_alt_text(asset: ImmichAsset) -> str:
|
||||||
|
cache = current_app.alt_text_cache
|
||||||
|
cached = cache.get(asset.id)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
generated = _generate_alt_text_for_asset(asset)
|
||||||
|
cache.set(asset.id, generated)
|
||||||
|
return generated
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_asset_ids(values: list[str]) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
ordered: list[str] = []
|
||||||
|
for value in values:
|
||||||
|
value = (value or "").strip()
|
||||||
|
if not value or value in seen:
|
||||||
|
continue
|
||||||
|
seen.add(value)
|
||||||
|
ordered.append(value)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
MAX_MEDIA_ATTACHMENTS = 4
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def index():
|
def index():
|
||||||
immich_client = current_app.immich_client
|
immich_client = current_app.immich_client
|
||||||
|
|
@ -87,7 +135,6 @@ def index():
|
||||||
def asset_detail(asset_id: str):
|
def asset_detail(asset_id: str):
|
||||||
immich_client = current_app.immich_client
|
immich_client = current_app.immich_client
|
||||||
alt_cache = current_app.alt_text_cache
|
alt_cache = current_app.alt_text_cache
|
||||||
generator = current_app.alt_text_generator
|
|
||||||
|
|
||||||
error_message = None
|
error_message = None
|
||||||
notes = ""
|
notes = ""
|
||||||
|
|
@ -108,22 +155,7 @@ def asset_detail(asset_id: str):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
notes = request.form.get("notes", "")
|
notes = request.form.get("notes", "")
|
||||||
try:
|
try:
|
||||||
content, mime_type = immich_client.fetch_asset_content(asset_id, "preview")
|
generated = _generate_alt_text_for_asset(asset, notes)
|
||||||
data_url = "data:{};base64,{}".format(
|
|
||||||
mime_type, base64.b64encode(content).decode("ascii")
|
|
||||||
)
|
|
||||||
location_text = asset.location_label
|
|
||||||
coordinates_text = None
|
|
||||||
if asset.latitude is not None and asset.longitude is not None:
|
|
||||||
coordinates_text = f"{asset.latitude:.5f}, {asset.longitude:.5f}"
|
|
||||||
captured_text = _timestamp_with_timezone(asset.captured_at)
|
|
||||||
generated = generator.generate_alt_text(
|
|
||||||
data_url,
|
|
||||||
notes,
|
|
||||||
captured_at=captured_text,
|
|
||||||
location=location_text,
|
|
||||||
coordinates=coordinates_text,
|
|
||||||
)
|
|
||||||
alt_cache.set(asset_id, generated)
|
alt_cache.set(asset_id, generated)
|
||||||
flash("Alt text generated.")
|
flash("Alt text generated.")
|
||||||
return redirect(url_for("main.asset_detail", asset_id=asset_id))
|
return redirect(url_for("main.asset_detail", asset_id=asset_id))
|
||||||
|
|
@ -155,6 +187,169 @@ def asset_detail(asset_id: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/compose/select", methods=["GET", "POST"])
|
||||||
|
def compose_select():
|
||||||
|
immich_client = current_app.immich_client
|
||||||
|
alt_cache = current_app.alt_text_cache
|
||||||
|
days = current_app.config.get("RECENT_DAYS", 3)
|
||||||
|
|
||||||
|
asset_objs: list[ImmichAsset] = []
|
||||||
|
error_message = None
|
||||||
|
try:
|
||||||
|
asset_objs = immich_client.get_recent_assets(days=days, limit=200)
|
||||||
|
except ImmichError as exc:
|
||||||
|
error_message = str(exc)
|
||||||
|
|
||||||
|
assets = []
|
||||||
|
for asset in asset_objs:
|
||||||
|
assets.append(
|
||||||
|
{
|
||||||
|
"id": asset.id,
|
||||||
|
"file_name": asset.file_name,
|
||||||
|
"captured_display": _humanize_timestamp(asset.captured_at),
|
||||||
|
"alt_text": alt_cache.get(asset.id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
selected_set: set[str] = set()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
selected = _unique_asset_ids(request.form.getlist("asset_ids"))
|
||||||
|
selected_set = set(selected)
|
||||||
|
if not selected:
|
||||||
|
error_message = "Select at least one photo."
|
||||||
|
elif len(selected) > MAX_MEDIA_ATTACHMENTS:
|
||||||
|
error_message = f"Select up to {MAX_MEDIA_ATTACHMENTS} photos."
|
||||||
|
else:
|
||||||
|
return redirect(url_for("main.compose_draft", ids=",".join(selected)))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"compose_select.html",
|
||||||
|
assets=assets,
|
||||||
|
error_message=error_message,
|
||||||
|
max_photos=MAX_MEDIA_ATTACHMENTS,
|
||||||
|
selected_ids=selected_set,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/compose/draft", methods=["GET", "POST"])
|
||||||
|
def compose_draft():
|
||||||
|
mastodon_client = getattr(current_app, "mastodon_client", None)
|
||||||
|
immich_client = current_app.immich_client
|
||||||
|
alt_cache = current_app.alt_text_cache
|
||||||
|
generator = current_app.alt_text_generator
|
||||||
|
|
||||||
|
error_message = None
|
||||||
|
post_text = ""
|
||||||
|
instructions = ""
|
||||||
|
|
||||||
|
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", "")
|
||||||
|
else:
|
||||||
|
ids_param = request.args.get("ids", "")
|
||||||
|
asset_ids = _unique_asset_ids(ids_param.split(",") if ids_param else [])
|
||||||
|
|
||||||
|
if not asset_ids:
|
||||||
|
flash("Choose photos before composing a post.")
|
||||||
|
return redirect(url_for("main.compose_select"))
|
||||||
|
|
||||||
|
if len(asset_ids) > MAX_MEDIA_ATTACHMENTS:
|
||||||
|
flash(f"Select at most {MAX_MEDIA_ATTACHMENTS} photos.")
|
||||||
|
return redirect(url_for("main.compose_select"))
|
||||||
|
|
||||||
|
assets: list[ImmichAsset] = []
|
||||||
|
try:
|
||||||
|
for asset_id in asset_ids:
|
||||||
|
assets.append(immich_client.get_asset(asset_id))
|
||||||
|
except ImmichError as exc:
|
||||||
|
return render_template(
|
||||||
|
"compose_draft.html",
|
||||||
|
assets=[],
|
||||||
|
error_message=str(exc),
|
||||||
|
post_text=post_text,
|
||||||
|
instructions=instructions,
|
||||||
|
mastodon_ready=bool(mastodon_client),
|
||||||
|
)
|
||||||
|
|
||||||
|
asset_entries = []
|
||||||
|
for asset in assets:
|
||||||
|
if request.method == "POST":
|
||||||
|
alt_value = request.form.get(f"alt_text_{asset.id}", "")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
alt_value = _ensure_alt_text(asset)
|
||||||
|
except (ImmichError, AltTextGenerationError) as exc:
|
||||||
|
alt_value = ""
|
||||||
|
if not error_message:
|
||||||
|
error_message = str(exc)
|
||||||
|
asset_entries.append(
|
||||||
|
{
|
||||||
|
"id": asset.id,
|
||||||
|
"file_name": asset.file_name,
|
||||||
|
"captured_display": _humanize_timestamp(asset.captured_at),
|
||||||
|
"preview_url": url_for(
|
||||||
|
"main.asset_proxy", asset_id=asset.id, variant="preview"
|
||||||
|
),
|
||||||
|
"alt_text": alt_value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
action = request.form.get("action")
|
||||||
|
if action == "refine":
|
||||||
|
try:
|
||||||
|
post_text = generator.improve_post_text(post_text, instructions)
|
||||||
|
flash("Post refined with ChatGPT.")
|
||||||
|
except TextImprovementError as exc:
|
||||||
|
error_message = str(exc)
|
||||||
|
elif action == "post":
|
||||||
|
if not mastodon_client:
|
||||||
|
error_message = "Configure Mastodon access before posting."
|
||||||
|
elif not post_text.strip():
|
||||||
|
error_message = "Enter post text before posting."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
media_ids: list[str] = []
|
||||||
|
for entry, asset in zip(asset_entries, assets):
|
||||||
|
alt_value = entry["alt_text"].strip()
|
||||||
|
if not alt_value:
|
||||||
|
raise ValueError(
|
||||||
|
f"Alt text missing for {entry['file_name']}"
|
||||||
|
)
|
||||||
|
entry["alt_text"] = alt_value
|
||||||
|
alt_cache.set(asset.id, alt_value)
|
||||||
|
content, mime_type = immich_client.fetch_asset_content(
|
||||||
|
asset.id, "original"
|
||||||
|
)
|
||||||
|
media_id = mastodon_client.upload_media(
|
||||||
|
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.")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
except ValueError as exc:
|
||||||
|
error_message = str(exc)
|
||||||
|
except ImmichError as exc:
|
||||||
|
error_message = f"Failed to fetch media: {exc}"
|
||||||
|
except MastodonError as exc:
|
||||||
|
error_message = str(exc)
|
||||||
|
elif action:
|
||||||
|
error_message = "Unknown action."
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"compose_draft.html",
|
||||||
|
assets=asset_entries,
|
||||||
|
error_message=error_message,
|
||||||
|
post_text=post_text,
|
||||||
|
instructions=instructions,
|
||||||
|
mastodon_ready=bool(mastodon_client),
|
||||||
|
max_photos=MAX_MEDIA_ATTACHMENTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/proxy/assets/<asset_id>/<variant>")
|
@bp.route("/proxy/assets/<asset_id>/<variant>")
|
||||||
def asset_proxy(asset_id: str, variant: str):
|
def asset_proxy(asset_id: str, variant: str):
|
||||||
immich_client = current_app.immich_client
|
immich_client = current_app.immich_client
|
||||||
|
|
|
||||||
53
static/app.js
Normal file
53
static/app.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
(function () {
|
||||||
|
function updateOrderLabels(list) {
|
||||||
|
var items = list.querySelectorAll('.compose-asset');
|
||||||
|
items.forEach(function (item, index) {
|
||||||
|
var badge = item.querySelector('.order-badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.textContent = 'Photo ' + (index + 1);
|
||||||
|
}
|
||||||
|
var upButton = item.querySelector('[data-move="up"]');
|
||||||
|
var downButton = item.querySelector('[data-move="down"]');
|
||||||
|
if (upButton) {
|
||||||
|
upButton.disabled = index === 0;
|
||||||
|
}
|
||||||
|
if (downButton) {
|
||||||
|
downButton.disabled = index === items.length - 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var list = document.querySelector('.compose-form .asset-list');
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.addEventListener('click', function (event) {
|
||||||
|
var button = event.target.closest('[data-move]');
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
var direction = button.getAttribute('data-move');
|
||||||
|
var item = button.closest('.compose-asset');
|
||||||
|
if (!item || !list.contains(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (direction === 'up') {
|
||||||
|
var prev = item.previousElementSibling;
|
||||||
|
if (prev) {
|
||||||
|
prev.before(item);
|
||||||
|
}
|
||||||
|
} else if (direction === 'down') {
|
||||||
|
var next = item.nextElementSibling;
|
||||||
|
if (next) {
|
||||||
|
next.after(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateOrderLabels(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateOrderLabels(list);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -22,11 +22,29 @@ body {
|
||||||
border-bottom: 1px solid #2d2d2d;
|
border-bottom: 1px solid #2d2d2d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.site-header a {
|
.site-header a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: #9cc1ff;
|
||||||
|
}
|
||||||
|
|
||||||
.flash-messages {
|
.flash-messages {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +64,28 @@ body {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
background: #443510;
|
||||||
|
border: 1px solid #7a5c1c;
|
||||||
|
padding: 0.8rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2b74ff;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-link:hover {
|
||||||
|
background: #1f5fe0;
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
|
@ -117,3 +157,166 @@ body {
|
||||||
.notes-form button:hover {
|
.notes-form button:hover {
|
||||||
background: #1f5fe0;
|
background: #1f5fe0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selection-form .select-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-form .actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-form .actions.top-actions {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-form .actions button {
|
||||||
|
background: #2b74ff;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-card {
|
||||||
|
border: 1px solid #2d2d2d;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #1b1b1b;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-card input[type=\"checkbox\"] {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-card img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #2d2d2d;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alt-text-sample {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #bcbcbc;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
max-height: 4.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-asset {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
border: 1px solid #2d2d2d;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #151515;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-asset img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #2d2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-asset textarea {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #3b3b3b;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: #121212;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-badge {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid #3b3b3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-buttons button {
|
||||||
|
border: 1px solid #3b3b3b;
|
||||||
|
background: #1f1f1f;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-buttons button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #3b3b3b;
|
||||||
|
padding: 0.6rem;
|
||||||
|
background: #121212;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-actions button {
|
||||||
|
background: #2b74ff;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.6rem 1.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-actions button[name=\"action\"][value=\"post\"] {
|
||||||
|
background: #2ecc71;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-actions button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,13 @@
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="header-bar">
|
||||||
<h1><a href="{{ url_for('main.index') }}">Immich Alt Text Helper</a></h1>
|
<h1><a href="{{ url_for('main.index') }}">Immich Alt Text Helper</a></h1>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="{{ url_for('main.index') }}">Recent photos</a>
|
||||||
|
<a href="{{ url_for('main.compose_select') }}">Compose Mastodon post</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="container">
|
<main class="container">
|
||||||
|
|
@ -24,5 +30,6 @@
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
59
templates/compose_draft.html
Normal file
59
templates/compose_draft.html
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="intro">
|
||||||
|
<h2>Draft Mastodon post</h2>
|
||||||
|
<p>Review alt text, edit your caption, and ask ChatGPT for phrasing help before posting.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if not mastodon_ready %}
|
||||||
|
<div class="warning">Mastodon access token missing. You can refine content, but posting is disabled.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="error">{{ error_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="compose-form">
|
||||||
|
<div class="asset-list">
|
||||||
|
{% for asset in assets %}
|
||||||
|
<article class="compose-asset" data-asset-id="{{ asset.id }}">
|
||||||
|
<input type="hidden" name="asset_ids" value="{{ asset.id }}">
|
||||||
|
<div class="order-controls">
|
||||||
|
<span class="order-badge">Photo {{ loop.index }}</span>
|
||||||
|
<div class="move-buttons">
|
||||||
|
<button type="button" data-move="up" aria-label="Move photo up">↑</button>
|
||||||
|
<button type="button" data-move="down" aria-label="Move photo down">↓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image">
|
||||||
|
<img src="{{ asset.preview_url }}" alt="{{ asset.file_name }}">
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<h3>{{ asset.file_name }}</h3>
|
||||||
|
{% if asset.captured_display %}
|
||||||
|
<p class="date">{{ asset.captured_display }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<label for="alt_text_{{ asset.id }}">Alt text</label>
|
||||||
|
<textarea id="alt_text_{{ asset.id }}" name="alt_text_{{ asset.id }}" rows="4">{{ asset.alt_text }}</textarea>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-text">
|
||||||
|
<label for="post_text">Mastodon post text</label>
|
||||||
|
<textarea id="post_text" name="post_text" rows="6" placeholder="Compose your post here.">{{ post_text }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="post-instructions">
|
||||||
|
<label for="post_instructions">Instructions for ChatGPT (optional)</label>
|
||||||
|
<textarea id="post_instructions" name="post_instructions" rows="3" placeholder="Mention tone, audience, hashtags, etc.">{{ instructions }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compose-actions">
|
||||||
|
<button type="submit" name="action" value="refine">Improve text with ChatGPT</button>
|
||||||
|
<button type="submit" name="action" value="post" {% if not mastodon_ready %}disabled{% endif %}>Post to Mastodon</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
44
templates/compose_select.html
Normal file
44
templates/compose_select.html
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="intro">
|
||||||
|
<h2>Select photos for your Mastodon post</h2>
|
||||||
|
<p>Choose between 1 and {{ max_photos }} recent Immich photos. We'll pull in cached alt text where possible.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="error">{{ error_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if assets %}
|
||||||
|
<form method="post" class="selection-form">
|
||||||
|
<div class="actions top-actions">
|
||||||
|
<button type="submit">Use selected photos</button>
|
||||||
|
</div>
|
||||||
|
<div class="select-grid">
|
||||||
|
{% for asset in assets %}
|
||||||
|
<label class="select-card">
|
||||||
|
<input type="checkbox" name="asset_ids" value="{{ asset.id }}" {% if selected_ids and asset.id in selected_ids %}checked{% endif %}>
|
||||||
|
<div class="thumbnail">
|
||||||
|
<img src="{{ url_for('main.asset_proxy', asset_id=asset.id, variant='thumbnail') }}" alt="{{ asset.file_name }}">
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<div class="file-name">{{ asset.file_name }}</div>
|
||||||
|
{% if asset.captured_display %}
|
||||||
|
<div class="date">{{ asset.captured_display }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if asset.alt_text %}
|
||||||
|
<div class="alt-text-sample">{{ asset.alt_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Use selected photos</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>No assets available right now.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="intro">
|
<section class="intro">
|
||||||
<p>Choose a recent Immich photo to request Mastodon-friendly alt text.</p>
|
<p>Choose a recent Immich photo to request Mastodon-friendly alt text.</p>
|
||||||
|
<p><a class="button-link" href="{{ url_for('main.compose_select') }}">Compose a Mastodon post</a></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if error_message %}
|
{% if error_message %}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue