diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b0c1598..b697c17d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ ### 变更 - `WORKER_CONCURRENCY` 从 io 组总并发改为映射到网络道;compose 不再给它缺省值(缺省走设置页)。`worker` / `worker:agent` / `all` 角色与镜像入口不变。 +### 变更(IA-2 导航与站点类型) +- 侧栏改为 4 组:概览 / 获取 / 整理 / 系统。「任务」移到系统组;站点、订阅、规则合成「获取」组(组名是 `nav.ts` 的 `ACQUIRE_GROUP_TITLE` 常量,改名只改一处);独立的 RSS 导航项取消。 +- RSS 属于站点:站点详情新增「RSS」Tab,承载原 RSS 页的新建 / 编辑 / 启停 / 立即抓取 / 抓取状态,详情里加「最近条目」;原「抓取与 RSS」拆成「抓取」和「RSS」。旧 `/console/sources*` 链接跳到对应站点的 RSS Tab(`?feed=` 查所属站点后打开该 RSS)或站点列表;任务 / 业务任务里的 RSS 链接改指站点。 +- 站点类型:站点新增 `kind`(PT 站 / 公开 BT 站 / 自定义 RSS 源;Torznab 索引器只定义结构,未实现),每种类型声明获取方式(搜索 / 列表抓取 / RSS / 详情页 / 登录 / 账号数据),实际可用 = 类型 ∩ 站点定义。站点列表可按类型筛选,详情按类型显示 Tab 与字段(自定义 RSS 源没有登录、账号、等级);添加站点第一步选类型,PT 站仍从站点定义库选。 +- 服务端按获取方式调度:快照只排能抓列表的站点,RSS 只排能抓 RSS 的站点,账号数据 / 健康检测只扫有登录的站点,搜索站点只含能搜索的;不支持的登录 / 凭证 / 抓取请求返回 400。 +- 不带站点新建 RSS(旧 API)时归入同主机的自定义 RSS 源站点;RSS 不能再移出站点。智能体 `site.list` 带站点类型与获取方式,系统提示同步"RSS 属于站点"。 +- 迁移 0055:`site.kind`;旧的通用 RSS 站点 → 自定义 RSS 源;不属于任何站点的 RSS 按主机归入新建的自定义 RSS 源站点,下次抓取把条目写成资源。 + +### 修复(IA-2) +- 同一站点添加第二个 RSS 且都用缺省名称时,secret 名冲突导致 500。 + ## [0.3.0] - 2026-09-29 ### 新增 diff --git a/apps/server/src/modules/search/actions.ts b/apps/server/src/modules/search/actions.ts index 00c0d13e..f62eae0a 100644 --- a/apps/server/src/modules/search/actions.ts +++ b/apps/server/src/modules/search/actions.ts @@ -1,6 +1,6 @@ import type { SearchAction } from "@umt/contracts"; -// 页面与操作跳转(命令面板静态清单),按 IA-3 定稿导航:概览 / 资源 / 订阅 / 整理 / 系统(定时任务在任务页"计划"Tab)。 +// 页面与操作跳转(命令面板静态清单),按 IA-2 导航:概览 / 获取 / 整理 / 系统(任务归系统;RSS 在站点详情里)。 // href 是控制台内路径;action 类带查询参数,由前端页面识别后打开对应表单。 const page = (id: string, group: string, title: string, href: string, keywords: string[] = []): SearchAction => ({ @@ -22,28 +22,28 @@ const action = (id: string, group: string, title: string, href: string, keywords export const SEARCH_ACTIONS: readonly SearchAction[] = [ page("home", "概览", "首页", "/console", ["home", "dashboard", "概览"]), - page("tasks", "概览", "任务", "/console/tasks", ["task", "运行", "历史"]), page("agent", "概览", "智能体会话", "/console/agent", ["agent", "会话"]), page("inbox", "概览", "待处理", "/console/inbox", ["inbox", "收件箱", "decision"]), - page("sites", "资源", "站点", "/console/sites", ["site", "pt", "站点账号"]), - page("sources", "资源", "RSS 订阅", "/console/sources", ["rss", "feed"]), - page("subscriptions", "订阅", "订阅", "/console/subscriptions", ["subscription", "追剧"]), - page("rules", "订阅", "规则", "/console/rules", ["rules", "收录策略", "policy"]), + page("schedules", "概览", "计划", "/console/schedules", ["schedule", "cron", "巡检", "计划", "定时任务"]), + page("sites", "获取", "站点", "/console/sites", ["site", "pt", "站点账号", "rss", "feed", "bt"]), + page("subscriptions", "获取", "订阅", "/console/subscriptions", ["subscription", "追剧"]), + page("rules", "获取", "规则", "/console/rules", ["rules", "收录策略", "policy"]), page("downloads", "整理", "下载面板", "/console/downloads", ["download", "qb", "下载任务"]), page("download-plans", "整理", "下载计划", "/console/downloads/plans", ["plan", "计划"]), page("organize", "整理", "整理记录", "/console/organize", ["organize", "入库", "历史"]), page("files", "整理", "文件管理", "/console/files", ["files", "文件"]), page("trash", "整理", "回收站", "/console/files/trash", ["trash", "删除"]), page("library", "整理", "媒体库", "/console/library", ["library", "媒体库", "作品"]), + page("tasks", "系统", "任务", "/console/tasks", ["task", "运行", "历史"]), page("settings", "系统", "设置", "/console/settings", ["settings", "配置", "tmdb", "下载器"]), - page("schedules", "概览", "定时任务", "/console/tasks?tab=plan", ["schedule", "cron", "巡检", "计划"]), page("audit", "系统", "审计", "/console/audit", ["audit", "日志"]), page("preferences", "系统", "偏好", "/console/preferences", ["preferences", "主题"]), - action("new-subscription", "订阅", "新建订阅", "/console/subscriptions?new=1", ["subscribe", "订阅"]), + action("new-subscription", "获取", "新建订阅", "/console/subscriptions?new=1", ["subscribe", "订阅"]), action("new-agent-run", "概览", "新建智能体会话", "/console/agent?new=1", ["agent", "委托"]), - action("add-site", "资源", "添加站点", "/console/sites?new=1", ["site", "站点"]), - action("add-feed", "资源", "添加 RSS 订阅", "/console/sources?new=1", ["rss", "feed"]), - action("new-schedule", "概览", "新建巡检", "/console/tasks?tab=plan&new=1", ["巡检", "agent_check"]), + action("add-site", "获取", "添加站点", "/console/sites?new=1", ["site", "站点", "rss", "feed", "bt"]), + // RSS 属于站点(IA-2):添加 RSS 源 = 添加「自定义 RSS 源」类型的站点 + action("add-feed", "获取", "添加自定义 RSS 源", "/console/sites?new=rss_feed", ["rss", "feed"]), + action("new-schedule", "概览", "新建巡检", "/console/schedules?new=1", ["巡检", "agent_check"]), ]; /** 标题 / 关键词 / 路径包含查询词(不区分大小写);标题前缀命中排前 */ diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts index 7643c6e5..edf4be3f 100644 --- a/apps/server/src/modules/search/releases.ts +++ b/apps/server/src/modules/search/releases.ts @@ -14,6 +14,7 @@ import { type SiteRow, sql } from "@umt/db"; import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, parseRelease } from "@umt/domain"; import type { SourcesRuntime } from "../sources-identity/runtime.ts"; import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts"; +import { siteCan } from "../sources-identity/site-kinds.ts"; import { raceAbort } from "./media.ts"; // 跨站资源搜索:选中的已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限); @@ -63,15 +64,16 @@ export class ReleaseFanout { private readonly opts: FanoutOptions, ) {} - /** 参与搜索的候选站点:启用且未删除(按名称排序) */ + /** 参与搜索的候选站点:启用、未删除且支持搜索(IA-2 按站点类型与定义的获取方式;按名称排序) */ async enabledSites(): Promise { - return this.rt() + const rows = await this.rt() .ctx.db.selectFrom("site") .selectAll() .where("enabled", "=", true) .where("deleted_at", "is", null) .orderBy("name") .execute(); + return rows.filter((s) => siteCan(s, "search")); } /** diff --git a/apps/server/src/modules/sources-identity/capabilities-sites.ts b/apps/server/src/modules/sources-identity/capabilities-sites.ts index 03c95b25..dcb00194 100644 --- a/apps/server/src/modules/sources-identity/capabilities-sites.ts +++ b/apps/server/src/modules/sources-identity/capabilities-sites.ts @@ -3,6 +3,7 @@ import { capabilitySpec, type SiteAccountUserData, type SiteLastHealth } from "@ import type { SiteAccountRow, SiteRow } from "@umt/db"; import type { SourcesRuntime } from "./runtime.ts"; import { checkSiteHealth, overallAccountStatus, refreshSiteAccount, userDataHistory } from "./service/account.ts"; +import { capabilitiesOf, requireCapability } from "./site-kinds.ts"; // W10-pre2 站点能力:site.list / site.health_check / site.refresh_login / site.user_data,处理器调 W9-A 站点中心的 service。 // 脱敏:输出按白名单逐字段拼装——不含 secret 引用、Cookie、密码、登录名、UA 与站点 config。 @@ -62,6 +63,8 @@ export function registerSiteCapabilities(registry: CapabilityRegistry, rt: () => id: s.id, key: s.key, name: s.name, + kind: s.kind, + capabilities: capabilitiesOf(s), adapter: s.adapter, baseUrl: s.base_url, enabled: s.enabled, @@ -83,6 +86,7 @@ export function registerSiteCapabilities(registry: CapabilityRegistry, rt: () => registry.register(capabilitySpec("site.refresh_login"), async (input, ctx) => { const r = rt(); const s = await liveSite(r, input.siteId); + requireCapability(s, "login"); let q = r.ctx.db.selectFrom("site_account").select(["id"]).where("site_id", "=", s.id); q = input.accountId ? q.where("id", "=", input.accountId) diff --git a/apps/server/src/modules/sources-identity/routes/sources.ts b/apps/server/src/modules/sources-identity/routes/sources.ts index d95597bc..d6006d74 100644 --- a/apps/server/src/modules/sources-identity/routes/sources.ts +++ b/apps/server/src/modules/sources-identity/routes/sources.ts @@ -10,6 +10,7 @@ import { OkResult, ReleaseIdentityAssign, type SecretPurpose, + SITE_KINDS, Site, type SiteAccount, type SiteAdapter, @@ -25,7 +26,7 @@ import { SiteUserDataQuery, type SourceRelease, } from "@umt/contracts"; -import { sql } from "@umt/db"; +import { type FeedSourceRow, sql } from "@umt/db"; import { z } from "zod"; import { ApiException } from "../../../http/errors.ts"; import type { ModuleApp } from "../../types.ts"; @@ -44,9 +45,10 @@ import { setSiteCredentials, userDataHistory, } from "../service/account.ts"; -import { audit, closeDecisions } from "../service/common.ts"; +import { audit, closeDecisions, type Exec } from "../service/common.ts"; import { feedDefaultsForSite } from "../service/feeds.ts"; import { assignReleaseIdentity } from "../service/identity-admin.ts"; +import { ensureRssSite, requireCapability, siteToApi, uniqueSiteKey } from "../site-kinds.ts"; // 站点、站点账号、Feed、来源资源、观察的管理 API。凭证只写(存 secret,对外只给 secret_ref / 脱敏 URL)。 @@ -88,6 +90,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { items: listSiteDefinitions().map((d) => ({ key: d.id, name: d.umt.kind === "nexusphp" ? (d.umt.displayName ?? d.name) : d.umt.displayName, + kind: "pt" as const, adapter: adapterOf(d), baseUrl: d.domain, shippedEnabled: d.enabled !== false, @@ -113,7 +116,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { .limit(query.limit + 1) .execute(); const p = page(rows, off, query.limit); - const items = toApiList>(p.items); + const items = p.items.map((r) => siteToApi>(r)); if (query.include === "accounts" && p.items.length) { const accounts = await db() .selectFrom("site_account") @@ -133,37 +136,70 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { } return { items, nextCursor: p.nextCursor }; }); - route(app, D.sites.get, async ({ params }) => toApi(await getSite(params.id))); + route(app, D.sites.get, async ({ params }) => siteToApi(await getSite(params.id))); route(app, D.sites.create, async ({ body, actor }) => { - const def = listSiteDefinitions().find((d) => d.id === body.key); - if (def && adapterOf(def) !== body.adapter) { - throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(def)}`); - } - if (!def && body.adapter !== "generic_rss") { - throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`); + const def = body.key ? listSiteDefinitions().find((d) => d.id === body.key) : undefined; + // 类型缺省:注册表里有定义 = PT 站;否则按旧口径(generic_rss = 自定义 RSS 站点) + const kind = body.kind ?? (def ? "pt" : "rss_feed"); + if (!SITE_KINDS[kind].implemented) throw new ApiException("bad_request", `还不支持添加${SITE_KINDS[kind].label}`); + if (kind === "pt") { + if (!body.key) throw new ApiException("bad_request", "PT 站要从站点定义里选(缺 key)"); + if (def && adapterOf(def) !== body.adapter) { + throw new ApiException("bad_request", `站点 ${body.key} 的适配器类型应为 ${adapterOf(def)}`); + } + if (!def && body.adapter !== "generic_rss") { + throw new ApiException("bad_request", `注册表中没有站点 ${body.key};未适配站点只能以 generic_rss 登记`); + } + } else if (body.adapter !== "generic_rss") { + throw new ApiException("bad_request", `${SITE_KINDS[kind].label}只能以 generic_rss 登记`); } + const key = body.key ?? (await uniqueSiteKey(db(), body.rssUrl ?? body.baseUrl)); try { const row = await db() - .insertInto("site") - .values({ - key: body.key, - name: body.name, - adapter: body.adapter, - base_url: body.baseUrl, - enabled: body.enabled ?? true, - rate_limit: jsonb(body.rateLimit ?? {}), - config: jsonb(body.config ?? {}), - snapshot_enabled: body.snapshotEnabled ?? false, - snapshot_interval_sec: body.snapshotIntervalSec ?? null, - proxy: body.proxy ?? false, - search_default: body.searchDefault ?? true, - }) - .returningAll() - .executeTakeFirstOrThrow(); - await audit(db(), actor, "site.created", { type: "site", id: row.id }, { key: body.key }); - return toApi(row); + .transaction() + .execute(async (trx) => { + const row = await trx + .insertInto("site") + .values({ + key, + name: body.name, + kind, + adapter: body.adapter, + base_url: body.baseUrl, + enabled: body.enabled ?? true, + rate_limit: jsonb(body.rateLimit ?? {}), + config: jsonb(body.config ?? {}), + snapshot_enabled: kind === "pt" ? (body.snapshotEnabled ?? false) : false, + snapshot_interval_sec: body.snapshotIntervalSec ?? null, + proxy: body.proxy ?? false, + // 不能搜索的站点不进默认搜索集合 + search_default: kind === "pt" ? (body.searchDefault ?? true) : false, + }) + .returningAll() + .executeTakeFirstOrThrow(); + if (body.rssUrl) { + const inferred = await feedDefaultsForSite(trx, row, body.rssUrl, null); + const feed = await insertFeed(trx, { + siteId: row.id, + url: body.rssUrl, + name: inferred.name, + parserVersion: inferred.parserVersion, + pollIntervalSec: inferred.pollIntervalSec, + }); + await audit( + trx, + actor, + "feed_source.created", + { type: "feed_source", id: feed.id }, + { url: feed.url_display }, + ); + } + return row; + }); + await audit(db(), actor, "site.created", { type: "site", id: row.id }, { key, kind }); + return siteToApi(row); } catch (e) { - if ((e as { code?: string }).code === "23505") throw new ApiException("conflict", `站点 ${body.key} 已登记`); + if ((e as { code?: string }).code === "23505") throw new ApiException("conflict", `站点 ${key} 已登记`); throw e; } }); @@ -184,9 +220,9 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { await db().updateTable("site").set(patch).where("id", "=", params.id).execute(); await audit(db(), actor, "site.updated", { type: "site", id: params.id }, { fields: Object.keys(patch) }); } - return toApi(await setSiteEnabled(params.id, body.enabled, actor)); + return siteToApi(await setSiteEnabled(params.id, body.enabled, actor)); } - if (!Object.keys(patch).length) return toApi(await getSite(params.id)); + if (!Object.keys(patch).length) return siteToApi(await getSite(params.id)); const row = await db() .updateTable("site") .set(patch) @@ -194,7 +230,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { .returningAll() .executeTakeFirstOrThrow(); await audit(db(), actor, "site.updated", { type: "site", id: params.id }, { fields: Object.keys(patch) }); - return toApi(row); + return siteToApi(row); }); for (const enabled of [true, false]) { route( @@ -208,7 +244,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { params: IdParam, response: Site, }, - async ({ params, actor }) => toApi(await setSiteEnabled(params.id, enabled, actor)), + async ({ params, actor }) => siteToApi(await setSiteEnabled(params.id, enabled, actor)), ); } route( @@ -225,6 +261,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { }, async ({ params, body }) => { const s = await getSite(params.id); + requireCapability(s, "list"); if (!s.enabled) throw new ApiException("precondition_failed", "站点已关闭"); const r = await rt().ctx.engine.enqueue( "snapshotSite", @@ -349,6 +386,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { }, async ({ params, body, actor }) => { const s = await getSite(params.id); + requireCapability(s, "login"); let q = db().selectFrom("site_account").select(["id", "enabled", "auth_kind"]).where("site_id", "=", s.id); q = body.accountId ? q.where("id", "=", body.accountId) @@ -409,7 +447,11 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { body: SiteCredentialsPut, response: SiteCredentialsResult, }, - async ({ params, body, actor }) => setSiteCredentials(rt(), await getSite(params.id), body, actor), + async ({ params, body, actor }) => { + const s = await getSite(params.id); + requireCapability(s, "login"); + return setSiteCredentials(rt(), s, body, actor); + }, ); route( app, @@ -562,37 +604,33 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { }); route(app, D.feedSources.get, async ({ params }) => feedToApi(await getFeed(params.id))); route(app, D.feedSources.create, async ({ body, actor }) => { - const site = body.siteId ? await getSite(body.siteId) : null; - if (body.siteAccountId) await checkAccount(body.siteAccountId, body.siteId ?? null); + // RSS 属于站点(IA-2):没给站点时归入同主机的「自定义 RSS 源」站点,没有就建一个 + const site = body.siteId ? await getSite(body.siteId) : await ensureRssSite(db(), body.url); + requireCapability(site, "rss"); + if (body.siteAccountId) await checkAccount(body.siteAccountId, site.id); // 站点内添加:账号 / 解析器 / 名称按站点推断,域名不符只提示 - const inferred = site ? await feedDefaultsForSite(db(), site, body.url, body.siteAccountId ?? null) : null; - const name = body.name?.trim() || inferred?.name || new URL(body.url).hostname; - const ref = await rt().ctx.secrets.create({ purpose: "feed_url", plaintext: body.url, label: `feed:${name}` }); - const row = await db() - .insertInto("feed_source") - .values({ - site_id: body.siteId ?? null, - site_account_id: inferred?.siteAccountId ?? body.siteAccountId ?? null, - name, - ...(inferred ? { parser_version: inferred.parserVersion } : {}), - purpose: body.purpose ?? "generic", - kind: body.kind ?? "rss", - url_secret_ref: ref, - url_display: redactUrl(body.url), - enabled: body.enabled ?? true, - poll_interval_sec: body.pollIntervalSec ?? inferred?.pollIntervalSec ?? 900, - filter: jsonb(body.filter ?? {}), - }) - .returningAll() - .executeTakeFirstOrThrow(); + const inferred = await feedDefaultsForSite(db(), site, body.url, body.siteAccountId ?? null); + const row = await insertFeed(db(), { + siteId: site.id, + url: body.url, + name: body.name?.trim() || inferred.name, + siteAccountId: inferred.siteAccountId, + parserVersion: inferred.parserVersion, + purpose: body.purpose, + kind: body.kind, + enabled: body.enabled, + pollIntervalSec: body.pollIntervalSec ?? inferred.pollIntervalSec, + filter: body.filter, + }); await audit(db(), actor, "feed_source.created", { type: "feed_source", id: row.id }, { url: row.url_display }); - return inferred?.warnings.length ? { ...feedToApi(row), warnings: inferred.warnings } : feedToApi(row); + return inferred.warnings.length ? { ...feedToApi(row), warnings: inferred.warnings } : feedToApi(row); }); route(app, D.feedSources.update, async ({ params, body, actor }) => { const feed = await getFeed(params.id); const patch: Record = {}; if (body.siteId !== undefined) { - if (body.siteId) await getSite(body.siteId); + if (!body.siteId) throw new ApiException("bad_request", "RSS 必须属于一个站点"); + requireCapability(await getSite(body.siteId), "rss"); patch.site_id = body.siteId; } if (body.siteAccountId !== undefined) { @@ -831,6 +869,45 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) { if (!row) throw new ApiException("not_found", "站点不存在"); return row; } + async function insertFeed( + exec: Exec, + f: { + siteId: string; + url: string; + name: string; + pollIntervalSec: number; + siteAccountId?: string | null; + parserVersion?: string; + purpose?: FeedSourceRow["purpose"] | undefined; + kind?: FeedSourceRow["kind"] | undefined; + enabled?: boolean | undefined; + filter?: Record | undefined; + }, + ) { + // secret.name 唯一:同站多个 RSS 都叫「站点名 RSS」时不能撞名 + const ref = await rt().ctx.secrets.create({ + purpose: "feed_url", + plaintext: f.url, + label: `feed:${f.name}:${randomUUID().slice(0, 8)}`, + }); + return exec + .insertInto("feed_source") + .values({ + site_id: f.siteId, + site_account_id: f.siteAccountId ?? null, + name: f.name, + ...(f.parserVersion ? { parser_version: f.parserVersion } : {}), + purpose: f.purpose ?? "generic", + kind: f.kind ?? "rss", + url_secret_ref: ref, + url_display: redactUrl(f.url), + enabled: f.enabled ?? true, + poll_interval_sec: f.pollIntervalSec, + filter: jsonb(f.filter ?? {}), + }) + .returningAll() + .executeTakeFirstOrThrow(); + } async function getFeed(id: string) { const row = await db().selectFrom("feed_source").selectAll().where("id", "=", id).executeTakeFirst(); if (!row) throw new ApiException("not_found", "Feed 不存在"); diff --git a/apps/server/src/modules/sources-identity/scheduler.ts b/apps/server/src/modules/sources-identity/scheduler.ts index a7335501..58aa2991 100644 --- a/apps/server/src/modules/sources-identity/scheduler.ts +++ b/apps/server/src/modules/sources-identity/scheduler.ts @@ -3,6 +3,7 @@ import { getSiteDefinition } from "@umt/adapters"; import { sql } from "@umt/db"; import { type SourcesRuntime, siteAccessFor } from "./runtime.ts"; import { archiveStalePool } from "./service/identity-decisions.ts"; +import { RSS_KINDS, siteCan } from "./site-kinds.ts"; // worker 后台调度:按 Feed 轮询策略发 feed.due(outbox → ingestFeed)、按站点快照周期入队 snapshotSite、 // 按待识别池 next_review_at 入队复查。幂等键按时间槽生成,多 worker 并发也不会重复。 @@ -39,7 +40,7 @@ export async function tickFeeds(rt: SourcesRuntime): Promise { const feedsDue = await ctx.db.transaction().execute(async (trx) => { const due = await sql<{ id: string; poll_interval_sec: number }>` select f.id, f.poll_interval_sec from feed_source f left join site s on s.id = f.site_id - where f.enabled and (s.id is null or s.enabled) + where f.enabled and (s.id is null or (s.enabled and s.deleted_at is null and s.kind = any(${RSS_KINDS}))) and (f.next_fetch_at is null or f.next_fetch_at <= ${now}) and (f.backoff_until is null or f.backoff_until <= ${now}) and f.health_status <> 'circuit_open' @@ -72,13 +73,15 @@ export async function tickSnapshots(rt: SourcesRuntime): Promise { const defaults = await rt.siteDefaults(); const rows = await ctx.db .selectFrom("site") - .select(["id", "key", "snapshot_interval_sec", "last_snapshot_at"]) + .select(["id", "key", "kind", "adapter", "snapshot_interval_sec", "last_snapshot_at"]) .where("enabled", "=", true) .where("snapshot_enabled", "=", true) .where("deleted_at", "is", null) .execute(); const due: Array<{ id: string; interval: number }> = []; for (const r of rows) { + // 只给支持列表抓取的站点排快照(IA-2:按站点类型与定义的获取方式) + if (!siteCan(r, "list")) continue; const interval = r.snapshot_interval_sec ?? siteAccessFor(getSiteDefinition(r.key) ?? null, defaults).snapshotIntervalSec; if (!r.last_snapshot_at || r.last_snapshot_at.getTime() + interval * 1000 <= now.getTime()) { diff --git a/apps/server/src/modules/sources-identity/service/account.ts b/apps/server/src/modules/sources-identity/service/account.ts index 1d3f6497..228a1032 100644 --- a/apps/server/src/modules/sources-identity/service/account.ts +++ b/apps/server/src/modules/sources-identity/service/account.ts @@ -26,6 +26,7 @@ import { type SiteAccountRow, type SiteRow, sql } from "@umt/db"; import { ApiException } from "../../../http/errors.ts"; import { toApi } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; +import { capabilitiesOf, LOGIN_KINDS } from "../site-kinds.ts"; import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts"; import { cookieFromBrowser, writeBrowserCookie } from "./credentials.ts"; @@ -929,6 +930,7 @@ export async function tickSiteUserData(rt: SourcesRuntime, slot: string): Promis .select(["a.id"]) .where("s.enabled", "=", true) .where("s.deleted_at", "is", null) + .where("s.kind", "in", LOGIN_KINDS) .where("a.enabled", "=", true) .where("a.status", "=", "active") .where("a.auth_kind", "!=", "passkey") @@ -950,11 +952,12 @@ export async function tickSiteHealth(rt: SourcesRuntime): Promise { .selectAll() .where("enabled", "=", true) .where("deleted_at", "is", null) - .where("adapter", "!=", "generic_rss") .execute(); let n = 0; const now = rt.now().getTime(); for (const s of sites) { + // 没有站点适配器的(自定义 RSS 源、未适配站点)没有首页检测;只剩 RSS 的站点跳过 + if (!capabilitiesOf(s).some((c) => c !== "rss")) continue; // 站点访问默认的最短间隔内检测过(手动或定时)就跳过;留 2 分钟余量,免得和定时任务周期错开一轮 const { healthCheckIntervalSec } = await rt.siteAccess(s.key); if (s.last_health_at && now - s.last_health_at.getTime() < healthCheckIntervalSec * 1000 - 120_000) continue; diff --git a/apps/server/src/modules/sources-identity/service/feeds.ts b/apps/server/src/modules/sources-identity/service/feeds.ts index 35e09b05..971f7a03 100644 --- a/apps/server/src/modules/sources-identity/service/feeds.ts +++ b/apps/server/src/modules/sources-identity/service/feeds.ts @@ -13,6 +13,7 @@ import type { FeedHealth } from "@umt/contracts"; import { type FeedSourceRow, type SiteRow, sql } from "@umt/db"; import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; +import { siteCan } from "../site-kinds.ts"; import { clip, type Exec, openDecision } from "./common.ts"; // ingestFeed:RSS 抓取 → observation(feed+guid 唯一,摘要变化 = 更新)→ source_release(site+站点资源 ID 聚合)。 @@ -154,6 +155,7 @@ export async function ingestFeed( ? await db.selectFrom("site").selectAll().where("id", "=", feed.site_id).executeTakeFirst() : undefined; if (site && !site.enabled) return { status: "skipped", reason: "site_disabled", ...empty }; + if (site && !siteCan(site, "rss")) return { status: "skipped", reason: "unsupported", ...empty }; if (!feed.url_secret_ref) return { status: "skipped", reason: "no_url", ...empty }; const url = await secrets.resolve(feed.url_secret_ref); @@ -216,7 +218,8 @@ export async function ingestFeed( ? { detectedAt: now.toISOString(), previousFetchedAt: cursor.window?.lastFetchedAt ?? null, - backfillQueued: !!site?.id, + // 补漏靠列表抓取:站点支持 list 才会排补漏(自定义 RSS 源没有列表) + backfillQueued: !!site && siteCan(site, "list"), } : undefined : cursor.gap; diff --git a/apps/server/src/modules/sources-identity/service/snapshot.ts b/apps/server/src/modules/sources-identity/service/snapshot.ts index 91358cc7..63a65e29 100644 --- a/apps/server/src/modules/sources-identity/service/snapshot.ts +++ b/apps/server/src/modules/sources-identity/service/snapshot.ts @@ -6,6 +6,7 @@ import { parseRelease } from "@umt/domain"; import { siteReleaseToSourceRelease } from "@umt/mapping"; import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; +import { siteCan } from "../site-kinds.ts"; import { enqueueRelogin } from "./account.ts"; import { type Exec, openDecision } from "./common.ts"; import { hasDetailPage, saveDetailPage } from "./detail-page.ts"; @@ -134,6 +135,10 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr const site = await db.selectFrom("site").selectAll().where("id", "=", input.siteId).executeTakeFirst(); if (!site) return { status: "skipped", reason: "not_found", ...none }; if (!site.enabled) return { status: "skipped", reason: "site_disabled", ...none }; + // 按站点类型的获取方式(IA-2):实时搜索要 search,列表快照 / 补漏要 list + if (!siteCan(site, input.kind === "live_search" ? "search" : "list")) { + return { status: "skipped", reason: "unsupported", ...none }; + } if (site.health_status === "failing" && input.kind !== "manual" && input.kind !== "live_search") { const invalid = await db .selectFrom("site_account") diff --git a/apps/server/src/modules/sources-identity/site-kinds.ts b/apps/server/src/modules/sources-identity/site-kinds.ts new file mode 100644 index 00000000..f408cea2 --- /dev/null +++ b/apps/server/src/modules/sources-identity/site-kinds.ts @@ -0,0 +1,110 @@ +import { getSiteDefinition } from "@umt/adapters"; +import { SITE_KINDS, type SiteCapability, type SiteKind, siteCapabilities } from "@umt/contracts"; +import type { SiteRow } from "@umt/db"; +import { ApiException } from "../../http/errors.ts"; +import { toApi } from "./rows.ts"; +import type { Exec } from "./service/common.ts"; + +// IA-2 站点类型:服务端统一按 capabilities 判断能不能搜索 / 抓列表 / 抓 RSS / 看详情 / 登录 / 取账号数据。 +// 实际可用 = 类型允许的 ∩ 站点定义实现的(contracts siteCapabilities)。generic_rss 站点不查定义。 + +type SiteLike = Pick & { kind: SiteKind }; + +export function capabilitiesOf(site: SiteLike): SiteCapability[] { + const def = site.adapter === "generic_rss" ? null : getSiteDefinition(site.key); + return siteCapabilities(site.kind, def ? def.umt.capabilities : null); +} + +export function siteCan(site: SiteLike, cap: SiteCapability): boolean { + return capabilitiesOf(site).includes(cap); +} + +const CAP_TEXT: Record = { + search: "搜索", + list: "列表抓取", + rss: "RSS", + detail: "详情页", + login: "登录", + userinfo: "账号数据", +}; + +/** 站点不支持该获取方式时抛 400:「名称」是自定义 RSS 源,不支持登录 */ +export function requireCapability(site: SiteLike & Pick, cap: SiteCapability): void { + if (siteCan(site, cap)) return; + throw new ApiException("bad_request", `「${site.name}」是${SITE_KINDS[site.kind].label},不支持${CAP_TEXT[cap]}`); +} + +/** site 行 → Site(补派生字段 capabilities) */ +export function siteToApi(row: SiteLike & object): T { + return { ...toApi>(row), capabilities: capabilitiesOf(row) } as T; +} + +/** 有登录类能力的类型(健康检测、刷新登录、账号数据的定时任务按这个过滤) */ +export const LOGIN_KINDS: SiteKind[] = (Object.keys(SITE_KINDS) as SiteKind[]).filter( + (k) => SITE_KINDS[k].implemented && SITE_KINDS[k].capabilities.includes("login"), +); +/** 能抓 RSS 的类型(Feed 调度按这个过滤) */ +export const RSS_KINDS: SiteKind[] = (Object.keys(SITE_KINDS) as SiteKind[]).filter( + (k) => SITE_KINDS[k].implemented && SITE_KINDS[k].capabilities.includes("rss"), +); + +/** 主机名 → key 片段(与迁移 0055 一致):小写,非字母数字换成 -,去掉 www. */ +export function hostSlug(host: string): string { + const s = host + .toLowerCase() + .replace(/^www\./, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) + .replace(/-+$/, ""); + return s || "feed"; +} + +function hostOfUrl(url: string): string | null { + try { + return new URL(url).host || null; + } catch { + return null; + } +} + +/** 非 PT 站的 key:rss-<主机>,已占用(含已删除的站点)时加序号 */ +export async function uniqueSiteKey(db: Exec, url: string): Promise { + const base = `rss-${hostSlug(hostOfUrl(url) ?? "")}`; + const taken = new Set( + (await db.selectFrom("site").select("key").where("key", "like", `${base}%`).execute()).map((r) => r.key), + ); + let key = base; + for (let n = 2; taken.has(key); n++) key = `${base}-${n}`; + return key; +} + +/** + * 不带站点新建 RSS(旧 API 调用方):归入同主机的「自定义 RSS 源」站点,没有就建一个。 + * 与迁移 0055 同口径:名称 = 主机名,地址 = https://<主机>,不参与默认搜索。 + */ +export async function ensureRssSite(db: Exec, url: string): Promise { + const host = hostOfUrl(url); + if (!host) throw new ApiException("bad_request", "RSS 地址格式不对"); + const existing = await db + .selectFrom("site") + .selectAll() + .where("kind", "=", "rss_feed") + .where("deleted_at", "is", null) + .where("base_url", "=", `https://${host}`) + .orderBy("created_at") + .executeTakeFirst(); + if (existing) return existing; + return db + .insertInto("site") + .values({ + key: await uniqueSiteKey(db, url), + name: host, + kind: "rss_feed", + adapter: "generic_rss", + base_url: `https://${host}`, + search_default: false, + }) + .returningAll() + .executeTakeFirstOrThrow(); +} diff --git a/apps/server/src/modules/sources-identity/tasks.ts b/apps/server/src/modules/sources-identity/tasks.ts index 8e7babbb..bf813872 100644 --- a/apps/server/src/modules/sources-identity/tasks.ts +++ b/apps/server/src/modules/sources-identity/tasks.ts @@ -252,6 +252,7 @@ export const sourcesOutboxRoutes: Record = { "feed.gap_suspected": (ev) => { const p = ev.payload as P; if (!p.siteId) return null; + // 站点不支持列表抓取时 snapshotSite 自己跳过(reason=unsupported) return { task: "snapshotSite", input: { siteId: p.siteId, kind: "backfill", maxPages: BACKFILL_MAX_PAGES } }; }, }; diff --git a/apps/server/test/sources-identity/api.test.ts b/apps/server/test/sources-identity/api.test.ts index ddb7e262..715359df 100644 --- a/apps/server/test/sources-identity/api.test.ts +++ b/apps/server/test/sources-identity/api.test.ts @@ -137,13 +137,23 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => { expect(other.status).toBe(200); expect(other.body.data.name).toBe("促销"); expect(other.body.data.warnings?.[0]).toContain("elsewhere.example"); + // IA-2:RSS 属于站点。不带站点新建 → 归入同主机的「自定义 RSS 源」站点(没有就建一个) const bare = await json(await h.req("/feed-sources", { body: { url: "https://any.example/feed.xml" } })); - expect(bare.body.data).toMatchObject({ + expect(bare.body.data).toMatchObject({ name: "any.example RSS", siteAccountId: null, parserVersion: "rss@1" }); + const bareSite = await json(await h.req(`/sites/${bare.body.data.siteId}`)); + expect(bareSite.body.data).toMatchObject({ + key: "rss-any-example", name: "any.example", - siteId: null, - siteAccountId: null, - parserVersion: "1", + kind: "rss_feed", + adapter: "generic_rss", + capabilities: ["rss"], + searchDefault: false, }); + const again = await json(await h.req("/feed-sources", { body: { url: "https://any.example/other.xml" } })); + expect(again.body.data.siteId).toBe(bare.body.data.siteId); + // 不能把 RSS 移出站点 + const detach = await h.req(`/feed-sources/${bare.body.data.id}`, { method: "PATCH", body: { siteId: null } }); + expect(detach.status).toBe(400); }); it("快照:列表页 upsert 到 source_release,记录覆盖范围;站点级限流器每站一个实例", async () => { diff --git a/apps/server/test/sources-identity/migration-site-kind.test.ts b/apps/server/test/sources-identity/migration-site-kind.test.ts new file mode 100644 index 00000000..6e7b31e6 --- /dev/null +++ b/apps/server/test/sources-identity/migration-site-kind.test.ts @@ -0,0 +1,90 @@ +import { createMigrator, type DB, migrateToLatest, sql } from "@umt/db"; +import { Kysely, PostgresDialect } from "kysely"; +import pg from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { PG_URL, pgAvailable, withSchemaLock } from "../auth-settings/harness.ts"; + +// 0055:site.kind;generic_rss 站点 → rss_feed;不属于任何站点的 RSS 按主机归入「自定义 RSS 源」站点。 + +const hasPg = await pgAvailable(); + +describe.skipIf(!hasPg)("0055 站点类型迁移", () => { + const schema = `ia2_mig_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 6)}`; + const admin = async (q: string) => { + const c = new pg.Client({ connectionString: PG_URL }); + await c.connect(); + await c.query(q).finally(() => c.end()); + }; + let db: Kysely; + + afterAll(async () => { + await db?.destroy(); + await withSchemaLock(() => admin(`drop schema if exists ${schema} cascade`)); + }); + + it("存量站点定类型;孤立 RSS 按主机建站点、清游标窗口;回滚只删列", async () => { + await admin(`create schema ${schema}`); + db = new Kysely({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ connectionString: PG_URL, max: 2, options: `-c search_path=${schema},public` }), + }), + }); + await withSchemaLock(async () => { + const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0054_download_task_entity"); + if (r.error) throw r.error; + }); + await sql`insert into site (key, name, adapter, base_url) values + ('audiences', '观众', 'nexusphp', 'https://audiences.example/'), + ('myrss', '旧自定义', 'generic_rss', 'https://old.example/'), + ('rss-nyaa-si', '占用', 'generic_rss', 'https://taken.example/')`.execute(db); + const cursor = JSON.stringify({ window: { lastGuidSet: ["a"], lastFetchedAt: "2026-09-01T00:00:00Z" } }); + await sql`insert into feed_source (site_id, name, url_display, etag, cursor) values + (null, 'A', 'https://nyaa.si/?page=rss&q=frieren', 'W/"1"', ${cursor}::jsonb), + (null, 'B', 'https://nyaa.si/?page=rss&q=dune', null, ${cursor}::jsonb), + (null, 'C', 'https://www.example.org/feed?passkey=[REDACTED]', null, '{}'::jsonb), + ((select id from site where key = 'audiences'), 'D', 'https://audiences.example/rss', 'W/"2"', ${cursor}::jsonb)`.execute( + db, + ); + + await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema })); + + const sites = await sql<{ key: string; name: string; kind: string; base_url: string; search_default: boolean }>` + select key, name, kind, base_url, search_default from site order by key`.execute(db); + expect(sites.rows).toEqual([ + { key: "audiences", name: "观众", kind: "pt", base_url: "https://audiences.example/", search_default: true }, + { key: "myrss", name: "旧自定义", kind: "rss_feed", base_url: "https://old.example/", search_default: true }, + { + key: "rss-example-org", + name: "www.example.org", + kind: "rss_feed", + base_url: "https://www.example.org", + search_default: false, + }, + { key: "rss-nyaa-si", name: "占用", kind: "rss_feed", base_url: "https://taken.example/", search_default: true }, + { key: "rss-nyaa-si-2", name: "nyaa.si", kind: "rss_feed", base_url: "https://nyaa.si", search_default: false }, + ]); + const feeds = await sql<{ name: string; key: string; etag: string | null; cursor: Record }>` + select f.name, s.key, f.etag, f.cursor from feed_source f join site s on s.id = f.site_id order by f.name`.execute( + db, + ); + expect(feeds.rows.map((f) => [f.name, f.key])).toEqual([ + ["A", "rss-nyaa-si-2"], + ["B", "rss-nyaa-si-2"], + ["C", "rss-example-org"], + ["D", "audiences"], + ]); + // 迁移过来的 RSS 清掉窗口与条件请求头(下次抓取重新写成资源);原本属于站点的不动 + expect(feeds.rows[0]).toMatchObject({ etag: null, cursor: {} }); + expect(feeds.rows[3]!.etag).toBe('W/"2"'); + expect(feeds.rows[3]!.cursor).toHaveProperty("window"); + + await withSchemaLock(async () => { + const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0054_download_task_entity"); + if (r.error) throw r.error; + }); + const cols = await sql<{ n: number }>`select count(*)::int as n from information_schema.columns + where table_schema = ${schema} and table_name = 'site' and column_name = 'kind'`.execute(db); + expect(cols.rows[0]!.n).toBe(0); + await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema })); + }); +}); diff --git a/apps/server/test/sources-identity/site-kinds.test.ts b/apps/server/test/sources-identity/site-kinds.test.ts new file mode 100644 index 00000000..b5aee8c6 --- /dev/null +++ b/apps/server/test/sources-identity/site-kinds.test.ts @@ -0,0 +1,178 @@ +import { SITE_KINDS, siteCapabilities } from "@umt/contracts"; +import { createMockRssServer, type MockRssServer } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { tickFeeds, tickSnapshots } from "../../src/modules/sources-identity/scheduler.ts"; +import { ingestFeed } from "../../src/modules/sources-identity/service/feeds.ts"; +import { snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts"; +import { capabilitiesOf, hostSlug } from "../../src/modules/sources-identity/site-kinds.ts"; +import { createHarness, type Harness, json, pgAvailable, seedFeed, seedSite } from "./harness.ts"; + +// IA-2 站点类型:类型声明获取方式,实际可用 = 类型 ∩ 站点定义;调度 / 搜索 / 登录按 capabilities 判断。 + +describe("站点类型与获取方式(纯函数)", () => { + it("实际可用 = 类型允许的 ∩ 定义实现的;RSS 不依赖定义;torznab 未实现为空", () => { + const def = { search: true, list: true, detail: false }; + expect(siteCapabilities("pt", def)).toEqual(["search", "list", "rss", "login", "userinfo"]); + expect(siteCapabilities("pt", null)).toEqual(["rss"]); + expect(siteCapabilities("public_bt", def)).toEqual(["search", "list", "rss"]); + expect(siteCapabilities("public_bt", null)).toEqual(["rss"]); + expect(siteCapabilities("rss_feed", def)).toEqual(["rss"]); + expect(siteCapabilities("torznab", def)).toEqual([]); + expect(SITE_KINDS.torznab.implemented).toBe(false); + expect(SITE_KINDS.rss_feed.capabilities).toEqual(["rss"]); + }); + + it("generic_rss 站点不查定义;key 片段", () => { + expect(capabilitiesOf({ kind: "pt", key: "audiences", adapter: "generic_rss" })).toEqual(["rss"]); + expect(capabilitiesOf({ kind: "pt", key: "audiences", adapter: "nexusphp" })).toContain("login"); + expect(hostSlug("www.Nyaa.si:8443")).toBe("nyaa-si-8443"); + expect(hostSlug("")).toBe("feed"); + }); +}); + +const up = await pgAvailable(); + +describe.skipIf(!up)("站点类型:API、调度与 RSS", () => { + let h: Harness; + let rss: MockRssServer; + + beforeAll(async () => { + h = await createHarness("kinds"); + rss = await createMockRssServer({ + items: [ + { + guid: "nyaa-1", + title: "[SubsPlease] Frieren - 12 (1080p) [ABCDEF12].mkv", + link: "https://nyaa.example/view/1812345", + enclosureUrl: "https://nyaa.example/download/1812345.torrent", + size: 1_400_000_000, + pubDate: new Date("2026-09-20T08:00:00Z"), + categories: ["Anime"], + }, + ], + }); + }); + afterAll(async () => { + await rss?.close(); + await h?.close(); + }); + + it("添加自定义 RSS 源:一个事务建站点 + RSS,key 按主机生成,不参与默认搜索", async () => { + const r = await json( + await h.req("/sites", { + body: { + kind: "rss_feed", + adapter: "generic_rss", + name: "Nyaa", + baseUrl: "https://nyaa.example/", + rssUrl: rss.feedUrl, + }, + }), + ); + expect(r.status).toBe(200); + expect(r.body.data).toMatchObject({ + kind: "rss_feed", + capabilities: ["rss"], + searchDefault: false, + snapshotEnabled: false, + }); + expect(r.body.data.key).toMatch(/^rss-/); + const feeds = await json(await h.req(`/feed-sources?siteId=${r.body.data.id}`)); + expect(feeds.body.data.items).toHaveLength(1); + expect(feeds.body.data.items[0]).toMatchObject({ name: "Nyaa RSS", siteId: r.body.data.id }); + + // 抓取:自定义 RSS 源的条目按站点聚合成资源(以前无站点的 Feed 只留观察) + const res = await ingestFeed(h.rt, feeds.body.data.items[0].id); + expect(res).toMatchObject({ status: "ok", newReleases: 1 }); + const rel = await h.db.selectFrom("source_release").selectAll().where("site_id", "=", r.body.data.id).execute(); + expect(rel).toHaveLength(1); + expect(rel[0]!.title).toContain("Frieren"); + }); + + it("不支持的获取方式 400:自定义 RSS 源不能登录 / 填凭证 / 抓列表;torznab 不能添加;PT 站缺 key 400", async () => { + const list = await json(await h.req("/sites")); + const feedSite = list.body.data.items.find((s: { kind: string }) => s.kind === "rss_feed"); + for (const [path, body] of [ + [`/sites/${feedSite.id}/refresh-login`, {}], + [`/sites/${feedSite.id}/snapshot`, {}], + ] as const) { + const res = await json(await h.req(path, { body })); + expect(res.status, path).toBe(400); + expect(res.body.error.message).toContain("自定义 RSS 源"); + } + const cred = await h.req(`/sites/${feedSite.id}/credentials`, { + method: "PUT", + body: { kind: "cookie", cookie: "c=1" }, + }); + expect(cred.status).toBe(400); + const tz = await h.req("/sites", { + body: { kind: "torznab", adapter: "generic_rss", name: "Jackett", baseUrl: "https://jackett.example/" }, + }); + expect(tz.status).toBe(400); + const noKey = await h.req("/sites", { + body: { kind: "pt", adapter: "nexusphp", name: "x", baseUrl: "https://x.example/" }, + }); + expect(noKey.status).toBe(400); + const pubWrongAdapter = await h.req("/sites", { + body: { kind: "public_bt", adapter: "nexusphp", name: "x", key: "audiences", baseUrl: "https://x.example/" }, + }); + expect(pubWrongAdapter.status).toBe(400); + }); + + it("站点定义都是 PT 站;PT 站带登录与账号数据", async () => { + const defs = await json(await h.req("/site-definitions")); + expect(defs.body.data.items.every((d: { kind: string }) => d.kind === "pt")).toBe(true); + const pt = await json( + await h.req("/sites", { + body: { key: "audiences", name: "观众", adapter: "nexusphp", baseUrl: "https://audiences.invalid/" }, + }), + ); + expect(pt.status).toBe(200); + expect(pt.body.data.kind).toBe("pt"); + expect(pt.body.data.capabilities).toEqual(expect.arrayContaining(["search", "list", "rss", "login", "userinfo"])); + }); + + it("调度按获取方式:快照只排能抓列表的站点;Feed 只排能抓 RSS 的站点", async () => { + const pt = await seedSite(h, { key: "hdhome", baseUrl: "https://hdhome.invalid/" }); + // 自定义 RSS 源即使 snapshot_enabled 被写成 true 也不排快照 + const feedOnly = await h.db + .insertInto("site") + .values({ + key: "rss-only", + name: "only", + kind: "rss_feed", + adapter: "generic_rss", + base_url: "https://only.invalid/", + snapshot_enabled: true, + }) + .returningAll() + .executeTakeFirstOrThrow(); + const tz = await h.db + .insertInto("site") + .values({ key: "tz", name: "tz", kind: "torznab", adapter: "generic_rss", base_url: "https://tz.invalid/" }) + .returningAll() + .executeTakeFirstOrThrow(); + const tzFeed = await seedFeed(h, { siteId: tz.id, url: "https://tz.invalid/api?t=search" }); + const okFeed = await seedFeed(h, { siteId: feedOnly.id, url: "https://only.invalid/rss" }); + + h.engine.enqueued.length = 0; + await tickSnapshots(h.rt); + const snapSites = h.engine.enqueued + .filter((e) => e.task === "snapshotSite") + .map((e) => (e.input as { siteId: string }).siteId); + expect(snapSites).toContain(pt.site.id); + expect(snapSites).not.toContain(feedOnly.id); + + await tickFeeds(h.rt); + const due = (await h.outbox("feed.due")).map((e) => e.payload.feedId); + expect(due).toContain(okFeed.id); + expect(due).not.toContain(tzFeed.id); + expect((await ingestFeed(h.rt, tzFeed.id)).reason).toBe("unsupported"); + + // 实时搜索 / 列表快照在不支持的站点上直接跳过,不请求站点 + expect(await snapshotSite(h.rt, { siteId: feedOnly.id, kind: "live_search", query: "x" })).toMatchObject({ + status: "skipped", + reason: "unsupported", + }); + }); +}); diff --git a/apps/web/app/api/feeds.ts b/apps/web/app/api/feeds.ts index 30bc440a..9f8e78f7 100644 --- a/apps/web/app/api/feeds.ts +++ b/apps/web/app/api/feeds.ts @@ -6,6 +6,7 @@ import { type FeedSourceCreate, type FeedSourceUpdate, Id, + Observation, } from "@umt/contracts"; import { z } from "zod"; import { TTL_SECOND } from "~/lib/api.ts"; @@ -40,6 +41,18 @@ export function useFeed(id: string | null, poll = false) { }); } +const ObservationPage = cursorPage(Observation); + +/** RSS 最近条目(观察记录,新的在前);只在详情打开时请求 */ +export function useFeedObservations(feedId: string | null, limit = 20) { + return useQuery({ + queryKey: [...feedKeys.all, "observations", feedId, limit] as const, + enabled: !!feedId, + queryFn: ({ signal }) => + apiParsed(ObservationPage, "/api/v1/observations", { params: { feedId: feedId ?? "", limit }, signal }), + }); +} + /** 同站判定的主机键(与服务端 feedDefaultsForSite 一致):去 www.,取最后两级;IP 原样 */ export function feedHostKey(u: string): string | null { try { diff --git a/apps/web/app/api/sites.ts b/apps/web/app/api/sites.ts index 69a3b7f6..38e0aae5 100644 --- a/apps/web/app/api/sites.ts +++ b/apps/web/app/api/sites.ts @@ -151,7 +151,11 @@ export function useCreateSite() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: SiteCreate) => apiMutate("/api/v1/sites", { body }).then((d) => Site.parse(d)), - onSuccess: () => qc.invalidateQueries({ queryKey: siteKeys.all }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: siteKeys.all }); + // 非 PT 站添加时可能一并建了 RSS + void qc.invalidateQueries({ queryKey: ["feeds"] }); + }, }); } diff --git a/apps/web/app/components/agent/quick-goals.ts b/apps/web/app/components/agent/quick-goals.ts index 8f69af6f..a5436bc9 100644 --- a/apps/web/app/components/agent/quick-goals.ts +++ b/apps/web/app/components/agent/quick-goals.ts @@ -14,11 +14,11 @@ const RULES: Rule[] = [ }, { match: /^\/console\/sites\/[^/]+/, - goals: ["从这个站点找 ", "看看这个站点最近有哪些新的 4K 资源", "检查这个站点的登录和抓取是否正常"], + goals: ["从这个站点找 ", "看看这个站点最近有哪些新的 4K 资源", "检查这个站点的登录和 RSS 抓取是否正常"], }, { match: /^\/console\/sites\/?$/, - goals: ["在所有站点里找 ", "哪个站点最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"], + goals: ["在所有站点里找 ", "哪些站点或 RSS 最近抓取失败了,原因是什么", "对比各站点最近一周的新资源数量"], }, { match: /^\/console\/tasks\/[^/]+/, @@ -48,10 +48,6 @@ const RULES: Rule[] = [ match: /^\/console\/subscriptions/, goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的收录版本是否合理"], }, - { - match: /^\/console\/sources/, - goals: ["哪些来源不新鲜了,原因是什么", "检查所有 RSS 的抓取状态", "最近一天各来源抓到了什么"], - }, { match: /^\/console\/engine/, goals: ["这个任务为什么失败", "哪些工作流一直在重试", "汇总最近一小时失败的运行"], diff --git a/apps/web/app/components/console/nav.test.ts b/apps/web/app/components/console/nav.test.ts index 08e2d01a..ff47d139 100644 --- a/apps/web/app/components/console/nav.test.ts +++ b/apps/web/app/components/console/nav.test.ts @@ -1,7 +1,15 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { LEGACY_REDIRECTS, legacyRedirect } from "../../routes/console/redirects.ts"; -import { consolePath, findNavItem, NAV_GROUPS, NAV_ITEMS, pageTitle, settingsPath } from "./nav.ts"; +import { + ACQUIRE_GROUP_TITLE, + consolePath, + findNavItem, + NAV_GROUPS, + NAV_ITEMS, + pageTitle, + settingsPath, +} from "./nav.ts"; /** routes.ts 里登记的控制台路由(相对 /console) */ const ROUTE_PATTERNS = [ @@ -16,17 +24,17 @@ const matchesRoute = (pathname: string) => { }; describe("控制台导航", () => { - it("IA-3:5 组,按定稿清单逐项列出,路径唯一且都在 /console 下", () => { - expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "整理", "系统"]); + it("IA-2:4 组,任务归系统,站点 / 订阅 / 规则合为「获取」,没有独立 RSS 项", () => { + expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", ACQUIRE_GROUP_TITLE, "整理", "系统"]); + expect(ACQUIRE_GROUP_TITLE).toBe("获取"); expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([ - ["首页", "任务", "计划", "智能体会话"], - ["站点", "RSS"], - ["订阅", "规则"], + ["首页", "计划", "智能体会话"], + ["站点", "订阅", "规则"], ["下载面板", "整理记录", "文件管理", "媒体库"], - ["设置", "审计", "运行状态"], + ["任务", "设置", "审计", "运行状态"], ]); - // IA-3 逐项清单 14 项 + FE-6 计划页 - expect(NAV_ITEMS).toHaveLength(15); + expect(NAV_ITEMS).toHaveLength(14); + expect(NAV_ITEMS.map((i) => i.to)).not.toContain("/console/sources"); expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length); expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true); }); @@ -87,6 +95,7 @@ describe("旧链接重定向", () => { expect(legacyRedirect("/console/tasks", "?tab=failed")).toBeNull(); expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull(); expect(legacyRedirect("/console/subscriptions", "")).toBeNull(); - expect(legacyRedirect("/console/sources", "?health=failing")).toBeNull(); + // 旧 RSS 页的 ?feed= 由 sources.tsx 查所属站点后跳转 + expect(legacyRedirect("/console/sources", "?feed=f1")).toBeNull(); }); }); diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 5405f755..532fb2ea 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -12,7 +12,6 @@ import { ListChecks, ListTodo, type LucideIcon, - Rss, ScrollText, Settings, } from "lucide-react"; @@ -35,7 +34,13 @@ export function consolePath(...segments: (string | number)[]): string { } /** - * 侧栏 5 组 15 项(IA-3 定稿清单 14 项 + FE-6 从任务页拆出的"计划",docs/plan/00-first-version-plan.md §8):概览 → 资源 → 订阅 → 整理 → 系统。 + * 「获取」组的组名(IA-2):站点、订阅、规则合成一组。用户原话"都算做规则",组名可能再改,只改这一处。 + */ +export const ACQUIRE_GROUP_TITLE = "获取"; + +/** + * 侧栏 4 组 14 项(IA-2,docs/plan/00-first-version-plan.md 末尾 IA-2 条目):概览 → 获取 → 整理 → 系统。 + * 任务归系统;RSS 属于站点(站点详情的 RSS Tab),不再单独占一项。 * 待处理不进侧栏,由顶栏收件箱打开;移动端抽屉用同一份表。 */ export const NAV_GROUPS: NavGroup[] = [ @@ -48,12 +53,6 @@ export const NAV_GROUPS: NavGroup[] = [ icon: House, description: "在忙什么、要我处理什么、最近入库了什么", }, - { - to: "/console/tasks", - label: "任务", - icon: ListTodo, - description: "最近处理了什么、有没有失败", - }, { to: "/console/schedules", label: "计划", @@ -69,20 +68,14 @@ export const NAV_GROUPS: NavGroup[] = [ ], }, { - title: "资源", + title: ACQUIRE_GROUP_TITLE, items: [ { to: "/console/sites", label: "站点", icon: Globe, - description: "站点账号、资源与 RSS", + description: "PT 站、公开 BT 站与 RSS 源:账号、资源与 RSS", }, - { to: "/console/sources", label: "RSS", icon: Rss, description: "全部 RSS 与抓取状态" }, - ], - }, - { - title: "订阅", - items: [ { to: "/console/subscriptions", label: "订阅", icon: CalendarClock, description: "单次收录与持续追踪" }, { to: "/console/rules", label: "规则", icon: ListChecks, description: "收录策略与偏好标签" }, ], @@ -114,6 +107,12 @@ export const NAV_GROUPS: NavGroup[] = [ { title: "系统", items: [ + { + to: "/console/tasks", + label: "任务", + icon: ListTodo, + description: "最近处理了什么、有没有失败", + }, { to: "/console/settings", label: "设置", diff --git a/apps/web/app/components/downloads/business-tasks.tsx b/apps/web/app/components/downloads/business-tasks.tsx index cc2d2e99..04a001fe 100644 --- a/apps/web/app/components/downloads/business-tasks.tsx +++ b/apps/web/app/components/downloads/business-tasks.tsx @@ -81,7 +81,8 @@ export function subjectLink(t: Pick): string case "site": return consolePath("sites", t.subjectId); case "feed_source": - return consolePath("sources"); + // RSS 属于站点(IA-2):按 ?feed= 查所属站点后跳到站点的 RSS Tab + return `${consolePath("sources")}?feed=${encodeURIComponent(t.subjectId)}`; default: return null; } diff --git a/apps/web/app/components/search/site-picker-dialog.tsx b/apps/web/app/components/search/site-picker-dialog.tsx index 2ff10283..eceb2327 100644 --- a/apps/web/app/components/search/site-picker-dialog.tsx +++ b/apps/web/app/components/search/site-picker-dialog.tsx @@ -75,7 +75,10 @@ export function SitePickerDialog({ }) { const sitesQ = useSiteOptions(); const settings = useSettings(); - const sites = useMemo(() => (sitesQ.data?.items ?? []).filter((s) => s.enabled), [sitesQ.data]); + const sites = useMemo( + () => (sitesQ.data?.items ?? []).filter((s) => s.enabled && s.capabilities.includes("search")), + [sitesQ.data], + ); const [pick, setPick] = useState(null); // 打开时取初始值:本机上次 > 全局设置 > 默认 useEffect(() => { diff --git a/apps/web/app/components/sites/search-default-dialog.tsx b/apps/web/app/components/sites/search-default-dialog.tsx index 4bb4a238..02c83a28 100644 --- a/apps/web/app/components/sites/search-default-dialog.tsx +++ b/apps/web/app/components/sites/search-default-dialog.tsx @@ -42,7 +42,7 @@ export function SearchDefaultDialog({ setMode(settings.data.search?.defaultSiteMode ?? "default"); setIds(settings.data.search?.siteIds ?? []); }, [open, settings.data]); - const enabled = sites.filter((s) => s.enabled); + const enabled = sites.filter((s) => s.enabled && s.capabilities.includes("search")); const save = () => update.mutate( diff --git a/apps/web/app/components/sites/site-add-dialog.tsx b/apps/web/app/components/sites/site-add-dialog.tsx index a790a49e..8e49a3bf 100644 --- a/apps/web/app/components/sites/site-add-dialog.tsx +++ b/apps/web/app/components/sites/site-add-dialog.tsx @@ -1,8 +1,19 @@ -import type { SiteDefinitionView, SiteHealthResult, SiteListItem, SiteLoginResult } from "@umt/contracts"; -import { ArrowLeft, Check, LoaderCircle, Search } from "lucide-react"; +import { + cursorPage, + FeedSource, + SITE_KINDS, + type SiteDefinitionView, + type SiteHealthResult, + type SiteKind, + type SiteListItem, + type SiteLoginResult, +} from "@umt/contracts"; +import { ArrowLeft, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react"; import { useMemo, useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; +import { useFetchFeedNow } from "~/api/feeds.ts"; +import { apiParsed } from "~/api/parse.ts"; import { useCreateSite, useHealthCheck, @@ -31,12 +42,26 @@ import { type CredentialDraft, CredentialFields, credentialBody, emptyCredential import { LoginResultView } from "./site-dialogs.tsx"; import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fields.ts"; -// 添加站点向导(site-center §4.3):① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康) +// 添加站点向导(site-center §4.3 + IA-2 站点类型): +// ⓪ 选类型 → PT 站:① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康); +// 公开 BT 站 / 自定义 RSS 源:① 名称 / 地址 / RSS 地址 → 保存后立即抓一次,进站点的 RSS Tab。 +// 旧的"未适配站点(自定义 RSS 站点)"入口并入「自定义 RSS 源」类型。 -const CUSTOM = "__custom_rss"; -type Step = 1 | 2 | 3; -const STEP_LABEL = ["选择站点", "填写凭证", "测试"] as const; +type Step = 0 | 1 | 2 | 3; +const PT_STEPS = ["选择类型", "选择站点", "填写凭证", "测试"] as const; +const FEED_STEPS = ["选择类型", "填写地址"] as const; const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const; +const FeedPage = cursorPage(FeedSource); +const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "rss_feed", "torznab"]; +const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server } as const; + +function isHttpUrl(v: string): boolean { + try { + return /^https?:$/.test(new URL(v.trim()).protocol); + } catch { + return false; + } +} /** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */ function defaultKind(kinds: LoginKind[]): LoginKind { @@ -44,10 +69,13 @@ function defaultKind(kinds: LoginKind[]): LoginKind { return kinds.includes("cookie") ? "cookie" : (kinds[0] ?? "cookie"); } -export function SiteAddDialog({ onClose }: { onClose: () => void }) { +export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; initialKind?: SiteKind | null }) { const defs = useSiteDefinitions(); const navigate = useNavigate(); - const [step, setStep] = useState(1); + const startKind = initialKind && SITE_KINDS[initialKind]?.implemented ? initialKind : null; + const [kind, setKind] = useState(startKind); + const [step, setStep] = useState(startKind ? 1 : 0); + const [rssUrl, setRssUrl] = useState(""); const [q, setQ] = useState(""); const [defKey, setDefKey] = useState(null); const [form, setForm] = useState({ name: "", baseUrl: "", key: "" }); @@ -63,11 +91,55 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { const put = usePutCredentials(); const refresh = useRefreshLogin(); const check = useHealthCheck(); + const fetchFeed = useFetchFeedNow(); + const feedKind = kind !== null && kind !== "pt"; + const stepLabels: readonly string[] = feedKind ? FEED_STEPS : PT_STEPS; + + const chooseKind = (k: SiteKind) => { + setKind(k); + setErrors({}); + if (k !== "pt") setForm({ name: "", baseUrl: "", key: "" }); + setStep(1); + }; + + /** 公开 BT 站 / 自定义 RSS 源:建站点 + RSS(服务端一个事务),立即抓一次,进 RSS Tab */ + const saveFeedSite = async () => { + const fe: Record = {}; + const url = rssUrl.trim(); + const base = form.baseUrl.trim(); + if (!form.name.trim()) fe.name = "填写名称"; + if (kind === "rss_feed" && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)"; + if (kind === "public_bt" && url && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)"; + if (kind === "public_bt" && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org"; + if (kind === "rss_feed" && base && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org"; + setErrors(fe); + if (Object.keys(fe).length || !kind) return; + try { + setPhase("保存中…"); + const site = await create.mutateAsync({ + kind, + adapter: "generic_rss", + name: form.name.trim(), + baseUrl: base || new URL(url).origin, + ...(url ? { rssUrl: url } : {}), + }); + if (url) { + const feeds = await apiParsed(FeedPage, "/api/v1/feed-sources", { params: { siteId: site.id } }); + for (const f of feeds.items) fetchFeed.mutate(f.id); + } + toast.success(`已添加「${site.name}」`); + onClose(); + navigate(`${consolePath("sites", site.id)}?tab=rss`); + } catch (err) { + toast.error(errorMessage(err, "保存失败")); + } finally { + setPhase(null); + } + }; const items = defs.data?.items ?? []; const def = items.find((d) => d.key === defKey); - const custom = defKey === CUSTOM; - const kinds: LoginKind[] = custom ? [] : supportedKinds(def); + const kinds: LoginKind[] = supportedKinds(def); const busy = phase !== null; const groups = useMemo(() => { @@ -88,6 +160,93 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { setErrors({}); setStep(2); }; + const kindBody = ( +
    + {KIND_ORDER.map((k) => { + const spec = SITE_KINDS[k]; + const Icon = KIND_ICON[k]; + return ( +
  • + +
  • + ); + })} +
