diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0af3786 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Magpie — environment config +# Copy to .env and fill in values. + +# Reddit credentials (for Playwright-based posting) +REDDIT_USERNAME= +REDDIT_PASSWORD= + +# Path to system Chrome binary (xvfb posting) +CHROME_BIN=/usr/bin/google-chrome + +# API server +API_HOST=0.0.0.0 +API_PORT=8532 +DEBUG=false + +# Database location (default: ~/.local/share/magpie/magpie.db) +# DB_PATH=/path/to/magpie.db + +# Session file location (default: ~/.local/share/magpie/session.json) +# REDDIT_SESSION_FILE=/path/to/session.json + +# APScheduler +SCHEDULER_ENABLED=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb2f90d --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Secrets / config +.env +session.json + +# Python +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +dist/ +.venv/ + +# Node +node_modules/ +frontend/dist/ + +# Debug screenshots +debug_*.png + +# Data +*.db +*.db-wal +*.db-shm + +# IDE +.vscode/ +.idea/ + +# CLAUDE.md — gitignored per BSL 1.1 + docs-location policy +CLAUDE.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b7658d8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.11-slim + +# System deps for Playwright + Chrome + Xvfb +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb google-chrome-stable wget gnupg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python deps +COPY pyproject.toml . +COPY ../circuitforge-core /cf-core +RUN pip install --no-cache-dir -e /cf-core && \ + pip install --no-cache-dir -e . + +# Install Playwright browsers +RUN playwright install chromium + +COPY . . + +EXPOSE 8532 + +CMD ["python", "-m", "app.main"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/endpoints/campaigns.py b/app/api/endpoints/campaigns.py new file mode 100644 index 0000000..52ea8e9 --- /dev/null +++ b/app/api/endpoints/campaigns.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.core.config import get_settings +from app.db.store import Store +from app.services.poster import run_campaign +from app.services.scheduler import remove_campaign as scheduler_remove, sync_campaign + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +def _get_store() -> Store: + return Store(get_settings().db_path) + + +def _in_thread(fn): + store = _get_store() + try: + return fn(store) + finally: + store.close() + + +# ------------------------------------------------------------------ # +# Schemas +# ------------------------------------------------------------------ # + +class CampaignCreate(BaseModel): + name: str + product: str + platform: str = "reddit" + cron_schedule: str | None = None + notes: str | None = None + + +class CampaignUpdate(BaseModel): + name: str | None = None + product: str | None = None + cron_schedule: str | None = None + active: bool | None = None + notes: str | None = None + + +class VariantCreate(BaseModel): + sub_pattern: str = "*" + title: str + body: str + flair: str | None = None + notes: str | None = None + + +class VariantUpdate(BaseModel): + sub_pattern: str | None = None + title: str | None = None + body: str | None = None + flair: str | None = None + notes: str | None = None + + +class SubEntry(BaseModel): + sub: str + sort_order: int = 0 + + +# ------------------------------------------------------------------ # +# Campaign CRUD +# ------------------------------------------------------------------ # + +@router.get("") +async def list_campaigns(active_only: bool = False): + return await asyncio.to_thread(_in_thread, lambda s: s.list_campaigns(active_only)) + + +@router.post("", status_code=201) +async def create_campaign(body: CampaignCreate): + campaign = await asyncio.to_thread( + _in_thread, + lambda s: s.create_campaign(**body.model_dump()), + ) + sync_campaign(campaign) + return campaign + + +@router.get("/{campaign_id}") +async def get_campaign(campaign_id: int): + result = await asyncio.to_thread(_in_thread, lambda s: s.get_campaign(campaign_id)) + if result is None: + raise HTTPException(404, "Campaign not found") + return result + + +@router.patch("/{campaign_id}") +async def update_campaign(campaign_id: int, body: CampaignUpdate): + updates = {k: v for k, v in body.model_dump().items() if v is not None} + if "active" in updates: + updates["active"] = 1 if updates["active"] else 0 + result = await asyncio.to_thread(_in_thread, lambda s: s.update_campaign(campaign_id, **updates)) + if result is None: + raise HTTPException(404, "Campaign not found") + sync_campaign(result) + return result + + +@router.delete("/{campaign_id}", status_code=204) +async def delete_campaign(campaign_id: int): + ok = await asyncio.to_thread(_in_thread, lambda s: s.delete_campaign(campaign_id)) + if not ok: + raise HTTPException(404, "Campaign not found") + scheduler_remove(campaign_id) + + +# ------------------------------------------------------------------ # +# Manual trigger +# ------------------------------------------------------------------ # + +@router.post("/{campaign_id}/trigger") +async def trigger_campaign(campaign_id: int): + """Manually fire a campaign to all its configured subs.""" + campaign = await asyncio.to_thread(_in_thread, lambda s: s.get_campaign(campaign_id)) + if campaign is None: + raise HTTPException(404, "Campaign not found") + results = await run_campaign(campaign_id, triggered_by="manual") + return {"campaign_id": campaign_id, "results": results} + + +# ------------------------------------------------------------------ # +# Variants +# ------------------------------------------------------------------ # + +@router.get("/{campaign_id}/variants") +async def list_variants(campaign_id: int): + return await asyncio.to_thread(_in_thread, lambda s: s.list_variants(campaign_id)) + + +@router.post("/{campaign_id}/variants", status_code=201) +async def create_variant(campaign_id: int, body: VariantCreate): + return await asyncio.to_thread( + _in_thread, + lambda s: s.create_variant(campaign_id=campaign_id, **body.model_dump()), + ) + + +@router.patch("/{campaign_id}/variants/{variant_id}") +async def update_variant(campaign_id: int, variant_id: int, body: VariantUpdate): + updates = {k: v for k, v in body.model_dump().items() if v is not None} + result = await asyncio.to_thread(_in_thread, lambda s: s.update_variant(variant_id, **updates)) + if result is None: + raise HTTPException(404, "Variant not found") + return result + + +@router.delete("/{campaign_id}/variants/{variant_id}", status_code=204) +async def delete_variant(campaign_id: int, variant_id: int): + ok = await asyncio.to_thread(_in_thread, lambda s: s.delete_variant(variant_id)) + if not ok: + raise HTTPException(404, "Variant not found") + + +# ------------------------------------------------------------------ # +# Subs +# ------------------------------------------------------------------ # + +@router.get("/{campaign_id}/subs") +async def list_campaign_subs(campaign_id: int): + return await asyncio.to_thread(_in_thread, lambda s: s.list_campaign_subs(campaign_id)) + + +@router.post("/{campaign_id}/subs", status_code=201) +async def add_campaign_sub(campaign_id: int, body: SubEntry): + return await asyncio.to_thread( + _in_thread, + lambda s: s.add_campaign_sub(campaign_id, body.sub, body.sort_order), + ) + + +@router.delete("/{campaign_id}/subs/{sub}", status_code=204) +async def remove_campaign_sub(campaign_id: int, sub: str): + ok = await asyncio.to_thread(_in_thread, lambda s: s.remove_campaign_sub(campaign_id, sub)) + if not ok: + raise HTTPException(404, "Sub not found in campaign") diff --git a/app/api/endpoints/posts.py b/app/api/endpoints/posts.py new file mode 100644 index 0000000..4c956ab --- /dev/null +++ b/app/api/endpoints/posts.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.core.config import get_settings +from app.db.store import Store +from app.services.poster import post_campaign_to_sub + +router = APIRouter(prefix="/posts", tags=["posts"]) + + +def _in_thread(fn): + store = Store(get_settings().db_path) + try: + return fn(store) + finally: + store.close() + + +class PostToSub(BaseModel): + campaign_id: int + sub: str + + +@router.get("") +async def list_posts(campaign_id: int | None = None, target: str | None = None, limit: int = 50): + return await asyncio.to_thread( + _in_thread, lambda s: s.list_posts(campaign_id, target, limit) + ) + + +@router.post("/trigger") +async def trigger_single_post(body: PostToSub): + """Manually trigger a single post to one sub.""" + campaign = await asyncio.to_thread(_in_thread, lambda s: s.get_campaign(body.campaign_id)) + if campaign is None: + raise HTTPException(404, "Campaign not found") + return await post_campaign_to_sub(body.campaign_id, body.sub, triggered_by="manual") + + +@router.get("/{post_id}/engagement") +async def get_engagement(post_id: int): + result = await asyncio.to_thread(_in_thread, lambda s: s.get_latest_engagement(post_id)) + if result is None: + raise HTTPException(404, "No engagement data for this post") + return result diff --git a/app/api/endpoints/scheduler.py b/app/api/endpoints/scheduler.py new file mode 100644 index 0000000..c15df77 --- /dev/null +++ b/app/api/endpoints/scheduler.py @@ -0,0 +1,22 @@ +"""Scheduler status endpoints.""" +from __future__ import annotations + +from fastapi import APIRouter + +from app.services.scheduler import get_scheduler + +router = APIRouter(prefix="/scheduler", tags=["scheduler"]) + + +@router.get("/status") +async def scheduler_status(): + """Return running state and next-fire-time for all scheduled jobs.""" + sched = get_scheduler() + jobs = [] + for job in sched.get_jobs(): + jobs.append({ + "job_id": job.id, + "name": job.name, + "next_run": job.next_run_time.isoformat() if job.next_run_time else None, + }) + return {"running": sched.running, "jobs": jobs} diff --git a/app/api/endpoints/subs.py b/app/api/endpoints/subs.py new file mode 100644 index 0000000..f4e2cff --- /dev/null +++ b/app/api/endpoints/subs.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import asyncio + +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="/subs", tags=["subs"]) + + +def _in_thread(fn): + store = Store(get_settings().db_path) + try: + return fn(store) + finally: + store.close() + + +class SubRulesUpsert(BaseModel): + flair_required: bool = False + flair_to_use: str | None = None + promo_allowed: bool | None = None # None = unknown + rule_warning: bool = False + notes: str | None = None + + +@router.get("") +async def list_sub_rules(platform: str = "reddit"): + return await asyncio.to_thread(_in_thread, lambda s: s.list_sub_rules(platform)) + + +@router.get("/{sub}") +async def get_sub_rules(sub: str, platform: str = "reddit"): + result = await asyncio.to_thread(_in_thread, lambda s: s.get_sub_rules(sub, platform)) + if result is None: + raise HTTPException(404, f"No rules on record for r/{sub}") + return result + + +@router.put("/{sub}") +async def upsert_sub_rules(sub: str, body: SubRulesUpsert, platform: str = "reddit"): + fields = body.model_dump() + # Convert bool | None to int | None for SQLite + if fields.get("promo_allowed") is not None: + fields["promo_allowed"] = 1 if fields["promo_allowed"] else 0 + fields["flair_required"] = 1 if fields["flair_required"] else 0 + fields["rule_warning"] = 1 if fields["rule_warning"] else 0 + return await asyncio.to_thread( + _in_thread, lambda s: s.upsert_sub_rules(sub, platform, **fields) + ) diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..53de211 --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI + +from app.api.endpoints import campaigns, opportunities, posts, scheduler, subs + + +def register_routes(app: FastAPI) -> None: + app.include_router(campaigns.router, prefix="/api/v1") + app.include_router(posts.router, prefix="/api/v1") + app.include_router(subs.router, prefix="/api/v1") + app.include_router(scheduler.router, prefix="/api/v1") + app.include_router(opportunities.router, prefix="/api/v1") diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..52d9cd9 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from pathlib import Path +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + # Server + api_host: str = "0.0.0.0" + api_port: int = 8532 + debug: bool = False + + # Database + db_path: str = str(Path.home() / ".local" / "share" / "magpie" / "magpie.db") + + # Reddit session + reddit_session_file: str = str(Path.home() / ".local" / "share" / "magpie" / "session.json") + + # Scheduler + scheduler_enabled: bool = True + + +def get_settings() -> Settings: + return Settings() diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/migrations/001_campaigns.sql b/app/db/migrations/001_campaigns.sql new file mode 100644 index 0000000..b6ec5de --- /dev/null +++ b/app/db/migrations/001_campaigns.sql @@ -0,0 +1,16 @@ +-- Campaigns: a named posting campaign for a product on a platform. +-- cron_schedule uses standard cron syntax (e.g. "0 9 * * 2" = Tuesday 9 AM) +CREATE TABLE IF NOT EXISTS campaigns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + product TEXT NOT NULL, -- peregrine, kiwi, snipe, circuitforge + platform TEXT NOT NULL DEFAULT 'reddit', + cron_schedule TEXT, -- NULL = manual trigger only + active INTEGER NOT NULL DEFAULT 1, + notes TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_campaigns_product ON campaigns(product); +CREATE INDEX IF NOT EXISTS idx_campaigns_active ON campaigns(active); diff --git a/app/db/migrations/002_campaign_variants.sql b/app/db/migrations/002_campaign_variants.sql new file mode 100644 index 0000000..a4c9f4b --- /dev/null +++ b/app/db/migrations/002_campaign_variants.sql @@ -0,0 +1,16 @@ +-- Campaign variants: per-sub (or wildcard) content framing. +-- sub_pattern: exact sub name ("selfhosted"), prefix wildcard ("nd_*"), or "*" for default. +-- Priority: exact match > prefix > "*" default. +CREATE TABLE IF NOT EXISTS campaign_variants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + sub_pattern TEXT NOT NULL DEFAULT '*', -- exact sub, glob prefix, or "*" default + title TEXT NOT NULL, + body TEXT NOT NULL, + flair TEXT, -- override flair for this variant + notes TEXT, -- internal framing notes + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_variants_campaign ON campaign_variants(campaign_id); diff --git a/app/db/migrations/003_campaign_subs.sql b/app/db/migrations/003_campaign_subs.sql new file mode 100644 index 0000000..7f41099 --- /dev/null +++ b/app/db/migrations/003_campaign_subs.sql @@ -0,0 +1,12 @@ +-- Campaign subs: which subreddits a campaign posts to, and in what order. +CREATE TABLE IF NOT EXISTS campaign_subs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + sub TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1, + + UNIQUE(campaign_id, sub) +); + +CREATE INDEX IF NOT EXISTS idx_campaign_subs_campaign ON campaign_subs(campaign_id); diff --git a/app/db/migrations/004_posts.sql b/app/db/migrations/004_posts.sql new file mode 100644 index 0000000..ba7a754 --- /dev/null +++ b/app/db/migrations/004_posts.sql @@ -0,0 +1,20 @@ +-- Posts: individual post attempts, one row per sub per campaign run. +-- status: pending | running | success | failed | skipped (dupe guard) +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL REFERENCES campaigns(id), + variant_id INTEGER REFERENCES campaign_variants(id), + platform TEXT NOT NULL DEFAULT 'reddit', + target TEXT NOT NULL, -- subreddit name + status TEXT NOT NULL DEFAULT 'pending', + url TEXT, -- permalink if succeeded + error_msg TEXT, + screenshot_path TEXT, + triggered_by TEXT DEFAULT 'scheduler', -- scheduler | manual + posted_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_posts_campaign ON posts(campaign_id); +CREATE INDEX IF NOT EXISTS idx_posts_target ON posts(target); +CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status); +CREATE INDEX IF NOT EXISTS idx_posts_posted_at ON posts(posted_at); diff --git a/app/db/migrations/005_sub_rules.sql b/app/db/migrations/005_sub_rules.sql new file mode 100644 index 0000000..2f1cf45 --- /dev/null +++ b/app/db/migrations/005_sub_rules.sql @@ -0,0 +1,16 @@ +-- Sub rules: per-subreddit posting rules and capabilities. +-- promo_allowed: NULL = unknown, 1 = allowed, 0 = banned +CREATE TABLE IF NOT EXISTS sub_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL DEFAULT 'reddit', + sub TEXT NOT NULL, + flair_required INTEGER NOT NULL DEFAULT 0, + flair_to_use TEXT, + promo_allowed INTEGER, -- NULL=unknown, 1=yes, 0=hard-banned + rule_warning INTEGER NOT NULL DEFAULT 0, -- shows "Your post may break rules" modal + notes TEXT, + last_checked TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + + UNIQUE(platform, sub) +); diff --git a/app/db/migrations/006_engagement.sql b/app/db/migrations/006_engagement.sql new file mode 100644 index 0000000..99dc96f --- /dev/null +++ b/app/db/migrations/006_engagement.sql @@ -0,0 +1,13 @@ +-- Engagement snapshots: periodic metric pulls after posting. +CREATE TABLE IF NOT EXISTS engagement ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + score INTEGER, + upvotes INTEGER, + comments INTEGER, + awards INTEGER DEFAULT 0, + checked_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_engagement_post ON engagement(post_id); +CREATE INDEX IF NOT EXISTS idx_engagement_checked_at ON engagement(checked_at); diff --git a/app/db/store.py b/app/db/store.py new file mode 100644 index 0000000..09e6c0b --- /dev/null +++ b/app/db/store.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path +from typing import Any + +MIGRATIONS_DIR = Path(__file__).parent / "migrations" + +# Columns whose values are stored as JSON and should be auto-decoded on read. +_JSON_COLUMNS = frozenset() + + +def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + if row is None: + return None + d = dict(row) + for col in _JSON_COLUMNS: + if col in d and isinstance(d[col], str): + try: + d[col] = json.loads(d[col]) + except (json.JSONDecodeError, TypeError): + pass + return d + + +class Store: + def __init__(self, db_path: str | Path) -> None: + path = Path(db_path) + path.parent.mkdir(parents=True, exist_ok=True) + self.conn = sqlite3.connect(str(path), check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA foreign_keys=ON") + + def close(self) -> None: + self.conn.close() + + # ------------------------------------------------------------------ # + # Migrations + # ------------------------------------------------------------------ # + + def run_migrations(self) -> None: + self.conn.execute( + "CREATE TABLE IF NOT EXISTS _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))" + ) + self.conn.commit() + applied = { + row[0] + for row in self.conn.execute("SELECT name FROM _migrations").fetchall() + } + for sql_file in sorted(MIGRATIONS_DIR.glob("*.sql")): + if sql_file.name not in applied: + self.conn.executescript(sql_file.read_text()) + self.conn.execute( + "INSERT INTO _migrations (name) VALUES (?)", (sql_file.name,) + ) + self.conn.commit() + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + + def _insert_returning(self, sql: str, params: tuple = ()) -> dict[str, Any]: + self.conn.row_factory = sqlite3.Row + cur = self.conn.execute(sql, params) + row = _row_to_dict(cur.fetchone()) + self.conn.commit() + return row + + def _fetchall(self, sql: str, params: tuple = ()) -> list[dict[str, Any]]: + self.conn.row_factory = sqlite3.Row + cur = self.conn.execute(sql, params) + return [_row_to_dict(r) for r in cur.fetchall()] + + def _fetchone(self, sql: str, params: tuple = ()) -> dict[str, Any] | None: + self.conn.row_factory = sqlite3.Row + cur = self.conn.execute(sql, params) + return _row_to_dict(cur.fetchone()) + + # ------------------------------------------------------------------ # + # Campaigns + # ------------------------------------------------------------------ # + + def list_campaigns(self, active_only: bool = False) -> list[dict]: + where = "WHERE active = 1" if active_only else "" + return self._fetchall(f"SELECT * FROM campaigns {where} ORDER BY product, name") + + def get_campaign(self, campaign_id: int) -> dict | None: + return self._fetchone("SELECT * FROM campaigns WHERE id = ?", (campaign_id,)) + + def create_campaign(self, name: str, product: str, platform: str = "reddit", + cron_schedule: str | None = None, notes: str | None = None) -> dict: + return self._insert_returning( + "INSERT INTO campaigns (name, product, platform, cron_schedule, notes) VALUES (?,?,?,?,?) RETURNING *", + (name, product, platform, cron_schedule, notes), + ) + + def update_campaign(self, campaign_id: int, **fields) -> dict | None: + allowed = {"name", "product", "cron_schedule", "active", "notes"} + updates = {k: v for k, v in fields.items() if k in allowed} + if not updates: + return self.get_campaign(campaign_id) + set_clause = ", ".join(f"{k} = ?" for k in updates) + set_clause += ", updated_at = datetime('now')" + params = tuple(updates.values()) + (campaign_id,) + self.conn.execute( + f"UPDATE campaigns SET {set_clause} WHERE id = ?", params + ) + self.conn.commit() + return self.get_campaign(campaign_id) + + def delete_campaign(self, campaign_id: int) -> bool: + cur = self.conn.execute("DELETE FROM campaigns WHERE id = ?", (campaign_id,)) + self.conn.commit() + return cur.rowcount > 0 + + # ------------------------------------------------------------------ # + # Campaign variants + # ------------------------------------------------------------------ # + + def list_variants(self, campaign_id: int) -> list[dict]: + return self._fetchall( + "SELECT * FROM campaign_variants WHERE campaign_id = ? ORDER BY sub_pattern", + (campaign_id,), + ) + + def get_variant(self, variant_id: int) -> dict | None: + return self._fetchone("SELECT * FROM campaign_variants WHERE id = ?", (variant_id,)) + + def resolve_variant(self, campaign_id: int, sub: str) -> dict | None: + """Pick the best-matching variant for a given sub (exact > prefix > default).""" + variants = self.list_variants(campaign_id) + exact = next((v for v in variants if v["sub_pattern"] == sub), None) + if exact: + return exact + prefix = next( + (v for v in variants + if v["sub_pattern"].endswith("*") and sub.startswith(v["sub_pattern"][:-1])), + None, + ) + if prefix: + return prefix + return next((v for v in variants if v["sub_pattern"] == "*"), None) + + def create_variant(self, campaign_id: int, title: str, body: str, + sub_pattern: str = "*", flair: str | None = None, + notes: str | None = None) -> dict: + return self._insert_returning( + "INSERT INTO campaign_variants (campaign_id, sub_pattern, title, body, flair, notes) VALUES (?,?,?,?,?,?) RETURNING *", + (campaign_id, sub_pattern, title, body, flair, notes), + ) + + def update_variant(self, variant_id: int, **fields) -> dict | None: + allowed = {"sub_pattern", "title", "body", "flair", "notes"} + updates = {k: v for k, v in fields.items() if k in allowed} + if not updates: + return self.get_variant(variant_id) + set_clause = ", ".join(f"{k} = ?" for k in updates) + set_clause += ", updated_at = datetime('now')" + params = tuple(updates.values()) + (variant_id,) + self.conn.execute( + f"UPDATE campaign_variants SET {set_clause} WHERE id = ?", params + ) + self.conn.commit() + return self.get_variant(variant_id) + + def delete_variant(self, variant_id: int) -> bool: + cur = self.conn.execute("DELETE FROM campaign_variants WHERE id = ?", (variant_id,)) + self.conn.commit() + return cur.rowcount > 0 + + # ------------------------------------------------------------------ # + # Campaign subs + # ------------------------------------------------------------------ # + + def list_campaign_subs(self, campaign_id: int) -> list[dict]: + return self._fetchall( + "SELECT * FROM campaign_subs WHERE campaign_id = ? ORDER BY sort_order, sub", + (campaign_id,), + ) + + def add_campaign_sub(self, campaign_id: int, sub: str, sort_order: int = 0) -> dict: + return self._insert_returning( + "INSERT OR REPLACE INTO campaign_subs (campaign_id, sub, sort_order) VALUES (?,?,?) RETURNING *", + (campaign_id, sub, sort_order), + ) + + def remove_campaign_sub(self, campaign_id: int, sub: str) -> bool: + cur = self.conn.execute( + "DELETE FROM campaign_subs WHERE campaign_id = ? AND sub = ?", (campaign_id, sub) + ) + self.conn.commit() + return cur.rowcount > 0 + + # ------------------------------------------------------------------ # + # Posts + # ------------------------------------------------------------------ # + + def list_posts(self, campaign_id: int | None = None, target: str | None = None, + limit: int = 50) -> list[dict]: + clauses, params = [], [] + if campaign_id is not None: + clauses.append("campaign_id = ?") + params.append(campaign_id) + if target is not None: + clauses.append("target = ?") + params.append(target) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + params.append(limit) + return self._fetchall( + f"SELECT * FROM posts {where} ORDER BY posted_at DESC LIMIT ?", tuple(params) + ) + + def create_post(self, campaign_id: int, target: str, variant_id: int | None = None, + platform: str = "reddit", triggered_by: str = "scheduler") -> dict: + return self._insert_returning( + "INSERT INTO posts (campaign_id, variant_id, platform, target, status, triggered_by) VALUES (?,?,?,?,'pending',?) RETURNING *", + (campaign_id, variant_id, platform, target, triggered_by), + ) + + def update_post_status(self, post_id: int, status: str, url: str | None = None, + error_msg: str | None = None, screenshot_path: str | None = None) -> dict | None: + self.conn.execute( + "UPDATE posts SET status = ?, url = ?, error_msg = ?, screenshot_path = ? WHERE id = ?", + (status, url, error_msg, screenshot_path, post_id), + ) + self.conn.commit() + return self._fetchone("SELECT * FROM posts WHERE id = ?", (post_id,)) + + def already_posted_this_week(self, campaign_id: int, target: str) -> bool: + """Return True if a successful post to this sub exists in the past 7 days.""" + row = self._fetchone( + "SELECT id FROM posts WHERE campaign_id = ? AND target = ? AND status = 'success'" + " AND posted_at >= datetime('now', '-7 days')", + (campaign_id, target), + ) + return row is not None + + # ------------------------------------------------------------------ # + # Sub rules + # ------------------------------------------------------------------ # + + def list_sub_rules(self, platform: str = "reddit") -> list[dict]: + return self._fetchall( + "SELECT * FROM sub_rules WHERE platform = ? ORDER BY sub", (platform,) + ) + + def get_sub_rules(self, sub: str, platform: str = "reddit") -> dict | None: + return self._fetchone( + "SELECT * FROM sub_rules WHERE platform = ? AND sub = ?", (platform, sub) + ) + + def upsert_sub_rules(self, sub: str, platform: str = "reddit", **fields) -> dict: + existing = self.get_sub_rules(sub, platform) + if existing: + allowed = {"flair_required", "flair_to_use", "promo_allowed", "rule_warning", "notes", "last_checked"} + updates = {k: v for k, v in fields.items() if k in allowed} + if updates: + set_clause = ", ".join(f"{k} = ?" for k in updates) + set_clause += ", updated_at = datetime('now')" + params = tuple(updates.values()) + (platform, sub) + self.conn.execute( + f"UPDATE sub_rules SET {set_clause} WHERE platform = ? AND sub = ?", params + ) + self.conn.commit() + return self.get_sub_rules(sub, platform) + return self._insert_returning( + "INSERT INTO sub_rules (platform, sub, flair_required, flair_to_use, promo_allowed, rule_warning, notes, last_checked) VALUES (?,?,?,?,?,?,?,?) RETURNING *", + (platform, sub, + fields.get("flair_required", 0), fields.get("flair_to_use"), + fields.get("promo_allowed"), fields.get("rule_warning", 0), + fields.get("notes"), fields.get("last_checked")), + ) + + # ------------------------------------------------------------------ # + # Engagement + # ------------------------------------------------------------------ # + + def record_engagement(self, post_id: int, score: int | None = None, + upvotes: int | None = None, comments: int | None = None, + awards: int = 0) -> dict: + return self._insert_returning( + "INSERT INTO engagement (post_id, score, upvotes, comments, awards) VALUES (?,?,?,?,?) RETURNING *", + (post_id, score, upvotes, comments, awards), + ) + + def get_latest_engagement(self, post_id: int) -> dict | None: + return self._fetchone( + "SELECT * FROM engagement WHERE post_id = ? ORDER BY checked_at DESC LIMIT 1", + (post_id,), + ) + + # ------------------------------------------------------------------ # + # Opportunities + # ------------------------------------------------------------------ # + + def list_opportunities(self, status: str | None = None) -> list[dict]: + if status: + return self._fetchall( + "SELECT * FROM opportunities WHERE status = ? ORDER BY created_at DESC", + (status,), + ) + return self._fetchall( + "SELECT * FROM opportunities ORDER BY created_at DESC" + ) + + def get_opportunity(self, opportunity_id: int) -> dict | None: + return self._fetchone( + "SELECT * FROM opportunities WHERE id = ?", (opportunity_id,) + ) + + def create_opportunity( + self, + community: str, + thread_url: str, + draft_body: str, + platform: str = "reddit", + thread_title: str | None = None, + thread_body: str | None = None, + signal_reason: str | None = None, + product: str | None = None, + draft_title: str | None = None, + post_type: str = "reply_to_thread", + campaign_id: int | None = None, + ) -> dict: + return self._insert_returning( + """INSERT INTO opportunities + (platform, community, thread_url, thread_title, thread_body, + signal_reason, product, draft_title, draft_body, post_type, campaign_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?) RETURNING *""", + (platform, community, thread_url, thread_title, thread_body, + signal_reason, product, draft_title, draft_body, post_type, campaign_id), + ) + + def update_opportunity(self, opportunity_id: int, **fields) -> dict | None: + allowed = {"draft_title", "draft_body", "signal_reason", "product", + "status", "dismiss_note", "campaign_id"} + updates = {k: v for k, v in fields.items() if k in allowed} + if not updates: + return self.get_opportunity(opportunity_id) + set_clause = ", ".join(f"{k} = ?" for k in updates) + set_clause += ", updated_at = datetime('now')" + params = tuple(updates.values()) + (opportunity_id,) + self.conn.execute( + f"UPDATE opportunities SET {set_clause} WHERE id = ?", params + ) + self.conn.commit() + return self.get_opportunity(opportunity_id) + + def approve_opportunity(self, opportunity_id: int) -> dict | None: + return self.update_opportunity(opportunity_id, status="approved") + + def dismiss_opportunity(self, opportunity_id: int, note: str | None = None) -> dict | None: + return self.update_opportunity(opportunity_id, status="dismissed", dismiss_note=note) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..912ee30 --- /dev/null +++ b/app/main.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes import register_routes +from app.core.config import get_settings +from app.db.store import Store +from app.services.scheduler import start_scheduler, stop_scheduler, sync_all_campaigns + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + + # Run DB migrations + store = Store(settings.db_path) + store.run_migrations() + + # Boot scheduler and register all active campaigns + if settings.scheduler_enabled: + sched = start_scheduler() + app.state.scheduler = sched + campaigns = store.list_campaigns(active_only=True) + sync_all_campaigns(campaigns) + logger.info("Magpie started — %d campaign(s) scheduled", len(campaigns)) + else: + app.state.scheduler = None + logger.info("Magpie started — scheduler disabled") + + store.close() + yield + + # Graceful shutdown + stop_scheduler() + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( + title="Magpie", + description="CircuitForge cross-product social media management", + version="0.1.0", + lifespan=lifespan, + ) + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:8531", "http://0.0.0.0:8531"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + register_routes(app) + return app + + +app = create_app() + + +if __name__ == "__main__": + import uvicorn + settings = get_settings() + uvicorn.run("app.main:app", host=settings.api_host, port=settings.api_port, reload=settings.debug) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/platforms.py b/app/services/platforms.py new file mode 100644 index 0000000..6b659a9 --- /dev/null +++ b/app/services/platforms.py @@ -0,0 +1,37 @@ +""" +Platform registry: maps platform names to their poster implementations. + +Adding a new platform: + 1. Create app/services//client.py implementing PlatformClient + 2. Register it here in REGISTRY + +This keeps poster.py platform-agnostic — it looks up the right client by +the campaign's `platform` field rather than branching on strings everywhere. +""" +from __future__ import annotations + +from typing import Protocol + + +class PlatformClient(Protocol): + def post(self, target: str, title: str, body: str, flair: str | None = None) -> str: + """Post content to a target (sub, group, channel). Returns a URL.""" + ... + + +def get_client(platform: str) -> PlatformClient: + """Return an initialized client for the given platform name.""" + if platform == "reddit": + from app.services.reddit.client import RedditClient + return RedditClient() + raise NotImplementedError( + f"Platform '{platform}' is not yet implemented. " + f"Add a client in app/services/{platform}/ and register it here." + ) + + +# Platforms with posting support implemented +SUPPORTED_PLATFORMS = {"reddit"} + +# Platforms planned but not yet implemented +PLANNED_PLATFORMS = {"facebook", "discord", "lemmy", "mastodon"} diff --git a/app/services/poster.py b/app/services/poster.py new file mode 100644 index 0000000..a160ff3 --- /dev/null +++ b/app/services/poster.py @@ -0,0 +1,87 @@ +""" +Posting service: orchestrates variant resolution, dupe guard, and post execution. +Called by the scheduler and by the manual-trigger API endpoint. +""" +from __future__ import annotations + +import asyncio +from pathlib import Path + +from app.core.config import get_settings +from app.db.store import Store +from app.services.platforms import get_client, SUPPORTED_PLATFORMS + + +def _run_post(db_path: str, campaign_id: int, sub: str, triggered_by: str = "scheduler") -> dict: + """Execute a single post attempt (blocking, runs in a thread).""" + store = Store(db_path) + try: + # Dupe guard: skip if already posted to this sub this week + if store.already_posted_this_week(campaign_id, sub): + return {"skipped": True, "reason": f"already posted to r/{sub} this week"} + + # Resolve the best variant for this sub + variant = store.resolve_variant(campaign_id, sub) + if variant is None: + return {"skipped": True, "reason": "no variant found for campaign"} + + # Check sub rules + rules = store.get_sub_rules(sub) + if rules and rules.get("promo_allowed") == 0: + return {"skipped": True, "reason": f"r/{sub} hard-bans promotion"} + + # Check platform support + campaign = store.get_campaign(campaign_id) + platform = campaign["platform"] if campaign else "reddit" + if platform not in SUPPORTED_PLATFORMS: + return {"skipped": True, "reason": f"platform '{platform}' not yet implemented"} + + # Create the pending post record + post = store.create_post( + campaign_id=campaign_id, + target=sub, + variant_id=variant["id"], + platform=platform, + triggered_by=triggered_by, + ) + post_id = post["id"] + + # Execute + try: + client = get_client(platform) + flair = variant.get("flair") or (rules.get("flair_to_use") if rules else None) + url = client.post( + sub=sub, + title=variant["title"], + body=variant["body"], + flair=flair, + ) + return store.update_post_status(post_id, "success", url=url) + except Exception as exc: + return store.update_post_status(post_id, "failed", error_msg=str(exc)) + finally: + store.close() + + +async def post_campaign_to_sub(campaign_id: int, sub: str, + triggered_by: str = "scheduler") -> dict: + """Async wrapper for API and scheduler use.""" + db_path = get_settings().db_path + return await asyncio.to_thread(_run_post, db_path, campaign_id, sub, triggered_by) + + +async def run_campaign(campaign_id: int, triggered_by: str = "scheduler") -> list[dict]: + """Post a campaign to all of its configured subs, sequentially.""" + db_path = get_settings().db_path + store = Store(db_path) + try: + subs = store.list_campaign_subs(campaign_id) + active_subs = [s["sub"] for s in subs if s.get("active", 1)] + finally: + store.close() + + results = [] + for sub in active_subs: + result = await post_campaign_to_sub(campaign_id, sub, triggered_by) + results.append(result) + return results diff --git a/app/services/reddit/__init__.py b/app/services/reddit/__init__.py new file mode 100644 index 0000000..90cdb14 --- /dev/null +++ b/app/services/reddit/__init__.py @@ -0,0 +1,3 @@ +from app.services.reddit.client import RedditClient + +__all__ = ["RedditClient"] diff --git a/app/services/reddit/client.py b/app/services/reddit/client.py new file mode 100644 index 0000000..37015cf --- /dev/null +++ b/app/services/reddit/client.py @@ -0,0 +1,122 @@ +""" +RedditClient: thin wrapper around the Playwright post.py script. + +Migrated from claude-bridge/reddit-poster/reddit.py. +Posting goes via xvfb-run + Playwright (avoids Reddit API bot detection). +Comment/delete go via httpx with saved session cookies. +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import httpx + +from app.core.config import get_settings +from app.services.reddit.session import ensure_valid_session, load_cookies + +_POST_SCRIPT = Path(__file__).parent / "post.py" +USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) Chrome/124.0.0.0" + + +class RedditClient: + def __init__(self, session_file: Path | None = None) -> None: + settings = get_settings() + self._session_file = session_file or Path(settings.reddit_session_file) + ensure_valid_session(self._session_file) + self.cookies = load_cookies(self._session_file) + self.headers = {"User-Agent": USER_AGENT} + self._modhash: str | None = None + + @property + def modhash(self) -> str: + if self._modhash is None: + resp = httpx.get( + "https://www.reddit.com/api/me.json", + cookies=self.cookies, + headers=self.headers, + timeout=15, + ) + self._modhash = resp.json().get("data", {}).get("modhash", "") + return self._modhash + + def post(self, sub: str, title: str, body: str, flair: str | None = None) -> str: + """Submit a text post via Playwright (xvfb-run). Returns the permalink.""" + settings = get_settings() + cmd = [ + "xvfb-run", "--auto-servernum", + sys.executable, str(_POST_SCRIPT), + "--sub", sub, + "--title", title, + "--body", body, + "--yes", + ] + if flair: + cmd += ["--flair", flair] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=180, + env={ + **__import__("os").environ, + "REDDIT_SESSION_FILE": str(self._session_file), + }, + ) + if result.returncode != 0: + raise RuntimeError( + f"Post to r/{sub} failed (exit {result.returncode}):\n" + f"STDOUT:\n{result.stdout}\n" + f"STDERR:\n{result.stderr}" + ) + for line in result.stdout.splitlines(): + if line.startswith("Posted:"): + url = line.split("Posted:", 1)[-1].strip() + return url + raise RuntimeError( + f"Post to r/{sub} may have failed — no 'Posted:' line in output.\n" + f"Full stdout:\n{result.stdout}" + ) + + def comment(self, thread_id: str, body: str) -> str: + """Post a top-level comment to a thread. Returns the permalink.""" + resp = httpx.post( + "https://www.reddit.com/api/comment", + cookies=self.cookies, + headers={**self.headers, "X-Modhash": self.modhash}, + data={ + "api_type": "json", + "thing_id": f"t3_{thread_id}", + "text": body, + }, + timeout=15, + ) + result = resp.json() + errors = result.get("json", {}).get("errors", []) + if errors: + raise RuntimeError(f"Comment failed: {errors}") + things = result.get("json", {}).get("data", {}).get("things", []) + permalink = ( + "https://reddit.com" + things[0]["data"].get("permalink", "") if things else "" + ) + return permalink + + def delete(self, post_url: str) -> None: + """Delete a post by URL.""" + import re + match = re.search(r"/comments/([a-z0-9]+)/", post_url) + if not match: + raise ValueError(f"Cannot extract post ID from: {post_url}") + post_id = match.group(1) + resp = httpx.post( + "https://www.reddit.com/api/del", + cookies=self.cookies, + headers={**self.headers, "X-Modhash": self.modhash}, + data={"id": f"t3_{post_id}"}, + timeout=15, + ) + if resp.status_code != 200: + raise RuntimeError(f"Delete failed ({resp.status_code}): {resp.text[:200]}") diff --git a/app/services/reddit/post.py b/app/services/reddit/post.py new file mode 100644 index 0000000..72361bd --- /dev/null +++ b/app/services/reddit/post.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +""" +Reddit posting script using Playwright + stealth. + +Migrated from claude-bridge/reddit-poster/post.py. +Uses system Google Chrome (non-headless via Xvfb) with anti-detection flags +to avoid Reddit's bot detection. Saves a cookie session after first login. + +Usage: + python -m app.services.reddit.post --login + python -m app.services.reddit.post --sub selfhosted --title "..." --body "..." + python -m app.services.reddit.post --sub selfhosted --title "..." --body-file draft.txt + python -m app.services.reddit.post --delete +""" +from __future__ import annotations + +import argparse +import os +import sys +import time +from pathlib import Path + +from dotenv import load_dotenv +from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout +from playwright_stealth import Stealth + +# Load .env from project root (two levels up from this file) +_HERE = Path(__file__).parent +_PROJECT_ROOT = _HERE.parents[3] +load_dotenv(_PROJECT_ROOT / ".env") + +REDDIT_USERNAME = os.getenv("REDDIT_USERNAME", "") +REDDIT_PASSWORD = os.getenv("REDDIT_PASSWORD", "") +CHROME_BIN = os.getenv("CHROME_BIN", "/usr/bin/google-chrome") + +# Session file path from env (so the service layer can pass it via env var) +SESSION_FILE = Path(os.getenv("REDDIT_SESSION_FILE", str(_HERE / "session.json"))) + +LOGIN_URL = "https://www.reddit.com/login" +SUBMIT_URL = "https://www.reddit.com/r/{sub}/submit?type=text" + + +def _make_browser(p): + return p.chromium.launch( + executable_path=CHROME_BIN, + headless=False, + args=[ + "--disable-blink-features=AutomationControlled", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--window-size=1280,900", + ], + ) + + +def _make_context(browser): + return browser.new_context( + user_agent=( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + ), + viewport={"width": 1280, "height": 900}, + locale="en-US", + storage_state=str(SESSION_FILE) if SESSION_FILE.exists() else None, + ) + + +def _apply_stealth(page) -> None: + Stealth().apply_stealth_sync(page) + + +def _login(page) -> None: + print("Navigating to Reddit login...") + page.goto(LOGIN_URL, wait_until="domcontentloaded") + time.sleep(2) + + selectors_user = [ + "#login-username", + 'input[name="username"]', + 'input[id="loginUsername"]', + 'input[placeholder*="Username"]', + 'input[autocomplete="username"]', + ] + selectors_pass = [ + "#login-password", + 'input[name="password"]', + 'input[id="loginPassword"]', + 'input[placeholder*="Password"]', + 'input[autocomplete="current-password"]', + ] + + def fill_first(selectors, value): + for sel in selectors: + try: + el = page.locator(sel).first + if el.count() and el.is_visible(): + el.fill(value) + return sel + except Exception: + continue + raise RuntimeError(f"Could not find input. Tried: {selectors}") + + usel = fill_first(selectors_user, REDDIT_USERNAME) + print(f" Filled username via {usel}") + time.sleep(0.3) + psel = fill_first(selectors_pass, REDDIT_PASSWORD) + print(f" Filled password via {psel}") + time.sleep(0.3) + page.keyboard.press("Enter") + + try: + page.wait_for_url(lambda url: "login" not in url, timeout=20_000) + except PlaywrightTimeout: + raise RuntimeError("Login did not redirect — check credentials or CAPTCHA.") + + print(f"Logged in as u/{REDDIT_USERNAME}") + + +def do_login() -> None: + if not REDDIT_USERNAME or not REDDIT_PASSWORD: + sys.exit("Set REDDIT_USERNAME and REDDIT_PASSWORD in .env") + SESSION_FILE.parent.mkdir(parents=True, exist_ok=True) + with sync_playwright() as p: + browser = _make_browser(p) + ctx = browser.new_context( + user_agent=( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + ), + viewport={"width": 1280, "height": 900}, + locale="en-US", + ) + page = ctx.new_page() + _apply_stealth(page) + _login(page) + ctx.storage_state(path=str(SESSION_FILE)) + print(f"Session saved to {SESSION_FILE}") + browser.close() + + +def _ensure_logged_in(page) -> None: + page.goto("https://www.reddit.com", wait_until="domcontentloaded") + time.sleep(2) + if REDDIT_USERNAME.lower() not in page.content().lower(): + print("Session expired — re-logging in...") + _login(page) + + +def post(sub: str, title: str, body: str, flair: str | None = None, yes: bool = False) -> str: + """Submit a text post. Returns the posted URL.""" + if not REDDIT_USERNAME or not REDDIT_PASSWORD: + sys.exit("Set REDDIT_USERNAME and REDDIT_PASSWORD in .env") + + print(f"\n{'='*60}") + print(f" Subreddit : r/{sub}") + print(f" Title : {title}") + print(f" Body :\n") + for line in body.splitlines(): + print(f" {line}") + print(f"\n{'='*60}\n") + + if not yes: + confirm = input("Post this? [y/N] ").strip().lower() + if confirm != "y": + print("Aborted.") + return "" + + with sync_playwright() as p: + browser = _make_browser(p) + ctx = _make_context(browser) + page = ctx.new_page() + _apply_stealth(page) + + _ensure_logged_in(page) + + page.goto(SUBMIT_URL.format(sub=sub), wait_until="domcontentloaded") + time.sleep(2) + + # Fill title + try: + title_el = page.locator('textarea[name="title"]').first + title_el.wait_for(state="visible", timeout=10_000) + title_el.fill(title) + except Exception as exc: + print(f" Warning: title fill failed ({exc})") + + time.sleep(0.5) + + # Fill body (Lexical editor — click to focus, then type) + try: + body_el = page.locator('div[contenteditable="true"]').first + body_el.wait_for(state="visible", timeout=10_000) + body_el.click() + time.sleep(0.3) + page.keyboard.type(body, delay=2) + except Exception as exc: + print(f" Warning: body fill failed ({exc})") + + time.sleep(0.5) + + # Flair selection (faceplate-radio-input custom web component) + if flair: + try: + page.locator('faceplate-radio-input').filter(has_text=flair).click() + time.sleep(0.5) + # "Add" button in flair dialog — coordinate-based (1280x900 viewport) + page.mouse.click(877, 765) + time.sleep(0.5) + except Exception as exc: + print(f" Warning: flair selection failed ({exc})") + + # Submit + try: + submit_btn = page.locator('button[type="submit"]').filter(has_text="Post") + submit_btn.wait_for(state="visible", timeout=10_000) + submit_btn.click() + except Exception as exc: + print(f" Warning: submit button click failed ({exc})") + + time.sleep(2) + + # Rule-warning dialog — must use wait_for(state=), not is_visible() + try: + warning_btn = page.locator('button:has-text("Submit without editing")') + warning_btn.wait_for(state="visible", timeout=3_000) + warning_btn.click() + print(" Acknowledged rule warning — submitted without editing") + time.sleep(1) + except PlaywrightTimeout: + pass + + try: + page.wait_for_url(lambda url: "/comments/" in url, timeout=20_000) + except PlaywrightTimeout: + pass + + final_url = page.url + if "/submit" in final_url: + screenshot_path = SESSION_FILE.parent / f"debug_{sub}_{int(time.time())}.png" + page.screenshot(path=str(screenshot_path)) + raise RuntimeError( + f"Post may have failed — still on submit URL: {final_url}\n" + f"Screenshot saved to {screenshot_path}" + ) + + print(f"\nPosted: {final_url}") + ctx.storage_state(path=str(SESSION_FILE)) + browser.close() + return final_url + + +def delete_post(post_url: str) -> None: + import json + import re + import httpx + + state = json.loads(SESSION_FILE.read_text()) + cookies = {c["name"]: c["value"] for c in state.get("cookies", [])} + headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/124.0.0.0"} + + match = re.search(r"/comments/([a-z0-9]+)/", post_url) + if not match: + print(f"Could not extract post ID from URL: {post_url}") + return + post_id = match.group(1) + + me = httpx.get("https://www.reddit.com/api/me.json", cookies=cookies, headers=headers) + modhash = me.json().get("data", {}).get("modhash", "") + if not modhash: + print("Could not get modhash — session may be expired. Run --login first.") + return + + resp = httpx.post( + "https://www.reddit.com/api/del", + cookies=cookies, + headers={**headers, "X-Modhash": modhash}, + data={"id": f"t3_{post_id}"}, + ) + if resp.status_code == 200: + print(f"Deleted: {post_url}") + else: + print(f"Delete failed ({resp.status_code}): {resp.text[:200]}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Post to Reddit via Playwright") + parser.add_argument("--sub") + parser.add_argument("--title") + parser.add_argument("--body") + parser.add_argument("--body-file") + parser.add_argument("--flair") + parser.add_argument("--login", action="store_true") + parser.add_argument("--delete", metavar="POST_URL") + parser.add_argument("--yes", "-y", action="store_true") + args = parser.parse_args() + + if args.login: + do_login() + return + + if args.delete: + delete_post(args.delete) + return + + if not args.sub or not args.title: + parser.error("--sub and --title are required") + + body = "" + if args.body_file: + body = Path(args.body_file).read_text() + elif args.body: + body = args.body + else: + print("Enter post body (Ctrl+D when done):") + body = sys.stdin.read() + + post(args.sub, args.title, body.strip(), flair=args.flair, yes=args.yes) + + +if __name__ == "__main__": + main() diff --git a/app/services/reddit/session.py b/app/services/reddit/session.py new file mode 100644 index 0000000..12b6adf --- /dev/null +++ b/app/services/reddit/session.py @@ -0,0 +1,64 @@ +""" +Reddit session management via Playwright + xvfb-run. + +Migrated from claude-bridge/reddit-poster/reddit.py. +Session cookies are stored in a JSON file and refreshed automatically when stale. +""" +from __future__ import annotations + +import json +import subprocess +import sys +import time +from pathlib import Path + +from app.core.config import get_settings + +SESSION_MAX_AGE_HOURS = 12 +USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) Chrome/124.0.0.0" + +_POST_SCRIPT = Path(__file__).parent / "post.py" + + +def _session_age_hours(session_file: Path) -> float: + if not session_file.exists(): + return float("inf") + return (time.time() - session_file.stat().st_mtime) / 3600 + + +def session_is_valid(session_file: Path | None = None) -> bool: + if session_file is None: + session_file = Path(get_settings().reddit_session_file) + return _session_age_hours(session_file) < SESSION_MAX_AGE_HOURS + + +def refresh_session(session_file: Path | None = None) -> None: + """Re-login via Playwright (xvfb-run) and overwrite session.json.""" + if session_file is None: + session_file = Path(get_settings().reddit_session_file) + session_file.parent.mkdir(parents=True, exist_ok=True) + print("Session expired or missing — re-establishing via Playwright login...") + result = subprocess.run( + ["xvfb-run", "--auto-servernum", sys.executable, str(_POST_SCRIPT), "--login"], + cwd=str(_POST_SCRIPT.parent), + timeout=120, + ) + if result.returncode != 0: + raise RuntimeError("Playwright re-login failed. Check credentials in .env.") + print("Session refreshed.") + + +def load_cookies(session_file: Path | None = None) -> dict[str, str]: + if session_file is None: + session_file = Path(get_settings().reddit_session_file) + if not session_file.exists(): + refresh_session(session_file) + state = json.loads(session_file.read_text()) + return {c["name"]: c["value"] for c in state.get("cookies", [])} + + +def ensure_valid_session(session_file: Path | None = None) -> None: + if session_file is None: + session_file = Path(get_settings().reddit_session_file) + if not session_is_valid(session_file): + refresh_session(session_file) diff --git a/app/services/scheduler.py b/app/services/scheduler.py new file mode 100644 index 0000000..71795fe --- /dev/null +++ b/app/services/scheduler.py @@ -0,0 +1,121 @@ +""" +Campaign scheduler: wraps APScheduler's AsyncIOScheduler. + +One cron job per active campaign with a cron_schedule. +Jobs are re-synced at startup and updated dynamically via sync_campaign(). +""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.services.poster import run_campaign + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + +# Module-level singleton — attached to app.state in main.py so it is accessible +# from endpoints without importing it directly. +_scheduler: AsyncIOScheduler | None = None + + +def _job_id(campaign_id: int) -> str: + return f"campaign_{campaign_id}" + + +async def _run_campaign_job(campaign_id: int) -> None: + """APScheduler coroutine target: run a campaign and log the outcome.""" + logger.info("Scheduler firing campaign %d", campaign_id) + try: + results = await run_campaign(campaign_id, triggered_by="scheduler") + success = sum(1 for r in results if not r.get("skipped") and r.get("status") == "success") + skipped = sum(1 for r in results if r.get("skipped")) + failed = sum(1 for r in results if not r.get("skipped") and r.get("status") == "failed") + logger.info( + "Campaign %d done: %d success, %d skipped, %d failed", + campaign_id, success, skipped, failed, + ) + except Exception: + logger.exception("Unhandled error running campaign %d", campaign_id) + + +def get_scheduler() -> AsyncIOScheduler: + global _scheduler + if _scheduler is None: + _scheduler = AsyncIOScheduler(timezone="UTC") + return _scheduler + + +def start_scheduler() -> AsyncIOScheduler: + sched = get_scheduler() + if not sched.running: + sched.start() + logger.info("Scheduler started") + return sched + + +def stop_scheduler() -> None: + global _scheduler + if _scheduler is not None and _scheduler.running: + _scheduler.shutdown(wait=False) + logger.info("Scheduler stopped") + _scheduler = None + + +def sync_campaign(campaign: dict) -> None: + """ + Add, update, or remove the cron job for a campaign. + + Call this after any create / update / delete / toggle. + """ + sched = get_scheduler() + job_id = _job_id(campaign["id"]) + cron_expr = campaign.get("cron_schedule") + is_active = bool(campaign.get("active", 1)) + + # Remove existing job (if any) before re-adding so schedule changes take effect + existing = sched.get_job(job_id) + if existing: + existing.remove() + + if cron_expr and is_active: + try: + trigger = CronTrigger.from_crontab(cron_expr, timezone="UTC") + sched.add_job( + _run_campaign_job, + trigger=trigger, + id=job_id, + args=[campaign["id"]], + replace_existing=True, + misfire_grace_time=3600, # fire up to 1 hour late (e.g. after a restart) + ) + logger.info( + "Scheduled campaign %d (%s) with cron: %s", + campaign["id"], campaign.get("name", ""), cron_expr, + ) + except Exception: + logger.exception( + "Invalid cron expression for campaign %d: %r", + campaign["id"], cron_expr, + ) + + +def remove_campaign(campaign_id: int) -> None: + """Remove the cron job for a deleted campaign.""" + sched = get_scheduler() + existing = sched.get_job(_job_id(campaign_id)) + if existing: + existing.remove() + logger.info("Removed scheduler job for campaign %d", campaign_id) + + +def sync_all_campaigns(campaigns: list[dict]) -> None: + """Sync scheduler state from a full campaign list (called at startup).""" + for campaign in campaigns: + sync_campaign(campaign) + logger.info("Synced %d campaign(s) to scheduler", len(campaigns)) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e75b352 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + magpie-api: + build: . + container_name: magpie-api + restart: unless-stopped + network_mode: host + env_file: .env + volumes: + - magpie-data:/data + - /dev/shm:/dev/shm # needed for Chrome in Docker + environment: + - DB_PATH=/data/magpie.db + - REDDIT_SESSION_FILE=/data/session.json + + magpie-web: + image: node:20-slim + container_name: magpie-web + restart: unless-stopped + working_dir: /app + command: sh -c "npm install && npm run dev" + network_mode: host + volumes: + - ./frontend:/app + +volumes: + magpie-data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..dc47d0a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Magpie — Campaign Dashboard + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..1a82f16 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1919 @@ +{ + "name": "magpie-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "magpie-frontend", + "version": "0.1.0", + "dependencies": { + "axios": "^1.13.1", + "pinia": "^3.0.3", + "vue": "^3.5.22", + "vue-router": "^4.6.3" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.9.3", + "vite": "^7.1.7", + "vue-tsc": "^3.1.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.7.tgz", + "integrity": "sha512-Gn4q/tRxbpVGLEuARQ43p3YELlNAFgRUVCgW9U5Cr+5q4vfD2bWDWpl3ABbJMXUt5xlE1dF8dkigg2aUq7JYYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.1.2", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.7.tgz", + "integrity": "sha512-zc1tL3HoQni1zGTGrwBVRQb7rGP5SWdu/m4rGB6JcnAC5MT5LFZIxF7Y+EJEnt4hGF23d60rXH7gRjHGb5KQQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.7" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5d220a2 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "magpie-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite --host 0.0.0.0 --port 8531", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.13.1", + "pinia": "^3.0.3", + "vue": "^3.5.22", + "vue-router": "^4.6.3" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.8.1", + "typescript": "~5.9.3", + "vite": "^7.1.7", + "vue-tsc": "^3.1.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..a193309 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,14 @@ + diff --git a/frontend/src/components/CampaignDetail.vue b/frontend/src/components/CampaignDetail.vue new file mode 100644 index 0000000..cacc174 --- /dev/null +++ b/frontend/src/components/CampaignDetail.vue @@ -0,0 +1,213 @@ +