mail based rss feed aggregator
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

remove `subscription.gleam` make `rss.Location` use `uri.Uri` for the link also added some sql stuff

(this commit is probably broken)

ollie 55b32332 9272f65b

+230 -50
+5 -6
dev/insert_test_data.gleam
··· 15 15 16 16 import eater/database 17 17 import eater/feed/rss 18 - import eater/subscription 19 18 import eater/user 20 19 import envoy 21 20 import gleam/io 22 21 import gleam/result 22 + import gleam/uri 23 23 import logging 24 24 import sqlight 25 25 ··· 38 38 io.println("Inserted test user") 39 39 40 40 // go subscribe to mar's blog if you havnt already 41 - let example_feed = rss.new_location("https://strawmelonjuice.com/feed.xml") 41 + let assert Ok(example_feed) = 42 + uri.parse("https://strawmelonjuice.com/feed.xml") 43 + |> result.map(rss.new_location) 42 44 43 45 let assert Ok(_) = database.add_feed(example_feed, into: database) 44 46 as "Failed to insert example feed" ··· 46 48 io.println("Inserted example feed") 47 49 48 50 let assert Ok(_) = 49 - database.add_subscription( 50 - subscription.Subscription(feed: example_feed, user: test_user), 51 - into: database, 52 - ) 51 + database.add_subscription(test_user, example_feed, into: database) 53 52 as "Failed to insert example subscription" 54 53 io.println("Inserted example subscription") 55 54
+114 -16
src/eater/database.gleam
··· 17 17 18 18 import eater/feed/rss 19 19 import eater/sql 20 - import eater/subscription 21 20 import eater/user 22 21 import gleam/dynamic/decode 23 22 import gleam/list 24 23 import gleam/option.{Some} 25 24 import gleam/result 25 + import gleam/uri 26 26 import parrot/dev 27 27 import sqlight 28 28 import youid/uuid ··· 30 30 // feeds ------------------------------------------------------------------------ 31 31 32 32 /// add a new feed to the database 33 + /// returns the feed for convenience 33 34 /// 34 35 pub fn add_feed( 35 36 feed feed: rss.Location, 36 37 into on: sqlight.Connection, 37 - ) -> Result(Nil, sqlight.Error) { 38 + ) -> Result(rss.Location, sqlight.Error) { 38 39 let #(sql, with) = 39 40 sql.add_feed( 40 41 feed.id |> uuid.to_bit_array(), 41 - feed.link, 42 + feed.link |> uri.to_string(), 42 43 failed_n_times: feed.failed_n_times, 43 44 skip_n_times: feed.skip_n_times, 44 45 ) ··· 46 47 let with = list.map(with, parrot_to_sqlight) 47 48 48 49 sqlight.query(sql, on:, with:, expecting: decode.success("")) 49 - |> result.replace(Nil) 50 + |> result.replace(feed) 50 51 } 51 52 52 53 /// get all feeds from the database ··· 62 63 let assert Ok(id) = uuid.from_bit_array(feed.id) 63 64 as "invalid UUID from db UUID column?!" 64 65 66 + let assert Ok(link) = uri.parse(feed.link) as "Invalid uri from database?!" 67 + 65 68 rss.Location( 66 69 id:, 67 - link: feed.link, 70 + link:, 68 71 failed_n_times: feed.failed_n_times, 69 72 skip_n_times: feed.skip_n_times, 70 73 ) 74 + }) 75 + |> Ok 76 + } 77 + 78 + /// get a feed from the database using its link 79 + /// 80 + pub fn feed_by_link( 81 + uri uri: uri.Uri, 82 + from on: sqlight.Connection, 83 + ) -> Result(List(rss.Location), sqlight.Error) { 84 + let #(sql, with, expecting) = sql.feed_by_link(uri.to_string(uri)) 85 + 86 + let with = list.map(with, parrot_to_sqlight) 87 + 88 + use feeds <- result.try(sqlight.query(sql, on:, with:, expecting:)) 89 + 90 + // it is a `:one` query with limit 1 91 + list.map(feeds, fn(feed) { 92 + let sql.FeedByLink(id:, link:, skip_n_times:, failed_n_times:) = feed 93 + 94 + let assert Ok(id) = uuid.from_bit_array(id) 95 + as "invalid UUID from db UUID column?!" 96 + 97 + let assert Ok(link) = uri.parse(link) as "Invalid uri from database?!" 98 + 99 + rss.Location(id:, link:, failed_n_times:, skip_n_times:) 71 100 }) 72 101 |> Ok 73 102 } ··· 237 266 // subscriptions ---------------------------------------------------------------- 238 267 239 268 /// add a given subscription to the database 269 + /// returns the feed on success for convenience 240 270 /// 241 271 pub fn add_subscription( 242 - subscription: subscription.Subscription, 272 + user: user.User, 273 + feed: rss.Location, 243 274 into on: sqlight.Connection, 244 - ) -> Result(Nil, sqlight.Error) { 275 + ) -> Result(rss.Location, sqlight.Error) { 245 276 let #(sql, with) = 246 277 sql.add_subscription( 247 - subscription.user.id |> uuid.to_bit_array, 248 - subscription.feed.id |> uuid.to_bit_array, 278 + user.id |> uuid.to_bit_array, 279 + feed.id |> uuid.to_bit_array, 249 280 ) 250 281 251 282 let with = list.map(with, parrot_to_sqlight) 252 283 253 284 sqlight.query(sql, on:, with:, expecting: decode.success("")) 254 - |> result.replace(Nil) 285 + |> result.replace(feed) 286 + } 287 + 288 + /// remove a given subscription from the database 289 + /// returns the feed in the `Ok()` for convenience 290 + /// 291 + pub fn delete_subscription( 292 + user: user.User, 293 + feed: rss.Location, 294 + database on: sqlight.Connection, 295 + ) -> Result(rss.Location, sqlight.Error) { 296 + let #(sql, with) = 297 + sql.delete_subsciption( 298 + user.id |> uuid.to_bit_array, 299 + feed.id |> uuid.to_bit_array, 300 + ) 301 + 302 + let with = list.map(with, parrot_to_sqlight) 303 + 304 + sqlight.query(sql, on:, with:, expecting: decode.success("")) 305 + |> result.replace(feed) 255 306 } 256 307 257 308 /// feeds a given user is subscribed to ··· 264 315 265 316 let with = list.map(with, parrot_to_sqlight) 266 317 267 - use subscriptions <- result.try(sqlight.query(sql, on:, with:, expecting:)) 318 + use feeds <- result.try(sqlight.query(sql, on:, with:, expecting:)) 268 319 269 - list.filter_map(subscriptions, fn(subscription) { 320 + list.filter_map(feeds, fn(feed) { 270 321 { 271 - use id <- option.then(subscription.feed_id) 322 + use id <- option.then(feed.feed_id) 272 323 let assert Ok(id) = uuid.from_bit_array(id) 273 324 as "Invalid UUID from datbase?!" 274 325 275 - use link <- option.then(subscription.feed_link) 276 - use failed_n_times <- option.then(subscription.feed_failed) 277 - use skip_n_times <- option.then(subscription.feed_skip) 326 + use link <- option.then(feed.feed_link) 327 + use failed_n_times <- option.then(feed.feed_failed) 328 + use skip_n_times <- option.then(feed.feed_skip) 329 + 330 + let assert Ok(link) = uri.parse(link) as "Invalid uri from database?!" 278 331 279 332 rss.Location(id:, link:, failed_n_times:, skip_n_times:) 280 333 |> Some 281 334 } 282 335 |> option.to_result(Nil) 336 + }) 337 + |> Ok 338 + } 339 + 340 + /// feeds a given user is subscribed to 341 + /// 342 + pub fn subscription_count( 343 + for feed: rss.Location, 344 + in on: sqlight.Connection, 345 + ) -> Result(Int, sqlight.Error) { 346 + let #(sql, with, expecting) = 347 + sql.feed_subscription_count(feed.id |> uuid.to_bit_array) 348 + 349 + let with = list.map(with, parrot_to_sqlight) 350 + 351 + use count <- result.try(sqlight.query(sql, on:, with:, expecting:)) 352 + 353 + case count { 354 + [] -> Ok(0) 355 + [count, ..] -> Ok(count.count) 356 + } 357 + } 358 + 359 + pub fn subscription_count_per_feed( 360 + in on: sqlight.Connection, 361 + ) -> Result(List(#(rss.Location, Int)), sqlight.Error) { 362 + let #(sql, with, expecting) = sql.subscriptions_per_feed() 363 + 364 + use feeds <- result.try(sqlight.query(sql, on:, with:, expecting:)) 365 + 366 + list.map(feeds, fn(feed) { 367 + let assert Ok(id) = uuid.from_bit_array(feed.id) 368 + as "invalid UUID from db UUID column?!" 369 + 370 + let assert Ok(link) = uri.parse(feed.link) as "Invalid uri from database?!" 371 + 372 + #( 373 + rss.Location( 374 + id:, 375 + link:, 376 + failed_n_times: feed.failed_n_times, 377 + skip_n_times: feed.skip_n_times, 378 + ), 379 + feed.count, 380 + ) 283 381 }) 284 382 |> Ok 285 383 }
+3 -2
src/eater/feed/rss.gleam
··· 17 17 // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 18 19 19 import gleam/dynamic/decode 20 + import gleam/uri 20 21 import youid/uuid 21 22 22 23 pub type Feed { ··· 85 86 /// try to fetch if = 0 86 87 /// on failure: set = failed_n_times * 2 87 88 /// 88 - Location(id: uuid.Uuid, link: String, failed_n_times: Int, skip_n_times: Int) 89 + Location(id: uuid.Uuid, link: uri.Uri, failed_n_times: Int, skip_n_times: Int) 89 90 } 90 91 91 92 /// creates a new Location using the given `link` 92 93 /// sets a new uuid 93 94 /// 94 - pub fn new_location(link link: String) { 95 + pub fn new_location(link link: uri.Uri) { 95 96 Location(id: uuid.v7(), link:, failed_n_times: 0, skip_n_times: 0) 96 97 } 97 98
+80 -4
src/eater/sql.gleam
··· 66 66 ]) 67 67 } 68 68 69 + pub type FeedByLink { 70 + FeedByLink(id: BitArray, link: String, skip_n_times: Int, failed_n_times: Int) 71 + } 72 + 73 + pub fn feed_by_link(link link: String) { 74 + let sql = 75 + "SELECT id, link, skip_n_times, failed_n_times FROM feeds WHERE link = ? LIMIT 1" 76 + #(sql, [dev.ParamString(link)], feed_by_link_decoder()) 77 + } 78 + 79 + pub fn feed_by_link_decoder() -> decode.Decoder(FeedByLink) { 80 + use id <- decode.field(0, decode.bit_array) 81 + use link <- decode.field(1, decode.string) 82 + use skip_n_times <- decode.field(2, decode.int) 83 + use failed_n_times <- decode.field(3, decode.int) 84 + decode.success(FeedByLink(id:, link:, skip_n_times:, failed_n_times:)) 85 + } 86 + 69 87 pub fn add_user( 70 88 id id: BitArray, 71 89 email email: String, ··· 180 198 #(sql, [dev.ParamBitArray(user_id), dev.ParamBitArray(feed_id)]) 181 199 } 182 200 183 - pub fn delete_subsciption_for_user( 184 - user_id user_id: BitArray, 185 - feed_id feed_id: BitArray, 186 - ) { 201 + pub fn delete_subsciption(user_id user_id: BitArray, feed_id feed_id: BitArray) { 187 202 let sql = 188 203 "DELETE FROM subscriptions 189 204 WHERE ··· 226 241 use feed_failed <- decode.field(3, decode.optional(decode.int)) 227 242 decode.success(FeedsForUser(feed_id:, feed_link:, feed_skip:, feed_failed:)) 228 243 } 244 + 245 + pub type FeedSubscriptionCount { 246 + FeedSubscriptionCount(count: Int) 247 + } 248 + 249 + pub fn feed_subscription_count(feed_id feed_id: BitArray) { 250 + let sql = 251 + "SELECT count(user_id) FROM subscriptions 252 + WHERE 253 + feed_id = ? 254 + LIMIT 1" 255 + #(sql, [dev.ParamBitArray(feed_id)], feed_subscription_count_decoder()) 256 + } 257 + 258 + pub fn feed_subscription_count_decoder() -> decode.Decoder( 259 + FeedSubscriptionCount, 260 + ) { 261 + use count <- decode.field(0, decode.int) 262 + decode.success(FeedSubscriptionCount(count:)) 263 + } 264 + 265 + pub type SubscriptionsPerFeed { 266 + SubscriptionsPerFeed( 267 + id: BitArray, 268 + link: String, 269 + skip_n_times: Int, 270 + failed_n_times: Int, 271 + count: Int, 272 + ) 273 + } 274 + 275 + pub fn subscriptions_per_feed() { 276 + let sql = 277 + "SELECT 278 + feeds.id, 279 + feeds.link, 280 + feeds.skip_n_times, 281 + feeds.failed_n_times, 282 + count(subscriptions.user_id) 283 + FROM 284 + feeds 285 + LEFT JOIN 286 + subscriptions 287 + ON feeds.id = subscriptions.feed_id" 288 + #(sql, [], subscriptions_per_feed_decoder()) 289 + } 290 + 291 + pub fn subscriptions_per_feed_decoder() -> decode.Decoder(SubscriptionsPerFeed) { 292 + use id <- decode.field(0, decode.bit_array) 293 + use link <- decode.field(1, decode.string) 294 + use skip_n_times <- decode.field(2, decode.int) 295 + use failed_n_times <- decode.field(3, decode.int) 296 + use count <- decode.field(4, decode.int) 297 + decode.success(SubscriptionsPerFeed( 298 + id:, 299 + link:, 300 + skip_n_times:, 301 + failed_n_times:, 302 + count:, 303 + )) 304 + }
+4
src/eater/sql/feeds.sql
··· 32 32 -- name: UpdateFeedCooldown :exec 33 33 UPDATE feeds SET skip_n_times = ?, failed_n_times = ? 34 34 WHERE id = ?; 35 + 36 + -- name: FeedByLink :one 37 + SELECT * FROM feeds WHERE link = ? LIMIT 1; 38 +
+24 -1
src/eater/sql/subscriptions.sql
··· 21 21 values 22 22 (?, ?); 23 23 24 - -- name: DeleteSubsciptionForUser :exec 24 + -- name: DeleteSubsciption :exec 25 25 DELETE FROM subscriptions 26 26 WHERE 27 27 user_id = ? ··· 42 42 ON 43 43 subscriptions.feed_id = feeds.id 44 44 AND subscriptions.user_id = ?; 45 + 46 + 47 + 48 + -- name: FeedSubscriptionCount :one 49 + SELECT count(user_id) FROM subscriptions 50 + WHERE 51 + feed_id = ? 52 + LIMIT 1; 53 + 54 + 55 + -- name: SubscriptionsPerFeed :many 56 + SELECT 57 + feeds.id, 58 + feeds.link, 59 + feeds.skip_n_times, 60 + feeds.failed_n_times, 61 + count(subscriptions.user_id) 62 + FROM 63 + feeds 64 + LEFT JOIN 65 + subscriptions 66 + ON feeds.id = subscriptions.feed_id; 67 +
-21
src/eater/subscription.gleam
··· 1 - // eater 2 - // Copyright (C) 2026 Olivia Streun and contributors. [cite: 4] 3 - // 4 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 5 - // You may not use this work except in compliance with the Licence. 6 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 7 - // 8 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 9 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 10 - // See LICENSE file in the repository root for full details. 11 - // 12 - // 13 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 14 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 15 - 16 - import eater/feed/rss 17 - import eater/user 18 - 19 - pub type Subscription { 20 - Subscription(user: user.User, feed: rss.Location) 21 - }