+ ); + const feedBody = ( +
{ + e.preventDefault(); + void saveFeedSite(); + }} + className="min-w-0" + > + + setForm((f) => ({ ...f, name: e.target.value }))} + disabled={busy} + /> + + + setRssUrl(e.target.value)} + disabled={busy} + /> + + + setForm((f) => ({ ...f, baseUrl: e.target.value }))} + disabled={busy} + /> + +

不需要账号。保存后立即抓一次 RSS。

+
+ ); const validate = () => { const fe: Record = {}; @@ -98,11 +257,10 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { } catch { fe.baseUrl = "填完整地址,如 https://example.org"; } - if (custom && !/^[a-z][a-z0-9_-]*$/.test(form.key.trim())) fe.key = "小写字母开头,只含小写字母、数字、- 和 _"; let body = null; if (kinds.length) { // 账号标签用站点标识:服务端 secret 名按标签生成且全局唯一,多站都叫 default 会冲突 - const b = credentialBody(cred, (custom ? form.key.trim() : def?.key) || "default"); + const b = credentialBody(cred, def?.key || "default"); if (b.errors) Object.assign(fe, b.errors); body = b.body; } @@ -122,9 +280,10 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { const base = { name: form.name.trim(), baseUrl: form.baseUrl.trim() }; if (!site) { site = await create.mutateAsync({ - key: custom ? form.key.trim() : (def?.key ?? form.key), - adapter: custom ? "generic_rss" : (def?.adapter ?? "nexusphp"), - snapshotEnabled: !custom, + kind: "pt", + key: def?.key ?? form.key, + adapter: def?.adapter ?? "nexusphp", + snapshotEnabled: true, ...base, }); setCreated(site); @@ -161,25 +320,25 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { 添加站点
    - {STEP_LABEL.map((l, i) => ( + {stepLabels.map((l, i) => (
  1. i + 1 + step > i ? "border-foreground bg-foreground text-background" - : step === i + 1 + : step === i ? "border-foreground" : "border-border", )} > - {step > i + 1 ? : i + 1} + {step > i ? : i + 1} {l}
  2. @@ -188,7 +347,11 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { - {step === 1 ? ( + {step === 0 ? ( + kindBody + ) : feedKind ? ( + feedBody + ) : step === 1 ? (
    @@ -251,14 +414,14 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { ) : null}

    - 未适配站点 + 没有这个站点

    @@ -288,13 +451,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { disabled={busy} /> - + void }) { disabled={busy} /> - {custom ? ( - - setForm((f) => ({ ...f, key: e.target.value }))} - disabled={busy} - /> - - ) : null}
    {kinds.length ? ( <> @@ -335,7 +480,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { ) : (

    - 不需要登录凭证。添加后在站点详情的"抓取与 RSS"里新建 RSS。 + 不需要登录凭证。添加后在站点详情的「RSS」里新建 RSS。

    )} @@ -362,7 +507,22 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) { )} - {step === 2 ? ( + {step === 0 ? ( + + ) : feedKind ? ( + <> + + + + ) : step === 2 ? ( <> ) : ( - )} diff --git a/apps/web/app/components/sites/site-card.tsx b/apps/web/app/components/sites/site-card.tsx index 2db342b7..efd35c8a 100644 --- a/apps/web/app/components/sites/site-card.tsx +++ b/apps/web/app/components/sites/site-card.tsx @@ -14,6 +14,7 @@ import { import { type ReactNode, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; +import { useFeeds } from "~/api/feeds.ts"; import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.ts"; import { DataTable } from "~/components/console/data-table.tsx"; import { consolePath } from "~/components/console/nav.ts"; @@ -29,13 +30,16 @@ import { ACCOUNT_OVERALL, bytes, dataAccount, + hasLogin, healthLine, healthMessage, hostOf, + kindLabel, loginFailureText, overallStatus, primaryAccount, ratioText, + siteCan, } from "./site-fields.ts"; type Checks = ReturnType; @@ -135,18 +139,22 @@ export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog: - {item(, updateCredLabel(site), () => onDialog({ kind: "credentials", site }))} + {hasLogin(site) + ? item(, updateCredLabel(site), () => onDialog({ kind: "credentials", site })) + : null} {item(, "编辑站点", () => onDialog({ kind: "edit", site }))} - {item( - , - "抓取最新资源", - () => - snap.mutate( - { id: site.id }, - { onSuccess: () => toast.success("已开始抓取"), onError: (e) => toast.error(errorMessage(e)) }, - ), - { disabled: !site.enabled }, - )} + {siteCan(site, "list") + ? item( + , + "抓取最新资源", + () => + snap.mutate( + { id: site.id }, + { onSuccess: () => toast.success("已开始抓取"), onError: (e) => toast.error(errorMessage(e)) }, + ), + { disabled: !site.enabled }, + ) + : null} {item(, site.enabled ? "停用站点" : "启用站点", () => toggle.mutate( { id: site.id, enabled: !site.enabled }, @@ -194,6 +202,7 @@ function CheckButtons({ site, checks, compact }: Omit & { com } function AccountBadges({ site }: { site: SiteListItem }) { + if (!hasLogin(site)) return null; const st = ACCOUNT_OVERALL[overallStatus(site)]; // 正常不标;登录方式不做徽章(DEAI-2) return st.tone === "success" ? null : 账号{st.label}; @@ -208,7 +217,73 @@ function Stat({ label, children, className }: { label: string; children: ReactNo ); } +/** 没有登录的站点(公开 BT、自定义 RSS 源):卡片只看 RSS */ +function useSiteFeeds(siteId: string) { + // 列表页所有卡片共用一次 /feed-sources 请求 + const feeds = useFeeds(); + const mine = (feeds.data?.items ?? []).filter((f) => f.siteId === siteId); + const enabled = mine.filter((f) => f.enabled); + const failing = enabled.filter((f) => f.healthStatus === "failing" || f.healthStatus === "circuit_open").length; + const lastOk = mine + .map((f) => f.lastSuccessAt) + .filter((x): x is string => !!x) + .sort() + .at(-1); + return { loaded: !!feeds.data, count: mine.length, enabled: enabled.length, failing, lastOk }; +} + +function FeedSiteCard({ site, onDialog }: Omit) { + const f = useSiteFeeds(site.id); + return ( +
    +
    +
    + + {site.name} + +

    {hostOf(site.baseUrl)}

    +
    + +
    +
    + {kindLabel(site.kind)} + {f.failing ? {f.failing} 个 RSS 抓取失败 : null} +
    +
    +
    +
    RSS
    +
    + {!f.loaded + ? "—" + : f.count + ? `${f.count} 个${f.enabled !== f.count ? `,${f.enabled} 个启用` : ""}` + : "还没有"} +
    +
    +
    +
    上次成功
    +
    {relTime(f.lastOk)}
    +
    +
    +
    + + +
    +
    + ); +} + export function SiteCard({ site, checks, onDialog }: Props) { + if (!hasLogin(site)) return ; + return ; +} + +function AccountSiteCard({ site, checks, onDialog }: Props) { const acc = primaryAccount(site.accounts); const dAcc = dataAccount(site.accounts); const u = dAcc?.userData ?? null; @@ -242,6 +317,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {
    + {site.kind && site.kind !== "pt" ? {kindLabel(site.kind)} : null} {u?.vip ? VIP : null} {circuit ? {circuit} : null} @@ -311,7 +387,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {
    - + {siteCan(site, "search") ? : } {acc ? ( ) : ( @@ -371,6 +447,12 @@ export function SiteTable({
    ), }, + { + key: "kind", + header: "类型", + hideBelow: "sm", + cell: (s) => {kindLabel(s.kind)}, + }, { key: "account", header: "账号", @@ -425,6 +507,7 @@ export function SiteTable({ header: "状态", hideBelow: "md", cell: (s) => { + if (!hasLogin(s)) return —; const h = healthLine(s.lastHealth, s.lastHealthAt); return ( @@ -440,7 +523,13 @@ export function SiteTable({ hideBelow: "lg", cell: (s) => ( - {!s.snapshotEnabled ? "未开启" : s.lastSnapshotAt ? relTime(s.lastSnapshotAt) : "还没有"} + {!siteCan(s, "list") + ? "只有 RSS" + : !s.snapshotEnabled + ? "未开启" + : s.lastSnapshotAt + ? relTime(s.lastSnapshotAt) + : "还没有"} ), }, @@ -451,7 +540,7 @@ export function SiteTable({ cell: (s) => (
    - {primaryAccount(s.accounts) ? : null} + {hasLogin(s) && primaryAccount(s.accounts) ? : null}
    ), diff --git a/apps/web/app/components/sites/site-feed-dialog.tsx b/apps/web/app/components/sites/site-feed-dialog.tsx index 3b2df6bd..79f2b7be 100644 --- a/apps/web/app/components/sites/site-feed-dialog.tsx +++ b/apps/web/app/components/sites/site-feed-dialog.tsx @@ -137,8 +137,10 @@ export function SiteFeedDialog({ 保存后显示为 {shown} - ) : ( + ) : site.kind === "pt" ? ( "站点「控制面板 → RSS」里生成的链接" + ) : ( + "RSS 或 Atom 地址" ) } > @@ -149,11 +151,13 @@ export function SiteFeedDialog({ spellCheck={false} value={url} aria-invalid={!!error} - placeholder={`${site.baseUrl.replace(/\/$/, "")}/torrentrss.php?passkey=…`} + placeholder={ + site.kind === "pt" ? `${site.baseUrl.replace(/\/$/, "")}/torrentrss.php?passkey=…` : "https://" + } onChange={(e) => setUrl(e.target.value)} /> - {mismatch ? ( + {mismatch && site.kind !== "rss_feed" ? (

    地址域名与 {site.name}({new URL(site.baseUrl).hostname})不一致,仍可保存;确认没有填错站点。

    diff --git a/apps/web/app/components/sites/site-fields.ts b/apps/web/app/components/sites/site-fields.ts index 6c5ac83a..19a2241e 100644 --- a/apps/web/app/components/sites/site-fields.ts +++ b/apps/web/app/components/sites/site-fields.ts @@ -1,13 +1,16 @@ -import type { - SiteAccount, - SiteAccountOverall, - SiteAdapter, - SiteAuthKind, - SiteDefinitionView, - SiteLastHealth, - SiteListItem, - SiteLoginFailureReason, - SiteRateLimit, +import { + SITE_KINDS, + type SiteAccount, + type SiteAccountOverall, + type SiteAdapter, + type SiteAuthKind, + type SiteCapability, + type SiteDefinitionView, + type SiteKind, + type SiteLastHealth, + type SiteListItem, + type SiteLoginFailureReason, + type SiteRateLimit, } from "@umt/contracts"; import type { StatusTone } from "~/components/console/status-badge.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; @@ -22,6 +25,21 @@ export const ADAPTER_LABEL: Record = { generic_rss: "通用 RSS", }; +/** 站点类型名(IA-2);旧数据缺 kind 时按 PT 站 */ +export function kindLabel(kind: SiteKind | undefined): string { + return SITE_KINDS[kind ?? "pt"].label; +} + +/** 站点支持某种获取方式(服务端按类型 ∩ 站点定义算好的 capabilities) */ +export function siteCan(site: Pick, cap: SiteCapability): boolean { + return (site.capabilities ?? []).includes(cap); +} + +/** 有登录与账号(凭证、账号数据、刷新登录、检测健康)的站点 */ +export function hasLogin(site: Pick): boolean { + return siteCan(site, "login"); +} + export const ACCOUNT_OVERALL: Record = { active: { label: "正常", tone: "success" }, expired: { label: "已失效", tone: "destructive" }, @@ -80,8 +98,9 @@ export function overallStatus(site: SiteListItem): SiteAccountOverall { return acc?.enabled ? acc.status : "unconfigured"; } -/** 需处理:账号非正常(已配置)或健康故障 */ +/** 需处理:账号非正常(已配置)或健康故障;没有登录的站点只看健康 */ export function needsAttention(site: SiteListItem): boolean { + if (!hasLogin(site)) return site.enabled && site.healthStatus === "failing"; const st = overallStatus(site); return ( site.enabled && (st === "expired" || st === "invalid" || st === "challenge" || site.healthStatus === "failing") diff --git a/apps/web/app/components/sites/site-overview.tsx b/apps/web/app/components/sites/site-overview.tsx index 015b3481..f21a120c 100644 --- a/apps/web/app/components/sites/site-overview.tsx +++ b/apps/web/app/components/sites/site-overview.tsx @@ -1,4 +1,5 @@ import type { SiteListItem } from "@umt/contracts"; +import { useFeeds } from "~/api/feeds.ts"; import { accessSummary, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts"; import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; @@ -11,17 +12,77 @@ import { AUTH_KIND_LABEL, bytes, dataAccount, + hasLogin, healthLine, healthMessage, + kindLabel, overallStatus, primaryAccount, rateLimitText, ratioText, + siteCan, } from "./site-fields.ts"; import { UserDataChart } from "./user-data-chart.tsx"; -/** 站点详情"概览":账号数据磁贴 + 30 天趋势 + 状态 */ +const CAP_LABEL = [ + ["search", "搜索"], + ["list", "列表抓取"], + ["rss", "RSS"], + ["detail", "详情页"], + ["login", "登录"], + ["userinfo", "账号数据"], +] as const; + +/** 可用的获取方式:"搜索 · 列表抓取 · RSS" */ +export function capabilityText(site: Pick): string { + const on = CAP_LABEL.filter(([c]) => siteCan(site, c)).map(([, l]) => l); + return on.length ? on.join(" · ") : "—"; +} + +/** 站点详情"概览":账号数据磁贴 + 30 天趋势 + 状态;没有登录的站点(公开 BT、自定义 RSS 源)只看 RSS */ export function SiteOverview({ site }: { site: SiteListItem }) { + return hasLogin(site) ? : ; +} + +function FeedSiteOverview({ site }: { site: SiteListItem }) { + const feeds = useFeeds({ siteId: site.id }); + const stats = useSiteRequestStats(site.id); + const items = feeds.data?.items ?? []; + const enabled = items.filter((f) => f.enabled); + const failing = enabled.filter((f) => f.healthStatus === "failing" || f.healthStatus === "circuit_open").length; + const lastOk = items + .map((f) => f.lastSuccessAt) + .filter((x): x is string => !!x) + .sort() + .at(-1); + return ( +
    +
    + + + + +
    + + + +
    + ); +} + +function AccountSiteOverview({ site }: { site: SiteListItem }) { const dAcc = dataAccount(site.accounts); const acc = primaryAccount(site.accounts); const u = dAcc?.userData ?? null; @@ -117,6 +178,8 @@ export function SiteOverview({ site }: { site: SiteListItem }) { ), }, { label: "代理", value: site.proxy ? "使用" : "不使用" }, + ...(site.kind && site.kind !== "pt" ? [{ label: "类型", value: kindLabel(site.kind) }] : []), + { label: "获取方式", value: capabilityText(site) }, ]} /> diff --git a/apps/web/app/components/tasks/labels.ts b/apps/web/app/components/tasks/labels.ts index 2e2cbcb9..1ad84733 100644 --- a/apps/web/app/components/tasks/labels.ts +++ b/apps/web/app/components/tasks/labels.ts @@ -119,7 +119,8 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s case "schedule": return { to: `/console/schedules?job=${id}`, label: "定时任务" }; case "feed": - return { to: consolePath("sources"), label: "RSS" }; + // RSS 属于站点(IA-2):旧 RSS 页按 ?feed= 查所属站点后跳到站点的 RSS Tab + return { to: `${consolePath("sources")}?feed=${encodeURIComponent(id)}`, label: "RSS" }; case "site": return { to: `/console/sites/${id}`, label: "站点" }; case "decision": diff --git a/apps/web/app/components/workbench/feed-form.tsx b/apps/web/app/components/workbench/feed-form.tsx index 3950d35b..2742b258 100644 --- a/apps/web/app/components/workbench/feed-form.tsx +++ b/apps/web/app/components/workbench/feed-form.tsx @@ -111,7 +111,8 @@ export function FeedFormDialog({ const raw = { ...(d.name.trim() ? { name: d.name.trim() } : {}), purpose: d.purpose, - siteId: d.siteId || null, + // RSS 属于站点(IA-2):不能改成"不关联";没选站点时由服务端归入同主机的自定义 RSS 源 + ...(d.siteId ? { siteId: d.siteId } : {}), siteAccountId: d.siteAccountId || null, pollIntervalSec: Math.round(Number(d.pollMin) * 60), enabled: d.enabled, @@ -242,12 +243,14 @@ export function FeedFormDialog({ - 不关联站点 - {sites.map((s) => ( - - {s.name} - - ))} + {!d.siteId ? 按地址归入自定义 RSS 源 : null} + {sites + .filter((s) => s.capabilities.includes("rss")) + .map((s) => ( + + {s.name} + + ))} diff --git a/apps/web/app/components/workbench/feed-table.tsx b/apps/web/app/components/workbench/feed-table.tsx index 0d505228..47181581 100644 --- a/apps/web/app/components/workbench/feed-table.tsx +++ b/apps/web/app/components/workbench/feed-table.tsx @@ -1,16 +1,18 @@ import { useQueryClient } from "@tanstack/react-query"; -import { EventType, type FeedHealth, type FeedSource } from "@umt/contracts"; +import { EventType, type FeedHealth, type FeedSource, type Observation } from "@umt/contracts"; import { RotateCcw, Rss, Zap } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { feedGapHint, feedKeys, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts"; +import { feedGapHint, feedKeys, useFeedAction, useFeedObservations, useFeeds, useFetchFeedNow } from "~/api/feeds.ts"; import { useSiteAccounts, useSites } from "~/api/sites.ts"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { SiteFeedDialog } from "~/components/sites/site-feed-dialog.tsx"; +import { hasLogin } from "~/components/sites/site-fields.ts"; import { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; @@ -78,8 +80,42 @@ function FeedActions({ f }: { f: FeedSource }) { ); } +const OBS_STATUS: Record = { + new: { label: "待识别", tone: "neutral" }, + linked: null, + ignored: { label: "已忽略", tone: "neutral" }, + error: { label: "出错", tone: "warning" }, +}; + +/** RSS 详情里的最近条目:标题 + 发布时间;已关联资源的正常态不加徽章 */ +function RecentItems({ feedId }: { feedId: string }) { + const obs = useFeedObservations(feedId); + if (obs.isPending) return ; + if (obs.error) return void obs.refetch()} />; + // 按发布时间(没有时用首次发现)新的在前;接口按首次发现排序,同一次抓取的条目发现时间相同 + const at = (o: Observation) => o.sourcePublishedAt ?? o.firstSeenAt; + const items = [...(obs.data?.items ?? [])].sort((a, b) => at(b).localeCompare(at(a))); + if (!items.length) return

    还没有抓到条目。

    ; + return ( +
      + {items.map((o) => { + const st = OBS_STATUS[o.status]; + return ( +
    • +

      {o.title}

      + + {st ? {st.label} : null} + {relTime(o.sourcePublishedAt ?? o.firstSeenAt)} + +
    • + ); + })} +
    + ); +} + /** - * Feed 列表 + 详情抽屉 + 新建/编辑对话框。来源健康页列全部,站点详情按 siteId 过滤。 + * RSS 列表 + 详情抽屉 + 新建/编辑对话框。站点详情的 RSS Tab 按 siteId 过滤(IA-2 起没有跨站点的 RSS 页)。 */ /** 抓取完成 / 健康变化(outbox → SSE 桥)到达时节流刷新 Feed 列表 */ function useLiveFeeds() { @@ -106,7 +142,10 @@ export function FeedTable({ creating, onCreatingChange, filtered = false, + openFeedId, }: { + /** 深链打开的 RSS(?feed=) */ + openFeedId?: string | null; siteId?: string; health?: FeedHealth; creating: boolean; @@ -119,7 +158,10 @@ export function FeedTable({ const sites = useSites(); const accounts = useSiteAccounts(); const [editingFeed, setEditingFeed] = useState(null); - const [openId, setOpenId] = useState(null); + const [openId, setOpenId] = useState(openFeedId ?? null); + useEffect(() => { + if (openFeedId) setOpenId(openFeedId); + }, [openFeedId]); const action = useFeedAction(); const siteName = new Map((sites.data?.items ?? []).map((s) => [s.id, s.name])); const accountName = new Map((accounts.data?.items ?? []).map((a) => [a.id, a.label])); @@ -167,17 +209,25 @@ export function FeedTable({ hideBelow: "md", cell: (f) => (f.purpose === "generic" ? null : PURPOSE_LABEL[f.purpose]), }, - { - key: "site", - header: "站点 / 账号", - hideBelow: "lg", - cell: (f) => ( - - {f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"} - {f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""} - - ), - }, + // 站点详情里只列账号(站点已知);没有登录的站点不显示这一列 + ...(scopedSite && !hasLogin(scopedSite) + ? [] + : [ + { + key: "site", + header: siteId ? "账号" : "站点 / 账号", + hideBelow: "lg" as const, + cell: (f: FeedSource) => ( + + {siteId + ? f.siteAccountId + ? (accountName.get(f.siteAccountId) ?? "账号") + : "—" + : `${f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}${f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}`} + + ), + }, + ]), { key: "poll", header: "轮询", @@ -287,6 +337,9 @@ export function FeedTable({ ]} /> + + + diff --git a/apps/web/app/routes/console/redirects.ts b/apps/web/app/routes/console/redirects.ts index 344e9985..891f2851 100644 --- a/apps/web/app/routes/console/redirects.ts +++ b/apps/web/app/routes/console/redirects.ts @@ -1,6 +1,6 @@ import { redirect } from "react-router"; -// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7)。 +// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7;IA-2 站点类型:RSS 并入站点)。 // 纯函数,各路由模块的 clientLoader 调用;nav.test 与冒烟都读 LEGACY_REDIRECTS。 // 设置页的 ?tab= 旧链接由设置页自己处理(IA-2)。 @@ -26,6 +26,15 @@ export function legacyRedirect(pathname: string, search: string): string | null // IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览 if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`); + // IA-2:独立 RSS 页取消,RSS 在站点详情的 RSS Tab。?feed= 要查所属站点,由 sources.tsx 的 clientLoader 处理 + if (path === "/console/sources") { + const siteId = q.get("siteId"); + if (q.get("feed")) return null; + if (siteId) return `/console/sites/${encodeURIComponent(siteId)}?tab=${tab === "snapshots" ? "snapshots" : "rss"}`; + if (q.get("new")) return "/console/sites?new=rss_feed"; + return "/console/sites"; + } + if (!tab) return null; if (path === "/console/tasks") { // FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传) @@ -37,11 +46,6 @@ export function legacyRedirect(pathname: string, search: string): string | null return null; } if (path === "/console/subscriptions" && tab === "policies") return withRest("/console/rules"); - if (path === "/console/sources" && (tab === "snapshots" || tab === "feeds")) { - const siteId = q.get("siteId"); - if (tab === "snapshots" && siteId) return `/console/sites/${encodeURIComponent(siteId)}?tab=snapshots`; - return withRest("/console/sources"); - } return null; } @@ -53,8 +57,13 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [ { from: "/console/tasks?tab=tasks", to: "/console/tasks" }, { from: "/console/tasks?tab=business", to: "/console/tasks" }, { from: "/console/subscriptions?tab=policies", to: "/console/rules" }, - { from: "/console/sources?tab=snapshots", to: "/console/sources" }, + { from: "/console/sources?tab=snapshots", to: "/console/sites" }, { from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites/abc?tab=snapshots" }, + { from: "/console/sources", to: "/console/sites" }, + { from: "/console/sources?health=failing", to: "/console/sites" }, + { from: "/console/sources?siteId=abc", to: "/console/sites/abc?tab=rss" }, + { from: "/console/sources?tab=feeds&siteId=abc", to: "/console/sites/abc?tab=rss" }, + { from: "/console/sources?new=1", to: "/console/sites?new=rss_feed" }, { from: "/console/engine", to: "/console/tasks" }, { from: "/console/engine?status=failed", to: "/console/tasks?status=failed" }, { from: "/console/engine/run-1", to: "/console/tasks?run=run-1" }, diff --git a/apps/web/app/routes/console/sites.$siteId.tsx b/apps/web/app/routes/console/sites.$siteId.tsx index 95f57b40..47973f3e 100644 --- a/apps/web/app/routes/console/sites.$siteId.tsx +++ b/apps/web/app/routes/console/sites.$siteId.tsx @@ -1,4 +1,4 @@ -import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts"; +import type { FeedHealth, ResolutionStatus, SiteListItem, WorkbenchRelease } from "@umt/contracts"; import { Activity, ArrowLeft, @@ -20,7 +20,6 @@ import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts"; import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts"; import { openAgentDrawer } from "~/components/agent/agent-store.ts"; import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx"; -import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { consolePath } from "~/components/console/nav.ts"; @@ -31,11 +30,20 @@ import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/ import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx"; import { SiteMoreMenu } from "~/components/sites/site-card.tsx"; import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx"; -import { ACCOUNT_OVERALL, healthLine, hostOf, overallStatus, primaryAccount } from "~/components/sites/site-fields.ts"; +import { + ACCOUNT_OVERALL, + hasLogin, + healthLine, + hostOf, + kindLabel, + overallStatus, + primaryAccount, + siteCan, +} from "~/components/sites/site-fields.ts"; import { SiteOverview } from "~/components/sites/site-overview.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; -import { FeedTable } from "~/components/workbench/feed-table.tsx"; +import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx"; import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx"; import { categoryLabel, circuitLabel, isFree, relTime } from "~/components/workbench/release-fields.ts"; import { ReleaseRow } from "~/components/workbench/release-list.tsx"; @@ -91,24 +99,53 @@ function useDebounced(value: T, ms = 400) { return v; } -const TABS = ["overview", "resources", "origin", "snapshots", "accounts"] as const; -/** 旧页签(整理视图)落到"资源" */ -const LEGACY_TAB: Record = { organized: "resources" }; +const TABS = ["overview", "resources", "origin", "snapshots", "rss", "accounts"] as const; +type Tab = (typeof TABS)[number]; +/** 旧页签(整理视图)落到"资源";旧 RSS 页的 ?tab=feeds 落到 RSS */ +const LEGACY_TAB: Record = { organized: "resources", feeds: "rss" }; + +/** 按站点类型与可用获取方式显示的 Tab(IA-2):自定义 RSS 源没有原站视图、抓取、账号 */ +function visibleTabs(s: SiteListItem | undefined): Tab[] { + if (!s) return [...TABS]; + return TABS.filter((t) => { + if (t === "origin") return s.kind !== "rss_feed"; + if (t === "snapshots") return siteCan(s, "list"); + if (t === "rss") return siteCan(s, "rss"); + if (t === "accounts") return hasLogin(s); + return true; + }); +} export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }]; +const TAB_LABEL: Record = { + overview: "概览", + resources: "资源", + origin: "原站视图", + snapshots: "抓取", + rss: "RSS", + accounts: "账号", +}; + export default function SiteWorkbenchPage() { const { siteId = "" } = useParams(); const [params, setParams] = useSearchParams(); const navigate = useNavigate(); - const rawTab = params.get("tab") ?? ""; - const mapped = LEGACY_TAB[rawTab] ?? rawTab; - const tab = (TABS as readonly string[]).includes(mapped) ? mapped : "overview"; const [creatingFeed, setCreatingFeed] = useState(false); + const [feedHealth, setFeedHealth] = useState(""); const [dialog, setDialog] = useState(null); const checks = useSiteChecks(setDialog); const sites = useSites(); const snap = useSnapshotSite(); + const site = sites.data?.items.find((x) => x.id === siteId); + const tabs = visibleTabs(site); + const rawTab = params.get("tab") ?? ""; + let mapped = LEGACY_TAB[rawTab] ?? rawTab; + // 只有 RSS 的站点:旧的"抓取与 RSS"深链落到 RSS + if (mapped === "snapshots" && site && !siteCan(site, "list")) mapped = "rss"; + const tab: Tab = (tabs as string[]).includes(mapped) ? (mapped as Tab) : "overview"; + /** 旧 RSS 页 / 任务页的 ?feed=:打开该 RSS 的详情 */ + const feedParam = params.get("feed"); const [qInput, setQInput] = useState(""); const q = useDebounced(qInput.trim()); @@ -152,7 +189,7 @@ export default function SiteWorkbenchPage() { return [...m].map(([value, label]) => ({ value, label })); }, [all, category]); - const s = sites.data?.items.find((x) => x.id === siteId); + const s = site; const refreshFailed = !!releases.error && !!releases.data; // 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期 const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null; @@ -187,8 +224,11 @@ export default function SiteWorkbenchPage() { /> ); - const account = s ? ACCOUNT_OVERALL[overallStatus(s)] : null; - const health = s ? healthLine(s.lastHealth, s.lastHealthAt) : null; + const login = !!s && hasLogin(s); + /** 有站点适配器(不只 RSS)才有首页健康检测 */ + const checkable = !!s && s.capabilities.some((c) => c !== "rss"); + const account = s && login ? ACCOUNT_OVERALL[overallStatus(s)] : null; + const health = s && checkable ? healthLine(s.lastHealth, s.lastHealthAt) : null; const circuit = s ? circuitLabel(s) : null; const hasAccount = !!s?.accounts?.some((a) => a.enabled && a.authKind !== "passkey"); @@ -207,6 +247,7 @@ export default function SiteWorkbenchPage() { > {hostOf(s.baseUrl)} + {s.kind !== "pt" ? {kindLabel(s.kind)} : null} {account ? 账号{account.label} : null} {health ? 健康 {health.text} : null} {!s.enabled ? 站点已停用,只显示历史数据 : null} @@ -225,7 +266,7 @@ export default function SiteWorkbenchPage() { 站点 - {s ? ( + {s && checkable ? ( <> - - + {login ? ( + + ) : null} ) : null} + {s ? : null} - + {s && !siteCan(s, "list") ? null : ( + + )} + {s && !siteCan(s, "search") ? null : ( + + )} } > @@ -375,7 +423,9 @@ export default function SiteWorkbenchPage() { ? "没有符合筛选条件的资源" : q ? "本地资源里没有匹配的资源" - : "还没抓取过" + : s && !siteCan(s, "list") + ? "还没有资源" + : "还没抓取过" } description={ all.length @@ -384,9 +434,11 @@ export default function SiteWorkbenchPage() { ? "减少快速筛选条件再看。" : coverage?.liveError ? `去站点搜失败(${coverage.liveError}),站点上可能仍有该资源。` - : q - ? "点「去站点搜」直接在站点搜索。" - : "点「刷新」抓取站点列表。" + : s && !siteCan(s, "list") + ? "RSS 抓到的资源会出现在这里。" + : q + ? "点「去站点搜」直接在站点搜索。" + : "点「刷新」抓取站点列表。" } /> ) : ( @@ -438,21 +490,34 @@ export default function SiteWorkbenchPage() { {s ? : } - -
    - {s ? : } - setCreatingFeed(true)}> - - 新建 RSS - - } - > - - -
    + {s ? : } + + + {/* 原独立 RSS 页的全部能力(IA-2):新建 / 编辑 / 启停 / 立即抓取 / 抓取状态 / 最近条目 */} + setFeedHealth("") : undefined} + actions={ + + } + > + ({ value, label: h.label }))} + /> + + diff --git a/apps/web/app/routes/console/sites.tsx b/apps/web/app/routes/console/sites.tsx index 092259ea..a91250cc 100644 --- a/apps/web/app/routes/console/sites.tsx +++ b/apps/web/app/routes/console/sites.tsx @@ -1,3 +1,4 @@ +import { SITE_KINDS, type SiteKind } from "@umt/contracts"; import { Globe, LayoutGrid, List, PackageSearch, Plus, RefreshCw } from "lucide-react"; import { useEffect, useState } from "react"; import { useSearchParams } from "react-router"; @@ -13,11 +14,15 @@ import { needsAttention, type SortKey, sortSites } from "~/components/sites/site import { Button } from "~/components/ui/button.tsx"; import { cn } from "~/lib/utils.ts"; -// 资源 → 站点:站点、凭证、健康、账号数据的唯一入口(docs/design/site-center.md §4) +// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4); +// IA-2 起站点有类型(PT 站 / 公开 BT 站 / 自定义 RSS 源),列表可按类型筛选,?new=<类型> 直接打开添加。 const VIEW_KEY = "umt.sites.view"; type View = "cards" | "list"; type Filter = "all" | "enabled" | "attention"; +const KIND_OPTIONS = (Object.keys(SITE_KINDS) as SiteKind[]) + .filter((k) => SITE_KINDS[k].implemented) + .map((k) => ({ value: k, label: SITE_KINDS[k].label })); const FILTERS: { value: Filter; label: string }[] = [ { value: "all", label: "全部" }, @@ -45,7 +50,8 @@ export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }]; export default function SitesPage() { const sites = useSites(); const [params, setParams] = useSearchParams(); - const [adding, setAdding] = useState(false); + const newParam = params.get("new"); + const [adding, setAdding] = useState(null); const [dialog, setDialog] = useState(null); const checks = useSiteChecks(setDialog); @@ -58,6 +64,7 @@ export default function SitesPage() { const view: View = urlView === "list" || urlView === "cards" ? urlView : storedView; const filter = (FILTERS.find((f) => f.value === params.get("filter"))?.value ?? "all") as Filter; const sort = (SORTS.find((s) => s.value === params.get("sort"))?.value ?? "name") as SortKey; + const kindFilter = KIND_OPTIONS.find((k) => k.value === params.get("kind"))?.value ?? ""; const setParam = (k: string, v: string | null) => setParams( @@ -78,6 +85,14 @@ export default function SitesPage() { setParam("view", v); }; + // 命令面板 / 旧 RSS 页的 ?new=1、?new=rss_feed:打开添加(带类型时跳过选类型) + // biome-ignore lint/correctness/useExhaustiveDependencies: 只在参数出现时处理一次 + useEffect(() => { + if (!newParam) return; + setAdding(newParam in SITE_KINDS ? (newParam as SiteKind) : "any"); + setParam("new", null); + }, [newParam]); + // 待处理"改用 Cookie"深链:?credentials= const credFor = params.get("credentials"); // biome-ignore lint/correctness/useExhaustiveDependencies: 只在数据到达时处理一次 @@ -92,7 +107,11 @@ export default function SitesPage() { const [searchDefaults, setSearchDefaults] = useState(false); const attention = all.filter(needsAttention).length; const shown = sortSites( - all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)), + all.filter( + (s) => + (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true) && + (!kindFilter || s.kind === kindFilter), + ), sort, ); @@ -110,7 +129,7 @@ export default function SitesPage() { 搜索站点 - @@ -130,6 +149,14 @@ export default function SitesPage() { label: f.value === "attention" && attention ? `${f.label} ${attention}` : f.label, }))} /> + {new Set(all.map((s) => s.kind)).size > 1 || kindFilter ? ( + setParam("kind", v || null)} + options={KIND_OPTIONS} + /> + ) : null} setAdding(true)}> + @@ -171,8 +198,8 @@ export default function SitesPage() { ) : !shown.length ? ( ) : view === "list" ? ( @@ -184,7 +211,7 @@ export default function SitesPage() {
    )} - {adding ? setAdding(false)} /> : null} + {adding ? setAdding(null)} /> : null} setDialog(null)} /> diff --git a/apps/web/app/routes/console/sources.tsx b/apps/web/app/routes/console/sources.tsx index 0a285961..49809d22 100644 --- a/apps/web/app/routes/console/sources.tsx +++ b/apps/web/app/routes/console/sources.tsx @@ -1,76 +1,25 @@ -import type { FeedHealth } from "@umt/contracts"; -import { Plus, RefreshCw } from "lucide-react"; -import { useState } from "react"; -import { useFeeds } from "~/api/feeds.ts"; -import { useSites } from "~/api/sites.ts"; -import { DetailSection } from "~/components/console/detail-sheet.tsx"; -import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; -import { PageHeader } from "~/components/console/page-header.tsx"; -import { useQueryState } from "~/components/console/use-query-state.ts"; -import { Button } from "~/components/ui/button.tsx"; -import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx"; -import { legacyRedirectLoader } from "./redirects.ts"; +import { FeedSource } from "@umt/contracts"; +import { redirect } from "react-router"; +import { apiParsed } from "~/api/parse.ts"; +import { consolePath } from "~/components/console/nav.ts"; +import { legacyRedirect } from "./redirects.ts"; -// RSS:跨站点的 RSS 列表;站点健康只在站点页(DEAI-2 去掉重复的站点表)。 +// IA-2:独立 RSS 页取消,RSS 属于站点(站点详情的 RSS Tab)。这里只做旧链接跳转,深链不失效: +// ?siteId= → 该站点 RSS Tab;?feed= → 查 RSS 所属站点后打开它的详情;其余 → 站点列表。 export const meta = () => [{ title: "RSS · UHD 媒体自动化控制台" }]; -// 旧的 ?tab=snapshots&siteId= 跳到站点详情的"快照与 Feed" -export const clientLoader = legacyRedirectLoader; +export async function clientLoader({ request }: { request: Request }) { + const url = new URL(request.url); + const feed = url.searchParams.get("feed"); + if (feed) { + const f = await apiParsed(FeedSource, "/api/v1/feed-sources/:id", { pathParams: { id: feed } }).catch(() => null); + if (f?.siteId) return redirect(`${consolePath("sites", f.siteId)}?tab=rss&feed=${encodeURIComponent(feed)}`); + return redirect(consolePath("sites")); + } + return redirect(legacyRedirect(url.pathname, url.search) ?? consolePath("sites")); +} -export default function SourcesPage() { - const f = useQueryState(["health", "siteId"] as const); - const [creating, setCreating] = useState(false); - const sites = useSites(); - const feeds = useFeeds({ - health: (f.values.health || undefined) as FeedHealth | undefined, - siteId: f.values.siteId || undefined, - }); - - return ( - <> - setCreating(true)}> - - 新建 RSS - - } - /> -
    - - void feeds.refetch()} disabled={feeds.isFetching}> - - 刷新 - - } - > - f.set("health", v)} - options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))} - /> - f.set("siteId", v)} - options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))} - /> - - - -
    - - ); +export default function LegacySourcesRedirect() { + return null; } diff --git a/deno.lock b/deno.lock index 05cb264e..12126f31 100644 --- a/deno.lock +++ b/deno.lock @@ -16,6 +16,7 @@ "npm:@react-router/dev@*": "7.12.0_react-router@7.12.0_typescript@7.0.2_vite@7.3.6_@types+node@24.19.0_react@19.3.0_react-dom@19.3.0_yaml@2.9.1", "npm:@react-router/dev@7.12.0": "7.12.0_react-router@7.12.0_typescript@7.0.2_vite@7.3.6_@types+node@24.19.0_react@19.3.0_react-dom@19.3.0_yaml@2.9.1", "npm:@react-router/node@7.12.0": "7.12.0_react-router@7.12.0_typescript@7.0.2_react@19.3.0_react-dom@19.3.0", + "npm:@simplewebauthn/browser@^13.3.0": "13.3.0", "npm:@simplewebauthn/server@13.3.3": "13.3.3", "npm:@tailwindcss/vite@^4.1.13": "4.3.3_vite@7.3.6_@types+node@24.19.0_yaml@2.9.1", "npm:@tanstack/react-query@^5.90.21": "5.104.0_react@19.3.0", @@ -44,12 +45,13 @@ "npm:pino@10.3.1": "10.3.1", "npm:playwright-core@1.63.0": "1.63.0", "npm:postgres@3.4.9": "3.4.9", + "npm:qr-code-styling@^1.9.2": "1.9.2", "npm:radix-ui@^1.4.3": "1.6.7_@types+react@19.3.0_@types+react-dom@19.3.0_react@19.3.0_react-dom@19.3.0", "npm:react-day-picker@^10.0.1": "10.0.1_@types+react@19.3.0_react@19.3.0", "npm:react-dom@^19.2.4": "19.3.0_react@19.3.0", "npm:react-router@7.12.0": "7.12.0_react@19.3.0_react-dom@19.3.0", "npm:react@^19.2.4": "19.3.0", - "npm:shadcn@^3.8.5": "3.8.5_@types+node@24.19.0_typescript@7.0.2", + "npm:recharts@^3.10.1": "3.10.1_react@19.3.0_react-dom@19.3.0_react-is@19.3.0_@types+react@19.3.0_redux@5.0.1", "npm:sonner@^2.0.7": "2.0.8_@types+react@19.3.0_react@19.3.0_react-dom@19.3.0", "npm:tailwind-merge@^3.5.0": "3.7.0", "npm:tailwindcss@^4.1.13": "4.3.3", @@ -78,25 +80,15 @@ } }, "npm": { - "@antfu/ni@25.0.0": { - "integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==", - "dependencies": [ - "ansis", - "fzf", - "package-manager-detector", - "tinyexec" - ], - "bin": true - }, "@anthropic-ai/sdk@0.124.0_zod@4.6.5": { "integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", "dependencies": [ "json-schema-to-ts", "standardwebhooks", - "zod@4.6.5" + "zod" ], "optionalPeers": [ - "zod@4.6.5" + "zod" ], "bin": true }, @@ -104,7 +96,7 @@ "integrity": "sha512-pLMeRgRYS7/vZIgAAkOe2P6+XGTifR1MT9pqDoxZ11EmcJJ+DPmdPqLN8ZZF4U8R9KHCCQCS9c2wtuE8wSrfkw==", "dependencies": [ "openapi3-ts", - "zod@4.6.5" + "zod" ] }, "@aws-sdk/client-bedrock-runtime@3.1127.0": { @@ -603,31 +595,6 @@ "@date-fns/tz@1.5.0": { "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==" }, - "@dotenvx/dotenvx@1.75.1": { - "integrity": "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==", - "dependencies": [ - "@dotenvx/primitives", - "commander@11.1.0", - "conf", - "dotenv", - "enquirer", - "env-paths", - "execa@5.1.1", - "fdir", - "ignore@5.3.2", - "object-treeify", - "open@8.4.2", - "picomatch@4.0.7", - "systeminformation", - "undici", - "which@4.0.0", - "yocto-spinner" - ], - "bin": true - }, - "@dotenvx/primitives@0.8.0": { - "integrity": "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==" - }, "@earendil-works/chord@0.87.1": { "integrity": "sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg==", "dependencies": [ @@ -641,7 +608,7 @@ "@earendil-works/pi-ai", "@earendil-works/pi-telemetry", "diff", - "ignore@7.0.8", + "ignore", "typebox", "yaml@2.9.0" ] @@ -861,56 +828,14 @@ "@hono/zod-validator", "hono", "openapi3-ts", - "zod@4.6.5" + "zod" ] }, "@hono/zod-validator@0.9.1_hono@4.13.9_zod@4.6.5": { "integrity": "sha512-iiv6w0qrIc0arfvCtUqBWsvl4fXjzaTcQcCJTTtCnkawF9HHGE+KjEl0ox3gQJ6rKZEgE3mLExlQCF72M+mNuw==", "dependencies": [ "hono", - "zod@4.6.5" - ] - }, - "@inquirer/ansi@2.0.8": { - "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==" - }, - "@inquirer/confirm@6.3.2_@types+node@24.19.0": { - "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", - "dependencies": [ - "@inquirer/core", - "@inquirer/type", - "@types/node" - ], - "optionalPeers": [ - "@types/node" - ] - }, - "@inquirer/core@12.0.3_@types+node@24.19.0": { - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dependencies": [ - "@inquirer/ansi", - "@inquirer/figures", - "@inquirer/type", - "@types/node", - "cli-width", - "fast-wrap-ansi", - "mute-stream", - "signal-exit@4.1.0" - ], - "optionalPeers": [ - "@types/node" - ] - }, - "@inquirer/figures@2.0.9": { - "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==" - }, - "@inquirer/type@4.1.1_@types+node@24.19.0": { - "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", - "dependencies": [ - "@types/node" - ], - "optionalPeers": [ - "@types/node" + "zod" ] }, "@jridgewell/gen-mapping@0.3.13": { @@ -951,7 +876,7 @@ "dependencies": [ "@hono/node-server", "ajv", - "ajv-formats@3.0.1_ajv@8.20.0", + "ajv-formats", "content-type@1.0.5", "cors", "cross-spawn", @@ -961,24 +886,13 @@ "express-rate-limit", "hono", "jose", - "json-schema-typed@8.0.2", + "json-schema-typed", "pkce-challenge", "raw-body", - "zod@4.6.5", + "zod", "zod-to-json-schema" ] }, - "@mswjs/interceptors@0.41.9": { - "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", - "dependencies": [ - "@open-draft/deferred-promise@2.2.0", - "@open-draft/logger", - "@open-draft/until", - "is-node-process", - "outvariant", - "strict-event-emitter" - ] - }, "@napi-rs/lzma-linux-x64-gnu@1.5.1": { "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", "os": ["linux"], @@ -987,39 +901,6 @@ "@nodable/entities@3.0.0": { "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==" }, - "@nodelib/fs.scandir@2.1.5": { - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dependencies": [ - "@nodelib/fs.stat", - "run-parallel" - ] - }, - "@nodelib/fs.stat@2.0.5": { - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" - }, - "@nodelib/fs.walk@1.2.8": { - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dependencies": [ - "@nodelib/fs.scandir", - "fastq" - ] - }, - "@open-draft/deferred-promise@2.2.0": { - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==" - }, - "@open-draft/deferred-promise@3.0.0": { - "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==" - }, - "@open-draft/logger@0.3.0": { - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "dependencies": [ - "is-node-process", - "outvariant" - ] - }, - "@open-draft/until@2.1.0": { - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==" - }, "@peculiar/asn1-android@2.9.5": { "integrity": "sha512-KnNE4l033gbJ3ZzfYk7fJpSZwhd5xT+dPAnrB8bcwqylHiLN2zjZ0OyFao0LDIOI/ZdXRabV0n3Ww/KvpgZq6w==", "dependencies": [ @@ -2250,6 +2131,23 @@ "typescript" ] }, + "@reduxjs/toolkit@2.12.0_react@19.3.0_react-redux@9.3.0_@types+react@19.3.0": { + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "dependencies": [ + "@standard-schema/spec", + "@standard-schema/utils", + "immer", + "react", + "react-redux", + "redux", + "redux-thunk", + "reselect" + ], + "optionalPeers": [ + "react", + "react-redux" + ] + }, "@remix-run/node-fetch-server@0.9.0": { "integrity": "sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA==" }, @@ -2378,8 +2276,8 @@ "os": ["win32"], "cpu": ["x64"] }, - "@sec-ant/readable-stream@0.4.1": { - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==" + "@simplewebauthn/browser@13.3.0": { + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==" }, "@simplewebauthn/server@13.3.3": { "integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==", @@ -2394,9 +2292,6 @@ "@peculiar/x509" ] }, - "@sindresorhus/merge-streams@4.0.0": { - "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==" - }, "@smithy/core@3.35.0": { "integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==", "dependencies": [ @@ -2445,6 +2340,12 @@ "@stablelib/base64@1.0.1": { "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" }, + "@standard-schema/spec@1.1.0": { + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "@standard-schema/utils@0.3.0": { + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, "@tailwindcss/node@4.3.3": { "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dependencies": [ @@ -2552,14 +2453,6 @@ "react" ] }, - "@ts-morph/common@0.27.0": { - "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", - "dependencies": [ - "fast-glob", - "minimatch", - "path-browserify" - ] - }, "@types/chai@5.2.3": { "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dependencies": [ @@ -2567,6 +2460,42 @@ "assertion-error" ] }, + "@types/d3-array@3.2.2": { + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "@types/d3-color@3.1.3": { + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "@types/d3-ease@3.0.2": { + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "@types/d3-interpolate@3.0.4": { + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": [ + "@types/d3-color" + ] + }, + "@types/d3-path@3.1.1": { + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "@types/d3-scale@4.0.9": { + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": [ + "@types/d3-time" + ] + }, + "@types/d3-shape@3.2.0": { + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "dependencies": [ + "@types/d3-path" + ] + }, + "@types/d3-time@3.0.4": { + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "@types/d3-timer@3.0.2": { + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, "@types/deep-eql@4.0.2": { "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==" }, @@ -2605,17 +2534,8 @@ "@types/retry@0.12.0": { "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" }, - "@types/set-cookie-parser@2.4.10": { - "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", - "dependencies": [ - "@types/node" - ] - }, - "@types/statuses@2.0.6": { - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==" - }, - "@types/validate-npm-package-name@4.0.2": { - "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==" + "@types/use-sync-external-store@0.0.6": { + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" }, "@typescript/typescript-aix-ppc64@7.0.2": { "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", @@ -2752,15 +2672,6 @@ "agent-base@9.0.0": { "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==" }, - "ajv-formats@2.1.1_ajv@8.20.0": { - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dependencies": [ - "ajv" - ], - "optionalPeers": [ - "ajv" - ] - }, "ajv-formats@3.0.1_ajv@8.20.0": { "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dependencies": [ @@ -2779,33 +2690,12 @@ "require-from-string" ] }, - "ansi-colors@4.1.3": { - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==" - }, - "ansi-regex@5.0.1": { - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" - }, - "ansi-regex@6.4.0": { - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==" - }, - "ansi-styles@4.3.0": { - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": [ - "color-convert" - ] - }, - "ansis@4.4.0": { - "integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==" - }, "anynum@1.0.1": { "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==" }, "arg@5.0.2": { "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" }, - "argparse@2.0.1": { - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" - }, "aria-hidden@1.2.6": { "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "dependencies": [ @@ -2826,18 +2716,9 @@ "assertion-error@2.0.1": { "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" }, - "ast-types@0.16.3": { - "integrity": "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==", - "dependencies": [ - "tslib@2.8.1" - ] - }, "atomic-sleep@1.0.0": { "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==" }, - "atomically@1.7.0": { - "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==" - }, "babel-dead-code-elimination@1.0.12": { "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", "dependencies": [ @@ -2847,9 +2728,6 @@ "@babel/types" ] }, - "balanced-match@4.0.4": { - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" - }, "base64-js@1.5.1": { "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" }, @@ -2880,18 +2758,6 @@ "bowser@2.14.1": { "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" }, - "brace-expansion@5.0.12": { - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", - "dependencies": [ - "balanced-match" - ] - }, - "braces@3.0.3": { - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dependencies": [ - "fill-range" - ] - }, "browserslist@4.29.1": { "integrity": "sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==", "dependencies": [ @@ -2906,12 +2772,6 @@ "buffer-equal-constant-time@1.0.1": { "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, - "bundle-name@4.1.1": { - "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", - "dependencies": [ - "run-applescript" - ] - }, "bytes@3.1.2": { "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" }, @@ -2932,18 +2792,12 @@ "get-intrinsic" ] }, - "callsites@3.1.0": { - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==" - }, "caniuse-lite@1.0.30001812": { "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==" }, "chai@6.2.2": { "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==" }, - "chalk@5.6.2": { - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" - }, "cheerio-select@2.1.0": { "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", "dependencies": [ @@ -2983,68 +2837,15 @@ "clsx" ] }, - "cli-cursor@5.0.0": { - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dependencies": [ - "restore-cursor" - ] - }, - "cli-spinners@2.9.2": { - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==" - }, - "cli-width@4.1.0": { - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==" - }, - "cliui@8.0.1": { - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dependencies": [ - "string-width@4.2.3", - "strip-ansi@6.0.1", - "wrap-ansi" - ] - }, "clsx@2.1.1": { "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" }, - "code-block-writer@13.0.3": { - "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==" - }, - "color-convert@2.0.1": { - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": [ - "color-name" - ] - }, - "color-name@1.1.4": { - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "colorette@2.0.20": { "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" }, - "commander@11.1.0": { - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==" - }, - "commander@14.0.3": { - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==" - }, "commander@5.1.0": { "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==" }, - "conf@10.2.0": { - "integrity": "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==", - "dependencies": [ - "ajv", - "ajv-formats@2.1.1_ajv@8.20.0", - "atomically", - "debounce-fn", - "dot-prop", - "env-paths", - "json-schema-typed@7.0.3", - "onetime@5.1.2", - "pkg-up", - "semver@7.8.5" - ] - }, "confbox@0.3.1": { "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==" }, @@ -3076,25 +2877,12 @@ "vary" ] }, - "cosmiconfig@9.0.2_typescript@7.0.2": { - "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", - "dependencies": [ - "env-paths", - "import-fresh", - "js-yaml", - "parse-json", - "typescript" - ], - "optionalPeers": [ - "typescript" - ] - }, "cross-spawn@7.0.6": { "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": [ - "path-key@3.1.1", + "path-key", "shebang-command", - "which@2.0.2" + "which" ] }, "css-select@5.2.2": { @@ -3110,13 +2898,64 @@ "css-what@6.2.2": { "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==" }, - "cssesc@3.0.0": { - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "bin": true - }, "csstype@3.2.3": { "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "d3-array@3.2.4": { + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": [ + "internmap" + ] + }, + "d3-color@3.1.0": { + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" + }, + "d3-ease@3.0.1": { + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, + "d3-format@3.1.2": { + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==" + }, + "d3-interpolate@3.0.1": { + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": [ + "d3-color" + ] + }, + "d3-path@3.1.0": { + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" + }, + "d3-scale@4.0.2": { + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": [ + "d3-array", + "d3-format", + "d3-interpolate", + "d3-time", + "d3-time-format" + ] + }, + "d3-shape@3.2.0": { + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": [ + "d3-path" + ] + }, + "d3-time-format@4.1.0": { + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": [ + "d3-time" + ] + }, + "d3-time@3.1.0": { + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": [ + "d3-array" + ] + }, + "d3-timer@3.0.1": { + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, "data-uri-to-buffer@4.0.1": { "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==" }, @@ -3126,40 +2965,18 @@ "dateformat@4.6.3": { "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==" }, - "debounce-fn@4.0.0": { - "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", - "dependencies": [ - "mimic-fn@3.1.0" - ] - }, "debug@4.4.3": { "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": [ "ms" ] }, + "decimal.js-light@2.5.1": { + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "dedent@1.7.2": { "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==" }, - "deepmerge@4.3.1": { - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" - }, - "default-browser-id@5.0.1": { - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==" - }, - "default-browser@5.5.1": { - "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", - "dependencies": [ - "bundle-name", - "default-browser-id" - ] - }, - "define-lazy-prop@2.0.0": { - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==" - }, - "define-lazy-prop@3.0.0": { - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==" - }, "depd@2.0.0": { "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, @@ -3197,15 +3014,6 @@ "domhandler" ] }, - "dot-prop@6.0.1": { - "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", - "dependencies": [ - "is-obj@2.0.0" - ] - }, - "dotenv@17.4.2": { - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==" - }, "dunder-proto@1.0.1": { "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dependencies": [ @@ -3226,12 +3034,6 @@ "electron-to-chromium@1.5.439": { "integrity": "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==" }, - "emoji-regex@10.6.0": { - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==" - }, - "emoji-regex@8.0.0": { - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, "encodeurl@2.0.0": { "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==" }, @@ -3255,13 +3057,6 @@ "tapable" ] }, - "enquirer@2.4.1": { - "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", - "dependencies": [ - "ansi-colors", - "strip-ansi@6.0.1" - ] - }, "entities@4.5.0": { "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" }, @@ -3271,15 +3066,6 @@ "entities@7.0.1": { "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==" }, - "env-paths@2.2.1": { - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==" - }, - "error-ex@1.3.4": { - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dependencies": [ - "is-arrayish" - ] - }, "es-define-property@1.0.1": { "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" }, @@ -3298,6 +3084,9 @@ "es-errors" ] }, + "es-toolkit@1.52.0": { + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==" + }, "esbuild@0.28.2": { "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "optionalDependencies": [ @@ -3337,10 +3126,6 @@ "escape-html@1.0.3": { "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, - "esprima@4.0.1": { - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "bin": true - }, "estree-walker@3.0.3": { "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dependencies": [ @@ -3350,6 +3135,9 @@ "etag@1.8.1": { "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, + "eventemitter3@5.0.4": { + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" + }, "eventsource-parser@3.1.1": { "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==" }, @@ -3359,37 +3147,6 @@ "eventsource-parser" ] }, - "execa@5.1.1": { - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dependencies": [ - "cross-spawn", - "get-stream@6.0.1", - "human-signals@2.1.0", - "is-stream@2.0.1", - "merge-stream", - "npm-run-path@4.0.1", - "onetime@5.1.2", - "signal-exit@3.0.7", - "strip-final-newline@2.0.0" - ] - }, - "execa@9.6.1": { - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", - "dependencies": [ - "@sindresorhus/merge-streams", - "cross-spawn", - "figures", - "get-stream@9.0.1", - "human-signals@8.0.1", - "is-plain-obj", - "is-stream@4.0.1", - "npm-run-path@6.0.0", - "pretty-ms", - "signal-exit@4.1.0", - "strip-final-newline@4.0.0", - "yoctocolors" - ] - }, "exit-hook@2.2.1": { "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==" }, @@ -3449,40 +3206,15 @@ "fast-deep-equal@3.1.3": { "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, - "fast-glob@3.3.3": { - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dependencies": [ - "@nodelib/fs.stat", - "@nodelib/fs.walk", - "glob-parent", - "merge2", - "micromatch" - ] - }, "fast-safe-stringify@2.1.1": { "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, "fast-sha256@1.3.0": { "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==" }, - "fast-string-truncated-width@3.0.3": { - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==" - }, - "fast-string-width@3.0.2": { - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dependencies": [ - "fast-string-truncated-width" - ] - }, "fast-uri@3.1.8": { "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==" }, - "fast-wrap-ansi@0.2.2": { - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "dependencies": [ - "fast-string-width" - ] - }, "fast-xml-builder@1.3.1": { "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", "dependencies": [ @@ -3502,19 +3234,13 @@ ], "bin": true }, - "fastq@1.20.3": { - "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", - "dependencies": [ - "reusify" - ] - }, "fdir@6.5.0_picomatch@4.0.7": { "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dependencies": [ - "picomatch@4.0.7" + "picomatch" ], "optionalPeers": [ - "picomatch@4.0.7" + "picomatch" ] }, "fetch-blob@3.2.0": { @@ -3524,18 +3250,6 @@ "web-streams-polyfill" ] }, - "figures@6.1.0": { - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "dependencies": [ - "is-unicode-supported@2.1.0" - ] - }, - "fill-range@7.1.1": { - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dependencies": [ - "to-regex-range" - ] - }, "finalhandler@2.1.1": { "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dependencies": [ @@ -3547,12 +3261,6 @@ "statuses" ] }, - "find-up@3.0.0": { - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dependencies": [ - "locate-path" - ] - }, "formdata-polyfill@4.0.10": { "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "dependencies": [ @@ -3565,14 +3273,6 @@ "fresh@2.0.0": { "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==" }, - "fs-extra@11.4.1": { - "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", - "dependencies": [ - "graceful-fs", - "jsonfile", - "universalify" - ] - }, "fsevents@2.3.3": { "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "os": ["darwin"], @@ -3581,12 +3281,6 @@ "function-bind@1.1.2": { "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, - "fuzzysort@3.1.0": { - "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==" - }, - "fzf@0.5.2": { - "integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==" - }, "gaxios@7.3.1": { "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", "dependencies": [ @@ -3606,12 +3300,6 @@ "gensync@1.0.0-beta.2": { "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==" }, - "get-caller-file@2.0.5": { - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" - }, - "get-east-asian-width@1.7.0": { - "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==" - }, "get-intrinsic@1.3.0": { "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dependencies": [ @@ -3630,9 +3318,6 @@ "get-nonce@1.0.1": { "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" }, - "get-own-enumerable-keys@1.0.0": { - "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==" - }, "get-proto@1.0.1": { "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dependencies": [ @@ -3640,22 +3325,6 @@ "es-object-atoms" ] }, - "get-stream@6.0.1": { - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==" - }, - "get-stream@9.0.1": { - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dependencies": [ - "@sec-ant/readable-stream", - "is-stream@4.0.1" - ] - }, - "glob-parent@5.1.2": { - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": [ - "is-glob" - ] - }, "globrex@0.1.2": { "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==" }, @@ -3679,9 +3348,6 @@ "graceful-fs@4.2.11": { "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, - "graphql@16.14.2": { - "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==" - }, "has-symbols@1.1.0": { "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" }, @@ -3694,13 +3360,6 @@ "function-bind" ] }, - "headers-polyfill@5.0.1": { - "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", - "dependencies": [ - "@types/set-cookie-parser", - "set-cookie-parser@3.1.2" - ] - }, "help-me@5.0.0": { "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" }, @@ -3749,12 +3408,6 @@ "proxy-agent-negotiate" ] }, - "human-signals@2.1.0": { - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" - }, - "human-signals@8.0.1": { - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==" - }, "iconv-lite@0.6.3": { "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dependencies": [ @@ -3767,121 +3420,36 @@ "safer-buffer" ] }, - "ignore@5.3.2": { - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" - }, "ignore@7.0.8": { "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==" }, - "import-fresh@3.3.1": { - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dependencies": [ - "parent-module", - "resolve-from" - ] + "immer@11.1.18": { + "integrity": "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==" }, "inherits@2.0.4": { "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "internmap@2.0.3": { + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==" + }, "ip-address@10.7.2": { "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==" }, "ipaddr.js@1.9.1": { "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, - "is-arrayish@0.2.1": { - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" - }, - "is-docker@2.2.1": { - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "bin": true - }, - "is-docker@3.0.0": { - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "bin": true - }, - "is-extglob@2.1.1": { - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" - }, - "is-fullwidth-code-point@3.0.0": { - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" - }, - "is-glob@4.0.3": { - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dependencies": [ - "is-extglob" - ] - }, - "is-in-ssh@1.0.0": { - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==" - }, - "is-inside-container@1.0.0": { - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dependencies": [ - "is-docker@3.0.0" - ], - "bin": true - }, - "is-interactive@2.0.0": { - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==" - }, - "is-node-process@1.2.0": { - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==" - }, - "is-number@7.0.0": { - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" - }, - "is-obj@2.0.0": { - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" - }, - "is-obj@3.0.0": { - "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==" - }, - "is-plain-obj@4.1.0": { - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==" - }, "is-promise@4.0.0": { "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, - "is-regexp@3.1.0": { - "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==" - }, - "is-stream@2.0.1": { - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" - }, - "is-stream@4.0.1": { - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==" - }, - "is-unicode-supported@1.3.0": { - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==" - }, - "is-unicode-supported@2.1.0": { - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==" - }, "is-unsafe@2.0.2": { "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==" }, - "is-wsl@2.2.0": { - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dependencies": [ - "is-docker@2.2.1" - ] - }, - "is-wsl@3.1.1": { - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dependencies": [ - "is-inside-container" - ] - }, "isbot@5.2.2": { "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==" }, "isexe@2.0.0": { "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, - "isexe@3.1.5": { - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==" - }, "jiti@2.7.0": { "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "bin": true @@ -3898,13 +3466,6 @@ "js-tokens@4.0.0": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, - "js-yaml@4.3.2": { - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dependencies": [ - "argparse" - ], - "bin": true - }, "jsesc@3.0.2": { "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", "bin": true @@ -3915,9 +3476,6 @@ "bignumber.js" ] }, - "json-parse-even-better-errors@2.3.1": { - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, "json-schema-to-ts@3.1.1": { "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "dependencies": [ @@ -3928,9 +3486,6 @@ "json-schema-traverse@1.0.0": { "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, - "json-schema-typed@7.0.3": { - "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==" - }, "json-schema-typed@8.0.2": { "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==" }, @@ -3938,15 +3493,6 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": true }, - "jsonfile@6.2.1": { - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dependencies": [ - "universalify" - ], - "optionalDependencies": [ - "graceful-fs" - ] - }, "jwa@2.0.1": { "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "dependencies": [ @@ -3962,12 +3508,6 @@ "safe-buffer" ] }, - "kleur@3.0.3": { - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==" - }, - "kleur@4.1.5": { - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" - }, "kysely@0.29.6": { "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==" }, @@ -4045,26 +3585,9 @@ "lightningcss-win32-x64-msvc" ] }, - "lines-and-columns@1.2.4": { - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" - }, - "locate-path@3.0.0": { - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dependencies": [ - "p-locate", - "path-exists" - ] - }, "lodash@4.18.1": { "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, - "log-symbols@6.0.0": { - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "dependencies": [ - "chalk", - "is-unicode-supported@1.3.0" - ] - }, "long@5.3.2": { "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" }, @@ -4101,19 +3624,6 @@ "merge-descriptors@2.0.0": { "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==" }, - "merge-stream@2.0.0": { - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" - }, - "merge2@1.4.1": { - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" - }, - "micromatch@4.0.8": { - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dependencies": [ - "braces", - "picomatch@2.3.2" - ] - }, "mime-db@1.54.0": { "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==" }, @@ -4123,59 +3633,12 @@ "mime-db" ] }, - "mimic-fn@2.1.0": { - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" - }, - "mimic-fn@3.1.0": { - "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==" - }, - "mimic-function@5.0.1": { - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==" - }, - "minimatch@10.2.6": { - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dependencies": [ - "brace-expansion" - ] - }, "minimist@1.2.8": { "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" }, "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "msw@2.15.0_typescript@7.0.2_@types+node@24.19.0": { - "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", - "dependencies": [ - "@inquirer/confirm", - "@mswjs/interceptors", - "@open-draft/deferred-promise@3.0.0", - "@types/statuses", - "cookie@1.1.1", - "graphql", - "headers-polyfill", - "is-node-process", - "outvariant", - "path-to-regexp@6.3.0", - "picocolors", - "rettime", - "statuses", - "strict-event-emitter", - "tough-cookie", - "type-fest", - "typescript", - "until-async", - "yargs" - ], - "optionalPeers": [ - "typescript" - ], - "scripts": true, - "bin": true - }, - "mute-stream@3.0.0": { - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==" - }, "nanoid@3.3.19": { "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "bin": true @@ -4208,19 +3671,6 @@ "node-releases@2.0.57": { "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==" }, - "npm-run-path@4.0.1": { - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dependencies": [ - "path-key@3.1.1" - ] - }, - "npm-run-path@6.0.0": { - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dependencies": [ - "path-key@4.0.0", - "unicorn-magic" - ] - }, "nth-check@2.1.1": { "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", "dependencies": [ @@ -4232,7 +3682,7 @@ "dependencies": [ "a-sync-waterfall", "asap", - "commander@5.1.0" + "commander" ], "bin": true }, @@ -4242,9 +3692,6 @@ "object-inspect@1.13.4": { "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, - "object-treeify@1.1.33": { - "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==" - }, "obug@2.2.1": { "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==" }, @@ -4263,44 +3710,13 @@ "wrappy" ] }, - "onetime@5.1.2": { - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dependencies": [ - "mimic-fn@2.1.0" - ] - }, - "onetime@7.0.0": { - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dependencies": [ - "mimic-function" - ] - }, - "open@11.0.4": { - "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", - "dependencies": [ - "default-browser", - "define-lazy-prop@3.0.0", - "is-in-ssh", - "is-inside-container", - "powershell-utils@0.2.1", - "wsl-utils" - ] - }, - "open@8.4.2": { - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dependencies": [ - "define-lazy-prop@2.0.0", - "is-docker@2.2.1", - "is-wsl@2.2.0" - ] - }, "openai@6.40.0_zod@4.6.5": { "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", "dependencies": [ - "zod@4.6.5" + "zod" ], "optionalPeers": [ - "zod@4.6.5" + "zod" ] }, "openapi3-ts@4.6.1": { @@ -4318,35 +3734,6 @@ "postgres" ] }, - "ora@8.2.0": { - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "dependencies": [ - "chalk", - "cli-cursor", - "cli-spinners", - "is-interactive", - "is-unicode-supported@2.1.0", - "log-symbols", - "stdin-discarder", - "string-width@7.2.0", - "strip-ansi@7.2.0" - ] - }, - "outvariant@1.4.3": { - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==" - }, - "p-limit@2.3.0": { - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dependencies": [ - "p-try" - ] - }, - "p-locate@3.0.0": { - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dependencies": [ - "p-limit" - ] - }, "p-map@7.0.8": { "integrity": "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==" }, @@ -4357,30 +3744,6 @@ "retry" ] }, - "p-try@2.2.0": { - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" - }, - "package-manager-detector@1.8.0": { - "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==" - }, - "parent-module@1.0.1": { - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dependencies": [ - "callsites" - ] - }, - "parse-json@5.2.0": { - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dependencies": [ - "@babel/code-frame", - "error-ex", - "json-parse-even-better-errors", - "lines-and-columns" - ] - }, - "parse-ms@4.0.0": { - "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==" - }, "parse5-htmlparser2-tree-adapter@7.1.0": { "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", "dependencies": [ @@ -4406,24 +3769,12 @@ "partial-json@0.1.7": { "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==" }, - "path-browserify@1.0.1": { - "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" - }, - "path-exists@3.0.0": { - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==" - }, "path-expression-matcher@1.6.2": { "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==" }, "path-key@3.1.1": { "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" }, - "path-key@4.0.0": { - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==" - }, - "path-to-regexp@6.3.0": { - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" - }, "path-to-regexp@8.4.2": { "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" }, @@ -4483,9 +3834,6 @@ "picocolors@1.1.1": { "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, - "picomatch@2.3.2": { - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==" - }, "picomatch@4.0.7": { "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==" }, @@ -4545,23 +3893,10 @@ "pathe@2.0.3" ] }, - "pkg-up@3.1.0": { - "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", - "dependencies": [ - "find-up" - ] - }, "playwright-core@1.63.0": { "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "bin": true }, - "postcss-selector-parser@7.1.6": { - "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", - "dependencies": [ - "cssesc", - "util-deprecate" - ] - }, "postcss@8.5.28": { "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dependencies": [ @@ -4588,32 +3923,13 @@ "postgres@3.4.9": { "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" }, - "powershell-utils@0.1.0": { - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==" - }, - "powershell-utils@0.2.1": { - "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==" - }, "prettier@3.9.9": { "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "bin": true }, - "pretty-ms@9.3.1": { - "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", - "dependencies": [ - "parse-ms" - ] - }, "process-warning@5.1.0": { "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==" }, - "prompts@2.4.2": { - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dependencies": [ - "kleur@3.0.3", - "sisteransi" - ] - }, "protobufjs@7.6.6": { "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", "dependencies": [ @@ -4657,6 +3973,15 @@ "pvutils@1.2.0": { "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==" }, + "qr-code-styling@1.9.2": { + "integrity": "sha512-RgJaZJ1/RrXJ6N0j7a+pdw3zMBmzZU4VN2dtAZf8ZggCfRB5stEQ3IoDNGaNhYY3nnZKYlYSLl5YkfWN5dPutg==", + "dependencies": [ + "qrcode-generator" + ] + }, + "qrcode-generator@1.5.2": { + "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==" + }, "qs@6.16.0": { "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dependencies": [ @@ -4664,9 +3989,6 @@ "side-channel" ] }, - "queue-microtask@1.2.3": { - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" - }, "quick-format-unescaped@4.0.4": { "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, @@ -4769,6 +4091,23 @@ "scheduler" ] }, + "react-is@19.3.0": { + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==" + }, + "react-redux@9.3.0_@types+react@19.3.0_react@19.3.0_redux@5.0.1": { + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "dependencies": [ + "@types/react", + "@types/use-sync-external-store", + "react", + "redux", + "use-sync-external-store" + ], + "optionalPeers": [ + "@types/react", + "redux" + ] + }, "react-refresh@0.14.2": { "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" }, @@ -4805,7 +4144,7 @@ "cookie@1.1.1", "react", "react-dom", - "set-cookie-parser@2.7.2" + "set-cookie-parser" ], "optionalPeers": [ "react-dom" @@ -4835,44 +4174,46 @@ "real-require@1.0.0": { "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==" }, - "recast@0.23.21": { - "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", + "recharts@3.10.1_react@19.3.0_react-dom@19.3.0_react-is@19.3.0_@types+react@19.3.0_redux@5.0.1": { + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", "dependencies": [ - "ast-types", - "esprima", - "source-map", + "@reduxjs/toolkit", + "clsx", + "decimal.js-light", + "es-toolkit", + "eventemitter3", + "immer", + "react", + "react-dom", + "react-is", + "react-redux", + "reselect", "tiny-invariant", - "tslib@2.8.1" + "use-sync-external-store", + "victory-vendor" ] }, + "redux-thunk@3.1.0_redux@5.0.1": { + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "dependencies": [ + "redux" + ] + }, + "redux@5.0.1": { + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, "reflect-metadata@0.2.2": { "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, - "require-directory@2.1.1": { - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" - }, "require-from-string@2.0.2": { "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, - "resolve-from@4.0.0": { - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" - }, - "restore-cursor@5.1.0": { - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dependencies": [ - "onetime@7.0.0", - "signal-exit@4.1.0" - ] + "reselect@5.2.0": { + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==" }, "retry@0.13.1": { "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==" }, - "rettime@0.11.12": { - "integrity": "sha512-5ExtR1ppiDBQkVoPw5NAqdbfH4Iq2VOmQKHMtUoaFxA9bo2krzFYBtyq6K40xxbYp+2w/YOr6D+1ToF6tTN53A==" - }, - "reusify@1.1.0": { - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" - }, "rollup@4.63.5": { "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", "dependencies": [ @@ -4916,16 +4257,7 @@ "depd", "is-promise", "parseurl", - "path-to-regexp@8.4.2" - ] - }, - "run-applescript@7.1.0": { - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==" - }, - "run-parallel@1.2.0": { - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dependencies": [ - "queue-microtask" + "path-to-regexp" ] }, "safe-buffer@5.2.1": { @@ -4979,53 +4311,9 @@ "set-cookie-parser@2.7.2": { "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" }, - "set-cookie-parser@3.1.2": { - "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==" - }, "setprototypeof@1.2.0": { "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, - "shadcn@3.8.5_@types+node@24.19.0_typescript@7.0.2": { - "integrity": "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA==", - "dependencies": [ - "@antfu/ni", - "@babel/core", - "@babel/parser", - "@babel/plugin-transform-typescript", - "@babel/preset-typescript", - "@dotenvx/dotenvx", - "@modelcontextprotocol/sdk", - "@types/validate-npm-package-name", - "browserslist", - "commander@14.0.3", - "cosmiconfig", - "dedent", - "deepmerge", - "diff", - "execa@9.6.1", - "fast-glob", - "fs-extra", - "fuzzysort", - "https-proxy-agent@7.0.6", - "kleur@4.1.5", - "msw", - "node-fetch", - "open@11.0.4", - "ora", - "postcss", - "postcss-selector-parser", - "prompts", - "recast", - "stringify-object", - "tailwind-merge", - "ts-morph", - "tsconfig-paths", - "validate-npm-package-name", - "zod@3.25.76", - "zod-to-json-schema" - ], - "bin": true - }, "shebang-command@2.0.0": { "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dependencies": [ @@ -5071,15 +4359,6 @@ "side-channel-weakmap" ] }, - "signal-exit@3.0.7": { - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, - "signal-exit@4.1.0": { - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" - }, - "sisteransi@1.0.5": { - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" - }, "sonic-boom@4.2.1": { "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", "dependencies": [ @@ -5100,9 +4379,6 @@ "source-map-js@1.2.1": { "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, - "source-map@0.6.1": { - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - }, "split2@4.2.0": { "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" }, @@ -5119,57 +4395,6 @@ "std-env@4.2.0": { "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==" }, - "stdin-discarder@0.2.2": { - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==" - }, - "strict-event-emitter@0.5.1": { - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==" - }, - "string-width@4.2.3": { - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": [ - "emoji-regex@8.0.0", - "is-fullwidth-code-point", - "strip-ansi@6.0.1" - ] - }, - "string-width@7.2.0": { - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dependencies": [ - "emoji-regex@10.6.0", - "get-east-asian-width", - "strip-ansi@7.2.0" - ] - }, - "stringify-object@5.0.0": { - "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", - "dependencies": [ - "get-own-enumerable-keys", - "is-obj@3.0.0", - "is-regexp" - ] - }, - "strip-ansi@6.0.1": { - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": [ - "ansi-regex@5.0.1" - ] - }, - "strip-ansi@7.2.0": { - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dependencies": [ - "ansi-regex@6.4.0" - ] - }, - "strip-bom@3.0.0": { - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==" - }, - "strip-final-newline@2.0.0": { - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==" - }, - "strip-final-newline@4.0.0": { - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==" - }, "strip-json-comments@5.0.3": { "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==" }, @@ -5179,14 +4404,6 @@ "anynum" ] }, - "systeminformation@5.33.13": { - "integrity": "sha512-GtyakkchTQKymM1WeAB652zi3rM1q00RKdc+jZe9KGstuc/mWUwAnQGN/RE5gVYvHIR8xYOUIUD9irVKDtA0wA==", - "os": ["darwin", "linux", "win32", "freebsd", "openbsd", "netbsd", "sunos", "android"], - "bin": true - }, - "tagged-tag@1.0.0": { - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==" - }, "tailwind-merge@3.7.0": { "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==" }, @@ -5215,44 +4432,15 @@ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dependencies": [ "fdir", - "picomatch@4.0.7" - ] - }, - "tldts-core@7.4.15": { - "integrity": "sha512-ERuv0p98XgSzmlSLJr8vDNxX+uATGInlN97V3R+JpYWaSqn5IG3ewWgCwczk4bkOpgth/o8Nt5FOi4B4w0n/1A==" - }, - "tldts@7.4.15": { - "integrity": "sha512-SJVBeHOxDbNoq14CvpAoA2mLEWdbldGK8nR+yumpjY5nrlZxOflcA7p1Qj1gbTGmbu9DY9qLj/bENSWhBzjxRQ==", - "dependencies": [ - "tldts-core" - ], - "bin": true - }, - "to-regex-range@5.0.1": { - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dependencies": [ - "is-number" + "picomatch" ] }, "toidentifier@1.0.1": { "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, - "tough-cookie@6.0.2": { - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dependencies": [ - "tldts" - ] - }, "ts-algebra@2.0.0": { "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==" }, - "ts-morph@26.0.0": { - "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", - "dependencies": [ - "@ts-morph/common", - "code-block-writer" - ] - }, "tsconfck@3.1.6_typescript@7.0.2": { "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", "dependencies": [ @@ -5264,14 +4452,6 @@ "deprecated": true, "bin": true }, - "tsconfig-paths@4.2.0": { - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dependencies": [ - "json5", - "minimist", - "strip-bom" - ] - }, "tslib@1.14.1": { "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, @@ -5287,12 +4467,6 @@ "tw-animate-css@1.4.0": { "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==" }, - "type-fest@5.10.0": { - "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", - "dependencies": [ - "tagged-tag" - ] - }, "type-is@2.1.0": { "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dependencies": [ @@ -5336,18 +4510,9 @@ "undici@7.30.0": { "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==" }, - "unicorn-magic@0.3.0": { - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==" - }, - "universalify@2.0.1": { - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==" - }, "unpipe@1.0.0": { "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, - "until-async@3.0.2": { - "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==" - }, "update-browserslist-db@1.3.3_browserslist@4.29.1": { "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dependencies": [ @@ -5380,8 +4545,11 @@ "@types/react" ] }, - "util-deprecate@1.0.2": { - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "use-sync-external-store@1.7.0_react@19.3.0": { + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "dependencies": [ + "react" + ] }, "valibot@1.5.0_typescript@7.0.2": { "integrity": "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==", @@ -5392,12 +4560,28 @@ "typescript" ] }, - "validate-npm-package-name@7.0.2": { - "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==" - }, "vary@1.1.2": { "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, + "victory-vendor@37.3.6": { + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "dependencies": [ + "@types/d3-array", + "@types/d3-ease", + "@types/d3-interpolate", + "@types/d3-scale", + "@types/d3-shape", + "@types/d3-time", + "@types/d3-timer", + "d3-array", + "d3-ease", + "d3-interpolate", + "d3-scale", + "d3-shape", + "d3-time", + "d3-timer" + ] + }, "vite-node@3.2.4_@types+node@24.19.0_yaml@2.9.1": { "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dependencies": [ @@ -5427,7 +4611,7 @@ "@types/node", "esbuild", "fdir", - "picomatch@4.0.7", + "picomatch", "postcss", "rollup", "tinyglobby", @@ -5453,7 +4637,7 @@ "expect-type", "magic-string@1.4.2", "obug", - "picomatch@4.0.7", + "picomatch", "std-env", "tinybench", "tinyexec", @@ -5482,14 +4666,7 @@ "which@2.0.2": { "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dependencies": [ - "isexe@2.0.0" - ], - "bin": true - }, - "which@4.0.0": { - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "dependencies": [ - "isexe@3.1.5" + "isexe" ], "bin": true }, @@ -5497,27 +4674,12 @@ "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==", "bin": true }, - "wrap-ansi@7.0.0": { - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dependencies": [ - "ansi-styles", - "string-width@4.2.3", - "strip-ansi@6.0.1" - ] - }, "wrappy@1.0.2": { "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws@8.21.3": { "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==" }, - "wsl-utils@1.0.0": { - "integrity": "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==", - "dependencies": [ - "is-wsl@3.1.1", - "powershell-utils@0.1.0" - ] - }, "xml-naming@0.3.0": { "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==" }, @@ -5527,9 +4689,6 @@ "xtend@4.0.2": { "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" }, - "y18n@5.0.8": { - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" - }, "yallist@3.1.1": { "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, @@ -5541,39 +4700,12 @@ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "bin": true }, - "yargs-parser@21.1.1": { - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" - }, - "yargs@17.7.3": { - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", - "dependencies": [ - "cliui", - "escalade", - "get-caller-file", - "require-directory", - "string-width@4.2.3", - "y18n", - "yargs-parser" - ] - }, - "yocto-spinner@1.2.2": { - "integrity": "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==", - "dependencies": [ - "yoctocolors" - ] - }, - "yoctocolors@2.2.0": { - "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==" - }, "zod-to-json-schema@3.25.2_zod@4.6.5": { "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "dependencies": [ - "zod@4.6.5" + "zod" ] }, - "zod@3.25.76": { - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" - }, "zod@4.6.5": { "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==" } @@ -5619,6 +4751,7 @@ "npm:@fontsource-variable/space-grotesk@^5.2.10", "npm:@react-router/dev@7.12.0", "npm:@react-router/node@7.12.0", + "npm:@simplewebauthn/browser@^13.3.0", "npm:@tailwindcss/vite@^4.1.13", "npm:@tanstack/react-query@^5.90.21", "npm:@types/react-dom@^19.2.3", @@ -5629,12 +4762,13 @@ "npm:isbot@^5.1.31", "npm:lucide-react@0.576", "npm:next-themes@~0.4.6", + "npm:qr-code-styling@^1.9.2", "npm:radix-ui@^1.4.3", "npm:react-day-picker@^10.0.1", "npm:react-dom@^19.2.4", "npm:react-router@7.12.0", "npm:react@^19.2.4", - "npm:shadcn@^3.8.5", + "npm:recharts@^3.10.1", "npm:sonner@^2.0.7", "npm:tailwind-merge@^3.5.0", "npm:tailwindcss@^4.1.13", diff --git a/docs/design/glossary.md b/docs/design/glossary.md index 8528ce7e..d0d0b36d 100644 --- a/docs/design/glossary.md +++ b/docs/design/glossary.md @@ -13,7 +13,10 @@ | RuleGroup | 规则组;条件叫"关键字"(内置)/"自定义条件";只有一条规则的组在规则页以"偏好"标签显示 | 偏好标签、过滤规则、规则集 | | Subscription | 订阅 | 追剧、关注 | | Site / SiteAccount | 站点 / 站点账号 | PT 源 | -| FeedSource | RSS;新建叫"新建 RSS",地址叫"RSS 地址" | Feed、RSS 源、订阅源、来源健康、RSS 订阅 | +| Site.kind(IA-2) | 站点类型:PT 站(`pt`)/ 公开 BT 站(`public_bt`)/ 自定义 RSS 源(`rss_feed`)/ Torznab 索引器(`torznab`,只定义结构,界面显示"未支持") | 站点种类、来源类型、索引器(单用) | +| Site capabilities | 获取方式:搜索 / 列表抓取 / RSS / 详情页 / 登录 / 账号数据 | 能力(界面)、功能 | +| 侧栏「站点 / 订阅 / 规则」一组 | 获取(组名常量 `ACQUIRE_GROUP_TITLE`,`components/console/nav.ts`,改名只改这一处) | 资源(旧组名) | +| FeedSource | RSS;新建叫"新建 RSS",地址叫"RSS 地址";RSS 属于站点,在站点详情的「RSS」里管理(IA-2 起没有独立 RSS 页) | Feed、订阅源、来源健康、RSS 订阅、RSS 页 | | 站点列表/搜索结果的一次抓取 | 抓取;时间写"上次抓取",按钮"刷新";站点实时搜索叫"去站点搜" | 快照、实时补查 | | SourceRelease | 资源 | 来源资源、站点资源、种子(种子只指 .torrent 文件) | | 资源按收录版本评估后 | 不单独命名;说"可下载的资源" | 候选(只在代码与给模型的说明里) | diff --git a/docs/plan/00-first-version-plan.md b/docs/plan/00-first-version-plan.md index a274b8f3..04441379 100644 --- a/docs/plan/00-first-version-plan.md +++ b/docs/plan/00-first-version-plan.md @@ -195,6 +195,8 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏 - **v0.3.0(2026-09-29 打包)VER-5 只读核对问题 → FIX-7**:中——智能体旧会话汇报直出 UUID/能力名/原因码(新提示词只管新会话,展示层要兜底);进度行"步骤 6/6 全部完成"与展开"已完成 4/6(跳过 2)"不一致;"派出了 5 个子会话"实际 2 个(汇报数字要以系统事实为准);巡检目标原样显示 `{{now}}`、`{{openDecisions}}` 占位符;汇报称"下载器不在系统内"而 qB 已配置(查能力返回)。低——种子详情"未关联本地作品(unidentifiable)"直出码、"解析结果"JSON 默认展开;srt 被标"主文件 是";版本行悬浮按钮盖住大小/时间;作品页"还没有收录版本"与"4 个版本"易混;会话标题直出 ISO 时间与 download_failed。LIBV-1 回填已对用户库执行(5 个资产)。 - 状态(分支 fix7):根因与改法——① 汇报兜底:`components/agent/report-humanize.ts` 展示时把 UUID(按本会话工具结果与子会话配名字,认不出缩成"…")、能力 ID(换能力标题)、原因码(`reasons.ts` 补了能力错误、下载盘判定、站点登录、待处理类型,`cache:` 这类前缀按后半段查)换成人话,原文进「正文原文」折叠区,历史不改;② 概况条把跳过算进分子,步骤清单只数完成——统一为 `stepProgress`:「步骤 4/6 · 完成 4 · 跳过 2」;③ "派出了 5 个子会话"是进度行把 3 次被预算拒绝的 `agent.spawn` 也算进去(产品计数错):失败的调用不计入动作计数,实际结果行加「派出 N 个子会话」(会话树),汇报格式加"数量以工具返回为准";④ 巡检执行时占位符已正确替换(补单测),界面显示的是模板本身:改成名称标签,会话标题里的 ISO 时间转「9月28日 18:53」,`download_failed` 等码转人话;⑤ 该会话运行时 qB 尚未添加(会话 12:06,qB 12:09 建),但根因是智能体根本没有查下载器的能力(`site.list` 不含下载器),新增只读能力 `downloader.list`(下载器与媒体服务器、连通状态、管理的任务数);⑥ 种子详情按识别状态说人话、解析结果 JSON 跟随技术信息默认收起;"主文件"只认视频(整理把字幕登记成独立资产时其位置 is_primary 为真,文件列表接口按扩展名收紧);悬浮操作只在鼠标悬浮时出现,选中行不再常驻遮挡;作品页空收录版本写「没设收录要求」,版本行写「库里有 N 个版本」。追加:文件管理目录树改成与主面板同款圆角卡片(头部「目录」+ 折叠钮与工具栏第一行对齐,拖宽与折叠记忆保留)。截图 `apps/web/test/screenshots/fix7/`。 - **导航与站点类型(IA-2,用户 2026-09-29)**:「任务」归入系统组;站点、订阅、规则合为一组(组名「获取」,用户原话"都算做规则",组名可再改);独立的 RSS 页取消——RSS 属于站点,在站点详情里管理;"站点"增加类型,PT 站之外的下载来源(公开 BT、自定义 RSS 源,以后 Torznab/Jackett/Prowlarr 等)都作为一种站点类型,各类型声明自己支持的获取方式(搜索 / RSS / 详情 / 登录)。 + - 状态(ia2 分支):已实现。导航 4 组(概览:首页 / 计划 / 智能体会话;获取:站点 / 订阅 / 规则;整理;系统:任务 / 设置 / 审计 / 运行状态),组名常量 `ACQUIRE_GROUP_TITLE`;`/console/sources*` 只做跳转。站点 `kind`(pt / public_bt / rss_feed / torznab)+ `capabilities`(search / list / rss / detail / login / userinfo,类型 ∩ 站点定义,contracts `SITE_KINDS` / `siteCapabilities`);torznab 只定义 `TorznabSiteConfig`,不能添加。服务端调度 / 搜索 / 登录按 capabilities 判断(`sources-identity/site-kinds.ts`)。迁移 0055:孤立 RSS 按主机归入自定义 RSS 源站点;用户库只读核对孤立 RSS 0 条。详见 `docs/runbook/sites.md`「站点类型与获取方式」。 + - 偏离与补充:获取方式比任务单多一项 `list`(列表抓取 / 快照,调度需要区分);"计划"仍在概览组(用户只提了任务);公开 BT 站目前没有站点定义,实际只有 RSS(有定义后自动获得搜索 / 列表);`POST /feed-sources` 不带站点时自动归入同主机的自定义 RSS 源(兼容旧调用方,而不是 400)。截图 `apps/web/test/screenshots/ia2/`(不进 git)。 - **浏览器 UA 与指纹(BR-4,用户 2026-09-30:不着急,先研究明白)**:现状 HTTP 默认 Windows Chrome 140 UA、浏览器是 camofox(Firefox 内核)自生成 UA,同一站点账号 Cookie 两边互注 → UA 不一致。方向:每个站点账号一套固定 UA(跟随 camofox 的 Firefox)+ 固定指纹,浏览器与 HTTP 共用,不轮换,浏览器升级时同步。先做只读调研(camofox 能否按会话指定/持久化指纹、各站会话与 UA 绑定情况、MP 做法),出报告再定实现。 - 调研见 `docs/design/browser-ua-research.md`。用户决定(2026-09-30):对外伪装 **Windows**;全实例**一套固定指纹**(插件 `browser:launching` 首次生成落盘、之后复用);浏览器与 HTTP **按站点统一出口 IP**;站点适配器 / RSS 目前发 `Deno/2.9.7` UA 的问题**和 BR-4 一起做**(不单独先修)。cf_clearance 等 CF Cookie 回写时标"仅浏览器"。平滑迁移:下次重新登录 / 浏览器回写 Cookie 时原子切换 Cookie+UA。 - **下载计划并入下载面板(DL-3,用户 2026-09-30 同意)**:acquisition_plan 后台保留(批准绑定、预算/空间预占、防重复、升级/换源追踪);界面去掉「下载计划」页签——等批准/等空间/等预算的计划作为「进行中」行显示(需操作的同时进待处理);已提交的计划并入对应下载任务,详情加「为什么下这个」;失败/过期进下载历史,细节进技术信息;用户可见处不再出现"下载计划",统一说"下载"。 diff --git a/docs/runbook/sites.md b/docs/runbook/sites.md index e2523045..47b71e15 100644 --- a/docs/runbook/sites.md +++ b/docs/runbook/sites.md @@ -2,6 +2,19 @@ 规格:`docs/design/site-center.md`。实现:`apps/server/src/modules/sources-identity/service/account.ts`(凭证、刷新登录、用户数据、健康检测、删除、定时任务)、`routes/sources.ts`、`runtime.ts`(凭证注入、站点默认、代理);账密登录 `packages/adapters/src/sites/login/nexusphp.ts`;迁移 `0031_site_center`。 +## 站点类型与获取方式(IA-2,迁移 `0055_site_kind`) + +- `site.kind`:`pt`(PT 站,从站点定义库选)/ `public_bt`(公开 BT 站,不登录)/ `rss_feed`(自定义 RSS 源,只有 RSS)/ `torznab`(Jackett、Prowlarr 等的 Torznab 接口,**只定义结构**:`TorznabSiteConfig{apiUrl, apiKeySecretRef, indexerId, categories}`,不能添加、不参与调度)。定义在 `packages/contracts/src/sources.ts` 的 `SITE_KINDS`。 +- 获取方式 `capabilities`:`search / list / rss / detail / login / userinfo`。站点实际可用 = 类型允许的 ∩ 站点定义实现的(`siteCapabilities`):RSS 走通用解析器,不依赖定义;搜索 / 列表 / 详情看定义的 `umt.capabilities`;登录与账号数据要有定义。`adapter = generic_rss` 的站点不查定义。`GET /sites` 每项带派生字段 `capabilities`;`site.list`(智能体)同样带 `kind` 与 `capabilities`。 +- 服务端统一按 capabilities 判断(`modules/sources-identity/site-kinds.ts`): + - 调度:`sites.snapshot` 只排能 `list` 的站点;`feeds.poll` 只排 `kind` 能抓 RSS 的站点(`RSS_KINDS`);`sites.user_data` 只扫能登录的类型(`LOGIN_KINDS`);`sites.health_check` 跳过只剩 RSS 的站点。 + - 搜索:参与搜索的站点先过滤能 `search` 的(自定义 RSS 源不出现在搜索站点选择里);实时搜索(`live_search`)/ 列表快照 / 补漏在不支持的站点上直接跳过(`reason: unsupported`,不请求站点)。RSS 疑似缺口只在站点能 `list` 时排补漏。 + - API:`POST /sites/{id}/refresh-login`、`PUT /sites/{id}/credentials` 要 `login`,`POST /sites/{id}/snapshot` 要 `list`;不支持时 400「「名称」是自定义 RSS 源,不支持登录」。 +- `POST /sites`:`kind` 缺省为"注册表里有定义 = pt,否则 rss_feed"(兼容旧的 generic_rss 登记)。非 PT 站 `adapter` 必须是 `generic_rss`,`key` 可省(按地址主机生成 `rss-<主机>`,重名加序号),可带 `rssUrl` 在同一事务里建 RSS(名称「站点名 RSS」,轮询 30 分钟);非 PT 站 `searchDefault=false`、`snapshotEnabled=false`。`kind` 添加后不改(`SiteUpdate` 不含)。 +- **RSS 属于站点**:`POST /feed-sources` 不带 `siteId` 时归入同主机(`https://<主机>`)的自定义 RSS 源站点,没有就建一个;`PATCH /feed-sources/{id}` 不能把 `siteId` 改成 null(400)。secret 名带随机后缀,同站多个「站点名 RSS」不再撞名。 +- 迁移 0055:`adapter = generic_rss` 的存量站点 → `rss_feed`,其余 → `pt`;`site_id` 为空的 RSS 按地址主机归入新建的自定义 RSS 源站点(同主机一个),并清掉游标窗口与 ETag / Last-Modified,下次抓取把条目重新写成资源(无站点时观察记录只标 `ignored/no_site`,没有资源)。用户库 2026-09-30 只读核对:孤立 RSS 0 条(共 1 条 RSS,1 个 nexusphp 站点),迁移只加列。 +- 界面:侧栏「获取」组(站点 / 订阅 / 规则);站点列表按类型筛选,非 PT 站卡片只显示 RSS 数与上次成功;站点详情按类型显示 Tab(自定义 RSS 源:概览 / 资源 / RSS);添加站点第一步选类型。旧 `/console/sources*` 跳转:`?siteId=` → 该站 RSS Tab,`?feed=` → 查所属站点后打开该 RSS,`?new=1` → 添加自定义 RSS 源,其余 → 站点列表。 + ## 端点 | 端点 | scope | 说明 | @@ -52,20 +65,20 @@ ## Feed(RSS)添加 -- **站点内添加**(站点详情「快照与 Feed」→ 新建 Feed,`components/sites/site-feed-dialog.tsx`):只填地址、用途(发现 = `generic` / 追踪 = `search` / 促销 = `promotion`,缺省发现)、可选备注(即名称)。地址输入时下方预览脱敏后的显示值;域名与站点不符给黄色提示但允许保存。保存后立即 `POST /feed-sources/{id}/fetch` 一次,对话框每 2 s 轮询 `GET /feed-sources/{id}`(最多 90 s),显示首抓条目数(游标窗口 `lastGuidSet` 长度)或 `lastError`。 +- **站点内添加**(站点详情「RSS」→ 新建 RSS,`components/sites/site-feed-dialog.tsx`):只填地址、用途(发现 = `generic` / 追踪 = `search` / 促销 = `promotion`,缺省发现)、可选备注(即名称)。地址输入时下方预览脱敏后的显示值;域名与站点不符给黄色提示但允许保存。保存后立即 `POST /feed-sources/{id}/fetch` 一次,对话框每 2 s 轮询 `GET /feed-sources/{id}`(最多 90 s),显示首抓条目数(游标窗口 `lastGuidSet` 长度)或 `lastError`。 - **`POST /feed-sources` 带 `siteId` 时的推断**(`service/feeds.ts feedDefaultsForSite`):`siteAccountId` 缺省取该站启用账号,优先带 passkey 的,再取最早建的;`parserVersion` 记为 `rss@1;<站点定义 umt.version>`(解析仍走通用 RSS 解析器,版本只作追溯);`name` 缺省「站点名 RSS」;`pollIntervalSec` 缺省 1800(站点请求克制;不挂站点的 Feed 仍缺省 900)。地址主机键(去 `www.`、取最后两级,IP 原样)与站点 `baseUrl` / 定义 `domain` 都不一致时,响应附 `warnings[]`(只在创建响应出现)。`name` 现在可省,不挂站点时缺省为地址主机名。 - **站点 RSS 条目规范化(HH-2,`packages/adapters/src/sites/feed-item.ts normalizeSiteFeedItem`)**:NexusPHP `torrentrss.php` 的标题是 `[分类]种子名[副标题][大小][上传者]`(憨憨实测 `[音乐]Travis - L.A. Times (2024) - WEB-DL - 24bit ALAC-HHWEB[Travis - L.A. Times | ALAC | …][697.64 MB]`)。挂站点的 Feed 入库时按站点定义拆开:前缀只认定义里登记过的分类名(`umt.categoryNames` / `category.*.desc`),换成站点分类 ID 写 `category_raw`(与列表快照一致,资源页分类筛选可用);尾部配平地剥最多 3 段方括号,只有出现大小段时才把它前面一段当副标题、后面一段当上传者;去掉「[新]」。大小优先用 enclosure `length`(精确字节),没有才用标题里的显示值。`source_release.raw.rss` 记 `rawTitle / categoryName / mediaType(movie·tv·music·null) / uploader / v`。不改标题解析器(`[分类]` 前缀引起的误识别归 ID-2)。 - **与列表快照合并**:同站同资源 ID 一行。快照见过的行(`raw._site`)标题 / 副标题 / 分类 / 做种 / 促销 / H&R 以快照为准,RSS 只补空缺、更新 `raw.rss`;快照没见过的行,RSS 内容变化时用新值。快照后到时照常覆盖 RSS 值。 - **旧行补规范化**:规范化上线前写入的 RSS 行(`raw.rss.v` 缺失)在该站任一 Feed 下次抓取时按原标题重算(每次最多 500 行,不请求站点);标题变了且未锁定的重新发 `release.observed`(幂等键 `…:rss-norm-1`)。 - **资源列表展示**:列表按 `coalesce(发布时间, 首次发现)` 倒序,和字段是否齐全无关。RSS 独有的条目(憨憨的音乐区不在 `torrents.php` 快照里)没有做种 / 下载 / 完成、H&R,列表显示「—」,不挂「H&R 未知」徽章(详情抽屉的 H&R 字段仍写「未知」);分类名取快照分类名 → `raw.rss.categoryName` → 分类 ID。 -- **来源页任意 RSS**(`components/workbench/feed-form.tsx`):完整表单不变;新建时地址域名匹配已登记站点会自动选中该站和账号并提示「按地址域名关联到 X · 不关联」,手动改过站点后不再自动改。 +- **编辑 RSS**(`components/workbench/feed-form.tsx`,站点 RSS Tab 的详情 → 编辑):完整表单;站点只能换成另一个能抓 RSS 的站点,不能"不关联"。RSS 详情里有「最近条目」(`GET /observations?feedId=`,新的在前 20 条)。IA-2 前的独立 RSS 页(`/console/sources`)已取消,只做跳转。 ## 定时任务 | key | 名称 | 默认 | 行为 | |---|---|---|---| | `sites.user_data` | 站点数据刷新 | 每 6 小时,一个周期后首跑 | 启用站点里 `status=active` 的非 passkey 账号逐个入队 `refreshSiteAccount{mode:"userData"}`;会话失效的账密账号自动重登一次;只读对账模式跳过 | -| `sites.health_check` | 站点健康检测 | 每小时扫描,一个周期后首跑 | 启用站点(非 generic_rss)逐个同步检测;距上次检测(手动或定时)不足该站健康检测间隔(缺省 6 小时)的跳过;可在定时任务页关闭 | +| `sites.health_check` | 站点健康检测 | 每小时扫描,一个周期后首跑 | 启用站点(有站点适配器、不只 RSS 的)逐个同步检测;距上次检测(手动或定时)不足该站健康检测间隔(缺省 6 小时)的跳过;可在定时任务页关闭 | 注:定时任务 key 只允许小写(注册表校验),所以规格里的 `sites.userData` / `sites.healthCheck` 落为下划线形式。 diff --git a/packages/agent/src/system-prompt.ts b/packages/agent/src/system-prompt.ts index e29d49cb..e3cb2e2e 100644 --- a/packages/agent/src/system-prompt.ts +++ b/packages/agent/src/system-prompt.ts @@ -86,6 +86,7 @@ export function buildSystemPrompt({ '参数按工具 schema 的类型填写:ID 类参数一律是字符串(如 externalId:"693134",不是数字 693134);不要传 schema 里没有的字段。', "业务任务 ID 用 task.inspect,下载任务 ID 或种子 hash 用 download.inspect,两者不要混用。", "下载器、媒体服务器有没有接入、是否正常,用 downloader.list 查;站点列表里没有下载器不代表没配置。", + "站点分类型:PT 站、公开 BT 站、自定义 RSS 源;RSS 属于站点(在站点详情的 RSS 里管理),没有单独的 RSS 页。能不能搜索、抓列表、登录看 site.list 返回的获取方式,不支持的不要去试。", "思考、步骤说明和汇报都用中文;引用文件名、原站标题时保持原文。", "工具结果里出现【预算提醒】时立刻停止探查,直接用中文写小结;被预算或中止打断时,汇报只写中文小结(已完成什么、证据、还缺什么),不要复述中间过程或下一步打算。", ]; diff --git a/packages/agent/test/system-prompt.test.ts b/packages/agent/test/system-prompt.test.ts index e6275850..89826390 100644 --- a/packages/agent/test/system-prompt.test.ts +++ b/packages/agent/test/system-prompt.test.ts @@ -73,3 +73,12 @@ describe("工作方式(AGUI-3)", () => { expect(interactionRules({ options: true, proposeFirst: true }).join("\n")).toContain("本会话要求先看方案再执行"); }); }); + +describe("站点类型(IA-2)", () => { + it("RSS 属于站点,没有单独的 RSS 页;按获取方式判断能不能搜索 / 登录", () => { + const p = userPrompt(); + expect(p).toContain("RSS 属于站点"); + expect(p).toContain("没有单独的 RSS 页"); + expect(p).toContain("自定义 RSS 源"); + }); +}); diff --git a/packages/contracts/src/capabilities-io.ts b/packages/contracts/src/capabilities-io.ts index d98e3e7e..fc53576f 100644 --- a/packages/contracts/src/capabilities-io.ts +++ b/packages/contracts/src/capabilities-io.ts @@ -25,8 +25,10 @@ import { SiteAccountUserData, SiteAdapter, SiteAuthKind, + SiteCapability, SiteHealth, SiteHealthResult, + SiteKind, SiteLastHealth, SiteLoginResult, SiteUserDataPoint, @@ -461,6 +463,10 @@ export const SiteBrief = z.object({ id: Id, key: z.string(), name: z.string(), + /** 站点类型:pt / public_bt / rss_feed / torznab */ + kind: SiteKind, + /** 实际可用的获取方式(search / list / rss / detail / login / userinfo) */ + capabilities: z.array(SiteCapability), adapter: SiteAdapter, baseUrl: z.string(), enabled: z.boolean(), diff --git a/packages/contracts/src/capability.ts b/packages/contracts/src/capability.ts index 6d3b60c2..eb91d484 100644 --- a/packages/contracts/src/capability.ts +++ b/packages/contracts/src/capability.ts @@ -370,7 +370,7 @@ export const CAPABILITY_CATALOG: Record "site.list": d( "site.list", "列出站点", - "列出已登记站点的健康、登录状态与账号摘要(不含凭证)。排查站点问题或确认站点可用时用", + "列出已登记站点的类型(PT 站 / 公开 BT 站 / 自定义 RSS 源)、可用的获取方式、健康、登录状态与账号摘要(不含凭证)。RSS 属于站点:自定义 RSS 源也是一种站点。排查站点或 RSS 问题、确认站点能不能搜索时用", "read", "SiteList", "read", diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts index 94622eee..81c5da88 100644 --- a/packages/contracts/src/sources.ts +++ b/packages/contracts/src/sources.ts @@ -8,6 +8,94 @@ import { QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts"; /** api = MP 专用 API spider(Haidan/HDDolby/RousiPro/SunnyPT/TNode/TorrentLeech/Yema) */ export const SiteAdapter = z.enum(["nexusphp", "mteam_api", "api", "generic_rss"]); export type SiteAdapter = z.infer; + +/** + * 站点类型(IA-2):PT 站之外的下载来源都作为一种站点类型。类型声明它可能支持的获取方式, + * 站点实际可用的方式 = 类型允许的 ∩ 适配器(站点定义)实现的,见 siteCapabilities。 + * torznab 只定义结构(Jackett / Prowlarr 的 Torznab 接口),尚未实现:不能添加,也不参与调度。 + */ +export const SiteKind = z.enum(["pt", "public_bt", "rss_feed", "torznab"]); +export type SiteKind = z.infer; +/** 获取方式:搜索 / 列表抓取 / RSS / 详情页 / 登录 / 账号数据(上传量、分享率、等级) */ +export const SiteCapability = z.enum(["search", "list", "rss", "detail", "login", "userinfo"]); +export type SiteCapability = z.infer; + +export interface SiteKindSpec { + /** 界面名 */ + label: string; + /** 添加站点第一步的一句说明 */ + description: string; + /** 这类站点可能支持的获取方式 */ + capabilities: readonly SiteCapability[]; + /** false = 只定义了结构,还不能添加 */ + implemented: boolean; + /** 需要从站点定义库里选(PT 站);否则只填名称和地址 */ + fromDefinition: boolean; +} + +export const SITE_KINDS: Record = { + pt: { + label: "PT 站", + description: "需要账号的私有站。从站点定义里选,支持搜索、列表抓取、RSS、登录和账号数据", + capabilities: ["search", "list", "rss", "detail", "login", "userinfo"], + implemented: true, + fromDefinition: true, + }, + public_bt: { + label: "公开 BT 站", + description: "不用登录的公开站。填网址和 RSS 地址,按 RSS 获取资源", + capabilities: ["search", "list", "rss", "detail"], + implemented: true, + fromDefinition: false, + }, + rss_feed: { + label: "自定义 RSS 源", + description: "只有一个 RSS 地址的来源,不登录、不搜索", + capabilities: ["rss"], + implemented: true, + fromDefinition: false, + }, + torznab: { + label: "Torznab 索引器", + description: "Jackett、Prowlarr 等索引器的 Torznab 接口,还不支持", + capabilities: ["search", "rss"], + implemented: false, + fromDefinition: false, + }, +}; + +/** 站点定义(适配器)声明的能力;没有定义的站点传 null */ +export type SiteDefinitionCaps = { search: boolean; list: boolean; detail: boolean } | null; + +/** + * 站点实际可用的获取方式:类型允许的 ∩ 适配器实现的。RSS 走通用解析器,不依赖站点定义; + * 搜索 / 列表 / 详情要站点定义声明支持;登录与账号数据要有站点定义(没有定义就没有登录流程)。 + */ +export function siteCapabilities(kind: SiteKind, def: SiteDefinitionCaps): SiteCapability[] { + const spec = SITE_KINDS[kind]; + if (!spec.implemented) return []; + return spec.capabilities.filter((c) => { + if (c === "rss") return true; + if (!def) return false; + if (c === "search") return def.search; + if (c === "list") return def.list; + if (c === "detail") return def.detail; + return true; + }); +} + +/** + * Torznab 索引器的站点 config(只定义结构,未实现):API 地址、API Key 的 secret 引用、分类过滤。 + * 对照 MP 的 Jackett / Prowlarr 插件:按索引器登记,搜索走 `t=search`,RSS 走不带 q 的同一接口。 + */ +export const TorznabSiteConfig = z.object({ + apiUrl: z.url(), + apiKeySecretRef: z.uuid().nullable(), + indexerId: z.string().max(200).nullable(), + categories: z.array(z.number().int().nonnegative()).default([]), +}); +export type TorznabSiteConfig = z.infer; + export const SiteHealth = z.enum(["unknown", "healthy", "degraded", "failing", "disabled"]); export type SiteHealth = z.infer; export const CircuitState = z.enum(["closed", "open", "half_open"]); @@ -34,6 +122,10 @@ export type SiteLastHealth = z.infer; export const Site = EntityBase.extend({ key: z.string().regex(/^[a-z][a-z0-9_-]*$/), name: z.string().min(1), + /** 站点类型(IA-2) */ + kind: SiteKind, + /** 派生字段(非表列):实际可用的获取方式,见 siteCapabilities */ + capabilities: z.array(SiteCapability), adapter: SiteAdapter, baseUrl: z.url(), enabled: z.boolean(), @@ -58,6 +150,7 @@ export type Site = z.infer; export const SiteCreate = Site.pick({ key: true, name: true, + kind: true, adapter: true, baseUrl: true, enabled: true, @@ -67,17 +160,28 @@ export const SiteCreate = Site.pick({ snapshotIntervalSec: true, proxy: true, searchDefault: true, -}).partial({ - enabled: true, - rateLimit: true, - config: true, - snapshotEnabled: true, - snapshotIntervalSec: true, - proxy: true, - searchDefault: true, -}); +}) + .partial({ + key: true, + kind: true, + enabled: true, + rateLimit: true, + config: true, + snapshotEnabled: true, + snapshotIntervalSec: true, + proxy: true, + searchDefault: true, + }) + .extend({ + /** + * 非 PT 站(public_bt / rss_feed)添加时一并建的 RSS 地址(只写,存为 secret)。 + * key 缺省时按地址主机名生成(rss-<主机>,重名加序号)。 + */ + rssUrl: z.url().optional(), + }); export type SiteCreate = z.infer; -export const SiteUpdate = SiteCreate.omit({ key: true }).partial(); +/** 类型添加后不改(换类型 = 删了重加) */ +export const SiteUpdate = SiteCreate.omit({ key: true, kind: true, rssUrl: true }).partial(); export type SiteUpdate = z.infer; export const SiteAuthKind = z.enum(["cookie", "passkey", "api_key", "password"]); @@ -553,6 +657,8 @@ export type SiteRequestStats = z.infer; export const SiteDefinitionView = z.object({ key: z.string(), name: z.string(), + /** 定义对应的站点类型(现有定义都是 PT 站) */ + kind: SiteKind, adapter: SiteAdapter, baseUrl: z.string(), /** 定义是否随产品启用(未实测站点为 false) */ diff --git a/packages/contracts/test/db-sync.test.ts b/packages/contracts/test/db-sync.test.ts index 197081db..a6ee807e 100644 --- a/packages/contracts/test/db-sync.test.ts +++ b/packages/contracts/test/db-sync.test.ts @@ -77,8 +77,8 @@ const ROWS: Record Secret: { row: C.Secret, dbOnly: ["ciphertext", "nonce", "authTag"] }, ServerMapping: { row: C.ServerMapping }, Settings: { row: C.SettingEntry }, - // deleted_at:软删除只在服务端过滤(已删站点不出 API) - Site: { row: C.Site, dbOnly: ["deletedAt"] }, + // deleted_at:软删除只在服务端过滤(已删站点不出 API);capabilities 由类型 ∩ 站点定义算出(IA-2) + Site: { row: C.Site, dbOnly: ["deletedAt"], apiOnly: ["capabilities"] }, // secret 引用不外露,只给布尔 SiteUserData: { row: C.SiteUserData }, SiteAccount: { @@ -248,6 +248,7 @@ const ENUMS: Record = { SiteAdapter: C.SiteAdapter, SiteHealthStatus: C.SiteHealth, SiteCircuitState: C.CircuitState, + SiteKind: C.SiteKind, SiteAccountAuthKind: C.SiteAuthKind, SiteAccountStatus: C.SiteAccountStatus, SourceReleaseResolutionStatus: C.ResolutionStatus, diff --git a/packages/db/migrations/0055_site_kind.ts b/packages/db/migrations/0055_site_kind.ts new file mode 100644 index 00000000..4ceed7d5 --- /dev/null +++ b/packages/db/migrations/0055_site_kind.ts @@ -0,0 +1,100 @@ +import { type Kysely, sql } from "kysely"; +import { execDdl, oneOf } from "../src/migration-helpers.ts"; + +// IA-2 站点类型:site.kind(pt / public_bt / rss_feed / torznab),各类型声明支持的获取方式(contracts SITE_KINDS)。 +// - 存量:adapter = generic_rss 的站点(旧"自定义 RSS 站点:只抓 RSS,不登录")→ rss_feed;其余 → pt。 +// - RSS 归属站点:不属于任何站点的 feed_source 按地址主机名归入「自定义 RSS 源」站点(同主机一个站点, +// key = rss-<主机>,重名加序号);清掉这些 Feed 的游标窗口与条件请求头,下次抓取把窗口里的条目重新写成资源 +// (之前无站点时观察记录被标 ignored/no_site,没有进 source_release)。 +// down 只删列;迁移建的站点与 Feed 归属保留(对旧版本就是普通的 generic_rss 站点)。 + +export const SITE_KINDS = ["pt", "public_bt", "rss_feed", "torznab"] as const; + +/** 主机名 → 站点 key 片段:小写,非字母数字换成 -,首尾去 -;空则 feed */ +export function hostSlug(host: string): string { + const s = host + .toLowerCase() + .replace(/^www\./, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) + .replace(/-+$/, ""); + return s || "feed"; +} + +function hostOf(urlDisplay: string): string | null { + try { + return new URL(urlDisplay).host || null; + } catch { + return null; + } +} + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + `alter table site add column kind text not null default 'pt' ${oneOf("kind", SITE_KINDS)}; + update site set kind = 'rss_feed' where adapter = 'generic_rss'; + create index site_kind_idx on site (kind) where deleted_at is null;`, + ); + + const orphans = await db + .selectFrom("feed_source") + .select(["id", "url_display", "name"]) + .where("site_id", "is", null) + .orderBy("created_at") + .execute(); + if (!orphans.length) return; + + const taken = new Set((await db.selectFrom("site").select("key").execute()).map((r) => r.key as string)); + const byHost = new Map(); + for (const f of orphans) { + const host = hostOf(f.url_display as string) ?? "unknown"; + const list = byHost.get(host) ?? []; + list.push({ id: f.id as string, name: f.name as string }); + byHost.set(host, list); + } + for (const [host, feeds] of byHost) { + const base = `rss-${hostSlug(host === "unknown" ? "" : host)}`; + let key = base; + for (let n = 2; taken.has(key); n++) key = `${base}-${n}`; + taken.add(key); + const site = await db + .insertInto("site") + .values({ + key, + name: host === "unknown" ? (feeds[0]?.name ?? "自定义 RSS 源") : host, + kind: "rss_feed", + adapter: "generic_rss", + base_url: host === "unknown" ? "https://invalid.local" : `https://${host}`, + snapshot_enabled: false, + search_default: false, + }) + .returning("id") + .executeTakeFirstOrThrow(); + await db + .updateTable("feed_source") + .set({ + site_id: site.id, + etag: null, + last_modified: null, + cursor: sql`cursor - 'window'`, + }) + .where( + "id", + "in", + feeds.map((f) => f.id), + ) + .execute(); + } +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl( + db, + `drop index if exists site_kind_idx; + alter table site drop column if exists kind;`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 2aeb4171..e83dd8df 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -239,6 +239,7 @@ export type ServerMappingMatchStatus = "matched" | "ambiguous" | "unmatched" | " export type SiteAdapter = "nexusphp" | "mteam_api" | "api" | "generic_rss"; export type SiteHealthStatus = "unknown" | "healthy" | "degraded" | "failing" | "disabled"; export type SiteCircuitState = "closed" | "open" | "half_open"; +export type SiteKind = "pt" | "public_bt" | "rss_feed" | "torznab"; export type SiteAccountAuthKind = "cookie" | "passkey" | "api_key" | "password"; export type SiteAccountStatus = "unknown" | "active" | "invalid" | "expired" | "challenge"; export type SiteSnapshotKind = "periodic" | "backfill" | "live_search" | "manual"; @@ -1384,6 +1385,7 @@ export interface SiteTable { proxy: Generated; deleted_at: Timestamptz | null; search_default: Generated; + kind: Generated; } export interface SiteAccountTable {