chore: apply Prettier formatting to entire codebase

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-20 22:01:53 +02:00
parent 1d8cffb119
commit 3ed9d2ee22
18 changed files with 1008 additions and 319 deletions
+576 -76
View File
@@ -2,79 +2,579 @@
* Collection of common nouns for feed ID generation
*/
export const nouns = [
'actor', 'almond', 'amber', 'anchor', 'angel', 'animal', 'answer', 'apple',
'autumn', 'avenue', 'badge', 'bagel', 'baker', 'ballet', 'bamboo',
'banana', 'basket', 'beach', 'beard', 'beauty', 'beetle', 'berry', 'bicycle',
'bird', 'blanket', 'blossom', 'boat', 'bottle', 'bowl', 'breeze', 'bubble',
'bucket', 'button', 'cabin', 'cactus', 'cafe', 'camera', 'candle', 'candy',
'canvas', 'canyon', 'captain', 'carpet', 'carrot', 'castle', 'cave', 'cellar',
'chair', 'chalk', 'cheese', 'cherry', 'chest', 'chicken', 'chimney',
'circus', 'cliff', 'clock', 'cloud', 'clover', 'coast', 'cobalt', 'cocoa',
'coffee', 'coin', 'comet', 'compass', 'cookie', 'copper', 'coral', 'corner',
'cotton', 'cradle', 'craft', 'creek', 'cricket', 'crown', 'crystal', 'cube',
'cupboard', 'curtain', 'cushion', 'daisy', 'dance', 'date', 'dawn', 'deer',
'desert', 'dew', 'diamond', 'dinner', 'dish', 'doctor', 'dolphin',
'donut', 'door', 'dream', 'dress', 'drink', 'drum', 'duck', 'dusk',
'eagle', 'earth', 'echo', 'emerald', 'engine', 'evening', 'face', 'fairy',
'fall', 'family', 'fan', 'farm', 'feather', 'fence', 'ferry', 'field',
'finger', 'fire', 'fish', 'flag', 'flame', 'flash', 'flavor', 'flight',
'floor', 'flour', 'flower', 'flute', 'fog', 'foil', 'forest', 'fork',
'fox', 'frame', 'friend', 'frog', 'frost', 'fruit', 'garden', 'garlic',
'gate', 'gem', 'gift', 'ginger', 'giraffe', 'glacier', 'glass',
'glitter', 'glove', 'glow', 'goat', 'gold', 'grape', 'grass', 'gravel',
'gravity', 'guitar', 'gum', 'hair', 'hammer', 'hand', 'harbor', 'harp',
'hat', 'hawk', 'heart', 'heath', 'heaven', 'helmet', 'herb', 'hill',
'hippo', 'honey', 'hood', 'horn', 'horse', 'hotel', 'hour', 'house',
'hunter', 'ice', 'icicle', 'idea', 'ink', 'insect', 'iron', 'island',
'ivy', 'jacket', 'jade', 'jam', 'jasmine', 'jelly', 'jewel',
'joke', 'journal', 'journey', 'joy', 'judge', 'jungle', 'kettle', 'key',
'kid', 'kingdom', 'kitchen', 'kite', 'kitten', 'knight',
'lab', 'ladder', 'lake', 'lamb', 'lamp', 'land', 'lantern',
'laptop', 'laugh', 'lava', 'lawn', 'leaf', 'legend', 'lemon', 'letter',
'library', 'light', 'lily', 'lime', 'lion', 'lip', 'lobby', 'lock',
'locket', 'lodge', 'lotus', 'love', 'lunch', 'lyric', 'magic', 'magnet',
'mango', 'maple', 'marble', 'market', 'mask', 'meadow', 'melody', 'melon',
'memory', 'metal', 'meteor', 'milk', 'mint', 'mirror', 'mist', 'mitten',
'moon', 'morning', 'moth', 'motor', 'mountain', 'mouse',
'movie', 'muffin', 'museum', 'music', 'myth', 'napkin', 'nectar', 'needle',
'nest', 'net', 'nickel', 'night', 'nose', 'note', 'novel', 'number',
'nurse', 'nutmeg', 'oasis', 'ocean', 'olive', 'onion', 'opera', 'orange',
'orbit', 'orchard', 'orchid', 'ostrich', 'otter', 'oven', 'owl', 'oxygen',
'oyster', 'page', 'paint', 'palace', 'palm', 'pan', 'pancake', 'panda',
'paper', 'parade', 'parcel', 'park', 'parrot', 'party', 'pasta', 'patch',
'path', 'peach', 'peanut', 'pear', 'pearl', 'pebble', 'pencil', 'penny',
'people', 'pepper', 'petal', 'phone', 'photo', 'piano', 'pickle', 'picture',
'pie', 'pillow', 'pine', 'pink', 'pirate', 'pizza', 'planet',
'plant', 'plum', 'pocket', 'poem', 'poet', 'point', 'pony', 'pool',
'popcorn', 'porch', 'port', 'potato', 'powder', 'prairie', 'pretzel', 'prism',
'prose', 'puppet', 'puppy', 'puzzle', 'quail', 'quartz', 'queen', 'quilt',
'rabbit', 'raccoon', 'radio', 'raft', 'rain', 'rainbow', 'raisin',
'ranch', 'rapids', 'raven', 'ray', 'record', 'reef', 'ribbon', 'rice',
'ring', 'river', 'road', 'robin', 'robot', 'rock', 'rocket', 'rodeo',
'roof', 'room', 'root', 'rope', 'rose', 'ruby', 'rug', 'ruler', 'sage',
'sail', 'salad', 'salmon', 'salt', 'sand', 'sandal', 'sauce', 'saucer',
'scale', 'scarf', 'school', 'sea', 'seed', 'shadow', 'shell', 'ship',
'shirt', 'shoe', 'shop', 'shower', 'shrimp', 'side', 'sign', 'silk',
'silver', 'singer', 'sink', 'sky', 'sled', 'sleet', 'sleigh', 'slice',
'slide', 'slipper', 'slope', 'smoke', 'snail', 'snake', 'snow', 'soap',
'sock', 'soda', 'sofa', 'soil', 'song', 'soup', 'spade', 'spark', 'sparrow',
'spice', 'spider', 'spoon', 'spot', 'spring', 'sprout', 'square', 'squirrel',
'stable', 'stage', 'stair', 'stamp', 'star', 'station', 'steam', 'steel',
'stem', 'stick', 'stone', 'stork', 'storm', 'story', 'stove', 'straw',
'stream', 'street', 'string', 'studio', 'sugar', 'summer', 'sun', 'sunset',
'swan', 'sweater', 'sweets', 'sword', 'table', 'tablet', 'tail', 'talent',
'tangerine', 'tank', 'tea', 'team', 'teapot', 'tear', 'temple', 'tennis',
'tent', 'theater', 'thistle', 'thought', 'thread', 'thunder',
'ticket', 'tide', 'tiger', 'tile', 'time', 'toast', 'toffee', 'tomato',
'tooth', 'top', 'torch', 'tower', 'town', 'toy', 'track',
'train', 'tree', 'triangle', 'trick', 'truck', 'trumpet', 'tulip', 'tunnel',
'turkey', 'turtle', 'twig', 'uncle', 'unicorn', 'universe', 'vacuum', 'valley',
'vanilla', 'vase', 'velvet', 'vessel', 'village', 'vine', 'violin',
'voice', 'volcano', 'voyage', 'wagon', 'walnut', 'waltz', 'water',
'wave', 'wax', 'weather', 'web', 'wedding', 'whale', 'wheat',
'wheel', 'whistle', 'whisper', 'willow', 'wind', 'window', 'wine', 'wing',
'winter', 'wire', 'wish', 'wizard', 'wood', 'wool',
'word', 'world', 'wreath', 'wrist', 'writer', 'xylophone', 'yacht',
'yard', 'yarn', 'year', 'yolk', 'zebra', 'zephyr', 'zinc', 'zipper',
'zone', 'zoo'
];
"actor",
"almond",
"amber",
"anchor",
"angel",
"animal",
"answer",
"apple",
"autumn",
"avenue",
"badge",
"bagel",
"baker",
"ballet",
"bamboo",
"banana",
"basket",
"beach",
"beard",
"beauty",
"beetle",
"berry",
"bicycle",
"bird",
"blanket",
"blossom",
"boat",
"bottle",
"bowl",
"breeze",
"bubble",
"bucket",
"button",
"cabin",
"cactus",
"cafe",
"camera",
"candle",
"candy",
"canvas",
"canyon",
"captain",
"carpet",
"carrot",
"castle",
"cave",
"cellar",
"chair",
"chalk",
"cheese",
"cherry",
"chest",
"chicken",
"chimney",
"circus",
"cliff",
"clock",
"cloud",
"clover",
"coast",
"cobalt",
"cocoa",
"coffee",
"coin",
"comet",
"compass",
"cookie",
"copper",
"coral",
"corner",
"cotton",
"cradle",
"craft",
"creek",
"cricket",
"crown",
"crystal",
"cube",
"cupboard",
"curtain",
"cushion",
"daisy",
"dance",
"date",
"dawn",
"deer",
"desert",
"dew",
"diamond",
"dinner",
"dish",
"doctor",
"dolphin",
"donut",
"door",
"dream",
"dress",
"drink",
"drum",
"duck",
"dusk",
"eagle",
"earth",
"echo",
"emerald",
"engine",
"evening",
"face",
"fairy",
"fall",
"family",
"fan",
"farm",
"feather",
"fence",
"ferry",
"field",
"finger",
"fire",
"fish",
"flag",
"flame",
"flash",
"flavor",
"flight",
"floor",
"flour",
"flower",
"flute",
"fog",
"foil",
"forest",
"fork",
"fox",
"frame",
"friend",
"frog",
"frost",
"fruit",
"garden",
"garlic",
"gate",
"gem",
"gift",
"ginger",
"giraffe",
"glacier",
"glass",
"glitter",
"glove",
"glow",
"goat",
"gold",
"grape",
"grass",
"gravel",
"gravity",
"guitar",
"gum",
"hair",
"hammer",
"hand",
"harbor",
"harp",
"hat",
"hawk",
"heart",
"heath",
"heaven",
"helmet",
"herb",
"hill",
"hippo",
"honey",
"hood",
"horn",
"horse",
"hotel",
"hour",
"house",
"hunter",
"ice",
"icicle",
"idea",
"ink",
"insect",
"iron",
"island",
"ivy",
"jacket",
"jade",
"jam",
"jasmine",
"jelly",
"jewel",
"joke",
"journal",
"journey",
"joy",
"judge",
"jungle",
"kettle",
"key",
"kid",
"kingdom",
"kitchen",
"kite",
"kitten",
"knight",
"lab",
"ladder",
"lake",
"lamb",
"lamp",
"land",
"lantern",
"laptop",
"laugh",
"lava",
"lawn",
"leaf",
"legend",
"lemon",
"letter",
"library",
"light",
"lily",
"lime",
"lion",
"lip",
"lobby",
"lock",
"locket",
"lodge",
"lotus",
"love",
"lunch",
"lyric",
"magic",
"magnet",
"mango",
"maple",
"marble",
"market",
"mask",
"meadow",
"melody",
"melon",
"memory",
"metal",
"meteor",
"milk",
"mint",
"mirror",
"mist",
"mitten",
"moon",
"morning",
"moth",
"motor",
"mountain",
"mouse",
"movie",
"muffin",
"museum",
"music",
"myth",
"napkin",
"nectar",
"needle",
"nest",
"net",
"nickel",
"night",
"nose",
"note",
"novel",
"number",
"nurse",
"nutmeg",
"oasis",
"ocean",
"olive",
"onion",
"opera",
"orange",
"orbit",
"orchard",
"orchid",
"ostrich",
"otter",
"oven",
"owl",
"oxygen",
"oyster",
"page",
"paint",
"palace",
"palm",
"pan",
"pancake",
"panda",
"paper",
"parade",
"parcel",
"park",
"parrot",
"party",
"pasta",
"patch",
"path",
"peach",
"peanut",
"pear",
"pearl",
"pebble",
"pencil",
"penny",
"people",
"pepper",
"petal",
"phone",
"photo",
"piano",
"pickle",
"picture",
"pie",
"pillow",
"pine",
"pink",
"pirate",
"pizza",
"planet",
"plant",
"plum",
"pocket",
"poem",
"poet",
"point",
"pony",
"pool",
"popcorn",
"porch",
"port",
"potato",
"powder",
"prairie",
"pretzel",
"prism",
"prose",
"puppet",
"puppy",
"puzzle",
"quail",
"quartz",
"queen",
"quilt",
"rabbit",
"raccoon",
"radio",
"raft",
"rain",
"rainbow",
"raisin",
"ranch",
"rapids",
"raven",
"ray",
"record",
"reef",
"ribbon",
"rice",
"ring",
"river",
"road",
"robin",
"robot",
"rock",
"rocket",
"rodeo",
"roof",
"room",
"root",
"rope",
"rose",
"ruby",
"rug",
"ruler",
"sage",
"sail",
"salad",
"salmon",
"salt",
"sand",
"sandal",
"sauce",
"saucer",
"scale",
"scarf",
"school",
"sea",
"seed",
"shadow",
"shell",
"ship",
"shirt",
"shoe",
"shop",
"shower",
"shrimp",
"side",
"sign",
"silk",
"silver",
"singer",
"sink",
"sky",
"sled",
"sleet",
"sleigh",
"slice",
"slide",
"slipper",
"slope",
"smoke",
"snail",
"snake",
"snow",
"soap",
"sock",
"soda",
"sofa",
"soil",
"song",
"soup",
"spade",
"spark",
"sparrow",
"spice",
"spider",
"spoon",
"spot",
"spring",
"sprout",
"square",
"squirrel",
"stable",
"stage",
"stair",
"stamp",
"star",
"station",
"steam",
"steel",
"stem",
"stick",
"stone",
"stork",
"storm",
"story",
"stove",
"straw",
"stream",
"street",
"string",
"studio",
"sugar",
"summer",
"sun",
"sunset",
"swan",
"sweater",
"sweets",
"sword",
"table",
"tablet",
"tail",
"talent",
"tangerine",
"tank",
"tea",
"team",
"teapot",
"tear",
"temple",
"tennis",
"tent",
"theater",
"thistle",
"thought",
"thread",
"thunder",
"ticket",
"tide",
"tiger",
"tile",
"time",
"toast",
"toffee",
"tomato",
"tooth",
"top",
"torch",
"tower",
"town",
"toy",
"track",
"train",
"tree",
"triangle",
"trick",
"truck",
"trumpet",
"tulip",
"tunnel",
"turkey",
"turtle",
"twig",
"uncle",
"unicorn",
"universe",
"vacuum",
"valley",
"vanilla",
"vase",
"velvet",
"vessel",
"village",
"vine",
"violin",
"voice",
"volcano",
"voyage",
"wagon",
"walnut",
"waltz",
"water",
"wave",
"wax",
"weather",
"web",
"wedding",
"whale",
"wheat",
"wheel",
"whistle",
"whisper",
"willow",
"wind",
"window",
"wine",
"wing",
"winter",
"wire",
"wish",
"wizard",
"wood",
"wool",
"word",
"world",
"wreath",
"wrist",
"writer",
"xylophone",
"yacht",
"yard",
"yarn",
"year",
"yolk",
"zebra",
"zephyr",
"zinc",
"zipper",
"zone",
"zoo",
];
+30 -16
View File
@@ -285,13 +285,16 @@ describe("Admin Routes", () => {
)) as { feeds: Array<{ id: string; title: string }> } | null;
const feedId = feedList?.feeds[0].id as string;
const deleteRes = await request(`/admin/feeds/${feedId}/delete?view=list`, {
method: "POST",
headers: {
Cookie: authCookie,
Accept: "application/json",
const deleteRes = await request(
`/admin/feeds/${feedId}/delete?view=list`,
{
method: "POST",
headers: {
Cookie: authCookie,
Accept: "application/json",
},
},
});
);
expect(deleteRes.status).toBe(200);
const payload = await deleteRes.json();
@@ -334,8 +337,12 @@ describe("Admin Routes", () => {
});
expect(bulkDeleteRes.status).toBe(302);
expect(bulkDeleteRes.headers.get("Location")).toContain("/admin?view=list");
expect(bulkDeleteRes.headers.get("Location")).toContain("message=bulkDeleted");
expect(bulkDeleteRes.headers.get("Location")).toContain(
"/admin?view=list",
);
expect(bulkDeleteRes.headers.get("Location")).toContain(
"message=bulkDeleted",
);
const feedListAfter = (await mockEnv.EMAIL_STORAGE.get(
"feeds:list",
@@ -386,7 +393,9 @@ describe("Admin Routes", () => {
const feedMetadata = (await mockEnv.EMAIL_STORAGE.get(
feedMetadataKey,
"json",
)) as { emails: Array<{ key: string; subject: string; receivedAt: number }> } | null;
)) as {
emails: Array<{ key: string; subject: string; receivedAt: number }>;
} | null;
const updatedMetadata = {
emails: [
...(feedMetadata?.emails || []),
@@ -398,13 +407,16 @@ describe("Admin Routes", () => {
JSON.stringify(updatedMetadata),
);
const deleteRes = await request(`/admin/emails/${emailKey}/delete?feedId=${feedId}`, {
method: "POST",
headers: {
Cookie: authCookie,
Accept: "application/json",
const deleteRes = await request(
`/admin/emails/${emailKey}/delete?feedId=${feedId}`,
{
method: "POST",
headers: {
Cookie: authCookie,
Accept: "application/json",
},
},
});
);
expect(deleteRes.status).toBe(200);
const payload = await deleteRes.json();
@@ -417,7 +429,9 @@ describe("Admin Routes", () => {
const metadataAfter = (await mockEnv.EMAIL_STORAGE.get(
feedMetadataKey,
"json",
)) as { emails: Array<{ key: string; subject: string; receivedAt: number }> } | null;
)) as {
emails: Array<{ key: string; subject: string; receivedAt: number }>;
} | null;
expect(metadataAfter?.emails.length).toBe(0);
});
});
+214 -83
View File
@@ -285,7 +285,9 @@ app.get("/", async (c) => {
layout(
"Dashboard",
html`
<div class="container ${view === "table" ? "container-wide" : ""} fade-in">
<div
class="container ${view === "table" ? "container-wide" : ""} fade-in"
>
<div class="header-with-actions">
<div class="header-title">
<h1>Email to RSS Admin</h1>
@@ -346,15 +348,15 @@ app.get("/", async (c) => {
? html`<div class="card"><p>No feeds were selected.</p></div>`
: ""}
<div class="toolbar">
<div class="toolbar-group">
<h2 style="margin: 0;">Your Feeds</h2>
<span class="pill" id="feed-total-count"
>${feedsWithConfig.length}</span
>
</div>
<div class="toolbar-group">${viewToggle}</div>
</div>
<div class="toolbar">
<div class="toolbar-group">
<h2 style="margin: 0;">Your Feeds</h2>
<span class="pill" id="feed-total-count"
>${feedsWithConfig.length}</span
>
</div>
<div class="toolbar-group">${viewToggle}</div>
</div>
${feedsWithConfig.length === 0
? html`<div class="card">
@@ -362,14 +364,14 @@ app.get("/", async (c) => {
</div>`
: view === "table"
? html`
<div class="card">
<form
id="bulk-feed-delete-form"
action="/admin/feeds/bulk-delete"
method="post"
onsubmit="return onBulkFeedDeleteSubmit(event)"
>
<input type="hidden" name="view" value="table" />
<div class="card">
<form
id="bulk-feed-delete-form"
action="/admin/feeds/bulk-delete"
method="post"
onsubmit="return onBulkFeedDeleteSubmit(event)"
>
<input type="hidden" name="view" value="table" />
<div class="toolbar">
<div class="toolbar-group toolbar-group-fill">
@@ -430,33 +432,101 @@ app.get("/", async (c) => {
onchange="toggleAllFeeds(this.checked)"
/>
</th>
<th class="th-resizable" data-sort-key="title" aria-sort="none">
<button type="button" class="th-button" data-sort-key="title">
Title <span class="sort-indicator" aria-hidden="true"></span>
<th
class="th-resizable"
data-sort-key="title"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="title"
>
Title
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div class="col-resizer" data-col="title" title="Resize"></div>
<div
class="col-resizer"
data-col="title"
title="Resize"
></div>
</th>
<th class="th-resizable" data-sort-key="feedId" aria-sort="none">
<button type="button" class="th-button" data-sort-key="feedId">
Feed ID <span class="sort-indicator" aria-hidden="true"></span>
<th
class="th-resizable"
data-sort-key="feedId"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="feedId"
>
Feed ID
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div class="col-resizer" data-col="feedId" title="Resize"></div>
<div
class="col-resizer"
data-col="feedId"
title="Resize"
></div>
</th>
<th class="th-resizable" data-sort-key="email" aria-sort="none">
<button type="button" class="th-button" data-sort-key="email">
Email <span class="sort-indicator" aria-hidden="true"></span>
<th
class="th-resizable"
data-sort-key="email"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="email"
>
Email
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div class="col-resizer" data-col="email" title="Resize"></div>
<div
class="col-resizer"
data-col="email"
title="Resize"
></div>
</th>
<th class="th-resizable" data-sort-key="rss" aria-sort="none">
<button type="button" class="th-button" data-sort-key="rss">
RSS <span class="sort-indicator" aria-hidden="true"></span>
<th
class="th-resizable"
data-sort-key="rss"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="rss"
>
RSS
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div class="col-resizer" data-col="rss" title="Resize"></div>
<div
class="col-resizer"
data-col="rss"
title="Resize"
></div>
</th>
<th class="th-resizable">
<span>Actions</span>
<div class="col-resizer" data-col="actions" title="Resize"></div>
<div
class="col-resizer"
data-col="actions"
title="Resize"
></div>
</th>
</tr>
</thead>
@@ -470,21 +540,27 @@ app.get("/", async (c) => {
const sortFeedId = feed.id.toLowerCase();
const sortEmail = emailAddress.toLowerCase();
const sortRss = rssUrl.toLowerCase();
const descDisplay = clampText(feed.description || "", 220);
const descHover = clampText(feed.description || "", 1000);
const descDisplay = clampText(
feed.description || "",
220,
);
const descHover = clampText(
feed.description || "",
1000,
);
const searchHaystack =
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
return html`
<tr
class="feed-row"
data-feed-id="${feed.id}"
data-search="${searchHaystack}"
data-sort-title="${sortTitle}"
data-sort-feed-id="${sortFeedId}"
data-sort-email="${sortEmail}"
data-sort-rss="${sortRss}"
>
<tr
class="feed-row"
data-feed-id="${feed.id}"
data-search="${searchHaystack}"
data-sort-title="${sortTitle}"
data-sort-feed-id="${sortFeedId}"
data-sort-email="${sortEmail}"
data-sort-rss="${sortRss}"
>
<td>
<input
type="checkbox"
@@ -495,7 +571,9 @@ app.get("/", async (c) => {
/>
</td>
<td>
<strong class="truncate" title="${titleHover}"
<strong
class="truncate"
title="${titleHover}"
>${titleDisplay}</strong
>
${feed.description
@@ -553,9 +631,7 @@ app.get("/", async (c) => {
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M20 6L9 17l-5-5"
></path>
<path d="M20 6L9 17l-5-5"></path>
</svg>
</div>
</div>
@@ -605,9 +681,7 @@ app.get("/", async (c) => {
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M20 6L9 17l-5-5"
></path>
<path d="M20 6L9 17l-5-5"></path>
</svg>
</div>
</div>
@@ -667,7 +741,10 @@ app.get("/", async (c) => {
const rssUrl = `https://${env.DOMAIN}/rss/${feed.id}`;
const titleDisplay = clampText(feed.title, 140);
const titleHover = clampText(feed.title, 1000);
const descDisplay = clampText(feed.description || "", 240);
const descDisplay = clampText(
feed.description || "",
240,
);
const descHover = clampText(feed.description || "", 1000);
const searchHaystack =
`${clampText(feed.title, 320)} ${feed.id} ${clampText(feed.description || "", 320)}`.toLowerCase();
@@ -684,7 +761,9 @@ app.get("/", async (c) => {
</h3>
${feed.description
? html`<p class="feed-description">
<span title="${descHover}">${descDisplay}</span>
<span title="${descHover}"
>${descDisplay}</span
>
</p>`
: ""}
</div>
@@ -1490,7 +1569,8 @@ app.post("/feeds/create", async (c) => {
const title = formData.get("title")?.toString() || "";
const description = formData.get("description")?.toString();
const language = formData.get("language")?.toString() || "en";
const view = formData.get("view")?.toString() === "table" ? "table" : "list";
const view =
formData.get("view")?.toString() === "table" ? "table" : "list";
const allowedSenders = parseAllowedSenders(
formData.get("allowed_senders")?.toString() || "",
);
@@ -1776,15 +1856,16 @@ async function purgeFeedKeysStep(
listComplete: boolean;
}> {
const prefix = `feed:${feedId}:`;
const limit = Math.min(
1000,
Math.max(1, Math.floor(options.limit || 100)),
);
const limit = Math.min(1000, Math.max(1, Math.floor(options.limit || 100)));
const cursor = options.cursor || undefined;
const listed = await emailStorage.list({ prefix, cursor, limit });
const keys = (listed.keys || []).map((k) => k.name);
const { ok, failed } = await deleteKeysWithConcurrency(emailStorage, keys, 35);
const { ok, failed } = await deleteKeysWithConcurrency(
emailStorage,
keys,
35,
);
return {
deletedKeys: ok,
@@ -1816,7 +1897,10 @@ app.post("/feeds/:feedId/delete", async (c) => {
} catch (error) {
console.error("Error deleting feed:", error);
if (wantsJson) {
return c.json({ ok: false, error: "Error deleting feed. Please try again." }, 400);
return c.json(
{ ok: false, error: "Error deleting feed. Please try again." },
400,
);
}
return c.text("Error deleting feed. Please try again.", 400);
}
@@ -1953,7 +2037,8 @@ app.post("/feeds/bulk-delete", async (c) => {
}
const formData = await c.req.formData();
const view = formData.get("view")?.toString() === "table" ? "table" : "list";
const view =
formData.get("view")?.toString() === "table" ? "table" : "list";
const redirectBase = `/admin?view=${view}`;
const rawIds = formData.getAll("feedIds").map((value) => value.toString());
const parsedFeedIds = Array.from(new Set(rawIds.filter(Boolean)));
@@ -2135,9 +2220,11 @@ app.get("/feeds/:feedId/emails", async (c) => {
</div>
</div>
<h2>
Emails (<span id="email-total-count">${feedMetadata.emails.length}</span>)
</h2>
<h2>
Emails (<span id="email-total-count"
>${feedMetadata.emails.length}</span
>)
</h2>
${message === "bulkDeleted"
? html`<div class="card">
@@ -2149,11 +2236,11 @@ app.get("/feeds/:feedId/emails", async (c) => {
: ""}
${feedMetadata.emails.length > 0
? html`
<form
action="/admin/feeds/${feedId}/emails/bulk-delete"
method="post"
onsubmit="return onBulkEmailDeleteSubmit(event)"
>
<form
action="/admin/feeds/${feedId}/emails/bulk-delete"
method="post"
onsubmit="return onBulkEmailDeleteSubmit(event)"
>
<div class="toolbar">
<div class="toolbar-group toolbar-group-fill">
<input
@@ -2211,21 +2298,57 @@ app.get("/feeds/:feedId/emails", async (c) => {
onchange="toggleAllEmails(this.checked)"
/>
</th>
<th class="th-resizable" data-sort-key="subject" aria-sort="none">
<button type="button" class="th-button" data-sort-key="subject">
Subject <span class="sort-indicator" aria-hidden="true"></span>
<th
class="th-resizable"
data-sort-key="subject"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="subject"
>
Subject
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div class="col-resizer" data-col="subject" title="Resize"></div>
<div
class="col-resizer"
data-col="subject"
title="Resize"
></div>
</th>
<th class="th-resizable" data-sort-key="receivedAt" aria-sort="none">
<button type="button" class="th-button" data-sort-key="receivedAt">
Received <span class="sort-indicator" aria-hidden="true"></span>
<th
class="th-resizable"
data-sort-key="receivedAt"
aria-sort="none"
>
<button
type="button"
class="th-button"
data-sort-key="receivedAt"
>
Received
<span
class="sort-indicator"
aria-hidden="true"
></span>
</button>
<div class="col-resizer" data-col="receivedAt" title="Resize"></div>
<div
class="col-resizer"
data-col="receivedAt"
title="Resize"
></div>
</th>
<th class="th-resizable">
<span>Actions</span>
<div class="col-resizer" data-col="actions" title="Resize"></div>
<div
class="col-resizer"
data-col="actions"
title="Resize"
></div>
</th>
</tr>
</thead>
@@ -2235,7 +2358,10 @@ app.get("/feeds/:feedId/emails", async (c) => {
const subjectHover = clampText(email.subject, 1000);
const sortSubject = subjectHover.toLowerCase();
const sortReceivedAt = String(email.receivedAt);
const searchHaystack = clampText(email.subject, 320).toLowerCase();
const searchHaystack = clampText(
email.subject,
320,
).toLowerCase();
return html`
<tr
@@ -3261,7 +3387,10 @@ app.post("/emails/:emailKey/delete", async (c) => {
} catch (error) {
console.error("Error deleting email:", error);
if (wantsJson) {
return c.json({ ok: false, error: "Error deleting email. Please try again." }, 400);
return c.json(
{ ok: false, error: "Error deleting email. Please try again." },
400,
);
}
return c.text("Error deleting email. Please try again.", 400);
}
@@ -3295,7 +3424,9 @@ app.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
emailKeys?: unknown;
} | null;
const rawEmailKeys = Array.isArray(body?.emailKeys) ? body?.emailKeys : [];
const rawEmailKeys = Array.isArray(body?.emailKeys)
? body?.emailKeys
: [];
const emailKeys = Array.from(
new Set(rawEmailKeys.map((value) => String(value)).filter(Boolean)),
);
+37 -28
View File
@@ -1,6 +1,6 @@
import { Context } from 'hono';
import { Env, FeedConfig, FeedMetadata, EmailData } from '../types';
import { generateRssFeed } from '../utils/feed-generator';
import { Context } from "hono";
import { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
import { generateRssFeed } from "../utils/feed-generator";
/**
* Generates an RSS feed for a specific feed ID
@@ -9,62 +9,71 @@ export async function handle(c: Context): Promise<Response> {
try {
// Type assertion for environment variables
const env = c.env as unknown as Env;
// Extract the feed ID from the route params
const feedId = c.req.param('feedId');
const feedId = c.req.param("feedId");
if (!feedId) {
return new Response('Feed ID is required', { status: 400 });
return new Response("Feed ID is required", { status: 400 });
}
// Get the KV namespace
const emailStorage = env.EMAIL_STORAGE;
// Check if the feed exists
const feedMetadataKey = `feed:${feedId}:metadata`;
const feedMetadata = await emailStorage.get(feedMetadataKey, 'json') as FeedMetadata | null;
const feedMetadata = (await emailStorage.get(
feedMetadataKey,
"json",
)) as FeedMetadata | null;
if (!feedMetadata) {
return new Response('Feed not found', { status: 404 });
return new Response("Feed not found", { status: 404 });
}
// Get feed configuration (title, description, etc.)
const feedConfigKey = `feed:${feedId}:config`;
const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null || {
const feedConfig = ((await emailStorage.get(
feedConfigKey,
"json",
)) as FeedConfig | null) || {
title: `Newsletter Feed ${feedId}`,
description: 'Converted email newsletter',
description: "Converted email newsletter",
site_url: `https://${env.DOMAIN}/rss/${feedId}`,
feed_url: `https://${env.DOMAIN}/rss/${feedId}`,
language: 'en',
created_at: Date.now()
language: "en",
created_at: Date.now(),
};
// Get the emails for this feed (up to the last 20)
const emails = feedMetadata.emails.slice(0, 20);
const emailsData: EmailData[] = [];
// Fetch all email content
for (const email of emails) {
const emailData = await emailStorage.get(email.key, 'json') as EmailData | null;
const emailData = (await emailStorage.get(
email.key,
"json",
)) as EmailData | null;
if (emailData) {
emailsData.push(emailData);
}
}
// Generate the RSS feed XML
const baseUrl = `https://${env.DOMAIN}`;
const rssXml = generateRssFeed(feedConfig, emailsData, baseUrl);
// Return the RSS feed with appropriate content type
return new Response(rssXml, {
status: 200,
headers: {
'Content-Type': 'application/rss+xml',
'Cache-Control': 'max-age=1800' // 30 minutes cache
}
"Content-Type": "application/rss+xml",
"Cache-Control": "max-age=1800", // 30 minutes cache
},
});
} catch (error) {
console.error('Error generating RSS feed:', error);
return new Response('Internal Server Error', { status: 500 });
console.error("Error generating RSS feed:", error);
return new Response("Internal Server Error", { status: 500 });
}
}
}
+1 -1
View File
@@ -61,4 +61,4 @@ export const clipboardScripts = `
form.submit();
}
}
`;
`;
+1 -1
View File
@@ -70,4 +70,4 @@ export const initScripts = `
// Run setup when DOM is fully loaded
document.addEventListener('DOMContentLoaded', initInteractive);
`;
`;
+1 -1
View File
@@ -1014,4 +1014,4 @@ export const componentStyles = `
padding: 0;
margin: 0;
}
`;
`;
+3 -3
View File
@@ -1,7 +1,7 @@
// This file is kept for backwards compatibility
// It re-exports the new modular design system
import { designSystem } from './index';
import { interactiveScripts, authHelpers } from '../scripts/index';
import { designSystem } from "./index";
import { interactiveScripts, authHelpers } from "../scripts/index";
export { designSystem, interactiveScripts, authHelpers };
export { designSystem, interactiveScripts, authHelpers };
+12 -5
View File
@@ -1,10 +1,10 @@
// Main style exports file
// Combines all style components and re-exports them for easy imports
import { variables, lightModeTheme, fontImport } from './variables';
import { layoutStyles } from './layout';
import { componentStyles } from './components';
import { utilityStyles } from './utilities';
import { variables, lightModeTheme, fontImport } from "./variables";
import { layoutStyles } from "./layout";
import { componentStyles } from "./components";
import { utilityStyles } from "./utilities";
// Combine all style components into a single CSS string
export const designSystem = `
@@ -17,4 +17,11 @@ export const designSystem = `
`;
// Re-export everything for modular usage if needed
export { variables, lightModeTheme, fontImport, layoutStyles, componentStyles, utilityStyles };
export {
variables,
lightModeTheme,
fontImport,
layoutStyles,
componentStyles,
utilityStyles,
};
+1 -1
View File
@@ -177,4 +177,4 @@ export const layoutStyles = `
margin-bottom: var(--spacing-md);
color: var(--color-text-primary);
}
`;
`;
+3 -3
View File
@@ -1,8 +1,8 @@
import { Env } from './index';
import { Env } from "./index";
// Extend Hono's types to include our custom environment
declare module 'hono' {
declare module "hono" {
interface ContextVariableMap {
env: Env;
}
}
}
+7 -4
View File
@@ -1,8 +1,11 @@
// Extend mailparser types for Buffer in worker environment
declare module 'buffer-polyfill' {
declare module "buffer-polyfill" {
global {
var Buffer: {
from(data: string, encoding?: string): {
from(
data: string,
encoding?: string,
): {
toString(encoding?: string): string;
};
};
@@ -10,8 +13,8 @@ declare module 'buffer-polyfill' {
}
// Add missing atob declaration
declare module 'atob-polyfill' {
declare module "atob-polyfill" {
global {
function atob(data: string): string;
}
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
declare module 'rss' {
declare module "rss" {
interface RSSOptions {
title: string;
description: string;
@@ -48,4 +48,4 @@ declare module 'rss' {
}
export = RSS;
}
}
+47 -41
View File
@@ -1,4 +1,4 @@
import { EmailData } from '../types';
import { EmailData } from "../types";
/**
* Simple email parser specialized for ForwardEmail.net's webhook format
@@ -14,59 +14,62 @@ export class EmailParser {
const match = emailAddress.match(/^([a-z]+\.[a-z]+\.\d{2})@/i);
return match ? match[1] : null;
}
/**
* Parse email data from ForwardEmail.net's webhook payload
* @param payload ForwardEmail.net webhook payload
*/
static parseForwardEmailPayload(payload: any): EmailData {
if (!payload) {
throw new Error('Missing or invalid webhook payload');
throw new Error("Missing or invalid webhook payload");
}
// Extract the "to" address
const toAddress = payload.recipients?.[0] || '';
const toAddress = payload.recipients?.[0] || "";
// Extract the sender information using ForwardEmail's structure
const fromAddress = payload.from?.text ||
(payload.from?.value?.[0]?.address ?
`${payload.from.value[0].name || ''} <${payload.from.value[0].address}>` :
'Unknown Sender');
const fromAddress =
payload.from?.text ||
(payload.from?.value?.[0]?.address
? `${payload.from.value[0].name || ""} <${payload.from.value[0].address}>`
: "Unknown Sender");
// Extract subject
let subject = payload.subject || 'No Subject';
let subject = payload.subject || "No Subject";
// Decode any encoded words in the subject
subject = this.decodeEncodedWords(subject);
// Get content, preferring HTML over plain text
const content = payload.html || payload.text || '';
const content = payload.html || payload.text || "";
// Create simple email data object
return {
subject,
from: fromAddress,
content,
receivedAt: payload.date ? new Date(payload.date).getTime() : Date.now(),
headers: this.extractHeaders(payload)
headers: this.extractHeaders(payload),
};
}
/**
* Extract headers from ForwardEmail payload
*/
private static extractHeaders(payload: any): Record<string, string> {
const headers: Record<string, string> = {};
// Extract headers from headerLines if available
if (payload.headerLines && Array.isArray(payload.headerLines)) {
payload.headerLines.forEach((h: {key: string; line: string}) => {
payload.headerLines.forEach((h: { key: string; line: string }) => {
const key = h.key.toLowerCase();
const value = h.line.replace(new RegExp(`^${h.key}:\\s*`, 'i'), '').trim();
const value = h.line
.replace(new RegExp(`^${h.key}:\\s*`, "i"), "")
.trim();
headers[key] = value;
});
}
// Or from headers string if provided
else if (typeof payload.headers === 'string') {
else if (typeof payload.headers === "string") {
payload.headers.split(/\r?\n/).forEach((line: string) => {
const match = line.match(/^([^:]+):\s*(.*)$/);
if (match) {
@@ -74,35 +77,38 @@ export class EmailParser {
}
});
}
return headers;
}
/**
* Decode RFC 2047 encoded words in headers
* @param text Text that may contain encoded words like =?UTF-8?Q?Hello_World?=
*/
static decodeEncodedWords(text: string): string {
if (!text) return '';
if (!text) return "";
// Simple RFC 2047 encoded-word decoder
return text.replace(/=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi, (_, charset, encoding, text) => {
if (encoding.toUpperCase() === 'B') {
// Base64 encoding
try {
const decoded = atob(text);
return decoded;
} catch (e) {
return text;
return text.replace(
/=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi,
(_, charset, encoding, text) => {
if (encoding.toUpperCase() === "B") {
// Base64 encoding
try {
const decoded = atob(text);
return decoded;
} catch (e) {
return text;
}
} else if (encoding.toUpperCase() === "Q") {
// Quoted-printable encoding
return this.decodeQuotedPrintable(text.replace(/_/g, " "));
}
} else if (encoding.toUpperCase() === 'Q') {
// Quoted-printable encoding
return this.decodeQuotedPrintable(text.replace(/_/g, ' '));
}
return text;
});
return text;
},
);
}
/**
* Decode quoted-printable encoded text
* @param text Quoted-printable encoded text
@@ -112,4 +118,4 @@ export class EmailParser {
return String.fromCharCode(parseInt(hex, 16));
});
}
}
}
+16 -14
View File
@@ -1,38 +1,40 @@
import { Feed } from 'feed';
import { FeedConfig, EmailData } from '../types';
import { Feed } from "feed";
import { FeedConfig, EmailData } from "../types";
/**
* Generate an RSS feed from a list of emails
*/
export function generateRssFeed(
feedConfig: FeedConfig,
feedConfig: FeedConfig,
emails: EmailData[],
baseUrl: string
baseUrl: string,
): string {
// Create a new feed
const feed = new Feed({
title: feedConfig.title,
description: feedConfig.description || '',
description: feedConfig.description || "",
id: feedConfig.feed_url,
link: feedConfig.site_url,
language: feedConfig.language,
updated: new Date(),
generator: 'Email-to-RSS',
generator: "Email-to-RSS",
copyright: `Copyright © ${new Date().getFullYear()} ${feedConfig.title}`,
feedLinks: {
rss: feedConfig.feed_url
rss: feedConfig.feed_url,
},
author: feedConfig.author ? {
name: feedConfig.author,
email: `noreply@${new URL(feedConfig.site_url).hostname}`
} : undefined
author: feedConfig.author
? {
name: feedConfig.author,
email: `noreply@${new URL(feedConfig.site_url).hostname}`,
}
: undefined,
});
// Add each email as a feed item
for (const email of emails) {
const date = new Date(email.receivedAt);
const uniqueId = `${email.receivedAt}-${Buffer.from(email.subject).toString('base64').substring(0, 10)}`;
const uniqueId = `${email.receivedAt}-${Buffer.from(email.subject).toString("base64").substring(0, 10)}`;
feed.addItem({
title: email.subject,
id: uniqueId,
@@ -50,4 +52,4 @@ export function generateRssFeed(
// Return the RSS feed as XML
return feed.rss2();
}
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { nouns } from '../data/nouns';
import { nouns } from "../data/nouns";
/**
* Generates a random feed ID in the format noun1.noun2.XY
@@ -8,10 +8,10 @@ export function generateFeedId(): string {
// Select two random nouns
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
// Generate a random 2-digit number between 10 and 99
const number = Math.floor(Math.random() * 90) + 10;
// Combine to create the ID with dots as separators
return `${noun1}.${noun2}.${number}`;
}
}
+52 -35
View File
@@ -1,4 +1,10 @@
import { EmailData, FeedConfig, FeedMetadata, FeedList, EmailMetadata } from '../types';
import {
EmailData,
FeedConfig,
FeedMetadata,
FeedList,
EmailMetadata,
} from "../types";
/**
* Store email data in KV
@@ -6,22 +12,22 @@ import { EmailData, FeedConfig, FeedMetadata, FeedList, EmailMetadata } from '..
export async function storeEmail(
kv: KVNamespace,
feedId: string,
emailData: EmailData
emailData: EmailData,
): Promise<string> {
// Generate a unique key for this email
const timestamp = Date.now();
const key = `feed:${feedId}:email:${timestamp}`;
// Store the email content
await kv.put(key, JSON.stringify(emailData));
// Update the feed's metadata (list of emails)
await updateFeedMetadata(kv, feedId, {
key,
subject: emailData.subject,
receivedAt: timestamp
receivedAt: timestamp,
});
return key;
}
@@ -31,21 +37,23 @@ export async function storeEmail(
async function updateFeedMetadata(
kv: KVNamespace,
feedId: string,
emailMetadata: EmailMetadata
emailMetadata: EmailMetadata,
): Promise<void> {
const feedMetadataKey = `feed:${feedId}:metadata`;
const existingMetadata = await kv.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null;
const existingMetadata = (await kv.get(feedMetadataKey, {
type: "json",
})) as FeedMetadata | null;
const metadata: FeedMetadata = existingMetadata || { emails: [] };
// Add new email to the beginning of the list
metadata.emails.unshift(emailMetadata);
// Keep only the last 50 emails in the metadata
if (metadata.emails.length > 50) {
metadata.emails = metadata.emails.slice(0, 50);
}
// Store updated metadata
await kv.put(feedMetadataKey, JSON.stringify(metadata));
}
@@ -55,10 +63,12 @@ async function updateFeedMetadata(
*/
export async function getFeedMetadata(
kv: KVNamespace,
feedId: string
feedId: string,
): Promise<FeedMetadata | null> {
const feedMetadataKey = `feed:${feedId}:metadata`;
return await kv.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null;
return (await kv.get(feedMetadataKey, {
type: "json",
})) as FeedMetadata | null;
}
/**
@@ -66,10 +76,10 @@ export async function getFeedMetadata(
*/
export async function getFeedConfig(
kv: KVNamespace,
feedId: string
feedId: string,
): Promise<FeedConfig | null> {
const feedConfigKey = `feed:${feedId}:config`;
return await kv.get(feedConfigKey, { type: 'json' }) as FeedConfig | null;
return (await kv.get(feedConfigKey, { type: "json" })) as FeedConfig | null;
}
/**
@@ -77,9 +87,9 @@ export async function getFeedConfig(
*/
export async function getEmailData(
kv: KVNamespace,
key: string
key: string,
): Promise<EmailData | null> {
return await kv.get(key, { type: 'json' }) as EmailData | null;
return (await kv.get(key, { type: "json" })) as EmailData | null;
}
/**
@@ -88,18 +98,21 @@ export async function getEmailData(
export async function createFeed(
kv: KVNamespace,
feedId: string,
feedConfig: FeedConfig
feedConfig: FeedConfig,
): Promise<void> {
// Store feed configuration
const feedConfigKey = `feed:${feedId}:config`;
await kv.put(feedConfigKey, JSON.stringify(feedConfig));
// Create empty metadata for the feed
const feedMetadataKey = `feed:${feedId}:metadata`;
await kv.put(feedMetadataKey, JSON.stringify({
emails: []
}));
await kv.put(
feedMetadataKey,
JSON.stringify({
emails: [],
}),
);
// Add feed to the list of all feeds
await addFeedToList(kv, feedId, feedConfig.title, feedConfig.description);
}
@@ -111,19 +124,21 @@ export async function addFeedToList(
kv: KVNamespace,
feedId: string,
title: string,
description?: string
description?: string,
): Promise<void> {
const feedListKey = 'feeds:list';
const existingList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null;
const feedListKey = "feeds:list";
const existingList = (await kv.get(feedListKey, {
type: "json",
})) as FeedList | null;
const feedList: FeedList = existingList || { feeds: [] };
feedList.feeds.push({
id: feedId,
title,
description
description,
});
await kv.put(feedListKey, JSON.stringify(feedList));
}
@@ -131,8 +146,10 @@ export async function addFeedToList(
* Get all feeds
*/
export async function getAllFeeds(kv: KVNamespace): Promise<FeedList> {
const feedListKey = 'feeds:list';
const feedList = await kv.get(feedListKey, { type: 'json' }) as FeedList | null;
const feedListKey = "feeds:list";
const feedList = (await kv.get(feedListKey, {
type: "json",
})) as FeedList | null;
return feedList || { feeds: [] };
}
}