diff --git a/apps/server/src/modules/sources-identity/routes/sources.ts b/apps/server/src/modules/sources-identity/routes/sources.ts index d691ab46..1ca2ebc7 100644 --- a/apps/server/src/modules/sources-identity/routes/sources.ts +++ b/apps/server/src/modules/sources-identity/routes/sources.ts @@ -44,6 +44,7 @@ import { userDataHistory, } from "../service/account.ts"; import { audit, closeDecisions } from "../service/common.ts"; +import { feedDefaultsForSite } from "../service/feeds.ts"; import { assignReleaseIdentity } from "../service/identity-admin.ts"; // 站点、站点账号、Feed、来源资源、观察的管理 API。凭证只写(存 secret,对外只给 secret_ref / 脱敏 URL)。 @@ -515,27 +516,31 @@ 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 }) => { - if (body.siteId) await getSite(body.siteId); + const site = body.siteId ? await getSite(body.siteId) : null; if (body.siteAccountId) await checkAccount(body.siteAccountId, body.siteId ?? null); - const ref = await rt().ctx.secrets.create({ purpose: "feed_url", plaintext: body.url, label: `feed:${body.name}` }); + // 站点内添加:账号 / 解析器 / 名称按站点推断,域名不符只提示 + 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: body.siteAccountId ?? null, - name: body.name, + 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 ?? 900, + poll_interval_sec: body.pollIntervalSec ?? inferred?.pollIntervalSec ?? 900, filter: jsonb(body.filter ?? {}), }) .returningAll() .executeTakeFirstOrThrow(); await audit(db(), actor, "feed_source.created", { type: "feed_source", id: row.id }, { url: row.url_display }); - return 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); diff --git a/apps/server/src/modules/sources-identity/service/feeds.ts b/apps/server/src/modules/sources-identity/service/feeds.ts index bbaf1af3..2882a2f0 100644 --- a/apps/server/src/modules/sources-identity/service/feeds.ts +++ b/apps/server/src/modules/sources-identity/service/feeds.ts @@ -6,9 +6,10 @@ import { type FeedFetchResult, type FeedFetchState, type FeedItem, + getSiteDefinition, } from "@umt/adapters"; import type { FeedHealth } from "@umt/contracts"; -import { type FeedSourceRow, sql } from "@umt/db"; +import { type FeedSourceRow, type SiteRow, sql } from "@umt/db"; import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { clip, type Exec, openDecision } from "./common.ts"; @@ -53,6 +54,64 @@ export function extractSiteReleaseId(item: Pick, + url: string, + explicitAccountId: string | null, +): Promise<{ + siteAccountId: string | null; + parserVersion: string; + name: string; + pollIntervalSec: number; + warnings: string[]; +}> { + const def = getSiteDefinition(site.key); + let siteAccountId = explicitAccountId; + if (!siteAccountId) { + const accs = await db + .selectFrom("site_account") + .select(["id", "passkey_secret_ref", "auth_kind"]) + .where("site_id", "=", site.id) + .where("enabled", "=", true) + .orderBy("created_at") + .execute(); + const withPasskey = accs.filter((a) => a.passkey_secret_ref || a.auth_kind === "passkey"); + siteAccountId = (withPasskey[0] ?? accs[0])?.id ?? null; + } + const warnings: string[] = []; + const feedHost = hostKey(url); + const siteHosts = new Set([hostKey(site.base_url), def ? hostKey(def.domain) : null].filter(Boolean)); + if (feedHost && !siteHosts.has(feedHost)) { + warnings.push(`地址域名 ${new URL(url).hostname} 与站点「${site.name}」不一致,已按该站保存;请确认没有填错站点`); + } + return { + siteAccountId, + parserVersion: def ? `rss@1;${def.umt.version}` : "rss@1", + name: `${site.name} RSS`, + pollIntervalSec: SITE_FEED_POLL_SEC, + warnings, + }; +} + export function stateFromRow(f: FeedSourceRow): FeedFetchState { const cur = (f.cursor ?? {}) as StoredCursor; const err = f.last_error as FeedFetchState["lastError"] | null; diff --git a/apps/server/test/sources-identity/api.test.ts b/apps/server/test/sources-identity/api.test.ts index 7b099684..50dbb7b9 100644 --- a/apps/server/test/sources-identity/api.test.ts +++ b/apps/server/test/sources-identity/api.test.ts @@ -115,6 +115,37 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => { expect((await h.req("/feed-sources/00000000-0000-4000-8000-000000000000")).status).toBe(404); }); + it("站点内添加 Feed:只给地址即可,账号 / 解析器 / 名称按站点推断;域名不符只提示", async () => { + const acc = (await json(await h.req(`/site-accounts?siteId=${siteId}`))).body.data.items[0]; + const same = await json( + await h.req("/feed-sources", { body: { siteId, url: "https://audiences.me/torrentrss.php?passkey=k1" } }), + ); + expect(same.status).toBe(200); + expect(same.body.data).toMatchObject({ + name: "观众 RSS", + siteAccountId: acc.id, + purpose: "generic", + pollIntervalSec: 1800, + }); + expect(same.body.data.parserVersion).toMatch(/^rss@1;audiences@/); + expect(same.body.data.warnings).toBeUndefined(); + const other = await json( + await h.req("/feed-sources", { + body: { siteId, purpose: "promotion", name: "促销", url: "https://elsewhere.example/rss?passkey=k2" }, + }), + ); + expect(other.status).toBe(200); + expect(other.body.data.name).toBe("促销"); + expect(other.body.data.warnings?.[0]).toContain("elsewhere.example"); + const bare = await json(await h.req("/feed-sources", { body: { url: "https://any.example/feed.xml" } })); + expect(bare.body.data).toMatchObject({ + name: "any.example", + siteId: null, + siteAccountId: null, + parserVersion: "1", + }); + }); + it("快照:列表页 upsert 到 source_release,记录覆盖范围;站点级限流器每站一个实例", async () => { const r = await snapshotSite(h.rt, { siteId, kind: "periodic" }); expect(r.status).toBe("ok"); diff --git a/apps/web/app/api/feeds.test.ts b/apps/web/app/api/feeds.test.ts new file mode 100644 index 00000000..c2384b79 --- /dev/null +++ b/apps/web/app/api/feeds.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { feedHostKey } from "./feeds.ts"; + +describe("feedHostKey", () => { + it("同站子域名 / www 归到同一键", () => { + expect(feedHostKey("https://hhanclub.net/torrentrss.php?passkey=x")).toBe("hhanclub.net"); + expect(feedHostKey("https://www.hhanclub.net/")).toBe("hhanclub.net"); + expect(feedHostKey("https://rss.hhanclub.net/a")).toBe("hhanclub.net"); + }); + it("IP 原样;非法地址为 null", () => { + expect(feedHostKey("http://127.0.0.1:8080/rss")).toBe("127.0.0.1"); + expect(feedHostKey("not a url")).toBeNull(); + }); +}); diff --git a/apps/web/app/api/feeds.ts b/apps/web/app/api/feeds.ts index 04ee3d4c..731b84df 100644 --- a/apps/web/app/api/feeds.ts +++ b/apps/web/app/api/feeds.ts @@ -29,6 +29,34 @@ export function useFeeds(f: { siteId?: string; health?: FeedHealth } = {}) { }); } +/** 单个 Feed;poll 为真时每 2 秒刷新(站点内添加后等首抓结果) */ +export function useFeed(id: string | null, poll = false) { + return useQuery({ + queryKey: [...feedKeys.all, "one", id] as const, + enabled: !!id, + queryFn: ({ signal }) => + apiParsed(FeedSource, "/api/v1/feed-sources/:id", { pathParams: { id: id ?? "" }, signal }), + refetchInterval: poll ? 2 * TTL_SECOND : false, + }); +} + +/** 同站判定的主机键(与服务端 feedDefaultsForSite 一致):去 www.,取最后两级;IP 原样 */ +export function feedHostKey(u: string): string | null { + try { + const host = new URL(u).hostname.toLowerCase().replace(/^www\./, ""); + if (/^[\d.]+$/.test(host) || host.includes(":")) return host; + return host.split(".").slice(-2).join("."); + } catch { + return null; + } +} + +/** 游标窗口里的条目数(最近一次成功抓取的 Feed 条目数);没抓过为 null */ +export function feedWindowCount(f: FeedSource): number | null { + const w = f.cursor.window as { lastGuidSet?: unknown[] } | undefined; + return Array.isArray(w?.lastGuidSet) ? w.lastGuidSet.length : null; +} + export function useCreateFeed() { const qc = useQueryClient(); return useMutation({ diff --git a/apps/web/app/components/sites/site-feed-dialog.tsx b/apps/web/app/components/sites/site-feed-dialog.tsx new file mode 100644 index 00000000..1566e4f6 --- /dev/null +++ b/apps/web/app/components/sites/site-feed-dialog.tsx @@ -0,0 +1,292 @@ +import { type FeedPurpose, type FeedSource, redactUrl, type Site, type SiteAccount } from "@umt/contracts"; +import { CheckCircle2, CircleAlert, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { feedHostKey, feedWindowCount, useCreateFeed, useFeed, useFetchFeedNow } from "~/api/feeds.ts"; +import { FormField } from "~/components/console/form-field.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; + +// 站点内添加 RSS:站点固定,账号 / 解析器 / 轮询由服务端按站点带出;只填地址、用途、可选备注。 +// 保存后立即抓一次,在对话框里等结果(条目数或错误)。 + +const PURPOSES: { value: FeedPurpose; label: string; hint: string }[] = [ + { value: "generic", label: "发现", hint: "全站新种,进入识别和收录评估" }, + { value: "search", label: "追踪", hint: "按关键词、分类或收藏筛过的 RSS" }, + { value: "promotion", label: "促销", hint: "只含免费 / 促销种子的 RSS" }, +]; + +/** 首抓最多等 90 秒(站点限流排队);超时后提示去列表看 */ +const WAIT_MS = 90_000; + +function defaultAccount(accounts: SiteAccount[]): string { + return (accounts.find((a) => a.hasPasskey || a.authKind === "passkey") ?? accounts[0])?.id ?? ""; +} + +export function SiteFeedDialog({ + site, + accounts, + onOpenChange, +}: { + site: Site; + accounts: SiteAccount[]; + onOpenChange: (o: boolean) => void; +}) { + const siteAccounts = accounts.filter((a) => a.siteId === site.id && a.enabled); + const [url, setUrl] = useState(""); + const [purpose, setPurpose] = useState("generic"); + const [note, setNote] = useState(""); + const [accountId, setAccountId] = useState(() => defaultAccount(siteAccounts)); + const [error, setError] = useState(null); + const [created, setCreated] = useState(null); + const [fetchError, setFetchError] = useState(null); + const [startedAt, setStartedAt] = useState(0); + const [timedOut, setTimedOut] = useState(false); + const create = useCreateFeed(); + const fetchNow = useFetchFeedNow(); + + const polled = useFeed(created?.id ?? null, !!created && !timedOut); + const feed = polled.data ?? created; + const done = !!feed?.lastFetchAt; + + useEffect(() => { + if (!created || done || timedOut) return; + const t = setTimeout(() => setTimedOut(true), Math.max(0, startedAt + WAIT_MS - Date.now())); + return () => clearTimeout(t); + }, [created, done, timedOut, startedAt]); + + const trimmed = url.trim(); + let valid = false; + try { + valid = /^https?:$/.test(new URL(trimmed).protocol); + } catch { + valid = false; + } + const shown = valid ? redactUrl(trimmed) : null; + const siteHosts = [feedHostKey(site.baseUrl)]; + const mismatch = valid && !siteHosts.includes(feedHostKey(trimmed)); + const account = siteAccounts.find((a) => a.id === accountId); + + const submit = () => { + if (!valid) { + setError("填写完整的 RSS 地址(http / https)"); + return; + } + setError(null); + create.mutate( + { + siteId: site.id, + url: trimmed, + purpose, + ...(note.trim() ? { name: note.trim() } : {}), + ...(siteAccounts.length > 1 && accountId ? { siteAccountId: accountId } : {}), + }, + { + onSuccess: (f) => { + setCreated(f); + setStartedAt(Date.now()); + fetchNow.mutate(f.id, { onError: (e) => setFetchError(errorMessage(e)) }); + }, + onError: (e) => setError(errorMessage(e)), + }, + ); + }; + + return ( + + + + 添加 {site.name} RSS + 地址加密保存,页面上只显示去掉 passkey 的地址。 + + + {created && feed ? ( + + ) : ( +
{ + e.preventDefault(); + submit(); + }} + > + + 保存后显示为 {shown} + + ) : ( + "站点「控制面板 → RSS」里生成的链接" + ) + } + > + setUrl(e.target.value)} + /> + + {mismatch ? ( +

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

+ ) : null} + +
+

