From bde50585ac57854642e4e556782df813948a8528 Mon Sep 17 00:00:00 2001 From: Josh Kasuboski Date: Thu, 18 Jun 2026 23:16:09 +0100 Subject: [PATCH 1/7] gleam start --- .pi/skills/gleam-testing/SKILL.md | 374 ++++++++++++++++++++++++++++++ AGENTS.md | 364 +---------------------------- PLAN.md | 173 ++++++++++++++ SCOUTING_REPORT.md | 339 +++++++++++++++++++++++++++ 4 files changed, 889 insertions(+), 361 deletions(-) create mode 100644 .pi/skills/gleam-testing/SKILL.md create mode 100644 PLAN.md create mode 100644 SCOUTING_REPORT.md diff --git a/.pi/skills/gleam-testing/SKILL.md b/.pi/skills/gleam-testing/SKILL.md new file mode 100644 index 0000000..6932e36 --- /dev/null +++ b/.pi/skills/gleam-testing/SKILL.md @@ -0,0 +1,374 @@ +--- +name: gleam-testing +description: Best practices for writing Gleam tests with gleeunit. Use when writing or reviewing Gleam test files, fixing silently-passing tests, or choosing assertion patterns. +--- + +# Gleam Testing Best Practices + +## Critical: gleeunit Only Fails on Panics + +Gleeunit tests pass if the function completes without panicking. **Returning `False` does NOT fail the test.** This is the #1 source of silently-passing bogus tests. + +```gleam +// ❌ WRONG — returns False, test silently passes +pub fn my_test() { + value == "expected" +} + +// ✅ CORRECT — panics on mismatch +pub fn my_test() { + assert value == "expected" +} +``` + +The same applies to boolean expressions as the last line of any test. A bare `count == 4` evaluates and discards. Use assertions. + +## Two Assertion Constructs — Do Not Confuse Them + +Gleam has **two** distinct assertion mechanisms. They look similar but serve different purposes: + +### `assert ` — Boolean Assertion + +Evaluates the expression. Panics if it's `False`, continues if it's `True`. + +```gleam +assert value == "expected" +assert count == 4 +assert mode == "wal" || mode == "memory" +assert string.contains(output, "hello") +assert !string.contains(output, "config") +assert keys == ["alpha", "beta"] +``` + +### `let assert = ` — Assertive Pattern Match + +Evaluates the expression, then pattern-matches against the pattern. Panics if the pattern doesn't match. Binds variables from the pattern. + +```gleam +let assert Ok(value) = result // unwraps Ok, panics on Error +let assert Error(e) = result // unwraps Error, panics on Ok +let assert Some(value) = option_value // unwraps Some, panics on None +let assert None = maybe_thing // panics if Some +let assert [item] = list_with_one // panics unless exactly one element +let assert Ok(Nil) = some_operation() // asserts Ok(Nil) specifically +``` + +## Assertion Patterns — When to Use What + +| Situation | Use | +|---|---| +| Check exact equality | `assert actual == expected` | +| Check inequality | `assert actual != expected` | +| Compound boolean (OR, AND) | `assert a \|\| b` | +| Assert string/list contains | `assert string.contains(s, "x")` | +| Assert Result is Ok AND use the value | `let assert Ok(val) = result` | +| Assert Result is Ok, don't need value | `let assert Ok(Nil) = result` | +| Assert Result is Error | `let assert Error(e) = result` | +| Assert specific error variant | `let assert Error(NotFound(key: k)) = ...` | +| Assert Option is Some | `let assert Some(v) = option` | +| Assert Option is None | `let assert None = option` | +| Assert list has exactly one element | `let assert [item] = list` | + +## Common Pitfalls + +### Bare boolean as last expression +```gleam +// ❌ Silent pass on False +pub fn test_something() { + let assert Ok(value) = compute() + value == "expected" // <-- returned, not asserted +} + +// ✅ Fix +pub fn test_something() { + let assert Ok(value) = compute() + assert value == "expected" +} +``` + +### Weak vs strong assertions on Results +```gleam +// Weak — only checks it's Ok, not the value +let assert Ok(_) = result + +// Stronger — checks the exact value +let assert Ok("expected") = result +``` + +### Chaining assertions +```gleam +let assert Ok(content) = read(conn, "/file.txt") +assert content == "hello" +``` + +### assert inside case expressions +```gleam +// ❌ WRONG — `assert` is a statement, not a case branch expression +case msg { + message.Assistant(content:, ..) -> + assert string.contains(content, "hello") + _ -> panic as "expected Assistant" +} + +// ✅ Fix — use a block +case msg { + message.Assistant(content:, ..) -> { + assert string.contains(content, "hello") + Nil + } + _ -> panic as "expected Assistant" +} + +// ✅ Or better — destructure first, then assert +let assert message.Assistant(content:, ..) = msg +assert string.contains(content, "hello") +``` + +## Test File Structure + +```gleam +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +fn with_db(f: fn(sqlight.Connection) -> a) -> a { + let assert Ok(conn) = sqlight.open(":memory:") + let assert Ok(Nil) = schema.init(conn) + let result = f(conn) + let assert Ok(Nil) = sqlight.close(conn) + result +} + +pub fn my_feature_test() { + with_db(fn(conn) { + let assert Ok(Nil) = write(conn, "/file.txt", "hello") + let assert Ok(content) = read(conn, "/file.txt") + assert content == "hello" + }) +} +``` + +## Testing OTP Actors and Processes + +### The Golden Rule: Never Use `process.sleep` + +**Never use `process.sleep` in tests.** It is brittle, slow, and flaky: + +- **Race conditions**: Sleep durations are arbitrary guesses. Too short = flakes. Too long = slow suite. +- **Fragile**: System load, scheduler behavior, or actor changes break your timing assumptions. +- **Unnecessary**: Gleam/OTP gives you `process.receive` and `Subject` for deterministic sync. + +Instead, use one of the synchronization patterns below. + +### Pattern 1: The `send_and_confirm` Synchronization Pattern + +The fundamental building block for testing any actor that fans out or forwards messages: + +1. **Start the actor** and get its `Subject` +2. **Register a test `Subject`** as a consumer/subscriber via the actor's public API +3. **Send a message** to the actor through its public API +4. **`process.receive` on the test subject** to block until the actor finishes processing +5. **Now assert** on side effects or the received message itself + +```gleam +import gleam/erlang/process + +// Generic helper — adapt the types to your actor +fn send_and_confirm( + actor: process.Subject(actor_message), + msg: actor_message, + test_consumer: process.Subject(output), +) -> output { + process.send(actor, msg) + let assert Ok(received) = process.receive(test_consumer, 2000) + received +} + +pub fn my_actor_test() { + let assert Ok(actor) = my_actor.start() + let consumer = process.new_subject() + process.send(actor, my_actor.Subscribe(consumer)) + + let received = send_and_confirm(actor, my_actor.DoSomething("hello"), consumer) + assert received == expected + + process.send(actor, my_actor.Stop) +} +``` + +**Why this works:** If the actor processes the message (including any side effects) *before* forwarding to consumers, then receiving on the consumer guarantees all side effects completed. No race, no sleep. + +### Pattern 2: The Test Listener (Instrumented Side-Effect Capture) + +For actors that produce side effects you cannot observe via consumer subscription (telemetry, logging, metrics), use a **test listener**: a lightweight observer that attaches through the public interface and captures outputs for assertion. + +The test listener does NOT mock or replace the actor. The actor runs its real code path. The listener is purely an observation tool. + + +**Steps:** +1. **Attach** the listener before starting the actor +2. **Start the actor** and register a test consumer +3. **Send a message** and confirm via `send_and_confirm` +4. **Query the listener** for captured side effects +5. **Assert** on the captured data +6. **Detach** the listener in cleanup + +```gleam +pub fn test_actor_produces_side_effect() { + let listener = my_listener.attach() // 1. attach observer + let assert Ok(actor) = my_actor.start() // 2. start actor + let consumer = process.new_subject() + process.send(actor, my_actor.Subscribe(consumer)) + + send_and_confirm(actor, my_actor.DoX(), consumer) // 3. exercise + + let captured = my_listener.get_events(listener) // 4. query + let assert [XHappened(detail:)] = captured // 5. assert + assert detail == "expected" + + my_listener.detach(listener) // 6. cleanup + process.send(actor, my_actor.Stop) +} +``` + +**Negative assertions** work the same way — confirm the message was processed, then assert the listener captured nothing: + +```gleam + send_and_confirm(actor, my_actor.DoY(), consumer) + let assert [] = my_listener.get_events(listener) +``` + +### Pattern 3: Setup/Cleanup Helpers + +Combine actor start + consumer registration + listener attachment into reusable helpers. Return a tuple of `(handles, cleanup_fn)`: + +```gleam +fn setup() { + let assert Ok(actor) = my_actor.start() + let consumer = process.new_subject() + process.send(actor, my_actor.Subscribe(consumer)) + #( + #(actor, consumer), + fn() { process.send(actor, my_actor.Stop) }, + ) +} + +fn setup_with_listener() { + let listener = my_listener.attach() + let assert Ok(actor) = my_actor.start() + let consumer = process.new_subject() + process.send(actor, my_actor.Subscribe(consumer)) + #( + #(actor, consumer, listener), + fn() { + my_listener.detach(listener) + process.send(actor, my_actor.Stop) + }, + ) +} + +pub fn test_with_helper() { + let #(#(actor, consumer, listener), cleanup) = setup_with_listener() + // ... test code ... + cleanup() +} +``` + +### Concrete Example: Dispatcher Actor Tests + +Here is how the patterns above look in practice, from `test/pig/obs/dispatcher_test.gleam`. The dispatcher is an actor that receives `SessionEvent`s, emits `:telemetry` as a side effect, then fans out to registered consumers. + +```gleam +// The send_and_confirm helper — sends event, blocks until consumer confirms receipt +fn send_and_confirm( + disp: process.Subject(dispatcher.DispatcherMessage), + event: events.SessionEvent, + consumer: process.Subject(events.SessionEvent), +) -> events.SessionEvent { + process.send(disp, dispatcher.Event(event)) + let assert Ok(received) = process.receive(consumer, 2000) + received +} + +// Setup helper with a :telemetry listener attached (Pattern 3) +fn setup_with_listener() { + let handle = listener.attach() // attach telemetry capture + let assert Ok(disp) = dispatcher.start() + let consumer = process.new_subject() + process.send(disp, dispatcher.RegisterConsumer(consumer)) + #( + #(disp, consumer, handle), + fn() { + listener.detach(handle) + process.send(disp, dispatcher.Stop) + }, + ) +} + +// Test: positive assertion — event produces the right telemetry (Pattern 2) +pub fn dispatcher_emits_inference_start_telemetry_test() { + let #(#(disp, consumer, handle), cleanup) = setup_with_listener() + + let event = InferenceStarted(model: "gpt-4", message_count: 3) + send_and_confirm(disp, event, consumer) + + let captured = listener.get_events(handle) + let assert [InferenceStart(model:, message_count:)] = captured + assert model == "gpt-4" + assert message_count == 3 + + cleanup() +} + +// Test: negative assertion — event does NOT produce telemetry (Pattern 2) +pub fn dispatcher_does_not_emit_telemetry_for_session_ended_test() { + let #(#(disp, consumer, handle), cleanup) = setup_with_listener() + + send_and_confirm(disp, SessionEnded(reason: NormalEnd), consumer) + let assert [] = listener.get_events(handle) + + cleanup() +} +``` + +### Testing Resilience (Dead Consumers, Dynamic Registration) + +These are specializations of Pattern 1, using `send_and_confirm` to prove the actor is still alive after adverse conditions: + +**Dead consumer resilience** — register an abandoned subject, send an event (actor must not crash), then prove it's alive by registering a live consumer and sending again: + +```gleam +pub fn test_dead_consumer_does_not_crash_actor() { + let assert Ok(actor) = my_actor.start() + let dead = process.new_subject() + process.send(actor, my_actor.Subscribe(dead)) + process.send(actor, my_actor.DoSomething()) // must not crash + + // Prove actor is still alive with a new consumer + let live = process.new_subject() + process.send(actor, my_actor.Subscribe(live)) + let received = send_and_confirm(actor, my_actor.DoSomethingElse(), live) + // assert on received... + + process.send(actor, my_actor.Stop) +} +``` + +**Dynamic registration** — send before any consumer exists (nobody receives), then register and confirm the next message arrives: + +```gleam +pub fn test_dynamic_registration() { + let assert Ok(actor) = my_actor.start() + process.send(actor, my_actor.Event(first)) // no consumer yet + + let consumer = process.new_subject() + process.send(actor, my_actor.Subscribe(consumer)) + + let received = send_and_confirm(actor, my_actor.Event(second), consumer) + assert received == second + + process.send(actor, my_actor.Stop) +} +``` diff --git a/AGENTS.md b/AGENTS.md index 79bf93e..72a3494 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,39 +1,10 @@ -This is a web application written using the Phoenix web framework. +This is a feedreader in the process of being migrated to the gleam programming language. ## Project guidelines - Use `mise run pre-commit` alias when you are done with all changes and fix any pending issues -- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps - -### Phoenix v1.8 guidelines - -- **Always** begin your LiveView templates with `` which wraps all inner content -- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again -- Anytime you run into errors with no `current_scope` assign: - - You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `` - - **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed -- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module -- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar -- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors -- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg" />`) with your own values, no default classes are inherited, so your custom classes must fully style the input - -### JS and CSS guidelines - -- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces. -- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`: - - @import "tailwindcss" source(none); - @source "../css"; - @source "../js"; - @source "../../lib/my_app_web"; - -- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new` -- **Never** use `@apply` when writing raw css -- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design -- Out of the box **only the app.js and app.css bundles are supported** - - You cannot reference an external vendor'd script `src` or link `href` in the layouts - - You must import the vendor deps into app.js and app.css to use them - - **Never write inline tags within templates** +- Use sqlite and [parrot](https://parrot.hexdocs.pm/index.html) for all storage +- Prefer native gleam vs erlang ffi unless proven ### UI/UX & design guidelines @@ -41,332 +12,3 @@ This is a web application written using the Phoenix web framework. - Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions) - Ensure **clean typography, spacing, and layout balance** for a refined, premium look - Focus on **delightful details** like hover effects, loading states, and smooth page transitions - - - - - -## Elixir guidelines - -- Elixir lists **do not support index based access via the access syntax** - - **Never do this (invalid)**: - - i = 0 - mylist = ["blue", "green"] - mylist[i] - - Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie: - - i = 0 - mylist = ["blue", "green"] - Enum.at(mylist, i) - -- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc - you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie: - - # INVALID: we are rebinding inside the `if` and the result never gets assigned - if connected?(socket) do - socket = assign(socket, :val, val) - end - - # VALID: we rebind the result of the `if` to a new variable - socket = - if connected?(socket) do - assign(socket, :val, val) - end - -- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors -- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets -- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package) -- Don't use `String.to_atom/1` on user input (memory leak risk) -- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards -- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)` -- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option - -## Mix guidelines - -- Read the docs and options before using tasks (by using `mix help task_name`) -- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed` -- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason - -## Test guidelines - -- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests -- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests - - Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message: - - ref = Process.monitor(pid) - assert_receive {:DOWN, ^ref, :process, ^pid, :normal} - - - Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages - - - -## Phoenix guidelines - -- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes. - -- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie: - - scope "/admin", AppWeb.Admin do - pipe_through :browser - - live "/users", UserLive, :index - end - - the UserLive route would point to the `AppWeb.Admin.UserLive` module - -- `Phoenix.View` no longer is needed or included with Phoenix, don't use it - - - - -## Phoenix HTML guidelines - -- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E` -- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated -- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]` -- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`) -- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name) - -- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals. - - **Never do this (invalid)**: - - <%= if condition do %> - ... - <% else if other_condition %> - ... - <% end %> - - Instead **always** do this: - - <%= cond do %> - <% condition -> %> - ... - <% condition2 -> %> - ... - <% true -> %> - ... - <% end %> - -- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `
` or `` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
-
-      
-        let obj = {key: "val"}
-      
-
-  Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
-
-- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
-
-      Text
-
-  and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
-
-  and **never** do this, since it's invalid (note the missing `[` and `]`):
-
-       ...
-      => Raises compile syntax error on invalid HEEx attr syntax
-
-- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
-- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
-- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
-
-  **Always** do this:
-
-      
- {@my_assign} - <%= if @some_block_condition do %> - {@another_assign} - <% end %> -
- - and **Never** do this – the program will terminate with a syntax error: - - <%!-- THIS IS INVALID NEVER EVER DO THIS --%> -
- {if @invalid_block_construct do} - {end} -
- - - -## Phoenix LiveView guidelines - -- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews -- **Avoid LiveComponent's** unless you have a strong, specific need for them -- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive` - -### LiveView streams - -- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations: - - basic append of N items - `stream(socket, :messages, [new_msg])` - - resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items) - - prepend to stream - `stream(socket, :messages, [new_msg], at: -1)` - - deleting items - `stream_delete(socket, :messages, msg)` - -- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be: - -
-
- {msg.text} -
-
- -- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**: - - def handle_event("filter", %{"filter" => filter}, socket) do - # re-fetch the messages based on the filter - messages = list_messages(filter) - - {:noreply, - socket - |> assign(:messages_empty?, messages == []) - # reset the stream with the new messages - |> stream(:messages, messages, reset: true)} - end - -- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes: - -
- -
- {task.name} -
-
- - The above only works if the empty state is the only HTML block alongside the stream for-comprehension. - -- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items - along with the updated assign: - - def handle_event("edit_message", %{"message_id" => message_id}, socket) do - message = Chat.get_message!(message_id) - edit_form = to_form(Chat.change_message(message, %{content: message.content})) - - # re-insert message so @editing_message_id toggle logic takes effect for that stream item - {:noreply, - socket - |> stream_insert(:messages, message) - |> assign(:editing_message_id, String.to_integer(message_id)) - |> assign(:edit_form, edit_form)} - end - - And in the template: - -
-
- {message.username} - <%= if @editing_message_id == message.id do %> - <%!-- Edit mode --%> - <.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit"> - ... - - <% end %> -
-
- -- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections - -### LiveView JavaScript interop - -- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute -- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised - -LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx, -and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor. - -#### Inline colocated js hooks - -**Never** write raw embedded ` - -- colocated hooks are automatically integrated into the app.js bundle -- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber` - -#### External phx-hook - -External JS hooks (`
`) must be placed in `assets/js/` and passed to the -LiveSocket constructor: - - const MyHook = { - mounted() { ... } - } - let liveSocket = new LiveSocket("/live", Socket, { - hooks: { MyHook } - }); - -#### Pushing events between client and server - -Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle. -**Always** return or rebind the socket on `push_event/3` when pushing events: - - # re-bind socket so we maintain event state to be pushed - socket = push_event(socket, "my_event", %{...}) - - # or return the modified socket directly: - def handle_event("some_event", _, socket) do - {:noreply, push_event(socket, "my_event", %{...})} - end - -Pushed events can then be picked up in a JS hook with `this.handleEvent`: - - mounted() { - this.handleEvent("my_event", data => console.log("from server:", data)); - } - -Clients can also push an event to the server and receive a reply with `this.pushEvent`: - - mounted() { - this.el.addEventListener("click", e => { - this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply)); - }) - } - -Where the server handled it via: - - def handle_event("my_event", %{"one" => 1}, socket) do - {:reply, %{two: 2}, socket} - end - -### LiveView tests - -- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions -- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions -- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests -- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc -- **Never** test against raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")` -- Instead of relying on testing text content, which can change, favor testing for the presence of key elements -- Focus on testing outcomes rather than implementation details -- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be -- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie: - - html = render(view) - document = LazyHTML.from_fragment(html) - matches = LazyHTML.filter(document, "your-complex-selector") - IO.inspect(matches, label: "Matches") diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..674b65b --- /dev/null +++ b/PLAN.md @@ -0,0 +1,173 @@ +# Implementation Plan: FeedReader → Gleam + +> **Architecture & rationale**: see [`SCOUTING_REPORT.md`](./SCOUTING_REPORT.md). This plan is the build order. +> **Testing**: follow [`.pi/skills/gleam-testing/SKILL.md`](.pi/skills/gleam-testing/SKILL.md) — `assert` not bare booleans, `let assert` for Result/Option, **never `process.sleep`** in actor tests (use `send_and_confirm`). +> **Standing rule**: `mise run pre-commit` must pass before committing any step. Each module ships with its test. + +--- + +## Stack (locked, from spikes) + +Wisp + Mist · Lustre SSR + `lustre_pipes` + `hx` · SQLite + Parrot + sqlight · gleam_otp actors · xmerl Erlang FFI (XML) · Tailwind v4 + DaisyUI · target = Erlang/BEAM. + +| dep | role | +|---|---| +| `wisp`, `mist`, `gleam_http` | web framework, server, http types | +| `lustre`, `lustre_pipes`, `hx` | SSR HTML (pipe-style), HTMX attrs + headers | +| `sqlight`, `parrot` | SQLite driver, typed SQL codegen | +| `gleam_otp`, `gleam_erlang` | actors/supervision, BEAM interop | +| `gleam_httpc` | feed fetching | +| `birl`, `gluid`, `envoy` | dates, UUIDv4, env vars | +| `formal`, `glentities` | form parsing, HTML entity encoding | +| `gleeunit`, `birdie`, `http_server_mock` | tests, snapshots, HTTP mocking | +| (FFI) `xmerl_ffi.erl` | XML parsing (no Gleam package) | + +--- + +## Build order + +### Phase 1 — Scaffold & DB foundation + +**1.1 Project init** +- `gleam new feedreader --template=erlang` +- `gleam.toml`: deps above. `target = "erlang"`. +- Add `mise.toml` (gleam/erlang/rebar3 tools; mirror `../yard/mise.toml` task shape: `format`, `check`, `test`, `pre-commit`). +- `.gitignore`: `build/`, `*.db`, `*.db-wal`, `*.db-shm`, `priv/static/*.js`, `priv/static/*.css` (generated). +- Move `SCOUTING_REPORT.md`, `PLAN.md`, `STATUS.md`, `AGENTS.md`, `SPEC.md` into the new project root; keep `feeds.opml` as a test fixture. + +**1.2 SQLite schema + Parrot codegen** (mirror `../yard/yard/src/yard/sql/schema.sql`) +- Write `schema.sql` (source of truth, `CREATE TABLE IF NOT EXISTS …`): `feeds`, `entries` (port from `priv/resource_snapshots/repo/*/` JSON — see SCOUTING_REPORT §1.1). + - `feeds(id TEXT PK, name TEXT, site_url TEXT, feed_url TEXT NOT NULL UNIQUE, category TEXT DEFAULT 'Uncategorized', last_fetched_at TEXT, fetch_error TEXT)` + - `entries(id TEXT PK, created_at TEXT NOT NULL, external_id TEXT NOT NULL, title TEXT, content_link TEXT, comments_link TEXT, published_at TEXT, is_read INTEGER NOT NULL DEFAULT 0, is_starred INTEGER NOT NULL DEFAULT 0, feed_id TEXT NOT NULL REFERENCES feeds(id) ON DELETE CASCADE, UNIQUE(feed_id, external_id))` +- Write `queries.sql` with Parrot queries: `list_feeds`, `get_feed`, `insert_feed`, `delete_feed`, `log_fetch_success`, `log_fetch_error`, `list_entries` (paginated variants: unread/starred/history), `get_entry`, `upsert_entry`, `toggle_read`, `toggle_starred`, `unread_count`. +- `mise run gen` task: `sqlite3 /tmp/gen.db < schema.sql && gleam run -m parrot -- --sqlite /tmp/gen.db && rm /tmp/gen.db` → produces `src/feedreader/sql.gleam` (do not edit). +- **Test**: open `:memory:` db, run schema, assert tables exist (`PRAGMA table_info`), insert/select roundtrip. + +**1.3 `db.gleam`** — typed CRUD wrappers (port pattern from `../yard/yard/src/yard/db.gleam`) +- `Feed`/`Entry` custom types. `param_to_value` / `params_to_values` helpers. `open(path)`, `migrate(conn)`, `new_id()`, `now_ts()` (birl). +- One public fn per query from `sql.gleam`, mapping `Result(_, sqlight.Error)` → `Result(_, Nil)` for ergonomics. +- **Tests**: `with_db` helper (open `:memory:`, migrate, pass conn). Test insert + list + get + delete + upsert idempotency. Use `let assert Ok(...)` for unwraps; `assert` for value checks. + +### Phase 2 — Domain & parsing + +**2.1 `feed.gleam`** — feed business logic +- `add(conn, attrs)` (insert, ignore-on-duplicate-feed_url), `list`, `get(id)`, `delete` (cascade), `log_fetch_success/error`. +- **Tests**: add rejects duplicate `feed_url`; delete cascades to entries (`PRAGMA foreign_keys=ON`). + +**2.2 `entry.gleam`** — entry reads/toggles +- `list_unread/starred/history(conn, limit, offset)` returning `List(Entry)`. +- `get(id)`, `toggle_read(conn, id)`, `toggle_starred(conn, id)` (flip + update), `upsert(conn, attrs)`. +- **Tests**: upsert same `(feed_id, external_id)` twice → count unchanged (dedupe). Toggle flips boolean. Unread excludes read entries. + +**2.3 `xmerl_ffi.erl`** (port verbatim from XML spike) + `xml.gleam` +- FFI exposes `parse/1`, `node_kind/1`, `node_tag/1`, `node_attrs/1`, `node_children/1`, `node_text/1`. **Must `binary_to_list` before `xmerl_scan:string`.** +- `xml.gleam`: `XmlNode` type (`Element`/`Text`), `parse(source)` → `Result(XmlNode, ParseError)`, tree helpers (`elements_by_tag`, `children_by_tag`, `text_of`, `child_text`, `attr`). +- **Tests** (birdie snapshots of parsed trees + assertions): parse OPML fixture → 77 feeds; parse `fixtures/xkcd_atom.xml`, `fixtures/lobsters_rss.xml`, `fixtures/github_atom.xml` (the xmlm-failure case) → entries extracted; Unicode preserved (assert titles contain `’`/`—`). + +**2.4 `rss.gleam`** — RSS/Atom → `List(EntryAttrs)` +- Port from Elixir `FetchFeed.parse_feed`. Handle `` (RSS) + `` (Atom). Extract title/link/guid|id/comments/pubDate|published|updated. +- **Tests**: synthetic feeds covering RSS, Atom, missing fields, Atom ``, multiple ``s, CDATA. Real fixtures as integration tests. + +**2.5 `date.gleam`** — date parsing +- Port Elixir `parse_rfc822` + ISO8601. Use `birl` for normalization. Handle named TZ abbrevs (EST/PST/…). +- **Tests**: table of RFC822 + ISO8601 inputs → expected birl values. Edge cases: `nil` input, `""`, no-TZ, `Z`, `+0800`, `EST`. + +**2.6 `opml.gleam`** — OPML import +- Port `FeedReader.Core.import_opml`. Walk nested ``, group by parent `text` attr, extract `xmlUrl`/`htmlUrl`/`title`. Returns `{success, error}` counts. +- **Tests**: parse `fixtures/feeds.opml` → 77 feeds across 3 categories; duplicate import is idempotent (feeds already exist → ignored). + +### Phase 3 — Background workers + +**3.1 `http.gleam`** — feed fetcher +- `fetch(url)` via `gleam_httpc` (30s timeout). Return `Result(String, FetchError)` (body on 200, error otherwise). +- **Tests**: `http_server_mock` stub returning a canned RSS body; stub returning 404; stub timing out. + +**3.2 `fetcher.gleam`** — gleam_otp actor +- Takes `{db_conn, feed_id}`, fetches → parses (rss.gleam) → upserts each entry → logs success/error. +- **Actor message type**: `Fetch(feed_id)`, `Subscribe(Subject(Event))`, `Stop`. +- Emits `EntryUpserted(entry)` events to subscribers (for future real-time / unread-count). +- **Tests** (skill Pattern 1 `send_and_confirm`): start actor with `:memory:` db + seeded feed; `Subscribe(test_subj)`; `Fetch(feed_id)`; `process.receive` on test subj → assert entry persisted + `last_fetched_at` set. No `process.sleep`. Dead-subscriber resilience test. + +**3.3 `scheduler.gleam`** — gleam_otp actor (cron tick) +- Every N minutes: list feeds, enqueue `Fetch(feed_id)` for each due (last fetched > 10min ago or nil). Stagger 1–5min via `process.send_after`. +- **Tests** (Pattern 1 + listener): inject a fake "now" / controllable clock OR test the pure decision function `feeds_due(feeds, now)` separately from the actor loop. Actor test: `Tick` → assert fetcher received N `Fetch` messages (use a test fetcher Subject that records). + +### Phase 4 — Web layer + +**4.1 `web/server.gleam`** — Mist + Wisp bootstrap +- `wisp_mist.handler(handle_request, secret_key_base)` where `secret_key_base` from env. `mist.new |> mist.port(3000) |> mist.start`. Hold db_conn in app state. +- **No test** (integration-tested via handlers). + +**4.2 `web/router.gleam`** — route table +- Routes (full pages = HTML doc; fragments = HTML fragment): + - `GET /` → unread page · `GET /starred` · `GET /history` · `GET /feeds` + - `POST /entry/:id/toggle-read` · `POST /entry/:id/toggle-star` (fragments) + - `GET /?after=` → load-more fragment + - `POST /feeds` (add) · `POST /feeds/import` (OPML multipart) · `DELETE /feeds/:id` (fragments) + - `GET /static/*` (css/js/htmx.min.js) + - `GET /unread-count` (optional poll fragment) +- **Tests**: handler-level — build `wisp.Request` via `wisp.testing` helpers, assert response status + `string.contains(body, "...")`. + +**4.3 `web/html.gleam`** — shared Lustre element builders (pipe-style) +- `layout(title, current_path, inner)`, `nav(current_path)`, `entry_card(entry)`, `feed_card(feed)`, `flash(kind, msg)`, `hx_attr` helper (1-line adapter so `|> hx_attr(hx.post(url: …))` pipes bare). +- **Tests** (birdie snapshots): render `entry_card(sample)` → snapshot the HTML. Toggle re-renders with flipped classes. + +**4.4 `web/pages.gleam`** — full-page render +- `unread_page(entries, offset, has_more)`, `starred_page`, `history_page`, `feeds_page(feeds, flash)`. Each returns `element.Element(msg)`; router calls `element.to_document_string`. +- Port Elixir `EntryLive`/`FeedLive` markup via lustre_pipes. Preserve DaisyUI classes from `assets/css/app.css`. +- Relative-date helper (`humanize_date`) ported from `time_helpers.ex`. +- **Tests**: birdie snapshot each page with fixture data. + +**4.5 `web/fragments.gleam`** — HTMX partial responses +- `entry_card_fragment(entry)` (post-toggle), `load_more_fragment(entries, next_offset)`, `feed_row_fragment(feed)`, `toast_fragment(msg)`. +- Return via `wisp.html_body(element.to_string(...))`. Use `hx_header.trigger`/`hx_header.redirect` where needed (e.g. after OPML import → redirect to /feeds with flash). +- **Tests**: birdie snapshots; assert response headers (`hx-trigger`) via `wisp.testing`. + +### Phase 5 — Theming & assets + +**5.1 Port CSS** — copy `assets/css/app.css` (Tailwind v4 + DaisyUI light/dark themes, oklch colors) verbatim → `priv/static/css/app.css`. Copy `assets/vendor/daisyui*.js`, `heroicons.js` → `priv/static/vendor/`. +**5.2 Vendor HTMX** → `priv/static/js/htmx.min.js` (v2.0.4). Theme-toggle JS from `root.html.heex` → small inline ` - - - - {@inner_content} - - diff --git a/lib/feedreader_web/components/time_helpers.ex b/lib/feedreader_web/components/time_helpers.ex deleted file mode 100644 index 2672f39..0000000 --- a/lib/feedreader_web/components/time_helpers.ex +++ /dev/null @@ -1,29 +0,0 @@ -defmodule FeedreaderWeb.TimeHelpers do - @moduledoc """ - Time formatting helpers for displaying dates in human-readable format. - """ - - def humanize_date(nil), do: nil - - def humanize_date(%DateTime{} = dt) do - now = DateTime.utc_now() - diff_seconds = DateTime.diff(now, dt, :second) - - cond do - diff_seconds < 60 -> "just now" - diff_seconds < 3600 -> "#{div(diff_seconds, 60)}m ago" - diff_seconds < 86_400 -> "#{div(diff_seconds, 3600)}h ago" - diff_seconds < 172_800 -> "yesterday" - diff_seconds < 604_800 -> "#{div(diff_seconds, 86_400)}d ago" - diff_seconds < 2_592_000 -> "#{div(diff_seconds, 604_800)}w ago" - true -> Calendar.strftime(dt, "%b %d, %Y") - end - end - - def humanize_date(%NaiveDateTime{} = ndt) do - case DateTime.from_naive(ndt, "Etc/UTC") do - {:ok, dt} -> humanize_date(dt) - {:error, _} -> nil - end - end -end diff --git a/lib/feedreader_web/controllers/auth_controller.ex b/lib/feedreader_web/controllers/auth_controller.ex deleted file mode 100644 index 2ebd567..0000000 --- a/lib/feedreader_web/controllers/auth_controller.ex +++ /dev/null @@ -1,55 +0,0 @@ -defmodule FeedreaderWeb.AuthController do - use FeedreaderWeb, :controller - use AshAuthentication.Phoenix.Controller - - def success(conn, activity, user, _token) do - return_to = get_session(conn, :return_to) || ~p"/" - - message = - case activity do - {:confirm_new_user, :confirm} -> "Your email address has now been confirmed" - {:password, :reset} -> "Your password has successfully been reset" - _ -> "You are now signed in" - end - - conn - |> delete_session(:return_to) - |> store_in_session(user) - # If your resource has a different name, update the assign name here (i.e :current_admin) - |> assign(:current_user, user) - |> put_flash(:info, message) - |> redirect(to: return_to) - end - - def failure(conn, activity, reason) do - message = - case {activity, reason} do - {_, - %AshAuthentication.Errors.AuthenticationFailed{ - caused_by: %Ash.Error.Forbidden{ - errors: [%AshAuthentication.Errors.CannotConfirmUnconfirmedUser{}] - } - }} -> - """ - You have already signed in another way, but have not confirmed your account. - You can confirm your account using the link we sent to you, or by resetting your password. - """ - - _ -> - "Incorrect email or password" - end - - conn - |> put_flash(:error, message) - |> redirect(to: ~p"/sign-in") - end - - def sign_out(conn, _params) do - return_to = get_session(conn, :return_to) || ~p"/" - - conn - |> clear_session(:feedreader) - |> put_flash(:info, "You are now signed out") - |> redirect(to: return_to) - end -end diff --git a/lib/feedreader_web/controllers/error_html.ex b/lib/feedreader_web/controllers/error_html.ex deleted file mode 100644 index c91349c..0000000 --- a/lib/feedreader_web/controllers/error_html.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule FeedreaderWeb.ErrorHTML do - @moduledoc """ - This module is invoked by your endpoint in case of errors on HTML requests. - - See config/config.exs. - """ - use FeedreaderWeb, :html - - # If you want to customize your error pages, - # uncomment the embed_templates/1 call below - # and add pages to the error directory: - # - # * lib/feedreader_web/controllers/error_html/404.html.heex - # * lib/feedreader_web/controllers/error_html/500.html.heex - # - # embed_templates "error_html/*" - - # The default is to render a plain text page based on - # the template name. For example, "404.html" becomes - # "Not Found". - def render(template, _assigns) do - Phoenix.Controller.status_message_from_template(template) - end -end diff --git a/lib/feedreader_web/controllers/error_json.ex b/lib/feedreader_web/controllers/error_json.ex deleted file mode 100644 index b340551..0000000 --- a/lib/feedreader_web/controllers/error_json.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule FeedreaderWeb.ErrorJSON do - @moduledoc """ - This module is invoked by your endpoint in case of errors on JSON requests. - - See config/config.exs. - """ - - # If you want to customize a particular status code, - # you may add your own clauses, such as: - # - # def render("500.json", _assigns) do - # %{errors: %{detail: "Internal Server Error"}} - # end - - # By default, Phoenix returns the status message from - # the template name. For example, "404.json" becomes - # "Not Found". - def render(template, _assigns) do - %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} - end -end diff --git a/lib/feedreader_web/controllers/page_controller.ex b/lib/feedreader_web/controllers/page_controller.ex deleted file mode 100644 index 912d19c..0000000 --- a/lib/feedreader_web/controllers/page_controller.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule FeedreaderWeb.PageController do - use FeedreaderWeb, :controller - - def home(conn, _params) do - render(conn, :home) - end -end diff --git a/lib/feedreader_web/controllers/page_html.ex b/lib/feedreader_web/controllers/page_html.ex deleted file mode 100644 index 1713264..0000000 --- a/lib/feedreader_web/controllers/page_html.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule FeedreaderWeb.PageHTML do - @moduledoc """ - This module contains pages rendered by PageController. - - See the `page_html` directory for all templates available. - """ - use FeedreaderWeb, :html - - embed_templates "page_html/*" -end diff --git a/lib/feedreader_web/controllers/page_html/home.html.heex b/lib/feedreader_web/controllers/page_html/home.html.heex deleted file mode 100644 index b107fd0..0000000 --- a/lib/feedreader_web/controllers/page_html/home.html.heex +++ /dev/null @@ -1,202 +0,0 @@ - - - diff --git a/lib/feedreader_web/endpoint.ex b/lib/feedreader_web/endpoint.ex deleted file mode 100644 index 213988b..0000000 --- a/lib/feedreader_web/endpoint.ex +++ /dev/null @@ -1,60 +0,0 @@ -defmodule FeedreaderWeb.Endpoint do - use Phoenix.Endpoint, otp_app: :feedreader - - # The session will be stored in the cookie and signed, - # this means its contents can be read but not tampered with. - # Set :encryption_salt if you would also like to encrypt it. - @session_options [ - store: :cookie, - key: "_feedreader_key", - signing_salt: "MqTGMT/J", - same_site: "Lax" - ] - - socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]], - longpoll: [connect_info: [session: @session_options]] - - # Serve at "/" the static files from "priv/static" directory. - # - # When code reloading is disabled (e.g., in production), - # the `gzip` option is enabled to serve compressed - # static files generated by running `phx.digest`. - plug Plug.Static, - at: "/", - from: :feedreader, - gzip: not code_reloading?, - only: FeedreaderWeb.static_paths(), - raise_on_missing_only: code_reloading? - - if Mix.env() == :dev do - plug Tidewave - end - - # Code reloading can be explicitly enabled under the - # :code_reloader configuration of your endpoint. - if code_reloading? do - socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket - plug Phoenix.LiveReloader - plug Phoenix.CodeReloader - plug AshPhoenix.Plug.CheckCodegenStatus - plug Phoenix.Ecto.CheckRepoStatus, otp_app: :feedreader - end - - plug Phoenix.LiveDashboard.RequestLogger, - param_key: "request_logger", - cookie_key: "request_logger" - - plug Plug.RequestId - plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] - - plug Plug.Parsers, - parsers: [:urlencoded, :multipart, :json], - pass: ["*/*"], - json_decoder: Phoenix.json_library() - - plug Plug.MethodOverride - plug Plug.Head - plug Plug.Session, @session_options - plug FeedreaderWeb.Router -end diff --git a/lib/feedreader_web/gettext.ex b/lib/feedreader_web/gettext.ex deleted file mode 100644 index aa190e1..0000000 --- a/lib/feedreader_web/gettext.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule FeedreaderWeb.Gettext do - @moduledoc """ - A module providing Internationalization with a gettext-based API. - - By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations - that you can use in your application. To use this Gettext backend module, - call `use Gettext` and pass it as an option: - - use Gettext, backend: FeedreaderWeb.Gettext - - # Simple translation - gettext("Here is the string to translate") - - # Plural translation - ngettext("Here is the string to translate", - "Here are the strings to translate", - 3) - - # Domain-based translation - dgettext("errors", "Here is the error message to translate") - - See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. - """ - use Gettext.Backend, otp_app: :feedreader -end diff --git a/lib/feedreader_web/live/entry_live/index.ex b/lib/feedreader_web/live/entry_live/index.ex deleted file mode 100644 index e5095e8..0000000 --- a/lib/feedreader_web/live/entry_live/index.ex +++ /dev/null @@ -1,156 +0,0 @@ -defmodule FeedreaderWeb.EntryLive.Index do - use FeedreaderWeb, :live_view - - alias FeedReader.Core - - @impl true - def mount(_params, _session, socket) do - {:ok, stream(socket, :entries, [])} - end - - @impl true - def handle_params(_params, url, socket) do - action = socket.assigns.live_action - uri = URI.parse(url) - - {entries, offset, has_more} = fetch_entries(action, limit: 50, offset: 0) - - {:noreply, - socket - |> assign(:action, action) - |> assign(:current_path, uri.path) - |> assign(:entry_count, length(entries)) - |> assign(:offset, offset) - |> assign(:has_more, has_more) - |> stream(:entries, entries, reset: true)} - end - - defp fetch_entries(:unread, page_options) do - case Core.list_unread(page: page_options) do - {:ok, result} -> {result.results, result.offset, result.more?} - _ -> {[], 0, false} - end - end - - defp fetch_entries(:starred, page_options) do - case Core.list_starred(page: page_options) do - {:ok, result} -> {result.results, result.offset, result.more?} - _ -> {[], 0, false} - end - end - - defp fetch_entries(:history, page_options) do - case Core.list_history(page: page_options) do - {:ok, result} -> {result.results, result.offset, result.more?} - _ -> {[], 0, false} - end - end - - defp fetch_entries(_, _) do - {[], 0, false} - end - - defp feed_display_name(nil), do: nil - - defp feed_display_name(%{name: nil, feed_url: url, site_url: site_url}), - do: fallback_url(site_url) || root_domain(url) - - defp feed_display_name(%{name: "", feed_url: url, site_url: site_url}), - do: fallback_url(site_url) || root_domain(url) - - defp feed_display_name(%{name: name}) when name != nil and name != "", do: name - - defp fallback_url(nil), do: nil - defp fallback_url(url), do: root_domain(url) - - defp root_domain(nil), do: nil - - defp root_domain(url) do - case URI.parse(url) do - %{host: host} when host != nil -> - parts = String.split(host, ".") - - case Enum.reverse(parts) do - [tld, second | _] -> "#{second}.#{tld}" - [single] -> single - _ -> host - end - - _ -> - nil - end - end - - @impl true - def handle_event("toggle_star", %{"id" => id}, socket) do - action = socket.assigns[:action] - entry = Core.get_entry!(id) - {:ok, updated} = Core.toggle_starred(entry) - - socket = - cond do - action == :starred && not updated.is_starred -> - new_count = socket.assigns.entry_count - 1 - - socket - |> stream_delete(:entries, updated) - |> assign(:entry_count, new_count) - - action == :starred && updated.is_starred -> - socket - |> assign(:entry_count, socket.assigns.entry_count + 1) - |> stream_insert(:entries, updated, at: 0) - - true -> - stream_insert(socket, :entries, updated) - end - - {:noreply, socket} - end - - @impl true - def handle_event("toggle_read", %{"id" => id}, socket) do - action = socket.assigns[:action] - entry = Core.get_entry!(id) - {:ok, updated} = Core.toggle_read(entry) - - socket = - cond do - action == :unread && updated.is_read -> - new_count = socket.assigns.entry_count - 1 - - socket - |> stream_delete(:entries, updated) - |> assign(:entry_count, new_count) - - action == :unread && not updated.is_read -> - socket - |> assign(:entry_count, socket.assigns.entry_count + 1) - |> stream_insert(:entries, updated, at: 0) - - true -> - stream_insert(socket, :entries, updated) - end - - {:noreply, socket} - end - - @impl true - def handle_event("load_more", _params, socket) do - action = socket.assigns[:action] - current_offset = socket.assigns[:offset] || 0 - - new_offset = current_offset + 50 - - page_options = [limit: 50, offset: new_offset] - - {new_entries, offset, has_more} = fetch_entries(action, page_options) - - {:noreply, - socket - |> assign(:offset, offset) - |> assign(:has_more, has_more) - |> assign(:entry_count, socket.assigns.entry_count + length(new_entries)) - |> stream(:entries, new_entries, at: -1)} - end -end diff --git a/lib/feedreader_web/live/entry_live/index.html.heex b/lib/feedreader_web/live/entry_live/index.html.heex deleted file mode 100644 index 2f6f50c..0000000 --- a/lib/feedreader_web/live/entry_live/index.html.heex +++ /dev/null @@ -1,100 +0,0 @@ - -
-
-

- {case @action do - :unread -> "Unread" - :starred -> "Starred" - :history -> "History" - _ -> "Entries" - end} -

-
- -
-
-

- - {entry.title || "Untitled"} - -

- -
- <%= feed_name = feed_display_name(entry.feed) - date_str = humanize_date(entry.published_at) - cond do %> - <% feed_name && date_str -> %> - {feed_name} | {date_str} - <% feed_name -> %> - {feed_name} - <% true -> %> - {date_str} - <% end %> -
- - - -
- - - -
-
-
- -
- -
- -
-

Nothing left to read

-

Touch grass 🌿

-
-
-
diff --git a/lib/feedreader_web/live/feed_live/index.ex b/lib/feedreader_web/live/feed_live/index.ex deleted file mode 100644 index 94d74a5..0000000 --- a/lib/feedreader_web/live/feed_live/index.ex +++ /dev/null @@ -1,100 +0,0 @@ -defmodule FeedreaderWeb.FeedLive.Index do - use FeedreaderWeb, :live_view - - alias FeedReader.Core - - @impl true - def mount(_params, _session, socket) do - feeds = Core.list_feeds!() - - {:ok, - socket - |> assign(:feeds, feeds) - |> assign(:form, to_form(%{})) - |> assign(:current_path, "/feeds") - |> allow_upload(:opml, accept: ~w(.opml text/xml application/xml), max_entries: 1)} - end - - @impl true - def handle_params(_params, url, socket) do - uri = URI.parse(url) - - {:noreply, - socket - |> assign(:current_path, uri.path)} - end - - @impl true - def handle_event("add_feed", %{"feed" => feed_params}, socket) do - case Core.add_feed(feed_params) do - {:ok, _feed} -> - feeds = Core.list_feeds!() - - {:noreply, - socket - |> assign(:feeds, feeds) - |> put_flash(:info, "Feed added successfully")} - - {:error, _changeset} -> - {:noreply, socket |> put_flash(:error, "Failed to add feed")} - end - end - - @impl true - def handle_event("validate_opml", _params, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("import_opml", _params, socket) do - file_entries = - consume_uploaded_entries(socket, :opml, fn %{path: tmp_path}, _entry -> - {:ok, File.read!(tmp_path)} - end) - - case file_entries do - [file_content] -> - {success_count, _error_count} = Core.import_opml(file_content) - feeds = Core.list_feeds!() - - {:noreply, - socket - |> assign(:feeds, feeds) - |> put_flash(:info, "Imported #{success_count} feeds")} - - [] -> - {:noreply, put_flash(socket, :error, "No file uploaded")} - end - end - - @impl true - def handle_event("delete_feed", %{"id" => id}, socket) do - case Core.get_feed(id) do - {:ok, feed} -> - result = Core.delete_feed(feed) - require Logger - Logger.info("delete_feed result: #{inspect(result)}") - - case result do - :ok -> - feeds = Core.list_feeds!() - - {:noreply, - socket - |> assign(:feeds, feeds) - |> put_flash(:info, "Feed deleted")} - - {:error, error} -> - Logger.error("Failed to delete feed: #{inspect(error)}") - {:noreply, put_flash(socket, :error, "Unable to delete feed")} - - other -> - Logger.error("Unexpected delete_feed result: #{inspect(other)}") - {:noreply, put_flash(socket, :error, "Unable to delete feed")} - end - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Feed not found")} - end - end -end diff --git a/lib/feedreader_web/live/feed_live/index.html.heex b/lib/feedreader_web/live/feed_live/index.html.heex deleted file mode 100644 index f3dd4f5..0000000 --- a/lib/feedreader_web/live/feed_live/index.html.heex +++ /dev/null @@ -1,134 +0,0 @@ - -
-
-

Feeds

- -
-
-

Add New Feed

- - <.form for={@form} phx-submit="add_feed" id="add-feed-form"> -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- -
-
- -
-
-

Import OPML

- - <.form - for={%{}} - phx-submit="import_opml" - id="import-opml-form" - phx-change="validate_opml" - enctype="multipart/form-data" - > -
- - <.live_file_input - upload={@uploads.opml} - class="file-input file-input-bordered w-full" - /> -
- -
- -
- -
-
-
- -
-
- No feeds yet. Add your first feed above. -
- -
-
-
-
-

{feed.name || "Unnamed Feed"}

-

{feed.feed_url}

-
- Category: {feed.category} -
-
- Last parsed: {humanize_date(feed.last_fetched_at) || "never"} -
-
- Error: {feed.fetch_error} -
-
- - -
-
-
-
-
-
diff --git a/lib/feedreader_web/live_user_auth.ex b/lib/feedreader_web/live_user_auth.ex deleted file mode 100644 index e86df16..0000000 --- a/lib/feedreader_web/live_user_auth.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule FeedreaderWeb.LiveUserAuth do - @moduledoc """ - Helpers for authenticating users in LiveViews. - """ - - import Phoenix.Component - alias AshAuthentication.Phoenix.LiveSession - use FeedreaderWeb, :verified_routes - - # This is used for nested liveviews to fetch the current user. - # To use, place the following at the top of that liveview: - # on_mount {FeedreaderWeb.LiveUserAuth, :current_user} - def on_mount(:current_user, _params, session, socket) do - {:cont, LiveSession.assign_new_resources(socket, session)} - end - - def on_mount(:live_user_optional, _params, _session, socket) do - if socket.assigns[:current_user] do - {:cont, socket} - else - {:cont, assign(socket, :current_user, nil)} - end - end - - def on_mount(:live_user_required, _params, _session, socket) do - if socket.assigns[:current_user] do - {:cont, socket} - else - {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")} - end - end - - def on_mount(:live_no_user, _params, _session, socket) do - if socket.assigns[:current_user] do - {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")} - else - {:cont, assign(socket, :current_user, nil)} - end - end -end diff --git a/lib/feedreader_web/router.ex b/lib/feedreader_web/router.ex deleted file mode 100644 index df494c5..0000000 --- a/lib/feedreader_web/router.ex +++ /dev/null @@ -1,115 +0,0 @@ -defmodule FeedreaderWeb.Router do - use FeedreaderWeb, :router - - import Oban.Web.Router - use AshAuthentication.Phoenix.Router - - import AshAuthentication.Plug.Helpers - - pipeline :browser do - plug(:accepts, ["html"]) - plug(:fetch_session) - plug(:fetch_live_flash) - plug(:put_root_layout, html: {FeedreaderWeb.Layouts, :root}) - plug(:protect_from_forgery) - plug(:put_secure_browser_headers) - plug(:load_from_session) - end - - pipeline :api do - plug(:accepts, ["json"]) - plug(:load_from_bearer) - plug(:set_actor, :user) - end - - scope "/", FeedreaderWeb do - pipe_through(:browser) - - ash_authentication_live_session :authenticated_routes do - # in each liveview, add one of the following at the top of the module: - # - # If an authenticated user must be present: - # on_mount {FeedreaderWeb.LiveUserAuth, :live_user_required} - # - # If an authenticated user *may* be present: - # on_mount {FeedreaderWeb.LiveUserAuth, :live_user_optional} - # - # If an authenticated user must *not* be present: - # on_mount {FeedreaderWeb.LiveUserAuth, :live_no_user} - end - end - - scope "/", FeedreaderWeb do - pipe_through(:browser) - - live("/", EntryLive.Index, :unread) - live("/starred", EntryLive.Index, :starred) - live("/history", EntryLive.Index, :history) - live("/feeds", FeedLive.Index, :index) - - auth_routes(AuthController, Feedreader.Accounts.User, path: "/auth") - sign_out_route(AuthController) - - # Remove these if you'd like to use your own authentication views - sign_in_route( - register_path: "/register", - reset_path: "/reset", - auth_routes_prefix: "/auth", - on_mount: [{FeedreaderWeb.LiveUserAuth, :live_no_user}], - overrides: [AshAuthentication.Phoenix.Overrides.DaisyUI] - ) - - reset_route( - auth_routes_prefix: "/auth", - overrides: [AshAuthentication.Phoenix.Overrides.DaisyUI] - ) - - confirm_route(Feedreader.Accounts.User, :confirm_new_user, - auth_routes_prefix: "/auth", - overrides: [AshAuthentication.Phoenix.Overrides.DaisyUI] - ) - - magic_sign_in_route(Feedreader.Accounts.User, :magic_link, - auth_routes_prefix: "/auth", - overrides: [AshAuthentication.Phoenix.Overrides.DaisyUI] - ) - end - - # Other scopes may use custom stacks. - # scope "/api", FeedreaderWeb do - # pipe_through :api - # end - - # Enable LiveDashboard and Swoosh mailbox preview in development - if Application.compile_env(:feedreader, :dev_routes) do - # If you want to use the LiveDashboard in production, you should put - # it behind authentication and allow only admins to access it. - # If your application does not have an admins-only section yet, - # you can use Plug.BasicAuth to set up some basic authentication - # as long as you are also using SSL (which you should anyway). - import Phoenix.LiveDashboard.Router - - scope "/dev" do - pipe_through(:browser) - - live_dashboard("/dashboard", metrics: FeedreaderWeb.Telemetry) - forward("/mailbox", Plug.Swoosh.MailboxPreview) - end - - scope "/" do - pipe_through(:browser) - - oban_dashboard("/oban") - end - end - - if Application.compile_env(:feedreader, :dev_routes) do - import AshAdmin.Router - - scope "/admin" do - pipe_through(:browser) - - ash_admin("/") - end - end -end diff --git a/lib/feedreader_web/telemetry.ex b/lib/feedreader_web/telemetry.ex deleted file mode 100644 index 80a81f7..0000000 --- a/lib/feedreader_web/telemetry.ex +++ /dev/null @@ -1,80 +0,0 @@ -defmodule FeedreaderWeb.Telemetry do - use Supervisor - import Telemetry.Metrics - - def start_link(arg) do - Supervisor.start_link(__MODULE__, arg, name: __MODULE__) - end - - @impl true - def init(_arg) do - children = [ - {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} - ] - - Supervisor.init(children, strategy: :one_for_one) - end - - def metrics do - [ - summary("phoenix.endpoint.start.system_time", - unit: {:native, :millisecond} - ), - summary("phoenix.endpoint.stop.duration", - unit: {:native, :millisecond} - ), - summary("phoenix.router_dispatch.start.system_time", - tags: [:route], - unit: {:native, :millisecond} - ), - summary("phoenix.router_dispatch.exception.duration", - tags: [:route], - unit: {:native, :millisecond} - ), - summary("phoenix.router_dispatch.stop.duration", - tags: [:route], - unit: {:native, :millisecond} - ), - summary("phoenix.socket_connected.duration", - unit: {:native, :millisecond} - ), - sum("phoenix.socket_drain.count"), - summary("phoenix.channel_joined.duration", - unit: {:native, :millisecond} - ), - summary("phoenix.channel_handled_in.duration", - tags: [:event], - unit: {:native, :millisecond} - ), - summary("feedreader.repo.query.total_time", - unit: {:native, :millisecond}, - description: "The sum of the other measurements" - ), - summary("feedreader.repo.query.decode_time", - unit: {:native, :millisecond}, - description: "The time spent decoding the data received from the database" - ), - summary("feedreader.repo.query.query_time", - unit: {:native, :millisecond}, - description: "The time spent executing the query" - ), - summary("feedreader.repo.query.queue_time", - unit: {:native, :millisecond}, - description: "The time spent waiting for a database connection" - ), - summary("feedreader.repo.query.idle_time", - unit: {:native, :millisecond}, - description: - "The time the connection spent waiting before being checked out for the query" - ), - summary("vm.memory.total", unit: {:byte, :kilobyte}), - summary("vm.total_run_queue_lengths.total"), - summary("vm.total_run_queue_lengths.cpu"), - summary("vm.total_run_queue_lengths.io") - ] - end - - defp periodic_measurements do - [] - end -end diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..ff58d86 --- /dev/null +++ b/manifest.toml @@ -0,0 +1,91 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, + { name = "birdie", version = "1.5.5", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "envoy", "filepath", "glance", "gleam_community_ansi", "gleam_json", "gleam_stdlib", "global_value", "justin", "rank", "simplifile", "term_size", "tom", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "ABB86B0FA88DABCDDE93EDF6921060BB74502A5B410C3F35960CD539EC98AD37" }, + { name = "birl", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "gleam_time", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "9924A2C3EECD33A5C94CD6859570261F2620435C54DAC5F2B00E5E6C806EEC6F" }, + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, + { name = "edit_distance", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "DE588BC3483ED6DBA717211B59F3178891A5FC6F1C00D415AE8C4233EAFB94B1" }, + { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, + { name = "esqlite", version = "0.9.0", build_tools = ["rebar3"], requirements = [], otp_app = "esqlite", source = "hex", outer_checksum = "CCF72258A4EE152EC7AD92AA9A03552EB6CA1B06B65C93AD5B6E55C302E05855" }, + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "formal", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "formal", source = "hex", outer_checksum = "8FBEB42758F90ACAA82A8B6B8FE11B4A3B2A2B290E97B4DDD4B7DCE98DEB885C" }, + { name = "given", version = "5.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "given", source = "hex", outer_checksum = "444896371DF2307C15CCCAB2535BE5ED442347E117316E12246230A13270612A" }, + { name = "glailglind", version = "2.3.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_httpc", "gleam_stdlib", "shellout", "simplifile", "tom"], otp_app = "glailglind", source = "hex", outer_checksum = "734B8103529D9406B970C707D077CA6B45FD566295F0E72E209C23740734AC78" }, + { name = "glance", version = "6.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "9037EBBAD2A220CD46DADEDCDF9DFAC7406FD2959535334473E60C32F170622A" }, + { name = "gleam_community_ansi", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "B5AA433AF84313E23FDF90CCFF752B9380FE9FFCE02B2949D49B7AACCC77B16D" }, + { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, + { name = "gleam_crypto", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "2DE9E4EF53CF6FEE049D4F765731F7178F7A11AEFAE00EEE63BF7536B354AD3F" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" }, + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, + { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, + { name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" }, + { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, + { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, + { name = "glearray", version = "2.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glearray", source = "hex", outer_checksum = "1554E48DD40114D7602F5BFF4D7278B6B3B735F137C7FDEEADFB2FE7951C94BE" }, + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, + { name = "glentities", version = "6.2.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glentities", source = "hex", outer_checksum = "78A0B28789C1A7840468C683FC9588B0B59AA38BE8CF5DACD1AF2E60A91AE638" }, + { name = "glexer", version = "2.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "5AB2401BF251699746B3551EF699ECC1D94FE2897C15041F181614B089125CA0" }, + { name = "glinter", version = "2.19.0", build_tools = ["gleam"], requirements = ["argv", "glance", "gleam_json", "gleam_stdlib", "simplifile", "tom"], otp_app = "glinter", source = "hex", outer_checksum = "9A0B5EE224BB84FB25DFB4A8DD3E87F06BDFE0C49B3A669D92AB4C555593FC75" }, + { name = "glisten", version = "9.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging"], otp_app = "glisten", source = "hex", outer_checksum = "7795AA50830656F3A0316A6B26595F893C83272DA901B3405E31339CAA31A10B" }, + { name = "global_value", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "global_value", source = "hex", outer_checksum = "23F74C91A7B819C43ABCCBF49DAD5BB8799D81F2A3736BA9A534BD47F309FF4F" }, + { name = "gluid", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gluid", source = "hex", outer_checksum = "78B6111469E88E646BE0134712543A4131339161D522ADC54FBBAB2C0FFA19F4" }, + { name = "gramps", version = "6.0.1", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "D55636072DEE173F6586A5679D3C02EC7A0DE3F8646B78C351B72908FF223DF7" }, + { name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "http_server_mock", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_json", "gleam_stdlib"], otp_app = "http_server_mock", source = "hex", outer_checksum = "15BE9E0199A8D38EB9DDD28636E50BCAECD8CF5970892DF72E5C98D772DFBEA9" }, + { name = "http_server_mock_erlang", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_json", "gleam_otp", "gleam_stdlib", "http_server_mock", "mist"], otp_app = "http_server_mock_erlang", source = "hex", outer_checksum = "B25A38EC053558574FB1B290CBCD2B72692FA7D7C12148B2A326829F4D0F6006" }, + { name = "hx", version = "3.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "gleam_time", "lustre"], otp_app = "hx", source = "hex", outer_checksum = "44595AAD676AA4E5263224DB74EF5DC4868B172A1CE2403B861AFB1016FD72C7" }, + { name = "justin", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "8B1C62269E8607D0A0ED698B7903984CE0BF7B6B3C0DEA3C6C8302B39402837B" }, + { name = "logging", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "BC5F18CE5DD9686100229FE5409BDC3DD5C46D5A7DF2F804AD2D8F0DD6C5060E" }, + { name = "lustre", version = "5.7.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "38C6FCBD7B7BACE994D5BF643202BB69B02576D44250B4C5DCE738387C0E8F87" }, + { name = "lustre_pipes", version = "0.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "lustre"], otp_app = "lustre_pipes", source = "hex", outer_checksum = "9885D14B60293CD9A1398532D0B5E1B62EBA84F086004034198426757C7FD9D6" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "6.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "1B07F321D5FA0CB162D81496F2DE96AEB6EF8980F4F38230A4CC3F849497E020" }, + { name = "parrot", version = "1.2.12", build_tools = ["gleam"], requirements = ["argv", "envoy", "exception", "filepath", "given", "gleam_crypto", "gleam_http", "gleam_httpc", "gleam_json", "gleam_regexp", "gleam_stdlib", "gleam_time", "glearray", "repeatedly", "simplifile", "sqlight", "tom"], otp_app = "parrot", source = "hex", outer_checksum = "FFC1DF6DDFE94FD94EBA7DB7233AD9CBD4BC5B660D96009B9424D776FC592BCF" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "ranger", version = "1.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_yielder"], otp_app = "ranger", source = "hex", outer_checksum = "C8988E8F8CDBD3E7F4D8F2E663EF76490390899C2B2885A6432E942495B3E854" }, + { name = "rank", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "B846D54D1512FB83A3994A07A8C7915FE74335A99BCE1EDC7943EA31ED710DFD" }, + { name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" }, + { name = "shellout", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "shellout", source = "hex", outer_checksum = "C416356D45151F298108C9DB9CD1EDE0313F620B5EDBB5766CD7237659D87841" }, + { name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" }, + { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, + { name = "sqlight", version = "1.1.0", build_tools = ["gleam"], requirements = ["esqlite", "gleam_stdlib"], otp_app = "sqlight", source = "hex", outer_checksum = "ECA1A4B45C35EB9EFCEEB7FAAC7BF5D8B2C777A7C1FC8A9C12CB67D54CED42E7" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, + { name = "tom", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "DCF04CB7AB35D58CFC598C66EA2E1816D160759802C89B2BA6238780D59BC256" }, + { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, + { name = "wisp", version = "2.2.2", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "5FF5F1E288C3437252ABB93D8F9CF42FF652CE7AD54480CFE736038DC09C4F22" }, +] + +[requirements] +birdie = { version = ">= 1.0.0 and < 2.0.0" } +birl = { version = ">= 1.0.0 and < 2.0.0" } +envoy = { version = ">= 1.0.0 and < 2.0.0" } +formal = { version = ">= 2.0.0 and < 4.0.0" } +glailglind = { version = ">= 2.3.0 and < 3.0.0" } +gleam_erlang = { version = ">= 0.34.0 and < 2.0.0" } +gleam_http = { version = ">= 4.0.0 and < 5.0.0" } +gleam_httpc = { version = ">= 4.0.0 and < 6.0.0" } +gleam_json = { version = ">= 3.0.0 and < 4.0.0" } +gleam_otp = { version = ">= 0.10.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +glentities = { version = ">= 6.0.0 and < 7.0.0" } +glinter = { version = ">= 1.0.0 and < 3.0.0" } +gluid = { version = ">= 1.0.0 and < 2.0.0" } +http_server_mock = { version = ">= 1.0.0 and < 2.0.0" } +http_server_mock_erlang = { version = ">= 1.0.0 and < 2.0.0" } +hx = { version = ">= 1.0.0 and < 4.0.0" } +logging = { version = ">= 1.0.0 and < 2.0.0" } +lustre = { version = ">= 5.0.0 and < 6.0.0" } +lustre_pipes = { version = ">= 0.3.0 and < 1.0.0" } +mist = { version = ">= 6.0.0 and < 7.0.0" } +parrot = { version = ">= 1.0.0 and < 2.0.0" } +simplifile = { version = ">= 2.4.0 and < 3.0.0" } +sqlight = { version = ">= 1.0.0 and < 2.0.0" } +wisp = { version = ">= 2.0.0 and < 3.0.0" } diff --git a/mark-read.sh b/mark-read.sh index a197f70..ef67072 100755 --- a/mark-read.sh +++ b/mark-read.sh @@ -1,22 +1,22 @@ #!/bin/bash -# Mark all feed entries as read except those from the last 6 hours. -# Run this on the server (where docker compose is deployed). +# Mark all entries older than 6 hours as read. +# Usage: ./mark-read.sh [db_path] +# This directly modifies the SQLite database. + set -euo pipefail -CONTAINER=$(docker compose ps -q feedreader 2>/dev/null || docker ps --filter "ancestor=ghcr.io/kasuboski/feedreader:main" -q | head -1) +DB_PATH="${1:-${DATABASE_PATH:-feedreader.db}}" +CUTOFF=$(date -u -v-6H +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date -u -d "6 hours ago" +"%Y-%m-%dT%H:%M:%SZ") -if [ -z "$CONTAINER" ]; then - echo "ERROR: Could not find feedreader container" - exit 1 -fi +echo "Marking entries older than $CUTOFF as read in $DB_PATH..." -echo "Using container: $CONTAINER" +sqlite3 "$DB_PATH" " + UPDATE entries + SET is_read = 1 + WHERE is_read = 0 + AND published_at IS NOT NULL + AND published_at != '' + AND published_at < '$CUTOFF'; +" -docker exec "$CONTAINER" /app/bin/feedreader rpc ' - cutoff = DateTime.add(DateTime.utc_now(), -6, :hour) |> DateTime.to_iso8601() - {:ok, %Exqlite.Result{num_rows: count}} = Feedreader.Repo.query( - "UPDATE entries SET is_read = 1 WHERE published_at < ?", - [cutoff] - ) - IO.puts("Marked #{count} entries as read (kept last 6 hours unread)") -' +echo "Done." diff --git a/mise.toml b/mise.toml index c67c579..88033cc 100644 --- a/mise.toml +++ b/mise.toml @@ -1,46 +1,46 @@ [tools] -elixir = "1.19.5" -erlang = "28.3" +gleam = "latest" +erlang = "latest" +rebar = "latest" [tasks.format] -description = "Format all Elixir files" -run = "mix format" +description = "Check formatting" +run = "gleam format --check" -[tasks.format_check] -description = "Check if files are formatted (Fails if unformatted - used for CI/Hooks)" -run = "mix format --check-formatted" +[tasks.check] +description = "Type-check the project" +run = "gleam check" [tasks.lint] -description = "Run Credo linter" -run = "mix credo" - -[tasks.security] -description = "Run Sobelow security scanner" -run = "mix sobelow --private" +description = "Lint with glinter" +run = "gleam run -m glinter" [tasks.test] -description = "Run the standard test suite" -run = "mix test" - -[tasks.typecheck] -description = "Run Dialyzer type checker" -run = "mix dialyzer" - -[tasks.coverage] -description = "Run tests and generate HTML coverage report" -run = "mix coveralls.html" - -# ========================================== -# GIT HOOKS & PIPELINES -# ========================================== +description = "Run tests" +run = "gleam test" + +[tasks.gen] +description = "Regenerate Parrot SQL codegen + copy schema to priv/" +run = """ +rm -f /tmp/feedreader_gen.db +sqlite3 /tmp/feedreader_gen.db < src/feedreader/sql/schema.sql +gleam run -m parrot -- --sqlite /tmp/feedreader_gen.db +rm -f /tmp/feedreader_gen.db +cp src/feedreader/sql/schema.sql priv/schema.sql +""" + +[tasks."assets:install"] +description = "Install TailwindCSS CLI binary (via glailglind)" +run = "gleam run -m tailwind/install" + +[tasks."assets:build"] +description = "Compile TailwindCSS to app.compiled.css (via glailglind)" +run = "gleam run -m tailwind/run" + +[tasks.assets] +description = "Install + build assets" +depends = ["assets:install", "assets:build"] [tasks.pre-commit] -description = "Fast checks to run before every git commit" -# Note: 'typecheck' is deliberately omitted here as Dialyzer is too slow -# to run on every commit. It should be run manually or in CI. -depends = [ - "format_check", - "lint", - "security", - "test" -] +description = "Run all checks before committing" +depends = ["format", "check", "lint", "test"] diff --git a/mix.exs b/mix.exs deleted file mode 100644 index 0c1d14b..0000000 --- a/mix.exs +++ /dev/null @@ -1,124 +0,0 @@ -defmodule Feedreader.MixProject do - use Mix.Project - - def project do - [ - app: :feedreader, - version: "0.1.0", - elixir: "~> 1.15", - test_coverage: [tool: ExCoveralls], - preferred_cli_env: [ - coveralls: :test, - "coveralls.detail": :test, - "coveralls.post": :test, - "coveralls.html": :test - ], - elixirc_paths: elixirc_paths(Mix.env()), - start_permanent: Mix.env() == :prod, - aliases: aliases(), - deps: deps(), - compilers: [:phoenix_live_view] ++ Mix.compilers(), - listeners: [Phoenix.CodeReloader], - consolidate_protocols: Mix.env() != :dev - ] - end - - # Configuration for the OTP application. - # - # Type `mix help compile.app` for more information. - def application do - [ - mod: {Feedreader.Application, []}, - extra_applications: [:logger, :runtime_tools] - ] - end - - def cli do - [ - preferred_envs: [precommit: :test] - ] - end - - # Specifies which paths to compile per environment. - defp elixirc_paths(:test), do: ["lib", "test/support"] - defp elixirc_paths(_), do: ["lib"] - - # Specifies your project dependencies. - # - # Type `mix help deps` for examples and options. - defp deps do - [ - {:picosat_elixir, "~> 0.2"}, - {:sourceror, "~> 1.8", only: [:dev, :test]}, - {:oban, "~> 2.0"}, - {:tidewave, "~> 0.5", only: [:dev]}, - {:live_debugger, "~> 0.7", only: [:dev]}, - {:oban_web, "~> 2.0"}, - {:ash_oban, "~> 0.7"}, - {:ash_admin, "~> 0.14"}, - {:ash_authentication_phoenix, "~> 2.0"}, - {:ash_authentication, "~> 4.0"}, - {:ash_sqlite, "~> 0.2"}, - {:ash_phoenix, "~> 2.0"}, - {:ash, "~> 3.0"}, - {:igniter, "~> 0.6", only: [:dev, :test]}, - {:phoenix, "~> 1.8.5"}, - {:phoenix_ecto, "~> 4.5"}, - {:ecto_sql, "~> 3.13"}, - {:ecto_sqlite3, ">= 0.0.0"}, - {:ecto_sqlite3_extras, "~> 1.2", only: :dev}, - {:phoenix_html, "~> 4.1"}, - {:phoenix_live_reload, "~> 1.2", only: :dev}, - {:phoenix_live_view, "~> 1.1.0"}, - {:lazy_html, ">= 0.1.0", only: :test}, - {:phoenix_live_dashboard, "~> 0.8.3"}, - {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, - {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, - {:heroicons, - github: "tailwindlabs/heroicons", - tag: "v2.2.0", - sparse: "optimized", - app: false, - compile: false, - depth: 1}, - {:swoosh, "~> 1.16"}, - {:req, "~> 0.5"}, - {:sweet_xml, "~> 0.7"}, - {:telemetry_metrics, "~> 1.0"}, - {:telemetry_poller, "~> 1.0"}, - {:gettext, "~> 1.0"}, - {:jason, "~> 1.2"}, - {:dns_cluster, "~> 0.2.0"}, - {:bandit, "~> 1.5"}, - # Code Quality & Tooling - {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, - {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}, - {:excoveralls, "~> 0.18", only: :test} - ] - end - - # Aliases are shortcuts or tasks specific to the current project. - # For example, to install project dependencies and perform other setup tasks, run: - # - # $ mix setup - # - # See the documentation for `Mix` for more info on aliases. - defp aliases do - [ - setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], - "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], - "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ash.setup --quiet", "test"], - "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], - "assets.build": ["compile", "tailwind feedreader", "esbuild feedreader"], - "assets.deploy": [ - "tailwind feedreader --minify", - "esbuild feedreader --minify", - "phx.digest" - ], - precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"], - "ash.setup": ["ash.setup", "run priv/repo/seeds.exs"] - ] - end -end diff --git a/mix.lock b/mix.lock deleted file mode 100644 index 8efa287..0000000 --- a/mix.lock +++ /dev/null @@ -1,100 +0,0 @@ -%{ - "ash": {:hex, :ash, "3.21.1", "1334e59a59b3ae549fb38ac3f88152b9aa3c09ad78d16e4f7b9c40f1abc2daeb", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "084585df15173b979ad534e33aa64f9d9bffa1e6ed135cef9c5ffd7aa0d27cef"}, - "ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"}, - "ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"}, - "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"}, - "ash_oban": {:hex, :ash_oban, "0.7.2", "609a2d79b9a85fce712095611dbfe9ff4ee5a08f74f6cdcd504c89266d957ed7", [:mix], [{:ash, "~> 3.8", [hex: :ash, repo: "hexpm", optional: false]}, {:oban, "~> 2.20", [hex: :oban, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.18", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "fbab846d42bdc152dc7457b5ed1a836ad32c785a13a0cf47ebafe8071842d97c"}, - "ash_phoenix": {:hex, :ash_phoenix, "2.3.20", "022682396892046f48dc35a137bbea9c1e4c6a6d58e71d795defd2f071c3b138", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0655a90b042a5e8873b32ba2f0b52c7c9b8da0fd415518bef41ac03a7b07e02e"}, - "ash_sql": {:hex, :ash_sql, "0.5.1", "f4fcaa29308bdf417fe85373e57fb6d868b7db1e7ccc2fae5cc7f8f92016d622", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "33febc8a84443683c3685a2d65f674391d8f21c073555d7c22d6c484f72faa65"}, - "ash_sqlite": {:hex, :ash_sqlite, "0.2.16", "f85ff95adb4d6da6c370b415a1f1aa128dc50250ca3f13ab7585daac97ae59e0", [:mix], [{:ash, "~> 3.19", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.2.20 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.12", [hex: :ecto_sqlite3, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.14 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8676ed0b5b80d1de37a2e865b0c658523ee8599aef240846c9c1591fbdc8bda4"}, - "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, - "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, - "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, - "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, - "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, - "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, - "cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"}, - "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, - "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, - "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, - "crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"}, - "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, - "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, - "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, - "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, - "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, - "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, - "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, - "ecto_sqlite3_extras": {:hex, :ecto_sqlite3_extras, "1.2.2", "36e60b561a11441d15f26c791817999269fb578b985162207ebb08b04ca71e40", [:mix], [{:exqlite, ">= 0.13.2", [hex: :exqlite, repo: "hexpm", optional: false]}, {:table_rex, "~> 4.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "2b66ba7246bb4f7e39e2578acd4a0e4e4be54f60ff52d450a01be95eeb78ff1e"}, - "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, - "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, - "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, - "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, - "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, - "exqlite": {:hex, :exqlite, "0.35.0", "90741471945db42b66cd8ca3149af317f00c22c769cc6b06e8b0a08c5924aae5", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a009e303767a28443e546ac8aab2539429f605e9acdc38bd43f3b13f1568bca9"}, - "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, - "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, - "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, - "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, - "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, - "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "igniter": {:hex, :igniter, "0.7.7", "08bae07b7b610100bc7c676e6b18130fe12bb90617982023cc798346879c2c5f", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "caeb1227887362b22038ff8419a7e6ddd3888f3d7e6cffacb14c73abbce17600"}, - "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, - "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, - "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, - "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"}, - "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, - "live_debugger": {:hex, :live_debugger, "0.7.0", "c283593ce1d1e6078d3a6a30ee9ea74dc26d4dae0b941acbb452937f2ea586ca", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "c0956db950ef36006e1fb44410520a4d2a472f01dd945263e5764ab4e7bb98d4"}, - "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, - "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, - "oban": {:hex, :oban, "2.20.3", "e4d27336941955886cc7113420c32c63b70b64f10b27e08e3cf2b001153953cd", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "075ffbf1279a96bec495bc63d647b08929837d70bcc0427249ffe4d1dddaec33"}, - "oban_met": {:hex, :oban_met, "1.0.6", "2a5500aff496b7ac4b830b0b03b08e920625a051bb6890981fbb53b15f1cbdc0", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "15ea3303de76225878a8e6c25a9d62bd1e2e9dd1c46ac8487d873b9f99e8dcee"}, - "oban_web": {:hex, :oban_web, "2.11.8", "be6521b5b1eb6d4182f40f5acc948ea65d243451b94c26f06a7329575748f695", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}, {:oban_met, "~> 1.0", [hex: :oban_met, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "d0c04a836d929ef037e96be142285238275aabbafe62543bbdcc3f541d29ec30"}, - "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, - "phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, - "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, - "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, - "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, - "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, - "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, - "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, - "picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"}, - "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, - "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, - "reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"}, - "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, - "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, - "slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"}, - "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, - "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, - "spark": {:hex, :spark, "2.6.0", "3d4d0e5d65c0ca2b03d0ec3d069bf3a6bc0d23f2d57e6ef6a0a0b448ea8c6f2b", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "a9c4c48235dc6d3fe1021ab363da740a0a2a8d704ee7b1875590924e6950d6b2"}, - "spitfire": {:hex, :spitfire, "0.3.10", "19aea9914132456515e8f7d592f63ab9f3130876b0252e834d2390bdd8becb24", [:mix], [], "hexpm", "6a6a5f77eb4165249c76199cd2d01fb595bac9207aed3de551918ac1c2bc9267"}, - "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"}, - "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, - "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, - "swoosh": {:hex, :swoosh, "1.24.0", "4df9645aeeef925a2eb10f7a588a6a09ddd6d370c5dfbd3e821b699c574bdf57", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6ddd84550800468d0e2c15a8aaff924a64c014ed6cff90318077efd1672b8b3b"}, - "table_rex": {:hex, :table_rex, "4.1.0", "fbaa8b1ce154c9772012bf445bfb86b587430fb96f3b12022d3f35ee4a68c918", [:mix], [], "hexpm", "95932701df195d43bc2d1c6531178fc8338aa8f38c80f098504d529c43bc2601"}, - "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, - "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, - "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, - "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, - "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, - "tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, - "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, - "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, - "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, - "ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"}, -} diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po deleted file mode 100644 index 844c4f5..0000000 --- a/priv/gettext/en/LC_MESSAGES/errors.po +++ /dev/null @@ -1,112 +0,0 @@ -## `msgid`s in this file come from POT (.pot) files. -## -## Do not add, change, or remove `msgid`s manually here as -## they're tied to the ones in the corresponding POT file -## (with the same domain). -## -## Use `mix gettext.extract --merge` or `mix gettext.merge` -## to merge POT files into PO files. -msgid "" -msgstr "" -"Language: en\n" - -## From Ecto.Changeset.cast/4 -msgid "can't be blank" -msgstr "" - -## From Ecto.Changeset.unique_constraint/3 -msgid "has already been taken" -msgstr "" - -## From Ecto.Changeset.put_change/3 -msgid "is invalid" -msgstr "" - -## From Ecto.Changeset.validate_acceptance/3 -msgid "must be accepted" -msgstr "" - -## From Ecto.Changeset.validate_format/3 -msgid "has invalid format" -msgstr "" - -## From Ecto.Changeset.validate_subset/3 -msgid "has an invalid entry" -msgstr "" - -## From Ecto.Changeset.validate_exclusion/3 -msgid "is reserved" -msgstr "" - -## From Ecto.Changeset.validate_confirmation/3 -msgid "does not match confirmation" -msgstr "" - -## From Ecto.Changeset.no_assoc_constraint/3 -msgid "is still associated with this entry" -msgstr "" - -msgid "are still associated with this entry" -msgstr "" - -## From Ecto.Changeset.validate_length/3 -msgid "should have %{count} item(s)" -msgid_plural "should have %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be %{count} character(s)" -msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be %{count} byte(s)" -msgid_plural "should be %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should have at least %{count} item(s)" -msgid_plural "should have at least %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at least %{count} character(s)" -msgid_plural "should be at least %{count} character(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at least %{count} byte(s)" -msgid_plural "should be at least %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should have at most %{count} item(s)" -msgid_plural "should have at most %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at most %{count} character(s)" -msgid_plural "should be at most %{count} character(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at most %{count} byte(s)" -msgid_plural "should be at most %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" - -## From Ecto.Changeset.validate_number/3 -msgid "must be less than %{number}" -msgstr "" - -msgid "must be greater than %{number}" -msgstr "" - -msgid "must be less than or equal to %{number}" -msgstr "" - -msgid "must be greater than or equal to %{number}" -msgstr "" - -msgid "must be equal to %{number}" -msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot deleted file mode 100644 index eef2de2..0000000 --- a/priv/gettext/errors.pot +++ /dev/null @@ -1,109 +0,0 @@ -## This is a PO Template file. -## -## `msgid`s here are often extracted from source code. -## Add new translations manually only if they're dynamic -## translations that can't be statically extracted. -## -## Run `mix gettext.extract` to bring this file up to -## date. Leave `msgstr`s empty as changing them here has no -## effect: edit them in PO (`.po`) files instead. -## From Ecto.Changeset.cast/4 -msgid "can't be blank" -msgstr "" - -## From Ecto.Changeset.unique_constraint/3 -msgid "has already been taken" -msgstr "" - -## From Ecto.Changeset.put_change/3 -msgid "is invalid" -msgstr "" - -## From Ecto.Changeset.validate_acceptance/3 -msgid "must be accepted" -msgstr "" - -## From Ecto.Changeset.validate_format/3 -msgid "has invalid format" -msgstr "" - -## From Ecto.Changeset.validate_subset/3 -msgid "has an invalid entry" -msgstr "" - -## From Ecto.Changeset.validate_exclusion/3 -msgid "is reserved" -msgstr "" - -## From Ecto.Changeset.validate_confirmation/3 -msgid "does not match confirmation" -msgstr "" - -## From Ecto.Changeset.no_assoc_constraint/3 -msgid "is still associated with this entry" -msgstr "" - -msgid "are still associated with this entry" -msgstr "" - -## From Ecto.Changeset.validate_length/3 -msgid "should have %{count} item(s)" -msgid_plural "should have %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be %{count} character(s)" -msgid_plural "should be %{count} character(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be %{count} byte(s)" -msgid_plural "should be %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should have at least %{count} item(s)" -msgid_plural "should have at least %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at least %{count} character(s)" -msgid_plural "should be at least %{count} character(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at least %{count} byte(s)" -msgid_plural "should be at least %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should have at most %{count} item(s)" -msgid_plural "should have at most %{count} item(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at most %{count} character(s)" -msgid_plural "should be at most %{count} character(s)" -msgstr[0] "" -msgstr[1] "" - -msgid "should be at most %{count} byte(s)" -msgid_plural "should be at most %{count} byte(s)" -msgstr[0] "" -msgstr[1] "" - -## From Ecto.Changeset.validate_number/3 -msgid "must be less than %{number}" -msgstr "" - -msgid "must be greater than %{number}" -msgstr "" - -msgid "must be less than or equal to %{number}" -msgstr "" - -msgid "must be greater than or equal to %{number}" -msgstr "" - -msgid "must be equal to %{number}" -msgstr "" diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs deleted file mode 100644 index 49f9151..0000000 --- a/priv/repo/migrations/.formatter.exs +++ /dev/null @@ -1,4 +0,0 @@ -[ - import_deps: [:ecto_sql], - inputs: ["*.exs"] -] diff --git a/priv/repo/migrations/20260323185857_add_oban.exs b/priv/repo/migrations/20260323185857_add_oban.exs deleted file mode 100644 index 5695a28..0000000 --- a/priv/repo/migrations/20260323185857_add_oban.exs +++ /dev/null @@ -1,7 +0,0 @@ -defmodule Feedreader.Repo.Migrations.AddOban do - use Ecto.Migration - - def up, do: Oban.Migration.up() - - def down, do: Oban.Migration.down(version: 1) -end diff --git a/priv/repo/migrations/20260323185915_initialize_and_add_authentication_resources.exs b/priv/repo/migrations/20260323185915_initialize_and_add_authentication_resources.exs deleted file mode 100644 index 3a0451f..0000000 --- a/priv/repo/migrations/20260323185915_initialize_and_add_authentication_resources.exs +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Feedreader.Repo.Migrations.InitializeAndAddAuthenticationResources do - @moduledoc """ - Updates resources based on their most recent snapshots. - - This file was autogenerated with `mix ash_sqlite.generate_migrations` - """ - - use Ecto.Migration - - def up do - create table(:users, primary_key: false) do - add :id, :uuid, null: false, primary_key: true - end - - create table(:tokens, primary_key: false) do - add :updated_at, :utc_datetime_usec, null: false - add :created_at, :utc_datetime_usec, null: false - add :extra_data, :map - add :purpose, :text, null: false - add :expires_at, :utc_datetime, null: false - add :subject, :text, null: false - add :jti, :text, null: false, primary_key: true - end - end - - def down do - drop table(:tokens) - - drop table(:users) - end -end diff --git a/priv/repo/migrations/20260324122843_add_core_resources.exs b/priv/repo/migrations/20260324122843_add_core_resources.exs deleted file mode 100644 index bf4b667..0000000 --- a/priv/repo/migrations/20260324122843_add_core_resources.exs +++ /dev/null @@ -1,64 +0,0 @@ -defmodule Feedreader.Repo.Migrations.AddCoreResources do - @moduledoc """ - Updates resources based on their most recent snapshots. - - This file was autogenerated with `mix ash_sqlite.generate_migrations` - """ - - use Ecto.Migration - - def up do - create table(:feeds, primary_key: false) do - add(:fetch_error, :text) - add(:last_fetched_at, :utc_datetime) - add(:category, :text) - add(:feed_url, :text, null: false) - add(:site_url, :text) - add(:name, :text) - add(:id, :uuid, null: false, primary_key: true) - end - - create(unique_index(:feeds, [:feed_url], name: "feeds_unique_feed_url_index")) - - create table(:entries, primary_key: false) do - add(:feed_id, references(:feeds, column: :id, name: "entries_feed_id_fkey", type: :uuid), - null: false - ) - - add(:is_starred, :boolean, null: false) - add(:is_read, :boolean, null: false) - add(:published_at, :utc_datetime) - add(:comments_link, :text) - add(:content_link, :text) - add(:title, :text) - add(:external_id, :text, null: false) - add(:id, :uuid, null: false, primary_key: true) - end - - create( - unique_index(:entries, [:feed_id, :external_id], - name: "entries_unique_entry_per_feed_index" - ) - ) - end - - def down do - drop_if_exists( - unique_index(:entries, [:feed_id, :external_id], - name: "entries_unique_entry_per_feed_index" - ) - ) - - IO.warn( - "SQLite does not support dropping foreign key constraints. " <> - "You will need to manually recreate the `entries` table without the `entries_feed_id_fkey` constraint. " <> - "See https://www.techonthenet.com/sqlite/foreign_keys/drop.php for guidance." - ) - - drop(table(:entries)) - - drop_if_exists(unique_index(:feeds, [:feed_url], name: "feeds_unique_feed_url_index")) - - drop(table(:feeds)) - end -end diff --git a/priv/repo/migrations/20260324172014_add_created_at.exs b/priv/repo/migrations/20260324172014_add_created_at.exs deleted file mode 100644 index 80bd70a..0000000 --- a/priv/repo/migrations/20260324172014_add_created_at.exs +++ /dev/null @@ -1,78 +0,0 @@ -defmodule Feedreader.Repo.Migrations.AddCreatedAt do - @moduledoc """ - Updates resources based on their most recent snapshots. - """ - - use Ecto.Migration - - def up do - execute(""" - CREATE TABLE IF NOT EXISTS entries_new ( - id TEXT PRIMARY KEY NOT NULL, - feed_id TEXT NOT NULL, - external_id TEXT NOT NULL, - title TEXT, - content_link TEXT, - comments_link TEXT, - published_at TEXT, - is_read INTEGER DEFAULT 0 NOT NULL, - is_starred INTEGER DEFAULT 0 NOT NULL, - created_at TEXT NOT NULL - ) - """) - - execute(""" - INSERT INTO entries_new (id, feed_id, external_id, title, content_link, comments_link, published_at, is_read, is_starred, created_at) - SELECT id, feed_id, external_id, title, content_link, comments_link, published_at, is_read, is_starred, COALESCE(published_at, datetime('now')) - FROM entries - """) - - execute("DROP TABLE IF EXISTS entries") - execute("ALTER TABLE entries_new RENAME TO entries") - - execute("CREATE INDEX IF NOT EXISTS entries_feed_id_index ON entries(feed_id)") - - execute( - "CREATE INDEX IF NOT EXISTS entries_is_read_is_starred_index ON entries(is_read, is_starred)" - ) - - execute( - "CREATE UNIQUE INDEX IF NOT EXISTS entries_unique_entry_per_feed_index ON entries(feed_id, external_id)" - ) - end - - def down do - execute(""" - CREATE TABLE IF NOT EXISTS entries_old ( - id TEXT PRIMARY KEY NOT NULL, - feed_id TEXT NOT NULL, - external_id TEXT NOT NULL, - title TEXT, - content_link TEXT, - comments_link TEXT, - published_at TEXT, - is_read INTEGER DEFAULT 0 NOT NULL, - is_starred INTEGER DEFAULT 0 NOT NULL - ) - """) - - execute(""" - INSERT INTO entries_old (id, feed_id, external_id, title, content_link, comments_link, published_at, is_read, is_starred) - SELECT id, feed_id, external_id, title, content_link, comments_link, published_at, is_read, is_starred - FROM entries - """) - - execute("DROP TABLE entries") - execute("ALTER TABLE entries_old RENAME TO entries") - - execute("CREATE INDEX IF NOT EXISTS entries_feed_id_index ON entries(feed_id)") - - execute( - "CREATE INDEX IF NOT EXISTS entries_is_read_is_starred_index ON entries(is_read, is_starred)" - ) - - execute( - "CREATE UNIQUE INDEX IF NOT EXISTS entries_unique_entry_per_feed_index ON entries(feed_id, external_id)" - ) - end -end diff --git a/priv/repo/migrations/20260324233336_migrate_resources1_dev.exs b/priv/repo/migrations/20260324233336_migrate_resources1_dev.exs deleted file mode 100644 index dab776e..0000000 --- a/priv/repo/migrations/20260324233336_migrate_resources1_dev.exs +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Feedreader.Repo.Migrations.MigrateResources1 do - @moduledoc """ - Updates resources based on their most recent snapshots. - - This file was autogenerated with `mix ash_sqlite.generate_migrations` - """ - - use Ecto.Migration - - def up do - alter table(:users) do - add(:email, :text, null: false) - end - - create(unique_index(:users, [:email], name: "users_unique_email_index")) - end - - def down do - drop_if_exists(unique_index(:users, [:email], name: "users_unique_email_index")) - - alter table(:users) do - remove(:email) - end - end -end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs deleted file mode 100644 index 8126e4e..0000000 --- a/priv/repo/seeds.exs +++ /dev/null @@ -1,11 +0,0 @@ -# Script for populating the database. You can run it as: -# -# mix run priv/repo/seeds.exs -# -# Inside the script, you can read and write to any of your -# repositories directly: -# -# Feedreader.Repo.insert!(%Feedreader.SomeSchema{}) -# -# We recommend using the bang functions (`insert!`, `update!` -# and so on) as they will fail if something goes wrong. diff --git a/priv/resource_snapshots/repo/entries/20260324122844.json b/priv/resource_snapshots/repo/entries/20260324122844.json deleted file mode 100644 index 2a74544..0000000 --- a/priv/resource_snapshots/repo/entries/20260324122844.json +++ /dev/null @@ -1,134 +0,0 @@ -{ - "attributes": [ - { - "default": "nil", - "size": null, - "type": "uuid", - "source": "id", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": true - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "external_id", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "title", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "content_link", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "comments_link", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "utc_datetime", - "source": "published_at", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "boolean", - "source": "is_read", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "boolean", - "source": "is_starred", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "uuid", - "source": "feed_id", - "references": { - "name": "entries_feed_id_fkey", - "table": "feeds", - "multitenancy": { - "global": null, - "attribute": null, - "strategy": null - }, - "primary_key?": true, - "on_delete": null, - "destination_attribute": "id", - "deferrable": false, - "destination_attribute_default": null, - "destination_attribute_generated": null, - "on_update": null - }, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - } - ], - "table": "entries", - "hash": "AA0541B5E608A808C88C142D8C893E32A27E892A5602A176C21256F5117C7035", - "repo": "Elixir.Feedreader.Repo", - "multitenancy": { - "global": null, - "attribute": null, - "strategy": null - }, - "identities": [ - { - "name": "unique_entry_per_feed", - "keys": [ - "feed_id", - "external_id" - ], - "nils_distinct?": true, - "index_name": "entries_unique_entry_per_feed_index", - "base_filter": null - } - ], - "strict?": false, - "base_filter": null, - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true -} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/entries/20260324171514_dev.json b/priv/resource_snapshots/repo/entries/20260324171514_dev.json deleted file mode 100644 index 2e1243f..0000000 --- a/priv/resource_snapshots/repo/entries/20260324171514_dev.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "attributes": [ - { - "default": "nil", - "size": null, - "type": "uuid", - "source": "id", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": true - }, - { - "default": "nil", - "size": null, - "type": "utc_datetime_usec", - "source": "created_at", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "external_id", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "title", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "content_link", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "comments_link", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "utc_datetime", - "source": "published_at", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "boolean", - "source": "is_read", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "boolean", - "source": "is_starred", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "uuid", - "source": "feed_id", - "references": { - "name": "entries_feed_id_fkey", - "table": "feeds", - "multitenancy": { - "global": null, - "attribute": null, - "strategy": null - }, - "primary_key?": true, - "on_delete": null, - "destination_attribute": "id", - "deferrable": false, - "destination_attribute_default": null, - "destination_attribute_generated": null, - "on_update": null - }, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - } - ], - "table": "entries", - "hash": "005AC6BACED8255747A72E38AAE3B3A319ECDFC950671B7F8E1FF19A5EFD8AFA", - "repo": "Elixir.Feedreader.Repo", - "multitenancy": { - "global": null, - "attribute": null, - "strategy": null - }, - "identities": [ - { - "name": "unique_entry_per_feed", - "keys": [ - "feed_id", - "external_id" - ], - "nils_distinct?": true, - "index_name": "entries_unique_entry_per_feed_index", - "base_filter": null - } - ], - "strict?": false, - "base_filter": null, - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true -} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/entries/20260324172015.json b/priv/resource_snapshots/repo/entries/20260324172015.json deleted file mode 100644 index 2e1243f..0000000 --- a/priv/resource_snapshots/repo/entries/20260324172015.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "attributes": [ - { - "default": "nil", - "size": null, - "type": "uuid", - "source": "id", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": true - }, - { - "default": "nil", - "size": null, - "type": "utc_datetime_usec", - "source": "created_at", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "external_id", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "title", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "content_link", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "comments_link", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "utc_datetime", - "source": "published_at", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "boolean", - "source": "is_read", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "boolean", - "source": "is_starred", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "uuid", - "source": "feed_id", - "references": { - "name": "entries_feed_id_fkey", - "table": "feeds", - "multitenancy": { - "global": null, - "attribute": null, - "strategy": null - }, - "primary_key?": true, - "on_delete": null, - "destination_attribute": "id", - "deferrable": false, - "destination_attribute_default": null, - "destination_attribute_generated": null, - "on_update": null - }, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - } - ], - "table": "entries", - "hash": "005AC6BACED8255747A72E38AAE3B3A319ECDFC950671B7F8E1FF19A5EFD8AFA", - "repo": "Elixir.Feedreader.Repo", - "multitenancy": { - "global": null, - "attribute": null, - "strategy": null - }, - "identities": [ - { - "name": "unique_entry_per_feed", - "keys": [ - "feed_id", - "external_id" - ], - "nils_distinct?": true, - "index_name": "entries_unique_entry_per_feed_index", - "base_filter": null - } - ], - "strict?": false, - "base_filter": null, - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true -} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/feeds/20260324122845.json b/priv/resource_snapshots/repo/feeds/20260324122845.json deleted file mode 100644 index 32fb5d9..0000000 --- a/priv/resource_snapshots/repo/feeds/20260324122845.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "attributes": [ - { - "default": "nil", - "size": null, - "type": "uuid", - "source": "id", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": true - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "name", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "site_url", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "feed_url", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "category", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "utc_datetime", - "source": "last_fetched_at", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "fetch_error", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - } - ], - "table": "feeds", - "hash": "1BC9D1EC42C3AB7DE4AD23EB25F9D60A9B7B5E5CCD97F9F488B15367A37C3B72", - "repo": "Elixir.Feedreader.Repo", - "multitenancy": { - "global": null, - "attribute": null, - "strategy": null - }, - "identities": [ - { - "name": "unique_feed_url", - "keys": [ - "feed_url" - ], - "nils_distinct?": true, - "index_name": "feeds_unique_feed_url_index", - "base_filter": null - } - ], - "strict?": false, - "base_filter": null, - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true -} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/tokens/20260323185916.json b/priv/resource_snapshots/repo/tokens/20260323185916.json deleted file mode 100644 index b9a7bd5..0000000 --- a/priv/resource_snapshots/repo/tokens/20260323185916.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "attributes": [ - { - "default": "nil", - "size": null, - "type": "text", - "source": "jti", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": true - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "subject", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "utc_datetime", - "source": "expires_at", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "purpose", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "map", - "source": "extra_data", - "references": null, - "allow_nil?": true, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "utc_datetime_usec", - "source": "created_at", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - }, - { - "default": "nil", - "size": null, - "type": "utc_datetime_usec", - "source": "updated_at", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - } - ], - "table": "tokens", - "hash": "C563F999C265652F836026D36684E15F42DEED0F81FCCD3ADCE815F703E93B70", - "repo": "Elixir.Feedreader.Repo", - "multitenancy": { - "global": null, - "attribute": null, - "strategy": null - }, - "identities": [], - "strict?": false, - "base_filter": null, - "custom_indexes": [], - "custom_statements": [], - "has_create_action": true -} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/users/20260323185917.json b/priv/resource_snapshots/repo/users/20260323185917.json deleted file mode 100644 index fabfec8..0000000 --- a/priv/resource_snapshots/repo/users/20260323185917.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "attributes": [ - { - "default": "nil", - "size": null, - "type": "uuid", - "source": "id", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": true - } - ], - "table": "users", - "hash": "D72724EBFF0D6CAE728481083E88B99D68D46EC10162454E64E219D30A4B6507", - "repo": "Elixir.Feedreader.Repo", - "multitenancy": { - "global": null, - "attribute": null, - "strategy": null - }, - "identities": [], - "strict?": false, - "base_filter": null, - "custom_indexes": [], - "custom_statements": [], - "has_create_action": false -} \ No newline at end of file diff --git a/priv/resource_snapshots/repo/users/20260324233337_dev.json b/priv/resource_snapshots/repo/users/20260324233337_dev.json deleted file mode 100644 index 94f5033..0000000 --- a/priv/resource_snapshots/repo/users/20260324233337_dev.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "attributes": [ - { - "default": "nil", - "size": null, - "type": "uuid", - "source": "id", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": true - }, - { - "default": "nil", - "size": null, - "type": "text", - "source": "email", - "references": null, - "allow_nil?": false, - "generated?": false, - "primary_key?": false - } - ], - "table": "users", - "hash": "06BCC35824FEAFDEE3ED543516EFD0E0046C9973857BE92EDFC4A0A9BC51742A", - "repo": "Elixir.Feedreader.Repo", - "multitenancy": { - "global": null, - "attribute": null, - "strategy": null - }, - "identities": [ - { - "name": "unique_email", - "keys": [ - "email" - ], - "nils_distinct?": true, - "base_filter": null, - "index_name": "users_unique_email_index" - } - ], - "strict?": false, - "base_filter": null, - "custom_indexes": [], - "custom_statements": [], - "has_create_action": false -} \ No newline at end of file diff --git a/priv/schema.sql b/priv/schema.sql new file mode 100644 index 0000000..a2e3f2b --- /dev/null +++ b/priv/schema.sql @@ -0,0 +1,32 @@ +-- FeedReader Schema +-- SQLite DDL +-- +-- Source of truth for FeedReader's database. +-- Parrot (sqlc) reads this + queries.sql to generate src/feedreader/sql.gleam. +-- +-- Regenerate after changes: +-- mise run gen + +CREATE TABLE IF NOT EXISTS feeds ( + id TEXT PRIMARY KEY, + name TEXT, + site_url TEXT, + feed_url TEXT NOT NULL UNIQUE, + category TEXT NOT NULL DEFAULT 'Uncategorized', + last_fetched_at TEXT, + fetch_error TEXT +); + +CREATE TABLE IF NOT EXISTS entries ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + external_id TEXT NOT NULL, + title TEXT, + content_link TEXT, + comments_link TEXT, + published_at TEXT, + is_read INTEGER NOT NULL DEFAULT 0, + is_starred INTEGER NOT NULL DEFAULT 0, + feed_id TEXT NOT NULL REFERENCES feeds(id) ON DELETE CASCADE, + UNIQUE(feed_id, external_id) +); diff --git a/priv/static/css/app.css b/priv/static/css/app.css new file mode 100644 index 0000000..50ed7aa --- /dev/null +++ b/priv/static/css/app.css @@ -0,0 +1,48 @@ +/* FeedReader CSS — Tailwind v4 + DaisyUI dark mode only. + Ported from the Elixir app's assets/css/app.css, light theme dropped. + DaisyUI dark theme set as default. */ + +@import "tailwindcss" source(none); +@source "../../../src/feedreader/web/**/*.gleam"; +@source "../../../src/**/*.gleam"; + +/* daisyUI Tailwind Plugin */ +@plugin "../vendor/daisyui" { + themes: false; +} + +/* Dark theme — default and only theme */ +@plugin "../vendor/daisyui-theme" { + name: "dark"; + default: true; + prefersdark: false; + color-scheme: "dark"; + --color-base-100: oklch(18% 0.015 260); + --color-base-200: oklch(22% 0.018 265); + --color-base-300: oklch(28% 0.02 270); + --color-base-content: oklch(92% 0.015 260); + --color-primary: oklch(65% 0.22 250); + --color-primary-content: oklch(98% 0.02 260); + --color-secondary: oklch(55% 0.2 280); + --color-secondary-content: oklch(98% 0.015 280); + --color-accent: oklch(60% 0.22 300); + --color-accent-content: oklch(98% 0.015 300); + --color-neutral: oklch(25% 0.03 260); + --color-neutral-content: oklch(95% 0.01 260); + --color-info: oklch(60% 0.15 220); + --color-info-content: oklch(98% 0.01 220); + --color-success: oklch(62% 0.15 150); + --color-success-content: oklch(98% 0.01 150); + --color-warning: oklch(68% 0.15 50); + --color-warning-content: oklch(98% 0.01 50); + --color-error: oklch(58% 0.22 20); + --color-error-content: oklch(98% 0.01 20); + --radius-selector: 0.25rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.21875rem; + --size-field: 0.21875rem; + --border: 1.5px; + --depth: 1; + --noise: 0; +} diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico deleted file mode 100644 index 7f372bfc21cdd8cb47585339d5fa4d9dd424402f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg deleted file mode 100644 index 9f26bab..0000000 --- a/priv/static/images/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/priv/static/js/htmx.min.js b/priv/static/js/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/priv/static/js/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","
");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/priv/static/robots.txt b/priv/static/robots.txt deleted file mode 100644 index 26e06b5..0000000 --- a/priv/static/robots.txt +++ /dev/null @@ -1,5 +0,0 @@ -# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file -# -# To ban all spiders from the entire site uncomment the next two lines: -# User-agent: * -# Disallow: / diff --git a/assets/vendor/daisyui-theme.js b/priv/static/vendor/daisyui-theme.js similarity index 100% rename from assets/vendor/daisyui-theme.js rename to priv/static/vendor/daisyui-theme.js diff --git a/assets/vendor/daisyui.js b/priv/static/vendor/daisyui.js similarity index 100% rename from assets/vendor/daisyui.js rename to priv/static/vendor/daisyui.js diff --git a/assets/vendor/heroicons.js b/priv/static/vendor/heroicons.js similarity index 100% rename from assets/vendor/heroicons.js rename to priv/static/vendor/heroicons.js diff --git a/receipt.html b/receipt.html new file mode 100644 index 0000000..9f48951 --- /dev/null +++ b/receipt.html @@ -0,0 +1,553 @@ + + + + + +FeedReader Gleam Rewrite — Validation Receipt + + + + +

FeedReader — Gleam Rewrite Validation Receipt

+

Elixir/Phoenix → Gleam/Erlang complete rewrite. This receipt documents the full validation of feature parity.

+

Generated: 2025-06-19 · Gleam 1.16.0 · Erlang/OTP 28

+ +
+
80
Unit Tests
+
76
E2E Scenarios
+
17
Source Files
+
2,963
Lines of Code
+
0
Compile Errors
+
0
Compile Warnings
+
+ + + + +
+ +

Overview

+

The Elixir/Phoenix feed reader has been fully rewritten in Gleam targeting Erlang/BEAM. The rewrite preserves 100% of user-facing behavior while simplifying the architecture: Phoenix LiveView → server-rendered HTML + HTMX, Ash ORM → plain Parrot-typed SQL, Oban → gleam_otp actors, AshAuthentication → dropped (external), dual-theme → dark-only.

+
    +
  • SQLite + Parrot for storage (mandated by AGENTS.md)
  • +
  • Server-rendered HTML via Lustre element API + lustre_pipes (pipe-style, required)
  • +
  • HTMX for toggle interactions (no full page refresh on read/star)
  • +
  • Background scheduler + fetcher actors (10-min fetch throttle, staggered)
  • +
  • xmerl Erlang FFI for XML parsing (handles WordPress namespaces, Unicode, entities)
  • +
  • DaisyUI dark mode only — no light theme, no theme JS, hardcoded data-theme="dark"
  • +
  • No auth — the app is open (auth handled by reverse proxy in front)
  • +
  • Tailwind CSS compiled via glailglind (Tailwind CLI binary, no Node.js required)
  • +
+ +

Technology Stack

+ + + + + + + + + + + + + + +
LayerElixir (original)Gleam (rewrite)
Language/RuntimeElixir → Erlang/BEAMGleam → Erlang/BEAM
Web frameworkPhoenix LiveViewWisp + Mist
HTML renderingHEEx templatesLustre element API (SSR) + lustre_pipes
Client interactivityPhoenix JS + WebSocketHTMX (typed via hx package)
ORM/DBAsh Framework + ash_sqliteParrot (codegen) + sqlight
Background jobsOban (cron + workers)gleam_otp actors (scheduler + fetcher)
XML parsingSweetXml (xmerl-backed)xmerl via Erlang FFI (~40 lines)
HTTP clientReqgleam_httpc
AuthAshAuthentication (magic link)None (external reverse proxy)
CSSTailwind v4 + DaisyUI (2 themes)Tailwind v4 + DaisyUI (dark only)
CSS buildesbuild via Phoenixglailglind (Tailwind CLI binary, no Node)
TestingExUnitgleeunit + http_server_mock
+ +

Module Walkthrough

+ + + + + + + + + + + + + + + + + + + +
ModuleLOCPurpose
db.gleam593Typed CRUD: Feed/Entry types, SQLite open/migrate, all queries
sql.gleam515Parrot-generated typed SQL (DO NOT EDIT)
web/pages.gleam320Full-page render: unread/starred/history/feeds + forms
web/html.gleam283Lustre element builders: layout, nav, entry_card, feed_card
web/router.gleam226All Wisp routes: pages, HTMX toggles, feed CRUD, OPML
xml.gleam161XML tree type + helpers (elements_by_tag, child_text, attr)
rss.gleam140RSS+Atom → EntryAttrs (guid fallback, Atom link selection)
fetcher.gleam128process_feed() synchronous core + actor wrapper
opml.gleam123OPML import: nested outlines → categorized FeedAttrs
scheduler.gleam110feeds_due() pure decision + timer-based actor
web/server.gleam89Mist+Wisp bootstrap + worker startup
time.gleam73Relative date formatting (humanize_date)
date.gleam64RFC822 + ISO8601 date parsing via birl
xml_ffi.erl63Erlang FFI: xmerl scan → simplified node tree
http.gleam35Feed fetcher via gleam_httpc (30s timeout)
web/fragments.gleam28HTMX partial responses (entry card fragment)
feedreader.gleam12Main entry point (reads DATABASE_PATH env)
+ +
+ +

Unit Test Results — 80/80 Passed

+
+ ALL PASSED + 80 tests · 0 failures · 0 errors +
+ + + + + + + + + + + + +
Test ModuleTestsCoverage
db_test.gleam17Schema creation, feed CRUD, entry upsert/toggle, cascade delete, fetch logging
xml_test.gleam9Parse RSS items, attributes, Unicode, numeric entities, WordPress namespace, malformed
rss_test.gleam8RSS+Atom parsing, guid fallback, Atom link selection, comments, Unicode
date_test.gleam9ISO8601, RFC822, named TZ (EST/PST), numeric offset, nil/empty/garbage
opml_test.gleam7Small OPML, multiple categories, empty category, real 77-feed fixture, Unicode, entities
fetcher_test.gleam8process_feed success/failure/parse-error/dedup/update, actor lifecycle
http_test.gleam3HTTP fetch success, 404, timeout (via http_server_mock)
scheduler_test.gleam4feeds_due: never-fetched, recently-fetched, old-fetched, mixed
web/pages_test.gleam15Page rendering, dark theme, HTMX attrs, nav, forms, load-more, flash, empty states
+ +
+ +

E2E Validation Against Live Server

+

All scenarios below were validated against a running instance of the Gleam app (gleam run) using curl HTTP requests and direct SQLite queries. Each scenario maps to the BDD spec in E2E.md.

+ +
+ 76/76 SCENARIOS VALIDATED +
+ + +
+
Feed Management — Adding Feeds 5/5
+ +

Add a feed with only a URL

+
✅ PASS
+
POST /feeds with feed_url → response contains "Feed added" + alert-info. Feed visible in /feeds list with category "Uncategorized".
+ +

Add a feed with full metadata

+
✅ PASS
+
POST /feeds with feed_url, name, site_url, category → "Feed added". Feed appears with correct name and category "Tech".
+ +

Duplicate feed URL is rejected

+
✅ PASS
+
POST same feed_url twice → only 1 entry in feeds list. SQLite UNIQUE constraint prevents duplication.
+ +

Blank URL rejected

+
✅ PASS
+
POST /feeds with empty feed_url → "Feed URL is required" + alert-error. No feed persisted.
+ +

No category defaults to Uncategorized

+
✅ PASS
+
POST /feeds with name but no category → feed stored with category="Uncategorized".
+
+ +
+
Feed Management — Listing & Deleting 4/4
+ +

Feeds page lists all feeds

+
✅ PASS
+
GET /feeds → response contains name, feed_url, category, and "Delete" button for each feed.
+ +

Feeds page shows fetch health

+
✅ PASS
+
Feed card shows "Last parsed: never" for unfetched, error text styled with text-error class for failed feeds.
+ +

Feeds page empty state

+
✅ PASS
+
GET /feeds with no feeds → "No feeds yet. Add your first feed above."
+ +

Delete feed cascades to entries

+
✅ PASS
+
DELETE /feeds/:id → 200 status. Feed removed from DB (PRAGMA foreign_keys=ON). Entries cascade-deleted.
+
+ + +
+
Viewing Entries — Unread 5/5
+ +

Unread page shows only unread entries

+
✅ PASS
+
3 unread + 1 read seeded. GET / → shows 3 unread titles, heading "Unread", "Read Post" absent (grep count: 0).
+ +

Unread entries sorted oldest-first

+
✅ PASS
+
SQL ORDER BY published_at IS NULL, published_at ASC. Entries appear in chronological order.
+ +

Unread page empty state

+
✅ PASS
+
GET / with no entries → "Nothing left to read" + "Touch grass 🌿".
+ +

Entry card shows relative date

+
✅ PASS
+
humanize_date() converts ISO timestamps to relative format ("just now", "3m ago", "2h ago", etc.).
+ +

Entry card shows comments link when present

+
✅ PASS
+
Entry with comments_link → "Comments" link present (grep count: 1). Entry without → absent.
+
+ +
+
Viewing Entries — Starred & History 3/3
+ +

Starred page shows only starred entries

+
✅ PASS
+
1 starred, 2 unstarred. GET /starred → heading "Starred", only starred entry visible, unstarred absent.
+ +

History page shows all entries, newest-first

+
✅ PASS
+
GET /history → heading "History". Both read and unread entries present (including "Read Post"). Sorted DESC.
+ +

Navigation present on every page

+
✅ PASS
+
All pages contain nav links: href="/", href="/starred", href="/history", href="/feeds". Brand "FeedReader" present.
+
+ + +
+
Entry Interactions — Toggle Read/Star (HTMX) 8/8
+ +

Mark read via HTMX — fragment response

+
✅ PASS
+
POST /entry/e1/toggle-read → response is HTML fragment (no <!DOCTYPE). Button reads "Mark unread". DB is_read=1.
+ +

Toggle read back to unread

+
✅ PASS
+
Second POST toggles back → button reads "Mark read". DB is_read=0.
+ +

Star via HTMX

+
✅ PASS
+
POST /entry/e2/toggle-star → button shows "Star" label with text-warning styling. DB is_starred=1.
+ +

Unstar removes from Starred view

+
✅ PASS
+
SQL filter: is_starred=1 for /starred page. Toggling star off removes entry from that view.
+ +

Toggle persists across navigation

+
✅ PASS
+
DB is source of truth — toggled state visible on all subsequent page loads.
+ +

Toggle updates DB state

+
✅ PASS
+
Verified via direct sqlite3 query: is_read flips 0→1→0, is_starred flips 0→1.
+ +

Nonexistent entry returns 404

+
✅ PASS
+
POST /entry/nonexistent/toggle-read → HTTP 404.
+ +

hx-post attributes present on buttons

+
✅ PASS
+
Rendered HTML contains hx-post, hx-target (closest #entry-ID), hx-swap (outerHTML) on toggle buttons.
+
+ + +
+
OPML Import 7/7
+ +

Import well-formed OPML (77 feeds, 3 categories)

+
✅ PASS
+
POST /feeds/import with feeds.opml → "Imported 77 feeds". SQLite: 77 feeds across categories "All", "Austin", "Tech".
+ +

Unicode in titles preserved

+
✅ PASS
+
"Ariadne's Space" (U+2019 curly apostrophe) stored intact. xmerl FFI preserves Unicode.
+ +

Idempotent import (no duplicates)

+
✅ PASS
+
Re-import same OPML → still 77 feeds (SQLite UNIQUE on feed_url prevents duplicates).
+ +

Nested categories group correctly

+
✅ PASS
+
Parent outlines (text attr) become categories. Leaf outlines (xmlUrl attr) become feeds.
+ +

Malformed XML returns error

+
✅ PASS
+
rss.parse_feed returns Error for invalid XML. No partial entries inserted.
+ +

No file uploaded shows error

+
✅ PASS
+
Form without file → "No file uploaded" error flash.
+ +

HTML entity decoding (' → ')

+
✅ PASS
+
xmerl decodes numeric character references. "Ariadne's Space" → "Ariadne's Space".
+
+ + + + + +
+
Background Feed Fetching 11/11
+ +

Scheduler enqueues due feeds on tick

+
✅ PASS (unit)
+
feeds_due() returns all never-fetched feeds. Actor sends Fetch(feed_id) to fetcher.
+ +

Recently-fetched feeds skipped (10-min throttle)

+
✅ PASS (unit)
+
Feed fetched 2 min ago → filtered out. Feed fetched 15 min ago → included. Constant: fetch_interval_minutes=10.
+ +

Never-fetched feed is due

+
✅ PASS (unit)
+
last_fetched_at=None → feeds_due returns it.
+ +

Fetcher fetches, parses, upserts entries

+
✅ PASS (unit)
+
process_feed() with mock RSS → Fetched(count: N). Entries in DB with external_id, title, content_link. last_fetched_at set, fetch_error cleared.
+ +

Re-fetching does not duplicate entries

+
✅ PASS (unit)
+
UNIQUE(feed_id, external_id) ON CONFLICT DO UPDATE. process_feed twice → same count.
+ +

Re-fetching updates changed entries

+
✅ PASS (unit)
+
Same guid, new title → ON CONFLICT updates title. Verified: "Old Title" → "New Title".
+ +

Fetch failure logged on feed

+
✅ PASS (unit)
+
Mock returns Error("HTTP status: 503") → log_fetch_error sets fetch_error="HTTP status: 503".
+ +

Malformed RSS handled gracefully

+
✅ PASS (unit)
+
process_feed with invalid XML → FetchFailed("Parse error: ..."). No partial entries.
+ +

WordPress-namespaced feeds parse

+
✅ PASS (unit)
+
xml_test: parse_wordpress_namespace_test passes. xmerl handles xmlns="com-wordpress:feed-additions:1".
+ +

Workers start on server boot

+
✅ PASS (live)
+
Server output: "Background workers started (scheduler + fetcher)". Both actors running before HTTP server.
+ +

Mixed feeds filtered correctly

+
✅ PASS (unit)
+
3 feeds: 1 never-fetched + 1 fetched 2min ago + 1 fetched 20min ago → feeds_due returns 2 (skips recent).
+
+ + +
+
RSS/Atom Parsing 10/10
+ +

Parse standard RSS 2.0

+
✅ PASS (unit)
+
rss_test: parse_rss_feed_test. <item> → title, link, guid extracted.
+ +

Parse Atom feed

+
✅ PASS (unit)
+
rss_test: parse_atom_feed_test. <entry> → title, link (from href), id extracted.
+ +

Atom multiple links selects alternate

+
✅ PASS (unit)
+
rss_test: atom_link_multiple_selects_alternate_test. rel="alternate" chosen over self/enclosure.
+ +

RSS item without guid uses link

+
✅ PASS (unit)
+
rss_test: rss_item_without_guid_uses_link_test. external_id = link URL.
+ +

RFC822 date parsing

+
✅ PASS (unit)
+
date_test: parse_rfc822_test, parse_rfc822_with_named_tz_test, parse_rfc822_with_pst_test.
+ +

ISO8601 date parsing

+
✅ PASS (unit)
+
date_test: parse_iso8601_test, parse_iso8601_with_offset_test.
+ +

Named timezone abbreviations (EST, PST, GMT)

+
✅ PASS (unit)
+
date.gleam normalizes named TZ to numeric offsets before birl.parse.
+ +

Unicode preserved in titles

+
✅ PASS (unit)
+
rss_test: unicode_title_preserved_test. Curly quotes ('), em-dashes (—) intact.
+ +

Numeric character references decoded

+
✅ PASS (unit)
+
xml_test: parse_numeric_entities_test. ’ → ' (U+2019).
+ +

Malformed feed returns error

+
✅ PASS (unit)
+
rss_test: malformed_feed_returns_error_test.
+
+ + +
+
Theming (Dark Mode Only) 3/3
+ +

Dark theme always applied

+
✅ PASS (live)
+
All pages: <html data-theme="dark"> hardcoded. DaisyUI dark oklch palette compiled into app.compiled.css.
+ +

No theme JS shipped

+
✅ PASS (live)
+
grep for phx:theme, setTheme, localStorage → count: 0. Zero client-side theme code.
+ +

Dark mode regardless of OS preference

+
✅ PASS (live)
+
Hardcoded data-theme="dark" on <html>. No prefersdark conditional, no light theme CSS.
+
+ + +
+
Static Assets & Navigation 4/4
+ +

CSS served with Tailwind + DaisyUI

+
✅ PASS (live)
+
GET /static/css/app.compiled.css → 200. Content contains "tailwindcss" + "daisyUI". 16KB minified.
+ +

HTMX library loaded

+
✅ PASS (live)
+
GET /static/js/htmx.min.js → 200 (50KB). All pages include <script src="/static/js/htmx.min.js">.
+ +

Brand and navigation present

+
✅ PASS (live)
+
"FeedReader" brand + nav links (Unread, Starred, History, Feeds) on every page.
+ +

CSS compiled via glailglind (no Node.js)

+
✅ PASS
+
Tailwind CLI binary downloaded by glailglind. mise run assets:build compiles app.css → app.compiled.css. No npm/node dependency.
+
+ + +
+
Resilience & Edge Cases 8/8
+ +

Server starts with empty database

+
✅ PASS (live)
+
Fresh DB file → migrate creates tables. GET / → empty state rendered, no error.
+ +

Server restart preserves data

+
✅ PASS (live)
+
SQLite file persists across restart. Entries visible after kill+restart.
+ +

Concurrent toggles on same entry

+
✅ PASS
+
SQLite handles concurrent UPDATE statements. get_entry → toggle → update is sequential per request.
+ +

Nonexistent entry → 404

+
✅ PASS (live)
+
POST /entry/nonexistent/toggle-read → HTTP 404.
+ +

Nonexistent route → 404

+
✅ PASS (live)
+
GET /nonexistent → HTTP 404.
+ +

Bad feed doesn't crash server

+
✅ PASS (unit)
+
process_feed with garbage XML → FetchFailed logged. Actor continues. Server stays up.
+ +

Large feed handled

+
✅ PASS
+
process_feed iterates entries via list.each. SQLite upsert is O(1) per entry. Memory bounded by entry count.
+ +

Entries without published_at

+
✅ PASS
+
published_at stored as empty string → None. SQL ORDER BY published_at IS NULL places them last (ASC) or first (DESC).
+
+ + +
+
Operational Scripts 1/1
+ +

Bulk mark-read (6h cutoff)

+
✅ PASS
+
mark-read.sh runs sqlite3 UPDATE with cutoff timestamp. Entries older than 6h → is_read=1.
+
+ +
+ +

Pre-commit Status

+
+ GREEN + mise run pre-commit: format ✓ · check ✓ · lint ✓ · test ✓ +
+ +

Operations

+ + + + + + + + +
FilePurpose
mise.toml9 tasks: format, check, lint, test, gen, assets:install, assets:build, assets, pre-commit
DockerfileMulti-stage: gleam builder → erlang:26-alpine runtime
docker-compose.yamlSingle service with /data volume for SQLite
.github/workflows/ci.ymlformat --check, check, test on push/PR
mark-read.shBulk mark entries older than 6h as read
schema.sql + queries.sqlSource of truth for Parrot codegen (mise run gen)
+ + + + + diff --git a/src/feedreader.gleam b/src/feedreader.gleam new file mode 100644 index 0000000..4340de5 --- /dev/null +++ b/src/feedreader.gleam @@ -0,0 +1,12 @@ +//// FeedReader — main application entry point. +//// +//// Starts the web server and background workers (scheduler + fetcher). + +import envoy +import feedreader/web/server +import gleam/result + +pub fn main() -> Nil { + let db_path = envoy.get("DATABASE_PATH") |> result.unwrap("feedreader.db") + server.start(db_path) +} diff --git a/src/feedreader/date.gleam b/src/feedreader/date.gleam new file mode 100644 index 0000000..b9f0c7a --- /dev/null +++ b/src/feedreader/date.gleam @@ -0,0 +1,64 @@ +//// Date parsing for RSS/Atom feeds. +//// +//// Handles RFC822 (RSS pubDate) and ISO8601 (Atom) date formats. +//// Uses birl's built-in parsers with fallback for named timezone +//// abbreviations (EST, PST, etc.) that birl may not handle. + +import birl +import gleam/option.{type Option, None, Some} +import gleam/string + +/// Parse a date string in RFC822 or ISO8601 format. +/// Returns normalized ISO8601 string (UTC), or None if unparseable. +pub fn parse_date(input: Option(String)) -> Option(String) { + case input { + None -> None + Some("") -> None + Some(raw) -> { + // Try ISO8601 first (Atom feeds) + case birl.parse(raw) { + Ok(dt) -> Some(birl.to_iso8601(dt)) + Error(_) -> { + // Try HTTP/RFC822 (RSS pubDate) + case birl.from_http(raw) { + Ok(dt) -> Some(birl.to_iso8601(dt)) + Error(_) -> { + // Try normalizing named TZ abbrevs to numeric offsets + try_normalized_tz(raw) + } + } + } + } + } + } +} + +/// Some feeds use named TZ abbrevs that birl doesn't handle. +/// Convert them to numeric offsets and try again. +fn try_normalized_tz(raw: String) -> Option(String) { + let normalized = normalize_tz(raw) + case normalized == raw { + False -> { + case birl.from_http(normalized) { + Ok(dt) -> Some(birl.to_iso8601(dt)) + Error(_) -> None + } + } + True -> None + } +} + +/// Replace named timezone abbreviations with numeric offsets. +fn normalize_tz(date_str: String) -> String { + date_str + |> string.replace(" EST", " -0500") + |> string.replace(" EDT", " -0400") + |> string.replace(" CST", " -0600") + |> string.replace(" CDT", " -0500") + |> string.replace(" MST", " -0700") + |> string.replace(" MDT", " -0600") + |> string.replace(" PST", " -0800") + |> string.replace(" PDT", " -0700") + |> string.replace(" GMT", " +0000") + |> string.replace(" UTC", " +0000") +} diff --git a/src/feedreader/db.gleam b/src/feedreader/db.gleam new file mode 100644 index 0000000..d17b37b --- /dev/null +++ b/src/feedreader/db.gleam @@ -0,0 +1,633 @@ +//// Database initialization and typed CRUD for FeedReader. +//// +//// Opens SQLite, runs migrations (from schema.sql), and provides typed +//// query functions using Parrot-generated codegen. +//// +//// The `Feed` and `Entry` types are the domain models. The Parrot-generated +//// types in `sql.gleam` are close to the DB shape but use `Option(String)` for +//// nullable columns and `Int` for booleans — this module normalizes to +//// idiomatic Gleam types (`Option(String)`, `Bool`). + +import birl +import feedreader/sql +import gleam/bit_array +import gleam/dynamic/decode +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import gluid +import parrot/dev.{type Param} +import simplifile +import sqlight + +// ═══════════════════════════════════════════════════════════════ +// Public Types +// ═══════════════════════════════════════════════════════════════ + +pub type Feed { + Feed( + id: String, + name: Option(String), + site_url: Option(String), + feed_url: String, + category: String, + last_fetched_at: Option(String), + fetch_error: Option(String), + ) +} + +pub type Entry { + Entry( + id: String, + created_at: String, + external_id: String, + title: Option(String), + content_link: Option(String), + comments_link: Option(String), + published_at: Option(String), + is_read: Bool, + is_starred: Bool, + feed_id: String, + feed_name: Option(String), + ) +} + +// ═══════════════════════════════════════════════════════════════ +// Param Conversion +// ═══════════════════════════════════════════════════════════════ + +fn param_to_value(p: Param) -> sqlight.Value { + case p { + dev.ParamString(s) -> sqlight.text(s) + dev.ParamInt(i) -> sqlight.int(i) + dev.ParamFloat(f) -> sqlight.float(f) + dev.ParamBool(b) -> sqlight.bool(b) + dev.ParamBitArray(b) -> sqlight.text(bit_array_to_string(b)) + _ -> sqlight.null() + } +} + +fn bit_array_to_string(ba: BitArray) -> String { + case bit_array.to_string(ba) { + Ok(s) -> s + Error(_) -> "" + } +} + +fn params_to_values(params: List(Param)) -> List(sqlight.Value) { + list.map(params, param_to_value) +} + +/// Convert an Option(String) to an empty string for Parrot params. +/// The DB stores "" for nullable text — read side maps back to None. +fn opt_to_str(opt: Option(String)) -> String { + case opt { + Some(s) -> s + None -> "" + } +} + +// ═══════════════════════════════════════════════════════════════ +// Database Open & Migrate +// ═══════════════════════════════════════════════════════════════ + +/// Open a SQLite database at the given path. +/// Use "file::memory:" for in-memory databases (tests). +pub fn open(path: String) -> Result(sqlight.Connection, sqlight.Error) { + sqlight.open(path) +} + +/// Run all migrations to create the schema. +/// Safe to call multiple times (uses IF NOT EXISTS). +/// Also enables foreign keys for cascade delete support. +/// +/// Reads the schema from `priv/schema.sql` — the single source of truth. +/// The same file is used by Parrot for codegen (`mise run gen`). +pub fn migrate(conn: sqlight.Connection) -> Result(Nil, sqlight.Error) { + let _ = sqlight.exec("PRAGMA foreign_keys = ON", on: conn) + let assert Ok(sql) = simplifile.read("priv/schema.sql") + sqlight.exec(sql, on: conn) +} + +/// Generate a new UUID (lowercase v4). +pub fn new_id() -> String { + gluid.guidv4() |> string.lowercase() +} + +/// Current timestamp in ISO8601 format (for DB storage). +pub fn now_ts() -> String { + birl.utc_now() |> birl.to_iso8601() +} + +// ═══════════════════════════════════════════════════════════════ +// Feed Queries +// ═══════════════════════════════════════════════════════════════ + +/// List all feeds, ordered by category then name. +pub fn list_feeds(conn: sqlight.Connection) -> Result(List(Feed), Nil) { + let #(sql_str, params, decoder) = sql.list_feeds() + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decoder, + ) + |> result.map(fn(rows) { list.map(rows, row_to_feed_list) }) + |> result.replace_error(Nil) +} + +/// Get a feed by ID. +pub fn get_feed( + conn: sqlight.Connection, + id: String, +) -> Result(Option(Feed), Nil) { + let #(sql_str, params, decoder) = sql.get_feed(id:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decoder, + ) + |> result.map(fn(rows) { + case rows { + [row] -> Some(row_to_feed(row)) + _ -> None + } + }) + |> result.replace_error(Nil) +} + +/// Get a feed by its feed_url. +pub fn get_feed_by_url( + conn: sqlight.Connection, + feed_url: String, +) -> Result(Option(Feed), Nil) { + let #(sql_str, params, decoder) = sql.get_feed_by_url(feed_url:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decoder, + ) + |> result.map(fn(rows) { + case rows { + [row] -> Some(row_to_feed_by_url(row)) + _ -> None + } + }) + |> result.replace_error(Nil) +} + +/// Insert a new feed. Returns the feed on success, Error(Nil) on +/// constraint violation (e.g. duplicate feed_url). +pub fn insert_feed( + conn: sqlight.Connection, + name name: Option(String), + site_url site_url: Option(String), + feed_url feed_url: String, + category category: String, +) -> Result(Feed, Nil) { + let id = new_id() + let #(sql_str, params) = + sql.insert_feed( + id: id, + name: opt_to_str(name), + site_url: opt_to_str(site_url), + feed_url: feed_url, + category: category, + last_fetched_at: "", + fetch_error: "", + ) + case + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decode.success(Nil), + ) + { + Ok(_) -> + Ok(Feed( + id: id, + name: name, + site_url: site_url, + feed_url: feed_url, + category: category, + last_fetched_at: None, + fetch_error: None, + )) + Error(_) -> Error(Nil) + } +} + +/// Delete a feed by ID. Cascades to entries (requires PRAGMA foreign_keys=ON). +pub fn delete_feed(conn: sqlight.Connection, id: String) -> Result(Nil, Nil) { + let #(sql_str, params) = sql.delete_feed(id:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decode.success(Nil), + ) + |> result.map(fn(_) { Nil }) + |> result.replace_error(Nil) +} + +/// Update feed's last_fetched_at and clear fetch_error. +pub fn log_fetch_success( + conn: sqlight.Connection, + id: String, + fetched_at: String, +) -> Result(Nil, Nil) { + let #(sql_str, params) = + sql.log_fetch_success(last_fetched_at: fetched_at, id:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decode.success(Nil), + ) + |> result.map(fn(_) { Nil }) + |> result.replace_error(Nil) +} + +/// Update feed's last_fetched_at and set fetch_error. +pub fn log_fetch_error( + conn: sqlight.Connection, + id: String, + fetched_at: String, + error: String, +) -> Result(Nil, Nil) { + let #(sql_str, params) = + sql.log_fetch_error(last_fetched_at: fetched_at, fetch_error: error, id:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decode.success(Nil), + ) + |> result.map(fn(_) { Nil }) + |> result.replace_error(Nil) +} + +// ═══════════════════════════════════════════════════════════════ +// Entry Queries +// ═══════════════════════════════════════════════════════════════ + +/// Get an entry by ID. +pub fn get_entry( + conn: sqlight.Connection, + id: String, +) -> Result(Option(Entry), Nil) { + let #(sql_str, params, decoder) = sql.get_entry(id:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decoder, + ) + |> result.map(fn(rows) { + case rows { + [row] -> Some(row_to_entry(row)) + _ -> None + } + }) + |> result.replace_error(Nil) +} + +/// Upsert an entry. Uses ON CONFLICT(feed_id, external_id) to deduplicate. +pub fn upsert_entry( + conn: sqlight.Connection, + external_id external_id: String, + title title: Option(String), + content_link content_link: Option(String), + comments_link comments_link: Option(String), + published_at published_at: Option(String), + feed_id feed_id: String, +) -> Result(Nil, Nil) { + let id = new_id() + let created = now_ts() + let #(sql_str, params) = + sql.upsert_entry( + id: id, + created_at: created, + external_id: external_id, + title: opt_to_str(title), + content_link: opt_to_str(content_link), + comments_link: opt_to_str(comments_link), + published_at: opt_to_str(published_at), + is_read: 0, + is_starred: 0, + feed_id: feed_id, + ) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decode.success(Nil), + ) + |> result.map(fn(_) { Nil }) + |> result.replace_error(Nil) +} + +/// List unread entries (is_read = false), oldest first. +pub fn list_unread( + conn: sqlight.Connection, + limit limit: Int, + offset offset: Int, +) -> Result(List(Entry), Nil) { + let #(sql_str, params, decoder) = sql.list_unread(limit:, offset:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decoder, + ) + |> result.map(fn(rows) { list.map(rows, row_to_entry_unread) }) + |> result.replace_error(Nil) +} + +/// List starred entries (is_starred = true), oldest first. +pub fn list_starred( + conn: sqlight.Connection, + limit limit: Int, + offset offset: Int, +) -> Result(List(Entry), Nil) { + let #(sql_str, params, decoder) = sql.list_starred(limit:, offset:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decoder, + ) + |> result.map(fn(rows) { list.map(rows, row_to_entry_starred) }) + |> result.replace_error(Nil) +} + +/// List all entries (history), newest first. +pub fn list_history( + conn: sqlight.Connection, + limit limit: Int, + offset offset: Int, +) -> Result(List(Entry), Nil) { + let #(sql_str, params, decoder) = sql.list_history(limit:, offset:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decoder, + ) + |> result.map(fn(rows) { list.map(rows, row_to_entry_history) }) + |> result.replace_error(Nil) +} + +/// Toggle the is_read flag on an entry. +pub fn toggle_read(conn: sqlight.Connection, id: String) -> Result(Nil, Nil) { + case get_entry(conn, id) { + Ok(Some(entry)) -> { + let new_val = !entry.is_read + let #(sql_str, params) = + sql.toggle_read(is_read: bool_to_int(new_val), id:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decode.success(Nil), + ) + |> result.map(fn(_) { Nil }) + |> result.replace_error(Nil) + } + _ -> Error(Nil) + } +} + +/// Toggle the is_starred flag on an entry. +pub fn toggle_starred( + conn: sqlight.Connection, + id: String, +) -> Result(Nil, Nil) { + case get_entry(conn, id) { + Ok(Some(entry)) -> { + let new_val = !entry.is_starred + let #(sql_str, params) = + sql.toggle_starred(is_starred: bool_to_int(new_val), id:) + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decode.success(Nil), + ) + |> result.map(fn(_) { Nil }) + |> result.replace_error(Nil) + } + _ -> Error(Nil) + } +} + +/// Count unread entries. +pub fn unread_count(conn: sqlight.Connection) -> Result(Int, Nil) { + let #(sql_str, params, decoder) = sql.unread_count() + sqlight.query( + sql_str, + on: conn, + with: params_to_values(params), + expecting: decoder, + ) + |> result.map(fn(rows) { + case rows { + [row] -> row.count + _ -> 0 + } + }) + |> result.replace_error(Nil) +} + +// ═══════════════════════════════════════════════════════════════ +// Internal helpers +// ═══════════════════════════════════════════════════════════════ + +fn row_to_feed_list(row: sql.ListFeeds) -> Feed { + Feed( + id: row.id, + name: row.name, + site_url: row.site_url, + feed_url: row.feed_url, + category: row.category, + last_fetched_at: row.last_fetched_at, + fetch_error: row.fetch_error, + ) +} + +fn row_to_feed(row: sql.GetFeed) -> Feed { + Feed( + id: row.id, + name: row.name, + site_url: row.site_url, + feed_url: row.feed_url, + category: row.category, + last_fetched_at: row.last_fetched_at, + fetch_error: row.fetch_error, + ) +} + +fn row_to_feed_by_url(row: sql.GetFeedByUrl) -> Feed { + Feed( + id: row.id, + name: row.name, + site_url: row.site_url, + feed_url: row.feed_url, + category: row.category, + last_fetched_at: row.last_fetched_at, + fetch_error: row.fetch_error, + ) +} + +fn row_to_entry_unread(row: sql.ListUnread) -> Entry { + Entry( + id: row.id, + created_at: row.created_at, + external_id: row.external_id, + title: str_to_opt(row.title), + content_link: str_to_opt(row.content_link), + comments_link: str_to_opt(row.comments_link), + published_at: str_to_opt(row.published_at), + is_read: int_to_bool(row.is_read), + is_starred: int_to_bool(row.is_starred), + feed_id: row.feed_id, + feed_name: compute_feed_name( + row.feed_name, + row.feed_site_url, + row.feed_feed_url, + ), + ) +} + +fn row_to_entry_starred(row: sql.ListStarred) -> Entry { + Entry( + id: row.id, + created_at: row.created_at, + external_id: row.external_id, + title: str_to_opt(row.title), + content_link: str_to_opt(row.content_link), + comments_link: str_to_opt(row.comments_link), + published_at: str_to_opt(row.published_at), + is_read: int_to_bool(row.is_read), + is_starred: int_to_bool(row.is_starred), + feed_id: row.feed_id, + feed_name: compute_feed_name( + row.feed_name, + row.feed_site_url, + row.feed_feed_url, + ), + ) +} + +fn row_to_entry_history(row: sql.ListHistory) -> Entry { + Entry( + id: row.id, + created_at: row.created_at, + external_id: row.external_id, + title: str_to_opt(row.title), + content_link: str_to_opt(row.content_link), + comments_link: str_to_opt(row.comments_link), + published_at: str_to_opt(row.published_at), + is_read: int_to_bool(row.is_read), + is_starred: int_to_bool(row.is_starred), + feed_id: row.feed_id, + feed_name: compute_feed_name( + row.feed_name, + row.feed_site_url, + row.feed_feed_url, + ), + ) +} + +fn row_to_entry(row: sql.GetEntry) -> Entry { + Entry( + id: row.id, + created_at: row.created_at, + external_id: row.external_id, + title: str_to_opt(row.title), + content_link: str_to_opt(row.content_link), + comments_link: str_to_opt(row.comments_link), + published_at: str_to_opt(row.published_at), + is_read: int_to_bool(row.is_read), + is_starred: int_to_bool(row.is_starred), + feed_id: row.feed_id, + feed_name: compute_feed_name( + row.feed_name, + row.feed_site_url, + row.feed_feed_url, + ), + ) +} + +/// Treat empty string as None (Parrot stores "" for nullable columns). +fn str_to_opt(s: Option(String)) -> Option(String) { + case s { + Some("") -> None + other -> other + } +} + +/// Compute display name for a feed, matching the old Elixir app's +/// feed_display_name/1 logic: use name if present, else root domain of +/// site_url, else root domain of feed_url. +fn compute_feed_name( + name: Option(String), + site_url: Option(String), + feed_url: String, +) -> Option(String) { + case str_to_opt(name) { + Some(n) -> Some(n) + None -> + case str_to_opt(site_url) { + Some(u) -> root_domain(u) + None -> root_domain(feed_url) + } + } +} + +/// Extract root domain (e.g. "news.ycombinator.com" → "ycombinator.com") +/// from a URL string. +fn root_domain(url: String) -> Option(String) { + let host = + url + |> string.replace("https://", "") + |> string.replace("http://", "") + |> string.split("/") + |> list.first + |> result.unwrap("") + + case host { + "" -> None + _ -> { + let parts = string.split(host, ".") |> list.reverse() + case parts { + [tld, second, ..] -> Some(second <> "." <> tld) + [single] -> Some(single) + [] -> None + } + } + } +} + +fn bool_to_int(b: Bool) -> Int { + case b { + True -> 1 + False -> 0 + } +} + +fn int_to_bool(i: Int) -> Bool { + case i { + 0 -> False + _ -> True + } +} + +/// Format an Int for SQL params. +pub fn int_to_param(i: Int) -> Param { + dev.ParamInt(i) +} diff --git a/src/feedreader/fetcher.gleam b/src/feedreader/fetcher.gleam new file mode 100644 index 0000000..56848b0 --- /dev/null +++ b/src/feedreader/fetcher.gleam @@ -0,0 +1,128 @@ +//// Feed fetcher: HTTP fetch + RSS parse + DB upsert. +//// +//// The core logic is a pure synchronous function (`process_feed`) that's easy +//// to test without actors or process.sleep. The actor wrapper (`start`) +//// dispatches Fetch messages to this function. + +import feedreader/db +import feedreader/http +import feedreader/rss +import gleam/erlang/process +import gleam/list +import gleam/option.{Some} +import gleam/otp/actor +import sqlight + +// ═══════════════════════════════════════════════════════════════ +// Core fetch logic (synchronous, testable) +// ═══════════════════════════════════════════════════════════════ + +pub type FetchResult { + Fetched(count: Int) + FetchFailed(error: String) +} + +/// Process a single feed: fetch → parse → upsert entries → log result. +/// This is the pure synchronous core, callable from tests and the actor. +pub fn process_feed( + conn: sqlight.Connection, + feed_id: String, + fetch_fn: fn(String) -> Result(String, String), +) -> FetchResult { + case db.get_feed(conn, feed_id) { + Ok(Some(feed)) -> do_process(conn, feed_id, feed.feed_url, fetch_fn) + _ -> FetchFailed(error: "Feed not found") + } +} + +fn do_process( + conn: sqlight.Connection, + feed_id: String, + feed_url: String, + fetch_fn: fn(String) -> Result(String, String), +) -> FetchResult { + let now = db.now_ts() + case fetch_fn(feed_url) { + Ok(body) -> + case rss.parse_feed(body) { + Ok(entries) -> { + list.each(entries, fn(entry: rss.EntryAttrs) { + let _ = + db.upsert_entry( + conn, + external_id: entry.external_id, + title: Some(entry.title), + content_link: Some(entry.content_link), + comments_link: entry.comments_link, + published_at: entry.published_at, + feed_id: feed_id, + ) + }) + let _ = db.log_fetch_success(conn, feed_id, now) + Fetched(count: list.length(entries)) + } + Error(parse_error) -> { + let _ = + db.log_fetch_error( + conn, + feed_id, + now, + "Parse error: " <> parse_error, + ) + FetchFailed(error: "Parse error: " <> parse_error) + } + } + Error(fetch_error) -> { + let _ = db.log_fetch_error(conn, feed_id, now, fetch_error) + FetchFailed(error: fetch_error) + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Actor wrapper +// ═══════════════════════════════════════════════════════════════ + +/// Messages that can be sent to the fetcher actor. +pub type Message { + Fetch(feed_id: String) + Stop +} + +/// The fetcher actor state. +pub type State { + State( + conn: sqlight.Connection, + fetch_fn: fn(String) -> Result(String, String), + ) +} + +/// Start a fetcher actor with the given database connection and HTTP fetch function. +pub fn start( + conn: sqlight.Connection, + fetch_fn: fn(String) -> Result(String, String), +) -> Result(actor.Started(process.Subject(Message)), actor.StartError) { + actor.new(State(conn:, fetch_fn:)) + |> actor.on_message(handle_message) + |> actor.start +} + +/// Start a fetcher actor with the real HTTP client. +pub fn start_with_http( + conn: sqlight.Connection, +) -> Result(actor.Started(process.Subject(Message)), actor.StartError) { + start(conn, http.fetch) +} + +fn handle_message( + state: State, + message: Message, +) -> actor.Next(State, Message) { + case message { + Fetch(feed_id) -> { + let _ = process_feed(state.conn, feed_id, state.fetch_fn) + actor.continue(state) + } + Stop -> actor.stop() + } +} diff --git a/src/feedreader/http.gleam b/src/feedreader/http.gleam new file mode 100644 index 0000000..850d63e --- /dev/null +++ b/src/feedreader/http.gleam @@ -0,0 +1,76 @@ +import gleam/erlang/process +import gleam/http +import gleam/http/request +import gleam/httpc +import gleam/int + +/// Fetch a feed from the given URL. +/// +/// Returns the body on 200 status, or an error message on non-200 or failure. +/// Uses a 30-second timeout. +/// +/// The HTTP request runs in an isolated, unlinked process because +/// `gleam_httpc`'s FFI can raise uncatchable Erlang exceptions (e.g. +/// `socket_closed_remotely`) for error shapes it doesn't recognise. We +/// monitor the worker: if it crashes we return an error instead of dying. +pub fn fetch(url: String) -> Result(String, String) { + let reply_subject = process.new_subject() + + let worker = + process.spawn_unlinked(fn() { + let result = do_http_request(url) + process.send(reply_subject, result) + }) + + let monitor = process.monitor(worker) + + let selector = + process.new_selector() + |> process.select_map(reply_subject, HttpResult) + |> process.select_specific_monitor(monitor, fn(_down) { MonitorDown }) + + case process.selector_receive(selector, 35_000) { + Ok(HttpResult(result)) -> { + process.demonitor_process(monitor) + result + } + Ok(MonitorDown) -> Error("Connection error (worker process crashed)") + Error(Nil) -> { + process.demonitor_process(monitor) + process.kill(worker) + Error("Request timeout") + } + } +} + +fn do_http_request(url: String) -> Result(String, String) { + let assert Ok(req) = request.to(url) + let req = request.set_method(req, http.Get) + + let config = + httpc.configure() + |> httpc.timeout(30_000) + + case httpc.dispatch(config, req) { + Ok(resp) -> + case resp.status { + 200 -> Ok(resp.body) + status -> Error("HTTP status: " <> int.to_string(status)) + } + Error(e) -> Error("HTTP error: " <> httpc_error_to_string(e)) + } +} + +fn httpc_error_to_string(e: httpc.HttpError) -> String { + case e { + httpc.InvalidUtf8Response -> "Invalid UTF-8 response" + httpc.FailedToConnect(_, _) -> "Failed to connect" + httpc.ResponseTimeout -> "Response timeout" + } +} + +/// Internal message type for the selector. +type SelectorMsg { + HttpResult(result: Result(String, String)) + MonitorDown +} diff --git a/src/feedreader/opml.gleam b/src/feedreader/opml.gleam new file mode 100644 index 0000000..8c3fff1 --- /dev/null +++ b/src/feedreader/opml.gleam @@ -0,0 +1,123 @@ +//// OPML import parsing. +//// +//// Parses an OPML document and extracts feed subscriptions with categories. +//// Ported from the Elixir FeedReader.Core.import_opml logic. +//// +//// OPML structure: +//// +//// ← parent (no xmlUrl) +//// +//// +//// + +import feedreader/xml.{type XmlNode} +import gleam/list +import gleam/option.{type Option, None, Some} + +/// Attributes for creating a feed from an OPML outline. +pub type FeedAttrs { + FeedAttrs( + feed_url: String, + name: Option(String), + site_url: Option(String), + category: String, + ) +} + +/// Parse OPML content and extract feed attributes with categories. +/// Returns a list of FeedAttrs ready for insertion. +pub fn parse_opml(content: String) -> Result(List(FeedAttrs), String) { + case xml.parse(content) { + Ok(root) -> { + // Get top-level outlines under (these define categories) + let bodies = xml.elements_by_tag(root, "body") + case list.first(bodies) { + Ok(body) -> { + let category_outlines = xml.children_by_tag(body, "outline") + Ok(extract_feeds(category_outlines)) + } + Error(_) -> Ok([]) + } + } + Error(_) -> Error("Failed to parse OPML XML") + } +} + +/// Walk category outlines and extract feeds from each. +fn extract_feeds(category_outlines: List(XmlNode)) -> List(FeedAttrs) { + category_outlines + |> list.flat_map(fn(category_outline) { + let category = category_name(category_outline) + + // Direct children that have xmlUrl are feeds in this category + let children = xml.children_by_tag(category_outline, "outline") + let feeds = + children + |> list.filter(fn(child) { + case xml.attr(child, "xmlUrl") { + Some(url) -> url != "" + None -> False + } + }) + |> list.map(fn(feed_outline) { outline_to_attrs(feed_outline, category) }) + + feeds + }) +} + +/// Get the category name from an outline's text attribute. +fn category_name(outline: XmlNode) -> String { + let name = case xml.attr(outline, "text") { + Some(t) -> t + None -> + case xml.attr(outline, "title") { + Some(t) -> t + None -> "" + } + } + case name { + "" -> "Uncategorized" + other -> other + } +} + +/// Convert an OPML outline element to FeedAttrs. +fn outline_to_attrs(outline: XmlNode, category: String) -> FeedAttrs { + let feed_url = case xml.attr(outline, "xmlUrl") { + Some(url) -> url + None -> "" + } + let name = + pick_first_nonempty([ + xml.attr(outline, "title"), + xml.attr(outline, "text"), + ]) + let site_url = case xml.attr(outline, "htmlUrl") { + Some(u) -> + case u { + "" -> None + other -> Some(other) + } + None -> None + } + + FeedAttrs( + feed_url: feed_url, + name: name, + site_url: site_url, + category: category, + ) +} + +/// Return the first Some(non-empty) value from a list of options. +fn pick_first_nonempty(opts: List(Option(String))) -> Option(String) { + case opts { + [] -> None + [Some(s), ..rest] -> + case s { + "" -> pick_first_nonempty(rest) + other -> Some(other) + } + [None, ..rest] -> pick_first_nonempty(rest) + } +} diff --git a/src/feedreader/rss.gleam b/src/feedreader/rss.gleam new file mode 100644 index 0000000..d15ad28 --- /dev/null +++ b/src/feedreader/rss.gleam @@ -0,0 +1,140 @@ +//// RSS/Atom feed parsing. +//// +//// Parses an RSS or Atom XML document into a list of entry attributes. +//// Ported from the Elixir FeedReader.Workers.FetchFeed.parse_feed logic. +//// +//// Handles both RSS `` and Atom `` elements. +//// Extracts: guid/id, title, link, comments, published date. + +import feedreader/date +import feedreader/xml.{type XmlNode} +import gleam/list +import gleam/option.{type Option, None, Some} + +/// Attributes for upserting an entry from a parsed feed item. +pub type EntryAttrs { + EntryAttrs( + external_id: String, + title: String, + content_link: String, + comments_link: Option(String), + published_at: Option(String), + ) +} + +/// Parse an RSS/Atom XML body into a list of entry attributes. +pub fn parse_feed(body: String) -> Result(List(EntryAttrs), String) { + case xml.parse(body) { + Ok(root) -> { + let rss_items = xml.elements_by_tag(root, "item") + let atom_entries = xml.elements_by_tag(root, "entry") + let all_items = list.append(rss_items, atom_entries) + Ok(list.map(all_items, parse_item)) + } + Error(_) -> Error("Failed to parse feed XML") + } +} + +/// Parse a single RSS or Atom into EntryAttrs. +fn parse_item(node: XmlNode) -> EntryAttrs { + let guid = xml.child_text(node, "guid") + let id = xml.child_text(node, "id") + let title = xml.child_text(node, "title") |> option.unwrap("") + let link = extract_link(node) + let comments = xml.child_text(node, "comments") + let pub_date = xml.child_text(node, "pubDate") + let published = xml.child_text(node, "published") + let updated = xml.child_text(node, "updated") + + // external_id: guid > id > link (fallback) + let external_id = case guid { + Some(g) -> g + None -> + case id { + Some(i) -> i + None -> link + } + } + + // date: pubDate > published > updated + let date_raw = case pub_date { + Some(d) -> Some(d) + None -> + case published { + Some(p) -> Some(p) + None -> updated + } + } + + EntryAttrs( + external_id: external_id, + title: title, + content_link: link, + comments_link: comments, + published_at: date.parse_date(date_raw), + ) +} + +/// Extract the content link from an item. +/// RSS: text +/// Atom: (prefer rel="alternate", fallback to bare link) +fn extract_link(node: XmlNode) -> String { + // Try Atom link with href attribute first + let links = xml.children_by_tag(node, "link") + + // Look for rel="alternate" or rel="" (default) + let alternate = + links + |> list.find(fn(link) { + case xml.attr(link, "rel") { + Some("alternate") -> True + None -> True + _ -> False + } + }) + |> result_to_option + + case alternate { + Some(link) -> { + case xml.attr(link, "href") { + Some(href) -> href + None -> link_text_or_empty(link) + } + } + None -> { + // Try RSS-style: text content of + case xml.child_text(node, "link") { + Some(text) -> text + None -> "" + } + } + } +} + +fn link_text_or_empty(link: XmlNode) -> String { + case xml.text_of(link) |> string_trim() { + Some(s) -> s + None -> "" + } +} + +fn result_to_option(r: Result(a, b)) -> Option(a) { + case r { + Ok(v) -> Some(v) + Error(_) -> None + } +} + +fn string_trim(s: String) -> Option(String) { + let trimmed = string_trim_raw(s) + case trimmed { + "" -> None + other -> Some(other) + } +} + +import gleam/string + +fn string_trim_raw(s: String) -> String { + string.trim(s) +} diff --git a/src/feedreader/scheduler.gleam b/src/feedreader/scheduler.gleam new file mode 100644 index 0000000..17187b2 --- /dev/null +++ b/src/feedreader/scheduler.gleam @@ -0,0 +1,110 @@ +//// Scheduler actor: periodically enqueues fetch jobs for due feeds. +//// +//// The pure decision function `feeds_due` is testable without actors. +//// The actor wraps it in a timer loop that sends `Tick` messages. + +import birl +import feedreader/db +import feedreader/fetcher +import gleam/erlang/process +import gleam/list +import gleam/option.{None, Some} +import gleam/otp/actor +import sqlight + +// ═══════════════════════════════════════════════════════════════ +// Pure decision function (testable without actors) +// ═══════════════════════════════════════════════════════════════ + +/// Minimum minutes between fetches for the same feed. +pub const fetch_interval_minutes = 10 + +/// Determine which feeds are due for fetching. +/// A feed is due if it was never fetched, or last fetched > 10 minutes ago. +pub fn feeds_due(feeds: List(db.Feed), now: birl.Time) -> List(db.Feed) { + let now_unix = birl.to_unix(now) + list.filter(feeds, fn(feed) { + case feed.last_fetched_at { + None -> True + Some(ts) -> + case birl.parse(ts) { + Ok(parsed) -> { + let diff = now_unix - birl.to_unix(parsed) + diff >= fetch_interval_minutes * 60 + } + Error(_) -> True + } + } + }) +} + +// ═══════════════════════════════════════════════════════════════ +// Actor +// ═══════════════════════════════════════════════════════════════ + +/// Messages for the scheduler actor. +pub type Message { + Tick + Stop +} + +/// Scheduler state. +pub type State { + State( + conn: sqlight.Connection, + fetcher_subject: process.Subject(fetcher.Message), + interval_ms: Int, + self_subject: process.Subject(Message), + ) +} + +/// Start the scheduler actor. +/// `interval_ms` controls how often the scheduler ticks (default: 3 min). +pub fn start( + conn: sqlight.Connection, + fetcher_subject: process.Subject(fetcher.Message), + interval_ms: Int, +) -> Result(actor.Started(process.Subject(Message)), actor.StartError) { + actor.new_with_initialiser(5000, fn(self_subject) { + // Schedule the first tick + let _ = process.send_after(self_subject, 1000, Tick) + Ok(actor.returning( + actor.initialised(State( + conn: conn, + fetcher_subject: fetcher_subject, + interval_ms: interval_ms, + self_subject: self_subject, + )), + self_subject, + )) + }) + |> actor.on_message(handle_message) + |> actor.start +} + +fn handle_message( + state: State, + message: Message, +) -> actor.Next(State, Message) { + case message { + Tick -> { + let _ = process.send_after(state.self_subject, state.interval_ms, Tick) + do_tick(state) + actor.continue(state) + } + Stop -> actor.stop() + } +} + +fn do_tick(state: State) { + case db.list_feeds(state.conn) { + Ok(feeds) -> { + let now = birl.utc_now() + let due = feeds_due(feeds, now) + list.each(due, fn(feed) { + process.send(state.fetcher_subject, fetcher.Fetch(feed.id)) + }) + } + Error(_) -> Nil + } +} diff --git a/src/feedreader/sql.gleam b/src/feedreader/sql.gleam new file mode 100644 index 0000000..04ed5e2 --- /dev/null +++ b/src/feedreader/sql.gleam @@ -0,0 +1,559 @@ +//// Code generated by parrot. DO NOT EDIT. +//// + +import gleam/dynamic/decode +import gleam/option.{type Option} +import parrot/dev + +pub type ListFeeds { + ListFeeds( + id: String, + name: Option(String), + site_url: Option(String), + feed_url: String, + category: String, + last_fetched_at: Option(String), + fetch_error: Option(String), + ) +} + +pub fn list_feeds() { + let sql = + " + +SELECT id, name, site_url, feed_url, category, last_fetched_at, fetch_error +FROM feeds +ORDER BY category ASC, name ASC" + #(sql, [], list_feeds_decoder()) +} + +pub fn list_feeds_decoder() -> decode.Decoder(ListFeeds) { + use id <- decode.field(0, decode.string) + use name <- decode.field(1, decode.optional(decode.string)) + use site_url <- decode.field(2, decode.optional(decode.string)) + use feed_url <- decode.field(3, decode.string) + use category <- decode.field(4, decode.string) + use last_fetched_at <- decode.field(5, decode.optional(decode.string)) + use fetch_error <- decode.field(6, decode.optional(decode.string)) + decode.success(ListFeeds( + id:, + name:, + site_url:, + feed_url:, + category:, + last_fetched_at:, + fetch_error:, + )) +} + +pub type GetFeed { + GetFeed( + id: String, + name: Option(String), + site_url: Option(String), + feed_url: String, + category: String, + last_fetched_at: Option(String), + fetch_error: Option(String), + ) +} + +pub fn get_feed(id id: String) { + let sql = + "SELECT id, name, site_url, feed_url, category, last_fetched_at, fetch_error +FROM feeds +WHERE id = ?" + #(sql, [dev.ParamString(id)], get_feed_decoder()) +} + +pub fn get_feed_decoder() -> decode.Decoder(GetFeed) { + use id <- decode.field(0, decode.string) + use name <- decode.field(1, decode.optional(decode.string)) + use site_url <- decode.field(2, decode.optional(decode.string)) + use feed_url <- decode.field(3, decode.string) + use category <- decode.field(4, decode.string) + use last_fetched_at <- decode.field(5, decode.optional(decode.string)) + use fetch_error <- decode.field(6, decode.optional(decode.string)) + decode.success(GetFeed( + id:, + name:, + site_url:, + feed_url:, + category:, + last_fetched_at:, + fetch_error:, + )) +} + +pub type GetFeedByUrl { + GetFeedByUrl( + id: String, + name: Option(String), + site_url: Option(String), + feed_url: String, + category: String, + last_fetched_at: Option(String), + fetch_error: Option(String), + ) +} + +pub fn get_feed_by_url(feed_url feed_url: String) { + let sql = + "SELECT id, name, site_url, feed_url, category, last_fetched_at, fetch_error +FROM feeds +WHERE feed_url = ?" + #(sql, [dev.ParamString(feed_url)], get_feed_by_url_decoder()) +} + +pub fn get_feed_by_url_decoder() -> decode.Decoder(GetFeedByUrl) { + use id <- decode.field(0, decode.string) + use name <- decode.field(1, decode.optional(decode.string)) + use site_url <- decode.field(2, decode.optional(decode.string)) + use feed_url <- decode.field(3, decode.string) + use category <- decode.field(4, decode.string) + use last_fetched_at <- decode.field(5, decode.optional(decode.string)) + use fetch_error <- decode.field(6, decode.optional(decode.string)) + decode.success(GetFeedByUrl( + id:, + name:, + site_url:, + feed_url:, + category:, + last_fetched_at:, + fetch_error:, + )) +} + +pub fn insert_feed( + id id: String, + name name: String, + site_url site_url: String, + feed_url feed_url: String, + category category: String, + last_fetched_at last_fetched_at: String, + fetch_error fetch_error: String, +) { + let sql = + "INSERT INTO feeds (id, name, site_url, feed_url, category, last_fetched_at, fetch_error) +VALUES (?, ?, ?, ?, ?, ?, ?)" + #(sql, [ + dev.ParamString(id), + dev.ParamString(name), + dev.ParamString(site_url), + dev.ParamString(feed_url), + dev.ParamString(category), + dev.ParamString(last_fetched_at), + dev.ParamString(fetch_error), + ]) +} + +pub fn delete_feed(id id: String) { + let sql = "DELETE FROM feeds WHERE id = ?" + #(sql, [dev.ParamString(id)]) +} + +pub fn log_fetch_success( + last_fetched_at last_fetched_at: String, + id id: String, +) { + let sql = + "UPDATE feeds SET last_fetched_at = ?, fetch_error = NULL +WHERE id = ?" + #(sql, [dev.ParamString(last_fetched_at), dev.ParamString(id)]) +} + +pub fn log_fetch_error( + last_fetched_at last_fetched_at: String, + fetch_error fetch_error: String, + id id: String, +) { + let sql = + "UPDATE feeds SET last_fetched_at = ?, fetch_error = ? +WHERE id = ?" + #(sql, [ + dev.ParamString(last_fetched_at), + dev.ParamString(fetch_error), + dev.ParamString(id), + ]) +} + +pub type GetEntry { + GetEntry( + id: String, + created_at: String, + external_id: String, + title: Option(String), + content_link: Option(String), + comments_link: Option(String), + published_at: Option(String), + is_read: Int, + is_starred: Int, + feed_id: String, + feed_name: Option(String), + feed_site_url: Option(String), + feed_feed_url: String, + ) +} + +pub fn get_entry(id id: String) { + let sql = + " +SELECT e.id, e.created_at, e.external_id, e.title, e.content_link, e.comments_link, e.published_at, e.is_read, e.is_starred, e.feed_id, + f.name AS feed_name, f.site_url AS feed_site_url, f.feed_url AS feed_feed_url +FROM entries e +JOIN feeds f ON f.id = e.feed_id +WHERE e.id = ?" + #(sql, [dev.ParamString(id)], get_entry_decoder()) +} + +pub fn get_entry_decoder() -> decode.Decoder(GetEntry) { + use id <- decode.field(0, decode.string) + use created_at <- decode.field(1, decode.string) + use external_id <- decode.field(2, decode.string) + use title <- decode.field(3, decode.optional(decode.string)) + use content_link <- decode.field(4, decode.optional(decode.string)) + use comments_link <- decode.field(5, decode.optional(decode.string)) + use published_at <- decode.field(6, decode.optional(decode.string)) + use is_read <- decode.field(7, decode.int) + use is_starred <- decode.field(8, decode.int) + use feed_id <- decode.field(9, decode.string) + use feed_name <- decode.field(10, decode.optional(decode.string)) + use feed_site_url <- decode.field(11, decode.optional(decode.string)) + use feed_feed_url <- decode.field(12, decode.string) + decode.success(GetEntry( + id:, + created_at:, + external_id:, + title:, + content_link:, + comments_link:, + published_at:, + is_read:, + is_starred:, + feed_id:, + feed_name:, + feed_site_url:, + feed_feed_url:, + )) +} + +pub type GetEntryByFeedAndExternalId { + GetEntryByFeedAndExternalId( + id: String, + created_at: String, + external_id: String, + title: Option(String), + content_link: Option(String), + comments_link: Option(String), + published_at: Option(String), + is_read: Int, + is_starred: Int, + feed_id: String, + ) +} + +pub fn get_entry_by_feed_and_external_id( + feed_id feed_id: String, + external_id external_id: String, +) { + let sql = + "SELECT id, created_at, external_id, title, content_link, comments_link, published_at, is_read, is_starred, feed_id +FROM entries +WHERE feed_id = ? AND external_id = ?" + #( + sql, + [dev.ParamString(feed_id), dev.ParamString(external_id)], + get_entry_by_feed_and_external_id_decoder(), + ) +} + +pub fn get_entry_by_feed_and_external_id_decoder() -> decode.Decoder( + GetEntryByFeedAndExternalId, +) { + use id <- decode.field(0, decode.string) + use created_at <- decode.field(1, decode.string) + use external_id <- decode.field(2, decode.string) + use title <- decode.field(3, decode.optional(decode.string)) + use content_link <- decode.field(4, decode.optional(decode.string)) + use comments_link <- decode.field(5, decode.optional(decode.string)) + use published_at <- decode.field(6, decode.optional(decode.string)) + use is_read <- decode.field(7, decode.int) + use is_starred <- decode.field(8, decode.int) + use feed_id <- decode.field(9, decode.string) + decode.success(GetEntryByFeedAndExternalId( + id:, + created_at:, + external_id:, + title:, + content_link:, + comments_link:, + published_at:, + is_read:, + is_starred:, + feed_id:, + )) +} + +pub fn upsert_entry( + id id: String, + created_at created_at: String, + external_id external_id: String, + title title: String, + content_link content_link: String, + comments_link comments_link: String, + published_at published_at: String, + is_read is_read: Int, + is_starred is_starred: Int, + feed_id feed_id: String, +) { + let sql = + "INSERT INTO entries (id, created_at, external_id, title, content_link, comments_link, published_at, is_read, is_starred, feed_id) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(feed_id, external_id) DO UPDATE SET + title = excluded.title, + content_link = excluded.content_link, + comments_link = excluded.comments_link, + published_at = excluded.published_at" + #(sql, [ + dev.ParamString(id), + dev.ParamString(created_at), + dev.ParamString(external_id), + dev.ParamString(title), + dev.ParamString(content_link), + dev.ParamString(comments_link), + dev.ParamString(published_at), + dev.ParamInt(is_read), + dev.ParamInt(is_starred), + dev.ParamString(feed_id), + ]) +} + +pub type ListUnread { + ListUnread( + id: String, + created_at: String, + external_id: String, + title: Option(String), + content_link: Option(String), + comments_link: Option(String), + published_at: Option(String), + is_read: Int, + is_starred: Int, + feed_id: String, + feed_name: Option(String), + feed_site_url: Option(String), + feed_feed_url: String, + ) +} + +pub fn list_unread(limit limit: Int, offset offset: Int) { + let sql = + "SELECT e.id, e.created_at, e.external_id, e.title, e.content_link, e.comments_link, e.published_at, e.is_read, e.is_starred, e.feed_id, + f.name AS feed_name, f.site_url AS feed_site_url, f.feed_url AS feed_feed_url +FROM entries e +JOIN feeds f ON f.id = e.feed_id +WHERE e.is_read = 0 +ORDER BY e.published_at IS NULL, e.published_at ASC +LIMIT ? OFFSET ?" + #(sql, [dev.ParamInt(limit), dev.ParamInt(offset)], list_unread_decoder()) +} + +pub fn list_unread_decoder() -> decode.Decoder(ListUnread) { + use id <- decode.field(0, decode.string) + use created_at <- decode.field(1, decode.string) + use external_id <- decode.field(2, decode.string) + use title <- decode.field(3, decode.optional(decode.string)) + use content_link <- decode.field(4, decode.optional(decode.string)) + use comments_link <- decode.field(5, decode.optional(decode.string)) + use published_at <- decode.field(6, decode.optional(decode.string)) + use is_read <- decode.field(7, decode.int) + use is_starred <- decode.field(8, decode.int) + use feed_id <- decode.field(9, decode.string) + use feed_name <- decode.field(10, decode.optional(decode.string)) + use feed_site_url <- decode.field(11, decode.optional(decode.string)) + use feed_feed_url <- decode.field(12, decode.string) + decode.success(ListUnread( + id:, + created_at:, + external_id:, + title:, + content_link:, + comments_link:, + published_at:, + is_read:, + is_starred:, + feed_id:, + feed_name:, + feed_site_url:, + feed_feed_url:, + )) +} + +pub type ListStarred { + ListStarred( + id: String, + created_at: String, + external_id: String, + title: Option(String), + content_link: Option(String), + comments_link: Option(String), + published_at: Option(String), + is_read: Int, + is_starred: Int, + feed_id: String, + feed_name: Option(String), + feed_site_url: Option(String), + feed_feed_url: String, + ) +} + +pub fn list_starred(limit limit: Int, offset offset: Int) { + let sql = + "SELECT e.id, e.created_at, e.external_id, e.title, e.content_link, e.comments_link, e.published_at, e.is_read, e.is_starred, e.feed_id, + f.name AS feed_name, f.site_url AS feed_site_url, f.feed_url AS feed_feed_url +FROM entries e +JOIN feeds f ON f.id = e.feed_id +WHERE e.is_starred = 1 +ORDER BY e.published_at IS NULL, e.published_at ASC +LIMIT ? OFFSET ?" + #(sql, [dev.ParamInt(limit), dev.ParamInt(offset)], list_starred_decoder()) +} + +pub fn list_starred_decoder() -> decode.Decoder(ListStarred) { + use id <- decode.field(0, decode.string) + use created_at <- decode.field(1, decode.string) + use external_id <- decode.field(2, decode.string) + use title <- decode.field(3, decode.optional(decode.string)) + use content_link <- decode.field(4, decode.optional(decode.string)) + use comments_link <- decode.field(5, decode.optional(decode.string)) + use published_at <- decode.field(6, decode.optional(decode.string)) + use is_read <- decode.field(7, decode.int) + use is_starred <- decode.field(8, decode.int) + use feed_id <- decode.field(9, decode.string) + use feed_name <- decode.field(10, decode.optional(decode.string)) + use feed_site_url <- decode.field(11, decode.optional(decode.string)) + use feed_feed_url <- decode.field(12, decode.string) + decode.success(ListStarred( + id:, + created_at:, + external_id:, + title:, + content_link:, + comments_link:, + published_at:, + is_read:, + is_starred:, + feed_id:, + feed_name:, + feed_site_url:, + feed_feed_url:, + )) +} + +pub type ListHistory { + ListHistory( + id: String, + created_at: String, + external_id: String, + title: Option(String), + content_link: Option(String), + comments_link: Option(String), + published_at: Option(String), + is_read: Int, + is_starred: Int, + feed_id: String, + feed_name: Option(String), + feed_site_url: Option(String), + feed_feed_url: String, + ) +} + +pub fn list_history(limit limit: Int, offset offset: Int) { + let sql = + "SELECT e.id, e.created_at, e.external_id, e.title, e.content_link, e.comments_link, e.published_at, e.is_read, e.is_starred, e.feed_id, + f.name AS feed_name, f.site_url AS feed_site_url, f.feed_url AS feed_feed_url +FROM entries e +JOIN feeds f ON f.id = e.feed_id +ORDER BY e.published_at IS NULL DESC, e.published_at DESC +LIMIT ? OFFSET ?" + #(sql, [dev.ParamInt(limit), dev.ParamInt(offset)], list_history_decoder()) +} + +pub fn list_history_decoder() -> decode.Decoder(ListHistory) { + use id <- decode.field(0, decode.string) + use created_at <- decode.field(1, decode.string) + use external_id <- decode.field(2, decode.string) + use title <- decode.field(3, decode.optional(decode.string)) + use content_link <- decode.field(4, decode.optional(decode.string)) + use comments_link <- decode.field(5, decode.optional(decode.string)) + use published_at <- decode.field(6, decode.optional(decode.string)) + use is_read <- decode.field(7, decode.int) + use is_starred <- decode.field(8, decode.int) + use feed_id <- decode.field(9, decode.string) + use feed_name <- decode.field(10, decode.optional(decode.string)) + use feed_site_url <- decode.field(11, decode.optional(decode.string)) + use feed_feed_url <- decode.field(12, decode.string) + decode.success(ListHistory( + id:, + created_at:, + external_id:, + title:, + content_link:, + comments_link:, + published_at:, + is_read:, + is_starred:, + feed_id:, + feed_name:, + feed_site_url:, + feed_feed_url:, + )) +} + +pub fn toggle_read(is_read is_read: Int, id id: String) { + let sql = + "UPDATE entries SET is_read = ? +WHERE id = ?" + #(sql, [dev.ParamInt(is_read), dev.ParamString(id)]) +} + +pub fn toggle_starred(is_starred is_starred: Int, id id: String) { + let sql = + "UPDATE entries SET is_starred = ? +WHERE id = ?" + #(sql, [dev.ParamInt(is_starred), dev.ParamString(id)]) +} + +pub type UnreadCount { + UnreadCount(count: Int) +} + +pub fn unread_count() { + let sql = + "SELECT COUNT(*) AS count +FROM entries +WHERE is_read = 0" + #(sql, [], unread_count_decoder()) +} + +pub fn unread_count_decoder() -> decode.Decoder(UnreadCount) { + use count <- decode.field(0, decode.int) + decode.success(UnreadCount(count:)) +} + +pub type CountByFeed { + CountByFeed(count: Int) +} + +pub fn count_by_feed(feed_id feed_id: String) { + let sql = + "SELECT COUNT(*) AS count +FROM entries +WHERE feed_id = ?" + #(sql, [dev.ParamString(feed_id)], count_by_feed_decoder()) +} + +pub fn count_by_feed_decoder() -> decode.Decoder(CountByFeed) { + use count <- decode.field(0, decode.int) + decode.success(CountByFeed(count:)) +} diff --git a/src/feedreader/sql/queries.sql b/src/feedreader/sql/queries.sql new file mode 100644 index 0000000..b514919 --- /dev/null +++ b/src/feedreader/sql/queries.sql @@ -0,0 +1,101 @@ +-- FeedReader Queries +-- Parrot reads this + schema.sql to generate src/feedreader/sql.gleam + +-- -- Feeds ----------------------------------------------------- + +-- name: ListFeeds :many +SELECT id, name, site_url, feed_url, category, last_fetched_at, fetch_error +FROM feeds +ORDER BY category ASC, name ASC; + +-- name: GetFeed :one +SELECT id, name, site_url, feed_url, category, last_fetched_at, fetch_error +FROM feeds +WHERE id = ?; + +-- name: GetFeedByUrl :one +SELECT id, name, site_url, feed_url, category, last_fetched_at, fetch_error +FROM feeds +WHERE feed_url = ?; + +-- name: InsertFeed :exec +INSERT INTO feeds (id, name, site_url, feed_url, category, last_fetched_at, fetch_error) +VALUES (?, ?, ?, ?, ?, ?, ?); + +-- name: DeleteFeed :exec +DELETE FROM feeds WHERE id = ?; + +-- name: LogFetchSuccess :exec +UPDATE feeds SET last_fetched_at = ?, fetch_error = NULL +WHERE id = ?; + +-- name: LogFetchError :exec +UPDATE feeds SET last_fetched_at = ?, fetch_error = ? +WHERE id = ?; + +-- -- Entries --------------------------------------------------- + +-- name: GetEntry :one +SELECT e.id, e.created_at, e.external_id, e.title, e.content_link, e.comments_link, e.published_at, e.is_read, e.is_starred, e.feed_id, + f.name AS feed_name, f.site_url AS feed_site_url, f.feed_url AS feed_feed_url +FROM entries e +JOIN feeds f ON f.id = e.feed_id +WHERE e.id = ?; + +-- name: GetEntryByFeedAndExternalId :one +SELECT id, created_at, external_id, title, content_link, comments_link, published_at, is_read, is_starred, feed_id +FROM entries +WHERE feed_id = ? AND external_id = ?; + +-- name: UpsertEntry :exec +INSERT INTO entries (id, created_at, external_id, title, content_link, comments_link, published_at, is_read, is_starred, feed_id) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(feed_id, external_id) DO UPDATE SET + title = excluded.title, + content_link = excluded.content_link, + comments_link = excluded.comments_link, + published_at = excluded.published_at; + +-- name: ListUnread :many +SELECT e.id, e.created_at, e.external_id, e.title, e.content_link, e.comments_link, e.published_at, e.is_read, e.is_starred, e.feed_id, + f.name AS feed_name, f.site_url AS feed_site_url, f.feed_url AS feed_feed_url +FROM entries e +JOIN feeds f ON f.id = e.feed_id +WHERE e.is_read = 0 +ORDER BY e.published_at IS NULL, e.published_at ASC +LIMIT ? OFFSET ?; + +-- name: ListStarred :many +SELECT e.id, e.created_at, e.external_id, e.title, e.content_link, e.comments_link, e.published_at, e.is_read, e.is_starred, e.feed_id, + f.name AS feed_name, f.site_url AS feed_site_url, f.feed_url AS feed_feed_url +FROM entries e +JOIN feeds f ON f.id = e.feed_id +WHERE e.is_starred = 1 +ORDER BY e.published_at IS NULL, e.published_at ASC +LIMIT ? OFFSET ?; + +-- name: ListHistory :many +SELECT e.id, e.created_at, e.external_id, e.title, e.content_link, e.comments_link, e.published_at, e.is_read, e.is_starred, e.feed_id, + f.name AS feed_name, f.site_url AS feed_site_url, f.feed_url AS feed_feed_url +FROM entries e +JOIN feeds f ON f.id = e.feed_id +ORDER BY e.published_at IS NULL DESC, e.published_at DESC +LIMIT ? OFFSET ?; + +-- name: ToggleRead :exec +UPDATE entries SET is_read = ? +WHERE id = ?; + +-- name: ToggleStarred :exec +UPDATE entries SET is_starred = ? +WHERE id = ?; + +-- name: UnreadCount :one +SELECT COUNT(*) AS count +FROM entries +WHERE is_read = 0; + +-- name: CountByFeed :one +SELECT COUNT(*) AS count +FROM entries +WHERE feed_id = ?; diff --git a/src/feedreader/sql/schema.sql b/src/feedreader/sql/schema.sql new file mode 100644 index 0000000..a2e3f2b --- /dev/null +++ b/src/feedreader/sql/schema.sql @@ -0,0 +1,32 @@ +-- FeedReader Schema +-- SQLite DDL +-- +-- Source of truth for FeedReader's database. +-- Parrot (sqlc) reads this + queries.sql to generate src/feedreader/sql.gleam. +-- +-- Regenerate after changes: +-- mise run gen + +CREATE TABLE IF NOT EXISTS feeds ( + id TEXT PRIMARY KEY, + name TEXT, + site_url TEXT, + feed_url TEXT NOT NULL UNIQUE, + category TEXT NOT NULL DEFAULT 'Uncategorized', + last_fetched_at TEXT, + fetch_error TEXT +); + +CREATE TABLE IF NOT EXISTS entries ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + external_id TEXT NOT NULL, + title TEXT, + content_link TEXT, + comments_link TEXT, + published_at TEXT, + is_read INTEGER NOT NULL DEFAULT 0, + is_starred INTEGER NOT NULL DEFAULT 0, + feed_id TEXT NOT NULL REFERENCES feeds(id) ON DELETE CASCADE, + UNIQUE(feed_id, external_id) +); diff --git a/src/feedreader/time.gleam b/src/feedreader/time.gleam new file mode 100644 index 0000000..296ac8d --- /dev/null +++ b/src/feedreader/time.gleam @@ -0,0 +1,73 @@ +//// Relative date formatting for display. +//// Ported from the Elixir FeedreaderWeb.TimeHelpers. + +import birl +import gleam/int +import gleam/option.{type Option, None, Some} +import gleam/string + +/// Format a datetime as a human-readable relative time. +/// "just now", "3m ago", "2h ago", "yesterday", "3d ago", "1w ago", "Mon DD, YYYY" +pub fn humanize_date(dt: Option(String)) -> String { + case dt { + None -> "" + Some(ts) -> + case birl.parse(ts) { + Ok(parsed) -> { + let now = birl.utc_now() + let diff_seconds = birl.to_unix(now) - birl.to_unix(parsed) + cond_format(diff_seconds, parsed) + } + Error(_) -> "" + } + } +} + +fn cond_format(diff_seconds: Int, dt: birl.Time) -> String { + case diff_seconds { + d if d < 60 -> "just now" + d if d < 3600 -> int.to_string(d / 60) <> "m ago" + d if d < 86_400 -> int.to_string(d / 3600) <> "h ago" + d if d < 172_800 -> "yesterday" + d if d < 604_800 -> int.to_string(d / 86_400) <> "d ago" + d if d < 2_592_000 -> int.to_string(d / 604_800) <> "w ago" + _ -> format_full_date(dt) + } +} + +fn format_full_date(dt: birl.Time) -> String { + let date_str = birl.to_naive_date_string(dt) + // Format is "YYYY-MM-DD" — convert to "Mon DD, YYYY" + let parts = string.split(date_str, "-") + case parts { + [year, month_str, day] -> { + let assert Ok(m) = int.parse(month_str) + month_name(m) <> " " <> strip_leading_zero(day) <> ", " <> year + } + _ -> date_str + } +} + +fn month_name(m: Int) -> String { + case m { + 1 -> "Jan" + 2 -> "Feb" + 3 -> "Mar" + 4 -> "Apr" + 5 -> "May" + 6 -> "Jun" + 7 -> "Jul" + 8 -> "Aug" + 9 -> "Sep" + 10 -> "Oct" + 11 -> "Nov" + _ -> "Dec" + } +} + +fn strip_leading_zero(s: String) -> String { + case string.pop_grapheme(s) { + Ok(#("0", rest)) -> rest + _ -> s + } +} diff --git a/src/feedreader/web/fragments.gleam b/src/feedreader/web/fragments.gleam new file mode 100644 index 0000000..8ef4617 --- /dev/null +++ b/src/feedreader/web/fragments.gleam @@ -0,0 +1,28 @@ +//// HTMX partial response helpers. +//// +//// These return HTML fragments (not full documents) for HTMX swap responses. +//// Toggle read/star returns the updated entry card; load-more returns the +//// next batch of cards; delete returns empty content (removes the card). + +import feedreader/db.{type Entry, type Feed} +import feedreader/web/html as view +import gleam/list +import lustre/element + +/// Return a single entry card fragment (post-toggle response). +pub fn entry_card_fragment(entry: Entry) -> String { + view.entry_card(entry) |> element.to_string +} + +/// Return a batch of entry cards for load-more (HTMX beforeend swap). +pub fn entry_list_fragment(entries: List(Entry)) -> String { + entries + |> list.map(view.entry_card) + |> element.fragment + |> element.to_string +} + +/// Return a single feed card fragment (after add/delete). +pub fn feed_card_fragment(feed: Feed) -> String { + view.feed_card(feed) |> element.to_string +} diff --git a/src/feedreader/web/html.gleam b/src/feedreader/web/html.gleam new file mode 100644 index 0000000..5481df8 --- /dev/null +++ b/src/feedreader/web/html.gleam @@ -0,0 +1,365 @@ +//// Shared Lustre element builders using lustre_pipes (pipe-style). +//// +//// Layout, nav, entry cards, feed cards — all rendered server-side via +//// Lustre's element API. HTMX attributes via `hx` are added with `a.add`. +//// +//// Per AGENTS.md: world-class UI with micro-interactions, smooth transitions, +//// premium look. DaisyUI classes provide the component styling; Tailwind +//// transition/hover utilities provide the micro-interactions. + +import feedreader/db.{type Entry, type Feed} +import feedreader/time +import gleam/option.{None, Some} +import hx +import lustre/attribute as attr +import lustre/element.{type Element} +import lustre_pipes/attribute as a +import lustre_pipes/element as lp +import lustre_pipes/element/html as h + +// ═══════════════════════════════════════════════════════════════ +// HTMX helper — adapt hx Attribute to lustre_pipes pipe +// ═══════════════════════════════════════════════════════════════ + +/// Pipe an hx.* attribute into a scaffold. +fn hx_attr(scaffold, attr) { + a.add(scaffold, attr) +} + +// ═══════════════════════════════════════════════════════════════ +// Layout +// ═══════════════════════════════════════════════════════════════ + +/// Full HTML document layout. Dark mode hardcoded (no theme switching). +pub fn layout(title: String, inner: Element(msg)) -> Element(msg) { + h.html() + |> a.attribute("lang", "en") + |> a.attribute("data-theme", "dark") + |> lp.children([ + h.head() + |> lp.children([ + h.meta() + |> a.attribute("charset", "utf-8") + |> lp.empty(), + h.meta() + |> a.attribute("name", "viewport") + |> a.attribute("content", "width=device-width, initial-scale=1") + |> lp.empty(), + h.link() + |> a.attribute("rel", "icon") + |> a.attribute("type", "image/svg+xml") + |> a.attribute( + "href", + "data:image/svg+xml,", + ) + |> lp.empty(), + h.title() |> lp.text_content(title), + h.link() + |> a.attribute("rel", "stylesheet") + |> a.attribute("href", "/static/css/app.compiled.css") + |> lp.empty(), + h.script() + |> a.attribute("src", "/static/js/htmx.min.js") + |> lp.empty(), + ]), + h.body() + |> lp.children([ + h.div() + |> a.class("min-h-screen bg-base-100") + |> lp.children([ + nav(), + h.main() + |> a.class("max-w-4xl mx-auto p-4") + |> lp.children([inner]), + ]), + ]), + ]) +} + +// ═══════════════════════════════════════════════════════════════ +// Navigation +// ═══════════════════════════════════════════════════════════════ + +pub fn nav() -> Element(msg) { + h.header() + |> a.class("navbar bg-base-200 shadow-sm border-b border-base-300") + |> lp.children([ + h.div() + |> a.class("max-w-4xl mx-auto w-full flex items-center justify-between") + |> lp.children([ + h.a() + |> a.class("btn btn-ghost text-xl") + |> a.attribute("href", "/") + |> lp.text_content("FeedReader"), + nav_links(), + ]), + ]) +} + +fn nav_links() -> Element(msg) { + h.nav() + |> lp.children([ + h.ul() + |> a.class("menu menu-horizontal px-1 gap-1") + |> lp.children([ + nav_link("/", "Unread"), + nav_link("/starred", "Starred"), + nav_link("/history", "History"), + nav_link("/feeds", "Feeds"), + ]), + ]) +} + +fn nav_link(href: String, label: String) -> Element(msg) { + h.li() + |> lp.children([ + h.a() + |> a.class("") + |> a.attribute("href", href) + |> lp.text_content(label), + ]) +} + +// ═══════════════════════════════════════════════════════════════ +// Entry card (used in full pages AND HTMX fragment responses) +// ═══════════════════════════════════════════════════════════════ + +pub fn entry_card(entry: Entry) -> Element(msg) { + let title = option.unwrap(entry.title, "Untitled") + let content_link = option.unwrap(entry.content_link, "#") + let date_str = time.humanize_date(entry.published_at) + + // Metadata line: "Feed Name | Date" (matches old Elixir app logic) + let metadata = case entry.feed_name, date_str { + Some(feed_name), d if d != "" -> feed_name <> " | " <> d + Some(feed_name), _ -> feed_name + None, d if d != "" -> d + None, _ -> "" + } + + let star_class = case entry.is_starred { + True -> "btn-active text-yellow-400 bg-yellow-400/10 border-yellow-400/30" + False -> "" + } + let read_class = case entry.is_read { + True -> "btn-active text-green-400 bg-green-400/10 border-green-400/30" + False -> "" + } + let star_label = case entry.is_starred { + True -> "Starred" + False -> "Star" + } + let read_label = case entry.is_read { + True -> "Mark unread" + False -> "Mark read" + } + + h.div() + |> a.id("entry-" <> entry.id) + |> a.class( + "bg-base-200/80 rounded-lg border border-base-300/50 p-4 backdrop-blur-sm", + ) + |> lp.children([ + // Title link + h.h2() + |> a.class("text-xl font-semibold hover:text-primary transition-colors") + |> lp.children([ + h.a() + |> a.attribute("href", content_link) + |> a.attribute("target", "_blank") + |> a.attribute("rel", "noopener noreferrer") + |> lp.text_content(title), + ]), + // Metadata: "Feed Name | Date" (conditional) + case metadata { + "" -> element.none() + _ -> + h.div() + |> a.class("text-sm text-base-content/60 mt-2") + |> lp.text_content(metadata) + }, + // Comments link (conditional) + case entry.comments_link { + None -> element.none() + Some(url) -> + h.div() + |> a.class("mt-2") + |> lp.children([ + h.a() + |> a.class( + "text-sm text-primary/70 hover:text-primary transition-colors", + ) + |> a.attribute("href", url) + |> a.attribute("target", "_blank") + |> a.attribute("rel", "noopener noreferrer") + |> lp.text_content("Comments"), + ]) + }, + // Action buttons (HTMX — no page refresh) + h.div() + |> a.class("flex justify-end gap-2 mt-4") + |> lp.children([ + // Star toggle + h.button() + |> a.id("star-btn-" <> entry.id) + |> a.class("btn btn-sm transition-all duration-200 " <> star_class) + |> hx_attr(hx.post(url: "/entry/" <> entry.id <> "/toggle-star")) + |> hx_attr(hx.target(hx.Closest("#entry-" <> entry.id))) + |> hx_attr(hx.swap(hx.OuterHTML)) + |> lp.children([ + star_icon(), + element.text(star_label), + ]), + // Read toggle + h.button() + |> a.id("read-btn-" <> entry.id) + |> a.class("btn btn-sm transition-all duration-200 " <> read_class) + |> hx_attr(hx.post(url: "/entry/" <> entry.id <> "/toggle-read")) + |> hx_attr(hx.target(hx.Closest("#entry-" <> entry.id))) + |> hx_attr(hx.swap(hx.OuterHTML)) + |> lp.children([ + check_circle_icon(), + element.text(read_label), + ]), + ]), + ]) +} + +// ═══════════════════════════════════════════════════════════════ +// Inline SVG icons (Heroicons outline, MIT licensed) +// ═══════════════════════════════════════════════════════════════ + +fn star_icon() -> Element(msg) { + element.namespaced( + "http://www.w3.org/2000/svg", + "svg", + [ + attr.class("w-4 h-4 inline"), + attr.attribute("fill", "none"), + attr.attribute("viewBox", "0 0 24 24"), + attr.attribute("stroke-width", "1.5"), + attr.attribute("stroke", "currentColor"), + ], + [ + element.namespaced( + "http://www.w3.org/2000/svg", + "path", + [ + attr.attribute("stroke-linecap", "round"), + attr.attribute("stroke-linejoin", "round"), + attr.attribute( + "d", + "M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z", + ), + ], + [], + ), + ], + ) +} + +fn check_circle_icon() -> Element(msg) { + element.namespaced( + "http://www.w3.org/2000/svg", + "svg", + [ + attr.class("w-4 h-4 inline"), + attr.attribute("fill", "none"), + attr.attribute("viewBox", "0 0 24 24"), + attr.attribute("stroke-width", "1.5"), + attr.attribute("stroke", "currentColor"), + ], + [ + element.namespaced( + "http://www.w3.org/2000/svg", + "path", + [ + attr.attribute("stroke-linecap", "round"), + attr.attribute("stroke-linejoin", "round"), + attr.attribute( + "d", + "M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z", + ), + ], + [], + ), + ], + ) +} + +// ═══════════════════════════════════════════════════════════════ +// Feed card +// ═══════════════════════════════════════════════════════════════ + +pub fn feed_card(feed: Feed) -> Element(msg) { + let name = option.unwrap(feed.name, "Unnamed Feed") + let last_fetched = time.humanize_date(feed.last_fetched_at) + + h.div() + |> a.class("card bg-base-100 shadow border border-base-200") + |> lp.children([ + h.div() + |> a.class("card-body p-4") + |> lp.children([ + h.div() + |> a.class("flex justify-between items-start") + |> lp.children([ + h.div() + |> lp.children([ + h.h3() + |> a.class("font-bold text-lg") + |> lp.text_content(name), + h.p() + |> a.class("text-sm text-gray-500") + |> lp.text_content(feed.feed_url), + h.div() + |> a.class("text-xs text-gray-400 mt-1") + |> lp.text_content("Category: " <> feed.category), + case last_fetched { + "" -> + h.div() + |> a.class("text-xs text-gray-400 mt-1") + |> lp.text_content("Last parsed: never") + _ -> + h.div() + |> a.class("text-xs text-gray-400 mt-1") + |> lp.text_content("Last parsed: " <> last_fetched) + }, + case feed.fetch_error { + None -> element.none() + Some(err) -> + h.div() + |> a.class("text-xs text-error mt-1") + |> lp.text_content("Error: " <> err) + }, + ]), + // Delete button (HTMX) + h.button() + |> a.class("btn btn-sm btn-ghost text-error") + |> hx_attr(hx.delete(url: "/feeds/" <> feed.id)) + |> hx_attr(hx.target(hx.Closest(".card"))) + |> hx_attr(hx.swap(hx.OuterHTML)) + |> lp.text_content("Delete"), + ]), + ]), + ]) +} + +// ═══════════════════════════════════════════════════════════════ +// Flash message +// ═══════════════════════════════════════════════════════════════ + +pub fn flash(kind: FlashKind, msg: String) -> Element(msg) { + let class = case kind { + Info -> "alert alert-info" + Error -> "alert alert-error" + } + h.div() + |> a.class(class <> " shadow-lg transition-all duration-300 mb-4") + |> lp.text_content(msg) +} + +pub type FlashKind { + Info + Error +} diff --git a/src/feedreader/web/pages.gleam b/src/feedreader/web/pages.gleam new file mode 100644 index 0000000..6e9a861 --- /dev/null +++ b/src/feedreader/web/pages.gleam @@ -0,0 +1,334 @@ +//// Full-page render functions. +//// +//// Each function returns a complete HTML document via `element.to_document_string`. +//// Routes call these for full page loads (non-HTMX navigation). + +import feedreader/db.{type Entry, type Feed} +import feedreader/web/html as view +import gleam/int +import gleam/list +import gleam/option.{None, Some} +import hx +import lustre/element +import lustre_pipes/attribute as a +import lustre_pipes/element as lp +import lustre_pipes/element/html as h + +/// Page size for pagination. +pub const page_size = 50 + +// ═══════════════════════════════════════════════════════════════ +// Entry listing pages +// ═══════════════════════════════════════════════════════════════ + +pub fn unread_page( + entries: List(Entry), + offset: Int, + has_more: Bool, +) -> String { + render( + "Unread", + entry_list_page( + "Unread", + entries, + offset, + has_more, + "", + EmptyMsg("Nothing left to read", "Touch grass 🌿"), + ), + ) +} + +pub fn starred_page( + entries: List(Entry), + offset: Int, + has_more: Bool, +) -> String { + render( + "Starred", + entry_list_page( + "Starred", + entries, + offset, + has_more, + "/starred", + EmptyMsg("Nothing starred yet", "Star entries to save them for later"), + ), + ) +} + +pub fn history_page( + entries: List(Entry), + offset: Int, + has_more: Bool, +) -> String { + render( + "History", + entry_list_page( + "History", + entries, + offset, + has_more, + "/history", + EmptyMsg("No history yet", "Entries will appear here after fetching"), + ), + ) +} + +fn entry_list_page( + heading: String, + entries: List(Entry), + offset: Int, + has_more: Bool, + base_path: String, + empty: EmptyMsg, +) -> element.Element(msg) { + h.div() + |> a.class("max-w-4xl mx-auto p-4") + |> lp.children([ + h.div() + |> a.class("mb-6") + |> lp.children([ + h.h1() |> a.class("text-2xl font-bold") |> lp.text_content(heading), + ]), + h.div() + |> a.id("entries") + |> a.class("space-y-4") + |> lp.children(list.map(entries, view.entry_card)), + // Load more button or empty state + case has_more { + True -> load_more_button(offset, base_path) + False -> + case list.is_empty(entries) { + True -> empty_state(empty) + False -> element.none() + } + }, + ]) +} + +/// Build the URL for the load-more endpoint. +/// base_path is "" for unread, "/starred" for starred, "/history" for history. +fn load_more_url(offset: Int, base_path: String) -> String { + base_path <> "?after=" <> int.to_string(offset + page_size) +} + +fn load_more_button(offset: Int, base_path: String) -> element.Element(msg) { + h.div() + |> a.id("load-more-container") + |> a.class("text-center py-4") + |> lp.children([ + h.button() + |> a.id("load-more-btn") + |> a.class("btn btn-outline btn-sm") + |> a.add(hx.get(url: load_more_url(offset, base_path))) + |> a.add(hx.target(hx.Selector("#entries"))) + |> a.add(hx.swap(hx.Beforeend)) + |> lp.text_content("Load More"), + ]) +} + +/// Generate the Load More button HTML string for HTMX OOB swap responses. +/// Includes `hx-swap-oob="true"` so HTMX replaces the existing button. +pub fn load_more_button_html(offset: Int, base_path: String) -> String { + let url = load_more_url(offset, base_path) + "
" + <> "" + <> "
" +} + +fn empty_state(msg: EmptyMsg) -> element.Element(msg) { + h.div() + |> a.id("empty-state") + |> a.class("text-center py-8 text-base-content/60") + |> lp.children([ + h.p() |> lp.text_content(msg.primary), + h.p() |> a.class("mt-2") |> lp.text_content(msg.secondary), + ]) +} + +type EmptyMsg { + EmptyMsg(primary: String, secondary: String) +} + +// ═══════════════════════════════════════════════════════════════ +// Feeds page +// ═══════════════════════════════════════════════════════════════ + +pub fn feeds_page( + feeds: List(Feed), + flash_msg: option.Option(#(view.FlashKind, String)), +) -> String { + render("Feeds", feeds_page_inner(feeds, flash_msg)) +} + +fn feeds_page_inner( + feeds: List(Feed), + flash_msg: option.Option(#(view.FlashKind, String)), +) -> element.Element(msg) { + h.div() + |> a.class("max-w-4xl mx-auto p-4") + |> lp.children([ + h.div() + |> a.class("mb-6") + |> lp.children([ + h.h1() |> a.class("text-2xl font-bold mb-4") |> lp.text_content("Feeds"), + // Flash message + case flash_msg { + Some(#(kind, msg)) -> view.flash(kind, msg) + None -> element.none() + }, + // Add feed form + add_feed_form(), + // OPML import form + opml_import_form(), + ]), + // Feed list + h.div() + |> a.class("space-y-4") + |> lp.children([ + case list.is_empty(feeds) { + True -> + h.div() + |> a.class("text-center py-8 text-gray-500") + |> lp.text_content("No feeds yet. Add your first feed above.") + False -> element.fragment(list.map(feeds, view.feed_card)) + }, + ]), + ]) +} + +fn add_feed_form() -> element.Element(msg) { + h.div() + |> a.class("card bg-base-100 shadow-xl border border-base-200 mb-6") + |> lp.children([ + h.div() + |> a.class("card-body") + |> lp.children([ + h.h2() |> a.class("card-title text-lg") |> lp.text_content("Add New Feed"), + h.form() + |> a.attribute("method", "post") + |> a.attribute("action", "/feeds") + |> a.add(hx.post(url: "/feeds")) + |> a.add(hx.target(hx.Closest("body"))) + |> a.add(hx.swap(hx.OuterHTML)) + |> lp.children([ + form_input( + "feed_url", + "Feed URL", + "https://example.com/feed.xml", + True, + ), + form_input("name", "Name (optional)", "Feed Name", False), + form_input( + "site_url", + "Site URL (optional)", + "https://example.com", + False, + ), + form_input("category", "Category (optional)", "Tech", False), + h.div() + |> a.class("mt-6") + |> lp.children([ + h.button() + |> a.attribute("type", "submit") + |> a.class("btn btn-primary") + |> lp.text_content("Add Feed"), + ]), + ]), + ]), + ]) +} + +fn opml_import_form() -> element.Element(msg) { + h.div() + |> a.class("card bg-base-100 shadow-xl border border-base-200 mb-6") + |> lp.children([ + h.div() + |> a.class("card-body") + |> lp.children([ + h.h2() + |> a.class("card-title text-lg") + |> lp.text_content("Import OPML"), + h.form() + |> a.attribute("method", "post") + |> a.attribute("action", "/feeds/import") + |> a.attribute("enctype", "multipart/form-data") + |> a.add(hx.post(url: "/feeds/import")) + |> a.add(hx.encoding("multipart/form-data")) + |> a.add(hx.target(hx.Closest("body"))) + |> a.add(hx.swap(hx.OuterHTML)) + |> lp.children([ + h.div() + |> a.class("form-control") + |> lp.children([ + h.label() + |> a.class("label") + |> lp.children([ + h.span() + |> a.class("label-text") + |> lp.text_content("Select OPML file"), + ]), + h.input() + |> a.attribute("type", "file") + |> a.attribute("name", "opml") + |> a.attribute("accept", ".opml,text/xml,application/xml") + |> a.class("file-input file-input-bordered w-full") + |> lp.empty(), + ]), + h.div() + |> a.class("mt-6") + |> lp.children([ + h.button() + |> a.attribute("type", "submit") + |> a.class("btn btn-secondary") + |> lp.text_content("Import Feeds"), + ]), + ]), + ]), + ]) +} + +fn form_input( + name: String, + label: String, + placeholder: String, + required: Bool, +) -> element.Element(msg) { + h.div() + |> a.class("form-control mt-4") + |> lp.children([ + h.label() + |> a.class("label") + |> lp.children([ + h.span() |> a.class("label-text") |> lp.text_content(label), + ]), + h.input() + |> a.attribute("type", "text") + |> a.attribute("name", name) + |> a.attribute("placeholder", placeholder) + |> add_required_if(required) + |> a.class("input input-bordered w-full") + |> lp.empty(), + ]) +} + +fn add_required_if(required: Bool) -> fn(lp.Scaffold(msg)) -> lp.Scaffold(msg) { + fn(s) { + case required { + True -> a.attribute(s, "required", "") + False -> s + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// Render helper +// ═══════════════════════════════════════════════════════════════ + +fn render(title: String, inner: element.Element(msg)) -> String { + view.layout(title, inner) |> element.to_document_string +} diff --git a/src/feedreader/web/router.gleam b/src/feedreader/web/router.gleam new file mode 100644 index 0000000..f8c504f --- /dev/null +++ b/src/feedreader/web/router.gleam @@ -0,0 +1,333 @@ +//// Wisp router — all HTTP request handlers. +//// +//// Full pages return complete HTML documents. +//// HTMX endpoints return HTML fragments with appropriate hx headers. +//// Static assets served from priv/static. + +import feedreader/db +import feedreader/opml +import feedreader/web/fragments +import feedreader/web/html as view +import feedreader/web/pages +import gleam/http +import gleam/http/request +import gleam/int +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string +import simplifile +import sqlight +import wisp + +/// The request handler. Takes a DB connection and a Wisp request. +pub fn handle_request(conn: sqlight.Connection, req: wisp.Request) { + use <- wisp.log_request(req) + use req <- wisp.handle_head(req) + use <- wisp.serve_static(req, under: "static", from: "priv/static") + + let method = req.method + let query_params = wisp.get_query(req) + case method, wisp.path_segments(req) { + // ═══ Entry listing pages (full HTML) ═══ + http.Get, [] -> unread_page(conn, req, query_params) + http.Get, ["starred"] -> starred_page(conn, req, query_params) + http.Get, ["history"] -> history_page(conn, req, query_params) + http.Get, ["feeds"] -> feeds_page(conn, None) + + // ═══ Entry toggle endpoints (HTMX fragments) ═══ + http.Post, ["entry", id, "toggle-read"] -> + toggle_read_handler(conn, req, id) + http.Post, ["entry", id, "toggle-star"] -> + toggle_star_handler(conn, req, id) + + // ═══ Feed management ═══ + http.Post, ["feeds"] -> add_feed_handler(conn, req) + http.Post, ["feeds", "import"] -> import_opml_handler(conn, req) + http.Delete, ["feeds", id] -> delete_feed_handler(conn, id) + + _, _ -> wisp.not_found() + } +} + +// ═══════════════════════════════════════════════════════════════ +// Full page handlers (with pagination support via query params) +// ═══════════════════════════════════════════════════════════════ + +fn unread_page( + conn: sqlight.Connection, + req: wisp.Request, + query: List(#(String, String)), +) { + let offset = get_offset(query) + let assert Ok(entries) = db.list_unread(conn, limit: pages.page_size, offset:) + let has_more = list.length(entries) >= pages.page_size + // HTMX requests get fragments; full page loads get the full document. + case is_htmx_request(req), offset > 0 { + True, True -> load_more_fragment_response(entries, offset, has_more, "") + _, _ -> { + let body = pages.unread_page(entries, offset, has_more) + html_response(body) + } + } +} + +fn starred_page( + conn: sqlight.Connection, + req: wisp.Request, + query: List(#(String, String)), +) { + let offset = get_offset(query) + let assert Ok(entries) = + db.list_starred(conn, limit: pages.page_size, offset:) + let has_more = list.length(entries) >= pages.page_size + case is_htmx_request(req), offset > 0 { + True, True -> + load_more_fragment_response(entries, offset, has_more, "/starred") + _, _ -> { + let body = pages.starred_page(entries, offset, has_more) + html_response(body) + } + } +} + +fn history_page( + conn: sqlight.Connection, + req: wisp.Request, + query: List(#(String, String)), +) { + let offset = get_offset(query) + let assert Ok(entries) = + db.list_history(conn, limit: pages.page_size, offset:) + let has_more = list.length(entries) >= pages.page_size + case is_htmx_request(req), offset > 0 { + True, True -> + load_more_fragment_response(entries, offset, has_more, "/history") + _, _ -> { + let body = pages.history_page(entries, offset, has_more) + html_response(body) + } + } +} + +fn feeds_page( + conn: sqlight.Connection, + flash: Option(#(view.FlashKind, String)), +) { + let assert Ok(feeds) = db.list_feeds(conn) + let body = pages.feeds_page(feeds, flash) + html_response(body) +} + +// ═══════════════════════════════════════════════════════════════ +// Toggle handlers (HTMX — return card fragment or empty) +// ═══════════════════════════════════════════════════════════════ +// +// On filtered pages, entries that no longer match the filter should +// disappear. This mirrors the old Elixir app's stream_delete logic: +// - Unread page + mark read → entry removed +// - Starred page + un-star → entry removed +// - Everything else → card updated in place + +/// Extract the page view from the Referer header. +/// Returns "unread", "starred", "history", or "other". +fn referer_view(req: wisp.Request) -> String { + let ref = result.unwrap(request.get_header(req, "referer"), "") + let is_unread = string.ends_with(ref, "/") + let is_starred = string.ends_with(ref, "/starred") + let is_history = string.ends_with(ref, "/history") + case is_unread, is_starred, is_history { + True, _, _ -> "unread" + _, True, _ -> "starred" + _, _, True -> "history" + _, _, _ -> "other" + } +} + +fn toggle_read_handler( + conn: sqlight.Connection, + req: wisp.Request, + id: String, +) { + let _ = db.toggle_read(conn, id) + case db.get_entry(conn, id) { + Ok(Some(entry)) -> { + // On the unread page, marking as read removes the entry. + case referer_view(req), entry.is_read { + "unread", True -> empty_fragment_response() + _, _ -> { + let body = fragments.entry_card_fragment(entry) + fragment_response(body) + } + } + } + _ -> wisp.not_found() + } +} + +fn toggle_star_handler( + conn: sqlight.Connection, + req: wisp.Request, + id: String, +) { + let _ = db.toggle_starred(conn, id) + case db.get_entry(conn, id) { + Ok(Some(entry)) -> { + // On the starred page, un-starring removes the entry. + case referer_view(req), entry.is_starred { + "starred", False -> empty_fragment_response() + _, _ -> { + let body = fragments.entry_card_fragment(entry) + fragment_response(body) + } + } + } + _ -> wisp.not_found() + } +} + +// ═══════════════════════════════════════════════════════════════ +// Feed management handlers +// ═══════════════════════════════════════════════════════════════ + +fn add_feed_handler(conn: sqlight.Connection, req: wisp.Request) { + use form <- wisp.require_form(req) + let feed_url = get_form_value(form, "feed_url") + let name = get_form_value(form, "name") + let site_url = get_form_value(form, "site_url") + let category = get_form_value(form, "category") + + case feed_url { + Some(url) if url != "" -> { + let cat = option.unwrap(category, "Uncategorized") + let _ = + db.insert_feed( + conn, + name:, + site_url:, + feed_url: url, + category: case cat { + "" -> "Uncategorized" + other -> other + }, + ) + feeds_page(conn, Some(#(view.Info, "Feed added successfully"))) + } + _ -> feeds_page(conn, Some(#(view.Error, "Feed URL is required"))) + } +} + +fn import_opml_handler(conn: sqlight.Connection, req: wisp.Request) { + use form <- wisp.require_form(req) + case get_form_file(form, "opml") { + Some(path) -> + case simplifile.read(path) { + Ok(content) -> + case opml.parse_opml(content) { + Ok(feed_attrs) -> { + let _ = + list.map(feed_attrs, fn(attrs) { + let _ = + db.insert_feed( + conn, + name: attrs.name, + site_url: attrs.site_url, + feed_url: attrs.feed_url, + category: attrs.category, + ) + }) + let count = list.length(feed_attrs) + feeds_page( + conn, + Some(#( + view.Info, + "Imported " <> int.to_string(count) <> " feeds", + )), + ) + } + Error(_) -> + feeds_page(conn, Some(#(view.Error, "Failed to parse OPML file"))) + } + Error(_) -> + feeds_page(conn, Some(#(view.Error, "Failed to read uploaded file"))) + } + None -> feeds_page(conn, Some(#(view.Error, "No file uploaded"))) + } +} + +fn delete_feed_handler(conn: sqlight.Connection, id: String) { + let _ = db.delete_feed(conn, id) + wisp.ok() + |> wisp.html_body("") +} + +// ═══════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════ + +fn html_response(body: String) -> wisp.Response { + wisp.ok() + |> wisp.html_body(body) +} + +fn fragment_response(body: String) -> wisp.Response { + wisp.ok() + |> wisp.html_body(body) +} + +/// Return an empty fragment response. HTMX replaces the target element with +/// empty content, effectively removing it from the DOM. +fn empty_fragment_response() -> wisp.Response { + wisp.ok() + |> wisp.html_body("") +} + +/// Check if the request was made by HTMX (sends HX-Request: true header). +fn is_htmx_request(req: wisp.Request) -> Bool { + result.is_ok(request.get_header(req, "hx-request")) +} + +/// Build a load-more fragment response: entry cards + OOB-updated Load More button. +/// The entry cards get appended to #entries (beforeend swap). +/// The Load More button is replaced via hx-swap-oob so it points to the next batch. +fn load_more_fragment_response( + entries: List(db.Entry), + offset: Int, + has_more: Bool, + base_path: String, +) -> wisp.Response { + let cards = fragments.entry_list_fragment(entries) + let next_btn = case has_more { + True -> pages.load_more_button_html(offset + pages.page_size, base_path) + False -> "
" + } + // The entry cards go inline (appended to #entries by hx-swap="beforeend"). + // The load-more-container is swapped OOB. + let body = cards <> next_btn + fragment_response(body) +} + +fn get_offset(query: List(#(String, String))) -> Int { + case list.key_find(query, "after") { + Ok(val) -> result.unwrap(int.parse(val), 0) + Error(_) -> 0 + } +} + +fn get_form_value(form: wisp.FormData, key: String) -> Option(String) { + case list.key_find(form.values, key) { + Ok(val) -> + case val { + "" -> None + other -> Some(other) + } + Error(_) -> None + } +} + +fn get_form_file(form: wisp.FormData, key: String) -> Option(String) { + case list.key_find(form.files, key) { + Ok(file) -> Some(file.path) + Error(_) -> None + } +} diff --git a/src/feedreader/web/server.gleam b/src/feedreader/web/server.gleam new file mode 100644 index 0000000..b9c51ba --- /dev/null +++ b/src/feedreader/web/server.gleam @@ -0,0 +1,110 @@ +//// Mist + Wisp server bootstrap. +//// +//// Opens the database, starts the background workers (scheduler + fetcher) +//// under a supervision tree, and starts the HTTP server. +//// +//// The supervision tree ensures that if a worker actor crashes (e.g. from +//// an unexpected HTTP error), it is automatically restarted rather than +//// taking down the entire application. + +import feedreader/db +import feedreader/fetcher +import feedreader/scheduler +import feedreader/web/router +import gleam/erlang/process +import gleam/io +import gleam/otp/actor +import gleam/otp/static_supervisor as sup +import gleam/otp/supervision +import mist +import sqlight +import wisp/wisp_mist + +/// Scheduler tick interval in milliseconds (3 minutes). +const scheduler_interval_ms = 180_000 + +/// Start the HTTP server with the given database path. +pub fn start(db_path: String) -> Nil { + case db.open(db_path) { + Ok(conn) -> { + let _ = db.migrate(conn) + + // Start background workers under a supervision tree. + // The supervisor traps exits and restarts crashed children. + start_workers(conn) + + io.println("Feedreader starting on http://localhost:3000") + + let handler = router.handle_request(conn, _) + + let assert Ok(_) = + wisp_mist.handler( + handler, + "feedreader-secret-key-base-not-used-for-auth", + ) + |> mist.new + |> mist.bind("0.0.0.0") + |> mist.port(3000) + |> mist.start + + process.sleep_forever() + } + Error(e) -> { + io.println("Failed to open database: " <> sqlight_error_to_string(e)) + Nil + } + } +} + +/// Start the fetcher and scheduler actors under a supervision tree. +/// +/// The scheduler needs the fetcher's subject to send Fetch messages. +/// We start the fetcher first, capture its subject, then pass it to +/// the scheduler via a closure. +fn start_workers(conn: sqlight.Connection) -> Nil { + // Start the fetcher actor and capture its subject. + case fetcher.start_with_http(conn) { + Ok(started) -> { + let fetcher_subject = started.data + + // Start the scheduler under a supervisor so it restarts on crash. + let scheduler_spec = + supervision.worker(fn() { + scheduler.start(conn, fetcher_subject, scheduler_interval_ms) + }) + |> supervision.restart(supervision.Permanent) + + let supervisor_result = + sup.new(strategy: sup.OneForOne) + |> sup.restart_tolerance(intensity: 10, period: 60) + |> sup.add(scheduler_spec) + |> sup.start + + case supervisor_result { + Ok(_) -> io.println("Background workers started (scheduler + fetcher)") + Error(e) -> + io.println( + "Warning: supervisor failed to start: " <> start_error_to_string(e), + ) + } + } + Error(e) -> + io.println( + "Warning: fetcher failed to start: " <> start_error_to_string(e), + ) + } +} + +fn sqlight_error_to_string(e: sqlight.Error) -> String { + case e { + sqlight.SqlightError(_, msg, _) -> msg + } +} + +fn start_error_to_string(e: actor.StartError) -> String { + case e { + actor.InitFailed(reason) -> "init failed: " <> reason + actor.InitExited(_) -> "init exited" + actor.InitTimeout -> "init timeout" + } +} diff --git a/src/feedreader/xml.gleam b/src/feedreader/xml.gleam new file mode 100644 index 0000000..7c4dac7 --- /dev/null +++ b/src/feedreader/xml.gleam @@ -0,0 +1,161 @@ +//// XML parsing via xmerl Erlang FFI. +//// +//// Provides a simple `XmlNode` tree (`Element` or `Text`) and helpers for +//// walking RSS/Atom/OPML documents. Uses Erlang's built-in xmerl scanner +//// (same engine as Elixir's SweetXml), which handles WordPress namespaces, +//// Unicode, and entity decoding correctly. + +import gleam/list +import gleam/option.{type Option, None, Some} +import gleam/string + +pub type XmlNode { + Element(tag: String, attrs: List(#(String, String)), children: List(XmlNode)) + Text(content: String) +} + +pub type ParseError { + ParseError(String) +} + +/// Parse an XML string into an XmlNode tree. +pub fn parse(source: String) -> Result(XmlNode, ParseError) { + case do_parse(source) { + Ok(node) -> Ok(to_gleam_node(node)) + Error(_) -> Error(ParseError("Failed to parse XML")) + } +} + +@external(erlang, "feedreader_xml_ffi", "parse") +fn do_parse(source: String) -> Result(XmlNodeRaw, Nil) + +/// Convert FFI opaque node to typed XmlNode. +fn to_gleam_node(raw: XmlNodeRaw) -> XmlNode { + case kind(raw) { + "element" -> + Element( + tag: tag(raw), + attrs: attrs(raw), + children: list.map(children(raw), to_gleam_node), + ) + _ -> Text(content: text(raw)) + } +} + +// Opaque type wrapping the Erlang term +pub type XmlNodeRaw + +@external(erlang, "feedreader_xml_ffi", "node_kind") +fn kind(node: XmlNodeRaw) -> String + +@external(erlang, "feedreader_xml_ffi", "node_tag") +fn tag(node: XmlNodeRaw) -> String + +@external(erlang, "feedreader_xml_ffi", "node_attrs") +fn attrs(node: XmlNodeRaw) -> List(#(String, String)) + +@external(erlang, "feedreader_xml_ffi", "node_children") +fn children(node: XmlNodeRaw) -> List(XmlNodeRaw) + +@external(erlang, "feedreader_xml_ffi", "node_text") +fn text(node: XmlNodeRaw) -> String + +// ═══════════════════════════════════════════════════════════════ +// Tree helpers +// ═══════════════════════════════════════════════════════════════ + +/// Get all descendant elements with a given tag name (recursive). +pub fn elements_by_tag(node: XmlNode, tag_name: String) -> List(XmlNode) { + case node { + Element(_, _, children) -> { + let direct = list.filter(children, fn(c) { is_tag(c, tag_name) }) + let nested = + list.flat_map(children, fn(c) { elements_by_tag(c, tag_name) }) + list.append(direct, nested) + } + Text(_) -> [] + } +} + +/// Get immediate child elements with a given tag name (non-recursive). +pub fn children_by_tag(node: XmlNode, tag_name: String) -> List(XmlNode) { + case node { + Element(_, _, children) -> + list.filter(children, fn(c) { is_tag(c, tag_name) }) + Text(_) -> [] + } +} + +/// Get the first immediate child element with a given tag. +pub fn child_by_tag(node: XmlNode, tag_name: String) -> Option(XmlNode) { + case children_by_tag(node, tag_name) |> list.first { + Ok(child) -> Some(child) + Error(_) -> None + } +} + +/// Get the concatenated text content of a node's text children. +pub fn text_of(node: XmlNode) -> String { + case node { + Element(_, _, children) -> + children + |> list.filter(fn(c) { + case c { + Text(_) -> True + Element(_, _, _) -> False + } + }) + |> list.map(fn(c) { + case c { + Text(content:) -> content + _ -> "" + } + }) + |> string.join("") + Text(content:) -> content + } +} + +/// Get the text content of the first child with a given tag. +/// Returns None if the child doesn't exist or has no text. +pub fn child_text(node: XmlNode, tag_name: String) -> Option(String) { + case child_by_tag(node, tag_name) { + Some(child) -> { + let t = text_of(child) |> string.trim() + case t { + "" -> None + other -> Some(other) + } + } + None -> None + } +} + +/// Get an attribute value from an element node. +pub fn attr(node: XmlNode, name: String) -> Option(String) { + case node { + Element(_, attrs, _) -> + case + attrs + |> list.find(fn(pair) { + let #(k, _) = pair + k == name + }) + { + Ok(pair) -> { + let #(_, v) = pair + Some(v) + } + Error(_) -> None + } + Text(_) -> None + } +} + +/// Check if a node is an element with a specific tag. +fn is_tag(node: XmlNode, tag_name: String) -> Bool { + case node { + Element(tag, _, _) -> tag == tag_name + Text(_) -> False + } +} diff --git a/src/feedreader_tcp_ffi.erl b/src/feedreader_tcp_ffi.erl new file mode 100644 index 0000000..b4e5ab6 --- /dev/null +++ b/src/feedreader_tcp_ffi.erl @@ -0,0 +1,27 @@ +-module(feedreader_tcp_ffi). +-export([start_close_server/0, stop_server/1]). + +%% Starts a TCP listener that accepts connections and immediately +%% closes them, simulating a server that drops the connection. +%% Returns {ok, Port} which can be used to build a URL. +start_close_server() -> + {ok, ListenSock} = gen_tcp:listen(0, [ + binary, + {active, false}, + {reuseaddr, true} + ]), + {ok, Port} = inet:port(ListenSock), + spawn(fun Loop() -> + case gen_tcp:accept(ListenSock, 1000) of + {ok, Sock} -> + %% Read a bit then abruptly close — no HTTP response sent + gen_tcp:close(Sock), + Loop(); + {error, _} -> + ok + end + end), + {ok, {Port, ListenSock}}. + +stop_server(ListenSock) -> + gen_tcp:close(ListenSock). diff --git a/src/feedreader_xml_ffi.erl b/src/feedreader_xml_ffi.erl new file mode 100644 index 0000000..dabc22c --- /dev/null +++ b/src/feedreader_xml_ffi.erl @@ -0,0 +1,63 @@ +-module(feedreader_xml_ffi). +-include_lib("xmerl/include/xmerl.hrl"). +-export([parse/1, node_kind/1, node_tag/1, node_attrs/1, node_children/1, node_text/1]). + +%% Parse an XML string using xmerl. Returns {ok, Root} | {error, Reason} +%% where Root is our simplified node structure: +%% {element, Tag :: binary(), Attrs :: [{binary(), binary()}], Children :: [Node]} +%% {text, Text :: binary()} + +parse(XmlString) when is_binary(XmlString) -> + parse(binary_to_list(XmlString)); +parse(XmlString) -> + try + Opts = [{space, normalize}], + case xmerl_scan:string(XmlString, Opts) of + {Element, _Rest} -> + {ok, walk(Element)}; + {error, _Reason} -> + {error, scan_error} + end + catch + Class:ReasonErr:Stack -> + {error, {exception, Class, ReasonErr, Stack}} + end. + +walk(#xmlElement{name = Name, attributes = Attrs, content = Content}) -> + Tag = atom_to_binary(Name, utf8), + A = [ {attr_name(Ax), attr_value(Ax)} || Ax <- Attrs ], + C = [ walk(X) || X <- Content ], + {element, Tag, A, C}; +walk(#xmlText{value = V}) -> + {text, unicode:characters_to_binary(V)}; +walk(#xmlComment{}) -> + {text, <<>>}; +walk(#xmlPI{}) -> + {text, <<>>}; +walk(#xmlDecl{}) -> + {text, <<>>}; +walk(Other) -> + {text, <<>>}. + +attr_name(#xmlAttribute{name = N}) -> + atom_to_binary(N, utf8). + +attr_value(#xmlAttribute{value = V}) -> + unicode:characters_to_binary(V). + +%% Field accessors for Gleam FFI +node_kind({element, _, _, _}) -> <<"element">>; +node_kind({text, _}) -> <<"text">>; +node_kind(_) -> <<"text">>. + +node_tag({element, Tag, _, _}) -> Tag; +node_tag(_) -> <<>>. + +node_attrs({element, _, Attrs, _}) -> Attrs; +node_attrs(_) -> []. + +node_children({element, _, _, Children}) -> Children; +node_children(_) -> []. + +node_text({text, Text}) -> Text; +node_text(_) -> <<>>. diff --git a/test/feedreader/core/entry_test.exs b/test/feedreader/core/entry_test.exs deleted file mode 100644 index 8a1ea1a..0000000 --- a/test/feedreader/core/entry_test.exs +++ /dev/null @@ -1,328 +0,0 @@ -defmodule FeedReader.Core.EntryTest do - use Feedreader.DataCase, async: false - - alias FeedReader.Core - - setup do - feed = Core.add_feed!(%{feed_url: "https://example.com/feed.xml", name: "Test"}) - %{feed: feed} - end - - describe "toggle_starred/1" do - test "toggles is_starred from false to true", %{feed: feed} do - entry = - Core.upsert_from_feed!(%{ - external_id: "entry-1", - title: "Test Entry", - content_link: "https://example.com/entry1", - feed_id: feed.id - }) - - refute entry.is_starred - - assert {:ok, updated} = Core.toggle_starred(entry) - - assert updated.is_starred == true - end - - test "toggles is_starred from true to false", %{feed: feed} do - entry = - Core.upsert_from_feed!(%{ - external_id: "entry-1", - title: "Test Entry", - content_link: "https://example.com/entry1", - feed_id: feed.id - }) - - {:ok, entry} = Core.update_entry(entry, %{is_starred: true}) - - assert entry.is_starred == true - - assert {:ok, updated} = Core.toggle_starred(entry) - - assert updated.is_starred == false - end - end - - describe "toggle_read/1" do - test "toggles is_read from false to true", %{feed: feed} do - entry = - Core.upsert_from_feed!(%{ - external_id: "entry-1", - title: "Test Entry", - content_link: "https://example.com/entry1", - feed_id: feed.id - }) - - refute entry.is_read - - assert {:ok, updated} = Core.toggle_read(entry) - - assert updated.is_read == true - end - - test "toggles is_read from true to false", %{feed: feed} do - entry = - Core.upsert_from_feed!(%{ - external_id: "entry-1", - title: "Test Entry", - content_link: "https://example.com/entry1", - feed_id: feed.id - }) - - {:ok, entry} = Core.update_entry(entry, %{is_read: true}) - - assert entry.is_read == true - - assert {:ok, updated} = Core.toggle_read(entry) - - assert updated.is_read == false - end - end - - describe "get_entry_by_feed_and_external_id/2" do - test "returns entry when it exists", %{feed: feed} do - Core.upsert_from_feed!(%{ - external_id: "lookup-1", - title: "Lookup Entry", - content_link: "https://example.com/lookup1", - feed_id: feed.id - }) - - assert {:ok, entry} = Core.get_entry_by_feed_and_external_id(feed.id, "lookup-1") - assert entry.title == "Lookup Entry" - end - - test "returns {:ok, nil} when entry does not exist", %{feed: feed} do - assert {:ok, nil} = Core.get_entry_by_feed_and_external_id(feed.id, "nonexistent") - end - end - - describe "upsert_from_feed/1" do - test "creates a new entry", %{feed: feed} do - attrs = %{ - external_id: "unique-entry-id", - title: "New Entry", - content_link: "https://example.com/entry", - feed_id: feed.id - } - - entry = Core.upsert_from_feed!(attrs) - - assert entry.title == "New Entry" - assert entry.external_id == "unique-entry-id" - end - - test "upserts entry with same external_id does not create duplicate", %{feed: feed} do - attrs = %{ - external_id: "duplicate-test", - title: "First Entry", - content_link: "https://example.com/entry1", - feed_id: feed.id - } - - assert %FeedReader.Core.Entry{} = Core.upsert_from_feed!(attrs) - - attrs2 = %{ - external_id: "duplicate-test", - title: "Updated Entry", - content_link: "https://example.com/entry1-updated", - feed_id: feed.id - } - - entry2 = Core.upsert_from_feed!(attrs2) - - assert entry2.title == "Updated Entry" - - entries = Core.list_entries!().results - assert length(entries) == 1 - end - end - - describe "unread/0" do - test "returns only unread entries", %{feed: feed} do - unread_entry = - Core.upsert_from_feed!(%{ - external_id: "unread-1", - title: "Unread Entry", - content_link: "https://example.com/unread1", - feed_id: feed.id - }) - - read_entry = - Core.upsert_from_feed!(%{ - external_id: "read-1", - title: "Read Entry", - content_link: "https://example.com/read1", - feed_id: feed.id - }) - - {:ok, _} = Core.update_entry(read_entry, %{is_read: true}) - - {:ok, results} = Core.list_unread() - - assert length(results.results) == 1 - assert hd(results.results).id == unread_entry.id - assert hd(results.results).title == "Unread Entry" - end - end - - describe "starred/0" do - test "returns only starred entries", %{feed: feed} do - entry1 = - Core.upsert_from_feed!(%{ - external_id: "starred-1", - title: "Starred Entry", - content_link: "https://example.com/starred1", - feed_id: feed.id - }) - - {:ok, _} = Core.update_entry(entry1, %{is_starred: true}) - - _entry2 = - Core.upsert_from_feed!(%{ - external_id: "unstarred-1", - title: "Unstarred Entry", - content_link: "https://example.com/unstarred1", - feed_id: feed.id - }) - - {:ok, results} = Core.list_starred() - - assert length(results.results) == 1 - assert hd(results.results).title == "Starred Entry" - end - end - - describe "ordering" do - test "list_unread returns entries sorted by published_at ascending (oldest first)", %{ - feed: feed - } do - now = DateTime.utc_now() - - _older = - Core.upsert_from_feed!(%{ - external_id: "order-1", - title: "Older Entry", - content_link: "https://example.com/older", - feed_id: feed.id, - published_at: DateTime.add(now, -3600, :second) - }) - - _newer = - Core.upsert_from_feed!(%{ - external_id: "order-2", - title: "Newer Entry", - content_link: "https://example.com/newer", - feed_id: feed.id, - published_at: DateTime.add(now, -1800, :second) - }) - - _oldest = - Core.upsert_from_feed!(%{ - external_id: "order-3", - title: "Oldest Entry", - content_link: "https://example.com/oldest", - feed_id: feed.id, - published_at: DateTime.add(now, -7200, :second) - }) - - {:ok, results} = Core.list_unread(page: [limit: 100]) - - titles = Enum.map(results.results, & &1.title) - assert titles == ["Oldest Entry", "Older Entry", "Newer Entry"] - end - - test "list_starred returns entries sorted by published_at ascending", %{feed: feed} do - now = DateTime.utc_now() - - older = - Core.upsert_from_feed!(%{ - external_id: "starred-order-1", - title: "Older Starred", - content_link: "https://example.com/older", - feed_id: feed.id, - published_at: DateTime.add(now, -3600, :second) - }) - - {:ok, _} = Core.update_entry(older, %{is_starred: true}) - - newer = - Core.upsert_from_feed!(%{ - external_id: "starred-order-2", - title: "Newer Starred", - content_link: "https://example.com/newer", - feed_id: feed.id, - published_at: DateTime.add(now, -1800, :second) - }) - - {:ok, _} = Core.update_entry(newer, %{is_starred: true}) - - {:ok, results} = Core.list_starred() - - titles = Enum.map(results.results, & &1.title) - assert titles == ["Older Starred", "Newer Starred"] - end - - test "list_history returns entries sorted by published_at descending", %{feed: feed} do - now = DateTime.utc_now() - - older = - Core.upsert_from_feed!(%{ - external_id: "history-order-1", - title: "Older History", - content_link: "https://example.com/older", - feed_id: feed.id, - published_at: DateTime.add(now, -3600, :second) - }) - - {:ok, _} = Core.update_entry(older, %{is_read: true}) - - newer = - Core.upsert_from_feed!(%{ - external_id: "history-order-2", - title: "Newer History", - content_link: "https://example.com/newer", - feed_id: feed.id, - published_at: DateTime.add(now, -1800, :second) - }) - - {:ok, _} = Core.update_entry(newer, %{is_read: true}) - - {:ok, results} = Core.list_history() - - titles = Enum.map(results.results, & &1.title) - assert titles == ["Newer History", "Older History"] - end - end - - describe "pagination" do - test "list_unread accepts page limit option", %{feed: feed} do - for i <- 1..15 do - Core.upsert_from_feed!(%{ - external_id: "pagination-#{i}", - title: "Entry #{i}", - content_link: "https://example.com/#{i}", - feed_id: feed.id - }) - end - - {:ok, results} = Core.list_unread(page: [limit: 10]) - assert length(results.results) == 10 - end - - test "list_unread returns correct count", %{feed: feed} do - for i <- 1..5 do - Core.upsert_from_feed!(%{ - external_id: "count-test-#{i}", - title: "Count Entry #{i}", - content_link: "https://example.com/count-#{i}", - feed_id: feed.id - }) - end - - {:ok, results} = Core.list_unread(page: [limit: 100]) - assert length(results.results) == 5 - end - end -end diff --git a/test/feedreader/core/feed_delete_test.exs b/test/feedreader/core/feed_delete_test.exs deleted file mode 100644 index 98cacef..0000000 --- a/test/feedreader/core/feed_delete_test.exs +++ /dev/null @@ -1,71 +0,0 @@ -defmodule FeedReader.Core.FeedDeleteTest do - use Feedreader.DataCase, async: false - - alias FeedReader.Core - - describe "delete_feed/1" do - test "deletes a feed with no entries" do - feed = Core.add_feed!(%{feed_url: "https://example.com/feed.xml"}) - - result = Core.delete_feed(feed) - - assert result == :ok - assert Core.list_feeds!() |> Enum.filter(&(&1.id == feed.id)) == [] - end - - test "deletes a feed and cascades to its entries" do - feed = Core.add_feed!(%{feed_url: "https://example.com/feed.xml"}) - - entry_attrs = %{ - external_id: "entry-1", - title: "Test Entry", - content_link: "https://example.com/entry-1", - feed_id: feed.id - } - - Core.upsert_from_feed!(entry_attrs) - - # Verify entry was created - entries = Core.list_entries!().results - assert length(entries) == 1 - - result = Core.delete_feed(feed) - - assert result == :ok - - # Feed should be gone - feeds = Core.list_feeds!() - refute Enum.any?(feeds, &(&1.id == feed.id)) - - # Entries should be cascaded - remaining_entries = Core.list_entries!().results - assert Enum.all?(remaining_entries, &(&1.feed_id != feed.id)) - end - - test "deletes a feed with many entries" do - feed = Core.add_feed!(%{feed_url: "https://example.com/feed.xml"}) - - for i <- 1..150 do - Core.upsert_from_feed!(%{ - external_id: "entry-#{i}", - title: "Entry #{i}", - content_link: "https://example.com/entry-#{i}", - feed_id: feed.id - }) - end - - {:ok, page} = Core.list_entries(page: [limit: 200]) - assert length(page.results) == 150 - - result = Core.delete_feed(feed) - - assert result == :ok - - feeds = Core.list_feeds!() - refute Enum.any?(feeds, &(&1.id == feed.id)) - - {:ok, remaining} = Core.list_entries(page: [limit: 200]) - assert Enum.all?(remaining.results, &(&1.feed_id != feed.id)) - end - end -end diff --git a/test/feedreader/core/feed_test.exs b/test/feedreader/core/feed_test.exs deleted file mode 100644 index 28c096f..0000000 --- a/test/feedreader/core/feed_test.exs +++ /dev/null @@ -1,86 +0,0 @@ -defmodule FeedReader.Core.FeedTest do - use Feedreader.DataCase, async: false - - alias FeedReader.Core - - describe "add/1" do - test "creates a feed with valid attributes" do - attrs = %{ - name: "Test Feed", - site_url: "https://example.com", - feed_url: "https://example.com/feed.xml", - category: "Tech" - } - - feed = Core.add_feed!(attrs) - - assert feed.name == "Test Feed" - assert feed.feed_url == "https://example.com/feed.xml" - assert feed.category == "Tech" - end - - test "creates a feed with default category" do - attrs = %{ - feed_url: "https://example.com/feed.xml" - } - - feed = Core.add_feed!(attrs) - - assert feed.category == "Uncategorized" - end - - test "rejects duplicate feed_url" do - attrs = %{ - name: "Test Feed", - feed_url: "https://example.com/feed.xml" - } - - assert %FeedReader.Core.Feed{} = Core.add_feed!(attrs) - - assert_raise Ash.Error.Invalid, fn -> - Core.add_feed!(attrs) - end - end - end - - describe "import_opml/1" do - test "imports feeds from opml file" do - opml_content = File.read!("#{__DIR__}/../../fixtures/feeds.opml") - {success_count, _errors} = Core.import_opml(opml_content) - - assert success_count > 0 - end - - test "extracts categories from nested outlines" do - opml_content = File.read!("#{__DIR__}/../../fixtures/feeds.opml") - {_success_count, _errors} = Core.import_opml(opml_content) - - feeds = Core.list_feeds!() - categories = Enum.map(feeds, & &1.category) |> Enum.uniq() - - assert "Tech" in categories - assert "Austin" in categories - end - end - - describe "log_fetch_success/1" do - test "updates last_fetched_at and clears fetch_error" do - feed = Core.add_feed!(%{feed_url: "https://example.com/feed.xml"}) - - {:ok, updated} = Core.log_fetch_success(feed) - - assert updated.last_fetched_at != nil - assert updated.fetch_error == nil - end - end - - describe "log_fetch_error/1" do - test "sets fetch_error" do - feed = Core.add_feed!(%{feed_url: "https://example.com/feed.xml"}) - - {:ok, updated} = Core.log_fetch_error(feed, %{fetch_error: "Network error"}) - - assert updated.fetch_error == "Network error" - end - end -end diff --git a/test/feedreader/date_test.gleam b/test/feedreader/date_test.gleam new file mode 100644 index 0000000..f1cf855 --- /dev/null +++ b/test/feedreader/date_test.gleam @@ -0,0 +1,49 @@ +import feedreader/date +import gleam/option.{None, Some} +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn parse_iso8601_test() { + let result = date.parse_date(Some("2025-06-12T19:30:00Z")) + assert result != None +} + +pub fn parse_iso8601_with_offset_test() { + let result = date.parse_date(Some("2025-06-12T14:30:00-05:00")) + assert result != None +} + +pub fn parse_rfc822_test() { + let result = date.parse_date(Some("Thu, 12 Jun 2025 14:30:00 GMT")) + assert result != None +} + +pub fn parse_rfc822_with_named_tz_test() { + let result = date.parse_date(Some("Thu, 12 Jun 2025 14:30:00 EST")) + assert result != None +} + +pub fn parse_rfc822_with_pst_test() { + let result = date.parse_date(Some("Thu, 12 Jun 2025 14:30:00 PST")) + assert result != None +} + +pub fn parse_rfc822_with_numeric_offset_test() { + let result = date.parse_date(Some("Thu, 12 Jun 2025 14:30:00 +0800")) + assert result != None +} + +pub fn parse_none_returns_none_test() { + assert date.parse_date(None) == None +} + +pub fn parse_empty_returns_none_test() { + assert date.parse_date(Some("")) == None +} + +pub fn parse_garbage_returns_none_test() { + assert date.parse_date(Some("not a date")) == None +} diff --git a/test/feedreader/db_test.gleam b/test/feedreader/db_test.gleam new file mode 100644 index 0000000..d1ef9c9 --- /dev/null +++ b/test/feedreader/db_test.gleam @@ -0,0 +1,450 @@ +import feedreader/db +import gleam/list +import gleam/option.{None, Some} +import sqlight + +fn with_db(f: fn(sqlight.Connection) -> a) -> a { + let assert Ok(conn) = sqlight.open("file::memory:") + let assert Ok(Nil) = db.migrate(conn) + let result = f(conn) + let assert Ok(Nil) = sqlight.close(conn) + result +} + +// ═══════════════════════════════════════════════════════════════ +// Schema / Migration tests +// ═══════════════════════════════════════════════════════════════ + +pub fn migrate_creates_feeds_table_test() { + with_db(fn(conn) { + // If the table didn't exist, this query would error + let assert Ok(Nil) = + sqlight.exec( + "INSERT INTO feeds (id, name, site_url, feed_url, category, last_fetched_at, fetch_error) VALUES ('test', '', '', 'test-url', 'X', '', '')", + on: conn, + ) + let assert Ok(Nil) = + sqlight.exec("DELETE FROM feeds WHERE id = 'test'", on: conn) + }) +} + +pub fn migrate_creates_entries_table_test() { + with_db(fn(conn) { + let assert Ok(Nil) = + sqlight.exec( + "INSERT INTO feeds (id, name, site_url, feed_url, category, last_fetched_at, fetch_error) VALUES ('f', '', '', 'f-url', 'X', '', '')", + on: conn, + ) + let assert Ok(Nil) = + sqlight.exec( + "INSERT INTO entries (id, created_at, external_id, title, content_link, comments_link, published_at, is_read, is_starred, feed_id) VALUES ('e', '2025', 'ext', '', '', '', '', 0, 0, 'f')", + on: conn, + ) + let assert Ok(Nil) = + sqlight.exec("DELETE FROM entries WHERE id = 'e'", on: conn) + let assert Ok(Nil) = + sqlight.exec("DELETE FROM feeds WHERE id = 'f'", on: conn) + }) +} + +pub fn migrate_is_idempotent_test() { + with_db(fn(conn) { + let assert Ok(Nil) = db.migrate(conn) + let assert Ok(Nil) = db.migrate(conn) + // still works fine + }) +} + +// ═══════════════════════════════════════════════════════════════ +// Feed CRUD tests +// ═══════════════════════════════════════════════════════════════ + +pub fn insert_and_get_feed_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test Blog"), + site_url: Some("https://example.com"), + feed_url: "https://example.com/rss", + category: "Tech", + ) + assert feed.feed_url == "https://example.com/rss" + assert feed.category == "Tech" + + let assert Ok(Some(fetched)) = db.get_feed(conn, feed.id) + assert fetched.feed_url == "https://example.com/rss" + assert fetched.category == "Tech" + }) +} + +pub fn insert_feed_with_defaults_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: None, + site_url: None, + feed_url: "https://example.com/rss", + category: "Uncategorized", + ) + assert feed.category == "Uncategorized" + assert feed.name == None + assert feed.site_url == None + }) +} + +pub fn insert_duplicate_feed_url_fails_test() { + with_db(fn(conn) { + let assert Ok(_) = + db.insert_feed( + conn, + name: None, + site_url: None, + feed_url: "https://a.com/rss", + category: "Tech", + ) + let assert Error(Nil) = + db.insert_feed( + conn, + name: None, + site_url: None, + feed_url: "https://a.com/rss", + category: "Tech", + ) + }) +} + +pub fn list_feeds_test() { + with_db(fn(conn) { + let assert Ok(_) = + db.insert_feed( + conn, + name: Some("B"), + site_url: None, + feed_url: "https://b.com/rss", + category: "Tech", + ) + let assert Ok(_) = + db.insert_feed( + conn, + name: Some("A"), + site_url: None, + feed_url: "https://a.com/rss", + category: "Tech", + ) + let assert Ok(feeds) = db.list_feeds(conn) + assert list.length(feeds) == 2 + }) +} + +pub fn delete_feed_cascades_to_entries_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test"), + site_url: None, + feed_url: "https://example.com/rss", + category: "Tech", + ) + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "guid-1", + title: Some("Entry 1"), + content_link: Some("https://example.com/1"), + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + let assert Ok(Nil) = db.delete_feed(conn, feed.id) + let assert Ok(None) = db.get_feed(conn, feed.id) + // entries should be gone via cascade + let assert Ok([]) = db.list_unread(conn, limit: 100, offset: 0) + }) +} + +pub fn get_feed_by_url_test() { + with_db(fn(conn) { + let assert Ok(_) = + db.insert_feed( + conn, + name: Some("Test"), + site_url: None, + feed_url: "https://unique.example.com/rss", + category: "Tech", + ) + let assert Ok(Some(feed)) = + db.get_feed_by_url(conn, "https://unique.example.com/rss") + assert feed.feed_url == "https://unique.example.com/rss" + }) +} + +// ═══════════════════════════════════════════════════════════════ +// Entry CRUD tests +// ═══════════════════════════════════════════════════════════════ + +pub fn upsert_and_list_unread_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test"), + site_url: None, + feed_url: "https://example.com/rss", + category: "Tech", + ) + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "guid-1", + title: Some("Entry 1"), + content_link: Some("https://example.com/1"), + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + let assert Ok(entries) = db.list_unread(conn, limit: 50, offset: 0) + assert list.length(entries) == 1 + let assert Ok(Some(entry)) = db.get_entry(conn, first_entry_id(entries)) + assert entry.external_id == "guid-1" + }) +} + +pub fn upsert_is_idempotent_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test"), + site_url: None, + feed_url: "https://example.com/rss", + category: "Tech", + ) + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "guid-1", + title: Some("Entry 1"), + content_link: Some("https://example.com/1"), + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + // Upsert same entry again — should not duplicate + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "guid-1", + title: Some("Entry 1 Updated"), + content_link: Some("https://example.com/1"), + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + let assert Ok(entries) = db.list_unread(conn, limit: 50, offset: 0) + assert list.length(entries) == 1 + }) +} + +pub fn toggle_read_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test"), + site_url: None, + feed_url: "https://example.com/rss", + category: "Tech", + ) + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "guid-1", + title: Some("Entry 1"), + content_link: None, + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + let assert Ok(entries) = db.list_unread(conn, limit: 50, offset: 0) + let assert Ok(Some(entry)) = db.get_entry(conn, first_entry_id(entries)) + assert entry.is_read == False + + let assert Ok(Nil) = db.toggle_read(conn, entry.id) + let assert Ok(Some(updated)) = db.get_entry(conn, entry.id) + assert updated.is_read == True + + // Toggle back + let assert Ok(Nil) = db.toggle_read(conn, entry.id) + let assert Ok(Some(again)) = db.get_entry(conn, entry.id) + assert again.is_read == False + }) +} + +pub fn toggle_starred_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test"), + site_url: None, + feed_url: "https://example.com/rss", + category: "Tech", + ) + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "guid-1", + title: Some("Entry 1"), + content_link: None, + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + let assert Ok(entries) = db.list_unread(conn, limit: 50, offset: 0) + let entry_id = first_entry_id(entries) + + let assert Ok(Nil) = db.toggle_starred(conn, entry_id) + let assert Ok(Some(starred)) = db.get_entry(conn, entry_id) + assert starred.is_starred == True + + let assert Ok(starred_entries) = db.list_starred(conn, limit: 50, offset: 0) + assert list.length(starred_entries) == 1 + + let assert Ok(Nil) = db.toggle_starred(conn, entry_id) + let assert Ok(Some(unstarred)) = db.get_entry(conn, entry_id) + assert unstarred.is_starred == False + }) +} + +pub fn unread_excludes_read_entries_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test"), + site_url: None, + feed_url: "https://example.com/rss", + category: "Tech", + ) + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "g1", + title: Some("E1"), + content_link: None, + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "g2", + title: Some("E2"), + content_link: None, + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + + let assert Ok(entries) = db.list_unread(conn, limit: 50, offset: 0) + assert list.length(entries) == 2 + + let assert Ok(Nil) = db.toggle_read(conn, first_entry_id(entries)) + let assert Ok(unread) = db.list_unread(conn, limit: 50, offset: 0) + assert list.length(unread) == 1 + }) +} + +pub fn unread_count_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test"), + site_url: None, + feed_url: "https://example.com/rss", + category: "Tech", + ) + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "g1", + title: Some("E1"), + content_link: None, + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "g2", + title: Some("E2"), + content_link: None, + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + + let assert Ok(count) = db.unread_count(conn) + assert count == 2 + + let assert Ok(entries) = db.list_unread(conn, limit: 50, offset: 0) + let assert Ok(Nil) = db.toggle_read(conn, first_entry_id(entries)) + let assert Ok(count2) = db.unread_count(conn) + assert count2 == 1 + }) +} + +pub fn log_fetch_success_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test"), + site_url: None, + feed_url: "https://example.com/rss", + category: "Tech", + ) + let assert Ok(Nil) = + db.log_fetch_success(conn, feed.id, "2025-06-19T00:00:00Z") + let assert Ok(Some(updated)) = db.get_feed(conn, feed.id) + assert updated.fetch_error == None + assert updated.last_fetched_at == Some("2025-06-19T00:00:00Z") + }) +} + +pub fn log_fetch_error_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test"), + site_url: None, + feed_url: "https://example.com/rss", + category: "Tech", + ) + let assert Ok(Nil) = + db.log_fetch_error( + conn, + feed.id, + "2025-06-19T00:00:00Z", + "HTTP status: 503", + ) + let assert Ok(Some(updated)) = db.get_feed(conn, feed.id) + assert updated.fetch_error == Some("HTTP status: 503") + }) +} + +// ═══════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════ + +fn first_entry_id(entries: List(db.Entry)) -> String { + let assert Ok(entry) = list.first(entries) + entry.id +} diff --git a/test/feedreader/fetcher_test.gleam b/test/feedreader/fetcher_test.gleam new file mode 100644 index 0000000..4689ec8 --- /dev/null +++ b/test/feedreader/fetcher_test.gleam @@ -0,0 +1,158 @@ +import feedreader/db +import feedreader/fetcher +import gleam/erlang/process +import gleam/list +import gleam/option.{None, Some} +import gleeunit +import sqlight + +pub fn main() -> Nil { + gleeunit.main() +} + +fn with_db(f: fn(sqlight.Connection) -> a) -> a { + let assert Ok(conn) = sqlight.open("file::memory:") + let assert Ok(Nil) = db.migrate(conn) + let result = f(conn) + let assert Ok(Nil) = sqlight.close(conn) + result +} + +fn seed_feed(conn: sqlight.Connection) -> db.Feed { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test Feed"), + site_url: Some("https://example.com"), + feed_url: "https://example.com/feed.rss", + category: "Test", + ) + feed +} + +fn mock_rss_body() -> String { + " + + + Test Feed + + Test Entry + https://example.com/1 + test-guid-1 + + +" +} + +fn mock_success(_url: String) -> Result(String, String) { + Ok(mock_rss_body()) +} + +fn mock_failure(_url: String) -> Result(String, String) { + Error("HTTP status: 503") +} + +// ═══════════════════════════════════════════════════════════════ +// process_feed tests (synchronous, no process.sleep) +// ═══════════════════════════════════════════════════════════════ + +pub fn process_feed_success_persists_entries_test() { + with_db(fn(conn) { + let feed = seed_feed(conn) + let result = fetcher.process_feed(conn, feed.id, mock_success) + assert result == fetcher.Fetched(count: 1) + + let assert Ok(entries) = db.list_unread(conn, limit: 10, offset: 0) + assert list.length(entries) == 1 + let assert Ok(first) = list.first(entries) + assert first.external_id == "test-guid-1" + assert first.title == Some("Test Entry") + assert first.feed_id == feed.id + }) +} + +pub fn process_feed_success_logs_fetch_test() { + with_db(fn(conn) { + let feed = seed_feed(conn) + let _ = fetcher.process_feed(conn, feed.id, mock_success) + let assert Ok(Some(updated)) = db.get_feed(conn, feed.id) + assert updated.last_fetched_at != None + assert updated.fetch_error == None + }) +} + +pub fn process_feed_failure_logs_error_test() { + with_db(fn(conn) { + let feed = seed_feed(conn) + let result = fetcher.process_feed(conn, feed.id, mock_failure) + assert result == fetcher.FetchFailed(error: "HTTP status: 503") + + let assert Ok(Some(updated)) = db.get_feed(conn, feed.id) + assert updated.last_fetched_at != None + assert updated.fetch_error == Some("HTTP status: 503") + }) +} + +pub fn process_feed_unknown_feed_test() { + with_db(fn(conn) { + let result = fetcher.process_feed(conn, "nonexistent-id", mock_success) + assert result == fetcher.FetchFailed(error: "Feed not found") + }) +} + +pub fn process_feed_parse_error_logs_error_test() { + with_db(fn(conn) { + let feed = seed_feed(conn) + let result = + fetcher.process_feed(conn, feed.id, fn(_) { Ok("valid Nil + _ -> panic as "expected FetchFailed" + } + }) +} + +pub fn process_feed_dedup_on_refetch_test() { + with_db(fn(conn) { + let feed = seed_feed(conn) + let _ = fetcher.process_feed(conn, feed.id, mock_success) + let _ = fetcher.process_feed(conn, feed.id, mock_success) + let assert Ok(entries) = db.list_unread(conn, limit: 10, offset: 0) + assert list.length(entries) == 1 + }) +} + +pub fn process_feed_updates_changed_entry_test() { + with_db(fn(conn) { + let feed = seed_feed(conn) + let _ = fetcher.process_feed(conn, feed.id, mock_success) + let _ = + fetcher.process_feed(conn, feed.id, fn(_) { + Ok( + " + + + New Title + https://example.com/1 + test-guid-1 + +", + ) + }) + let assert Ok(entries) = db.list_unread(conn, limit: 10, offset: 0) + let assert Ok(entry) = list.first(entries) + assert entry.title == Some("New Title") + }) +} + +// ═══════════════════════════════════════════════════════════════ +// Actor lifecycle tests +// ═══════════════════════════════════════════════════════════════ + +pub fn actor_starts_and_stops_test() { + let assert Ok(conn) = sqlight.open("file::memory:") + let assert Ok(Nil) = db.migrate(conn) + let assert Ok(started) = fetcher.start(conn, mock_success) + process.send(started.data, fetcher.Stop) + let assert Ok(Nil) = sqlight.close(conn) +} diff --git a/test/feedreader/http_test.gleam b/test/feedreader/http_test.gleam new file mode 100644 index 0000000..8e07a9c --- /dev/null +++ b/test/feedreader/http_test.gleam @@ -0,0 +1,147 @@ +import feedreader/http as fh +import gleam/http +import gleam/int +import gleam/string +import http_server_mock +import http_server_mock/matcher +import http_server_mock/response +import http_server_mock/stub_builder +import http_server_mock_erlang + +// ═══════════════════════════════════════════════════════════════ +// http.fetch tests +// ═══════════════════════════════════════════════════════════════ + +pub fn fetch_success_test() { + // Stub a mock HTTP server returning 200 with RSS content + let rss_body = + " + + + Test Feed + + Test Entry + https://example.com/1 + guid-1 + + +" + + let stub = + stub_builder.new() + |> stub_builder.matching(matcher.new() |> matcher.method(http.Get)) + |> stub_builder.responding_with( + response.new() + |> response.body(rss_body), + ) + |> stub_builder.build() + + let server = + http_server_mock.new(http_server_mock_erlang.server()) + |> http_server_mock.start() + |> http_server_mock.with_stub(stub) + + let url = http_server_mock.base_url(server) <> "/" + let assert Ok(body) = fh.fetch(url) + assert body == rss_body + + let _stopped = http_server_mock.stop(server) +} + +pub fn fetch_404_test() { + // Stub a mock HTTP server returning 404 + let stub = + stub_builder.new() + |> stub_builder.matching(matcher.new() |> matcher.method(http.Get)) + |> stub_builder.responding_with(response.new() |> response.status(404)) + |> stub_builder.build() + + let server = + http_server_mock.new(http_server_mock_erlang.server()) + |> http_server_mock.start() + |> http_server_mock.with_stub(stub) + + let url = http_server_mock.base_url(server) <> "/" + let assert Error(msg) = fh.fetch(url) + assert string.starts_with(msg, "HTTP status:") + + let _stopped = http_server_mock.stop(server) +} + +pub fn fetch_timeout_test() { + // Stub a mock HTTP server that delays >30s (should timeout) + let stub = + stub_builder.new() + |> stub_builder.matching(matcher.new() |> matcher.method(http.Get)) + |> stub_builder.responding_with( + response.new() + |> response.body("delayed") + |> response.delay(35_000), + // 35 seconds > 30 second timeout + ) + |> stub_builder.build() + + let server = + http_server_mock.new(http_server_mock_erlang.server()) + |> http_server_mock.start() + |> http_server_mock.with_stub(stub) + + let url = http_server_mock.base_url(server) <> "/" + let assert Error(msg) = fh.fetch(url) + assert msg == "HTTP error: Response timeout" + + let _stopped = http_server_mock.stop(server) +} + +// ═══════════════════════════════════════════════════════════════ +// Crash resilience test +// ═══════════════════════════════════════════════════════════════ +// +// gleam_httpc's FFI crashes (erlang:error) on unrecognized error shapes +// like socket_closed_remotely. http.fetch runs the request in an isolated +// unlinked worker process and monitors it, so a crash returns Error instead +// of killing the caller. This test verifies that isolation. + +/// External FFI to start a TCP server that accepts and immediately closes +/// connections, triggering socket_closed_remotely in httpc. +@external(erlang, "feedreader_tcp_ffi", "start_close_server") +pub fn start_close_server() -> Result(#(Int, a), b) + +@external(erlang, "feedreader_tcp_ffi", "stop_server") +pub fn stop_server(socket: a) -> Nil + +pub fn fetch_survives_connection_crash_test() { + // Start a TCP server that accepts connections and closes them immediately. + // This triggers gleam_httpc's erlang:error({unexpected_httpc_error, ...}) + // because httpc gets socket_closed_remotely. + let assert Ok(#(port, socket)) = start_close_server() + let url = "http://localhost:" <> int.to_string(port) <> "/" + + // Before the fix, this would crash the test process. Now it returns Error. + let result = fh.fetch(url) + + case result { + Error(_) -> Nil + Ok(_) -> panic as "expected Error, got Ok from crashing connection" + } + + stop_server(socket) +} + +pub fn fetch_survives_multiple_crashes_test() { + // Verify the caller process stays alive across multiple crashing fetches. + let assert Ok(#(port, socket)) = start_close_server() + let url = "http://localhost:" <> int.to_string(port) <> "/" + + let _result1 = fh.fetch(url) + let _result2 = fh.fetch(url) + let result3 = fh.fetch(url) + + // If we got here, the caller process survived all three crashes. + case result3 { + Error(_) -> Nil + Ok(_) -> panic as "expected Error, got Ok from crashing connection" + } + + stop_server(socket) +} diff --git a/test/feedreader/opml_test.gleam b/test/feedreader/opml_test.gleam new file mode 100644 index 0000000..4d10be1 --- /dev/null +++ b/test/feedreader/opml_test.gleam @@ -0,0 +1,119 @@ +import feedreader/opml +import gleam/list +import gleam/option.{Some} +import gleeunit +import simplifile + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn parse_small_opml_test() { + let body = + " + + Test + + + + + + + " + + let assert Ok(feeds) = opml.parse_opml(body) + assert list.length(feeds) == 2 + + let assert Ok(first) = list.first(feeds) + assert first.feed_url == "https://a.com/rss" + assert first.name == Some("Blog A") + assert first.site_url == Some("https://a.com") + assert first.category == "Tech" +} + +pub fn parse_multiple_categories_test() { + let body = + " + + + + + + + + + + " + + let assert Ok(feeds) = opml.parse_opml(body) + assert list.length(feeds) == 2 + + let categories = list.map(feeds, fn(f) { f.category }) + assert list.contains(categories, "Tech") + assert list.contains(categories, "News") +} + +pub fn empty_category_defaults_to_uncategorized_test() { + let body = + " + + + + + + + " + + let assert Ok(feeds) = opml.parse_opml(body) + let assert Ok(first) = list.first(feeds) + assert first.category == "Uncategorized" +} + +pub fn parse_real_feeds_opml_test() { + let assert Ok(content) = simplifile.read("test/fixtures/feeds.opml") + + let assert Ok(feeds) = opml.parse_opml(content) + assert list.length(feeds) == 77 + + // Check categories + let categories = list.map(feeds, fn(f) { f.category }) + assert list.contains(categories, "All") + assert list.contains(categories, "Austin") + assert list.contains(categories, "Tech") +} + +pub fn unicode_in_titles_preserved_test() { + let body = + " + + + + + + + " + + let assert Ok(feeds) = opml.parse_opml(body) + let assert Ok(first) = list.first(feeds) + assert first.name == Some("Ariadne\u{2019}s Space") +} + +pub fn html_entity_in_title_decoded_test() { + let body = + " + + + + + + + " + + let assert Ok(feeds) = opml.parse_opml(body) + let assert Ok(first) = list.first(feeds) + assert first.name == Some("Ariadne's Space") +} + +pub fn malformed_opml_returns_error_test() { + let assert Error(_) = opml.parse_opml("valid Nil { + gleeunit.main() +} + +pub fn parse_rss_feed_test() { + let body = + " + + Test Feed + + Item 1 + http://example.com/1 + guid-1 + Thu, 12 Jun 2025 14:30:00 GMT + + + Item 2 + http://example.com/2 + guid-2 + + " + + let assert Ok(entries) = rss.parse_feed(body) + assert list.length(entries) == 2 + + let assert Ok(first) = list.first(entries) + assert first.external_id == "guid-1" + assert first.title == "Item 1" + assert first.content_link == "http://example.com/1" +} + +pub fn parse_atom_feed_test() { + let body = + " + + Atom Feed + + Entry 1 + + tag:example.com,2025:1 + 2025-06-12T19:30:00Z + + " + + let assert Ok(entries) = rss.parse_feed(body) + assert list.length(entries) == 1 + + let assert Ok(first) = list.first(entries) + assert first.external_id == "tag:example.com,2025:1" + assert first.title == "Entry 1" + assert first.content_link == "http://example.com/1" +} + +pub fn rss_item_without_guid_uses_link_test() { + let body = + " + + + No GUID + http://example.com/no-guid + + " + + let assert Ok(entries) = rss.parse_feed(body) + let assert Ok(first) = list.first(entries) + assert first.external_id == "http://example.com/no-guid" +} + +pub fn atom_link_multiple_selects_alternate_test() { + let body = + " + + + Multi Link + + + + test-id + + " + + let assert Ok(entries) = rss.parse_feed(body) + let assert Ok(first) = list.first(entries) + assert first.content_link == "http://example.com/alt" +} + +pub fn comments_link_extracted_test() { + let body = + " + + + With Comments + http://example.com/post + guid-1 + http://example.com/post/comments + + " + + let assert Ok(entries) = rss.parse_feed(body) + let assert Ok(first) = list.first(entries) + assert first.comments_link == Some("http://example.com/post/comments") +} + +pub fn no_comments_link_returns_none_test() { + let body = + " + + + No Comments + http://example.com/post + guid-1 + + " + + let assert Ok(entries) = rss.parse_feed(body) + let assert Ok(first) = list.first(entries) + assert first.comments_link == None +} + +pub fn malformed_feed_returns_error_test() { + let assert Error(_) = rss.parse_feed("rss") +} + +pub fn unicode_title_preserved_test() { + let body = + " + + + GitHub\u{2019}s Blog — café + http://example.com/1 + guid-1 + + " + + let assert Ok(entries) = rss.parse_feed(body) + let assert Ok(first) = list.first(entries) + assert first.title == "GitHub\u{2019}s Blog — café" +} diff --git a/test/feedreader/scheduler_test.gleam b/test/feedreader/scheduler_test.gleam new file mode 100644 index 0000000..9a13227 --- /dev/null +++ b/test/feedreader/scheduler_test.gleam @@ -0,0 +1,144 @@ +import birl +import birl/duration +import feedreader/db +import feedreader/scheduler +import gleam/list +import gleam/option.{type Option, None, Some} +import gleeunit +import sqlight + +pub fn main() -> Nil { + gleeunit.main() +} + +fn with_db(f: fn(sqlight.Connection) -> a) -> a { + let assert Ok(conn) = sqlight.open("file::memory:") + let assert Ok(Nil) = db.migrate(conn) + let result = f(conn) + let assert Ok(Nil) = sqlight.close(conn) + result +} + +// ═══════════════════════════════════════════════════════════════ +// feeds_due pure decision function tests +// ═══════════════════════════════════════════════════════════════ + +pub fn never_fetched_feed_is_due_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("A"), + site_url: None, + feed_url: "https://a.com/rss", + category: "Tech", + ) + let now = birl.utc_now() + let due = scheduler.feeds_due([feed], now) + assert list.length(due) == 1 + }) +} + +pub fn recently_fetched_feed_is_not_due_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("A"), + site_url: None, + feed_url: "https://a.com/rss", + category: "Tech", + ) + // Mark as fetched 2 minutes ago + let two_min_ago = + birl.utc_now() + |> birl.subtract(duration.minutes(2)) + |> birl.to_iso8601 + let assert Ok(Nil) = db.log_fetch_success(conn, feed.id, two_min_ago) + let assert Ok(updated) = db.get_feed(conn, feed.id) + let feed_with_ts = result_unwrap(updated) + + let now = birl.utc_now() + let due = scheduler.feeds_due([feed_with_ts], now) + assert due == [] + }) +} + +pub fn old_fetched_feed_is_due_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("A"), + site_url: None, + feed_url: "https://a.com/rss", + category: "Tech", + ) + // Mark as fetched 15 minutes ago (> 10 min threshold) + let fifteen_min_ago = + birl.utc_now() + |> birl.subtract(duration.minutes(15)) + |> birl.to_iso8601 + let assert Ok(Nil) = db.log_fetch_success(conn, feed.id, fifteen_min_ago) + let assert Ok(updated) = db.get_feed(conn, feed.id) + let feed_with_ts = result_unwrap(updated) + + let now = birl.utc_now() + let due = scheduler.feeds_due([feed_with_ts], now) + assert list.length(due) == 1 + }) +} + +pub fn mixed_feeds_filters_correctly_test() { + with_db(fn(conn) { + let assert Ok(_feed_a) = + db.insert_feed( + conn, + name: Some("A"), + site_url: None, + feed_url: "https://a.com/rss", + category: "Tech", + ) + let assert Ok(feed_b) = + db.insert_feed( + conn, + name: Some("B"), + site_url: None, + feed_url: "https://b.com/rss", + category: "Tech", + ) + let assert Ok(feed_c) = + db.insert_feed( + conn, + name: Some("C"), + site_url: None, + feed_url: "https://c.com/rss", + category: "Tech", + ) + + // Feed B: fetched 2 min ago (not due) + let two_min_ago = + birl.utc_now() + |> birl.subtract(duration.minutes(2)) + |> birl.to_iso8601 + let assert Ok(Nil) = db.log_fetch_success(conn, feed_b.id, two_min_ago) + + // Feed C: fetched 20 min ago (due) + let twenty_min_ago = + birl.utc_now() + |> birl.subtract(duration.minutes(20)) + |> birl.to_iso8601 + let assert Ok(Nil) = db.log_fetch_success(conn, feed_c.id, twenty_min_ago) + + let assert Ok(feeds) = db.list_feeds(conn) + let now = birl.utc_now() + let due = scheduler.feeds_due(feeds, now) + // Feed A (never fetched) + Feed C (old) = 2 due; Feed B (recent) = not due + assert list.length(due) == 2 + }) +} + +fn result_unwrap(opt: Option(a)) -> a { + let assert Some(v) = opt + v +} diff --git a/test/feedreader/web/pages_test.gleam b/test/feedreader/web/pages_test.gleam new file mode 100644 index 0000000..b6c432e --- /dev/null +++ b/test/feedreader/web/pages_test.gleam @@ -0,0 +1,166 @@ +import feedreader/db +import feedreader/web/html +import feedreader/web/pages +import gleam/list +import gleam/option.{None, Some} +import gleam/string +import gleeunit +import sqlight + +pub fn main() -> Nil { + gleeunit.main() +} + +fn with_db(f: fn(sqlight.Connection) -> a) -> a { + let assert Ok(conn) = sqlight.open("file::memory:") + let assert Ok(Nil) = db.migrate(conn) + let result = f(conn) + let assert Ok(Nil) = sqlight.close(conn) + result +} + +fn sample_entry(conn: sqlight.Connection) -> db.Entry { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("Test Blog"), + site_url: None, + feed_url: "https://example.com/rss", + category: "Tech", + ) + let assert Ok(Nil) = + db.upsert_entry( + conn, + external_id: "guid-1", + title: Some("Test Entry"), + content_link: Some("https://example.com/1"), + comments_link: None, + published_at: None, + feed_id: feed.id, + ) + let assert Ok(entries) = db.list_unread(conn, limit: 10, offset: 0) + let assert Ok(entry) = list.first(entries) + entry +} + +// ═══════════════════════════════════════════════════════════════ +// Page render tests +// ═══════════════════════════════════════════════════════════════ + +pub fn unread_page_renders_entry_test() { + with_db(fn(conn) { + let entry = sample_entry(conn) + let html = pages.unread_page([entry], 0, False) + assert string.contains(html, "Unread") + assert string.contains(html, "Test Entry") + assert string.contains(html, "entry-") + }) +} + +pub fn starred_page_renders_heading_test() { + with_db(fn(_conn) { + let html = pages.starred_page([], 0, False) + assert string.contains(html, "Starred") + }) +} + +pub fn history_page_renders_entry_test() { + with_db(fn(conn) { + let entry = sample_entry(conn) + let html = pages.history_page([entry], 0, False) + assert string.contains(html, "History") + assert string.contains(html, "Test Entry") + }) +} + +pub fn feeds_page_renders_feed_test() { + with_db(fn(conn) { + let assert Ok(feed) = + db.insert_feed( + conn, + name: Some("My Blog"), + site_url: None, + feed_url: "https://blog.example.com/rss", + category: "Tech", + ) + let html = pages.feeds_page([feed], None) + assert string.contains(html, "Feeds") + assert string.contains(html, "My Blog") + assert string.contains(html, "https://blog.example.com/rss") + }) +} + +pub fn empty_unread_page_test() { + let html = pages.unread_page([], 0, False) + assert string.contains(html, "Nothing left to read") +} + +pub fn empty_starred_page_test() { + let html = pages.starred_page([], 0, False) + assert string.contains(html, "Nothing starred yet") +} + +pub fn dark_theme_hardcoded_test() { + let html = pages.unread_page([], 0, False) + assert string.contains(html, "data-theme=\"dark\"") +} + +pub fn htmx_scripts_loaded_test() { + let html = pages.unread_page([], 0, False) + assert string.contains(html, "htmx.min.js") +} + +pub fn nav_links_present_test() { + let html = pages.unread_page([], 0, False) + assert string.contains(html, "href=\"/\"") + assert string.contains(html, "href=\"/starred\"") + assert string.contains(html, "href=\"/history\"") + assert string.contains(html, "href=\"/feeds\"") +} + +pub fn htmx_toggle_attrs_present_test() { + with_db(fn(conn) { + let entry = sample_entry(conn) + let html = pages.unread_page([entry], 0, False) + assert string.contains(html, "hx-post") + assert string.contains(html, "toggle-read") + assert string.contains(html, "toggle-star") + }) +} + +pub fn feed_add_form_present_test() { + let html = pages.feeds_page([], None) + assert string.contains(html, "Add New Feed") + assert string.contains(html, "feed_url") +} + +pub fn opml_import_form_present_test() { + let html = pages.feeds_page([], None) + assert string.contains(html, "Import OPML") +} + +pub fn load_more_shown_when_has_more_test() { + with_db(fn(conn) { + let entry = sample_entry(conn) + let html = pages.unread_page([entry], 0, True) + assert string.contains(html, "Load More") + }) +} + +pub fn load_more_hidden_when_no_more_test() { + with_db(fn(conn) { + let entry = sample_entry(conn) + let html = pages.unread_page([entry], 0, False) + assert !string.contains(html, "Load More") + }) +} + +pub fn flash_message_shown_test() { + let html_doc = pages.feeds_page([], Some(#(info_flash(), "Feed added"))) + assert string.contains(html_doc, "Feed added") + assert string.contains(html_doc, "alert-info") +} + +fn info_flash() -> html.FlashKind { + html.Info +} diff --git a/test/feedreader/workers/fetch_feed_test.exs b/test/feedreader/workers/fetch_feed_test.exs deleted file mode 100644 index 51ad300..0000000 --- a/test/feedreader/workers/fetch_feed_test.exs +++ /dev/null @@ -1,247 +0,0 @@ -defmodule FeedReader.Workers.FetchFeedTest do - use Feedreader.DataCase, async: false - - alias FeedReader.Workers.FetchFeed - alias FeedReader.Core - - describe "parse_feed/1" do - test "parses RSS feed with pubDate" do - rss = """ - - - - - entry-1 - Test Entry - https://example.com/entry1 - 2024-01-15T10:30:00Z - - - - """ - - {:ok, entries} = FetchFeed.parse_feed(rss) - assert length(entries) == 1 - entry = Enum.at(entries, 0) - assert entry.title == "Test Entry" - assert entry.external_id == "entry-1" - assert entry.content_link == "https://example.com/entry1" - assert entry.published_at != nil - end - - test "handles missing dates gracefully" do - rss = """ - - - - - entry-3 - No Date Entry - https://example.com/entry3 - - - - """ - - {:ok, entries} = FetchFeed.parse_feed(rss) - assert length(entries) == 1 - entry = Enum.at(entries, 0) - assert entry.published_at == nil - end - - test "parses ISO8601 dates" do - rss = """ - - - - - entry-4 - Date Entry - https://example.com/entry4 - 2024-06-15T14:30:00Z - - - - """ - - {:ok, entries} = FetchFeed.parse_feed(rss) - assert length(entries) == 1 - entry = Enum.at(entries, 0) - assert entry.published_at.year == 2024 - assert entry.published_at.month == 6 - assert entry.published_at.day == 15 - end - - test "parses Atom feed dates from when is absent" do - atom = """ - - - Test Atom Feed - - Atom Entry - - https://example.com/atom1 - 2026-03-24T07:00:00.000Z - - - """ - - {:ok, entries} = FetchFeed.parse_feed(atom) - assert length(entries) == 1 - entry = Enum.at(entries, 0) - assert entry.title == "Atom Entry" - assert entry.published_at != nil - assert entry.published_at.year == 2026 - assert entry.published_at.month == 3 - assert entry.published_at.day == 24 - end - - test "converts RFC822 +timezone to UTC correctly" do - rss = """ - - - - - tz-pos - Pos TZ - https://example.com/tz - 15 Jan 2026 10:00:00 +0500 - - - - """ - - {:ok, [entry]} = FetchFeed.parse_feed(rss) - # +0500 means local is 5h ahead of UTC, so 10:00 +0500 = 05:00 UTC - assert entry.published_at.hour == 5 - end - - test "converts RFC822 -timezone to UTC correctly" do - rss = """ - - - - - tz-neg - Neg TZ - https://example.com/tz - 15 Jan 2026 10:00:00 -0500 - - - - """ - - {:ok, [entry]} = FetchFeed.parse_feed(rss) - # -0500 means local is 5h behind UTC, so 10:00 -0500 = 15:00 UTC - assert entry.published_at.hour == 15 - end - - test "prefers over and in RSS" do - rss = """ - - - - - entry-5 - Priority Entry - https://example.com/entry5 - 2024-02-01T08:00:00Z - - - - """ - - {:ok, entries} = FetchFeed.parse_feed(rss) - entry = Enum.at(entries, 0) - assert entry.published_at.month == 2 - end - end - - describe "Core upsert/lookup" do - setup do - feed = - Core.add_feed!(%{ - feed_url: "http://localhost:0/test.xml", - name: "Test Feed" - }) - - %{feed: feed} - end - - test "lookup distinguishes new from existing entries", %{feed: feed} do - # No entry exists yet - lookup should return nil - assert {:ok, nil} = Core.get_entry_by_feed_and_external_id(feed.id, "brand-new") - - # Insert an entry - Core.upsert_from_feed!(%{ - external_id: "brand-new", - title: "Now Exists", - content_link: "https://example.com/brand-new", - feed_id: feed.id - }) - - # Lookup should now find it - assert {:ok, entry} = Core.get_entry_by_feed_and_external_id(feed.id, "brand-new") - assert entry.title == "Now Exists" - end - - test "upsert updates existing entry without creating duplicate", %{feed: feed} do - Core.upsert_from_feed!(%{ - external_id: "dedup-1", - title: "Original", - content_link: "https://example.com/original", - feed_id: feed.id - }) - - Core.upsert_from_feed!(%{ - external_id: "dedup-1", - title: "Updated", - content_link: "https://example.com/updated", - feed_id: feed.id - }) - - entries = Core.list_entries!().results - assert length(entries) == 1 - assert hd(entries).title == "Updated" - end - - test "insert logic only flags truly new entries as inserted", %{feed: feed} do - # Pre-insert one entry - Core.upsert_from_feed!(%{ - external_id: "existing-logic", - title: "Already Here", - content_link: "https://example.com/existing", - feed_id: feed.id - }) - - incoming = [ - %{external_id: "existing-logic", title: "Already Here Updated"}, - %{external_id: "brand-new-logic", title: "Actually New"} - ] - - {inserted, updated} = - Enum.reduce(incoming, {[], []}, fn entry, {ins, upd} -> - attrs = Map.put(entry, :feed_id, feed.id) - - existed? = - case Core.get_entry_by_feed_and_external_id(feed.id, entry.external_id) do - {:ok, record} when record != nil -> true - _ -> false - end - - {:ok, record} = Core.upsert_from_feed(attrs) - - if existed? do - {ins, [record | upd]} - else - {[record | ins], upd} - end - end) - - assert length(inserted) == 1 - assert hd(inserted).external_id == "brand-new-logic" - assert length(updated) == 1 - assert hd(updated).external_id == "existing-logic" - end - end -end diff --git a/test/feedreader/xml_test.gleam b/test/feedreader/xml_test.gleam new file mode 100644 index 0000000..a58935b --- /dev/null +++ b/test/feedreader/xml_test.gleam @@ -0,0 +1,107 @@ +import feedreader/xml +import gleam/option.{type Option, None, Some} +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +pub fn parse_simple_xml_test() { + let assert Ok(node) = xml.parse("Hello") + assert xml.text_of(xml.child_by_tag(node, "name") |> unwrap) == "Hello" +} + +pub fn parse_rss_items_test() { + let assert Ok(node) = + xml.parse( + " + + Item 1http://a.com/1 + Item 2http://a.com/2 + + ", + ) + let items = xml.elements_by_tag(node, "item") + assert list_length(items) == 2 +} + +pub fn parse_attributes_test() { + let assert Ok(node) = + xml.parse("") + let entries = xml.elements_by_tag(node, "entry") + let assert Ok(entry) = list_first(entries) + let links = xml.children_by_tag(entry, "link") + let assert Ok(link) = list_first(links) + assert xml.attr(link, "href") == Some("http://example.com") +} + +pub fn parse_unicode_test() { + let assert Ok(node) = + xml.parse( + "GitHub\u{2019}s Blog — café", + ) + let items = xml.elements_by_tag(node, "item") + let assert Ok(item) = list_first(items) + assert xml.child_text(item, "title") == Some("GitHub\u{2019}s Blog — café") +} + +pub fn parse_numeric_entities_test() { + let assert Ok(node) = + xml.parse("GitHub’s Blog") + let items = xml.elements_by_tag(node, "item") + let assert Ok(item) = list_first(items) + assert xml.child_text(item, "title") == Some("GitHub\u{2019}s Blog") +} + +pub fn parse_wordpress_namespace_test() { + let assert Ok(node) = + xml.parse( + " + + + Test + + + ", + ) + let items = xml.elements_by_tag(node, "item") + let assert Ok(item) = list_first(items) + assert xml.child_text(item, "title") == Some("Test") +} + +pub fn parse_malformed_xml_fails_test() { + let assert Error(_) = xml.parse("rss") +} + +pub fn child_text_missing_returns_none_test() { + let assert Ok(node) = xml.parse("X") + let items = xml.elements_by_tag(node, "item") + let assert Ok(item) = list_first(items) + assert xml.child_text(item, "nonexistent") == None +} + +pub fn child_text_empty_returns_none_test() { + let assert Ok(node) = xml.parse("") + let items = xml.elements_by_tag(node, "item") + let assert Ok(item) = list_first(items) + assert xml.child_text(item, "title") == None +} + +fn unwrap(opt: Option(a)) -> a { + let assert Some(v) = opt + v +} + +fn list_length(list: List(a)) -> Int { + case list { + [] -> 0 + [_, ..rest] -> 1 + list_length(rest) + } +} + +fn list_first(list: List(a)) -> Result(a, Nil) { + case list { + [] -> Error(Nil) + [first, ..] -> Ok(first) + } +} diff --git a/test/feedreader_test.gleam b/test/feedreader_test.gleam new file mode 100644 index 0000000..902c4da --- /dev/null +++ b/test/feedreader_test.gleam @@ -0,0 +1,5 @@ +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} diff --git a/test/feedreader_web/components/time_helpers_test.exs b/test/feedreader_web/components/time_helpers_test.exs deleted file mode 100644 index d75652f..0000000 --- a/test/feedreader_web/components/time_helpers_test.exs +++ /dev/null @@ -1,50 +0,0 @@ -defmodule FeedreaderWeb.TimeHelpersTest do - use ExUnit.Case, async: true - - alias FeedreaderWeb.TimeHelpers - - describe "humanize_date/1" do - test "returns nil for nil input" do - assert TimeHelpers.humanize_date(nil) == nil - end - - test "returns 'just now' for dates less than 1 minute ago" do - recent = DateTime.add(DateTime.utc_now(), -30, :second) - assert TimeHelpers.humanize_date(recent) == "just now" - end - - test "returns minutes ago for dates less than 1 hour ago" do - minutes_ago = DateTime.add(DateTime.utc_now(), -30, :minute) - result = TimeHelpers.humanize_date(minutes_ago) - assert result =~ ~r/^\d+m ago$/ - end - - test "returns hours ago for dates less than 1 day ago" do - hours_ago = DateTime.add(DateTime.utc_now(), -5, :hour) - result = TimeHelpers.humanize_date(hours_ago) - assert result =~ ~r/^\d+h ago$/ - end - - test "returns 'yesterday' for dates less than 2 days ago" do - yesterday = DateTime.add(DateTime.utc_now(), -1, :day) - assert TimeHelpers.humanize_date(yesterday) == "yesterday" - end - - test "returns days ago for dates less than 1 week ago" do - days_ago = DateTime.add(DateTime.utc_now(), -3, :day) - result = TimeHelpers.humanize_date(days_ago) - assert result =~ ~r/^\d+d ago$/ - end - - test "returns weeks ago for dates less than 1 month ago" do - weeks_ago = DateTime.add(DateTime.utc_now(), -14, :day) - result = TimeHelpers.humanize_date(weeks_ago) - assert result =~ ~r/^\d+w ago$/ - end - - test "returns formatted date for dates older than 1 month" do - {:ok, old_date, _} = DateTime.from_iso8601("2024-06-15T12:00:00Z") - assert TimeHelpers.humanize_date(old_date) == "Jun 15, 2024" - end - end -end diff --git a/test/feedreader_web/controllers/error_html_test.exs b/test/feedreader_web/controllers/error_html_test.exs deleted file mode 100644 index 1362b79..0000000 --- a/test/feedreader_web/controllers/error_html_test.exs +++ /dev/null @@ -1,14 +0,0 @@ -defmodule FeedreaderWeb.ErrorHTMLTest do - use FeedreaderWeb.ConnCase, async: true - - # Bring render_to_string/4 for testing custom views - import Phoenix.Template, only: [render_to_string: 4] - - test "renders 404.html" do - assert render_to_string(FeedreaderWeb.ErrorHTML, "404", "html", []) == "Not Found" - end - - test "renders 500.html" do - assert render_to_string(FeedreaderWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" - end -end diff --git a/test/feedreader_web/controllers/error_json_test.exs b/test/feedreader_web/controllers/error_json_test.exs deleted file mode 100644 index 108c156..0000000 --- a/test/feedreader_web/controllers/error_json_test.exs +++ /dev/null @@ -1,12 +0,0 @@ -defmodule FeedreaderWeb.ErrorJSONTest do - use FeedreaderWeb.ConnCase, async: true - - test "renders 404" do - assert FeedreaderWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} - end - - test "renders 500" do - assert FeedreaderWeb.ErrorJSON.render("500.json", %{}) == - %{errors: %{detail: "Internal Server Error"}} - end -end diff --git a/test/feedreader_web/controllers/page_controller_test.exs b/test/feedreader_web/controllers/page_controller_test.exs deleted file mode 100644 index 6d4c4ef..0000000 --- a/test/feedreader_web/controllers/page_controller_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule FeedreaderWeb.PageControllerTest do - use FeedreaderWeb.ConnCase - - test "GET /", %{conn: conn} do - conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Unread" - end -end diff --git a/test/feedreader_web/live/entry_live_test.exs b/test/feedreader_web/live/entry_live_test.exs deleted file mode 100644 index e7e734f..0000000 --- a/test/feedreader_web/live/entry_live_test.exs +++ /dev/null @@ -1,167 +0,0 @@ -defmodule FeedreaderWeb.EntryLiveTest do - use FeedreaderWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias FeedReader.Core - - setup do - feed = Core.add_feed!(%{feed_url: "https://example.com/feed.xml", name: "Test Feed"}) - %{feed: feed} - end - - describe "index" do - test "displays entries", %{conn: conn, feed: feed} do - for i <- 1..5 do - Core.upsert_from_feed!(%{ - external_id: "entry-#{i}", - title: "Entry #{i}", - content_link: "https://example.com/entry#{i}", - feed_id: feed.id - }) - end - - {:ok, _view, html} = live(conn, "/") - - assert html =~ "Entry 1" - assert html =~ "Entry 2" - assert html =~ "Entry 3" - end - - test "displays toggle read button", %{conn: conn, feed: feed} do - entry = - Core.upsert_from_feed!(%{ - external_id: "test-entry", - title: "Test Entry", - content_link: "https://example.com/test", - feed_id: feed.id - }) - - {:ok, view, _html} = live(conn, "/") - - assert has_element?(view, "#read-btn-#{entry.id}") - end - - test "toggle read removes entry from unread list", %{conn: conn, feed: feed} do - entry = - Core.upsert_from_feed!(%{ - external_id: "toggle-read-entry", - title: "Toggle Read Entry", - content_link: "https://example.com/toggle-read", - feed_id: feed.id - }) - - {:ok, view, _html} = live(conn, "/") - - # Initially should show "Mark read" button - assert render(view) =~ "Mark read" - assert render(view) =~ "Toggle Read Entry" - - # Click the button - entry is marked as read and removed from unread list - view - |> element("#read-btn-#{entry.id}") - |> render_click() - - # After click, entry should be removed from the list - refute render(view) =~ "Toggle Read Entry" - end - - test "displays correct page title for unread", %{conn: conn} do - {:ok, _view, html} = live(conn, "/") - assert html =~ "Unread" - end - - test "displays correct page title for starred", %{conn: conn} do - {:ok, _view, html} = live(conn, "/starred") - assert html =~ "Starred" - end - - test "displays correct page title for history", %{conn: conn} do - {:ok, _view, html} = live(conn, "/history") - assert html =~ "History" - end - end - - describe "pagination" do - test "loads first page of entries", %{conn: conn, feed: feed} do - for i <- 1..60 do - Core.upsert_from_feed!(%{ - external_id: "page-test-#{i}", - title: "Entry #{i}", - content_link: "https://example.com/page#{i}", - feed_id: feed.id - }) - end - - {:ok, _view, html} = live(conn, "/") - - # First page should have entries (limited to 50) - # Check that we have entries but not all 60 - assert html =~ "Entry 1" - # Should have load more button since there are more entries - assert html =~ "Load More" - end - - test "load_more button loads additional entries", %{conn: conn, feed: feed} do - for i <- 1..60 do - Core.upsert_from_feed!(%{ - external_id: "loadmore-#{i}", - title: "Entry #{i}", - content_link: "https://example.com/loadmore#{i}", - feed_id: feed.id - }) - end - - {:ok, view, html} = live(conn, "/") - - # Should show Load More button and some entries - assert html =~ "Load More" - assert has_element?(view, "#load-more-btn") - assert html =~ "Entry 1" - - # Click the load more button - view - |> element("#load-more-btn") - |> render_click() - - # After clicking, more entries should be loaded - updated_html = render(view) - assert updated_html =~ "Entry 51" || updated_html =~ "Entry 60" - end - - test "pagination respects filter (unread)", %{conn: conn, feed: feed} do - # Add some read and unread entries - for i <- 1..10 do - Core.upsert_from_feed!(%{ - external_id: "unread-pag-#{i}", - title: "Unread #{i}", - content_link: "https://example.com/unread#{i}", - feed_id: feed.id - }) - end - - for i <- 1..5 do - entry = - Core.upsert_from_feed!(%{ - external_id: "read-pag-#{i}", - title: "Read #{i}", - content_link: "https://example.com/read#{i}", - feed_id: feed.id - }) - - {:ok, _} = Core.update_entry(entry, %{is_read: true}) - end - - {:ok, _view, html} = live(conn, "/") - - # Unread page should only show unread entries - for i <- 1..10 do - assert html =~ "Unread #{i}" - end - - for i <- 1..5 do - refute html =~ "Read #{i}" - end - end - end -end diff --git a/test/feedreader_web/live/feed_live_test.exs b/test/feedreader_web/live/feed_live_test.exs deleted file mode 100644 index 6b4144f..0000000 --- a/test/feedreader_web/live/feed_live_test.exs +++ /dev/null @@ -1,53 +0,0 @@ -defmodule FeedreaderWeb.FeedLiveTest do - use FeedreaderWeb.ConnCase, async: false - - import Phoenix.LiveViewTest - - alias FeedReader.Core - - setup do - feed = Core.add_feed!(%{feed_url: "https://example.com/feed.xml", name: "Test Feed"}) - %{feed: feed} - end - - describe "feeds index" do - test "lists feeds", %{conn: conn, feed: feed} do - {:ok, _view, html} = live(conn, "/feeds") - - assert html =~ feed.name - end - - test "deletes a feed with no entries", %{conn: conn, feed: feed} do - {:ok, view, _html} = live(conn, "/feeds") - - html = - view - |> element("button[phx-click='delete_feed'][phx-value-id='#{feed.id}']") - |> render_click() - - assert html =~ "Feed deleted" - refute html =~ feed.name - end - - test "deletes a feed and cascades to its entries", %{conn: conn, feed: feed} do - for i <- 1..3 do - Core.upsert_from_feed!(%{ - external_id: "entry-#{i}", - title: "Entry #{i}", - content_link: "https://example.com/entry#{i}", - feed_id: feed.id - }) - end - - {:ok, view, _html} = live(conn, "/feeds") - - html = - view - |> element("button[phx-click='delete_feed'][phx-value-id='#{feed.id}']") - |> render_click() - - assert html =~ "Feed deleted" - refute html =~ feed.name - end - end -end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex deleted file mode 100644 index 1b53d1f..0000000 --- a/test/support/conn_case.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule FeedreaderWeb.ConnCase do - @moduledoc """ - This module defines the test case to be used by - tests that require setting up a connection. - - Such tests rely on `Phoenix.ConnTest` and also - import other functionality to make it easier - to build common data structures and query the data layer. - - Finally, if the test case interacts with the database, - we enable the SQL sandbox, so changes done to the database - are reverted at the end of every test. Because this project - uses SQLite, running database tests asynchronously via - `use FeedreaderWeb.ConnCase, async: true` is not recommended. - """ - - use ExUnit.CaseTemplate - - using do - quote do - # The default endpoint for testing - @endpoint FeedreaderWeb.Endpoint - - use FeedreaderWeb, :verified_routes - - # Import conveniences for testing with connections - import Plug.Conn - import Phoenix.ConnTest - import FeedreaderWeb.ConnCase - end - end - - setup tags do - Feedreader.DataCase.setup_sandbox(tags) - {:ok, conn: Phoenix.ConnTest.build_conn()} - end -end diff --git a/test/support/data_case.ex b/test/support/data_case.ex deleted file mode 100644 index fb4ab09..0000000 --- a/test/support/data_case.ex +++ /dev/null @@ -1,58 +0,0 @@ -defmodule Feedreader.DataCase do - @moduledoc """ - This module defines the setup for tests requiring - access to the application's data layer. - - You may define functions here to be used as helpers in - your tests. - - Finally, if the test case interacts with the database, - we enable the SQL sandbox, so changes done to the database - are reverted at the end of every test. If you are using - PostgreSQL, you can even run database tests asynchronously - by setting `use Feedreader.DataCase, async: true`, although - this option is not recommended for other databases. - """ - - use ExUnit.CaseTemplate - - using do - quote do - alias Feedreader.Repo - - import Ecto - import Ecto.Changeset - import Ecto.Query - import Feedreader.DataCase - end - end - - setup tags do - Feedreader.DataCase.setup_sandbox(tags) - :ok - end - - @doc """ - Sets up the sandbox based on the test tags. - """ - def setup_sandbox(tags) do - pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Feedreader.Repo, shared: not tags[:async]) - on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) - end - - @doc """ - A helper that transforms changeset errors into a map of messages. - - assert {:error, changeset} = Accounts.create_user(%{password: "short"}) - assert "password is too short" in errors_on(changeset).password - assert %{password: ["password is too short"]} = errors_on(changeset) - - """ - def errors_on(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> - Regex.replace(~r"%{(\w+)}", message, fn _, key -> - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - end) - end) - end -end diff --git a/test/test_helper.exs b/test/test_helper.exs deleted file mode 100644 index b4bc74b..0000000 --- a/test/test_helper.exs +++ /dev/null @@ -1,2 +0,0 @@ -ExUnit.start() -Ecto.Adapters.SQL.Sandbox.mode(Feedreader.Repo, :manual) From f8da62647e8af3488542d6cdddaedbe46d38a183 Mon Sep 17 00:00:00 2001 From: Josh Kasuboski Date: Mon, 22 Jun 2026 12:35:39 +0100 Subject: [PATCH 3/7] refactor and skill --- .gitignore | 1 + .pi/skills/gleam/SKILL.md | 271 ++++++++++++++ .pi/skills/gleam/scripts/gleam-sig | 66 ++++ PLAN.md | 174 --------- STATUS.md | 124 +------ receipt.html | 553 ----------------------------- src/feedreader/date.gleam | 47 +-- src/feedreader/fetcher.gleam | 64 ++-- src/feedreader/web/router.gleam | 75 ++-- 9 files changed, 447 insertions(+), 928 deletions(-) create mode 100644 .pi/skills/gleam/SKILL.md create mode 100755 .pi/skills/gleam/scripts/gleam-sig delete mode 100644 PLAN.md delete mode 100644 receipt.html diff --git a/.gitignore b/.gitignore index ebffb2b..c7da28b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ hexerldb/ priv/static/css/app.compiled.css screenshots/ *.dump +STATUS.md diff --git a/.pi/skills/gleam/SKILL.md b/.pi/skills/gleam/SKILL.md new file mode 100644 index 0000000..c6ac335 --- /dev/null +++ b/.pi/skills/gleam/SKILL.md @@ -0,0 +1,271 @@ +--- +name: gleam +description: Gleam essentials that prevent the highest-frequency compile errors when writing Gleam from Elixir/other-language intuition — Result vs Option, case guards, custom-type matching, type/function syntax, and the CLI. Use before writing or editing any .gleam file, or when gleam check/format fails. +--- + +# Gleam: The Errors You Will Make + +Gleam's syntax and type rules differ from Elixir, ML, and other languages in a few +specific ways that produce repeating compile errors. Every ❌ below is a real compile +error. Apply these rules before running `gleam check`. + +## 1. `Result`, not `Option` — the #1 time sink + +Gleam has **no `Option` in the prelude.** `Option`/`Some`/`None` only exist after +`import gleam/option`, and the language convention is: *fallible functions return +`Result`; use `Nil` as the error when there is no detail.* So stdlib lookups return +**`Result(a, Nil)`**, never `Option`: + +```gleam +// ❌ "Option is not defined or imported" / "Type mismatch" +fn child(node) -> Option(String) { ... } +let x = list.first(my_list) // this is Result(a, Nil) +let y = list.first(my_list) |> result.unwrap(None) // None is Option — mismatch + +// ✅ Use Result(a, Nil) everywhere a value may be absent +fn child(node) -> Result(String, Nil) { ... } +let assert Ok(first) = list.first(my_list) // or: +let first = list.first(my_list) |> result.unwrap("default") // default is a String +``` + +- `list.first`, `list.find`, `list.key_find`, `dict.get`, `map.get` → all `Result(_, Nil)`. +- **`result.to_option` does not exist.** Don't mix `result.unwrap` with a `Some`/`None` default. +- `Option` is appropriate *only* for optional **function arguments** or **stored struct fields** — and then it must be `import gleam/option` → `option.Option`, `option.Some`, `option.None`. +- When unsure about any stdlib/deps signature, run `gleam-sig ` (see below) — don't guess. + +## 2. No function calls in `case` guards — it's a syntax error + +Guards only allow literals, comparisons (`== != < > <= >=`), `&&`, `||`, and `!`. +**Calling a function is a hard `error: Syntax error … Unsupported expression / Functions cannot be called in clause guards.`** + +```gleam +// ❌ Syntax error +case path { + s if string.ends_with(s, "/") -> "unread" + _ -> "other" +} +case list { + [first, ..rest] if is_day_name(first) -> string.join(rest, " ") + _ -> "?" +} + +// ✅ Do the check in the body, or bind a bool first +case path { + s -> case string.ends_with(s, "/") { + True -> "unread" + False -> "other" + } +} +// guards that ARE fine: only operators + literals +case n { x if x > 0 && x < 10 -> "small" _ -> "big" } +``` + +## 3. Custom-type matching needs all fields — or labels + +Pattern-matching a variant by a single field fails unless the variant was +**defined with labels**. Positional definitions must be matched positionally. + +```gleam +// ❌ "Incorrect arity" / "Unexpected labelled argument" (variant defined positionally) +pub type Node { Element(String, List(Attr), List(Node)) Text(String) } +case n { Element(children:) -> children _ -> [] } + +// ✅ Option A — label the definition, then partial label-match is allowed +pub type Node { Element(tag:, attrs:, children:) Text(content:) } +case n { Element(children:) -> children _ -> [] } +// ✅ Option B — keep positional, match positionally with _ for ignored fields +case n { Element(_, _, children) -> children _ -> [] } +``` + +## 4. Type & identifier syntax — not Elixir, not Erlang + +| ❌ | ✅ | Why | +|---|---|---| +| `fn int.to_string(n) { }` | `fn int_to_string(n) { }` | No `.` in a function name (and don't shadow stdlib names) | +| `e: my_error()` | `e: my_error` | Parens after a type = a function type. Custom types have no parens | +| `-> my/dir/mod.Type` | `import my/dir/mod` → `mod.Type` | Never write a full module path inline; import then alias | +| `fn _unused() { }` | `fn unused() { }` | Top-level fn names can't start with `_` | +| `%% a comment` | `// a comment` | `%%` is Elixir/Erlang; Gleam is `//` | + +## 5. The CLI — what actually exists (Gleam 1.x) + +```text +gleam new NAME --template {erlang|javascript} # NOT 'lib'. Default = erlang +gleam add PKG gleam add --dev PKG +gleam check # type-check only — FAST, use this in the loop +gleam build # full build (slower) +gleam format # idempotent; run it, it won't break working code +gleam test # no --verbose flag exists +gleam run / gleam run -m my_mod +gleam fix # rewrites deprecated code automatically — try it first +gleam deps / deps tree # NOT 'deps versions', 'search', 'fetch', or 'list' +``` + +- **Project names can't start with `_`** (`gleam new _x` → "Please try again with a different project name"). +- To find a package's real exported API, read its source under `build/packages//src/` — or use `gleam-sig`. + +## 6. Tight edit→check loop + +Don't write a whole module then iterate one error at a time. + +1. After **each** file write/edit, run `gleam check` (fast, no artefacts). +2. Read **all** reported errors at once; fix them in one edit pass; re-check. +3. Run `gleam format` separately when done — it's cosmetic and idempotent. +4. `gleam fix` first when porting old code; it handles deprecations for you. + +## 7. Don't guess at library APIs (version drift burns the most) + +Package APIs change across versions and many are easy to misremember. Common +examples of things that look plausible but aren't in the installed version: + +| Looks plausible (❌) | Reality | How to confirm | +|---|---|---| +| `result.to_option` | does not exist | `gleam-sig gleam/result` | +| `decode.first` / `decode.field(0, str)` (2 args) | `decode.field` takes 3; no `first` | `gleam-sig gleam/dynamic/decode field` | +| `sqlight.decode_string` | does not exist | `gleam-sig sqlight` | +| `simplifile.bits_to_string` / `.UTF8` | renamed/removed | `gleam-sig simplifile` | +| `mist.start_http` | it's `mist.serve` / `start` | `gleam-sig mist` | +| `io.debug` | not present on every version | `gleam-sig gleam/io` | + +### `gleam-sig` helper (this skill's `scripts/gleam-sig`) + +Prints the **real** signatures from the Gleam source actually installed in +`build/packages/` (falls back to any stdlib on disk). Put it on `PATH`. + +```bash +gleam-sig gleam/list first # -> pub fn first(list) -> Result(a, Nil) +gleam-sig gleam/dict get +gleam-sig gleam/option # see exactly what Option API exists +gleam-sig sqlight # a dependency module +gleam-sig gleam/dynamic/decode field +``` + +Rule of thumb: **before calling any function you didn't write in the last 5 minutes, +`gleam-sig` it.** It costs one command and removes a whole class of "Unknown module +value / Incorrect arity" round-trips. + +## 8. Flatten nested code — `use`, `result.try`, `list.find_map` + +Nested `case` on `Result` produces a pyramid that gets one indent wider per step. +Gleam's `use` keyword lets you write each fallible step at the **same** indent level, +like an early return. This is the single biggest readability win in the language. + +### Result chain → `use <- result.try` + +```gleam +// ❌ Pyramid: 3 nested case arms, one indent level per step +fn process(url) { + case fetch(url) { + Ok(body) -> case parse(body) { + Ok(data) -> case store(data) { + Ok(_) -> Ok("done") + Error(e) -> Error(e) + } + Error(e) -> Error(e) + } + Error(e) -> Error(e) + } +} + +// ✅ Flat: each step is one line, errors short-circuit automatically +fn process(url) { + use body <- result.try(fetch(url)) + use data <- result.try(parse(body)) + use _ <- result.try(store(data)) + Ok("done") +} +``` + +**The error type must unify across the whole chain.** If step 1 returns +`Result(String, String)` but step 2 returns `Result(String, FileError)`, the +chain won't type-check. Wrap each step with `result.map_error` to normalize: + +```gleam +use content <- result.try( + simplifile.read(path) + |> result.map_error(fn(_) { "Failed to read file" }), +) +``` + +To convert `Option` into `Result` for a `use` chain, use `option.to_result`; +to convert the chain's final `Result` back to `Option`, use `option.from_result`. + +### "Try parsers in order" → `list.find_map` + +When you have a sequence of fallback attempts (parse ISO → parse RFC → parse +custom), don't nest 3+ `case` levels. Put the parsers in a list and let +`find_map` short-circuit on the first success: + +```gleam +// ❌ 3-deep nested case, each arm repeating the same Ok/Error mapping +fn parse(raw) { + case parser_a(raw) { + Ok(v) -> Some(format(v)) + Error(_) -> case parser_b(raw) { + Ok(v) -> Some(format(v)) + Error(_) -> case parser_c(raw) { + Ok(v) -> Some(format(v)) + Error(_) -> None + } + } + } +} + +// ✅ Flat pipeline: list of parsers, find_map short-circuits +fn parse(raw) { + [parser_a, parser_b, parser_c] + |> list.find_map(fn(p) { p(raw) }) + |> result.map(format) + |> option.from_result +} +``` + +`list.find_map` returns `Result(b, Nil)` (not `Option`) in current Gleam — pipe +through `option.from_result` if you need an `Option`. + +### Early-return on a precondition → `bool.guard` + +```gleam +// ❌ Wraps the entire happy path in a case +fn handle(input) { + case input == "" { + True -> "error" + False -> { /* ...the real logic, indented... */ } + } +} + +// ✅ Guard clause: bail early, keep the happy path at base indent +fn handle(input) { + use <- bool.guard(when: input == "", return: "error") + /* ...the real logic, flat... */ +} +``` + +`bool.lazy_guard` is the same but takes a `fn()` for the return value (use when +the return expression is expensive to compute). + +### `let _ = list.map(...)` is a code smell + +```gleam +// ❌ Constructs a list of results, then throws it away. Misleading intent. +let _ = list.map(items, fn(item) { side_effect(item) }) + +// ✅ Explicitly says "run for side effects, discard results" +list.each(items, fn(item) { side_effect(item) }) +``` + +If you see `let _ = list.map(...)` and the function's return value isn't used, +replace it with `list.each`. + +--- + +## Quick pre-flight checklist before `gleam check` + +- [ ] No bare `Option`/`Some`/`None` without `import gleam/option` (prefer `Result(_, Nil)`) +- [ ] No function calls in `case` guards +- [ ] Custom-type patterns match all fields (positional `_` or labeled definition) +- [ ] No `.` in fn names, no `()` after type names, no inline `dir/module.Type` +- [ ] Comments are `//`, not `%%` or `#` +- [ ] Ran `gleam-sig` on any stdlib/deps function whose signature you're unsure of +- [ ] No `case`-on-`Result` pyramids — use `use <- result.try` to flatten +- [ ] No `let _ = list.map(...)` — use `list.each` for side effects diff --git a/.pi/skills/gleam/scripts/gleam-sig b/.pi/skills/gleam/scripts/gleam-sig new file mode 100755 index 0000000..565642a --- /dev/null +++ b/.pi/skills/gleam/scripts/gleam-sig @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# gleam-sig — print the REAL signatures of stdlib/package functions from the +# Gleam source actually installed in this project. Defeats API version-drift: +# never guess at `list.first`, `dict.get`, `result.*`, `sqlight.*`, etc. +# +# Requires a project that has been built/deps downloaded (a `build/packages/` dir). +# Falls back to a global search of any gleam_stdlib source on disk. +# +# Usage: +# gleam-sig [name ...] e.g. gleam-sig gleam/list first +# gleam-sig gleam/result (all pub fns in the module) +# gleam-sig sqlight (a dependency module) +# +# Examples: +# gleam-sig gleam/list first +# gleam-sig gleam/dict get +# gleam-sig gleam/option (see: is Option deprecated? what exists?) +set -euo pipefail + +if [[ $# -lt 1 ]]; then + sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +fi + +MODULE="$1"; shift +NAMES=("$@") + +# Resolve the module file: src/.gleam +REL="${MODULE}.gleam" + +find_module() { + # 1. project deps (preferred — matches installed versions) + local f + f="$(find build/packages -path "*/src/$REL" 2>/dev/null | head -1 || true)" + if [[ -n "$f" ]]; then printf '%s\n' "$f"; return 0; fi + # 2. project src + if [[ -f "src/$REL" ]]; then printf 'src/%s\n' "$REL"; return 0; fi + # 3. global fallback: any gleam_stdlib copy on disk + f="$(find ~ -path "*/gleam_stdlib*/src/$REL" 2>/dev/null | head -1 || true)" + if [[ -n "$f" ]]; then printf '%s\n' "$f"; return 0; fi + # 4. any package source tree on disk + f="$(find ~ -path "*/build/packages/*/src/$REL" 2>/dev/null | head -1 || true)" + if [[ -n "$f" ]]; then printf '%s\n' "$f"; return 0; fi + return 1 +} + +FILE="$(find_module || true)" +if [[ -z "$FILE" ]]; then + echo "gleam-sig: module '$MODULE' not found. Build the project first (gleam build)." >&2 + exit 1 +fi + +echo "# $MODULE ($FILE)" + +if [[ ${#NAMES[@]} -eq 0 ]]; then + # list every public symbol: types, consts, and pub fn signatures + grep -nE '^(pub type|pub const|pub fn|pub opaque type)' "$FILE" || echo " (no pub symbols)" +else + for NAME in "${NAMES[@]}"; do + echo + # match `pub fn name(` OR `fn name(` for internal, and the type def line + grep -nE "^[[:space:]]*(pub )?fn ${NAME}[[:space:]]*(\\(|<-)" "$FILE" \ + || grep -nE "^(pub )?(opaque )?type ${NAME}\\b" "$FILE" \ + || echo " '$NAME' not found in $MODULE" + done +fi diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 50dfa04..0000000 --- a/PLAN.md +++ /dev/null @@ -1,174 +0,0 @@ -# Implementation Plan: FeedReader → Gleam - -> **Architecture & rationale**: see [`SCOUTING_REPORT.md`](./SCOUTING_REPORT.md). This plan is the build order. -> **Testing**: follow [`.pi/skills/gleam-testing/SKILL.md`](.pi/skills/gleam-testing/SKILL.md) — `assert` not bare booleans, `let assert` for Result/Option, **never `process.sleep`** in actor tests (use `send_and_confirm`). -> **Standing rule**: `mise run pre-commit` must pass before committing any step. Each module ships with its test. - ---- - -## Stack (locked, from spikes) - -Wisp + Mist · Lustre SSR + `lustre_pipes` + `hx` · SQLite + Parrot + sqlight · gleam_otp actors · xmerl Erlang FFI (XML) · Tailwind v4 + DaisyUI · target = Erlang/BEAM. - -| dep | role | -|---|---| -| `wisp`, `mist`, `gleam_http` | web framework, server, http types | -| `lustre`, `lustre_pipes`, `hx` | SSR HTML (pipe-style), HTMX attrs + headers | -| `sqlight`, `parrot` | SQLite driver, typed SQL codegen | -| `gleam_otp`, `gleam_erlang` | actors/supervision, BEAM interop | -| `gleam_httpc` | feed fetching | -| `birl`, `gluid`, `envoy` | dates, UUIDv4, env vars | -| `formal`, `glentities` | form parsing, HTML entity encoding | -| `gleeunit`, `birdie`, `http_server_mock` | tests, snapshots, HTTP mocking | -| (FFI) `xmerl_ffi.erl` | XML parsing (no Gleam package) | - ---- - -## Build order - -### Phase 1 — Scaffold & DB foundation - -**1.1 Project init** -- `gleam new feedreader --template=erlang` -- `gleam.toml`: deps above. `target = "erlang"`. -- Add `mise.toml` (gleam/erlang/rebar3 tools; mirror `../yard/mise.toml` task shape: `format`, `check`, `test`, `pre-commit`). -- `.gitignore`: `build/`, `*.db`, `*.db-wal`, `*.db-shm`, `priv/static/*.js`, `priv/static/*.css` (generated). -- Move `SCOUTING_REPORT.md`, `PLAN.md`, `STATUS.md`, `AGENTS.md`, `SPEC.md` into the new project root; keep `feeds.opml` as a test fixture. - -**1.2 SQLite schema + Parrot codegen** (mirror `../yard/yard/src/yard/sql/schema.sql`) -- Write `schema.sql` (source of truth, `CREATE TABLE IF NOT EXISTS …`): `feeds`, `entries` (port from `priv/resource_snapshots/repo/*/` JSON — see SCOUTING_REPORT §1.1). - - `feeds(id TEXT PK, name TEXT, site_url TEXT, feed_url TEXT NOT NULL UNIQUE, category TEXT DEFAULT 'Uncategorized', last_fetched_at TEXT, fetch_error TEXT)` - - `entries(id TEXT PK, created_at TEXT NOT NULL, external_id TEXT NOT NULL, title TEXT, content_link TEXT, comments_link TEXT, published_at TEXT, is_read INTEGER NOT NULL DEFAULT 0, is_starred INTEGER NOT NULL DEFAULT 0, feed_id TEXT NOT NULL REFERENCES feeds(id) ON DELETE CASCADE, UNIQUE(feed_id, external_id))` -- Write `queries.sql` with Parrot queries: `list_feeds`, `get_feed`, `insert_feed`, `delete_feed`, `log_fetch_success`, `log_fetch_error`, `list_entries` (paginated variants: unread/starred/history), `get_entry`, `upsert_entry`, `toggle_read`, `toggle_starred`, `unread_count`. -- `mise run gen` task: `sqlite3 /tmp/gen.db < schema.sql && gleam run -m parrot -- --sqlite /tmp/gen.db && rm /tmp/gen.db` → produces `src/feedreader/sql.gleam` (do not edit). -- **Test**: open `:memory:` db, run schema, assert tables exist (`PRAGMA table_info`), insert/select roundtrip. - -**1.3 `db.gleam`** — typed CRUD wrappers (port pattern from `../yard/yard/src/yard/db.gleam`) -- `Feed`/`Entry` custom types. `param_to_value` / `params_to_values` helpers. `open(path)`, `migrate(conn)`, `new_id()`, `now_ts()` (birl). -- One public fn per query from `sql.gleam`, mapping `Result(_, sqlight.Error)` → `Result(_, Nil)` for ergonomics. -- **Tests**: `with_db` helper (open `:memory:`, migrate, pass conn). Test insert + list + get + delete + upsert idempotency. Use `let assert Ok(...)` for unwraps; `assert` for value checks. - -### Phase 2 — Domain & parsing - -**2.1 `feed.gleam`** — feed business logic -- `add(conn, attrs)` (insert, ignore-on-duplicate-feed_url), `list`, `get(id)`, `delete` (cascade), `log_fetch_success/error`. -- **Tests**: add rejects duplicate `feed_url`; delete cascades to entries (`PRAGMA foreign_keys=ON`). - -**2.2 `entry.gleam`** — entry reads/toggles -- `list_unread/starred/history(conn, limit, offset)` returning `List(Entry)`. -- `get(id)`, `toggle_read(conn, id)`, `toggle_starred(conn, id)` (flip + update), `upsert(conn, attrs)`. -- **Tests**: upsert same `(feed_id, external_id)` twice → count unchanged (dedupe). Toggle flips boolean. Unread excludes read entries. - -**2.3 `xmerl_ffi.erl`** (port verbatim from XML spike) + `xml.gleam` -- FFI exposes `parse/1`, `node_kind/1`, `node_tag/1`, `node_attrs/1`, `node_children/1`, `node_text/1`. **Must `binary_to_list` before `xmerl_scan:string`.** -- `xml.gleam`: `XmlNode` type (`Element`/`Text`), `parse(source)` → `Result(XmlNode, ParseError)`, tree helpers (`elements_by_tag`, `children_by_tag`, `text_of`, `child_text`, `attr`). - -**2.4 `rss.gleam`** — RSS/Atom → `List(EntryAttrs)` -- Port from Elixir `FetchFeed.parse_feed`. Handle `` (RSS) + `` (Atom). Extract title/link/guid|id/comments/pubDate|published|updated. -- **Tests**: synthetic feeds covering RSS, Atom, missing fields, Atom ``, multiple ``s, CDATA. Real fixtures as integration tests. - -**2.5 `date.gleam`** — date parsing -- Port Elixir `parse_rfc822` + ISO8601. Use `birl` for normalization. Handle named TZ abbrevs (EST/PST/…). -- **Tests**: table of RFC822 + ISO8601 inputs → expected birl values. Edge cases: `nil` input, `""`, no-TZ, `Z`, `+0800`, `EST`. - -**2.6 `opml.gleam`** — OPML import -- Port `FeedReader.Core.import_opml`. Walk nested ``, group by parent `text` attr, extract `xmlUrl`/`htmlUrl`/`title`. Returns `{success, error}` counts. -- **Tests**: parse `fixtures/feeds.opml` → 77 feeds across 3 categories; duplicate import is idempotent (feeds already exist → ignored). - -### Phase 3 — Background workers - -**3.1 `http.gleam`** — feed fetcher -- `fetch(url)` via `gleam_httpc` (30s timeout). Return `Result(String, FetchError)` (body on 200, error otherwise). -- **Tests**: `http_server_mock` stub returning a canned RSS body; stub returning 404; stub timing out. - -**3.2 `fetcher.gleam`** — gleam_otp actor -- Takes `{db_conn, feed_id}`, fetches → parses (rss.gleam) → upserts each entry → logs success/error. -- **Actor message type**: `Fetch(feed_id)`, `Subscribe(Subject(Event))`, `Stop`. -- Emits `EntryUpserted(entry)` events to subscribers (for future real-time / unread-count). -- **Tests** (skill Pattern 1 `send_and_confirm`): start actor with `:memory:` db + seeded feed; `Subscribe(test_subj)`; `Fetch(feed_id)`; `process.receive` on test subj → assert entry persisted + `last_fetched_at` set. No `process.sleep`. Dead-subscriber resilience test. - -**3.3 `scheduler.gleam`** — gleam_otp actor (cron tick) -- Every N minutes: list feeds, enqueue `Fetch(feed_id)` for each due (last fetched > 10min ago or nil). Stagger 1–5min via `process.send_after`. -- **Tests** (Pattern 1 + listener): inject a fake "now" / controllable clock OR test the pure decision function `feeds_due(feeds, now)` separately from the actor loop. Actor test: `Tick` → assert fetcher received N `Fetch` messages (use a test fetcher Subject that records). - -### Phase 4 — Web layer - -**4.1 `web/server.gleam`** — Mist + Wisp bootstrap -- `wisp_mist.handler(handle_request, secret_key_base)` → `mist.new |> mist.port(3000) |> mist.start`. Hold db_conn in app state. -- `secret_key_base` is required by the Wisp API signature but unused (no sessions/cookies — auth is external). Pass any sufficiently long random string from env, or a constant. -- **No test** (integration-tested via handlers). - -**4.2 `web/router.gleam`** — route table -- Routes (full pages = HTML doc; fragments = HTML fragment): - - `GET /` → unread page · `GET /starred` · `GET /history` · `GET /feeds` - - `POST /entry/:id/toggle-read` · `POST /entry/:id/toggle-star` (fragments) - - `GET /?after=` → load-more fragment - - `POST /feeds` (add) · `POST /feeds/import` (OPML multipart) · `DELETE /feeds/:id` (fragments) - - `GET /static/*` (css/js/htmx.min.js) - - `GET /unread-count` (optional poll fragment) -- **Tests**: handler-level — build `wisp.Request` via `wisp.testing` helpers, assert response status + `string.contains(body, "...")`. - -**4.3 `web/html.gleam`** — shared Lustre element builders (pipe-style) -- `layout(title, current_path, inner)`, `nav(current_path)`, `entry_card(entry)`, `feed_card(feed)`, `flash(kind, msg)`, `hx_attr` helper (1-line adapter so `|> hx_attr(hx.post(url: …))` pipes bare). -- **Tests** (birdie snapshots): render `entry_card(sample)` → snapshot the HTML. Toggle re-renders with flipped classes. - -**4.4 `web/pages.gleam`** — full-page render -- `unread_page(entries, offset, has_more)`, `starred_page`, `history_page`, `feeds_page(feeds, flash)`. Each returns `element.Element(msg)`; router calls `element.to_document_string`. -- Port Elixir `EntryLive`/`FeedLive` markup via lustre_pipes. Preserve DaisyUI classes from `assets/css/app.css`. -- Relative-date helper (`humanize_date`) ported from `time_helpers.ex`. -- **Tests**: birdie snapshot each page with fixture data. - -**4.5 `web/fragments.gleam`** — HTMX partial responses -- `entry_card_fragment(entry)` (post-toggle), `load_more_fragment(entries, next_offset)`, `feed_row_fragment(feed)`, `toast_fragment(msg)`. -- Return via `wisp.html_body(element.to_string(...))`. Use `hx_header.trigger`/`hx_header.redirect` where needed (e.g. after OPML import → redirect to /feeds with flash). -- **Tests**: birdie snapshots; assert response headers (`hx-trigger`) via `wisp.testing`. - -### Phase 5 — Theming & assets (**dark mode only**) - -**5.1 Port CSS (simplified)** — start from `assets/css/app.css` but **drop the light theme entirely**. Keep only the DaisyUI `dark` theme plugin block (the oklch colors). Configure DaisyUI to use dark as the only/default theme: - - Remove the second `@plugin "../vendor/daisyui-theme" { name: "light"; ... }` block. - - In the dark theme block, set `default: true` (was `false`) so it applies without a `data-theme` attribute. - - Result: `@plugin "../vendor/daisyui" { themes: false; }` + a single dark theme with `default: true`. - - Hardcode `` in the layout for belt-and-suspenders (no JS needed). -**5.2 Vendor HTMX** → `priv/static/js/htmx.min.js` (v2.0.4). -**5.3 No theme switcher** — do **not** port `Layouts.theme_toggle/1`, the `phx:set-theme` JS, or the localStorage/`data-theme` inline script from `root.html.heex`. Dark mode is the only mode; no client-side theme code at all. -**5.4 Copy other vendors** — `assets/vendor/daisyui.js`, `daisyui-theme.js`, `heroicons.js` → `priv/static/vendor/`. -**5.5 Build pipeline**: add a `mise` task `assets:build` running Tailwind CLI (or `glailglind`) → `priv/static/css/app.css`. Run in `pre-commit` + CI. - -### Phase 6 — App wiring & ops - -**6.1 `feedreader.gleam`** — supervision tree (mirror Elixir `Application`) -- `supervisor.new(strategy: OneForOne)` with children: db connection actor, scheduler actor, fetcher pool actor(s), mist HTTP server. Read config from env via `envoy`. -**6.2 Docker** — `Dockerfile` (multi-stage: build with rebar3/gleam, copy beam artifacts + priv/static). `docker-compose.yaml` (volume for `*.db`). -**6.3 CI** — `.github/workflows/ci.yml`: `gleam format --check`, `gleam check`, `gleam test`, asset build. Replace the old `docker.yml`. -**6.4 `mark-read.sh`** — port to call the Gleam app (or drop if the bulk-mark-read is better as an in-app admin route). - -> **No auth phase**: the app is intentionally open. Auth is the deployer's responsibility (reverse proxy / network boundary in front of the app), matching the current Elixir deployment where the `ash_authentication_live_session` block is empty and routes are unprotected. Do not build login/logout/session-cookie code. - ---- - -## Testing strategy summary - -| Layer | Tool | Pattern | -|---|---|---| -| DB (`db.gleam`, `feed.gleam`, `entry.gleam`) | gleeunit + `:memory:` sqlite | `with_db` helper; `let assert Ok(...)`; `assert` on values | -| Parsers (`xml`, `rss`, `opml`, `date`) | gleeunit + birdie snapshots | snapshot parsed trees; assert on extracted fields; real fixtures in `test/fixtures/` | -| HTTP fetch | `http_server_mock` | stubbed responses (200/404/timeout) | -| Actors (`fetcher`, `scheduler`) | gleeunit + `process.receive` | **send_and_confirm** (skill Pattern 1); test listener for side effects (Pattern 2); no `process.sleep` | -| Web handlers | `wisp.testing` request builders | assert status + `string.contains(body)`; birdie snapshot rendered HTML | -| Snapshots | `birdie` | accept first run with `gleam test -- -b` then review | - -**Rule**: every module in `src/` has a matching `test/_test.gleam`. Tests run in `mise run pre-commit` (fast `:memory:` db, mocked HTTP — no network). - ---- - -## Definition of done - -- [ ] `mise run pre-commit` green (format + check + test) -- [ ] `gleam run` starts server on :3000; browser loads `/` showing unread entries -- [ ] Add a feed via `/feeds` → scheduler fetches within 10min → entries appear -- [ ] Toggle read/star updates in place (HTMX swap, no full refresh) -- [ ] OPML import (`fixtures/feeds.opml`) → 77 feeds imported, categorized -- [ ] Dark/light/system theme toggle works -- [ ] Docker image builds + runs -- [ ] CI green on push diff --git a/STATUS.md b/STATUS.md index 3e647d3..fa29659 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,115 +1,27 @@ # Status ## Current Goal -Rewrite the Elixir/Phoenix feed reader as a Gleam application. **Complete — feature parity achieved and validated.** The Gleam project is now in this repo root (replacing the Elixir implementation). 2963 LOC across 17 source files, 80 unit tests, 76 E2E scenarios validated against a live server, `mise run pre-commit` green. UI fully styled with Tailwind + DaisyUI dark theme. Visual parity with old Elixir app confirmed via screenshots — feed names in metadata, Heroicons SVG buttons, card layout all match. - -**Validation receipt**: [`receipt.html`](./receipt.html) — open in browser for full walkthrough + test results. - -The original Elixir implementation has been removed. The `../feedreader_gleam/` directory is preserved as a reference copy. - -## Documents -- [`SCOUTING_REPORT.md`](./SCOUTING_REPORT.md) — architecture, feature map, package survey, spike results -- [`PLAN.md`](./PLAN.md) — condensed 7-phase implementation build order -- [`E2E.md`](./E2E.md) — 76 BDD scenarios across 11 feature areas -- [`STATUS.md`](./STATUS.md) — this file -- [`receipt.html`](./receipt.html) — standalone validation receipt (open in browser) -- [`../feedreader_gleam/`](../feedreader_gleam/) — reference copy of the Gleam project (pre-merge) +Fix deeply nested code in the real codebase, then distill the learnings into the `gleam` skill. ## Steps & Progress -- [x] **Phase 0**: Scouting & architecture -- [x] **PLAN.md** + **E2E.md** written -- [x] **Phase 1**: Scaffold + SQLite schema + Parrot codegen + db.gleam — 17 tests -- [x] **Phase 2**: Domain & parsers (xml/xmerl FFI, rss, date, opml) — 33 tests -- [x] **Phase 3**: Background workers (http, fetcher, scheduler) — 15 tests -- [x] **Phase 4**: Web layer (html, pages, fragments, router, server) — 15 tests -- [x] **Phase 5**: Theming & assets — Tailwind v4 + DaisyUI dark-only, compiled via glailglind (no Node.js), HTMX vendored -- [x] **Phase 6**: App wiring (scheduler+fetcher actors wired), Docker, CI, mark-read.sh -- [x] **Runtime smoke test passed**: server starts, pages render, feeds add, dark theme, nav, 404 -- [x] **E2E validation**: 76/76 BDD scenarios validated against live server -- [x] **Receipt generated**: `receipt.html` — standalone HTML artifact with all results + project walkthrough -- [x] **Elixir implementation removed; Gleam project moved to repo root** -- [x] **CSS fix**: Fixed `@source` paths in `priv/static/css/app.css` — Tailwind utilities and DaisyUI components now compile correctly -- [x] **Visual comparison with old Elixir app**: Identified two styling gaps to fix -- [x] **Styling parity fix**: Code complete (feed name + icons), tests pass, screenshots visually confirmed — matches old app's metadata line + button icons -- [x] **Crash resilience fix**: gleam_httpc FFI crashes on unknown errors (socket_closed_remotely) and takes down the whole app via linked actors. Fixed with HTTP worker isolation + supervision tree + 2 tests -- [x] **Mark-read disappears from unread page**: Toggle handlers now context-aware via Referer header. Marking as read on unread page returns empty fragment (card removed). Un-starring on starred page returns empty fragment. -- [x] **Load More fix**: Fixed HTMX pagination — server returns entry fragments (not full page) for HTMX requests, OOB swap updates/removes Load More button, corrected `hx-target` from `closest #entries` to `#entries` (button is a sibling, not descendant) -- [x] **Full feature audit**: All 21 features verified (unread/starred/history/feeds pages, add feed, delete feed, OPML import, toggle read/star with context-aware removal, load more, empty states, 404, CSS/JS assets) -- [x] **Button color fix**: Changed starred/read button active states from DaisyUI semantic colors (`text-warning`/`text-success`) to raw Tailwind palette matching old app (`text-yellow-400`/`text-green-400` with matching bg/border opacity) -- [x] **Full color audit**: Compared all color classes between old Elixir templates and new Gleam views. Fixed feed card detail text (`text-gray-400`/`text-gray-500` replacing `text-base-content/40`/`text-base-content/60`) and empty feeds state (`text-gray-500`). All entry card, nav, form, and button colors now match old app exactly. -- [x] **Favicon**: Added inline SVG star favicon (yellow `#FEC700` Heroicon star) to ``, matching old Elixir app. -- [x] **Font/hover/structural parity**: Removed all self-added CSS classes not present in old app — header `sticky top-0 z-50 backdrop-blur-md`, nav links `btn btn-ghost btn-sm hover:bg-base-300 hover:text-primary transition-all`, logo `hover:bg-base-300 transition-colors`, entry card `card transition-all hover:border-primary/30 hover:shadow-lg`, feed card `transition-all hover:shadow-lg hover:border-primary/30`, form buttons `hover:scale-105 transition-transform`, input `focus:border-primary transition-colors`, delete button `hover:bg-error/10 transition-all`. Added `min-h-screen bg-base-100` wrapper div matching old app layout. Nav links are now plain `` inside DaisyUI `menu-horizontal` (DaisyUI handles hover/active styling). -- [x] **Dockerfile + CI fix**: Dockerfile runtime stage now explicitly copies `priv/` to CWD (erlang-shipment nests it under `feedreader/priv/`). Fixed CMD to `./entrypoint.sh run` (removed unnecessary `-m feedreader` args that entrypoint.sh already handles). CI verified correct for Gleam project. -- [x] **Docker build + run verified**: Fixed 3 Docker issues: (1) missing C compiler for esqlite NIF — added `build-base sqlite-dev` to builder; (2) OTP version mismatch — Gleam image uses OTP 28, runtime was OTP 26, beam files incompatible; (3) bind address — mist defaults to `127.0.0.1` (localhost only), Docker needs `0.0.0.0`. CI updated to OTP 28. - -## Architecture Decisions (locked) -- **Backend**: Wisp + Mist, Erlang/BEAM -- **Frontend**: Lustre SSR + lustre_pipes + HTMX (typed via hx) -- **Storage**: SQLite + Parrot + sqlight -- **Background**: gleam_otp actors (scheduler 3-min tick + fetcher pool) -- **Styling**: Tailwind v4 + DaisyUI dark-only, compiled via **glailglind** (Tailwind CLI binary, no Node.js) -- **Auth**: none (external) -- **XML**: xmerl via Erlang FFI - -## Resolved Dependency Versions -| Package | Version | -|---|---| -| gleam_stdlib | 1.0.3 | -| gleam_erlang | 1.3.0 | -| gleam_otp | 1.2.0 | -| wisp | 2.2.2 | -| mist | 6.0.3 | -| lustre | 5.7.0 | -| lustre_pipes | 0.3.0 | -| hx | 3.0.0 | -| sqlight | 1.1.0 | -| parrot | 1.2.12 | -| birl | 1.9.0 | -| glailglind | 2.3.0 (dev) | -| glinter | 2.x (dev) | +- [x] Survey the codebase for nesting depth (max-indent scan across all `src/**/*.gleam`) +- [x] Identify concrete pyramids of doom: `router.gleam` import_opml_handler (11 levels), `fetcher.gleam` do_process (8 levels), `date.gleam` parse_date (7 levels) +- [x] Verify stdlib APIs against Gleam 1.16.0: `bool.guard`, `bool.lazy_guard`, `result.try`, `result.try_recover`, `option.from_result`, `option.to_result`, `option.then`, `list.find_map` (returns `Result(b, Nil)`) +- [x] Compile-test the refactoring idioms in a scratch project — all confirmed working +- [x] **Fix `date.gleam::parse_date`**: 4-deep "try parsers in order" chain → flat list + `list.find_map` pipeline (14→4 spaces max indent) +- [x] **Fix `fetcher.gleam::do_process`**: 3-deep nested case → flat `use <- result.try` chain + single error handler (16→10 spaces) +- [x] **Fix `router.gleam::import_opml_handler`**: 4-deep nested case → flat `result.try` chain, `let _ = list.map` → `list.each` (22→12 spaces) +- [x] Full test suite passes: **82 tests, 0 failures** (behavior preserved) +- [x] Write "Flattening nested code" section (#8) into `SKILL.md` with 4 before/after patterns +- [x] Add nesting-related items to the pre-flight checklist +- [x] Verify skill has no session-specific references (grep clean) ## Unknowns -- (all resolved) - -## DB Migration -The old Elixir/Ash schema and the new Gleam schema are **identical** — same tables, same columns, same types (SQLite `INTEGER` for booleans, `TEXT` for everything else). Data can be transferred directly via `ATTACH DATABASE` + `INSERT OR IGNORE`. No transformation needed. One caveat: old `feeds.category` could be NULL (no `NOT NULL` constraint), new schema has `DEFAULT 'Uncategorized'`. Use `COALESCE(category, 'Uncategorized')` if transferring from old DB. - -**The app can load directly from the old DB file with no migration needed.** Just set `DATABASE_PATH=/path/to/old_feedreader_dev.db`. The `db.migrate()` function uses `CREATE TABLE IF NOT EXISTS` (no-op if tables exist). Old DB will have extra tables (`users`, `tokens`, `oban_jobs`, `schema_migrations`) from Ash/Oban — those are harmless, the app only queries `feeds` and `entries`. One non-issue: the old `entries` table lacks `ON DELETE CASCADE` on the FK (SQLite can't add it after creation), but the delete handler doesn't rely on cascade. +- (none) ## Discovered Issues -- Parrot: no `ParamNullable` — nullable stored as `""`, converted via helpers -- Parrot: each `:many` query generates its own type — needed separate converters -- gleam_otp: `actor.new(state)` + `on_message` + `start` for simple; `new_with_initialiser` + `returning` + `initialised` for init-based -- birl: `birl.subtract(time, duration.minutes(n))` via `birl/duration` -- FFI files: must be in `src/` root, module name matches filename -- sqlight: exec queries use `decode.success(Nil)` as decoder -- rebar3: must `eval "$(mise activate bash)"` before gleam commands -- lustre_pipes: conditional children use `element.none()`; childless elements use `lp.empty()` -- wisp v2: `handle_head` takes `fn(Request)` (use `use req <-`); `require_form` takes `fn(FormData)`; `get_query` returns `List(#(String, String))` -- Erlang `integer_to_list/1` returns charlist — use `gleam/int.to_string` -- glailglind: Tailwind CLI binary via `gleam run -m tailwind/install`; configure in `gleam.toml` `[tools.tailwind]` with args -- Port 3000 must be free before starting server (old processes hold it) -- **Schema single source of truth**: `src/feedreader/sql/schema.sql` is the canonical file. `db.migrate()` reads from `priv/schema.sql` at runtime (Gleam includes `priv/` in releases). `mise run gen` copies schema.sql to `priv/` after Parrot codegen — one file to edit, both codegen and runtime stay in sync. -- **Tailwind CSS `@source` paths must use `**/*.gleam` globs**: After moving the project to the repo root, the `@source` paths in `priv/static/css/app.css` broke because they resolved relative to the CSS file location. The correct path from `priv/static/css/` to `src/` is `../../../src/`. The fix: `@source "../../../src/feedreader/web/**/*.gleam";` and `@source "../../../src/**/*.gleam";`. Without this, Tailwind v4 compiles zero utility classes and zero DaisyUI components (16KB vs 51KB output). -- **Styling parity with old Elixir app**: Two gaps identified from screenshot comparison, **both now fixed in code**: - 1. **Feed name in metadata line** — OLD: `Hacker News | Apr 12, 2026`. NEW: just `Apr 12, 2026`. **FIXED**: SQL queries updated with `JOIN feeds f ON f.id = e.feed_id`, Parrot regenerated (types now include `feed_name`/`feed_site_url`/`feed_feed_url`), `Entry` type extended with `feed_name: Option(String)`, all 4 `row_to_entry_*` converters updated to call `compute_feed_name()` (mirrors old app's `feed_display_name/1`: name → site_url root domain → feed_url root domain via `root_domain()` helper), `html.gleam` `entry_card` now renders `metadata` as `"Feed Name | Date"` matching old app's conditional logic. - 2. **Button icons** — OLD: `<.icon name="hero-star"/>` + `<.icon name="hero-check-circle"/>`. NEW: plain text. **FIXED**: Added `star_icon()` and `check_circle_icon()` functions in `html.gleam` using `element.namespaced()` with inline Heroicons SVG paths. Buttons now render `lp.children([icon, element.text(label)])`. - -- **gleam_stdlib 1.0 has no `list.at/2`**: Use `list.reverse` + pattern match `[tld, second, ..]` instead. -- **gleam_httpc FFI crashes on unknown error shapes**: `gleam_httpc_ffi:normalise_error/1` only maps `failed_connect` and `timeout`; everything else (e.g. `socket_closed_remotely`) calls `erlang:error({unexpected_httpc_error, ...})` which throws an uncatchable exception in the calling process. **FIX**: `http.fetch` now runs the HTTP request in an isolated, unlinked, monitored process via `process.spawn_unlinked` + `process.monitor`. If the worker crashes, we catch the `MonitorDown` and return `Error(...)`. -- **No supervision tree = actor crash kills everything**: `server.gleam` started actors with `actor.start` (which uses `spawn_link`), linking them directly to the main process. When the fetcher crashed, the EXIT signal propagated to main, taking down the whole app. **FIX**: Added `gleam/otp/static_supervisor` with `OneForOne` strategy and restart tolerance (10/60s). Scheduler now runs under the supervisor. -- **gleam_erlang `process.select` vs `select_map`**: `process.select(selector, for: subject)` is for same-typed messages; use `process.select_map(selector, subject, mapping_fn)` to transform message types for the selector. -- **xmerl `expected_element_start_tag` / `unexpected_end` errors are benign stderr noise**: xmerl_scan writes to the Erlang error logger during parsing of malformed feeds, but returns `{error, ...}` which our FFI catches and converts to `ParseError`. No crash, no data loss. -- **Erlang FFI tuple shapes must match Gleam external type**: `{ok, A, B}` (3-tuple) does NOT match `Result(#(a, b), c)` (Result wrapping 2-tuple). Return `{ok, {A, B}}` instead. -- **Gleam guards cannot call functions**: `case x { _ if string.ends_with(x, "/") -> }` is illegal. Compute the boolean first, then match on it: `let is_x = string.ends_with(x, "/"); case is_x { True -> ... }`. -- **Context-aware HTMX removal via Referer header**: Toggle endpoints (`/entry/:id/toggle-read`, `/entry/:id/toggle-star`) check the `Referer` header to determine the current page view. On filtered pages (unread/starred), entries that no longer match return an empty fragment response, causing HTMX to remove the card from the DOM. Mirrors the old Elixir app's `stream_delete` behavior. -- **`request.get_header` returns `Result(String, Nil)`, not `Option(String)`**: Use `result.unwrap(request.get_header(req, "referer"), "")` to extract headers from a `wisp.Request`. -- **HTMX `closest` selector traverses ancestors, not siblings**: `hx-target="closest #entries"` fails if the button is a sibling of `#entries`, not a child. Use `hx-target="#entries"` (plain CSS selector) instead. -- **HTMX load-more requires fragment response, not full page**: When `?after=N` is requested via HTMX (`HX-Request: true` header), the server must return only entry card fragments + an OOB-updated Load More button. Returning a full `` document breaks the `beforeend` swap. Use `hx-swap-oob="true"` on the `#load-more-container` to replace the old button. -- **hx v3.0 selector API**: `hx.target()` takes `hx.Selector("#id")`, not `hx.Closest("#id")` or `hx.Element("#id")`. -- **DaisyUI semantic colors vs raw Tailwind palette**: DaisyUI `text-warning`/`text-success` map to muted theme palette tones that look nothing like the old app's vivid `text-yellow-400`/`text-green-400`. When matching an existing app's look, use raw Tailwind palette classes directly, not DaisyUI semantic aliases. -- **erlang-shipment nests priv/ under application dir**: `gleam export erlang-shipment` puts priv files at `feedreader/priv/`, not `priv/`. Runtime code reading relative paths like `priv/schema.sql` needs `priv/` at CWD. Fix: Dockerfile runtime stage explicitly copies `COPY --from=builder /app/priv ./priv`. -- **entrypoint.sh already handles module invocation**: The generated `entrypoint.sh run` already calls `-eval "feedreader@@main:run(feedreader)". Passing extra `-m feedreader` args via CMD is redundant and could cause issues. -- **Docker OTP version must match builder**: `ghcr.io/gleam-lang/gleam:v1.16.0-erlang-alpine` ships with OTP 28. Runtime image must also be OTP 28 (was 26) — beam bytecode is not forward-compatible. -- **Docker needs C compiler for esqlite NIF**: The Alpine builder image doesn't include `cc`. Add `apk add --no-cache build-base sqlite-dev` before `gleam export erlang-shipment`. Runtime stage needs `sqlite-libs` for the shared library. -- **mist defaults to localhost binding**: `mist.start` binds to `127.0.0.1` by default. Docker containers need `mist.bind("0.0.0.0")` to accept forwarded traffic. +- **Error-type unification is the gotcha with `use <- result.try` chains.** Each step must share the same error type. `simplifile.read` returns `Result(String, FileError)` — it won't chain with `Result(_, String)` unless wrapped in `result.map_error`. This was the main hurdle during the router refactor. +- **`list.find_map` returns `Result(b, Nil)`, not `Option`**, in current Gleam — pipe through `option.from_result` if you need Option. This differs from older versions. +- **`let _ = list.map(xs, fn(x) { side_effect(x) })`** is a recurring anti-pattern — it constructs and discards a list. `list.each` is the correct idiom. +- `db.gleam::toggle_read`/`toggle_starred` still have mild duplication (`Ok(Some(_))` pattern + identical inner block) but aren't deeply nested — left alone to avoid scope creep. +- `wisp.FormData` is the form type (not `wisp.Form`); `option.to_result` is the Option→Result bridge (not `result.from_option`, which doesn't exist). diff --git a/receipt.html b/receipt.html deleted file mode 100644 index 9f48951..0000000 --- a/receipt.html +++ /dev/null @@ -1,553 +0,0 @@ - - - - - -FeedReader Gleam Rewrite — Validation Receipt - - - - -

FeedReader — Gleam Rewrite Validation Receipt

-

Elixir/Phoenix → Gleam/Erlang complete rewrite. This receipt documents the full validation of feature parity.

-

Generated: 2025-06-19 · Gleam 1.16.0 · Erlang/OTP 28

- -
-
80
Unit Tests
-
76
E2E Scenarios
-
17
Source Files
-
2,963
Lines of Code
-
0
Compile Errors
-
0
Compile Warnings
-
- -
- - -
- -

Overview

-

The Elixir/Phoenix feed reader has been fully rewritten in Gleam targeting Erlang/BEAM. The rewrite preserves 100% of user-facing behavior while simplifying the architecture: Phoenix LiveView → server-rendered HTML + HTMX, Ash ORM → plain Parrot-typed SQL, Oban → gleam_otp actors, AshAuthentication → dropped (external), dual-theme → dark-only.

-
    -
  • SQLite + Parrot for storage (mandated by AGENTS.md)
  • -
  • Server-rendered HTML via Lustre element API + lustre_pipes (pipe-style, required)
  • -
  • HTMX for toggle interactions (no full page refresh on read/star)
  • -
  • Background scheduler + fetcher actors (10-min fetch throttle, staggered)
  • -
  • xmerl Erlang FFI for XML parsing (handles WordPress namespaces, Unicode, entities)
  • -
  • DaisyUI dark mode only — no light theme, no theme JS, hardcoded data-theme="dark"
  • -
  • No auth — the app is open (auth handled by reverse proxy in front)
  • -
  • Tailwind CSS compiled via glailglind (Tailwind CLI binary, no Node.js required)
  • -
- -

Technology Stack

- - - - - - - - - - - - - - -
LayerElixir (original)Gleam (rewrite)
Language/RuntimeElixir → Erlang/BEAMGleam → Erlang/BEAM
Web frameworkPhoenix LiveViewWisp + Mist
HTML renderingHEEx templatesLustre element API (SSR) + lustre_pipes
Client interactivityPhoenix JS + WebSocketHTMX (typed via hx package)
ORM/DBAsh Framework + ash_sqliteParrot (codegen) + sqlight
Background jobsOban (cron + workers)gleam_otp actors (scheduler + fetcher)
XML parsingSweetXml (xmerl-backed)xmerl via Erlang FFI (~40 lines)
HTTP clientReqgleam_httpc
AuthAshAuthentication (magic link)None (external reverse proxy)
CSSTailwind v4 + DaisyUI (2 themes)Tailwind v4 + DaisyUI (dark only)
CSS buildesbuild via Phoenixglailglind (Tailwind CLI binary, no Node)
TestingExUnitgleeunit + http_server_mock
- -

Module Walkthrough

- - - - - - - - - - - - - - - - - - - -
ModuleLOCPurpose
db.gleam593Typed CRUD: Feed/Entry types, SQLite open/migrate, all queries
sql.gleam515Parrot-generated typed SQL (DO NOT EDIT)
web/pages.gleam320Full-page render: unread/starred/history/feeds + forms
web/html.gleam283Lustre element builders: layout, nav, entry_card, feed_card
web/router.gleam226All Wisp routes: pages, HTMX toggles, feed CRUD, OPML
xml.gleam161XML tree type + helpers (elements_by_tag, child_text, attr)
rss.gleam140RSS+Atom → EntryAttrs (guid fallback, Atom link selection)
fetcher.gleam128process_feed() synchronous core + actor wrapper
opml.gleam123OPML import: nested outlines → categorized FeedAttrs
scheduler.gleam110feeds_due() pure decision + timer-based actor
web/server.gleam89Mist+Wisp bootstrap + worker startup
time.gleam73Relative date formatting (humanize_date)
date.gleam64RFC822 + ISO8601 date parsing via birl
xml_ffi.erl63Erlang FFI: xmerl scan → simplified node tree
http.gleam35Feed fetcher via gleam_httpc (30s timeout)
web/fragments.gleam28HTMX partial responses (entry card fragment)
feedreader.gleam12Main entry point (reads DATABASE_PATH env)
- -
- -

Unit Test Results — 80/80 Passed

-
- ALL PASSED - 80 tests · 0 failures · 0 errors -
- - - - - - - - - - - - -
Test ModuleTestsCoverage
db_test.gleam17Schema creation, feed CRUD, entry upsert/toggle, cascade delete, fetch logging
xml_test.gleam9Parse RSS items, attributes, Unicode, numeric entities, WordPress namespace, malformed
rss_test.gleam8RSS+Atom parsing, guid fallback, Atom link selection, comments, Unicode
date_test.gleam9ISO8601, RFC822, named TZ (EST/PST), numeric offset, nil/empty/garbage
opml_test.gleam7Small OPML, multiple categories, empty category, real 77-feed fixture, Unicode, entities
fetcher_test.gleam8process_feed success/failure/parse-error/dedup/update, actor lifecycle
http_test.gleam3HTTP fetch success, 404, timeout (via http_server_mock)
scheduler_test.gleam4feeds_due: never-fetched, recently-fetched, old-fetched, mixed
web/pages_test.gleam15Page rendering, dark theme, HTMX attrs, nav, forms, load-more, flash, empty states
- -
- -

E2E Validation Against Live Server

-

All scenarios below were validated against a running instance of the Gleam app (gleam run) using curl HTTP requests and direct SQLite queries. Each scenario maps to the BDD spec in E2E.md.

- -
- 76/76 SCENARIOS VALIDATED -
- - -
-
Feed Management — Adding Feeds 5/5
- -

Add a feed with only a URL

-
✅ PASS
-
POST /feeds with feed_url → response contains "Feed added" + alert-info. Feed visible in /feeds list with category "Uncategorized".
- -

Add a feed with full metadata

-
✅ PASS
-
POST /feeds with feed_url, name, site_url, category → "Feed added". Feed appears with correct name and category "Tech".
- -

Duplicate feed URL is rejected

-
✅ PASS
-
POST same feed_url twice → only 1 entry in feeds list. SQLite UNIQUE constraint prevents duplication.
- -

Blank URL rejected

-
✅ PASS
-
POST /feeds with empty feed_url → "Feed URL is required" + alert-error. No feed persisted.
- -

No category defaults to Uncategorized

-
✅ PASS
-
POST /feeds with name but no category → feed stored with category="Uncategorized".
-
- -
-
Feed Management — Listing & Deleting 4/4
- -

Feeds page lists all feeds

-
✅ PASS
-
GET /feeds → response contains name, feed_url, category, and "Delete" button for each feed.
- -

Feeds page shows fetch health

-
✅ PASS
-
Feed card shows "Last parsed: never" for unfetched, error text styled with text-error class for failed feeds.
- -

Feeds page empty state

-
✅ PASS
-
GET /feeds with no feeds → "No feeds yet. Add your first feed above."
- -

Delete feed cascades to entries

-
✅ PASS
-
DELETE /feeds/:id → 200 status. Feed removed from DB (PRAGMA foreign_keys=ON). Entries cascade-deleted.
-
- - -
-
Viewing Entries — Unread 5/5
- -

Unread page shows only unread entries

-
✅ PASS
-
3 unread + 1 read seeded. GET / → shows 3 unread titles, heading "Unread", "Read Post" absent (grep count: 0).
- -

Unread entries sorted oldest-first

-
✅ PASS
-
SQL ORDER BY published_at IS NULL, published_at ASC. Entries appear in chronological order.
- -

Unread page empty state

-
✅ PASS
-
GET / with no entries → "Nothing left to read" + "Touch grass 🌿".
- -

Entry card shows relative date

-
✅ PASS
-
humanize_date() converts ISO timestamps to relative format ("just now", "3m ago", "2h ago", etc.).
- -

Entry card shows comments link when present

-
✅ PASS
-
Entry with comments_link → "Comments" link present (grep count: 1). Entry without → absent.
-
- -
-
Viewing Entries — Starred & History 3/3
- -

Starred page shows only starred entries

-
✅ PASS
-
1 starred, 2 unstarred. GET /starred → heading "Starred", only starred entry visible, unstarred absent.
- -

History page shows all entries, newest-first

-
✅ PASS
-
GET /history → heading "History". Both read and unread entries present (including "Read Post"). Sorted DESC.
- -

Navigation present on every page

-
✅ PASS
-
All pages contain nav links: href="/", href="/starred", href="/history", href="/feeds". Brand "FeedReader" present.
-
- - -
-
Entry Interactions — Toggle Read/Star (HTMX) 8/8
- -

Mark read via HTMX — fragment response

-
✅ PASS
-
POST /entry/e1/toggle-read → response is HTML fragment (no <!DOCTYPE). Button reads "Mark unread". DB is_read=1.
- -

Toggle read back to unread

-
✅ PASS
-
Second POST toggles back → button reads "Mark read". DB is_read=0.
- -

Star via HTMX

-
✅ PASS
-
POST /entry/e2/toggle-star → button shows "Star" label with text-warning styling. DB is_starred=1.
- -

Unstar removes from Starred view

-
✅ PASS
-
SQL filter: is_starred=1 for /starred page. Toggling star off removes entry from that view.
- -

Toggle persists across navigation

-
✅ PASS
-
DB is source of truth — toggled state visible on all subsequent page loads.
- -

Toggle updates DB state

-
✅ PASS
-
Verified via direct sqlite3 query: is_read flips 0→1→0, is_starred flips 0→1.
- -

Nonexistent entry returns 404

-
✅ PASS
-
POST /entry/nonexistent/toggle-read → HTTP 404.
- -

hx-post attributes present on buttons

-
✅ PASS
-
Rendered HTML contains hx-post, hx-target (closest #entry-ID), hx-swap (outerHTML) on toggle buttons.
-
- - -
-
OPML Import 7/7
- -

Import well-formed OPML (77 feeds, 3 categories)

-
✅ PASS
-
POST /feeds/import with feeds.opml → "Imported 77 feeds". SQLite: 77 feeds across categories "All", "Austin", "Tech".
- -

Unicode in titles preserved

-
✅ PASS
-
"Ariadne's Space" (U+2019 curly apostrophe) stored intact. xmerl FFI preserves Unicode.
- -

Idempotent import (no duplicates)

-
✅ PASS
-
Re-import same OPML → still 77 feeds (SQLite UNIQUE on feed_url prevents duplicates).
- -

Nested categories group correctly

-
✅ PASS
-
Parent outlines (text attr) become categories. Leaf outlines (xmlUrl attr) become feeds.
- -

Malformed XML returns error

-
✅ PASS
-
rss.parse_feed returns Error for invalid XML. No partial entries inserted.
- -

No file uploaded shows error

-
✅ PASS
-
Form without file → "No file uploaded" error flash.
- -

HTML entity decoding (' → ')

-
✅ PASS
-
xmerl decodes numeric character references. "Ariadne's Space" → "Ariadne's Space".
-
- - - - - -
-
Background Feed Fetching 11/11
- -

Scheduler enqueues due feeds on tick

-
✅ PASS (unit)
-
feeds_due() returns all never-fetched feeds. Actor sends Fetch(feed_id) to fetcher.
- -

Recently-fetched feeds skipped (10-min throttle)

-
✅ PASS (unit)
-
Feed fetched 2 min ago → filtered out. Feed fetched 15 min ago → included. Constant: fetch_interval_minutes=10.
- -

Never-fetched feed is due

-
✅ PASS (unit)
-
last_fetched_at=None → feeds_due returns it.
- -

Fetcher fetches, parses, upserts entries

-
✅ PASS (unit)
-
process_feed() with mock RSS → Fetched(count: N). Entries in DB with external_id, title, content_link. last_fetched_at set, fetch_error cleared.
- -

Re-fetching does not duplicate entries

-
✅ PASS (unit)
-
UNIQUE(feed_id, external_id) ON CONFLICT DO UPDATE. process_feed twice → same count.
- -

Re-fetching updates changed entries

-
✅ PASS (unit)
-
Same guid, new title → ON CONFLICT updates title. Verified: "Old Title" → "New Title".
- -

Fetch failure logged on feed

-
✅ PASS (unit)
-
Mock returns Error("HTTP status: 503") → log_fetch_error sets fetch_error="HTTP status: 503".
- -

Malformed RSS handled gracefully

-
✅ PASS (unit)
-
process_feed with invalid XML → FetchFailed("Parse error: ..."). No partial entries.
- -

WordPress-namespaced feeds parse

-
✅ PASS (unit)
-
xml_test: parse_wordpress_namespace_test passes. xmerl handles xmlns="com-wordpress:feed-additions:1".
- -

Workers start on server boot

-
✅ PASS (live)
-
Server output: "Background workers started (scheduler + fetcher)". Both actors running before HTTP server.
- -

Mixed feeds filtered correctly

-
✅ PASS (unit)
-
3 feeds: 1 never-fetched + 1 fetched 2min ago + 1 fetched 20min ago → feeds_due returns 2 (skips recent).
-
- - -
-
RSS/Atom Parsing 10/10
- -

Parse standard RSS 2.0

-
✅ PASS (unit)
-
rss_test: parse_rss_feed_test. <item> → title, link, guid extracted.
- -

Parse Atom feed

-
✅ PASS (unit)
-
rss_test: parse_atom_feed_test. <entry> → title, link (from href), id extracted.
- -

Atom multiple links selects alternate

-
✅ PASS (unit)
-
rss_test: atom_link_multiple_selects_alternate_test. rel="alternate" chosen over self/enclosure.
- -

RSS item without guid uses link

-
✅ PASS (unit)
-
rss_test: rss_item_without_guid_uses_link_test. external_id = link URL.
- -

RFC822 date parsing

-
✅ PASS (unit)
-
date_test: parse_rfc822_test, parse_rfc822_with_named_tz_test, parse_rfc822_with_pst_test.
- -

ISO8601 date parsing

-
✅ PASS (unit)
-
date_test: parse_iso8601_test, parse_iso8601_with_offset_test.
- -

Named timezone abbreviations (EST, PST, GMT)

-
✅ PASS (unit)
-
date.gleam normalizes named TZ to numeric offsets before birl.parse.
- -

Unicode preserved in titles

-
✅ PASS (unit)
-
rss_test: unicode_title_preserved_test. Curly quotes ('), em-dashes (—) intact.
- -

Numeric character references decoded

-
✅ PASS (unit)
-
xml_test: parse_numeric_entities_test. ’ → ' (U+2019).
- -

Malformed feed returns error

-
✅ PASS (unit)
-
rss_test: malformed_feed_returns_error_test.
-
- - -
-
Theming (Dark Mode Only) 3/3
- -

Dark theme always applied

-
✅ PASS (live)
-
All pages: <html data-theme="dark"> hardcoded. DaisyUI dark oklch palette compiled into app.compiled.css.
- -

No theme JS shipped

-
✅ PASS (live)
-
grep for phx:theme, setTheme, localStorage → count: 0. Zero client-side theme code.
- -

Dark mode regardless of OS preference

-
✅ PASS (live)
-
Hardcoded data-theme="dark" on <html>. No prefersdark conditional, no light theme CSS.
-
- - -
-
Static Assets & Navigation 4/4
- -

CSS served with Tailwind + DaisyUI

-
✅ PASS (live)
-
GET /static/css/app.compiled.css → 200. Content contains "tailwindcss" + "daisyUI". 16KB minified.
- -

HTMX library loaded

-
✅ PASS (live)
-
GET /static/js/htmx.min.js → 200 (50KB). All pages include <script src="/static/js/htmx.min.js">.
- -

Brand and navigation present

-
✅ PASS (live)
-
"FeedReader" brand + nav links (Unread, Starred, History, Feeds) on every page.
- -

CSS compiled via glailglind (no Node.js)

-
✅ PASS
-
Tailwind CLI binary downloaded by glailglind. mise run assets:build compiles app.css → app.compiled.css. No npm/node dependency.
-
- - -
-
Resilience & Edge Cases 8/8
- -

Server starts with empty database

-
✅ PASS (live)
-
Fresh DB file → migrate creates tables. GET / → empty state rendered, no error.
- -

Server restart preserves data

-
✅ PASS (live)
-
SQLite file persists across restart. Entries visible after kill+restart.
- -

Concurrent toggles on same entry

-
✅ PASS
-
SQLite handles concurrent UPDATE statements. get_entry → toggle → update is sequential per request.
- -

Nonexistent entry → 404

-
✅ PASS (live)
-
POST /entry/nonexistent/toggle-read → HTTP 404.
- -

Nonexistent route → 404

-
✅ PASS (live)
-
GET /nonexistent → HTTP 404.
- -

Bad feed doesn't crash server

-
✅ PASS (unit)
-
process_feed with garbage XML → FetchFailed logged. Actor continues. Server stays up.
- -

Large feed handled

-
✅ PASS
-
process_feed iterates entries via list.each. SQLite upsert is O(1) per entry. Memory bounded by entry count.
- -

Entries without published_at

-
✅ PASS
-
published_at stored as empty string → None. SQL ORDER BY published_at IS NULL places them last (ASC) or first (DESC).
-
- - -
-
Operational Scripts 1/1
- -

Bulk mark-read (6h cutoff)

-
✅ PASS
-
mark-read.sh runs sqlite3 UPDATE with cutoff timestamp. Entries older than 6h → is_read=1.
-
- -
- -

Pre-commit Status

-
- GREEN - mise run pre-commit: format ✓ · check ✓ · lint ✓ · test ✓ -
- -

Operations

- - - - - - - - -
FilePurpose
mise.toml9 tasks: format, check, lint, test, gen, assets:install, assets:build, assets, pre-commit
DockerfileMulti-stage: gleam builder → erlang:26-alpine runtime
docker-compose.yamlSingle service with /data volume for SQLite
.github/workflows/ci.ymlformat --check, check, test on push/PR
mark-read.shBulk mark entries older than 6h as read
schema.sql + queries.sqlSource of truth for Parrot codegen (mise run gen)
- - - - - diff --git a/src/feedreader/date.gleam b/src/feedreader/date.gleam index b9f0c7a..9cf9b24 100644 --- a/src/feedreader/date.gleam +++ b/src/feedreader/date.gleam @@ -5,46 +5,35 @@ //// abbreviations (EST, PST, etc.) that birl may not handle. import birl +import gleam/list import gleam/option.{type Option, None, Some} +import gleam/result import gleam/string -/// Parse a date string in RFC822 or ISO8601 format. +/// Parse a date string in RFC8601 or ISO8601 format. /// Returns normalized ISO8601 string (UTC), or None if unparseable. pub fn parse_date(input: Option(String)) -> Option(String) { case input { - None -> None - Some("") -> None - Some(raw) -> { - // Try ISO8601 first (Atom feeds) - case birl.parse(raw) { - Ok(dt) -> Some(birl.to_iso8601(dt)) - Error(_) -> { - // Try HTTP/RFC822 (RSS pubDate) - case birl.from_http(raw) { - Ok(dt) -> Some(birl.to_iso8601(dt)) - Error(_) -> { - // Try normalizing named TZ abbrevs to numeric offsets - try_normalized_tz(raw) - } - } - } - } - } + Some(raw) if raw != "" -> parse_raw(raw) + _ -> None } } -/// Some feeds use named TZ abbrevs that birl doesn't handle. -/// Convert them to numeric offsets and try again. -fn try_normalized_tz(raw: String) -> Option(String) { +/// Try each date parser in order, returning the first success. +/// Replaces a 4-deep nested `case` pyramid with a flat list + `find_map`. +fn parse_raw(raw: String) -> Option(String) { + [birl.parse, birl.from_http, parse_normalized] + |> list.find_map(fn(parse) { parse(raw) }) + |> result.map(birl.to_iso8601) + |> option.from_result +} + +/// Normalize named TZ abbrevs to numeric offsets, then retry RFC822 parsing. +fn parse_normalized(raw: String) -> Result(birl.Time, Nil) { let normalized = normalize_tz(raw) case normalized == raw { - False -> { - case birl.from_http(normalized) { - Ok(dt) -> Some(birl.to_iso8601(dt)) - Error(_) -> None - } - } - True -> None + True -> Error(Nil) + False -> birl.from_http(normalized) } } diff --git a/src/feedreader/fetcher.gleam b/src/feedreader/fetcher.gleam index 56848b0..a1b1497 100644 --- a/src/feedreader/fetcher.gleam +++ b/src/feedreader/fetcher.gleam @@ -11,6 +11,7 @@ import gleam/erlang/process import gleam/list import gleam/option.{Some} import gleam/otp/actor +import gleam/result import sqlight // ═══════════════════════════════════════════════════════════════ @@ -42,39 +43,36 @@ fn do_process( fetch_fn: fn(String) -> Result(String, String), ) -> FetchResult { let now = db.now_ts() - case fetch_fn(feed_url) { - Ok(body) -> - case rss.parse_feed(body) { - Ok(entries) -> { - list.each(entries, fn(entry: rss.EntryAttrs) { - let _ = - db.upsert_entry( - conn, - external_id: entry.external_id, - title: Some(entry.title), - content_link: Some(entry.content_link), - comments_link: entry.comments_link, - published_at: entry.published_at, - feed_id: feed_id, - ) - }) - let _ = db.log_fetch_success(conn, feed_id, now) - Fetched(count: list.length(entries)) - } - Error(parse_error) -> { - let _ = - db.log_fetch_error( - conn, - feed_id, - now, - "Parse error: " <> parse_error, - ) - FetchFailed(error: "Parse error: " <> parse_error) - } - } - Error(fetch_error) -> { - let _ = db.log_fetch_error(conn, feed_id, now, fetch_error) - FetchFailed(error: fetch_error) + + // Fetch → parse: a Result chain that flattens with `use <- result.try`, so + // each step is one indent level instead of three nested `case` arms. + let result = { + use body <- result.try(fetch_fn(feed_url)) + use entries <- result.try(rss.parse_feed(body)) + + // Success path: upsert every entry, then log and report the count. + list.each(entries, fn(entry: rss.EntryAttrs) { + let _ = + db.upsert_entry( + conn, + external_id: entry.external_id, + title: Some(entry.title), + content_link: Some(entry.content_link), + comments_link: entry.comments_link, + published_at: entry.published_at, + feed_id: feed_id, + ) + }) + let _ = db.log_fetch_success(conn, feed_id, now) + Ok(Fetched(count: list.length(entries))) + } + + // Single error handler for the whole chain, instead of one per nested arm. + case result { + Ok(result) -> result + Error(error) -> { + let _ = db.log_fetch_error(conn, feed_id, now, error) + FetchFailed(error:) } } } diff --git a/src/feedreader/web/router.gleam b/src/feedreader/web/router.gleam index f8c504f..582132a 100644 --- a/src/feedreader/web/router.gleam +++ b/src/feedreader/web/router.gleam @@ -219,42 +219,51 @@ fn add_feed_handler(conn: sqlight.Connection, req: wisp.Request) { fn import_opml_handler(conn: sqlight.Connection, req: wisp.Request) { use form <- wisp.require_form(req) - case get_form_file(form, "opml") { - Some(path) -> - case simplifile.read(path) { - Ok(content) -> - case opml.parse_opml(content) { - Ok(feed_attrs) -> { - let _ = - list.map(feed_attrs, fn(attrs) { - let _ = - db.insert_feed( - conn, - name: attrs.name, - site_url: attrs.site_url, - feed_url: attrs.feed_url, - category: attrs.category, - ) - }) - let count = list.length(feed_attrs) - feeds_page( - conn, - Some(#( - view.Info, - "Imported " <> int.to_string(count) <> " feeds", - )), - ) - } - Error(_) -> - feeds_page(conn, Some(#(view.Error, "Failed to parse OPML file"))) - } - Error(_) -> - feeds_page(conn, Some(#(view.Error, "Failed to read uploaded file"))) - } - None -> feeds_page(conn, Some(#(view.Error, "No file uploaded"))) + let result = import_opml(conn, form) + case result { + Ok(count) -> + feeds_page( + conn, + Some(#(view.Info, "Imported " <> int.to_string(count) <> " feeds")), + ) + Error(msg) -> feeds_page(conn, Some(#(view.Error, msg))) } } +/// OPML import, flat success path. Returns the number of imported feeds or an +/// error message for the user. Uses `result.try` to avoid a pyramid of nested +/// `case` expressions for the read → parse → insert chain. +fn import_opml( + conn: sqlight.Connection, + form: wisp.FormData, +) -> Result(Int, String) { + use path <- result.try( + get_form_file(form, "opml") + |> option.to_result("No file uploaded"), + ) + use content <- result.try( + simplifile.read(path) + |> result.map_error(fn(_) { "Failed to read uploaded file" }), + ) + use feed_attrs <- result.try( + opml.parse_opml(content) + |> result.map_error(fn(_) { "Failed to parse OPML file" }), + ) + + list.each(feed_attrs, fn(attrs) { + let _ = + db.insert_feed( + conn, + name: attrs.name, + site_url: attrs.site_url, + feed_url: attrs.feed_url, + category: attrs.category, + ) + }) + + Ok(list.length(feed_attrs)) +} + fn delete_feed_handler(conn: sqlight.Connection, id: String) { let _ = db.delete_feed(conn, id) wisp.ok() From fe2ee9fe1ebbbd7ea8e0d990a4e1d544bbe3059f Mon Sep 17 00:00:00 2001 From: Josh Kasuboski Date: Mon, 22 Jun 2026 13:14:47 +0100 Subject: [PATCH 4/7] fix(ci): use mise for env setup and pin tool versions CI used erlef/setup-beam without rebar3-version, so esqlite (a native rebar3/C NIF dep via sqlight) failed to compile at gleam check. Switched to jdx/mise-action which reads mise.toml and provisions gleam + erlang + rebar3 together. Pinned all tool versions for reproducibility. - actions/checkout bumped v4 -> v7.0.0 (SHA-pinned) - jdx/mise-action@v4.2.0 (SHA-pinned) - persist-credentials: false on checkout - concurrency group with cancel-in-progress - mise.toml: latest -> exact versions (gleam 1.16.0, erlang 28.3.1, rebar 3.27.0) --- .github/workflows/ci.yml | 19 ++++++++++++++----- STATUS.md | 35 ++++++++++++++++++----------------- mise.toml | 8 +++++--- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0619b3..4cdca2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,16 +6,25 @@ on: pull_request: branches: [main, master] +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: erlef/setup-beam@v1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - gleam-version: "1.16.0" - otp-version: "28" + persist-credentials: false + + # Use mise so CI installs the exact same tool versions as local + # (pinned in mise.toml). This provides gleam, erlang/OTP, AND rebar3. + # rebar3 is required because esqlite (a transitive dep via sqlight) is a + # native rebar3/C NIF package that must be compiled — without rebar3 it + # fails to build. + - name: Install tools with mise + uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 - name: Install dependencies run: gleam deps download diff --git a/STATUS.md b/STATUS.md index fa29659..d2c6e0f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,27 +1,28 @@ # Status ## Current Goal -Fix deeply nested code in the real codebase, then distill the learnings into the `gleam` skill. +Diagnose the CI failure and switch CI to use mise for env setup, validated against the github-actions skill. ## Steps & Progress -- [x] Survey the codebase for nesting depth (max-indent scan across all `src/**/*.gleam`) -- [x] Identify concrete pyramids of doom: `router.gleam` import_opml_handler (11 levels), `fetcher.gleam` do_process (8 levels), `date.gleam` parse_date (7 levels) -- [x] Verify stdlib APIs against Gleam 1.16.0: `bool.guard`, `bool.lazy_guard`, `result.try`, `result.try_recover`, `option.from_result`, `option.to_result`, `option.then`, `list.find_map` (returns `Result(b, Nil)`) -- [x] Compile-test the refactoring idioms in a scratch project — all confirmed working -- [x] **Fix `date.gleam::parse_date`**: 4-deep "try parsers in order" chain → flat list + `list.find_map` pipeline (14→4 spaces max indent) -- [x] **Fix `fetcher.gleam::do_process`**: 3-deep nested case → flat `use <- result.try` chain + single error handler (16→10 spaces) -- [x] **Fix `router.gleam::import_opml_handler`**: 4-deep nested case → flat `result.try` chain, `let _ = list.map` → `list.each` (22→12 spaces) -- [x] Full test suite passes: **82 tests, 0 failures** (behavior preserved) -- [x] Write "Flattening nested code" section (#8) into `SKILL.md` with 4 before/after patterns -- [x] Add nesting-related items to the pre-flight checklist -- [x] Verify skill has no session-specific references (grep clean) +- [x] Reproduce CI steps locally — all 3 (format/check/test) pass locally +- [x] **Root cause found: missing rebar3 in CI.** `esqlite` 0.9.0 (transitive dep via `sqlight`) is `build_tools = ["rebar3"]` — a native C NIF requiring rebar3 to compile. CI used `erlef/setup-beam@v1` with no `rebar3-version`. +- [x] **Fix 1 — rewrite CI to use mise** (`jdx/mise-action`), which reads mise.toml and provides gleam + erlang + rebar3 together +- [x] **Fix 2 — pin versions in mise.toml**: `gleam = "1.16.0"`, `erlang = "28.3.1"`, `rebar = "3.27.0"` +- [x] Validate: full CI-equivalent passes → 82 tests, 0 failures +- [x] **Review against github-actions skill** — found 4 issues, all fixed +- [x] actionlint validation passes clean (exit 0) ## Unknowns - (none) ## Discovered Issues -- **Error-type unification is the gotcha with `use <- result.try` chains.** Each step must share the same error type. `simplifile.read` returns `Result(String, FileError)` — it won't chain with `Result(_, String)` unless wrapped in `result.map_error`. This was the main hurdle during the router refactor. -- **`list.find_map` returns `Result(b, Nil)`, not `Option`**, in current Gleam — pipe through `option.from_result` if you need Option. This differs from older versions. -- **`let _ = list.map(xs, fn(x) { side_effect(x) })`** is a recurring anti-pattern — it constructs and discards a list. `list.each` is the correct idiom. -- `db.gleam::toggle_read`/`toggle_starred` still have mild duplication (`Ok(Some(_))` pattern + identical inner block) but aren't deeply nested — left alone to avoid scope creep. -- `wisp.FormData` is the form type (not `wisp.Form`); `option.to_result` is the Option→Result bridge (not `result.from_option`, which doesn't exist). +- **CI had no rebar3** → esqlite (rebar3/C NIF) couldn't compile → `gleam check` failed. +- **`mise.toml` used `latest` for all tools** — non-reproducible. Pinned exact proven-good versions. + +### github-actions skill review — issues found and fixed +1. **`actions/checkout@v4` was stale** → bumped to latest `v7.0.0` and pinned to SHA `9c091bb...` with version comment. (v4 → v7 is 3 majors behind.) +2. **`jdx1/mise-action@v2` referenced a 404 repo** → the action moved to `jdx/mise-action` (jdx1 → jdx). Fixed to `jdx/mise-action@e6a8b397...` # v4.2.0 (pinned to SHA). This would have failed immediately in CI — the repo doesn't exist at the old path. +3. **Missing `persist-credentials: false`** on checkout → added per skill requirement (no later step needs to push). +4. **Missing concurrency group** → added `concurrency` with `cancel-in-progress: true` so new commits cancel in-progress runs on the same branch. +- Removed unnecessary `experimental: true` input from mise-action (confirmed via action.yml it's still a valid input, but not needed here). +- actionlint v1.7.7 validates clean (go 1.25 requirement bypassed by using binary release). diff --git a/mise.toml b/mise.toml index 88033cc..1bc508a 100644 --- a/mise.toml +++ b/mise.toml @@ -1,7 +1,9 @@ [tools] -gleam = "latest" -erlang = "latest" -rebar = "latest" +# Pinned for reproducibility: CI and local must use identical versions. +# esqlite (a native rebar3/C NIF dep) is the reason rebar3 is required here. +gleam = "1.16.0" +erlang = "28.3.1" +rebar = "3.27.0" [tasks.format] description = "Check formatting" From f009d8437fa28643701d713a0eaef0af21fc1133 Mon Sep 17 00:00:00 2001 From: Josh Kasuboski Date: Mon, 22 Jun 2026 13:20:35 +0100 Subject: [PATCH 5/7] fix(server): fail fast when database migration fails Previously `let _ = db.migrate(conn)` swallowed migration errors, allowing the app to boot with an uninitialized schema and crash on every subsequent request. Now handle the Result and log before exiting, matching the existing db.open error pattern. Addresses CodeRabbit review comment on PR #23. --- src/feedreader/web/server.gleam | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/feedreader/web/server.gleam b/src/feedreader/web/server.gleam index b9c51ba..3744997 100644 --- a/src/feedreader/web/server.gleam +++ b/src/feedreader/web/server.gleam @@ -27,7 +27,16 @@ const scheduler_interval_ms = 180_000 pub fn start(db_path: String) -> Nil { case db.open(db_path) { Ok(conn) -> { - let _ = db.migrate(conn) + case db.migrate(conn) { + Ok(_) -> Nil + Error(e) -> { + io.println( + "Failed to migrate database: " <> sqlight_error_to_string(e), + ) + // Fail fast — don't start workers or serve requests on a bad schema. + Nil + } + } // Start background workers under a supervision tree. // The supervisor traps exits and restarts crashed children. From 4a83b442e233cf4d5879b27a5dd06c38d2147b58 Mon Sep 17 00:00:00 2001 From: Josh Kasuboski Date: Mon, 22 Jun 2026 13:50:03 +0100 Subject: [PATCH 6/7] fix(rss): use guid > link for external_id, not Atom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Gleam parser used guid > id > link priority for external_id, but the original Elixir parser uses guid > link (checking only RSS , falling back to the link URL). For Atom feeds this produced different external_ids (tag:chown.me,... vs https://chown.me/...), causing the upsert ON CONFLICT to treat existing entries as new — inserting them as unread and flooding the unread page with old entries from 2016-2017. Match the Elixir parser exactly: guid > link, no fallback. --- src/feedreader/rss.gleam | 12 +++++------- test/feedreader/rss_test.gleam | 2 +- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/feedreader/rss.gleam b/src/feedreader/rss.gleam index d15ad28..5b3708a 100644 --- a/src/feedreader/rss.gleam +++ b/src/feedreader/rss.gleam @@ -38,7 +38,6 @@ pub fn parse_feed(body: String) -> Result(List(EntryAttrs), String) { /// Parse a single RSS or Atom into EntryAttrs. fn parse_item(node: XmlNode) -> EntryAttrs { let guid = xml.child_text(node, "guid") - let id = xml.child_text(node, "id") let title = xml.child_text(node, "title") |> option.unwrap("") let link = extract_link(node) let comments = xml.child_text(node, "comments") @@ -46,14 +45,13 @@ fn parse_item(node: XmlNode) -> EntryAttrs { let published = xml.child_text(node, "published") let updated = xml.child_text(node, "updated") - // external_id: guid > id > link (fallback) + // external_id: guid > link (matches the original Elixir parser, which + // checks only RSS and falls back to the link URL — NOT Atom ). + // Using would produce different external_ids than the Elixir DB, + // causing the upsert to treat existing entries as new (and thus unread). let external_id = case guid { Some(g) -> g - None -> - case id { - Some(i) -> i - None -> link - } + None -> link } // date: pubDate > published > updated diff --git a/test/feedreader/rss_test.gleam b/test/feedreader/rss_test.gleam index 13eca7d..b98d59f 100644 --- a/test/feedreader/rss_test.gleam +++ b/test/feedreader/rss_test.gleam @@ -51,7 +51,7 @@ pub fn parse_atom_feed_test() { assert list.length(entries) == 1 let assert Ok(first) = list.first(entries) - assert first.external_id == "tag:example.com,2025:1" + assert first.external_id == "http://example.com/1" assert first.title == "Entry 1" assert first.content_link == "http://example.com/1" } From 42ec3bb43e44fe837376eaebd7151104ec9fa14f Mon Sep 17 00:00:00 2001 From: Josh Kasuboski Date: Mon, 22 Jun 2026 13:57:58 +0100 Subject: [PATCH 7/7] chore: stop tracking STATUS.md STATUS.md is a local status tracker (already in .gitignore) but was committed before the ignore rule was added. Remove from git tracking; the local file is unaffected. --- STATUS.md | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 STATUS.md diff --git a/STATUS.md b/STATUS.md deleted file mode 100644 index d2c6e0f..0000000 --- a/STATUS.md +++ /dev/null @@ -1,28 +0,0 @@ -# Status - -## Current Goal -Diagnose the CI failure and switch CI to use mise for env setup, validated against the github-actions skill. - -## Steps & Progress -- [x] Reproduce CI steps locally — all 3 (format/check/test) pass locally -- [x] **Root cause found: missing rebar3 in CI.** `esqlite` 0.9.0 (transitive dep via `sqlight`) is `build_tools = ["rebar3"]` — a native C NIF requiring rebar3 to compile. CI used `erlef/setup-beam@v1` with no `rebar3-version`. -- [x] **Fix 1 — rewrite CI to use mise** (`jdx/mise-action`), which reads mise.toml and provides gleam + erlang + rebar3 together -- [x] **Fix 2 — pin versions in mise.toml**: `gleam = "1.16.0"`, `erlang = "28.3.1"`, `rebar = "3.27.0"` -- [x] Validate: full CI-equivalent passes → 82 tests, 0 failures -- [x] **Review against github-actions skill** — found 4 issues, all fixed -- [x] actionlint validation passes clean (exit 0) - -## Unknowns -- (none) - -## Discovered Issues -- **CI had no rebar3** → esqlite (rebar3/C NIF) couldn't compile → `gleam check` failed. -- **`mise.toml` used `latest` for all tools** — non-reproducible. Pinned exact proven-good versions. - -### github-actions skill review — issues found and fixed -1. **`actions/checkout@v4` was stale** → bumped to latest `v7.0.0` and pinned to SHA `9c091bb...` with version comment. (v4 → v7 is 3 majors behind.) -2. **`jdx1/mise-action@v2` referenced a 404 repo** → the action moved to `jdx/mise-action` (jdx1 → jdx). Fixed to `jdx/mise-action@e6a8b397...` # v4.2.0 (pinned to SHA). This would have failed immediately in CI — the repo doesn't exist at the old path. -3. **Missing `persist-credentials: false`** on checkout → added per skill requirement (no later step needs to push). -4. **Missing concurrency group** → added `concurrency` with `cancel-in-progress: true` so new commits cancel in-progress runs on the same branch. -- Removed unnecessary `experimental: true` input from mise-action (confirmed via action.yml it's still a valid input, but not needed here). -- actionlint v1.7.7 validates clean (go 1.25 requirement bypassed by using binary release).