diff --git a/README.md b/README.md index 977b35c..1a82dbd 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Kin-CRM is published to the Unraid Community Applications store from this reposi - The official template points at `ghcr.io/ock666/kin-crm:latest` and installs with app data in `/mnt/user/appdata/kin-crm` mapped to `/data` inside the container. - If a listing built by a third party shows up first, prefer the one whose **Support/Project** links point to `github.com/ock666/Kin-CRM` — the author's listing is the source of truth. -Your data lives in the Docker volume at `/mnt/user/appdata/personal-crm_kin_data` mapped to `/data` inside the container — a SQLite database plus any cached Instagram session files. Back it up like any other volume, or export from the in-app Export page anytime. +Your data lives in the Docker volume at `/mnt/user/appdata/personal-crm_kin_data` mapped to `/data` inside the container — a SQLite database. Back it up like any other volume, or export from the in-app Export page anytime. ### Development @@ -306,10 +306,6 @@ Kin can push birthdays and notable dates into any calendar that can subscribe to - Keep the token secret — anyone with the link can see the synced dates. Rotate it by turning the feed off, saving, then turning it back on. - Only the dates you've chosen to sync are included; archived people are skipped. -### Instagram integration (use with caution) - -Kin includes an optional, unofficial Instagram reader using [instagrapi](https://github.com/subzeroid/instagrapi). It's against Instagram's Terms of Service. Use a throwaway/secondary account only — never your primary account. Nothing is ever posted or messaged. Posts land in the Review Queue for your approval. Leave it disabled if you'd rather not risk it; everything else works fine without it. -
@@ -334,7 +330,7 @@ Kin includes an optional, unofficial Instagram reader using [instagrapi](https:/ - **Scratchpad**: fleeting "bring up next time" reminders pinned on the person's profile - **Notable people**: lightweight references to people in their life without full CRM profiles - **Notable dates**: anniversaries, kids' birthdays, recurring dates -- **In-page photo viewer**: click any photo thumbnail (memories, timeline, gallery, Instagram) to dim the page and view the full-size image — dismiss with ✕, Esc, or a click on the backdrop +- **In-page photo viewer**: click any photo thumbnail (memories, timeline, gallery) to dim the page and view the full-size image — dismiss with ✕, Esc, or a click on the backdrop ### Journal: quick-capture logging - One text box. Optional title, date, location, energy cost (low/medium/high), event type @@ -394,7 +390,7 @@ Kin includes an optional, unofficial Instagram reader using [instagrapi](https:/ - Offline-first: app shell caches and works without a connection ### Data ownership -- Full JSON export (people, journal, tags, conflicts, chat transcripts, resolution plans, gift ideas, Instagram posts, settings) +- Full JSON export (people, journal, tags, conflicts, chat transcripts, resolution plans, gift ideas, settings) - CSV export (people + journal) - JSON/CSV import (get-or-create by name, non-destructive) - All data in a local SQLite database (or Postgres if you prefer) diff --git a/app/config.py b/app/config.py index 9fedc91..850a561 100644 --- a/app/config.py +++ b/app/config.py @@ -31,16 +31,14 @@ def _load_or_create_secret() -> str: class Settings: APP_NAME = "Kin — Personal Relationship Manager" - APP_VERSION = "2026.09.2" # date-based: YYYY.MM.N (N = release within the month) + APP_VERSION = "2026.09.4" # date-based: YYYY.MM.N (N = release within the month) DATABASE_URL: str = (os.environ.get("DATABASE_URL") or f"sqlite:///{DATA_DIR}/app.db") SESSION_SECRET: str = _load_or_create_secret() DATA_DIR: Path = DATA_DIR UPLOAD_DIR: Path = DATA_DIR / "uploads" - INSTAGRAM_SESSION_DIR: Path = DATA_DIR / "ig_sessions" TIMEZONE: str = os.environ.get("TZ", "UTC") DISABLE_SCHEDULER: bool = os.environ.get("DISABLE_SCHEDULER", "0") == "1" settings = Settings() settings.UPLOAD_DIR.mkdir(parents=True, exist_ok=True) -settings.INSTAGRAM_SESSION_DIR.mkdir(parents=True, exist_ok=True) diff --git a/app/models.py b/app/models.py index e1f9943..32a71b3 100644 --- a/app/models.py +++ b/app/models.py @@ -128,12 +128,6 @@ class Person(Base): # Immich linkage immich_person_id = Column(String(100), nullable=True, index=True) - # Instagram linkage - instagram_username = Column(String(255), nullable=True) - instagram_enabled = Column(Boolean, default=False) - instagram_last_checked = Column(DateTime, nullable=True) - instagram_last_error = Column(Text, nullable=True) - # Check-in cadence (AuDHD-friendly nudges) checkin_cadence_days = Column(Integer, nullable=True) # None = no reminder checkin_snoozed_until = Column(Date, nullable=True) @@ -147,7 +141,6 @@ class Person(Base): tags = relationship("Tag", secondary=person_tags, back_populates="people") notable_dates = relationship("NotableDate", back_populates="person", cascade="all, delete-orphan") - instagram_posts = relationship("InstagramPost", back_populates="person", cascade="all, delete-orphan") birthday_drafts = relationship("BirthdayMessageDraft", back_populates="person", cascade="all, delete-orphan") scratchpad_items = relationship( "ScratchpadItem", back_populates="person", cascade="all, delete-orphan", @@ -286,26 +279,6 @@ class ReviewStatus(str, enum.Enum): skipped = "skipped" -class InstagramPost(Base): - __tablename__ = "instagram_posts" - - id = Column(Integer, primary_key=True) - person_id = Column(Integer, ForeignKey("people.id", ondelete="CASCADE"), nullable=False) - ig_post_id = Column(String(100), nullable=False, index=True) - caption = Column(Text, nullable=True) - media_url = Column(String(1000), nullable=True) - permalink = Column(String(500), nullable=True) - post_type = Column(String(50), nullable=True) - posted_at = Column(DateTime, nullable=True) - fetched_at = Column(DateTime, default=utcnow) - status = Column(Enum(ReviewStatus), default=ReviewStatus.pending) - imported_as_journal_entry_id = Column(Integer, ForeignKey("journal_entries.id"), nullable=True) - - person = relationship("Person", back_populates="instagram_posts") - - __table_args__ = (UniqueConstraint("person_id", "ig_post_id", name="uq_person_ig_post"),) - - class BirthdayMessageDraft(Base): __tablename__ = "birthday_message_drafts" diff --git a/app/render.py b/app/render.py index 2a9df16..9b7e9d4 100644 --- a/app/render.py +++ b/app/render.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from .config import settings -from .models import InstagramPost, BirthdayMessageDraft, ReviewStatus +from .models import BirthdayMessageDraft, ReviewStatus from .services import whatsnew TEMPLATES_DIR = Path(__file__).parent / "templates" @@ -20,9 +20,8 @@ def _pending_review_count(db: Session | None) -> int: if db is None: return 0 - ig = db.query(InstagramPost).filter_by(status=ReviewStatus.pending).count() bd = db.query(BirthdayMessageDraft).filter_by(status=ReviewStatus.pending).count() - return ig + bd + return bd def _wrapped_ready(db: Session | None) -> bool: diff --git a/app/routers/api/export.py b/app/routers/api/export.py index 1060d68..45b76a2 100644 --- a/app/routers/api/export.py +++ b/app/routers/api/export.py @@ -38,8 +38,6 @@ def export_json(db: Session = Depends(get_db), user=Depends(get_current_api_user "checkin_cadence_days": p.checkin_cadence_days, "last_contact_date": p.last_contact_date.isoformat() if p.last_contact_date else None, "relationship_state": p.relationship_state.value if p.relationship_state else "none", - "instagram_username": p.instagram_username, - "instagram_enabled": p.instagram_enabled, "tags": [t.name for t in p.tags], "journal_entries": [{ "date": e.entry_date.isoformat(), "title": e.title, "body": e.body, diff --git a/app/routers/api/people.py b/app/routers/api/people.py index 55e10df..7211123 100644 --- a/app/routers/api/people.py +++ b/app/routers/api/people.py @@ -53,8 +53,6 @@ def _person_response(p: Person) -> dict: "checkin_cadence_days": p.checkin_cadence_days, "last_contact_date": p.last_contact_date.isoformat() if p.last_contact_date else None, "relationship_state": p.relationship_state.value if p.relationship_state else "none", - "instagram_username": p.instagram_username, - "instagram_enabled": p.instagram_enabled, "archived": p.archived, "tags": [t.name for t in p.tags], "friend_rank": rank.get("score", 0), @@ -98,8 +96,6 @@ def create_person(body: PersonCreate, db: Session = Depends(get_db), user=Depend hobbies=body.hobbies, bio=body.bio, checkin_cadence_days=_safe_int(body.checkin_cadence_days), - instagram_username=body.instagram_username, - instagram_enabled=body.instagram_enabled, archived=body.archived, ) db.add(p) diff --git a/app/routers/api/settings.py b/app/routers/api/settings.py index 1d4317d..8a78a8e 100644 --- a/app/routers/api/settings.py +++ b/app/routers/api/settings.py @@ -40,11 +40,6 @@ class AiSettingsUpdate(BaseModel): support_chat_model: str | None = None -class InstagramSettingsUpdate(BaseModel): - instagram_username: str | None = None - instagram_password: str | None = None - - class MfaDisableRequest(BaseModel): password: str @@ -107,18 +102,6 @@ def update_ai(body: AiSettingsUpdate, db: Session = Depends(get_db), user=Depend return get_all_settings(db) -@router.put("/instagram") -def update_instagram(body: InstagramSettingsUpdate, db: Session = Depends(get_db), user=Depends(get_current_api_user)): - updates = {} - if body.instagram_username is not None: - updates["instagram_username"] = body.instagram_username.strip() - if body.instagram_password: - updates["instagram_password"] = body.instagram_password - if updates: - set_many(db, updates) - return get_all_settings(db) - - # --- MFA --- @router.get("/mfa/setup") diff --git a/app/routers/api/stats.py b/app/routers/api/stats.py index e375060..4cbd9be 100644 --- a/app/routers/api/stats.py +++ b/app/routers/api/stats.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from ...database import get_db -from ...models import InstagramPost, BirthdayMessageDraft, ReviewStatus +from ...models import BirthdayMessageDraft, ReviewStatus from ...services import gamification from .deps import get_current_api_user @@ -12,20 +12,13 @@ @router.get("/reviews") def get_reviews(db: Session = Depends(get_db), user=Depends(get_current_api_user)): - ig = db.query(InstagramPost).filter_by(status=ReviewStatus.pending).order_by( - InstagramPost.posted_at.desc()).all() bd = db.query(BirthdayMessageDraft).filter_by(status=ReviewStatus.pending).order_by( - BirthdayMessageDraft.created_at.desc()).all() + BirthdayMessageDraft.generated_at.desc()).all() return { - "instagram_posts": [ - {"id": p.id, "person_name": p.person.name if p.person else "", "caption": p.caption, - "media_url": p.media_url, "permalink": p.permalink, "post_type": p.post_type, - "posted_at": p.posted_at.isoformat() if p.posted_at else None} - for p in ig - ], "birthday_drafts": [ - {"id": d.id, "person_name": d.person.name if d.person else "", "message": d.message, - "created_at": d.created_at.isoformat() if d.created_at else None} + {"id": d.id, "person_name": d.person.name if d.person else "", + "message": d.draft_text, + "created_at": d.generated_at.isoformat() if d.generated_at else None} for d in bd ], } diff --git a/app/routers/export.py b/app/routers/export.py index ad531ca..8c7a3a0 100644 --- a/app/routers/export.py +++ b/app/routers/export.py @@ -49,8 +49,6 @@ def export_json(db: Session = Depends(get_db), user=Depends(current_user)): "last_contact_date": p.last_contact_date.isoformat() if p.last_contact_date else None, "reminders_dismissed": p.reminders_dismissed, "relationship_state": p.relationship_state.value if p.relationship_state else "none", - "instagram_username": p.instagram_username, - "instagram_enabled": p.instagram_enabled, "tags": [t.name for t in p.tags], "notable_dates": [{"label": nd.label, "month": nd.month, "day": nd.day, "year": nd.year, "recurring": nd.recurring, "notes": nd.notes} for nd in p.notable_dates], @@ -70,12 +68,6 @@ def export_json(db: Session = Depends(get_db), user=Depends(current_user)): "created_at": cm.created_at.isoformat() if cm.created_at else None, } for cm in (c.chat_messages or [])], } for c in p.conflict_logs], - "instagram_posts": [{ - "ig_post_id": ip.ig_post_id, "caption": ip.caption, - "media_url": ip.media_url, "permalink": ip.permalink, - "post_type": ip.post_type, "posted_at": ip.posted_at.isoformat() if ip.posted_at else None, - "status": ip.status.value, - } for ip in p.instagram_posts], "journal_entries": [{ "date": e.entry_date.isoformat(), "title": e.title, "body": e.body, "event_type": e.event_type.value if e.event_type else None, diff --git a/app/routers/people.py b/app/routers/people.py index d32d7da..92e1efd 100644 --- a/app/routers/people.py +++ b/app/routers/people.py @@ -173,8 +173,7 @@ def person_update( birthday_year: str = Form(""), how_we_met: str = Form(""), met_date: str = Form(""), location: str = Form(""), phone: str = Form(""), email: str = Form(""), notes: str = Form(""), occupation: str = Form(""), hobbies: str = Form(""), bio: str = Form(""), - checkin_cadence_days: str = Form(""), instagram_username: str = Form(""), - instagram_enabled: str = Form(""), + checkin_cadence_days: str = Form(""), ): if not user: return RedirectResponse("/login") @@ -199,8 +198,6 @@ def person_update( person.hobbies = hobbies.strip() or None person.bio = bio.strip() or None person.checkin_cadence_days = _safe_int(checkin_cadence_days) - person.instagram_username = instagram_username.strip().lstrip("@") or None - person.instagram_enabled = bool(instagram_enabled) db.commit() gamification.award_and_flash(request, db, "PROFILE_UPDATED") return RedirectResponse(f"/people/{person.id}", status_code=303) diff --git a/app/routers/reviews.py b/app/routers/reviews.py index b97ee5b..c024c8a 100644 --- a/app/routers/reviews.py +++ b/app/routers/reviews.py @@ -7,12 +7,10 @@ from ..database import get_db from ..deps import current_user from ..models import ( - InstagramPost, BirthdayMessageDraft, GiftIdea, GiftStatus, ReviewStatus, - JournalEntry, JournalImage, EventType, + BirthdayMessageDraft, GiftIdea, GiftStatus, ReviewStatus, ) from ..render import render from ..services import birthdays as bday_service -from ..services import instagram_poll from ..services.ai_client import get_client_from_settings, build_person_context, AIError router = APIRouter() @@ -22,7 +20,6 @@ def reviews_page(request: Request, db: Session = Depends(get_db), user=Depends(current_user)): if not user: return RedirectResponse("/login") - ig_posts = db.query(InstagramPost).filter_by(status=ReviewStatus.pending).order_by(InstagramPost.posted_at.desc()).all() bday_drafts = db.query(BirthdayMessageDraft).filter_by(status=ReviewStatus.pending).all() approved_bday = db.query(BirthdayMessageDraft).filter_by(status=ReviewStatus.approved).all() @@ -32,46 +29,13 @@ def reviews_page(request: Request, db: Session = Depends(get_db), user=Depends(c gifts_by_key = {(g.person_id, g.year): g for g in gift_ideas} return render(request, "reviews.html", db=db, user=user, active="reviews", - ig_posts=ig_posts, bday_drafts=bday_drafts, approved_bday=approved_bday, + bday_drafts=bday_drafts, approved_bday=approved_bday, gifts_by_key=gifts_by_key) @router.post("/reviews/run-now") def run_now(request: Request, db: Session = Depends(get_db), user=Depends(current_user)): bday_service.generate_birthday_drafts(db) - instagram_poll.poll_all(db) - return RedirectResponse("/reviews", status_code=303) - - -@router.post("/reviews/instagram/{post_id}/approve") -def approve_instagram(post_id: int, db: Session = Depends(get_db), user=Depends(current_user)): - post = db.get(InstagramPost, post_id) - if post: - entry = JournalEntry( - author_user_id=user.id if user else None, - title=f"Instagram post from @{post.person.instagram_username}", - body=post.caption or "(no caption)", - entry_date=post.posted_at.date() if post.posted_at else dt.date.today(), - event_type=EventType.instagram, - source="instagram", - ) - entry.people.append(post.person) - db.add(entry) - db.flush() - if post.media_url: - db.add(JournalImage(journal_entry_id=entry.id, upload_path=post.media_url, caption="From Instagram")) - post.status = ReviewStatus.approved - post.imported_as_journal_entry_id = entry.id - db.commit() - return RedirectResponse("/reviews", status_code=303) - - -@router.post("/reviews/instagram/{post_id}/dismiss") -def dismiss_instagram(post_id: int, db: Session = Depends(get_db), user=Depends(current_user)): - post = db.get(InstagramPost, post_id) - if post: - post.status = ReviewStatus.dismissed - db.commit() return RedirectResponse("/reviews", status_code=303) diff --git a/app/routers/settings.py b/app/routers/settings.py index 5175010..8dcaa76 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -252,16 +252,6 @@ def tts_sample(request: Request, db: Session = Depends(get_db), user=Depends(cur return JSONResponse({"error": "Sample failed"}, status_code=400) -@router.post("/settings/instagram") -def save_instagram(request: Request, db: Session = Depends(get_db), user=Depends(current_user), - instagram_username: str = Form(""), instagram_password: str = Form("")): - values = {"instagram_username": instagram_username.strip()} - if instagram_password: - values["instagram_password"] = instagram_password - set_many(db, values) - return RedirectResponse("/settings", status_code=303) - - @router.post("/settings/calendar") def save_calendar(request: Request, db: Session = Depends(get_db), user=Depends(current_user), calendar_ics_enabled: str = Form("0"), diff --git a/app/schemas/people.py b/app/schemas/people.py index e7a4ff4..50ab2a5 100644 --- a/app/schemas/people.py +++ b/app/schemas/people.py @@ -20,8 +20,6 @@ class PersonCreate(BaseModel): hobbies: str | None = None bio: str | None = None checkin_cadence_days: int | None = None - instagram_username: str | None = None - instagram_enabled: bool = False archived: bool = False @@ -51,8 +49,6 @@ class PersonResponse(BaseModel): checkin_cadence_days: int | None = None last_contact_date: str | None = None relationship_state: str | None = None - instagram_username: str | None = None - instagram_enabled: bool = False archived: bool = False tags: list[str] = [] friend_rank: int | None = None diff --git a/app/services/friend_rank.py b/app/services/friend_rank.py index ea67820..992359f 100644 --- a/app/services/friend_rank.py +++ b/app/services/friend_rank.py @@ -53,7 +53,7 @@ def compute_friend_rank(person) -> dict: (bool(person.how_we_met), "how you met"), (bool(person.occupation), "their occupation"), (bool(person.hobbies), "their hobbies/interests"), - (bool(person.email or person.phone or person.instagram_username), "contact info"), + (bool(person.email or person.phone), "contact info"), (bool(person.notable_dates), "a notable date (anniversary, etc.)"), (bool(person.notable_people_refs), "notable people in their life"), (bool(person.immich_person_id), "a linked photo"), diff --git a/app/services/gamification.py b/app/services/gamification.py index 2ef76b1..75d10a7 100644 --- a/app/services/gamification.py +++ b/app/services/gamification.py @@ -223,7 +223,7 @@ def _locked(slug: str) -> bool: _unlock("details_matter") if _locked("well_connected"): has_contact = db.query(Person).filter( - (Person.email.isnot(None)) | (Person.phone.isnot(None)) | (Person.instagram_username.isnot(None)) + (Person.email.isnot(None)) | (Person.phone.isnot(None)) ).count() >= 1 if has_contact: _unlock("well_connected") diff --git a/app/services/instagram_client.py b/app/services/instagram_client.py deleted file mode 100644 index e12f399..0000000 --- a/app/services/instagram_client.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Instagram integration using the unofficial `instagrapi` library. - -IMPORTANT CAVEATS (documented for the user in Settings UI + README too): - - This uses an unofficial, reverse-engineered API. It is against Instagram's - Terms of Service and carries a real (if generally low, for light/read-only - personal use) risk of the login account being challenged or restricted. - - Never use your only/important Instagram account for this - consider a - secondary account that follows the people you want to track. - - Instagram frequently changes behaviour; this integration may break and - need updating. - - Everything fetched lands in a pending review queue - nothing is ever - posted, messaged, or acted on automatically (human in the loop). -""" -from __future__ import annotations - -from pathlib import Path -from typing import Optional - -from ..config import settings - - -class InstagramError(Exception): - pass - - -def _session_path(username: str) -> Path: - safe = "".join(c for c in username if c.isalnum() or c in "._-") - return settings.INSTAGRAM_SESSION_DIR / f"{safe}.json" - - -class InstagramClient: - def __init__(self, username: str, password: str): - if not username or not password: - raise InstagramError("Instagram is not configured. Add credentials in Settings.") - self.username = username - self.password = password - self._cl = None - - def _client(self): - if self._cl is not None: - return self._cl - try: - from instagrapi import Client - except ImportError: - raise InstagramError( - "instagrapi is not installed in this image. Rebuild the container " - "with requirements.txt as provided." - ) - cl = Client() - session_file = _session_path(self.username) - try: - if session_file.exists(): - cl.load_settings(session_file) - cl.login(self.username, self.password) - else: - cl.login(self.username, self.password) - cl.dump_settings(session_file) - except Exception as e: - raise InstagramError(f"Instagram login failed: {e}") - self._cl = cl - return cl - - def get_recent_posts(self, target_username: str, count: int = 12) -> list[dict]: - cl = self._client() - try: - user_id = cl.user_id_from_username(target_username) - medias = cl.user_medias(user_id, amount=count) - except Exception as e: - raise InstagramError(f"Could not fetch posts for @{target_username}: {e}") - - out = [] - for m in medias: - out.append({ - "ig_post_id": str(m.pk), - "caption": m.caption_text or "", - "media_url": str(m.thumbnail_url) if m.thumbnail_url else ( - str(m.video_url) if m.video_url else None - ), - "permalink": f"https://www.instagram.com/p/{m.code}/", - "post_type": str(m.media_type), - "posted_at": m.taken_at, - }) - return out - - -def get_client_from_settings(db) -> Optional["InstagramClient"]: - from ..settings_store import get_setting - username = get_setting(db, "instagram_username") - password = get_setting(db, "instagram_password") - if not username or not password: - return None - return InstagramClient(username, password) diff --git a/app/services/instagram_poll.py b/app/services/instagram_poll.py deleted file mode 100644 index f48e10b..0000000 --- a/app/services/instagram_poll.py +++ /dev/null @@ -1,78 +0,0 @@ -import datetime as dt -import logging - -from sqlalchemy.orm import Session - -from ..models import Person, InstagramPost, ReviewStatus -from .instagram_client import InstagramClient, InstagramError -from ..settings_store import get_setting - -logger = logging.getLogger(__name__) - - -def poll_all(db: Session) -> dict: - """Poll Instagram for every person with instagram_enabled + a username set. - New posts are inserted as pending InstagramPost rows for human review - - never auto-imported. Returns a summary dict for logging/telemetry.""" - username = get_setting(db, "instagram_username") - password = get_setting(db, "instagram_password") - summary = {"checked": 0, "new_posts": 0, "errors": []} - - if not username or not password: - summary["errors"].append("Instagram credentials not configured") - return summary - - people = ( - db.query(Person) - .filter(Person.instagram_enabled.is_(True)) - .filter(Person.instagram_username.isnot(None)) - .filter(Person.archived.is_(False)) - .all() - ) - if not people: - return summary - - try: - client = InstagramClient(username, password) - except InstagramError as e: - summary["errors"].append(str(e)) - return summary - - for person in people: - summary["checked"] += 1 - try: - posts = client.get_recent_posts(person.instagram_username, count=12) - person.instagram_last_error = None - except InstagramError as e: - logger.warning("Instagram check failed for %s: %s", person.instagram_username, e) - person.instagram_last_error = str(e) - summary["errors"].append(f"@{person.instagram_username}: {e}") - continue - finally: - person.instagram_last_checked = dt.datetime.utcnow() - db.add(person) - - for post in posts: - exists = ( - db.query(InstagramPost) - .filter_by(person_id=person.id, ig_post_id=post["ig_post_id"]) - .first() - ) - if exists: - continue - row = InstagramPost( - person_id=person.id, - ig_post_id=post["ig_post_id"], - caption=post.get("caption"), - media_url=post.get("media_url"), - permalink=post.get("permalink"), - post_type=post.get("post_type"), - posted_at=post.get("posted_at"), - status=ReviewStatus.pending, - ) - db.add(row) - summary["new_posts"] += 1 - - db.commit() - - return summary diff --git a/app/services/scheduler.py b/app/services/scheduler.py index 8aaed41..8997132 100644 --- a/app/services/scheduler.py +++ b/app/services/scheduler.py @@ -5,7 +5,7 @@ from ..database import SessionLocal from ..settings_store import get_setting -from . import birthdays, instagram_poll, push as push_service, resolution_plans, wrapped as wrapped_service +from . import birthdays, push as push_service, resolution_plans, wrapped as wrapped_service logger = logging.getLogger(__name__) @@ -22,15 +22,6 @@ def run_daily_jobs(): except Exception: logger.exception("Birthday draft generation failed") - try: - summary = instagram_poll.poll_all(db) - if summary["new_posts"]: - logger.info("Instagram poll found %d new post(s)", summary["new_posts"]) - if summary["errors"]: - logger.info("Instagram poll errors: %s", summary["errors"]) - except Exception: - logger.exception("Instagram poll failed") - try: push_service.send_push_notifications(db) except Exception: @@ -82,6 +73,20 @@ def start_scheduler(): finally: db.close() + # Hotfix: a container that (re)starts after the daily 8am run would otherwise leave in-window + # birthdays without a draft for up to ~24h. Generate birthday drafts once on startup so new + # or edited birthdays always have a message ready, no matter when the container came up. + db = SessionLocal() + try: + try: + n = birthdays.generate_birthday_drafts(db) + if n: + logger.info("Startup: generated %d birthday draft(s)", n) + except Exception: + logger.exception("Startup birthday draft generation failed") + finally: + db.close() + _scheduler = AsyncIOScheduler() _scheduler.add_job( run_daily_jobs, diff --git a/app/services/whatsnew.py b/app/services/whatsnew.py index a2e13ba..37ddbac 100644 --- a/app/services/whatsnew.py +++ b/app/services/whatsnew.py @@ -27,6 +27,15 @@ ### 🎂 Two weeks to plan Birthday reminders (drafts, nudges, and calendar alerts) now start two weeks out instead of three days, so there's more grace to sort a card or gift without the scramble. +### 🧹 Instagram, gone +The unofficial Instagram reader never quite worked and kept making "check now" hang, so it's been removed. Nothing else changes — your people, journal, and reviews all stay exactly as they are. + +### 🎂 Birthday drafts, sooner +Birthday message drafts now also generate the moment a container starts up, not just at the daily morning run — so a restart, or a birthday you've just added or edited, always has a draft ready instead of waiting up to a day. + +### 📱 Wrapped on the go +Kin Wrapped now scrolls properly on phones: the people and standout-moments sections have their own scroll area, and the cross-fade never hides a section that's taller than the screen. + That's everything for now. Thank you for being here — don't let the bastards get you down~ — Skye """.strip(), } diff --git a/app/settings_store.py b/app/settings_store.py index cf50d22..5a735fe 100644 --- a/app/settings_store.py +++ b/app/settings_store.py @@ -15,8 +15,6 @@ "whisper_api_key": "", "whisper_model": "whisper-1", "whisper_provider": "openai", # 'openai' or 'asr-webservice' - "instagram_username": "", - "instagram_password": "", "birthday_lead_days": "14", "checkin_default_cadence_days": "60", "daily_job_hour": "8", @@ -48,7 +46,7 @@ } -SENSITIVE_KEYS = {"immich_api_key", "ai_api_key", "instagram_password", "vapid_private_key", "whisper_api_key", "tts_api_key"} +SENSITIVE_KEYS = {"immich_api_key", "ai_api_key", "vapid_private_key", "whisper_api_key", "tts_api_key"} def get_all_settings(db: Session) -> dict: diff --git a/app/static/js/wrapped.js b/app/static/js/wrapped.js index 32c0659..67eb527 100644 --- a/app/static/js/wrapped.js +++ b/app/static/js/wrapped.js @@ -74,20 +74,34 @@ } } - // Cross-fade: a section is opaque while centred and fades as its centre leaves the middle of - // the viewport, so the outgoing fades as the next fades in. The last section stays visible. + // Cross-fade: a section is opaque while it substantially fills the viewport and fades out as it + // leaves, so the outgoing fades as the next fades in. Opacity is driven by how much of the + // section is VISIBLE (not its centre distance), so sections taller than the viewport - e.g. the + // people/moments grids on mobile - are never hidden while on screen. The last section stays + // visible at the bottom. function updateFade() { if (!sections.length || reducedMotion) return; var vh = window.innerHeight; var last = sections.length - 1; for (var i = 0; i < sections.length; i++) { var rect = sections[i].getBoundingClientRect(); - var pos = (rect.top + rect.height / 2) / vh; // 0=top edge, 0.5=centre, 1=bottom edge - var opacity = 1 - Math.abs(pos - 0.5) * 2.2; - opacity = Math.max(0, Math.min(1, opacity)); - if (i === last && pos <= 0.5) opacity = 1; + var visibleTop = Math.max(rect.top, 0); + var visibleBottom = Math.min(rect.bottom, vh); + var visible = Math.max(0, visibleBottom - visibleTop); + var denom = Math.min(rect.height, vh); + var fraction = denom > 0 ? visible / denom : 0; + // Small threshold so barely-entering/leaving sections stay faded, but anything substantially + // on screen is fully opaque. + var opacity = Math.max(0, Math.min(1, fraction * 1.25 - 0.1)); + if (i === last && rect.top <= 0) opacity = 1; sections[i].style.opacity = opacity.toFixed(3); - sections[i].style.transform = 'translateY(' + ((pos - 0.5) * vh * 0.06).toFixed(1) + 'px)'; + // Subtle parallax for viewport-height sections; skip for tall ones (would shift hugely). + if (rect.height <= vh * 1.2) { + var pos = (rect.top + rect.height / 2) / vh; + sections[i].style.transform = 'translateY(' + ((pos - 0.5) * vh * 0.06).toFixed(1) + 'px)'; + } else { + sections[i].style.transform = ''; + } } } diff --git a/app/static/sw.js b/app/static/sw.js index c6de8f1..176be77 100644 --- a/app/static/sw.js +++ b/app/static/sw.js @@ -4,7 +4,7 @@ - Offline fallback to a calm "you're offline" page instead of a hard error. - Web Push event handling: show a gentle notification when the app is closed/background. The user is ALWAYS in control: notifications are opt-in and never intrusive. */ -const CACHE = 'kin-shell-v16'; +const CACHE = 'kin-shell-v17'; const SHELL = [ '/static/css/style.css', '/static/js/htmx.min.js', diff --git a/app/templates/partials/wrapped_card.html b/app/templates/partials/wrapped_card.html index 797be48..2de04b2 100644 --- a/app/templates/partials/wrapped_card.html +++ b/app/templates/partials/wrapped_card.html @@ -83,7 +83,7 @@
- Tracking @{{ person.instagram_username }}.
- {% if person.instagram_last_checked %}Last checked {{ person.instagram_last_checked.strftime('%Y-%m-%d %H:%M') }} UTC.{% endif %}
- {% if person.instagram_last_error %}
{{ person.instagram_last_error }}{% endif %}
-
You'll get a gentle nudge on the Today page when it's been this long since you last logged contact.
Requires Instagram credentials configured in Settings. New posts land in your review queue - nothing is automatic.
-No new posts waiting for review.
- {% endif %} - {% for post in ig_posts %} -{{ post.caption or "(no caption)" }}
- {% if post.permalink %}View on Instagram{% endif %} -- ⚠️ This uses an unofficial Instagram API. It's against Instagram's Terms of Service and carries - a real risk of your account being challenged or restricted — use a secondary/throwaway account you don't mind - losing, never your primary one. New posts always land in your review queue for approval; - nothing is ever posted or messaged automatically. -
- -