"""HTTP endpoints for the Journal feature. Includes the conversational journal endpoints (config / today / day / days / trigger-prep / moments) plus the ambient-context surface lifted from the old Briefing routes (RSS feeds, weather, news, RSS reactions, article-discuss). The ambient endpoints read locations + temp_unit + topic preferences from the ``journal_config`` user setting. """ from __future__ import annotations import asyncio import datetime import json import logging from zoneinfo import ZoneInfo from quart import Blueprint, jsonify, request from sqlalchemy import select from fabledassistant.auth import get_current_user_id, login_required from fabledassistant.models import Conversation, Message, async_session from fabledassistant.services import weather as weather_svc from fabledassistant.services.journal_prep import ensure_daily_prep_message from fabledassistant.services.journal_scheduler import ( DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG, update_user_schedule, ) from fabledassistant.services.journal_search import search_journal from fabledassistant.services.moments import delete_moment, update_moment from fabledassistant.services.settings import get_setting, set_setting logger = logging.getLogger(__name__) journal_bp = Blueprint("journal", __name__, url_prefix="/api/journal") def _resolve_tz(tz_str: str) -> ZoneInfo: try: return ZoneInfo(tz_str) except Exception: return ZoneInfo("UTC") def _today_in_tz(tz_str: str, *, day_rollover_hour: int) -> datetime.date: tz = _resolve_tz(tz_str) now = datetime.datetime.now(tz) if now.hour < day_rollover_hour: return (now - datetime.timedelta(days=1)).date() return now.date() async def _user_timezone(user_id: int) -> str: return await get_setting(user_id, "user_timezone", "UTC") or "UTC" async def _resolve_config(user_id: int) -> dict: raw = await get_setting(user_id, "journal_config", "") config: dict = {} if raw: try: parsed = json.loads(raw) if isinstance(raw, str) else raw if isinstance(parsed, dict): config = parsed except Exception: logger.warning("Invalid journal_config for user %d", user_id) return {**DEFAULT_JOURNAL_CONFIG, **config} def _valid_location_keys(cfg: dict) -> set[str]: """Keys in ``cfg.locations`` that have a usable lat/lon. Anything else (orphaned cache rows, locations the user typed but didn't geocode) is excluded so it can't render as a fake site in the UI.""" locations = cfg.get("locations") or {} return { key for key, loc in locations.items() if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None } @journal_bp.get("/config") @login_required async def get_config(): user_id = get_current_user_id() return jsonify(await _resolve_config(user_id)) @journal_bp.put("/config") @login_required async def put_config(): user_id = get_current_user_id() body = await request.get_json() if not isinstance(body, dict): return jsonify({"error": "config must be an object"}), 400 await set_setting(user_id, "journal_config", json.dumps(body)) await update_user_schedule(user_id) # Trigger a background weather refresh for any newly-saved location with # valid lat/lon. Without this, the cache row for the location doesn't # exist (or stays stale) until the user clicks the manual refresh button, # so the journal weather panel renders empty for newly-entered sites. valid_locs = [ (key, loc) for key, loc in (body.get("locations") or {}).items() if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None ] if valid_locs: asyncio.create_task(_refresh_locations_in_background(user_id, valid_locs)) return jsonify({"ok": True}) async def _refresh_locations_in_background( user_id: int, locations: list[tuple[str, dict]] ) -> None: for key, loc in locations: try: await weather_svc.refresh_location_cache( user_id=user_id, location_key=key, location_label=loc.get("label", key), lat=loc["lat"], lon=loc["lon"], ) except Exception: logger.warning( "Post-save weather refresh failed for user %d / %s", user_id, key, exc_info=True, ) @journal_bp.get("/today") @login_required async def get_today(): user_id = get_current_user_id() config = await _resolve_config(user_id) tz_str = await _user_timezone(user_id) today = _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4))) await ensure_daily_prep_message( user_id=user_id, day_date=today, user_timezone=tz_str ) return await _day_payload(user_id=user_id, day_date=today) @journal_bp.get("/day/") @login_required async def get_day(iso_date: str): user_id = get_current_user_id() try: day = datetime.date.fromisoformat(iso_date) except ValueError: return jsonify({"error": "invalid date"}), 400 return await _day_payload(user_id=user_id, day_date=day) @journal_bp.get("/days") @login_required async def list_days(): user_id = get_current_user_id() async with async_session() as session: stmt = ( select(Conversation.day_date) .where( Conversation.user_id == user_id, Conversation.conversation_type == "journal", Conversation.day_date.is_not(None), ) .order_by(Conversation.day_date.desc()) ) rows = (await session.execute(stmt)).scalars().all() return jsonify({"days": [d.isoformat() for d in rows]}) @journal_bp.post("/trigger-prep") @login_required async def trigger_prep(): user_id = get_current_user_id() body = await request.get_json(silent=True) or {} iso_date = body.get("date") config = await _resolve_config(user_id) tz_str = await _user_timezone(user_id) day = ( datetime.date.fromisoformat(iso_date) if iso_date else _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4))) ) msg = await ensure_daily_prep_message( user_id=user_id, day_date=day, user_timezone=tz_str, force=True ) return jsonify({"ok": True, "message_id": msg.id}) @journal_bp.get("/moments") @login_required async def list_moments(): user_id = get_current_user_id() args = request.args df = args.get("date_from") dt = args.get("date_to") person_id = args.get("person_id", type=int) place_id = args.get("place_id", type=int) tag = args.get("tag") query = args.get("query") pinned_only = args.get("pinned_only", "false").lower() == "true" limit = args.get("limit", default=50, type=int) results = await search_journal( user_id=user_id, query=query, person_id=person_id, place_id=place_id, tag=tag, date_from=datetime.date.fromisoformat(df) if df else None, date_to=datetime.date.fromisoformat(dt) if dt else None, limit=limit, ) if pinned_only: results = [r for r in results if r.get("pinned")] return jsonify({"moments": results}) @journal_bp.patch("/moments/") @login_required async def patch_moment(moment_id: int): user_id = get_current_user_id() body = await request.get_json() if not isinstance(body, dict): return jsonify({"error": "body must be an object"}), 400 moment = await update_moment( user_id=user_id, moment_id=moment_id, content=body.get("content"), tags=body.get("tags"), pinned=body.get("pinned"), person_ids=body.get("person_ids"), place_ids=body.get("place_ids"), task_ids=body.get("task_ids"), note_ids=body.get("note_ids"), ) if moment is None: return jsonify({"error": "not found"}), 404 return jsonify(moment.to_dict()) @journal_bp.delete("/moments/") @login_required async def remove_moment(moment_id: int): user_id = get_current_user_id() deleted = await delete_moment(user_id=user_id, moment_id=moment_id) if not deleted: return jsonify({"error": "not found"}), 404 return jsonify({"ok": True}) # ─────────────────────────────────────────────────────────────────────────────── # Ambient endpoints (lifted from the old briefing surface). # ─────────────────────────────────────────────────────────────────────────────── async def _journal_temp_unit(user_id: int) -> str: cfg = await _resolve_config(user_id) unit = cfg.get("temp_unit", "C") return unit if unit in ("C", "F") else "C" # ── Weather ─────────────────────────────────────────────────────────────────── _STALE_THRESHOLD_SECONDS = 4 * 3600 # 4 hours — start refreshing well before the 7-day forecast window slides past today def _is_stale(cache_row) -> bool: if cache_row is None or cache_row.fetched_at is None: return True age = (datetime.datetime.now(datetime.timezone.utc) - cache_row.fetched_at).total_seconds() return age > _STALE_THRESHOLD_SECONDS async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> None: """Best-effort refresh of stale cache rows. Silently no-ops if the user's config has no usable lat/lon for a given location_key.""" cfg = await _resolve_config(user_id) locations = cfg.get("locations") or {} for key in stale_keys: loc = locations.get(key) if not loc or not loc.get("lat") or not loc.get("lon"): continue try: await weather_svc.refresh_location_cache( user_id=user_id, location_key=key, location_label=loc.get("label", key), lat=loc["lat"], lon=loc["lon"], ) except Exception: logger.warning("Background weather refresh failed for user %d / %s", user_id, key, exc_info=True) @journal_bp.get("/weather") @login_required async def get_weather(): user_id = get_current_user_id() cfg = await _resolve_config(user_id) valid_keys = _valid_location_keys(cfg) rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys) temp_unit = await _journal_temp_unit(user_id) # Kick off a best-effort background refresh for stale rows so the next page # load gets fresh data; we still serve whatever's currently cached now. stale_keys = {row.location_key for row in rows if _is_stale(row)} if stale_keys: asyncio.create_task(_refresh_stale_in_background(user_id, stale_keys)) cards = [ card for row in rows if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None ] return jsonify({"locations": cards, "temp_unit": temp_unit}) @journal_bp.get("/weather/current") @login_required async def get_current_weather(): """Live current temperature + conditions for the user's primary location.""" user_id = get_current_user_id() cfg = await _resolve_config(user_id) temp_unit = await _journal_temp_unit(user_id) locations = cfg.get("locations") or {} loc = locations.get("home") or locations.get("work") if not loc or not loc.get("lat") or not loc.get("lon"): return jsonify({"error": "No location configured"}), 404 current = await weather_svc.fetch_current_conditions(loc["lat"], loc["lon"]) if current is None: return jsonify({"error": "Failed to fetch current conditions"}), 502 temp = current["temperature"] if temp is not None and temp_unit == "F": temp = temp * 9 / 5 + 32 current["temperature"] = round(temp) if temp is not None else None current["temp_unit"] = temp_unit current["location"] = loc.get("label") or "Home" return jsonify(current) @journal_bp.post("/weather/refresh") @login_required async def refresh_weather(): user_id = get_current_user_id() cfg = await _resolve_config(user_id) temp_unit = await _journal_temp_unit(user_id) for key, loc in (cfg.get("locations") or {}).items(): if not loc.get("lat") or not loc.get("lon"): continue try: await weather_svc.refresh_location_cache( user_id=user_id, location_key=key, location_label=loc.get("label", key), lat=loc["lat"], lon=loc["lon"], ) except Exception: logger.warning("Failed to refresh weather for %s", key, exc_info=True) valid_keys = _valid_location_keys(cfg) rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys) cards = [ card for row in rows if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None ] return jsonify({"locations": cards, "temp_unit": temp_unit}) @journal_bp.post("/weather/geocode") @login_required async def geocode_location(): data = await request.get_json() query = (data.get("query") or "").strip() if not query: return jsonify({"error": "query required"}), 400 try: lat, lon, label = await weather_svc.geocode(query) return jsonify({"lat": lat, "lon": lon, "label": label}) except ValueError as e: return jsonify({"error": str(e)}), 404 async def _day_payload(*, user_id: int, day_date: datetime.date): async with async_session() as session: conv_stmt = select(Conversation).where( Conversation.user_id == user_id, Conversation.conversation_type == "journal", Conversation.day_date == day_date, ) conv = (await session.execute(conv_stmt)).scalar_one_or_none() if conv is None: return jsonify({ "day_date": day_date.isoformat(), "conversation": None, "messages": [], }) msgs_stmt = ( select(Message) .where(Message.conversation_id == conv.id) .order_by(Message.created_at) ) messages = (await session.execute(msgs_stmt)).scalars().all() # conv.to_dict() recomputes message_count from the `messages` # relationship, which isn't eager-loaded here, so it would report 0. # We already have the real list — override with the known count, same # convention the chat-list path uses (services/chat.py). conv_dict = conv.to_dict() conv_dict["message_count"] = len(messages) return jsonify({ "day_date": day_date.isoformat(), "conversation": conv_dict, "messages": [m.to_dict() for m in messages], })