merge: fix/ci-ruff-lint into freeze/rc-1
CI lint fixes, CVE security mitigations, sync status UI (#120), bugbot Forgejo token fallback (#118), npm audit, mnemo compose stub.
This commit is contained in:
commit
6db1fe1546
76 changed files with 705 additions and 312 deletions
16
.env.example
16
.env.example
|
|
@ -35,7 +35,8 @@ OPENAI_COMPAT_URL=
|
||||||
OPENAI_COMPAT_KEY=
|
OPENAI_COMPAT_KEY=
|
||||||
|
|
||||||
# Feedback button — Forgejo issue filing
|
# Feedback button — Forgejo issue filing
|
||||||
FORGEJO_API_TOKEN=
|
FORGEJO_API_TOKEN= # dev/admin token (your personal account)
|
||||||
|
FORGEJO_BOT_TOKEN= # cf-bugbot bot token — used for in-app feedback; falls back to FORGEJO_API_TOKEN
|
||||||
FORGEJO_REPO=pyr0ball/peregrine
|
FORGEJO_REPO=pyr0ball/peregrine
|
||||||
FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
||||||
# GITHUB_TOKEN= # future — enable when public mirror is active
|
# GITHUB_TOKEN= # future — enable when public mirror is active
|
||||||
|
|
@ -64,8 +65,21 @@ CF_ORCH_AGENT_PORT=7701
|
||||||
# Cloud multi-tenancy (compose.cloud.yml only — do not set for local installs)
|
# Cloud multi-tenancy (compose.cloud.yml only — do not set for local installs)
|
||||||
CLOUD_MODE=false
|
CLOUD_MODE=false
|
||||||
CLOUD_DATA_ROOT=/devl/menagerie-data
|
CLOUD_DATA_ROOT=/devl/menagerie-data
|
||||||
|
SYNC_DB_PATH= # optional; defaults to CLOUD_DATA_ROOT/sync.db
|
||||||
|
SYNC_DB_KEY= # optional; SQLCipher key for at-rest encryption
|
||||||
DIRECTUS_JWT_SECRET= # must match website/.env DIRECTUS_SECRET value
|
DIRECTUS_JWT_SECRET= # must match website/.env DIRECTUS_SECRET value
|
||||||
CF_SERVER_SECRET= # random 64-char hex — generate: openssl rand -hex 32
|
CF_SERVER_SECRET= # random 64-char hex — generate: openssl rand -hex 32
|
||||||
PLATFORM_DB_URL=postgresql://cf_platform:<password>@host.docker.internal:5433/circuitforge_platform
|
PLATFORM_DB_URL=postgresql://cf_platform:<password>@host.docker.internal:5433/circuitforge_platform
|
||||||
HEIMDALL_URL=http://cf-license:8000 # internal Docker URL; override for external access
|
HEIMDALL_URL=http://cf-license:8000 # internal Docker URL; override for external access
|
||||||
HEIMDALL_ADMIN_TOKEN= # must match ADMIN_TOKEN in circuitforge-license .env
|
HEIMDALL_ADMIN_TOKEN= # must match ADMIN_TOKEN in circuitforge-license .env
|
||||||
|
|
||||||
|
# ── Memory (mnemo sidecar) — opt-in, requires --profile memory ───────────────
|
||||||
|
# Launch with: docker compose --profile memory --profile <gpu-profile> up -d
|
||||||
|
# Mnemo builds a persistent knowledge graph from conversations and injects
|
||||||
|
# relevant context back into LLM prompts. Uses the ollama service as its LLM.
|
||||||
|
MNEMO_HOST=mnemo # internal service name; change for external sidecar
|
||||||
|
MNEMO_PORT=8080
|
||||||
|
MNEMO_LLM_PROVIDER=ollama # ollama | openai | anthropic | custom
|
||||||
|
MNEMO_LLM_BASE_URL=http://ollama:11434/v1 # override for external LLM
|
||||||
|
MNEMO_LLM_API_KEY=ollama # "ollama" is a dummy value for local Ollama
|
||||||
|
MNEMO_LLM_MODEL=llama3.2:3b # must be pulled in the ollama container
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Install lint tools
|
||||||
|
run: pip install ruff
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# Mirror push to GitHub and Codeberg on every push to main or tag.
|
# Mirror push to GitHub and Codeberg on every push to main or tag.
|
||||||
# Copied from Circuit-Forge/cf-agents workflows/mirror.yml
|
# Copied from Circuit-Forge/cf-agents workflows/mirror.yml
|
||||||
# Required secrets: GITHUB_MIRROR_TOKEN, CODEBERG_MIRROR_TOKEN
|
# Required secrets: GH_MIRROR_TOKEN, CODEBERG_MIRROR_TOKEN
|
||||||
|
# Note: Forgejo reserves the GITHUB_* prefix for secret names — use GH_* instead.
|
||||||
|
|
||||||
name: Mirror
|
name: Mirror
|
||||||
|
|
||||||
|
|
@ -19,10 +20,10 @@ jobs:
|
||||||
|
|
||||||
- name: Mirror to GitHub
|
- name: Mirror to GitHub
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }}
|
GH_MIRROR_PAT: ${{ secrets.GH_MIRROR_TOKEN }}
|
||||||
REPO: ${{ github.event.repository.name }}
|
REPO: ${{ github.event.repository.name }}
|
||||||
run: |
|
run: |
|
||||||
git remote add github "https://x-access-token:${GITHUB_TOKEN}@github.com/CircuitForgeLLC/${REPO}.git"
|
git remote add github "https://x-access-token:${GH_MIRROR_PAT}@github.com/CircuitForgeLLC/${REPO}.git"
|
||||||
git push github --mirror
|
git push github --mirror
|
||||||
|
|
||||||
- name: Mirror to Codeberg
|
- name: Mirror to Codeberg
|
||||||
|
|
|
||||||
|
|
@ -180,4 +180,8 @@ Peregrine uses a split license:
|
||||||
|
|
||||||
Fine-tuned model weights are proprietary and per-user — not redistributable.
|
Fine-tuned model weights are proprietary and per-user — not redistributable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Humans own design, architecture, code review, testing, and verification. LLMs are part of our development workflow. [Our positions on LLM use →](https://circuitforge.tech/positions)
|
||||||
|
|
||||||
© 2026 Circuit Forge LLC
|
© 2026 Circuit Forge LLC
|
||||||
|
|
|
||||||
27
compose.yml
27
compose.yml
|
|
@ -23,6 +23,8 @@ services:
|
||||||
- GPU_SERVER_URL=${GPU_SERVER_URL:-${CF_ORCH_URL:-http://host.docker.internal:7700}}
|
- GPU_SERVER_URL=${GPU_SERVER_URL:-${CF_ORCH_URL:-http://host.docker.internal:7700}}
|
||||||
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
||||||
- CF_APP_NAME=peregrine
|
- CF_APP_NAME=peregrine
|
||||||
|
- MNEMO_HOST=${MNEMO_HOST:-mnemo}
|
||||||
|
- MNEMO_PORT=${MNEMO_PORT:-8080}
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
@ -116,6 +118,28 @@ services:
|
||||||
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
|
profiles: [single-gpu, dual-gpu-ollama, dual-gpu-vllm, dual-gpu-mixed]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mnemo:
|
||||||
|
image: ghcr.io/zaydmulani09/mnemo:latest
|
||||||
|
ports:
|
||||||
|
- "${MNEMO_PORT:-8080}:8080"
|
||||||
|
volumes:
|
||||||
|
- mnemo-data:/data
|
||||||
|
environment:
|
||||||
|
- MNEMO_DB_PATH=/data/mnemo.db
|
||||||
|
- MNEMO_LLM_PROVIDER=${MNEMO_LLM_PROVIDER:-ollama}
|
||||||
|
- MNEMO_LLM_BASE_URL=${MNEMO_LLM_BASE_URL:-http://ollama:11434/v1}
|
||||||
|
- MNEMO_LLM_API_KEY=${MNEMO_LLM_API_KEY:-ollama}
|
||||||
|
- MNEMO_LLM_MODEL=${MNEMO_LLM_MODEL:-llama3.2:3b}
|
||||||
|
depends_on:
|
||||||
|
- ollama
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
profiles: [memory]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
finetune:
|
finetune:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -131,3 +155,6 @@ services:
|
||||||
- OLLAMA_MODELS_OLLAMA_PATH=/root/.ollama
|
- OLLAMA_MODELS_OLLAMA_PATH=/root/.ollama
|
||||||
profiles: [finetune]
|
profiles: [finetune]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mnemo-data:
|
||||||
|
|
|
||||||
134
dev-api.py
134
dev-api.py
|
|
@ -8,13 +8,13 @@ import imaplib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import ipaddress
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import ssl as ssl_mod
|
import ssl as ssl_mod
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -26,7 +26,7 @@ import yaml
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Query, Request, Response, UploadFile
|
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
@ -39,13 +39,38 @@ if str(PEREGRINE_ROOT) not in sys.path:
|
||||||
|
|
||||||
from circuitforge_core.api import make_feedback_router as _make_feedback_router # noqa: E402
|
from circuitforge_core.api import make_feedback_router as _make_feedback_router # noqa: E402
|
||||||
from circuitforge_core.config.settings import load_env as _load_env # noqa: E402
|
from circuitforge_core.config.settings import load_env as _load_env # noqa: E402
|
||||||
from scripts.credential_store import get_credential, set_credential, delete_credential # noqa: E402
|
from circuitforge_core.sync import SyncConfig, make_sync_router # noqa: E402
|
||||||
|
from scripts.credential_store import get_credential, set_credential # noqa: E402
|
||||||
|
|
||||||
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
|
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
|
||||||
|
|
||||||
_CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true")
|
_CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true")
|
||||||
_CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data"))
|
_CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data"))
|
||||||
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
||||||
|
|
||||||
|
# Allowlist for cloud user_id values — UUID format only (prevents path traversal)
|
||||||
|
_VALID_USER_ID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
|
||||||
|
|
||||||
|
# RFC-1918 + loopback + link-local blocks blocked from IMAP SSRF
|
||||||
|
_PRIVATE_NETS = [
|
||||||
|
ipaddress.ip_network("10.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("172.16.0.0/12"),
|
||||||
|
ipaddress.ip_network("192.168.0.0/16"),
|
||||||
|
ipaddress.ip_network("127.0.0.0/8"),
|
||||||
|
ipaddress.ip_network("169.254.0.0/16"),
|
||||||
|
ipaddress.ip_network("::1/128"),
|
||||||
|
ipaddress.ip_network("fc00::/7"),
|
||||||
|
ipaddress.ip_network("fe80::/10"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ssrf_host(host: str) -> bool:
|
||||||
|
"""Return True if host resolves to a private/loopback address (SSRF guard)."""
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(socket.gethostbyname(host))
|
||||||
|
return any(addr in net for net in _PRIVATE_NETS)
|
||||||
|
except Exception:
|
||||||
|
return True # fail closed on resolution errors
|
||||||
IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
# Resolve GPU inference server URL.
|
# Resolve GPU inference server URL.
|
||||||
|
|
@ -127,6 +152,43 @@ _feedback_router = _make_feedback_router(
|
||||||
)
|
)
|
||||||
app.include_router(_feedback_router, prefix="/api/feedback")
|
app.include_router(_feedback_router, prefix="/api/feedback")
|
||||||
|
|
||||||
|
# ── Cross-device sync (cf-core sync module, Paid+ only) ──────────────────────
|
||||||
|
|
||||||
|
class _SyncUser:
|
||||||
|
"""Minimal user object expected by the cf-core sync router."""
|
||||||
|
def __init__(self, user_id: str) -> None:
|
||||||
|
self.user_id = user_id
|
||||||
|
|
||||||
|
def _get_sync_session() -> _SyncUser:
|
||||||
|
"""FastAPI dependency: resolves user_id from the per-request DB ContextVar.
|
||||||
|
Returns a fixed 'local' user in single-tenant mode so the prefs/delete
|
||||||
|
endpoints still work for self-hosted users.
|
||||||
|
"""
|
||||||
|
db_path = _request_db.get()
|
||||||
|
if db_path:
|
||||||
|
try:
|
||||||
|
user_id = Path(db_path).parts[-3]
|
||||||
|
except IndexError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid session")
|
||||||
|
else:
|
||||||
|
user_id = "local"
|
||||||
|
return _SyncUser(user_id)
|
||||||
|
|
||||||
|
def _require_paid_sync() -> _SyncUser:
|
||||||
|
"""FastAPI dependency: raises 403 unless the resolved tier is paid or premium."""
|
||||||
|
tier = _resolve_cloud_tier()
|
||||||
|
if tier not in ("paid", "premium"):
|
||||||
|
raise HTTPException(status_code=403, detail="Cross-device sync requires a Paid or Premium subscription.")
|
||||||
|
return _get_sync_session()
|
||||||
|
|
||||||
|
_sync_router = make_sync_router(
|
||||||
|
product="peregrine",
|
||||||
|
get_session=_get_sync_session,
|
||||||
|
require_paid=_require_paid_sync,
|
||||||
|
config=SyncConfig.from_env("peregrine"),
|
||||||
|
)
|
||||||
|
app.include_router(_sync_router, prefix="/sync", tags=["sync"])
|
||||||
|
|
||||||
_log = logging.getLogger("peregrine.session")
|
_log = logging.getLogger("peregrine.session")
|
||||||
|
|
||||||
# ── Structured auth logging ───────────────────────────────────────────────────
|
# ── Structured auth logging ───────────────────────────────────────────────────
|
||||||
|
|
@ -221,6 +283,10 @@ async def cloud_session_middleware(request: Request, call_next):
|
||||||
if _CLOUD_MODE and _DIRECTUS_SECRET:
|
if _CLOUD_MODE and _DIRECTUS_SECRET:
|
||||||
cookie_header = request.headers.get("X-CF-Session", "")
|
cookie_header = request.headers.get("X-CF-Session", "")
|
||||||
user_id = _resolve_cf_user_id(cookie_header)
|
user_id = _resolve_cf_user_id(cookie_header)
|
||||||
|
if user_id:
|
||||||
|
if not _VALID_USER_ID_RE.match(user_id):
|
||||||
|
_log.warning("cloud_session_middleware: rejected non-UUID user_id: %s", user_id[:40])
|
||||||
|
user_id = None
|
||||||
if user_id:
|
if user_id:
|
||||||
first_access = user_id not in _seen_users
|
first_access = user_id not in _seen_users
|
||||||
if first_access:
|
if first_access:
|
||||||
|
|
@ -738,7 +804,6 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
|
||||||
3. render_resume_text() — renders to plain text for the preview panel
|
3. render_resume_text() — renders to plain text for the preview panel
|
||||||
Returns: {preview_text, preview_struct} — struct preserved for the approve step.
|
Returns: {preview_text, preview_struct} — struct preserved for the approve step.
|
||||||
"""
|
"""
|
||||||
import json as _json
|
|
||||||
from scripts.db import get_resume_draft as _get_draft
|
from scripts.db import get_resume_draft as _get_draft
|
||||||
from scripts.resume_optimizer import (
|
from scripts.resume_optimizer import (
|
||||||
apply_review_decisions, frame_skill_gaps, render_resume_text,
|
apply_review_decisions, frame_skill_gaps, render_resume_text,
|
||||||
|
|
@ -759,7 +824,6 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
|
||||||
# Step 2: inject gap framing for rejected skills (adjacent / learning)
|
# Step 2: inject gap framing for rejected skills (adjacent / learning)
|
||||||
framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")]
|
framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")]
|
||||||
if framings:
|
if framings:
|
||||||
db_path_obj = Path(_request_db.get() or DB_PATH)
|
|
||||||
job_row = _get_db().execute(
|
job_row = _get_db().execute(
|
||||||
"SELECT title, company FROM jobs WHERE id=?", (job_id,)
|
"SELECT title, company FROM jobs WHERE id=?", (job_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
@ -829,7 +893,6 @@ def approve_resume(job_id: int, body: dict):
|
||||||
saved_resume_id: int | None = None
|
saved_resume_id: int | None = None
|
||||||
if body.get("save_to_library"):
|
if body.get("save_to_library"):
|
||||||
from scripts.db import create_resume as _create_r
|
from scripts.db import create_resume as _create_r
|
||||||
import json as _json2
|
|
||||||
resume_name = (body.get("resume_name") or "").strip() or f"Optimized for job {job_id}"
|
resume_name = (body.get("resume_name") or "").strip() or f"Optimized for job {job_id}"
|
||||||
saved = _create_r(
|
saved = _create_r(
|
||||||
db_path,
|
db_path,
|
||||||
|
|
@ -926,7 +989,7 @@ def create_resume_endpoint(body: dict):
|
||||||
|
|
||||||
@app.post("/api/resumes/import")
|
@app.post("/api/resumes/import")
|
||||||
async def import_resume_endpoint(file: UploadFile, name: str = ""):
|
async def import_resume_endpoint(file: UploadFile, name: str = ""):
|
||||||
import os, tempfile, json as _json
|
import json as _json
|
||||||
from scripts.db import create_resume as _create
|
from scripts.db import create_resume as _create
|
||||||
db_path = Path(_request_db.get() or DB_PATH)
|
db_path = Path(_request_db.get() or DB_PATH)
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
|
|
@ -1128,6 +1191,35 @@ def set_job_resume_endpoint(job_id: int, body: dict):
|
||||||
# context. Avocet then routes these prompts through different local models to
|
# context. Avocet then routes these prompts through different local models to
|
||||||
# compare generation quality against the real Peregrine pipeline.
|
# compare generation quality against the real Peregrine pipeline.
|
||||||
|
|
||||||
|
_SYNTHETIC_JOB = {
|
||||||
|
"id": 0,
|
||||||
|
"title": "Senior Software Engineer",
|
||||||
|
"company": "Acme Corp",
|
||||||
|
"description": (
|
||||||
|
"We are looking for a Senior Software Engineer to join our platform team. "
|
||||||
|
"You will design and build scalable backend services in Python and Go, "
|
||||||
|
"contribute to our event-driven architecture using Kafka and Redis, and "
|
||||||
|
"mentor junior engineers. We value clear communication, strong code review "
|
||||||
|
"practices, and an ownership mindset.\n\n"
|
||||||
|
"Requirements:\n"
|
||||||
|
"- 5+ years of backend engineering experience\n"
|
||||||
|
"- Proficiency in Python or Go; experience with both is a plus\n"
|
||||||
|
"- Solid understanding of distributed systems and API design (REST/gRPC)\n"
|
||||||
|
"- Experience with containerization (Docker/Kubernetes)\n"
|
||||||
|
"- Comfort working in a remote-first, async team environment\n\n"
|
||||||
|
"Nice to have:\n"
|
||||||
|
"- Experience with Kafka or other message-queue systems\n"
|
||||||
|
"- Open-source contributions\n"
|
||||||
|
"- Familiarity with observability tooling (Prometheus, Grafana)\n"
|
||||||
|
),
|
||||||
|
"status": "applied",
|
||||||
|
"cover_letter": "",
|
||||||
|
"raw_output": "",
|
||||||
|
"company_brief": "",
|
||||||
|
"ats_gap_report": "",
|
||||||
|
"talking_points": "",
|
||||||
|
}
|
||||||
|
|
||||||
def _imitate_load_profile():
|
def _imitate_load_profile():
|
||||||
"""Load UserProfile from config/user.yaml, or None if missing."""
|
"""Load UserProfile from config/user.yaml, or None if missing."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1157,6 +1249,9 @@ def _imitate_cover_letter(db, profile, limit: int) -> dict:
|
||||||
except Exception:
|
except Exception:
|
||||||
corpus = []
|
corpus = []
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows = [_SYNTHETIC_JOB]
|
||||||
|
|
||||||
samples = []
|
samples = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
desc = r["description"] or ""
|
desc = r["description"] or ""
|
||||||
|
|
@ -1213,6 +1308,9 @@ def _imitate_company_research(db, profile, limit: int) -> dict:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows = [_SYNTHETIC_JOB]
|
||||||
|
|
||||||
samples = []
|
samples = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
jd = (r["description"] or "")[:1500].strip()
|
jd = (r["description"] or "")[:1500].strip()
|
||||||
|
|
@ -1270,6 +1368,10 @@ def _imitate_interview_prep(db, profile, limit: int) -> dict:
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
name = profile.name if profile else "the candidate"
|
name = profile.name if profile else "the candidate"
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows = [_SYNTHETIC_JOB]
|
||||||
|
|
||||||
samples = []
|
samples = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
|
|
@ -1324,6 +1426,9 @@ def _imitate_ats_resume(db, profile, limit: int) -> dict:
|
||||||
pass
|
pass
|
||||||
resume_block = f"\n## Current Resume\n{resume_text}" if resume_text else ""
|
resume_block = f"\n## Current Resume\n{resume_text}" if resume_text else ""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows = [_SYNTHETIC_JOB]
|
||||||
|
|
||||||
samples = []
|
samples = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
desc = (r["description"] or "")[:1500].strip()
|
desc = (r["description"] or "")[:1500].strip()
|
||||||
|
|
@ -1462,14 +1567,8 @@ def calendar_push(job_id: int):
|
||||||
# ── Survey endpoints ─────────────────────────────────────────────────────────
|
# ── Survey endpoints ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Module-level imports so tests can patch dev_api.LLMRouter etc.
|
# Module-level imports so tests can patch dev_api.LLMRouter etc.
|
||||||
from scripts.llm_router import LLMRouter
|
from scripts.db import insert_survey_response, get_survey_responses # noqa: E402
|
||||||
from scripts.db import insert_survey_response, get_survey_responses
|
|
||||||
|
|
||||||
from scripts.survey_assistant import (
|
|
||||||
SURVEY_SYSTEM as _SURVEY_SYSTEM,
|
|
||||||
build_text_prompt as _build_text_prompt,
|
|
||||||
build_image_prompt as _build_image_prompt,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/vision/health")
|
@app.get("/api/vision/health")
|
||||||
|
|
@ -2690,7 +2789,7 @@ def config_user():
|
||||||
|
|
||||||
# ── Settings: My Profile endpoints ───────────────────────────────────────────
|
# ── Settings: My Profile endpoints ───────────────────────────────────────────
|
||||||
|
|
||||||
from scripts.user_profile import load_user_profile, save_user_profile
|
from scripts.user_profile import load_user_profile, save_user_profile # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def _user_yaml_path() -> str:
|
def _user_yaml_path() -> str:
|
||||||
|
|
@ -3564,6 +3663,8 @@ def test_email(payload: dict):
|
||||||
username = payload.get("username", "")
|
username = payload.get("username", "")
|
||||||
if not all([host, username, password]):
|
if not all([host, username, password]):
|
||||||
return {"ok": False, "error": "Missing host, username, or password"}
|
return {"ok": False, "error": "Missing host, username, or password"}
|
||||||
|
if _is_ssrf_host(host):
|
||||||
|
return {"ok": False, "error": "IMAP host must be a public address"}
|
||||||
if use_ssl:
|
if use_ssl:
|
||||||
ctx = ssl_mod.create_default_context()
|
ctx = ssl_mod.create_default_context()
|
||||||
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
|
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)
|
||||||
|
|
@ -4352,7 +4453,8 @@ def _fetch_cforch_nodes() -> list[dict]:
|
||||||
if not url:
|
if not url:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
import urllib.request, json as _json
|
import urllib.request
|
||||||
|
import json as _json
|
||||||
req = urllib.request.Request(f"{url}/api/nodes", headers={"Accept": "application/json"})
|
req = urllib.request.Request(f"{url}/api/nodes", headers={"Accept": "application/json"})
|
||||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||||
data = _json.loads(resp.read())
|
data = _json.loads(resp.read())
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ dependencies:
|
||||||
- undetected-chromedriver
|
- undetected-chromedriver
|
||||||
- webdriver-manager
|
- webdriver-manager
|
||||||
- beautifulsoup4
|
- beautifulsoup4
|
||||||
- requests
|
- requests>=2.33.0 # CVE-2026-25645
|
||||||
- curl_cffi # Chrome TLS fingerprint — bypasses Cloudflare on The Ladders
|
- curl_cffi>=0.15.0 # CVE-2026-33752
|
||||||
- fake-useragent # company scraper rotation
|
- fake-useragent # company scraper rotation
|
||||||
|
|
||||||
# ── LLM / AI backends ─────────────────────────────────────────────────────
|
# ── LLM / AI backends ─────────────────────────────────────────────────────
|
||||||
|
|
@ -55,13 +55,13 @@ dependencies:
|
||||||
- google-auth>=2.0
|
- google-auth>=2.0
|
||||||
|
|
||||||
# ── Document handling ─────────────────────────────────────────────────────
|
# ── Document handling ─────────────────────────────────────────────────────
|
||||||
- pypdf
|
- pypdf>=6.12.0 # 12 CVEs in 6.7.x (CVE-2026-27628 through CVE-2026-48156)
|
||||||
- pdfminer-six
|
- pdfminer-six
|
||||||
- pyyaml>=6.0
|
- pyyaml>=6.0
|
||||||
- python-dotenv
|
- python-dotenv>=1.2.2 # CVE-2026-28684
|
||||||
|
|
||||||
# ── Auth / licensing ──────────────────────────────────────────────────────
|
# ── Auth / licensing ──────────────────────────────────────────────────────
|
||||||
- PyJWT>=2.8
|
- PyJWT>=2.13.0 # 2.11 has sig bypass CVEs (PYSEC-2026-120/175-179); used for cloud session routing
|
||||||
|
|
||||||
# ── Utilities ─────────────────────────────────────────────────────────────
|
# ── Utilities ─────────────────────────────────────────────────────────────
|
||||||
- sqlalchemy
|
- sqlalchemy
|
||||||
|
|
@ -71,6 +71,18 @@ dependencies:
|
||||||
- tenacity
|
- tenacity
|
||||||
- httpx
|
- httpx
|
||||||
|
|
||||||
|
# ── Security pins (transitive deps with known CVEs) ───────────────────────
|
||||||
|
- starlette>=1.0.1 # PYSEC-2026-161 (FastAPI foundation)
|
||||||
|
- python-multipart>=0.0.27 # CVE-2026-40347/42561 file upload parsing
|
||||||
|
- aiohttp>=3.14.0 # 12 CVEs (CVE-2026-34513 through CVE-2026-34993)
|
||||||
|
- tornado>=6.5.5 # CVE-2026-35536
|
||||||
|
- cryptography>=46.0.7 # PYSEC-2026-35/36
|
||||||
|
- langsmith>=0.8.0 # CVE-2026-41182/45134
|
||||||
|
- gitpython>=3.1.50 # CVE-2026-42215/42284/44244
|
||||||
|
- lxml>=6.1.0 # PYSEC-2026-87 (XXE)
|
||||||
|
- idna>=3.15 # CVE-2026-45409
|
||||||
|
- markdownify>=0.14.1 # CVE-2025-46656
|
||||||
|
|
||||||
# ── Testing ───────────────────────────────────────────────────────────────
|
# ── Testing ───────────────────────────────────────────────────────────────
|
||||||
- pytest>=9.0
|
- pytest>=9.0
|
||||||
- pytest-cov
|
- pytest-cov
|
||||||
|
|
|
||||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
[tool.ruff]
|
||||||
|
# app/ is the deprecated Streamlit UI (replaced by Vue+FastAPI).
|
||||||
|
# No new work goes there; exclude from linting rather than accumulate suppressions.
|
||||||
|
exclude = ["app/"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
# dev-api.py / dev_api.py (symlink): E702 semicolons in compact Pydantic model
|
||||||
|
# definitions — intentional style for dense data models with many simple fields.
|
||||||
|
"dev-api.py" = ["E702"]
|
||||||
|
"dev_api.py" = ["E702"]
|
||||||
|
|
||||||
|
# finetune_local.py: E402 ML libs (torch, datasets, trl) are imported after
|
||||||
|
# runtime CUDA / Unsloth availability checks — conditional import pattern.
|
||||||
|
"scripts/finetune_local.py" = ["E402", "E741"]
|
||||||
|
|
||||||
|
# scripts/: E402 mid-file imports used for lazy loading or post-env-setup imports.
|
||||||
|
"scripts/task_runner.py" = ["E402"]
|
||||||
|
"scripts/migrate.py" = ["E741"]
|
||||||
|
|
||||||
|
# scrapers/: third-party script; minimal changes policy.
|
||||||
|
"scrapers/companyScraper.py" = ["E722"]
|
||||||
|
|
||||||
|
# tools/: deprecated label tool copy (canonical in avocet); suppress style warnings.
|
||||||
|
"tools/label_tool.py" = ["E741"]
|
||||||
|
|
||||||
|
# tests/: F841 unused variables are the standard mock-patch capture pattern
|
||||||
|
# (e.g., `original_fn = obj.method` before monkeypatching).
|
||||||
|
# E741 ambiguous `l` names and E402 conditional imports are common in test fixtures.
|
||||||
|
# E702 compact `con.commit(); con.close()` is a common SQLite test helper idiom.
|
||||||
|
"tests/**" = ["F841", "E741", "E402", "E702"]
|
||||||
|
"tests/test_wizard_steps.py" = ["F841", "E741", "E402", "E702"]
|
||||||
|
"scripts/test_email_classify.py" = ["E402", "F841"]
|
||||||
|
|
@ -10,23 +10,15 @@ Usage — add to main.py once:
|
||||||
from app.cloud_session import session_middleware_dep
|
from app.cloud_session import session_middleware_dep
|
||||||
app = FastAPI(..., dependencies=[Depends(session_middleware_dep)])
|
app = FastAPI(..., dependencies=[Depends(session_middleware_dep)])
|
||||||
|
|
||||||
From that point, any route (and every service/llm function it calls)
|
Writing model is resolved from Heimdall's resolve response (user_preferences
|
||||||
has access to the current user context via llm.get_request_*() helpers.
|
JSON column, projected as custom_writing_model in the response). Assign models
|
||||||
|
via the admin UI at /account/admin/model-assignments.
|
||||||
Writing model resolution order (first match wins):
|
|
||||||
1. USER_WRITING_MODELS env var — JSON dict mapping Directus UUID → model name
|
|
||||||
e.g. USER_WRITING_MODELS={"5b99ca9f-...": "meghan-letter-writer:latest"}
|
|
||||||
Use this for Monday; no Heimdall changes required.
|
|
||||||
2. session.meta["custom_writing_model"] — returned by Heimdall resolve endpoint
|
|
||||||
once Heimdall is updated to expose user_preferences fields.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
|
|
||||||
from fastapi import Depends, Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from circuitforge_core.cloud_session import CloudSessionFactory, CloudUser, detect_byok
|
from circuitforge_core.cloud_session import CloudSessionFactory, CloudUser, detect_byok
|
||||||
|
|
||||||
|
|
@ -34,21 +26,6 @@ log = logging.getLogger(__name__)
|
||||||
|
|
||||||
__all__ = ["CloudUser", "get_session", "require_tier", "session_middleware_dep"]
|
__all__ = ["CloudUser", "get_session", "require_tier", "session_middleware_dep"]
|
||||||
|
|
||||||
# JSON dict mapping Directus user UUID → custom writing model name.
|
|
||||||
# Used until Heimdall's resolve endpoint exposes user_preferences.
|
|
||||||
def _load_user_writing_models() -> dict[str, str]:
|
|
||||||
raw = os.environ.get("USER_WRITING_MODELS", "").strip()
|
|
||||||
if not raw:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
return json.loads(raw)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
log.warning("USER_WRITING_MODELS is not valid JSON — ignoring")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
_USER_WRITING_MODELS: dict[str, str] = _load_user_writing_models()
|
|
||||||
|
|
||||||
|
|
||||||
_factory = CloudSessionFactory(
|
_factory = CloudSessionFactory(
|
||||||
product="peregrine",
|
product="peregrine",
|
||||||
byok_detector=detect_byok,
|
byok_detector=detect_byok,
|
||||||
|
|
@ -81,9 +58,4 @@ def session_middleware_dep(request: Request, response: Response) -> None:
|
||||||
|
|
||||||
set_request_user_id(user_id)
|
set_request_user_id(user_id)
|
||||||
set_request_tier(session.tier)
|
set_request_tier(session.tier)
|
||||||
# Resolution order: env-var map (Monday path) → Heimdall meta (future path)
|
set_request_writing_model(session.meta.get("custom_writing_model") or None)
|
||||||
writing_model = (
|
|
||||||
_USER_WRITING_MODELS.get(session.user_id)
|
|
||||||
or session.meta.get("custom_writing_model")
|
|
||||||
)
|
|
||||||
set_request_writing_model(writing_model)
|
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,62 @@ async def _allocate_orch_async(
|
||||||
logging.debug("cf-orch release failed (non-fatal): %s", exc)
|
logging.debug("cf-orch release failed (non-fatal): %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _allocate_by_task(
|
||||||
|
coordinator_url: str,
|
||||||
|
product: str,
|
||||||
|
task: str,
|
||||||
|
ttl_s: float,
|
||||||
|
caller: str,
|
||||||
|
):
|
||||||
|
"""Allocate via the task-model assignment layer (POST /api/inference/task).
|
||||||
|
|
||||||
|
Resolves product+task → model_id → service+node automatically.
|
||||||
|
Falls back gracefully: if the coordinator returns 404 (no assignment),
|
||||||
|
raises RuntimeError so the caller can fall back to model_candidates routing.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"product": product,
|
||||||
|
"task": task,
|
||||||
|
"payload": {"ttl_s": ttl_s, "caller": caller},
|
||||||
|
}
|
||||||
|
uid = get_request_user_id()
|
||||||
|
if uid:
|
||||||
|
payload["payload"]["user_id"] = uid
|
||||||
|
resp = await client.post(
|
||||||
|
f"{coordinator_url.rstrip('/')}/api/inference/task",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"No task assignment for product={product!r} task={task!r}; "
|
||||||
|
"falling back to model_candidates routing"
|
||||||
|
)
|
||||||
|
if not resp.is_success:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"cf-orch task allocation failed for {product}/{task}: "
|
||||||
|
f"HTTP {resp.status_code} — {resp.text[:200]}"
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
service = data.get("service_type", "vllm")
|
||||||
|
alloc = _OrchAllocation(
|
||||||
|
allocation_id=data["allocation_id"],
|
||||||
|
url=data["url"],
|
||||||
|
service=service,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield alloc
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await client.delete(
|
||||||
|
f"{coordinator_url.rstrip('/')}/api/services/{service}/allocations/{alloc.allocation_id}",
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logging.debug("cf-orch task release failed (non-fatal): %s", exc)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_api_base(provider: str, api_base: str | None) -> str | None:
|
def _normalize_api_base(provider: str, api_base: str | None) -> str | None:
|
||||||
"""Normalize api_base for LiteLLM provider-specific expectations.
|
"""Normalize api_base for LiteLLM provider-specific expectations.
|
||||||
|
|
||||||
|
|
@ -497,11 +553,41 @@ async def complete(
|
||||||
config: LLMConfig | None = None,
|
config: LLMConfig | None = None,
|
||||||
max_tokens: int = 4096,
|
max_tokens: int = 4096,
|
||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
|
task_name: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Make a completion request to the LLM."""
|
"""Make a completion request to the LLM.
|
||||||
|
|
||||||
|
When task_name is provided and CF_ORCH_URL is set, routing is resolved via
|
||||||
|
the task-model assignment layer (POST /api/inference/task) instead of using
|
||||||
|
hardcoded model_candidates. Falls back to model_candidates routing if the
|
||||||
|
assignment is missing, then to the default config if cf-orch is unavailable.
|
||||||
|
"""
|
||||||
if config is None:
|
if config is None:
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL", "").strip()
|
cf_orch_url = os.environ.get("CF_ORCH_URL", "").strip()
|
||||||
if cf_orch_url:
|
if cf_orch_url:
|
||||||
|
# Task-routing path: preferred when a task name is known.
|
||||||
|
if task_name:
|
||||||
|
try:
|
||||||
|
async with _allocate_by_task(
|
||||||
|
cf_orch_url,
|
||||||
|
product="peregrine",
|
||||||
|
task=task_name,
|
||||||
|
ttl_s=300.0,
|
||||||
|
caller="peregrine-resume-matcher",
|
||||||
|
) as alloc:
|
||||||
|
orch_config = LLMConfig(
|
||||||
|
provider="openai",
|
||||||
|
model="__auto__",
|
||||||
|
api_key="any",
|
||||||
|
api_base=alloc.url.rstrip("/") + "/v1",
|
||||||
|
)
|
||||||
|
return await complete(prompt, system_prompt, orch_config, max_tokens, temperature)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logging.warning(
|
||||||
|
"cf-orch task routing failed for %r, falling back to model_candidates: %s",
|
||||||
|
task_name, exc,
|
||||||
|
)
|
||||||
|
# Model-candidates path: legacy routing or task fallback.
|
||||||
try:
|
try:
|
||||||
# Premium/ultra users get their personal fine-tuned writing model as the
|
# Premium/ultra users get their personal fine-tuned writing model as the
|
||||||
# first candidate; the base model is the fallback so cf-orch can
|
# first candidate; the base model is the fallback so cf-orch can
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ Enhanced features:
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from scripts.classifier_adapters import (
|
from scripts.classifier_adapters import (
|
||||||
LABELS,
|
LABELS,
|
||||||
LABEL_DESCRIPTIONS,
|
|
||||||
ClassifierAdapter,
|
ClassifierAdapter,
|
||||||
GLiClassAdapter,
|
GLiClassAdapter,
|
||||||
RerankerAdapter,
|
RerankerAdapter,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ push updates the existing event rather than creating a duplicate.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
|
||||||
import yaml
|
import yaml
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,17 @@ CREATE TABLE IF NOT EXISTS survey_responses (
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CREATE_RESUME_CORRECTIONS = """
|
||||||
|
CREATE TABLE IF NOT EXISTS resume_optimizer_corrections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
job_id INTEGER NOT NULL REFERENCES jobs(id),
|
||||||
|
section TEXT NOT NULL,
|
||||||
|
proposed_json TEXT NOT NULL,
|
||||||
|
accepted_json TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
CREATE_DIGEST_QUEUE = """
|
CREATE_DIGEST_QUEUE = """
|
||||||
CREATE TABLE IF NOT EXISTS digest_queue (
|
CREATE TABLE IF NOT EXISTS digest_queue (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
|
|
@ -205,9 +216,10 @@ def _migrate_db(db_path: Path) -> None:
|
||||||
conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT")
|
conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass # column already exists
|
pass # column already exists
|
||||||
# Ensure references tables exist (CREATE IF NOT EXISTS is idempotent)
|
# Ensure tables that can't be added via ALTER TABLE exist (all idempotent).
|
||||||
conn.execute(CREATE_REFERENCES)
|
conn.execute(CREATE_REFERENCES)
|
||||||
conn.execute(CREATE_JOB_REFERENCES)
|
conn.execute(CREATE_JOB_REFERENCES)
|
||||||
|
conn.execute(CREATE_RESUME_CORRECTIONS)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -223,6 +235,7 @@ def init_db(db_path: Path = DEFAULT_DB) -> None:
|
||||||
conn.execute(CREATE_DIGEST_QUEUE)
|
conn.execute(CREATE_DIGEST_QUEUE)
|
||||||
conn.execute(CREATE_REFERENCES)
|
conn.execute(CREATE_REFERENCES)
|
||||||
conn.execute(CREATE_JOB_REFERENCES)
|
conn.execute(CREATE_JOB_REFERENCES)
|
||||||
|
conn.execute(CREATE_RESUME_CORRECTIONS)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
_migrate_db(db_path)
|
_migrate_db(db_path)
|
||||||
|
|
@ -1241,3 +1254,76 @@ def set_training_exclusion(db_path: Path, job_id: int, excluded: bool) -> None:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resume optimizer corrections ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def save_resume_correction(
|
||||||
|
db_path: Path,
|
||||||
|
job_id: int,
|
||||||
|
section: str,
|
||||||
|
proposed: object,
|
||||||
|
accepted: object,
|
||||||
|
) -> None:
|
||||||
|
"""Persist a (proposed, accepted) correction pair from the resume review UI.
|
||||||
|
|
||||||
|
Called when a user edits an LLM-proposed value and accepts it. The pair
|
||||||
|
becomes a supervised fine-tuning (SFT) candidate routed through Avocet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
section: 'summary' or 'experience:<title>|<company>'
|
||||||
|
proposed: Original LLM output (string for summary, list for bullets).
|
||||||
|
accepted: User-edited value (same type as proposed).
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO resume_optimizer_corrections
|
||||||
|
(job_id, section, proposed_json, accepted_json)
|
||||||
|
VALUES (?, ?, ?, ?)""",
|
||||||
|
(job_id, section, _json.dumps(proposed), _json.dumps(accepted)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_resume_corrections(
|
||||||
|
db_path: Path,
|
||||||
|
limit: int = 200,
|
||||||
|
job_id: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return pending resume corrections for Avocet export.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum rows to return.
|
||||||
|
job_id: If set, filter to corrections for a specific job.
|
||||||
|
"""
|
||||||
|
import json as _json
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
if job_id is not None:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM resume_optimizer_corrections WHERE job_id=? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
(job_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM resume_optimizer_corrections ORDER BY created_at DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"job_id": r["job_id"],
|
||||||
|
"section": r["section"],
|
||||||
|
"proposed": _json.loads(r["proposed_json"]),
|
||||||
|
"accepted": _json.loads(r["accepted_json"]),
|
||||||
|
"created_at": r["created_at"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,8 @@ def _ensure_labels(
|
||||||
|
|
||||||
def create_forgejo_issue(title: str, body: str, labels: list[str]) -> dict:
|
def create_forgejo_issue(title: str, body: str, labels: list[str]) -> dict:
|
||||||
"""Create a Forgejo issue. Returns {"number": int, "url": str}."""
|
"""Create a Forgejo issue. Returns {"number": int, "url": str}."""
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
# Use the bot token when set; fall back to the main API token for dev/self-hosted.
|
||||||
|
token = os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
|
||||||
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
|
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
|
||||||
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
||||||
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||||
|
|
@ -183,7 +184,7 @@ def upload_attachment(
|
||||||
issue_number: int, image_bytes: bytes, filename: str = "screenshot.png"
|
issue_number: int, image_bytes: bytes, filename: str = "screenshot.png"
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Upload a screenshot to an existing Forgejo issue. Returns attachment URL."""
|
"""Upload a screenshot to an existing Forgejo issue. Returns attachment URL."""
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
token = os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
|
||||||
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
|
repo = os.environ.get("FORGEJO_REPO", "pyr0ball/peregrine")
|
||||||
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
||||||
headers = {"Authorization": f"token {token}"}
|
headers = {"Authorization": f"token {token}"}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ if not LETTERS_JSONL.exists():
|
||||||
sys.exit(f"ERROR: Dataset not found at {LETTERS_JSONL}\n"
|
sys.exit(f"ERROR: Dataset not found at {LETTERS_JSONL}\n"
|
||||||
"Run: make prepare-training (or: python scripts/prepare_training_data.py)")
|
"Run: make prepare-training (or: python scripts/prepare_training_data.py)")
|
||||||
|
|
||||||
records = [json.loads(l) for l in LETTERS_JSONL.read_text().splitlines() if l.strip()]
|
records = [json.loads(line) for line in LETTERS_JSONL.read_text().splitlines() if line.strip()]
|
||||||
print(f"Loaded {len(records)} training examples.")
|
print(f"Loaded {len(records)} training examples.")
|
||||||
|
|
||||||
# Convert to chat format expected by SFTTrainer
|
# Convert to chat format expected by SFTTrainer
|
||||||
|
|
@ -323,6 +323,6 @@ if gguf_path and gguf_path.exists():
|
||||||
else:
|
else:
|
||||||
print(f"\n{'='*60}")
|
print(f"\n{'='*60}")
|
||||||
print(" Adapter saved (no GGUF produced).")
|
print(" Adapter saved (no GGUF produced).")
|
||||||
print(f" Re-run without --no-gguf to generate a GGUF for Ollama registration.")
|
print(" Re-run without --no-gguf to generate a GGUF for Ollama registration.")
|
||||||
print(f" Adapter path: {adapter_path}")
|
print(f" Adapter path: {adapter_path}")
|
||||||
print(f"{'='*60}\n")
|
print(f"{'='*60}\n")
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ def build_prompt(
|
||||||
)
|
)
|
||||||
parts.append(f"{recruiter_note}\n")
|
parts.append(f"{recruiter_note}\n")
|
||||||
|
|
||||||
parts.append(f"Now write a new cover letter for:")
|
parts.append("Now write a new cover letter for:")
|
||||||
parts.append(f" Role: {title}")
|
parts.append(f" Role: {title}")
|
||||||
parts.append(f" Company: {company}")
|
parts.append(f" Company: {company}")
|
||||||
if description:
|
if description:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime
|
||||||
from scripts.integrations.base import IntegrationBase
|
from scripts.integrations.base import IntegrationBase
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import argparse
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from textwrap import dedent
|
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -348,14 +348,14 @@ def write_compose_override(ports: dict[str, dict]) -> None:
|
||||||
for name, info in to_disable.items():
|
for name, info in to_disable.items():
|
||||||
lines += [
|
lines += [
|
||||||
f" {name}: # adopted — host service on :{info['resolved']}",
|
f" {name}: # adopted — host service on :{info['resolved']}",
|
||||||
f" entrypoint: [\"/bin/sh\", \"-c\", \"sleep infinity\"]",
|
" entrypoint: [\"/bin/sh\", \"-c\", \"sleep infinity\"]",
|
||||||
f" ports: []",
|
" ports: []",
|
||||||
f" healthcheck:",
|
" healthcheck:",
|
||||||
f" test: [\"CMD\", \"true\"]",
|
" test: [\"CMD\", \"true\"]",
|
||||||
f" interval: 1s",
|
" interval: 1s",
|
||||||
f" timeout: 1s",
|
" timeout: 1s",
|
||||||
f" start_period: 0s",
|
" start_period: 0s",
|
||||||
f" retries: 1",
|
" retries: 1",
|
||||||
]
|
]
|
||||||
|
|
||||||
OVERRIDE_YML.write_text("\n".join(lines) + "\n")
|
OVERRIDE_YML.write_text("\n".join(lines) + "\n")
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,9 @@ Falls back to empty dict on unrecoverable errors — caller shows the form build
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
import pdfplumber
|
import pdfplumber
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ FastAPI application. Callable directly or via the survey_analyze background task
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,6 @@ def _run_task(db_path: Path, task_id: int, task_type: str, job_id: int,
|
||||||
prioritize_gaps,
|
prioritize_gaps,
|
||||||
rewrite_for_ats,
|
rewrite_for_ats,
|
||||||
hallucination_check,
|
hallucination_check,
|
||||||
render_resume_text,
|
|
||||||
)
|
)
|
||||||
from scripts.user_profile import load_user_profile
|
from scripts.user_profile import load_user_profile
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,13 @@ Public API (unchanged — callers do not need to change):
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from circuitforge_core.tasks.scheduler import (
|
from circuitforge_core.tasks.scheduler import (
|
||||||
TaskSpec, # re-export unchanged
|
|
||||||
LocalScheduler as _CoreTaskScheduler,
|
LocalScheduler as _CoreTaskScheduler,
|
||||||
|
TaskSpec, # noqa: F401 — re-exported as part of public API; tests import from here
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import pytest
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from playwright.sync_api import Page, BrowserContext
|
from playwright.sync_api import Page, BrowserContext
|
||||||
|
|
||||||
from tests.e2e.models import ErrorRecord, ModeConfig, diff_errors
|
from tests.e2e.models import ErrorRecord, ModeConfig
|
||||||
from tests.e2e.modes.demo import DEMO
|
from tests.e2e.modes.demo import DEMO
|
||||||
from tests.e2e.modes.cloud import CLOUD
|
from tests.e2e.modes.cloud import CLOUD
|
||||||
from tests.e2e.modes.local import LOCAL
|
from tests.e2e.modes.local import LOCAL
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ from __future__ import annotations
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.e2e.conftest import (
|
from tests.e2e.conftest import (
|
||||||
wait_for_streamlit, get_page_errors, screenshot_on_fail,
|
wait_for_streamlit, screenshot_on_fail,
|
||||||
)
|
)
|
||||||
from tests.e2e.models import ModeConfig, diff_errors
|
from tests.e2e.models import diff_errors
|
||||||
from tests.e2e.pages.home_page import HomePage
|
from tests.e2e.pages.home_page import HomePage
|
||||||
from tests.e2e.pages.job_review_page import JobReviewPage
|
from tests.e2e.pages.job_review_page import JobReviewPage
|
||||||
from tests.e2e.pages.apply_page import ApplyPage
|
from tests.e2e.pages.apply_page import ApplyPage
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ Run: pytest tests/e2e/test_smoke.py --mode=demo
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.e2e.conftest import wait_for_streamlit, get_page_errors, get_console_errors, screenshot_on_fail
|
from tests.e2e.conftest import wait_for_streamlit, screenshot_on_fail
|
||||||
from tests.e2e.models import ModeConfig
|
|
||||||
from tests.e2e.pages.home_page import HomePage
|
from tests.e2e.pages.home_page import HomePage
|
||||||
from tests.e2e.pages.job_review_page import JobReviewPage
|
from tests.e2e.pages.job_review_page import JobReviewPage
|
||||||
from tests.e2e.pages.apply_page import ApplyPage
|
from tests.e2e.pages.apply_page import ApplyPage
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
from pathlib import Path
|
|
||||||
import yaml
|
import yaml
|
||||||
from scripts.user_profile import UserProfile
|
from scripts.user_profile import UserProfile
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
"""Tests for scripts/backup.py — create, list, restore, and multi-instance support."""
|
"""Tests for scripts/backup.py — create, list, restore, and multi-instance support."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scripts.backup import (
|
from scripts.backup import (
|
||||||
_decrypt_db_to_bytes,
|
_decrypt_db_to_bytes,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
"""Tests for BYOK cloud backend detection."""
|
"""Tests for BYOK cloud backend detection."""
|
||||||
import pytest
|
|
||||||
from scripts.byok_guard import is_cloud_backend, cloud_backends
|
from scripts.byok_guard import is_cloud_backend, cloud_backends
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ from datetime import timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import pytest
|
from unittest.mock import patch
|
||||||
import os
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_session_is_noop_in_local_mode(monkeypatch):
|
def test_resolve_session_is_noop_in_local_mode(monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
# tests/test_cover_letter.py
|
# tests/test_cover_letter.py
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -90,7 +88,7 @@ def test_find_similar_letters_returns_top_k():
|
||||||
|
|
||||||
def test_load_corpus_returns_list():
|
def test_load_corpus_returns_list():
|
||||||
"""load_corpus returns a list (empty if LETTERS_DIR absent) without crashing."""
|
"""load_corpus returns a list (empty if LETTERS_DIR absent) without crashing."""
|
||||||
from scripts.generate_cover_letter import load_corpus, LETTERS_DIR
|
from scripts.generate_cover_letter import load_corpus
|
||||||
|
|
||||||
corpus = load_corpus()
|
corpus = load_corpus()
|
||||||
assert isinstance(corpus, list)
|
assert isinstance(corpus, list)
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,6 @@ class TestTaskRunnerCoverLetterParams:
|
||||||
patch("sqlite3.connect") as mock_conn, \
|
patch("sqlite3.connect") as mock_conn, \
|
||||||
patch("scripts.task_runner.generate_cover_letter_fn", mock_generate, create=True):
|
patch("scripts.task_runner.generate_cover_letter_fn", mock_generate, create=True):
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
mock_row = MagicMock()
|
mock_row = MagicMock()
|
||||||
mock_row.__iter__ = lambda s: iter(job.items())
|
mock_row.__iter__ = lambda s: iter(job.items())
|
||||||
mock_row.keys = lambda: job.keys()
|
mock_row.keys = lambda: job.keys()
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from email.utils import format_datetime
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
import pytest
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import pytest
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
|
|
||||||
def test_init_db_creates_jobs_table(tmp_path):
|
def test_init_db_creates_jobs_table(tmp_path):
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Tests for scripts/db_migrate.py — numbered SQL migration runner."""
|
"""Tests for scripts/db_migrate.py — numbered SQL migration runner."""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import textwrap
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Tests for resume library db helpers."""
|
"""Tests for resume library db helpers."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""IS_DEMO write-block guard tests."""
|
"""IS_DEMO write-block guard tests."""
|
||||||
import importlib
|
import importlib
|
||||||
import os
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"""Tests for app/components/demo_toolbar.py."""
|
"""Tests for app/components/demo_toolbar.py."""
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"""Tests for digest queue API endpoints."""
|
"""Tests for digest queue API endpoints."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Tests for new dev-api.py endpoints: stage signals, email sync, signal dismiss."""
|
"""Tests for new dev-api.py endpoints: stage signals, email sync, signal dismiss."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
|
||||||
import os
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
"""Tests for interview prep endpoints: research GET/generate/task, contacts GET."""
|
"""Tests for interview prep endpoints: research GET/generate/task, contacts GET."""
|
||||||
import json
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
@ -17,7 +16,6 @@ def client():
|
||||||
|
|
||||||
def test_get_research_found(client):
|
def test_get_research_found(client):
|
||||||
"""Returns research row (minus raw_output) when present."""
|
"""Returns research row (minus raw_output) when present."""
|
||||||
import sqlite3
|
|
||||||
mock_row = {
|
mock_row = {
|
||||||
"job_id": 1,
|
"job_id": 1,
|
||||||
"company_brief": "Acme Corp makes anvils.",
|
"company_brief": "Acme Corp makes anvils.",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
"""Tests for all settings API endpoints added in Tasks 1–8."""
|
"""Tests for all settings API endpoints added in Tasks 1–8."""
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import yaml
|
import yaml
|
||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
# credential_store.py was merged to main repo — no worktree path manipulation needed
|
# credential_store.py was merged to main repo — no worktree path manipulation needed
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import yaml
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
# tests/test_discover.py
|
# tests/test_discover.py
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
SAMPLE_JOB = {
|
SAMPLE_JOB = {
|
||||||
"title": "Customer Success Manager",
|
"title": "Customer Success Manager",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
"""Unit tests for E2E harness models and helper utilities."""
|
"""Unit tests for E2E harness models and helper utilities."""
|
||||||
import fnmatch
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
import time
|
import time
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Tests for the feedback API backend."""
|
"""Tests for the feedback API backend."""
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
# ── mask_pii ──────────────────────────────────────────────────────────────────
|
# ── mask_pii ──────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
"""Tests for imap_sync helpers (no live IMAP connection required)."""
|
"""Tests for imap_sync helpers (no live IMAP connection required)."""
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -510,7 +509,7 @@ def test_search_folder_special_gmail_name():
|
||||||
def test_get_existing_message_ids_excludes_null(tmp_path):
|
def test_get_existing_message_ids_excludes_null(tmp_path):
|
||||||
"""NULL message_id rows are excluded from the returned set."""
|
"""NULL message_id rows are excluded from the returned set."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from scripts.db import init_db, insert_job, add_contact
|
from scripts.db import init_db, insert_job
|
||||||
from scripts.imap_sync import _get_existing_message_ids
|
from scripts.imap_sync import _get_existing_message_ids
|
||||||
|
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
|
|
@ -980,7 +979,6 @@ def test_scan_todo_label_stage_signal_set_for_non_neutral(tmp_path):
|
||||||
|
|
||||||
def test_scan_todo_label_body_fallback_matches(tmp_path):
|
def test_scan_todo_label_body_fallback_matches(tmp_path):
|
||||||
"""Company name only in body[:300] still triggers a match (body fallback)."""
|
"""Company name only in body[:300] still triggers a match (body fallback)."""
|
||||||
from scripts.db import get_contacts
|
|
||||||
from scripts.imap_sync import _scan_todo_label
|
from scripts.imap_sync import _scan_todo_label
|
||||||
|
|
||||||
db_path = tmp_path / "test.db"
|
db_path = tmp_path / "test.db"
|
||||||
|
|
@ -1110,7 +1108,6 @@ def test_parse_message_large_body_not_truncated():
|
||||||
def test_parse_message_binary_attachment_no_crash():
|
def test_parse_message_binary_attachment_no_crash():
|
||||||
"""Email with binary attachment returns a valid dict without crashing."""
|
"""Email with binary attachment returns a valid dict without crashing."""
|
||||||
from scripts.imap_sync import _parse_message
|
from scripts.imap_sync import _parse_message
|
||||||
import email as _email
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from email.mime.application import MIMEApplication
|
from email.mime.application import MIMEApplication
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@ def test_fields_returns_list_of_dicts():
|
||||||
def test_save_and_load_config(tmp_path):
|
def test_save_and_load_config(tmp_path):
|
||||||
"""save_config writes yaml; load_config reads it back."""
|
"""save_config writes yaml; load_config reads it back."""
|
||||||
from scripts.integrations.base import IntegrationBase
|
from scripts.integrations.base import IntegrationBase
|
||||||
import yaml
|
|
||||||
|
|
||||||
class TestIntegration(IntegrationBase):
|
class TestIntegration(IntegrationBase):
|
||||||
name = "savetest"
|
name = "savetest"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import json
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import json
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from unittest.mock import patch
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
from pathlib import Path
|
|
||||||
import yaml
|
import yaml
|
||||||
from scripts.user_profile import UserProfile
|
from scripts.user_profile import UserProfile
|
||||||
from scripts.generate_llm_config import apply_service_urls
|
from scripts.generate_llm_config import apply_service_urls
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ def test_complete_without_images_skips_vision_service(tmp_path):
|
||||||
"""When images=None, vision_service backend is skipped."""
|
"""When images=None, vision_service backend is skipped."""
|
||||||
import yaml
|
import yaml
|
||||||
from scripts.llm_router import LLMRouter
|
from scripts.llm_router import LLMRouter
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch
|
||||||
|
|
||||||
cfg = {
|
cfg = {
|
||||||
"fallback_order": ["vision_service"],
|
"fallback_order": ["vision_service"],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Tests for Peregrine's LLMRouter shim — priority fallback logic."""
|
"""Tests for Peregrine's LLMRouter shim — priority fallback logic."""
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, MagicMock, call
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
@ -54,7 +54,6 @@ def test_uses_local_yaml_when_present():
|
||||||
|
|
||||||
def test_falls_through_to_env_when_no_yamls():
|
def test_falls_through_to_env_when_no_yamls():
|
||||||
"""When no yaml files exist, super().__init__ is called with no args (env-var path)."""
|
"""When no yaml files exist, super().__init__ is called with no args (env-var path)."""
|
||||||
import scripts.llm_router as shim_mod
|
|
||||||
from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
|
from circuitforge_core.llm import LLMRouter as _CoreLLMRouter
|
||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
"""Integration tests for messaging endpoints."""
|
"""Integration tests for messaging endpoints."""
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
"""Tests for scripts/preflight.py additions: dual-GPU service table, size warning, VRAM check."""
|
"""Tests for scripts/preflight.py additions: dual-GPU service table, size warning, VRAM check."""
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import yaml
|
import yaml
|
||||||
import tempfile
|
import tempfile
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
# ── Service table ──────────────────────────────────────────────────────────────
|
# ── Service table ──────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Tests: preflight writes OLLAMA_HOST to .env when Ollama is adopted from host."""
|
"""Tests: preflight writes OLLAMA_HOST to .env when Ollama is adopted from host."""
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch, call
|
from unittest.mock import patch
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
Set CF_RERANKER_MOCK=1 to avoid loading real model weights during tests.
|
Set CF_RERANKER_MOCK=1 to avoid loading real model weights during tests.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
os.environ["CF_RERANKER_MOCK"] = "1"
|
os.environ["CF_RERANKER_MOCK"] = "1"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
# tests/test_resume_optimizer.py
|
# tests/test_resume_optimizer.py
|
||||||
"""Tests for scripts/resume_optimizer.py"""
|
"""Tests for scripts/resume_optimizer.py"""
|
||||||
import json
|
import json
|
||||||
import pytest
|
from unittest.mock import patch
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
|
|
||||||
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
# ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
"""Unit tests for scripts.resume_sync — format transform between library and profile."""
|
"""Unit tests for scripts.resume_sync — format transform between library and profile."""
|
||||||
import json
|
|
||||||
import pytest
|
|
||||||
from scripts.resume_sync import (
|
from scripts.resume_sync import (
|
||||||
library_to_profile_content,
|
library_to_profile_content,
|
||||||
profile_to_library,
|
profile_to_library,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""Integration tests for resume library<->profile sync endpoints."""
|
"""Integration tests for resume library<->profile sync endpoints."""
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
"""Tests for /api/resumes/* endpoints."""
|
"""Tests for /api/resumes/* endpoints."""
|
||||||
import json
|
|
||||||
import io
|
import io
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
# tests/test_sync.py
|
# tests/test_sync.py
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
SAMPLE_FM = {
|
SAMPLE_FM = {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import threading
|
|
||||||
import time
|
import time
|
||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
|
|
@ -178,7 +176,6 @@ def test_submit_task_actually_completes(tmp_path):
|
||||||
def test_run_task_enrich_craigslist_success(tmp_path):
|
def test_run_task_enrich_craigslist_success(tmp_path):
|
||||||
"""enrich_craigslist task calls enrich_craigslist_fields and marks completed."""
|
"""enrich_craigslist task calls enrich_craigslist_fields and marks completed."""
|
||||||
from scripts.db import init_db, insert_job, insert_task, get_task_for_job
|
from scripts.db import init_db, insert_job, insert_task, get_task_for_job
|
||||||
from unittest.mock import MagicMock
|
|
||||||
db = tmp_path / "test.db"
|
db = tmp_path / "test.db"
|
||||||
init_db(db)
|
init_db(db)
|
||||||
job_id = insert_job(db, {
|
job_id = insert_job(db, {
|
||||||
|
|
@ -200,7 +197,7 @@ def test_run_task_enrich_craigslist_success(tmp_path):
|
||||||
|
|
||||||
def test_scrape_url_submits_enrich_craigslist_for_craigslist_job(tmp_path):
|
def test_scrape_url_submits_enrich_craigslist_for_craigslist_job(tmp_path):
|
||||||
"""After scrape_url completes for a craigslist job with empty company, enrich_craigslist is queued."""
|
"""After scrape_url completes for a craigslist job with empty company, enrich_craigslist is queued."""
|
||||||
from scripts.db import init_db, insert_job, insert_task, get_task_for_job
|
from scripts.db import init_db, insert_job, insert_task
|
||||||
db = tmp_path / "test.db"
|
db = tmp_path / "test.db"
|
||||||
init_db(db)
|
init_db(db)
|
||||||
job_id = insert_job(db, {
|
job_id = insert_job(db, {
|
||||||
|
|
@ -285,7 +282,7 @@ def test_wizard_generate_null_params_fails(tmp_path):
|
||||||
|
|
||||||
def test_wizard_generate_stores_result_as_json(tmp_path):
|
def test_wizard_generate_stores_result_as_json(tmp_path):
|
||||||
"""wizard_generate stores result JSON in error field on success."""
|
"""wizard_generate stores result JSON in error field on success."""
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch
|
||||||
db = tmp_path / "t.db"
|
db = tmp_path / "t.db"
|
||||||
from scripts.db import init_db, insert_task
|
from scripts.db import init_db, insert_task
|
||||||
init_db(db)
|
init_db(db)
|
||||||
|
|
@ -311,7 +308,7 @@ def test_wizard_generate_stores_result_as_json(tmp_path):
|
||||||
|
|
||||||
def test_wizard_generate_feedback_appended_to_prompt(tmp_path):
|
def test_wizard_generate_feedback_appended_to_prompt(tmp_path):
|
||||||
"""feedback and previous_result fields in input_data are appended to the prompt."""
|
"""feedback and previous_result fields in input_data are appended to the prompt."""
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch
|
||||||
db = tmp_path / "t.db"
|
db = tmp_path / "t.db"
|
||||||
from scripts.db import init_db, insert_task
|
from scripts.db import init_db, insert_task
|
||||||
init_db(db)
|
init_db(db)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -192,7 +191,6 @@ def test_max_queue_depth_logs_warning(tmp_db, caplog):
|
||||||
"""Queue depth overflow logs a WARNING."""
|
"""Queue depth overflow logs a WARNING."""
|
||||||
import logging
|
import logging
|
||||||
from scripts.db import insert_task
|
from scripts.db import insert_task
|
||||||
from scripts.task_scheduler import TaskSpec
|
|
||||||
|
|
||||||
s = TaskScheduler(tmp_db, _noop_run_task)
|
s = TaskScheduler(tmp_db, _noop_run_task)
|
||||||
s._max_queue_depth = 0 # immediately at limit
|
s._max_queue_depth = 0 # immediately at limit
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import pytest
|
from unittest.mock import patch, MagicMock
|
||||||
import os
|
|
||||||
from unittest.mock import patch, MagicMock, call
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_op_in_local_mode(monkeypatch):
|
def test_no_op_in_local_mode(monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# tests/test_user_profile.py
|
# tests/test_user_profile.py
|
||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile, yaml
|
import yaml
|
||||||
from scripts.user_profile import UserProfile
|
from scripts.user_profile import UserProfile
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from unittest.mock import patch
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from app.wizard.tiers import can_use, tier_label, TIERS, FEATURES, BYOK_UNLOCKABLE
|
from app.wizard.tiers import can_use, tier_label, TIERS, BYOK_UNLOCKABLE
|
||||||
|
|
||||||
|
|
||||||
def test_tiers_list():
|
def test_tiers_list():
|
||||||
|
|
|
||||||
|
|
@ -352,8 +352,8 @@ with tab_fetch:
|
||||||
|
|
||||||
if not accounts:
|
if not accounts:
|
||||||
st.warning(
|
st.warning(
|
||||||
f"No accounts configured. Copy `config/label_tool.yaml.example` → "
|
"No accounts configured. Copy `config/label_tool.yaml.example` → "
|
||||||
f"`config/label_tool.yaml` and add your IMAP accounts.",
|
"`config/label_tool.yaml` and add your IMAP accounts.",
|
||||||
icon="⚠️",
|
icon="⚠️",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|
@ -625,7 +625,7 @@ with tab_stats:
|
||||||
st.markdown(f"**{len(labeled)} labeled emails total**")
|
st.markdown(f"**{len(labeled)} labeled emails total**")
|
||||||
|
|
||||||
# Show known labels first, then any custom labels
|
# Show known labels first, then any custom labels
|
||||||
all_display_labels = list(LABELS) + [l for l in counts if l not in LABELS]
|
all_display_labels = list(LABELS) + [lbl for lbl in counts if lbl not in LABELS]
|
||||||
max_count = max(counts.values()) if counts else 1
|
max_count = max(counts.values()) if counts else 1
|
||||||
for lbl in all_display_labels:
|
for lbl in all_display_labels:
|
||||||
if lbl not in counts:
|
if lbl not in counts:
|
||||||
|
|
|
||||||
275
web/package-lock.json
generated
275
web/package-lock.json
generated
|
|
@ -346,9 +346,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
|
@ -363,9 +363,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
|
@ -380,9 +380,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -397,9 +397,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -414,9 +414,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -431,9 +431,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -448,9 +448,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -465,9 +465,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -482,9 +482,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
|
@ -499,9 +499,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -516,9 +516,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
|
@ -533,9 +533,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
|
|
@ -550,9 +550,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
|
|
@ -567,9 +567,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
|
@ -584,9 +584,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
|
@ -601,9 +601,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
|
@ -618,9 +618,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -635,9 +635,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -652,9 +652,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -669,9 +669,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -686,9 +686,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -703,9 +703,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -720,9 +720,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -737,9 +737,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
|
@ -754,9 +754,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
|
|
@ -771,9 +771,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
|
@ -2728,9 +2728,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -2949,9 +2949,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/defu": {
|
"node_modules/defu": {
|
||||||
"version": "6.1.4",
|
"version": "6.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|
@ -3032,9 +3032,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -3045,32 +3045,32 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.27.4",
|
"@esbuild/aix-ppc64": "0.27.7",
|
||||||
"@esbuild/android-arm": "0.27.4",
|
"@esbuild/android-arm": "0.27.7",
|
||||||
"@esbuild/android-arm64": "0.27.4",
|
"@esbuild/android-arm64": "0.27.7",
|
||||||
"@esbuild/android-x64": "0.27.4",
|
"@esbuild/android-x64": "0.27.7",
|
||||||
"@esbuild/darwin-arm64": "0.27.4",
|
"@esbuild/darwin-arm64": "0.27.7",
|
||||||
"@esbuild/darwin-x64": "0.27.4",
|
"@esbuild/darwin-x64": "0.27.7",
|
||||||
"@esbuild/freebsd-arm64": "0.27.4",
|
"@esbuild/freebsd-arm64": "0.27.7",
|
||||||
"@esbuild/freebsd-x64": "0.27.4",
|
"@esbuild/freebsd-x64": "0.27.7",
|
||||||
"@esbuild/linux-arm": "0.27.4",
|
"@esbuild/linux-arm": "0.27.7",
|
||||||
"@esbuild/linux-arm64": "0.27.4",
|
"@esbuild/linux-arm64": "0.27.7",
|
||||||
"@esbuild/linux-ia32": "0.27.4",
|
"@esbuild/linux-ia32": "0.27.7",
|
||||||
"@esbuild/linux-loong64": "0.27.4",
|
"@esbuild/linux-loong64": "0.27.7",
|
||||||
"@esbuild/linux-mips64el": "0.27.4",
|
"@esbuild/linux-mips64el": "0.27.7",
|
||||||
"@esbuild/linux-ppc64": "0.27.4",
|
"@esbuild/linux-ppc64": "0.27.7",
|
||||||
"@esbuild/linux-riscv64": "0.27.4",
|
"@esbuild/linux-riscv64": "0.27.7",
|
||||||
"@esbuild/linux-s390x": "0.27.4",
|
"@esbuild/linux-s390x": "0.27.7",
|
||||||
"@esbuild/linux-x64": "0.27.4",
|
"@esbuild/linux-x64": "0.27.7",
|
||||||
"@esbuild/netbsd-arm64": "0.27.4",
|
"@esbuild/netbsd-arm64": "0.27.7",
|
||||||
"@esbuild/netbsd-x64": "0.27.4",
|
"@esbuild/netbsd-x64": "0.27.7",
|
||||||
"@esbuild/openbsd-arm64": "0.27.4",
|
"@esbuild/openbsd-arm64": "0.27.7",
|
||||||
"@esbuild/openbsd-x64": "0.27.4",
|
"@esbuild/openbsd-x64": "0.27.7",
|
||||||
"@esbuild/openharmony-arm64": "0.27.4",
|
"@esbuild/openharmony-arm64": "0.27.7",
|
||||||
"@esbuild/sunos-x64": "0.27.4",
|
"@esbuild/sunos-x64": "0.27.7",
|
||||||
"@esbuild/win32-arm64": "0.27.4",
|
"@esbuild/win32-arm64": "0.27.7",
|
||||||
"@esbuild/win32-ia32": "0.27.4",
|
"@esbuild/win32-ia32": "0.27.7",
|
||||||
"@esbuild/win32-x64": "0.27.4"
|
"@esbuild/win32-x64": "0.27.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/estree-walker": {
|
"node_modules/estree-walker": {
|
||||||
|
|
@ -3325,14 +3325,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/js-cookie": {
|
"node_modules/js-cookie": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz",
|
||||||
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
|
"integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"engines": {
|
|
||||||
"node": ">=14"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "28.1.0",
|
"version": "28.1.0",
|
||||||
|
|
@ -3500,9 +3497,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "18.0.0",
|
"version": "18.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||||
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
|
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
|
|
@ -3586,9 +3583,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -3787,9 +3784,9 @@
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|
@ -3831,9 +3828,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|
@ -3850,7 +3847,7 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|
@ -4484,9 +4481,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -4987,9 +4984,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.2",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
|
|
|
||||||
57
web/src/stores/settings/sync.ts
Normal file
57
web/src/stores/settings/sync.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApiFetch } from '../../composables/useApi'
|
||||||
|
|
||||||
|
export const SYNC_DATA_CLASSES = [
|
||||||
|
{ key: 'peregrine:dismissed', label: 'Dismissed job IDs', description: 'Hides jobs you have already reviewed across devices.' },
|
||||||
|
{ key: 'peregrine:drafts', label: 'Cover letter drafts', description: 'Saves in-progress drafts so you can continue on another device.' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type SyncDataClass = typeof SYNC_DATA_CLASSES[number]['key']
|
||||||
|
|
||||||
|
export interface SyncPref {
|
||||||
|
data_class: string
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSyncStore = defineStore('sync', () => {
|
||||||
|
const prefs = ref<Record<string, boolean>>({})
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref<string | null>(null)
|
||||||
|
const wiping = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadPrefs() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
const { data, error: err } = await useApiFetch<SyncPref[]>('/sync/prefs')
|
||||||
|
loading.value = false
|
||||||
|
if (err) { error.value = 'Failed to load sync preferences.'; return }
|
||||||
|
const map: Record<string, boolean> = {}
|
||||||
|
for (const p of data ?? []) map[p.data_class] = p.enabled
|
||||||
|
prefs.value = map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPref(dataClass: string, enabled: boolean) {
|
||||||
|
saving.value = dataClass
|
||||||
|
error.value = null
|
||||||
|
const { error: err } = await useApiFetch('/sync/prefs', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ data_class: dataClass, enabled }),
|
||||||
|
})
|
||||||
|
saving.value = null
|
||||||
|
if (err) { error.value = `Failed to update sync preference for ${dataClass}.`; return }
|
||||||
|
prefs.value = { ...prefs.value, [dataClass]: enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wipeAll() {
|
||||||
|
wiping.value = true
|
||||||
|
error.value = null
|
||||||
|
const { error: err } = await useApiFetch('/sync/all', { method: 'DELETE' })
|
||||||
|
wiping.value = false
|
||||||
|
if (err) { error.value = 'Failed to delete sync data.'; return }
|
||||||
|
prefs.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { prefs, loading, saving, wiping, error, loadPrefs, setPref, wipeAll }
|
||||||
|
})
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useDataStore } from '../../stores/settings/data'
|
import { useDataStore } from '../../stores/settings/data'
|
||||||
|
import { useSyncStore, SYNC_DATA_CLASSES } from '../../stores/settings/sync'
|
||||||
|
import { useAppConfigStore } from '../../stores/appConfig'
|
||||||
|
|
||||||
const store = useDataStore()
|
const store = useDataStore()
|
||||||
const { backupPath, backupFileCount, backupSizeBytes, creatingBackup, backupError } = storeToRefs(store)
|
const { backupPath, backupFileCount, backupSizeBytes, creatingBackup, backupError } = storeToRefs(store)
|
||||||
|
|
@ -9,6 +11,13 @@ const includeDb = ref(false)
|
||||||
const showRestoreConfirm = ref(false)
|
const showRestoreConfirm = ref(false)
|
||||||
const restoreFile = ref<File | null>(null)
|
const restoreFile = ref<File | null>(null)
|
||||||
|
|
||||||
|
const sync = useSyncStore()
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
|
const canSync = config.isCloud && ['paid', 'premium'].includes(config.tier)
|
||||||
|
|
||||||
|
onMounted(() => { if (config.isCloud) sync.loadPrefs() })
|
||||||
|
|
||||||
function formatBytes(b: number) {
|
function formatBytes(b: number) {
|
||||||
if (b < 1024) return `${b} B`
|
if (b < 1024) return `${b} B`
|
||||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
|
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
|
||||||
|
|
@ -77,5 +86,71 @@ function formatBytes(b: number) {
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Cross-device sync — cloud accounts only -->
|
||||||
|
<section v-if="config.isCloud" class="form-section">
|
||||||
|
<h3>Cross-device Sync <span class="tier-badge">Paid</span></h3>
|
||||||
|
<p class="section-note">
|
||||||
|
Sync selected data to your cloud account so it follows you across devices.
|
||||||
|
Each category is opt-in — nothing is uploaded until you enable it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="sync.loading" class="sync-loading">Loading sync preferences…</div>
|
||||||
|
|
||||||
|
<template v-else-if="canSync">
|
||||||
|
<div v-for="dc in SYNC_DATA_CLASSES" :key="dc.key" class="sync-row">
|
||||||
|
<label class="sync-toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="sync.prefs[dc.key] ?? false"
|
||||||
|
:disabled="sync.saving === dc.key"
|
||||||
|
@change="sync.setPref(dc.key, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span class="sync-label-text">
|
||||||
|
<strong>{{ dc.label }}</strong>
|
||||||
|
<span class="sync-label-desc">{{ dc.description }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="sync.error" class="error-msg">{{ sync.error }}</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-else class="tier-gate-note">
|
||||||
|
Cross-device sync is available on the Paid and Premium plans.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Delete all — tier-free, always shown to cloud users -->
|
||||||
|
<div class="form-actions sync-delete-row">
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
:disabled="sync.wiping"
|
||||||
|
@click="sync.wipeAll()"
|
||||||
|
>
|
||||||
|
{{ sync.wiping ? 'Deleting…' : 'Delete all sync data' }}
|
||||||
|
</button>
|
||||||
|
<span class="section-note">Removes all uploaded sync data immediately. Preferences are also reset.</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tier-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.15em 0.5em;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-accent, #6c63ff);
|
||||||
|
color: #fff;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 0.4em;
|
||||||
|
}
|
||||||
|
.sync-loading { color: var(--color-text-muted); font-size: 0.9rem; margin: 0.5rem 0; }
|
||||||
|
.sync-row { margin: 0.75rem 0; }
|
||||||
|
.sync-toggle-label { display: flex; align-items: flex-start; gap: 0.6rem; cursor: pointer; }
|
||||||
|
.sync-label-text { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
|
.sync-label-desc { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
|
.sync-delete-row { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-top: 1rem; }
|
||||||
|
.sync-delete-row .section-note { margin: 0; }
|
||||||
|
.tier-gate-note { font-size: 0.85rem; color: var(--color-text-muted); margin: 0.5rem 0; }
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue