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