feat: link_url variants, team accounts, session layout, menagerie route (#18 #19)

#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:
Alan Weinstock 2026-05-27 15:31:58 -07:00
parent a863960266
commit e9b4cdd88e
17 changed files with 360 additions and 38 deletions

View file

@ -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
View 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

View file

@ -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")

View file

@ -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

View 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;

View 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)
);

View 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);

View 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);

View file

@ -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)

View file

@ -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)

View file

@ -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])

View file

@ -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,

View file

@ -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' })

View file

@ -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 {

View file

@ -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

View 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()

View file

@ -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/"