用途

+
+ {PURPOSES.map((p) => ( + // biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达 + + ))} +
+

+ {PURPOSES.find((p) => p.value === purpose)?.hint} +

+
+ + {siteAccounts.length > 1 ? ( + + + + ) : null} + + + setNote(e.target.value)} + /> + + +

+ {account ? `账号 ${account.label}` : "该站没有账号,按地址里的 passkey 抓取"} · 解析器按站点定义 · 每 30 + 分钟抓一次,可在 Feed 详情里改 +

+
+ )} + + + {created ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} + +function FirstFetch({ + feed, + done, + timedOut, + fetchError, + warnings, +}: { + feed: FeedSource; + done: boolean; + timedOut: boolean; + fetchError: string | null; + warnings: string[]; +}) { + const err = feed.lastError + ? String((feed.lastError as { message?: unknown }).message ?? JSON.stringify(feed.lastError)) + : fetchError; + const count = feedWindowCount(feed); + return ( +
+

{feed.urlDisplay}

+ {warnings.map((w) => ( +

+ {w} +

+ ))} + {err ? ( +

+ + 首次抓取失败:{err} +

+ ) : done ? ( +

+ + 已保存,首次抓取到 {count ?? 0} 条 +

+ ) : timedOut ? ( +

+ 已保存。抓取还在排队(站点限流),结果稍后显示在 Feed 列表里。 +

+ ) : ( +

+ + 已保存,正在抓取… +

+ )} +
+ ); +} diff --git a/apps/web/app/components/workbench/feed-form.tsx b/apps/web/app/components/workbench/feed-form.tsx index b7a700bb..982731d5 100644 --- a/apps/web/app/components/workbench/feed-form.tsx +++ b/apps/web/app/components/workbench/feed-form.tsx @@ -8,7 +8,7 @@ import { } from "@umt/contracts"; import { useState } from "react"; import { toast } from "sonner"; -import { useCreateFeed, useUpdateFeed } from "~/api/feeds.ts"; +import { feedHostKey, useCreateFeed, useUpdateFeed } from "~/api/feeds.ts"; import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx"; import { Button } from "~/components/ui/button.tsx"; import { @@ -76,12 +76,26 @@ export function FeedFormDialog({ }) { const [d, setD] = useState(() => initial(feed)); const [errors, setErrors] = useState>({}); + // 新建时按地址域名建议关联已登记站点;用户动过站点选择后不再自动改 + const [siteTouched, setSiteTouched] = useState(!!feed); + const [suggested, setSuggested] = useState(null); const create = useCreateFeed(); const update = useUpdateFeed(); const pending = create.isPending || update.isPending; const siteAccounts = accounts.filter((a) => a.siteId === d.siteId); const set = (k: K, v: Draft[K]) => setD((x) => ({ ...x, [k]: v })); + const onUrl = (url: string) => { + if (siteTouched) return set("url", url); + const key = feedHostKey(url.trim()); + const match = key ? sites.find((s) => feedHostKey(s.baseUrl) === key) : undefined; + const accs = match ? accounts.filter((a) => a.siteId === match.id && a.enabled) : []; + const acc = accs.find((a) => a.hasPasskey || a.authKind === "passkey") ?? accs[0]; + setSuggested(match?.id ?? null); + setD((x) => ({ ...x, url, siteId: match?.id ?? "", siteAccountId: acc?.id ?? "" })); + }; + const suggestedSite = suggested && d.siteId === suggested ? sites.find((s) => s.id === suggested) : undefined; + const submit = () => { let filter: Record | undefined; if (d.filter.trim()) { @@ -95,7 +109,7 @@ export function FeedFormDialog({ } } const raw = { - name: d.name.trim(), + ...(d.name.trim() ? { name: d.name.trim() } : {}), purpose: d.purpose, siteId: d.siteId || null, siteAccountId: d.siteAccountId || null, @@ -109,7 +123,6 @@ export function FeedFormDialog({ const e = zodFieldErrors(parsed.error.issues); if (e.pollIntervalSec) e.pollMin = "轮询间隔至少 1 分钟"; if (e.url) e.url = feed ? "URL 格式不对" : "填写完整的 Feed URL"; - if (e.name) e.name = "名称不能为空"; setErrors(e); return; } @@ -139,7 +152,12 @@ export function FeedFormDialog({ submit(); }} > - + set("url", e.target.value)} + onChange={(e) => onUrl(e.target.value)} />
@@ -190,10 +208,35 @@ export function FeedFormDialog({ onChange={(e) => set("pollMin", e.target.value)} /> - + + 按地址域名关联到「{suggestedSite.name}」 + + + ) : undefined + } + > set("siteAccountId", v === NONE ? "" : v)} + // Radix 隐藏的原生 select 在选项列表同时变化时会回报 "",忽略 + onValueChange={(v) => v && set("siteAccountId", v === NONE ? "" : v)} > diff --git a/apps/web/app/components/workbench/feed-table.tsx b/apps/web/app/components/workbench/feed-table.tsx index efb6a116..c328511e 100644 --- a/apps/web/app/components/workbench/feed-table.tsx +++ b/apps/web/app/components/workbench/feed-table.tsx @@ -10,6 +10,7 @@ import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.ts 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 { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { errorMessage } from "~/lib/api.ts"; @@ -123,6 +124,7 @@ export function FeedTable({ 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])); const open = feeds.data?.items.find((f) => f.id === openId) ?? null; + const scopedSite = siteId ? sites.data?.items.find((s) => s.id === siteId) : undefined; const editing: FeedSource | "new" | null = creating ? "new" : editingFeed; const setEditing = (v: FeedSource | null) => { setEditingFeed(v); @@ -286,7 +288,14 @@ export function FeedTable({ ) : null} - {editing ? ( + {editing === "new" && scopedSite ? ( + // 站点内添加:只填地址 / 用途 / 备注,其余按站点带出 + !o && setEditing(null)} + /> + ) : editing ? ( !o && setEditing(null)} diff --git a/docs/runbook/sites.md b/docs/runbook/sites.md index b5c4c6b1..4ed4cc24 100644 --- a/docs/runbook/sites.md +++ b/docs/runbook/sites.md @@ -50,6 +50,12 @@ `DELETE /sites/{id}` 在一个事务里:资源 `availability` 改为 `site_disabled`(资源和下载任务都保留,`source_release.site_id` 外键为 restrict)、Feed 停用并解除账号关联、关闭该站待处理、删全部账号(`site_user_data` 随账号级联删除)、站点行软删除(`deleted_at`,`enabled=false`,key 改为 `-deleted-<时间>`,同一定义可以重新登记)。事务提交后删除账号的全部 secret。 +## 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`。 +- **`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**(`components/workbench/feed-form.tsx`):完整表单不变;新建时地址域名匹配已登记站点会自动选中该站和账号并提示「按地址域名关联到 X · 不关联」,手动改过站点后不再自动改。 + ## 定时任务 | key | 名称 | 默认 | 行为 | diff --git a/packages/contracts/src/sources.ts b/packages/contracts/src/sources.ts index a3beda8c..0e9987c3 100644 --- a/packages/contracts/src/sources.ts +++ b/packages/contracts/src/sources.ts @@ -328,9 +328,15 @@ export const FeedSource = EntityBase.extend({ possibleGap: z .object({ detectedAt: Timestamp, previousFetchedAt: Timestamp.nullable(), backfillQueued: z.boolean() }) .nullable(), + /** 只在创建响应里出现:不拦截的提示(如地址域名与所选站点不一致) */ + warnings: z.array(z.string()).optional(), }); export type FeedSource = z.infer; -/** url 为只写明文(可能含 passkey),服务端加密存为 secret 并生成 urlDisplay */ +/** + * url 为只写明文(可能含 passkey),服务端加密存为 secret 并生成 urlDisplay。 + * 带 siteId 时可只给 url(+ purpose):账号缺省取该站启用账号(优先带 passkey)、解析器按站点定义、名称缺省「站点名 RSS」; + * 不带 siteId 时名称缺省为地址主机名。 + */ export const FeedSourceCreate = FeedSource.pick({ siteId: true, siteAccountId: true, @@ -344,6 +350,7 @@ export const FeedSourceCreate = FeedSource.pick({ .partial({ siteId: true, siteAccountId: true, + name: true, purpose: true, kind: true, enabled: true,