#19 — link_url on campaign variants (migration 019) - ADD COLUMN link_url TEXT on campaign_variants - create_variant, upsert_variant, update_variant all carry link_url - RedditClient.post() supports kind=link when link_url set + body empty - RedditPostStrategy passes link_url from extra dict - poster.py merges link_url from variant into extra (same as slug/tags) - API VariantCreate/VariantUpdate schemas include link_url - CampaignDetail: link_url field in Add Variant form with copy button; link_url shown in variant list with clickable link + copy button - Variant button disabled if neither body nor link_url is set #18 — Multi-user team accounts (migrations 020-022) - 020: team_accounts table (display_name, platform, username, session_file) - 021: opportunities.assigned_to + post_as FK → team_accounts - 022: posts.posted_by_account_id FK → team_accounts - Store: list/get/get_by_username/create_team_account, assign_opportunity - API: GET/POST /api/v1/team; POST /api/v1/team/{id}/assign - config.py: sessions_dir added; reddit_session_file now points to sessions/alan_reddit.json (backward compat path kept) - scripts/migrate_sessions.py: one-shot move session.json → sessions/alan_reddit.json + creates placeholder files for future accounts - manage.sh: build (VITE_BASE_URL=/magpie/ npm build), serve (static), migrate-sessions subcommands added; login updated to new session path - Caddy: @magpie_no_session gate + handle /magpie/api* and /magpie* blocks added to menagerie.circuitforge.tech site block
This commit is contained in:
parent
a863960266
commit
e9b4cdd88e
17 changed files with 360 additions and 38 deletions
|
|
@ -51,6 +51,7 @@ class VariantCreate(BaseModel):
|
||||||
body: str
|
body: str
|
||||||
flair: str | None = None
|
flair: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
link_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class VariantUpdate(BaseModel):
|
class VariantUpdate(BaseModel):
|
||||||
|
|
@ -59,6 +60,7 @@ class VariantUpdate(BaseModel):
|
||||||
body: str | None = None
|
body: str | None = None
|
||||||
flair: str | None = None
|
flair: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
link_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SubEntry(BaseModel):
|
class SubEntry(BaseModel):
|
||||||
|
|
|
||||||
89
app/api/endpoints/team.py
Normal file
89
app/api/endpoints/team.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""
|
||||||
|
Team accounts — list and manage posting identities across platforms.
|
||||||
|
Read operations are available to all; create/update reserved for admin use.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/team", tags=["team"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_store() -> Store:
|
||||||
|
return Store(get_settings().db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _in_thread(fn):
|
||||||
|
store = _get_store()
|
||||||
|
try:
|
||||||
|
return fn(store)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TeamAccountCreate(BaseModel):
|
||||||
|
display_name: str
|
||||||
|
platform: str
|
||||||
|
username: str
|
||||||
|
account_type: str = "personal"
|
||||||
|
session_file: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Routes
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_team_accounts(platform: str | None = None, active_only: bool = True):
|
||||||
|
"""List all registered team accounts. Filter by platform if provided."""
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_in_thread,
|
||||||
|
lambda s: s.list_team_accounts(platform=platform, active_only=active_only),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{account_id}")
|
||||||
|
async def get_team_account(account_id: int):
|
||||||
|
result = await asyncio.to_thread(_in_thread, lambda s: s.get_team_account(account_id))
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, "Team account not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_team_account(body: TeamAccountCreate):
|
||||||
|
logger.info(
|
||||||
|
"Creating team account: %s / %s (%s)", body.display_name, body.platform, body.account_type
|
||||||
|
)
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_in_thread,
|
||||||
|
lambda s: s.create_team_account(**body.model_dump()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{opportunity_id}/assign")
|
||||||
|
async def assign_opportunity(
|
||||||
|
opportunity_id: int,
|
||||||
|
assigned_to: int | None = None,
|
||||||
|
post_as: int | None = None,
|
||||||
|
):
|
||||||
|
"""Assign an opportunity to a team member and/or set the posting account."""
|
||||||
|
result = await asyncio.to_thread(
|
||||||
|
_in_thread,
|
||||||
|
lambda s: s.assign_opportunity(opportunity_id, assigned_to, post_as),
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(404, "Opportunity not found")
|
||||||
|
logger.info(
|
||||||
|
"Opportunity %s assigned_to=%s post_as=%s", opportunity_id, assigned_to, post_as
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from app.api.endpoints import blog, campaigns, opportunities, posts, reddit, scheduler, signals, stats, subs
|
from app.api.endpoints import blog, campaigns, opportunities, posts, reddit, scheduler, signals, stats, subs, team
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app: FastAPI) -> None:
|
def register_routes(app: FastAPI) -> None:
|
||||||
|
|
@ -13,3 +13,4 @@ def register_routes(app: FastAPI) -> None:
|
||||||
app.include_router(blog.router, prefix="/api/v1")
|
app.include_router(blog.router, prefix="/api/v1")
|
||||||
app.include_router(reddit.router, prefix="/api/v1")
|
app.include_router(reddit.router, prefix="/api/v1")
|
||||||
app.include_router(stats.router, prefix="/api/v1")
|
app.include_router(stats.router, prefix="/api/v1")
|
||||||
|
app.include_router(team.router, prefix="/api/v1")
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,12 @@ class Settings(BaseSettings):
|
||||||
# Database
|
# Database
|
||||||
db_path: str = str(Path.home() / ".local" / "share" / "magpie" / "magpie.db")
|
db_path: str = str(Path.home() / ".local" / "share" / "magpie" / "magpie.db")
|
||||||
|
|
||||||
# Reddit session
|
# Session files — multi-user layout
|
||||||
reddit_session_file: str = str(Path.home() / ".local" / "share" / "magpie" / "session.json")
|
# sessions_dir holds per-account JSON files: alan_reddit.json, cf_reddit.json, etc.
|
||||||
|
# reddit_session_file kept for backward compat; still used by the campaign scheduler
|
||||||
|
# until all callers are migrated to look up session via team_accounts.
|
||||||
|
sessions_dir: str = str(Path.home() / ".local" / "share" / "magpie" / "sessions")
|
||||||
|
reddit_session_file: str = str(Path.home() / ".local" / "share" / "magpie" / "sessions" / "alan_reddit.json")
|
||||||
|
|
||||||
# Scheduler
|
# Scheduler
|
||||||
scheduler_enabled: bool = True
|
scheduler_enabled: bool = True
|
||||||
|
|
|
||||||
5
app/db/migrations/019_variant_link_url.sql
Normal file
5
app/db/migrations/019_variant_link_url.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Migration 019: Add link_url to campaign_variants
|
||||||
|
-- Supports link-style posts (Reddit link posts, Lemmy link posts) where the URL
|
||||||
|
-- appears as the post link rather than embedded in the body. Also useful for
|
||||||
|
-- one-click copying the canonical URL from the variant editor UI.
|
||||||
|
ALTER TABLE campaign_variants ADD COLUMN link_url TEXT;
|
||||||
16
app/db/migrations/020_team_accounts.sql
Normal file
16
app/db/migrations/020_team_accounts.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- Migration 020: Team accounts table
|
||||||
|
-- Tracks all posting identities across platforms (personal and official).
|
||||||
|
-- session_file is an absolute path to the session JSON; NULL for accounts
|
||||||
|
-- that post manually (Neon) or whose sessions haven't been established yet.
|
||||||
|
CREATE TABLE IF NOT EXISTS team_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL, -- reddit | lemmy | mastodon | bluesky
|
||||||
|
username TEXT NOT NULL, -- u/pyr0ball, @cf@floss.social, etc.
|
||||||
|
account_type TEXT NOT NULL DEFAULT 'personal', -- personal | official
|
||||||
|
session_file TEXT, -- absolute path; NULL = manual posting only
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(platform, username)
|
||||||
|
);
|
||||||
6
app/db/migrations/021_opportunities_assignment.sql
Normal file
6
app/db/migrations/021_opportunities_assignment.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Migration 021: Opportunity assignment + posting account
|
||||||
|
-- assigned_to: which team member is responsible for this opportunity.
|
||||||
|
-- post_as: which team account to use when auto-posting.
|
||||||
|
-- These are separate: Alan may be assigned to review but post as CF official.
|
||||||
|
ALTER TABLE opportunities ADD COLUMN assigned_to INTEGER REFERENCES team_accounts(id);
|
||||||
|
ALTER TABLE opportunities ADD COLUMN post_as INTEGER REFERENCES team_accounts(id);
|
||||||
3
app/db/migrations/022_posts_account_tracking.sql
Normal file
3
app/db/migrations/022_posts_account_tracking.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Migration 022: Track which account made each post
|
||||||
|
-- NULL = posted by the default account (alan_reddit) before multi-user was added.
|
||||||
|
ALTER TABLE posts ADD COLUMN posted_by_account_id INTEGER REFERENCES team_accounts(id);
|
||||||
|
|
@ -167,10 +167,11 @@ class Store:
|
||||||
|
|
||||||
def create_variant(self, campaign_id: int, title: str, body: str,
|
def create_variant(self, campaign_id: int, title: str, body: str,
|
||||||
sub_pattern: str = "*", flair: str | None = None,
|
sub_pattern: str = "*", flair: str | None = None,
|
||||||
notes: str | None = None) -> dict:
|
notes: str | None = None, link_url: str | None = None) -> dict:
|
||||||
return self._insert_returning(
|
return self._insert_returning(
|
||||||
"INSERT INTO campaign_variants (campaign_id, sub_pattern, title, body, flair, notes) VALUES (?,?,?,?,?,?) RETURNING *",
|
"INSERT INTO campaign_variants (campaign_id, sub_pattern, title, body, flair, notes, link_url)"
|
||||||
(campaign_id, sub_pattern, title, body, flair, notes),
|
" VALUES (?,?,?,?,?,?,?) RETURNING *",
|
||||||
|
(campaign_id, sub_pattern, title, body, flair, notes, link_url),
|
||||||
)
|
)
|
||||||
|
|
||||||
def upsert_variant(
|
def upsert_variant(
|
||||||
|
|
@ -183,6 +184,7 @@ class Store:
|
||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
tags: str | None = None,
|
tags: str | None = None,
|
||||||
seo_description: str | None = None,
|
seo_description: str | None = None,
|
||||||
|
link_url: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
existing = self._fetchone(
|
existing = self._fetchone(
|
||||||
"SELECT * FROM campaign_variants WHERE campaign_id = ? AND sub_pattern = ?",
|
"SELECT * FROM campaign_variants WHERE campaign_id = ? AND sub_pattern = ?",
|
||||||
|
|
@ -190,19 +192,22 @@ class Store:
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"UPDATE campaign_variants SET title=?, body=?, flair=?, slug=?, tags=?, seo_description=? WHERE id=?",
|
"UPDATE campaign_variants SET title=?, body=?, flair=?, slug=?, tags=?,"
|
||||||
(title, body, flair, slug, tags, seo_description, existing["id"]),
|
" seo_description=?, link_url=? WHERE id=?",
|
||||||
|
(title, body, flair, slug, tags, seo_description, link_url, existing["id"]),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
return self._fetchone("SELECT * FROM campaign_variants WHERE id=?", (existing["id"],))
|
return self._fetchone("SELECT * FROM campaign_variants WHERE id=?", (existing["id"],))
|
||||||
return self._insert_returning(
|
return self._insert_returning(
|
||||||
"INSERT INTO campaign_variants (campaign_id, sub_pattern, title, body, flair, slug, tags, seo_description)"
|
"INSERT INTO campaign_variants"
|
||||||
" VALUES (?,?,?,?,?,?,?,?) RETURNING *",
|
" (campaign_id, sub_pattern, title, body, flair, slug, tags, seo_description, link_url)"
|
||||||
(campaign_id, sub_pattern, title, body, flair, slug, tags, seo_description),
|
" VALUES (?,?,?,?,?,?,?,?,?) RETURNING *",
|
||||||
|
(campaign_id, sub_pattern, title, body, flair, slug, tags, seo_description, link_url),
|
||||||
)
|
)
|
||||||
|
|
||||||
def update_variant(self, variant_id: int, **fields) -> dict | None:
|
def update_variant(self, variant_id: int, **fields) -> dict | None:
|
||||||
allowed = {"sub_pattern", "title", "body", "flair", "notes"}
|
allowed = {"sub_pattern", "title", "body", "flair", "notes",
|
||||||
|
"slug", "tags", "seo_description", "link_url"}
|
||||||
updates = {k: v for k, v in fields.items() if k in allowed}
|
updates = {k: v for k, v in fields.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return self.get_variant(variant_id)
|
return self.get_variant(variant_id)
|
||||||
|
|
@ -623,3 +628,63 @@ class Store:
|
||||||
"SELECT * FROM signal_scrape_state WHERE platform = ? ORDER BY sub",
|
"SELECT * FROM signal_scrape_state WHERE platform = ? ORDER BY sub",
|
||||||
(platform,),
|
(platform,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Team accounts (multi-user — migration 020)
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def list_team_accounts(
|
||||||
|
self, platform: str | None = None, active_only: bool = True
|
||||||
|
) -> list[dict]:
|
||||||
|
if platform:
|
||||||
|
sql = "SELECT * FROM team_accounts WHERE platform = ?"
|
||||||
|
params: tuple = (platform,)
|
||||||
|
if active_only:
|
||||||
|
sql += " AND active = 1"
|
||||||
|
else:
|
||||||
|
sql = "SELECT * FROM team_accounts"
|
||||||
|
params = ()
|
||||||
|
if active_only:
|
||||||
|
sql += " WHERE active = 1"
|
||||||
|
sql += " ORDER BY display_name, platform"
|
||||||
|
return self._fetchall(sql, params)
|
||||||
|
|
||||||
|
def get_team_account(self, account_id: int) -> dict | None:
|
||||||
|
return self._fetchone(
|
||||||
|
"SELECT * FROM team_accounts WHERE id = ?", (account_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_team_account_by_username(self, platform: str, username: str) -> dict | None:
|
||||||
|
return self._fetchone(
|
||||||
|
"SELECT * FROM team_accounts WHERE platform = ? AND username = ?",
|
||||||
|
(platform, username),
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_team_account(
|
||||||
|
self,
|
||||||
|
display_name: str,
|
||||||
|
platform: str,
|
||||||
|
username: str,
|
||||||
|
account_type: str = "personal",
|
||||||
|
session_file: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
return self._insert_returning(
|
||||||
|
"INSERT INTO team_accounts"
|
||||||
|
" (display_name, platform, username, account_type, session_file, notes)"
|
||||||
|
" VALUES (?,?,?,?,?,?) RETURNING *",
|
||||||
|
(display_name, platform, username, account_type, session_file, notes),
|
||||||
|
)
|
||||||
|
|
||||||
|
def assign_opportunity(
|
||||||
|
self,
|
||||||
|
opp_id: int,
|
||||||
|
assigned_to: int | None,
|
||||||
|
post_as: int | None = None,
|
||||||
|
) -> dict | None:
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE opportunities SET assigned_to = ?, post_as = ? WHERE id = ?",
|
||||||
|
(assigned_to, post_as, opp_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return self.get_opportunity(opp_id)
|
||||||
|
|
|
||||||
|
|
@ -21,5 +21,6 @@ class RedditPostStrategy(PostingStrategy):
|
||||||
) -> PostResult:
|
) -> PostResult:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
client = RedditClient(session_file=settings.reddit_session_file)
|
client = RedditClient(session_file=settings.reddit_session_file)
|
||||||
url = client.post(sub=target, title=title, body=body, flair=flair)
|
link_url = (extra or {}).get("link_url") or None
|
||||||
|
url = client.post(sub=target, title=title, body=body, flair=flair, link_url=link_url)
|
||||||
return PostResult(url=url)
|
return PostResult(url=url)
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,9 @@ def _run_post(db_path: str, campaign_id: int, target: str,
|
||||||
)
|
)
|
||||||
post_id = post["id"]
|
post_id = post["id"]
|
||||||
|
|
||||||
# Build extra dict from sub_row; merge variant-level blog fields (blog_post strategy uses them)
|
# Build extra dict from sub_row; merge variant-level fields used by strategies
|
||||||
extra = dict(sub_row)
|
extra = dict(sub_row)
|
||||||
for field in ("slug", "tags", "seo_description"):
|
for field in ("slug", "tags", "seo_description", "link_url"):
|
||||||
if variant.get(field) is not None:
|
if variant.get(field) is not None:
|
||||||
extra.setdefault(field, variant[field])
|
extra.setdefault(field, variant[field])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,19 +42,46 @@ class RedditClient:
|
||||||
self._modhash = resp.json().get("data", {}).get("modhash", "")
|
self._modhash = resp.json().get("data", {}).get("modhash", "")
|
||||||
return self._modhash
|
return self._modhash
|
||||||
|
|
||||||
def post(self, sub: str, title: str, body: str, flair: str | None = None) -> str:
|
def post(
|
||||||
"""Submit a text post via Reddit legacy API (httpx). Returns the permalink."""
|
self,
|
||||||
data = {
|
sub: str,
|
||||||
"api_type": "json",
|
title: str,
|
||||||
"kind": "self",
|
body: str,
|
||||||
"sr": sub,
|
flair: str | None = None,
|
||||||
"title": title,
|
link_url: str | None = None,
|
||||||
"text": body,
|
) -> str:
|
||||||
"uh": self.modhash,
|
"""Submit a post via Reddit legacy API (httpx). Returns the permalink.
|
||||||
"sendreplies": "true",
|
|
||||||
"nsfw": "false",
|
If link_url is provided and body is empty, submits as a link post (kind=link).
|
||||||
"spoiler": "false",
|
If both link_url and body are provided, submits as a text post with the URL
|
||||||
}
|
embedded — Reddit link posts don't support body text.
|
||||||
|
"""
|
||||||
|
if link_url and not body:
|
||||||
|
kind = "link"
|
||||||
|
data: dict = {
|
||||||
|
"api_type": "json",
|
||||||
|
"kind": kind,
|
||||||
|
"sr": sub,
|
||||||
|
"title": title,
|
||||||
|
"url": link_url,
|
||||||
|
"uh": self.modhash,
|
||||||
|
"sendreplies": "true",
|
||||||
|
"nsfw": "false",
|
||||||
|
"spoiler": "false",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
kind = "self"
|
||||||
|
data = {
|
||||||
|
"api_type": "json",
|
||||||
|
"kind": kind,
|
||||||
|
"sr": sub,
|
||||||
|
"title": title,
|
||||||
|
"text": body,
|
||||||
|
"uh": self.modhash,
|
||||||
|
"sendreplies": "true",
|
||||||
|
"nsfw": "false",
|
||||||
|
"spoiler": "false",
|
||||||
|
}
|
||||||
resp = httpx.post(
|
resp = httpx.post(
|
||||||
"https://www.reddit.com/api/submit",
|
"https://www.reddit.com/api/submit",
|
||||||
cookies=self.cookies,
|
cookies=self.cookies,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@
|
||||||
<button class="btn btn-ghost btn-sm" style="margin-left: auto;" @click="deleteVariant(v.id)">✕</button>
|
<button class="btn btn-ghost btn-sm" style="margin-left: auto;" @click="deleteVariant(v.id)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-weight: 500; font-size: 13px; margin-bottom: 2px;">{{ v.title }}</div>
|
<div style="font-weight: 500; font-size: 13px; margin-bottom: 2px;">{{ v.title }}</div>
|
||||||
|
<div v-if="v.link_url" style="font-size: 11px; margin-bottom: 2px; display: flex; align-items: center; gap: 4px;">
|
||||||
|
<a :href="v.link_url" target="_blank" rel="noopener" style="color: var(--color-accent); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 340px;">{{ v.link_url }}</a>
|
||||||
|
<button class="btn btn-ghost" style="padding: 0 4px; font-size: 11px;" @click="copyText(v.link_url!)" title="Copy URL">📋</button>
|
||||||
|
</div>
|
||||||
<div style="color: var(--color-text-muted); font-size: 12px; white-space: pre-wrap; max-height: 60px; overflow: hidden;">{{ v.body }}</div>
|
<div style="color: var(--color-text-muted); font-size: 12px; white-space: pre-wrap; max-height: 60px; overflow: hidden;">{{ v.body }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -101,7 +105,14 @@
|
||||||
<input class="form-input" v-model="variantForm.title" placeholder="Post title..." />
|
<input class="form-input" v-model="variantForm.title" placeholder="Post title..." />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Body</label>
|
<label class="form-label">Link URL <span style="color: var(--color-text-muted)">(optional — use for link posts; if set with no body, posts as link-type)</span></label>
|
||||||
|
<div style="display: flex; gap: 6px;">
|
||||||
|
<input class="form-input" v-model="variantForm.link_url" placeholder="https://git.opensourcesolarpunk.com/..." style="flex: 1;" />
|
||||||
|
<button v-if="variantForm.link_url" class="btn btn-ghost" style="flex-shrink: 0;" @click="copyText(variantForm.link_url)" title="Copy URL">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Body <span style="color: var(--color-text-muted)">(leave empty to post as link-type using the URL above)</span></label>
|
||||||
<textarea class="form-textarea" v-model="variantForm.body" rows="8" placeholder="Post body (markdown)..." style="min-height: 200px;" />
|
<textarea class="form-textarea" v-model="variantForm.body" rows="8" placeholder="Post body (markdown)..." style="min-height: 200px;" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -110,7 +121,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: var(--spacing-sm); justify-content: flex-end;">
|
<div style="display: flex; gap: var(--spacing-sm); justify-content: flex-end;">
|
||||||
<button class="btn btn-ghost" @click="showAddVariant = false">Cancel</button>
|
<button class="btn btn-ghost" @click="showAddVariant = false">Cancel</button>
|
||||||
<button class="btn btn-primary" @click="addVariant" :disabled="!variantForm.title || !variantForm.body">Add Variant</button>
|
<button class="btn btn-primary" @click="addVariant" :disabled="!variantForm.title || (!variantForm.body && !variantForm.link_url)">Add Variant</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -204,7 +215,7 @@ const showAddSub = ref(false)
|
||||||
const copyModal = reactive({ sub: '', title: '', body: '', url: '', notes: '' })
|
const copyModal = reactive({ sub: '', title: '', body: '', url: '', notes: '' })
|
||||||
const copied = ref('')
|
const copied = ref('')
|
||||||
|
|
||||||
const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
|
const variantForm = reactive({ sub_pattern: '*', title: '', body: '', flair: '', notes: '', link_url: '' })
|
||||||
const subForm = reactive({ sub: '' })
|
const subForm = reactive({ sub: '' })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -258,10 +269,11 @@ async function addVariant() {
|
||||||
body: variantForm.body,
|
body: variantForm.body,
|
||||||
flair: variantForm.flair || null,
|
flair: variantForm.flair || null,
|
||||||
notes: variantForm.notes || null,
|
notes: variantForm.notes || null,
|
||||||
|
link_url: variantForm.link_url || null,
|
||||||
})
|
})
|
||||||
variants.value = [...variants.value, v]
|
variants.value = [...variants.value, v]
|
||||||
showAddVariant.value = false
|
showAddVariant.value = false
|
||||||
Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '' })
|
Object.assign(variantForm, { sub_pattern: '*', title: '', body: '', flair: '', notes: '', link_url: '' })
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
toast.error(`Failed to add variant: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
toast.error(`Failed to add variant: ${e instanceof Error ? e.message : 'Unknown error'}`)
|
||||||
}
|
}
|
||||||
|
|
@ -322,6 +334,14 @@ async function copy(text: string, which: string) {
|
||||||
setTimeout(() => { copied.value = '' }, 2000)
|
setTimeout(() => { copied.value = '' }, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyText(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not copy to clipboard')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(iso: string) {
|
function formatDate(iso: string) {
|
||||||
const d = new Date(iso + 'Z')
|
const d = new Date(iso + 'Z')
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export interface Variant {
|
||||||
body: string
|
body: string
|
||||||
flair: string | null
|
flair: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
|
link_url: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +53,7 @@ export interface VariantCreate {
|
||||||
body: string
|
body: string
|
||||||
flair?: string | null
|
flair?: string | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
|
link_url?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CampaignSub {
|
export interface CampaignSub {
|
||||||
|
|
|
||||||
39
manage.sh
39
manage.sh
|
|
@ -244,9 +244,33 @@ case "$cmd" in
|
||||||
ok "Update complete"
|
ok "Update complete"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
build)
|
||||||
|
# Build the frontend SPA for production serving on menagerie.circuitforge.tech/magpie
|
||||||
|
# VITE_BASE_URL must end with / so Vite generates correct asset paths.
|
||||||
|
info "Building frontend for /magpie/ path prefix..."
|
||||||
|
cd frontend && VITE_BASE_URL=/magpie/ npm run build && cd ..
|
||||||
|
ok "Build complete → frontend/dist/ (base=/magpie/)"
|
||||||
|
info "Serving: ./manage.sh serve"
|
||||||
|
;;
|
||||||
|
|
||||||
|
serve)
|
||||||
|
# Serve the pre-built frontend dist at port WEB_PORT using a simple static file server.
|
||||||
|
# In production, Caddy proxies menagerie.circuitforge.tech/magpie* → this port.
|
||||||
|
info "Serving pre-built frontend on :${WEB_PORT} ..."
|
||||||
|
conda run --no-capture-output -n "$CONDA_ENV" \
|
||||||
|
python -m http.server "$WEB_PORT" --directory frontend/dist >> "$LOG_WEB" 2>&1 &
|
||||||
|
echo $! > "$PID_WEB"
|
||||||
|
ok "Static server up → http://localhost:${WEB_PORT}"
|
||||||
|
;;
|
||||||
|
|
||||||
|
migrate-sessions)
|
||||||
|
info "Migrating session.json → sessions/ directory..."
|
||||||
|
conda run -n "$CONDA_ENV" python scripts/migrate_sessions.py
|
||||||
|
;;
|
||||||
|
|
||||||
login)
|
login)
|
||||||
info "Refreshing Reddit session (opens browser via Xvfb)..."
|
info "Refreshing Reddit session (opens browser via Xvfb)..."
|
||||||
REDDIT_SESSION_FILE="${DATA_DIR}/session.json" \
|
REDDIT_SESSION_FILE="${DATA_DIR}/sessions/alan_reddit.json" \
|
||||||
conda run --no-capture-output -n "$CONDA_ENV" \
|
conda run --no-capture-output -n "$CONDA_ENV" \
|
||||||
xvfb-run --auto-servernum \
|
xvfb-run --auto-servernum \
|
||||||
python -m app.services.reddit.post --login
|
python -m app.services.reddit.post --login
|
||||||
|
|
@ -288,11 +312,14 @@ EOF
|
||||||
echo " Logs at: ${LOG_DIR}/"
|
echo " Logs at: ${LOG_DIR}/"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Maintenance:"
|
echo " Maintenance:"
|
||||||
echo " update git pull + pip/npm install + API restart"
|
echo " update git pull + pip/npm install + API restart"
|
||||||
echo " migrate Run DB migrations standalone"
|
echo " migrate Run DB migrations standalone"
|
||||||
echo " seed Seed campaigns from legacy scripts"
|
echo " migrate-sessions Move session.json → sessions/alan_reddit.json"
|
||||||
echo " login Refresh Reddit Playwright session"
|
echo " seed Seed campaigns from legacy scripts"
|
||||||
echo " open Open dashboard in browser"
|
echo " login Refresh Reddit Playwright session"
|
||||||
|
echo " build Build frontend for menagerie (/magpie/ base path)"
|
||||||
|
echo " serve Serve pre-built frontend dist on :${WEB_PORT}"
|
||||||
|
echo " open Open dashboard in browser"
|
||||||
echo ""
|
echo ""
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
53
scripts/migrate_sessions.py
Normal file
53
scripts/migrate_sessions.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
One-time migration: moves the legacy session.json to the new sessions/ directory.
|
||||||
|
Safe to run multiple times (idempotent).
|
||||||
|
|
||||||
|
Usage: conda run -n cf python scripts/migrate_sessions.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DATA_DIR = Path.home() / ".local" / "share" / "magpie"
|
||||||
|
OLD_SESSION = DATA_DIR / "session.json"
|
||||||
|
SESSIONS_DIR = DATA_DIR / "sessions"
|
||||||
|
NEW_SESSION = SESSIONS_DIR / "alan_reddit.json"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"Sessions directory: {SESSIONS_DIR}")
|
||||||
|
|
||||||
|
if NEW_SESSION.exists():
|
||||||
|
print(f" alan_reddit.json already exists — nothing to move")
|
||||||
|
elif OLD_SESSION.exists():
|
||||||
|
shutil.copy2(OLD_SESSION, NEW_SESSION)
|
||||||
|
print(f" Copied {OLD_SESSION} → {NEW_SESSION}")
|
||||||
|
# Leave the original in place during rollout; remove manually once confirmed
|
||||||
|
print(f" NOTE: {OLD_SESSION} left in place. Remove manually after confirming.")
|
||||||
|
else:
|
||||||
|
print(f" No session.json found at {OLD_SESSION} — may need to run ./manage.sh login")
|
||||||
|
|
||||||
|
print("\nPlaceholder files created for future accounts (empty — fill in when ready):")
|
||||||
|
placeholders = [
|
||||||
|
"xander_reddit.json",
|
||||||
|
"neon_reddit.json",
|
||||||
|
"cf_reddit.json",
|
||||||
|
"cf_bluesky.json",
|
||||||
|
"cf_mastodon.json",
|
||||||
|
]
|
||||||
|
for name in placeholders:
|
||||||
|
path = SESSIONS_DIR / name
|
||||||
|
if not path.exists():
|
||||||
|
path.touch()
|
||||||
|
print(f" Created {path}")
|
||||||
|
else:
|
||||||
|
print(f" {name} already exists — skipping")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -27,6 +27,7 @@ def test_execute_delegates_to_reddit_client(tmp_path):
|
||||||
title="Test Title",
|
title="Test Title",
|
||||||
body="Test body",
|
body="Test body",
|
||||||
flair="Showcase",
|
flair="Showcase",
|
||||||
|
link_url=None,
|
||||||
)
|
)
|
||||||
assert isinstance(result, PostResult)
|
assert isinstance(result, PostResult)
|
||||||
assert result.url == "https://reddit.com/r/test/comments/abc/title/"
|
assert result.url == "https://reddit.com/r/test/comments/abc/title/"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue