mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-21 06:13:48 +00:00
feat(feed): optional per-feed sender-in-title toggle
Add a per-feed senderInTitle flag (domain FeedState.senderInTitle ↔ FeedConfig.sender_in_title). When set, the feed generator prefixes each entry title with [Sender] (display name, falling back to the address). Exposed as an admin edit-form checkbox and across the REST API create/update/response schemas. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1472,4 +1472,104 @@ describe("Admin Routes", () => {
|
||||
expect(body).not.toContain("validator.w3.org/feed/images");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sender-in-title toggle", () => {
|
||||
it("edit form renders the checkbox, unchecked by default", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||
const feedId = FeedId.generate();
|
||||
const feed = Feed.create(
|
||||
feedId,
|
||||
{
|
||||
title: "Title Toggle Feed",
|
||||
language: "en",
|
||||
allowedSenders: [],
|
||||
blockedSenders: [],
|
||||
},
|
||||
{ mailboxId: MailboxId.unchecked("title.toggle.01") },
|
||||
);
|
||||
await repo.save(feed);
|
||||
|
||||
const res = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||
headers: { Cookie: authCookie },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toContain('name="sender_in_title"');
|
||||
expect(body).toContain("Show sender in entry titles");
|
||||
expect(body).not.toContain("checked");
|
||||
});
|
||||
|
||||
it("persists the toggle through edit and reflects it back as checked", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||
const feedId = FeedId.generate();
|
||||
const feed = Feed.create(
|
||||
feedId,
|
||||
{
|
||||
title: "Title Toggle Feed",
|
||||
language: "en",
|
||||
allowedSenders: [],
|
||||
blockedSenders: [],
|
||||
},
|
||||
{ mailboxId: MailboxId.unchecked("title.toggle.02") },
|
||||
);
|
||||
await repo.save(feed);
|
||||
|
||||
const form = new FormData();
|
||||
form.append("title", "Title Toggle Feed");
|
||||
form.append("sender_in_title", "true");
|
||||
const post = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
Origin: "https://test.getmynews.app",
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
expect(post.status).toBe(302);
|
||||
|
||||
const cfg = await repo.getConfig(feedId);
|
||||
expect(cfg?.sender_in_title).toBe(true);
|
||||
|
||||
const editPage = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||
headers: { Cookie: authCookie },
|
||||
});
|
||||
expect(await editPage.text()).toContain("checked");
|
||||
});
|
||||
|
||||
it("clears the toggle when the checkbox is omitted (unchecked)", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const repo = FeedRepository.from(mockEnv as unknown as Env);
|
||||
const feedId = FeedId.generate();
|
||||
const feed = Feed.create(
|
||||
feedId,
|
||||
{
|
||||
title: "Title Toggle Feed",
|
||||
language: "en",
|
||||
senderInTitle: true,
|
||||
allowedSenders: [],
|
||||
blockedSenders: [],
|
||||
},
|
||||
{ mailboxId: MailboxId.unchecked("title.toggle.03") },
|
||||
);
|
||||
await repo.save(feed);
|
||||
|
||||
const form = new FormData();
|
||||
form.append("title", "Title Toggle Feed");
|
||||
// No sender_in_title field ⇒ unchecked.
|
||||
const post = await request(`/admin/feeds/${feedId.value}/edit`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
Origin: "https://test.getmynews.app",
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
expect(post.status).toBe(302);
|
||||
|
||||
const cfg = await repo.getConfig(feedId);
|
||||
expect(cfg?.sender_in_title).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,6 +269,23 @@ feedsRouter.get("/:feedId/edit", async (c) => {
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="sender_in_title"
|
||||
value="true"
|
||||
checked={feedConfig.sender_in_title ?? false}
|
||||
disabled={isExpired}
|
||||
/>
|
||||
Show sender in entry titles
|
||||
</label>
|
||||
<small>
|
||||
Render each entry's title as <code>[Sender] Subject</code> for
|
||||
at-a-glance scanning in your reader.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="lifetime_hours">Lifetime (hours)</label>
|
||||
<input
|
||||
@@ -322,6 +339,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
||||
const blockedSenders = parseAllowedSenders(
|
||||
formData.get("blocked_senders")?.toString() || "",
|
||||
);
|
||||
const senderInTitle = formData.get("sender_in_title") === "true";
|
||||
const lifetimeHoursRaw = formData.get("lifetime_hours")?.toString();
|
||||
|
||||
const parsedData = updateFeedSchema.parse({
|
||||
@@ -338,6 +356,7 @@ feedsRouter.post("/:feedId/edit", async (c) => {
|
||||
language: parsedData.language,
|
||||
allowedSenders: parsedData.allowedSenders,
|
||||
blockedSenders: parsedData.blockedSenders,
|
||||
senderInTitle,
|
||||
lifetimeHours: lifetimeHoursRaw
|
||||
? parseInt(lifetimeHoursRaw, 10)
|
||||
: undefined,
|
||||
|
||||
@@ -143,6 +143,40 @@ describe("REST API (/api/v1)", () => {
|
||||
expect(afterList.feeds.map((f) => f.id)).not.toContain(created.id);
|
||||
});
|
||||
|
||||
it("defaults senderInTitle to false and lets it be set on create and update", async () => {
|
||||
const createRes = await request("/api/v1/feeds", {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Title Feed" }),
|
||||
});
|
||||
const created = (await createRes.json()) as {
|
||||
id: string;
|
||||
senderInTitle: boolean;
|
||||
};
|
||||
expect(created.senderInTitle).toBe(false);
|
||||
|
||||
const setRes = await request("/api/v1/feeds", {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Prefixed Feed", senderInTitle: true }),
|
||||
});
|
||||
const set = (await setRes.json()) as {
|
||||
id: string;
|
||||
senderInTitle: boolean;
|
||||
};
|
||||
expect(set.senderInTitle).toBe(true);
|
||||
|
||||
const patchRes = await request(`/api/v1/feeds/${set.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...authHeaders, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ senderInTitle: false }),
|
||||
});
|
||||
expect(patchRes.status).toBe(200);
|
||||
expect(
|
||||
(await patchRes.json()) as { senderInTitle: boolean },
|
||||
).toMatchObject({ senderInTitle: false });
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid create body", async () => {
|
||||
const res = await request("/api/v1/feeds", {
|
||||
method: "POST",
|
||||
|
||||
@@ -59,6 +59,7 @@ function toFeed(
|
||||
language: config.language,
|
||||
allowedSenders: config.allowed_senders ?? [],
|
||||
blockedSenders: config.blocked_senders ?? [],
|
||||
senderInTitle: config.sender_in_title ?? false,
|
||||
createdAt: config.created_at,
|
||||
updatedAt: config.updated_at,
|
||||
expiresAt: config.expires_at,
|
||||
@@ -152,6 +153,7 @@ apiApp.openapi(
|
||||
language: body.language,
|
||||
allowedSenders: normalizeSenders(body.allowedSenders) ?? [],
|
||||
blockedSenders: normalizeSenders(body.blockedSenders) ?? [],
|
||||
senderInTitle: body.senderInTitle,
|
||||
lifetimeHours: body.lifetimeHours,
|
||||
});
|
||||
return c.json(toFeed(feedId, config, 0, env), 201);
|
||||
@@ -217,6 +219,7 @@ apiApp.openapi(
|
||||
language: body.language,
|
||||
allowedSenders: normalizeSenders(body.allowedSenders),
|
||||
blockedSenders: normalizeSenders(body.blockedSenders),
|
||||
senderInTitle: body.senderInTitle,
|
||||
lifetimeHours: body.lifetimeHours,
|
||||
});
|
||||
if (result.status === "not_found")
|
||||
|
||||
@@ -39,6 +39,10 @@ export const FeedCreateSchema = z
|
||||
language: z.string().optional().default("en"),
|
||||
allowedSenders: z.array(z.string()).optional().default([]),
|
||||
blockedSenders: z.array(z.string()).optional().default([]),
|
||||
senderInTitle: z.boolean().optional().openapi({
|
||||
description:
|
||||
"Render entry titles as `[Sender] Subject` in the feed output.",
|
||||
}),
|
||||
lifetimeHours: z.number().int().positive().optional().openapi({
|
||||
description:
|
||||
"Hours until the feed expires. Ignored when the server enforces a fixed FEED_TTL_HOURS.",
|
||||
@@ -53,6 +57,10 @@ export const FeedUpdateSchema = z
|
||||
language: z.string().optional(),
|
||||
allowedSenders: z.array(z.string()).optional(),
|
||||
blockedSenders: z.array(z.string()).optional(),
|
||||
senderInTitle: z.boolean().optional().openapi({
|
||||
description:
|
||||
"Render entry titles as `[Sender] Subject` in the feed output.",
|
||||
}),
|
||||
lifetimeHours: z.number().int().positive().optional().openapi({
|
||||
description: "Reset the feed's lifetime to this many hours from now.",
|
||||
}),
|
||||
@@ -83,6 +91,7 @@ export const FeedSchema = z
|
||||
language: z.string(),
|
||||
allowedSenders: z.array(z.string()),
|
||||
blockedSenders: z.array(z.string()),
|
||||
senderInTitle: z.boolean(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number().optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
|
||||
Reference in New Issue
Block a user