diff --git a/.credo.exs b/.credo.exs deleted file mode 100644 index 5179ef2..0000000 --- a/.credo.exs +++ /dev/null @@ -1,221 +0,0 @@ -# This file contains the configuration for Credo and you are probably reading -# this after creating it with `mix credo.gen.config`. -# -# If you find anything wrong or unclear in this file, please report an -# issue on GitHub: https://github.com/rrrene/credo/issues -# -%{ - # - # You can have as many configs as you like in the `configs:` field. - configs: [ - %{ - # - # Run any config using `mix credo -C `. If no config name is given - # "default" is used. - # - name: "default", - # - # These are the files included in the analysis: - files: %{ - # - # You can give explicit globs or simply directories. - # In the latter case `**/*.{ex,exs}` will be used. - # - included: [ - "lib/", - "src/", - "test/", - "web/", - "apps/*/lib/", - "apps/*/src/", - "apps/*/test/", - "apps/*/web/" - ], - excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] - }, - # - # Load and configure plugins here: - # - plugins: [], - # - # If you create your own checks, you must specify the source files for - # them here, so they can be loaded by Credo before running the analysis. - # - requires: [], - # - # If you want to enforce a style guide and need a more traditional linting - # experience, you can change `strict` to `true` below: - # - strict: false, - # - # To modify the timeout for parsing files, change this value: - # - parse_timeout: 5000, - # - # If you want to use uncolored output by default, you can change `color` - # to `false` below: - # - color: true, - # - # You can customize the parameters of any check by adding a second element - # to the tuple. - # - # To disable a check put `false` as second element: - # - # {Credo.Check.Design.DuplicatedCode, false} - # - checks: %{ - enabled: [ - # - ## Consistency Checks - # - {Credo.Check.Consistency.ExceptionNames, []}, - {Credo.Check.Consistency.LineEndings, []}, - {Credo.Check.Consistency.ParameterPatternMatching, []}, - {Credo.Check.Consistency.SpaceAroundOperators, []}, - {Credo.Check.Consistency.SpaceInParentheses, []}, - {Credo.Check.Consistency.TabsOrSpaces, []}, - - # - ## Design Checks - # - # You can customize the priority of any check - # Priority values are: `low, normal, high, higher` - # - {Credo.Check.Design.AliasUsage, - [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, - {Credo.Check.Design.TagFIXME, []}, - # You can also customize the exit_status of each check. - # If you don't want TODO comments to cause `mix credo` to fail, just - # set this value to 0 (zero). - # - {Credo.Check.Design.TagTODO, [exit_status: 2]}, - - # - ## Readability Checks - # - {Credo.Check.Readability.AliasOrder, []}, - {Credo.Check.Readability.FunctionNames, []}, - {Credo.Check.Readability.LargeNumbers, []}, - {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, - {Credo.Check.Readability.ModuleAttributeNames, []}, - {Credo.Check.Readability.ModuleDoc, []}, - {Credo.Check.Readability.ModuleNames, []}, - {Credo.Check.Readability.ParenthesesInCondition, []}, - {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, - {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, - {Credo.Check.Readability.PredicateFunctionNames, []}, - {Credo.Check.Readability.PreferImplicitTry, []}, - {Credo.Check.Readability.RedundantBlankLines, []}, - {Credo.Check.Readability.Semicolons, []}, - {Credo.Check.Readability.SpaceAfterCommas, []}, - {Credo.Check.Readability.StringSigils, []}, - {Credo.Check.Readability.TrailingBlankLine, []}, - {Credo.Check.Readability.TrailingWhiteSpace, []}, - {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, - {Credo.Check.Readability.VariableNames, []}, - {Credo.Check.Readability.WithSingleClause, []}, - - # - ## Refactoring Opportunities - # - {Credo.Check.Refactor.Apply, []}, - {Credo.Check.Refactor.CondStatements, []}, - {Credo.Check.Refactor.CyclomaticComplexity, [exit_status: 0]}, - {Credo.Check.Refactor.FilterCount, []}, - {Credo.Check.Refactor.FilterFilter, []}, - {Credo.Check.Refactor.FunctionArity, []}, - {Credo.Check.Refactor.LongQuoteBlocks, []}, - {Credo.Check.Refactor.MapJoin, []}, - {Credo.Check.Refactor.MatchInCondition, []}, - {Credo.Check.Refactor.NegatedConditionsInUnless, []}, - {Credo.Check.Refactor.NegatedConditionsWithElse, []}, - {Credo.Check.Refactor.Nesting, [exit_status: 0]}, - {Credo.Check.Refactor.RedundantWithClauseResult, []}, - {Credo.Check.Refactor.RejectReject, []}, - {Credo.Check.Refactor.UnlessWithElse, []}, - {Credo.Check.Refactor.WithClauses, []}, - - # - ## Warnings - # - {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, - {Credo.Check.Warning.BoolOperationOnSameValues, []}, - {Credo.Check.Warning.Dbg, []}, - {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, - {Credo.Check.Warning.IExPry, []}, - {Credo.Check.Warning.IoInspect, []}, - {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, - {Credo.Check.Warning.OperationOnSameValues, []}, - {Credo.Check.Warning.OperationWithConstantResult, []}, - {Credo.Check.Warning.RaiseInsideRescue, []}, - {Credo.Check.Warning.SpecWithStruct, []}, - {Credo.Check.Warning.StructFieldAmount, []}, - {Credo.Check.Warning.UnsafeExec, []}, - {Credo.Check.Warning.UnusedEnumOperation, []}, - {Credo.Check.Warning.UnusedFileOperation, []}, - {Credo.Check.Warning.UnusedKeywordOperation, []}, - {Credo.Check.Warning.UnusedListOperation, []}, - {Credo.Check.Warning.UnusedMapOperation, []}, - {Credo.Check.Warning.UnusedPathOperation, []}, - {Credo.Check.Warning.UnusedRegexOperation, []}, - {Credo.Check.Warning.UnusedStringOperation, []}, - {Credo.Check.Warning.UnusedTupleOperation, []}, - {Credo.Check.Warning.WrongTestFilename, []} - ], - disabled: [ - # - # Checks scheduled for next check update (opt-in for now) - {Credo.Check.Refactor.UtcNowTruncate, []}, - - # - # Controversial and experimental checks (opt-in, just move the check to `:enabled` - # and be sure to use `mix credo --strict` to see low priority checks) - # - {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, - {Credo.Check.Consistency.UnusedVariableNames, []}, - {Credo.Check.Design.DuplicatedCode, []}, - {Credo.Check.Design.SkipTestWithoutComment, []}, - {Credo.Check.Readability.AliasAs, []}, - {Credo.Check.Readability.BlockPipe, []}, - {Credo.Check.Readability.ImplTrue, []}, - {Credo.Check.Readability.MultiAlias, []}, - {Credo.Check.Readability.NestedFunctionCalls, []}, - {Credo.Check.Readability.OneArityFunctionInPipe, []}, - {Credo.Check.Readability.OnePipePerLine, []}, - {Credo.Check.Readability.SeparateAliasRequire, []}, - {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, - {Credo.Check.Readability.SinglePipe, []}, - {Credo.Check.Readability.Specs, []}, - {Credo.Check.Readability.StrictModuleLayout, []}, - {Credo.Check.Readability.WithCustomTaggedTuple, []}, - {Credo.Check.Refactor.ABCSize, []}, - {Credo.Check.Refactor.AppendSingleItem, []}, - {Credo.Check.Refactor.CondInsteadOfIfElse, []}, - {Credo.Check.Refactor.DoubleBooleanNegation, []}, - {Credo.Check.Refactor.FilterReject, []}, - {Credo.Check.Refactor.IoPuts, []}, - {Credo.Check.Refactor.MapMap, []}, - {Credo.Check.Refactor.ModuleDependencies, []}, - {Credo.Check.Refactor.NegatedIsNil, []}, - {Credo.Check.Refactor.PassAsyncInTestCases, []}, - {Credo.Check.Refactor.PipeChainStart, []}, - {Credo.Check.Refactor.RejectFilter, []}, - {Credo.Check.Refactor.VariableRebinding, []}, - {Credo.Check.Warning.LazyLogging, []}, - {Credo.Check.Warning.LeakyEnvironment, []}, - {Credo.Check.Warning.MapGetUnsafePass, []}, - {Credo.Check.Warning.MixEnv, []}, - {Credo.Check.Warning.UnsafeToAtom, []} - # {Credo.Check.Warning.UnusedOperation, [{MyMagicModule, [:fun1, :fun2]}]} - - # {Credo.Check.Refactor.MapInto, []}, - - # - # Custom checks can be created using `mix credo.gen.check`. - # - ] - } - } - ] -} diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index c2c4a5a..0000000 --- a/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -target/** \ No newline at end of file diff --git a/.formatter.exs b/.formatter.exs deleted file mode 100644 index 7eb38a3..0000000 --- a/.formatter.exs +++ /dev/null @@ -1,19 +0,0 @@ -[ - import_deps: [ - :ash_oban, - :oban, - :ash_admin, - :ash_authentication_phoenix, - :ash_authentication, - :ash_sqlite, - :ash_phoenix, - :ash, - :reactor, - :ecto, - :ecto_sql, - :phoenix - ], - subdirectories: ["priv/*/migrations"], - plugins: [Spark.Formatter, Phoenix.LiveView.HTMLFormatter], - inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] -] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4cdca2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [main, master] + 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + 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 + + - name: Check formatting + run: gleam format --check + + - name: Type check + run: gleam check + + - name: Run tests + run: gleam test diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml deleted file mode 100644 index b7e258e..0000000 --- a/.github/workflows/docker.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Docker - -on: - push: - branches: ['**'] - tags: ['v*.*.*'] - -permissions: - contents: read - packages: write - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build: - name: Build Multi-Arch Image - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - with: - platforms: linux/amd64,linux/arm64 - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Login to GHCR - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push multi-arch image - uses: docker/build-push-action@v7 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - sbom: true diff --git a/.gitignore b/.gitignore index c067cd8..c7da28b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,44 +1,22 @@ -# The directory Mix will write compiled artifacts to. -/_build/ +# Build artifacts +build/ +.erlang/ +hexerldb/ -# If you run "mix test --cover", coverage assets end up here. -/cover/ +# Generated +*.beam -# The directory Mix downloads your dependencies sources to. -/deps/ - -# Where 3rd-party dependencies like ExDoc output generated docs. -/doc/ - -# Ignore .fetch files in case you like to edit your project deps locally. -/.fetch - -# If the VM crashes, it generates a dump, let's ignore it too. -erl_crash.dump - -# Also ignore archive artifacts (built via "mix archive.build"). -*.ez - -# Temporary files, for example, from tests. -/tmp/ - -# Ignore package tarball (built via "mix hex.build"). -feedreader-*.tar - -# Ignore assets that are produced by build tools. -/priv/static/assets/ - -# Ignore digested assets cache. -/priv/static/cache_manifest.json - -# In case you use Node.js/npm, you want to ignore these. -npm-debug.log -/assets/node_modules/ - -# Database files +# Database *.db -*.db-* +*.db-wal +*.db-shm -.env +# Gleam +.gleam/ +# Note: priv/static/vendor/ and priv/static/js/ are checked in (vendored deps) +# Compiled CSS is a build artifact (generated by glailglind/tailwind) +priv/static/css/app.compiled.css +screenshots/ +*.dump STATUS.md diff --git a/.igniter.exs b/.igniter.exs deleted file mode 100644 index bdc3383..0000000 --- a/.igniter.exs +++ /dev/null @@ -1,10 +0,0 @@ -# This is a configuration file for igniter. -# For option documentation, see https://hexdocs.pm/igniter/Igniter.Project.IgniterConfig.html -# To keep it up to date, use `mix igniter.setup` -[ - module_location: :outside_matching_folder, - extensions: [{Igniter.Extensions.Phoenix, []}], - deps_location: :last_list_literal, - source_folders: ["lib", "test/support"], - dont_move_files: [~r"lib/mix"] -] 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/.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/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/Dockerfile b/Dockerfile index a89ae20..f2afebf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,66 +1,44 @@ -ARG BUILDER_IMAGE="hexpm/elixir:1.18-erlang-27.0-ubuntu-noble-20260217" -ARG RUNNER_IMAGE="ubuntu:noble-20260217" -ARG MIX_ENV=prod +# FeedReader — Gleam/Erlang feed reader +# Multi-stage Dockerfile: build with rebar3+gleam, ship minimal runtime -FROM ${BUILDER_IMAGE} AS builder - -RUN apt-get update -y && apt-get install --no-install-recommends -y build-essential git curl ca-certificates \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -ARG MIX_ENV -ENV MIX_ENV=${MIX_ENV} +FROM ghcr.io/gleam-lang/gleam:v1.16.0-erlang-alpine AS builder WORKDIR /app -RUN mix local.hex --force && \ - mix local.rebar --force - -COPY mix.exs mix.lock ./ -RUN mix deps.get --only $MIX_ENV - -RUN mkdir config -COPY config/config.exs config/${MIX_ENV}.exs config/ -RUN mix deps.compile +# Install build tools needed for esqlite NIF (C compiler + SQLite dev headers) +RUN apk add --no-cache build-base sqlite-dev -COPY priv priv -COPY lib lib -COPY assets assets +# Copy manifest and fetch deps first (layer caching) +COPY gleam.toml manifest.toml ./ +RUN gleam deps download -RUN mix assets.deploy +# Copy source +COPY src/ src/ +COPY priv/ priv/ -RUN mix compile +# Build +RUN gleam export erlang-shipment -COPY config/runtime.exs config/ - -RUN mix release - -FROM ${RUNNER_IMAGE} AS runner - -RUN apt-get update -y && apt-get install --no-install-recommends -y libstdc++-12-dev openssl ca-certificates \ - && apt-get clean && rm -rf /var/lib/apt/lists/* +# ─── Runtime stage ────────────────────────────────────────────── +# Must match the OTP version from the builder stage (gleam:v1.16.0-erlang-alpine = OTP 28) +FROM erlang:28-alpine WORKDIR /app -RUN groupadd -g 1001 app && \ - useradd -u 1001 -g app -m app - -RUN chown app:app /app +# Install SQLite runtime library (esqlite NIF depends on libsqlite3) +RUN apk add --no-cache sqlite-libs -RUN mkdir -p /app/data && chown app:app /app/data +COPY --from=builder /app/build/erlang-shipment ./ -USER app +# Copy priv/ to the CWD so runtime code can find schema.sql and static assets +# (gleam export erlang-shipment nests priv under feedreader/priv/, but our +# code reads from priv/ relative to CWD). +COPY --from=builder /app/priv ./priv -ARG MIX_ENV -COPY --from=builder --chown=app:app /app/_build/${MIX_ENV}/rel/feedreader ./ +ENV DATABASE_PATH=/data/feedreader.db -ENV MIX_ENV=${MIX_ENV} -ENV PHX_SERVER="true" -ENV DATABASE_PATH="/app/data/feedreader.db" -ENV PHX_HOST="localhost" -ENV POOL_SIZE="10" -ENV PORT="4000" -ENV PHX_PUBLIC_PORT="4000" +VOLUME ["/data"] -EXPOSE 4000 +EXPOSE 3000 -CMD ["/app/bin/feedreader", "start"] +CMD ["./entrypoint.sh", "run"] diff --git a/E2E.md b/E2E.md new file mode 100644 index 0000000..1f24e63 --- /dev/null +++ b/E2E.md @@ -0,0 +1,613 @@ +# E2E Behavioral Tests (BDD) + +Behavior-Driven Development scenarios for the feed reader, covering the full +user-facing surface. Written in Gherkin (`Feature` / `Scenario` / `Given` / `When` +/ `Then`). These describe *what the app does from the outside*, independent of +implementation — they should hold whether the backend is the old Elixir app or +the new Gleam rewrite. + +**How to use this file**: each Scenario maps to one E2E test. Group into suites +by Feature. Scenarios assume a running, **unauthenticated** server (auth is +handled outside the app, e.g. by a reverse proxy in front of it — the app +itself enforces no access control, matching the current Elixir deployment). +Background-fetch scenarios use injected/mocked feeds and a controllable clock +(no real network, no `process.sleep`). + +**Scope notes**: +- "The app" = the running server (Mist) + browser client (HTMX). E2E exercises + the HTTP boundary and rendered HTML, not internal functions. +- UI assertions target rendered HTML strings (titles, classes, hx-attributes). +- "Entry" = a feed article; "Feed" = an RSS/Atom subscription. +- **No authentication**: the app does not enforce auth (handled externally). + All routes are open. + +## Feature: Feed Management — Adding Feeds + +```gherkin +Background: + Given the database is empty + +Scenario: Add a feed with only a URL + When I submit the add-feed form with feed_url "https://example.com/rss" + Then a new Feed is persisted with that feed_url + And the response confirms "Feed added" + And the feed appears in the feeds list with category "Uncategorized" + +Scenario: Add a feed with full metadata + When I submit the add-feed form with: + | feed_url | https://example.com/rss | + | name | Example Blog | + | site_url | https://example.com | + | category | Tech | + Then the Feed is persisted with all four fields + And it appears under category "Tech" in the feeds list + +Scenario: Adding a duplicate feed URL is rejected + Given a feed exists with feed_url "https://example.com/rss" + When I submit the add-feed form with feed_url "https://example.com/rss" + Then no second feed is created + And the response indicates the feed already exists (or is silently ignored) + And the feeds list still contains exactly one entry for that URL + +Scenario: Add feed rejects a blank URL + When I submit the add-feed form with an empty feed_url + Then the form validation fails + And no feed is persisted + +Scenario: Add feed with a name but no explicit category defaults to Uncategorized + When I submit the add-feed form with name "X" and no category + Then the persisted feed has category "Uncategorized" +``` + +--- + +## Feature: Feed Management — Listing & Deleting + +```gherkin +Background: + Given several feeds exist across categories "Tech", "News", "Uncategorized" + +Scenario: Feeds page lists all feeds + When I request "/feeds" + Then the response contains one card per feed + And each card shows the feed name, feed_url, and category + And each card has a "Delete" button + +Scenario: Feeds page shows fetch health + Given a feed was last fetched successfully 2 minutes ago + And another feed's last fetch errored with "HTTP status: 503" + When I request "/feeds" + Then the first card shows "Last parsed: 2m ago" + And the second card shows the error text in an error-styled element + +Scenario: Feeds page empty state + Given the database has no feeds + When I request "/feeds" + Then the response shows an empty-state message like "No feeds yet" + +Scenario: Delete a feed removes it and cascades to entries + Given a feed exists with 5 entries + When I click "Delete" on that feed's card (HTMX DELETE) + Then the feed card is removed from the DOM via HTMX swap + And the feed no longer exists in the database + And none of its 5 entries remain in the database + +Scenario: Delete requires confirmation for feeds with entries + # Optional UX — document the chosen behavior + Given a feed exists with entries + When I click "Delete" + Then I am prompted to confirm (or the delete proceeds immediately — pick one) +``` + +--- + +## Feature: OPML Import + +```gherkin +Background: + Given the database is empty + +Scenario: Import a well-formed OPML file + Given an OPML file with 77 feeds across 3 categories (All, Austin, Tech) + When I upload it via the OPML import form + Then all 77 feeds are persisted + And each feed is categorized per its parent outline's "text" attribute + And the response reports "Imported 77 feeds" + +Scenario: OPML import preserves Unicode in titles + Given an OPML file containing outlines titled "Ariadne's Space" and "Christine Dodrill's Blog" + When I import it + Then the persisted feed titles contain the curly apostrophe (U+2019) intact + And the feeds list renders those titles without mojibake + +Scenario: OPML import is idempotent on duplicate feeds + Given 3 feeds from "All" category already exist + When I import an OPML file containing those same 3 feeds plus 10 new ones + Then only the 10 new feeds are added + And the 3 existing feeds are unchanged + And the response reports "Imported 10 feeds" + +Scenario: OPML with nested categories groups correctly + Given an OPML file with nested outline elements + When I import it + Then leaf outlines (with xmlUrl) become feeds + And parent outlines (without xmlUrl) define categories + And feeds with no parent category default to "Uncategorized" + +Scenario: OPML import with malformed XML reports an error + Given an OPML file that is not valid XML + When I import it + Then the response reports an import error + And no feeds are persisted + And the existing database state is unchanged + +Scenario: OPML import without a file shows an error + When I submit the OPML form with no file selected + Then the response reports "No file uploaded" + And no feeds are persisted + +Scenario: OPML outline with title uses title; falling back to text + Given an outline with title="Real Title" and text="Fallback" + When I import it + Then the feed name is "Real Title" + # And an outline with empty title but text="X" → name "X" +``` + +--- + +## Feature: Viewing Entries — Unread + +```gherkin +Background: + Given feeds exist with entries in various states + +Scenario: Unread page shows only unread entries + Given 3 unread entries and 2 read entries exist + When I request "/" (the unread view) + Then the response contains the 3 unread entries' titles + And does not contain the 2 read entries' titles + And the page heading says "Unread" + +Scenario: Unread entries are sorted oldest-first + Given unread entries with published_at times T1 < T2 < T3 + When I request "/" + Then the entries appear in order T1, T2, T3 (ascending by published_at) + +Scenario: Unread page empty state + Given no unread entries exist + When I request "/" + Then the response shows an empty-state message (e.g. "Nothing left to read") + +Scenario: Entry card shows feed name and relative date + Given an unread entry titled "X" from feed "Blog" published 3 hours ago + When I request "/" + Then the card shows "Blog" and "3h ago" + And the title links to the entry's content_link in a new tab + +Scenario: Entry card shows comments link when present + Given an entry with a comments_link + When I request "/" + Then the card contains a "Comments" link to that URL + +Scenario: Entry card hides comments link when absent + Given an entry with no comments_link + When I request "/" + Then the card does not contain a "Comments" link +``` + +--- + +## Feature: Viewing Entries — Starred & History + +```gherkin +Scenario: Starred page shows only starred entries, oldest-first + Given 2 starred and 3 unstarred entries + When I request "/starred" + Then only the 2 starred entries appear, ascending by published_at + And the heading says "Starred" + +Scenario: History page shows all entries, newest-first + Given entries across many dates + When I request "/history" + Then all entries appear, descending by published_at + And the heading says "History" + And both read and unread entries are included + +Scenario: Navigation highlights the current section + When I request "/starred" + Then the "Starred" nav link has the active class + And the other nav links do not +``` + +--- + +## Feature: Entry Interactions — Toggle Read (HTMX) + +```gherkin +Background: + Given I am on the unread page + And an unread entry "E1" is visible + +Scenario: Mark an entry read via HTMX without full page refresh + When I click "Mark read" on "E1" + Then the server receives POST /entry//toggle-read + And the response is an HTML fragment (not a full document) + And the entry's card is swapped in place (no full page reload) + And the button now reads "Mark unread" + And the entry is_read flag is now true in the database + +Scenario: Marking read removes the entry from the Unread view + When I click "Mark read" on "E1" while on "/" + Then "E1" is removed from the visible list + And the unread count decreases by one + # This mirrors the Elixir stream_delete behavior + +Scenario: Toggling read back to unread restores it + Given "E1" is currently read and visible on /history + When I click "Mark unread" on "E1" + Then the button now reads "Mark read" + And is_read is false in the database + And visiting "/" shows "E1" again + +Scenario: Toggle read is reflected across views + Given "E1" is unread + When I mark it read on "/" + And I navigate to "/history" + Then "E1" appears in history (because history shows all entries) +``` + +--- + +## Feature: Entry Interactions — Toggle Star (HTMX) + +```gherkin +Background: + Given an unstarred entry "E1" is visible + +Scenario: Star an entry via HTMX + When I click "Star" on "E1" + Then POST /entry//toggle-star is sent + And the card swaps in place with the button now reading "Starred" + And is_starred is true in the database + And the button has the starred styling (e.g. yellow highlight class) + +Scenario: Unstarring removes the entry from the Starred view + Given I am on "/starred" and "E1" (starred) is visible + When I click "Starred" to unstar "E1" + Then "E1" is removed from the visible list + And the starred count decreases by one + And is_starred is false in the database + +Scenario: Starring persists across navigation + When I star "E1" on "/" + And I navigate to "/starred" + Then "E1" appears in the starred list + +Scenario: Toggle star updates the count optimistically + Given the starred view shows 3 entries + When I unstar one + Then the displayed count updates to 2 without a full refresh +``` + +--- + +## Feature: Pagination — Load More + +```gherkin +Background: + Given more than one page of unread entries exist (page size N) + +Scenario: Load more appends the next page + When I am on "/" and the first N entries are visible + And I click "Load more" + Then GET /?after= is sent + And the next N entries are appended to the list (HTMX beforeend swap) + And the "Load more" button updates to fetch the following page + +Scenario: Load more is hidden when no more entries + Given exactly N unread entries exist + When I request "/" + Then no "Load more" button is rendered + +Scenario: Load more on the last page + Given 2.5 pages of entries exist + When I load more twice + Then the second load returns the remaining partial page + And the "Load more" button disappears after the final load + +Scenario: Load more preserves sort order + When entries are sorted ascending and I load more + Then the appended entries continue the ascending sequence seamlessly +``` + +--- + +## Feature: Background Feed Fetching + +```gherkin +Background: + Given the scheduler and fetcher actors are running + And HTTP fetches are mocked (no real network) + +Scenario: Scheduler enqueues due feeds on tick + Given 3 feeds exist, none ever fetched + When the scheduler ticks + Then a Fetch message is enqueued for each of the 3 feeds + +Scenario: Recently-fetched feeds are skipped on tick + Given feed A was fetched 2 minutes ago and feed B was fetched 15 minutes ago + When the scheduler ticks + Then only feed B is enqueued (10-minute throttle) + And feed A is skipped + +Scenario: Never-fetched feed is enqueued on first tick + Given a feed with last_fetched_at = NULL + When the scheduler ticks + Then that feed is enqueued + +Scenario: Fetcher fetches, parses, and upserts entries + Given feed F exists with feed_url pointing to a mocked RSS response containing 5 items + When the fetcher processes F + Then 5 entries are upserted into the database + And each entry has external_id, title, content_link, published_at populated + And feed F's last_fetched_at is updated to ~now + And feed F's fetch_error is cleared + +Scenario: Re-fetching a feed does not duplicate entries + Given feed F was already fetched and has 5 entries + When the fetcher processes F again with the same RSS content + Then the entries table still has exactly 5 entries for F + # Dedup via UNIQUE(feed_id, external_id) upsert + +Scenario: Re-fetching updates changed entries + Given feed F has an entry with title "Old Title" + When the fetcher processes F and the RSS now has title "New Title" for the same guid + Then the entry's title is updated to "New Title" + +Scenario: Fetch failure is logged on the feed + Given feed F's URL returns HTTP 503 + When the fetcher processes F + Then feed F's fetch_error is set to an error message containing "503" + And feed F's last_fetched_at is updated + And no new entries are inserted + +Scenario: Fetch timeout is logged as an error + Given feed F's URL times out (mocked) + When the fetcher processes F + Then feed F's fetch_error is set to a timeout error message + +Scenario: Fetcher handles malformed RSS gracefully + Given feed F's URL returns "rss" + When the fetcher processes F + Then feed F's fetch_error is set to a parse-error message + And no partial entries are inserted + +Scenario: Fetcher handles WordPress-namespaced feeds + Given feed F's RSS contains + When the fetcher processes F + Then the feed parses successfully (no crash) + And entries are extracted normally + +Scenario: Scheduler staggers fetches to avoid thundering herd + Given 10 feeds become due simultaneously + When the scheduler ticks + Then fetches are scheduled with staggered delays (1–5 minute spread) + # Assert via injected clock: not all 10 fire at t=0 + +Scenario: New entry triggers a notification event + Given the fetcher emits EntryUpserted events to subscribers + And a test subscriber is registered + When the fetcher inserts a genuinely new entry + Then the subscriber receives an EntryUpserted event for that entry + # Updated (re-fetched) entries do NOT emit the event +``` + +--- + +## Feature: RSS/Atom Parsing (via fetcher, behavioral) + +```gherkin +Scenario: Parse a standard RSS 2.0 feed + Given a mocked RSS response with elements + When the fetcher processes it + Then each becomes an entry with title, link, guid, pubDate + +Scenario: Parse an Atom feed + Given a mocked Atom response with elements + When the fetcher processes it + Then each becomes an entry with title, link (from ), id, updated/published + +Scenario: Atom entry with multiple links selects the alternate + Given an Atom with , , and + When parsed + Then the entry's content_link is the alternate link's href + +Scenario: Atom entry with no rel attribute defaults to alternate + Given an Atom with a bare (no rel) + When parsed + Then content_link is that href + +Scenario: RSS item without guid falls back to link as external_id + Given an RSS with a but no + When parsed + Then the entry's external_id equals the link + +Scenario: Date parsing handles RFC822 format + Given an item with Thu, 12 Jun 2025 14:30:00 EST + When parsed + Then published_at is normalized to UTC correctly + +Scenario: Date parsing handles ISO8601 format + Given an entry with 2025-06-12T19:30:00Z + When parsed + Then published_at is normalized correctly + +Scenario: Date parsing handles named timezone abbreviations + Given pubDates with EST, PST, GMT, UTC + When parsed + Then each is converted to the correct UTC offset + +Scenario: Unicode in titles is preserved + Given items with titles containing curly quotes (’), em-dashes (—), and CJK characters + When parsed and stored + Then the persisted titles match byte-for-byte (no corruption) + +Scenario: Numeric character references are decoded + Given a title containing "GitHub’s Blog" + When parsed + Then the stored title is "GitHub's Blog" (U+2019) +``` + +--- + +## Feature: Theming (dark mode only) + +> The rewrite ships **dark mode only** — no light theme, no theme switcher, no +> client-side theme JS. This is simpler than the current Elixir app (which +> defines both themes but has no working toggle UI). These scenarios pin the +> expected behavior. + +```gherkin +Scenario: Dark theme is always applied + Given any visitor on any page + When the page loads + Then the element has data-theme="dark" + And the DaisyUI dark theme colors are rendered (oklch dark palette) + And there is no light-theme CSS loaded + And there is no theme-switch UI element anywhere in the DOM + +Scenario: No theme JS is shipped + Given any page + When inspecting the bundled JS + Then there is no localStorage handling for "phx:theme" + And there is no setTheme / data-theme mutation script + # Dark mode is pure CSS — zero client-side theme logic. + +Scenario: Page renders correctly with no OS preference + Given a browser with prefers-color-scheme unset (or set to light) + When any page loads + Then the page still renders in dark mode + # Dark mode is hardcoded, not OS-conditional. +``` + +--- + +## Feature: Static Assets & Navigation + +```gherkin +Scenario: CSS is served and applied + When I request the stylesheet URL + Then the response is a CSS file with Tailwind + DaisyUI content + And the page renders with DaisyUI component styling visible + +Scenario: HTMX library is loaded + When any page loads + Then htmx.min.js is loaded (no console errors) + And HTMX attributes on buttons are functional + +Scenario: Client-side error reconnect indicator + Given the server becomes unreachable mid-session + When HTMX detects disconnection + Then a reconnect indicator toast appears + And it disappears when the connection restores + +Scenario: Brand and navigation are present on every page + When I request any page + Then the navbar shows "FeedReader" brand + And nav links to Unread, Starred, History, Feeds are present +``` + +--- + +## Feature: Resilience & Edge Cases + +```gherkin +Scenario: Server starts with an empty database + Given no database file exists + When the server starts + Then the schema is initialized (tables created) + And "/" renders the empty state without error + +Scenario: Server restarts and preserves data + Given feeds and entries exist + When the server is restarted + Then all feeds and entries are still present and viewable + +Scenario: Concurrent toggle requests on the same entry + When two toggle-read requests for "E1" fire near-simultaneously + Then the final is_read state is consistent (no corruption) + # Either both-succeed-serially or one-wins; document which + +Scenario: Request to a non-existent entry returns not-found + When I POST /entry/does-not-exist/toggle-read + Then the response is 404 (or a graceful error fragment) + +Scenario: Request to a non-existent route returns 404 + When I request "/nonexistent" + Then the response is 404 + +Scenario: Fetcher does not crash the server on a bad feed + Given a feed URL returns garbage bytes + When the scheduler ticks and the fetcher processes it + Then the error is logged on the feed + And the server remains running and responsive + And other feeds continue to be fetched normally + +Scenario: Very large feed is handled without exhaustion + Given a mocked feed with 1000 items + When the fetcher processes it + Then all 1000 entries are upserted + And memory usage remains bounded (no OOM) + +Scenario: Feed with entries lacking published_at + Given items with no parseable date + When processed + Then entries are stored with published_at = NULL + And sorting places them consistently (e.g. treated as oldest or newest — document) +``` + +--- + +## Feature: Operational Scripts (mark-read) + +```gherkin +Scenario: Bulk mark-read keeps recent entries unread + Given entries spanning the last 24 hours + When the bulk mark-read operation runs (cutoff = 6 hours ago) + Then entries older than 6 hours are marked read + And entries newer than 6 hours remain unread + # If this becomes an in-app admin route rather than a shell script, + # adapt the trigger but preserve the behavior. +``` + +--- + +## Coverage matrix + +| Feature area | Scenarios | Key risks covered | +|---|---:|---| +| Feed add/list/delete | 9 | duplicate rejection, cascade delete, health display | +| OPML import | 7 | Unicode, idempotency, malformed XML, nesting | +| Entry viewing | 10 | sort order, empty states, conditional comments link | +| Toggle read/star | 8 | HTMX no-refresh, view-removal semantics, cross-view consistency | +| Pagination | 4 | append, hide-on-empty, last partial page, sort continuity | +| Background fetching | 11 | throttle, dedup, update-on-change, error logging, WordPress ns, stagger, events | +| RSS/Atom parsing | 10 | RSS+Atom, link selection, guid fallback, dates, Unicode, entities | +| Theming | 3 | dark-only, no theme JS, ignores OS preference | +| Assets & nav | 4 | CSS/HTMX served, reconnect indicator | +| Resilience | 8 | empty DB, restart, concurrency, 404s, bad feeds, large feeds, NULL dates | +| Operational | 1 | bulk mark-read cutoff | +| **Total** | **76** | | + +--- + +## Implementation notes for the Gleam rewrite + +- **No auth layer to test**: the app is intentionally open; auth is the deployer's responsibility (reverse proxy, network boundary, etc.). Do not add login/logout scenarios or session-cookie assertions. + +- **HTTP-level E2E**: use `wisp.testing` request builders to assert status codes and `string.contains` on response bodies. Most scenarios above are HTTP-assertable without a real browser. +- **HTMX behavior** (toggle swaps, load-more appends): assert the response is a fragment (not a full document) and that hx-attributes are present on the returned markup. Full browser-level swap testing is optional; the contract is "server returns the right fragment + headers." +- **Background fetching**: never use real network or `process.sleep`. Mock HTTP via `http_server_mock`; control the scheduler clock or extract the pure `feeds_due(feeds, now)` decision function and test it directly. +- **Actor events**: use `send_and_confirm` (gleam-testing skill Pattern 1) — register a test subscriber, trigger the fetch, `process.receive` on the subscriber subject, assert the event. +- **Unicode assertions**: compare against literal codepoints (`"’"` not `"'"`) to catch encoding regressions (this is exactly the class of bug that killed `parsed_it`/`xmlm` in the spike). +- **Birdie snapshots**: use for rendered HTML fragments (entry cards, pages) to catch unintended markup changes; accept first run deliberately. diff --git a/README.md b/README.md index c262cbd..e836de3 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,24 @@ -# Feedreader +# feedreader_gleam -To start your Phoenix server: +[![Package Version](https://img.shields.io/hexpm/v/feedreader_gleam)](https://hex.pm/packages/feedreader_gleam) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/feedreader_gleam/) -* Run `mix setup` to install and setup dependencies -* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` +```sh +gleam add feedreader_gleam@1 +``` +```gleam +import feedreader_gleam -Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` -Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). +Further documentation can be found at . -## Learn more +## Development -* Official website: https://www.phoenixframework.org/ -* Guides: https://hexdocs.pm/phoenix/overview.html -* Docs: https://hexdocs.pm/phoenix -* Forum: https://elixirforum.com/c/phoenix-forum -* Source: https://github.com/phoenixframework/phoenix +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/SCOUTING_REPORT.md b/SCOUTING_REPORT.md new file mode 100644 index 0000000..43cb845 --- /dev/null +++ b/SCOUTING_REPORT.md @@ -0,0 +1,340 @@ +# FeedReader → Gleam: Scouting Report & Architecture Recommendation + +> Source app: Elixir + Phoenix LiveView + Ash Framework + Oban + SQLite +> Reference project: `../yard` (Gleam + Parrot + sqlight + gleam_otp on the BEAM) + +--- + +## 1. What the current app actually does + +A single-user RSS/Atom feed reader with four screens and a background polling engine. + +### 1.1 Domain / data (Ash Framework → SQLite) + +**`Feed`** — an RSS/Atom subscription +| field | type | notes | +|---|---|---| +| `id` | uuid PK | | +| `name` | string? | display name | +| `site_url` | string? | human site | +| `feed_url` | string **required** | unique identity | +| `category` | string | default `"Uncategorized"` | +| `last_fetched_at` | utc_datetime? | | +| `fetch_error` | string? | last error text | + +has_many entries (cascaded delete). + +**`Entry`** — a single article parsed from a feed +| field | type | notes | +|---|---|---| +| `id` | uuid PK | | +| `created_at` | utc_datetime | | +| `external_id` | string **required** | RSS ``/``, or link fallback | +| `title` | string? | | +| `content_link` | string? | article URL | +| `comments_link` | string? | | +| `published_at` | utc_datetime? | | +| `is_read` | bool | default false | +| `is_starred` | bool | default false | +| `feed_id` | uuid FK → feeds | required | + +Unique identity on `(feed_id, external_id)` → upsert on import. + +**`User`** / **`Token`** — AshAuthentication magic-link auth. Single `email` field. JWT tokens stored in DB. **Wired into the router but not actually protecting any route** — the `ash_authentication_live_session` block is empty and no LiveView mounts `{:live_user_required}`. Auth is handled by something in front of the app (reverse proxy / network boundary). **Effectively single-user, effectively unauthenticated at the app layer.** + +### 1.2 Business actions +- Feed: add, delete (cascade), list, log_fetch_success, log_fetch_error, **import_opml** (parse OPML XML → grouped by category `text` attr, extract `xmlUrl`/`htmlUrl`/`title`). +- Entry reads (keyset pagination, limit 50, sort published_at): + - `unread` (is_read=false, asc) + - `starred` (is_starred=true, asc) + - `history` (desc) +- Entry updates: `toggle_read`, `toggle_starred` (flip boolean), `upsert_from_feed`. + +### 1.3 Background processing (Oban) +- **Scheduler** (cron `*/3 * * * *`): list feeds, enqueue a `FetchFeed` job for each feed not fetched in the last 10 min, staggered by 1–5 random minutes. +- **FetchFeed** worker: `Req.get` (30s timeout) → `SweetXml` parse → handle RSS `` **and** Atom `` → extract guid/title/link(pubDate|published|updated)/comments → **custom RFC822 + ISO8601 date parser** with timezone handling → upsert each entry → PubSub-broadcast genuinely-new entries → log success/error. + +### 1.4 Presentation (Phoenix LiveView) +- `EntryLive.Index` — one LiveView, 3 actions (`:unread`/`:starred`/`:history`) via routes `/`, `/starred`, `/history`. + - Phoenix **Streams** for memory-efficient DOM. + - "Load More" button pagination (offset-based in practice, despite keyset config). + - `toggle_star` / `toggle_read` events → stream_insert/stream_delete with optimistic count + removal-from-view logic (e.g. unstarring removes from Starred view). +- `FeedLive.Index` — `/feeds`: add-feed form, **OPML file upload** (`allow_upload`), feed list with last-fetched/error display, delete. +- Humanized relative dates ("just now", "3h ago", "yesterday", fallback `Mon DD, YYYY`). + +**Key implication for the rewrite**: the only interactions that genuinely need to avoid a full refresh are the read/unread and star toggles (plus load-more appending, form submits, and deletes). Everything else (navigation between Unread/Starred/History/Feeds) is fine as full page loads. This makes HTMX the natural fit (see §4). + +### 1.5 UI / styling +- Tailwind CSS v4 + **DaisyUI** with custom light/dark themes (oklch colors) defined in `assets/css/app.css`. +- **No theme switcher in the actual UI.** A `Layouts.theme_toggle/1` component and localStorage/`data-theme` persistence JS exist in the codebase, but the toggle is only referenced by `home.html.heex`, which is rendered by `PageController.home/2` — and **`PageController.home` is not routed** (the default Phoenix welcome page was superseded by `live("/", :unread)` without cleanup). The 4 live routes (`/`, `/starred`, `/history`, `/feeds`) do not render the toggle. Users get their OS `prefers-color-scheme` via DaisyUI's `prefersdark: true` on the dark theme, with no in-app override. +- Heroicons. +- Layout: navbar + horizontal nav (Unread/Starred/History/Feeds), centered `max-w-4xl`. +- **Rewrite decision: dark mode only.** The light theme, the toggle component, and the localStorage/`data-theme` persistence JS are all dropped. Simpler CSS (`@plugin "daisyui" { themes: dark; }` or `data-theme="dark"` hardcoded on ``), no client-side theme JS at all. + +### 1.6 Dev / ops +- Dockerized (Dockerfile + docker-compose), GitHub Actions CI builds/pushes image. +- Dev conveniences: Oban Web dashboard, Ash Admin, LiveDashboard, Tidewave, live reload. +- `mark-read.sh` — bulk-mark-read via container RPC (operational script, not a feature). +- Pre-commit: compile, format, credo, sobelow, test. + +--- + +## 2. The mapping: Elixir/Phoenix → Gleam + +| Elixir / Phoenix concern | Gleam replacement | confidence | +|---|---|---| +| **Phoenix web framework** | **Wisp** (request/response, routing, sessions, file uploads, testing) | ✅ high | +| **HTTP server (Bandit)** | **Mist** | ✅ high | +| **Phoenix LiveView (real-time SSR)** | **Lustre element API (SSR) + HTMX** for partial swaps | ✅ high | +| **Ash Framework (domain)** | **Plain Gleam modules + custom types** (no ORM needed at this scale) | ✅ high | +| **Ecto / ash_sqlite** | **sqlight** driver | ✅ high | +| **Typed SQL / migrations** | **Parrot** (sqlc-style codegen from schema.sql + queries.sql) — *mandated by AGENTS.md*, proven in `yard` | ✅ high | +| **Oban (background jobs)** | **gleam_otp actors** (scheduler + fetch workers); optional minimal `jobs` table in SQLite for durability | ✅ high | +| **Req (HTTP client)** | **gleam_httpc** | ✅ high | +| **SweetXml (RSS/Atom parse)** | **xmlm** (pure Gleam pull parser) or **xmerl FFI** for messy real-world feeds | ⚠️ medium — see §5 | +| **RFC822 date parsing** | **custom parser** + **birl** for ISO8601/normalization | ✅ high | +| **PubSub (real-time)** | **drop SSE**; optional 30s HTMX poll for an unread badge | ✅ high | +| **AshAuthentication (magic link)** | **dropped entirely** — auth is external (reverse proxy), not enforced by the app | ✅ high | +| **Swoosh mailer** | drop (no email needs at all) | ✅ | +| **Tailwind v4 + DaisyUI** | **Tailwind v4 + DaisyUI** (unchanged — works with any server) | ✅ high | +| **Heroicons** | inline SVG / heroicon set | ✅ high | +| **Gettext (i18n)** | static strings (not meaningfully used) | ✅ | +| **LiveDashboard / Oban Web / Ash Admin** | optional minimal status route or skip | ✅ | + +--- + +## 3. Web framework decision: **Yes — use Wisp + Mist** + +**Recommendation: Wisp on Mist, targeting Erlang/BEAM.** + +- **Wisp** is Gleam's de-facto web framework (by Louis Pilfold, Gleam's creator). It gives us routing, request bodies, form parsing, **file uploads**, and an excellent testing story (handlers are pure functions of a `Request`). This covers *every* HTTP need of the current app (forms, OPML upload). +- **Mist** is the production HTTP server (and supports **SSE / WebSockets** for the real-time new-entry notification). +- **Target Erlang/BEAM** (not JavaScript) — this is critical because the background feed-fetcher is a major feature, and the BEAM's lightweight processes give us trivial concurrency for fetching dozens of feeds in parallel, exactly like the current app relies on. + +--- + +## 4. Frontend decision: **Server-rendered HTML + HTMX (no SPA)** + +This is the biggest architectural choice. Three viable paths were evaluated: + +| Option | Real-time? | UX control | Effort | Match to LiveView | +|---|---|---|---|---| +| A. Lustre SPA + Wisp JSON API + hydration | via SSE/polling | ★★★★★ (full client animation) | high | diverges | +| **B. Wisp + Lustre SSR + HTMX** ⭐ | optional poll | ★★★★ | **low** | closest | +| C. Lustre Server Components / Sprocket | built-in | ★★★★ | medium | closest (LiveView-style) | + +**Recommendation: Path B — server-rendered HTML using Lustre's element API (SSR only), with HTMX for the handful of interactions that must not full-refresh. No SPA, no JSON API, no client Gleam, no hydration, no SSE.** + +Why: +1. **The actual requirement is narrow**: server-rendered pages, but read/unread + star toggles must update without a full page refresh. That is HTMX's exact sweet spot. +2. **SSE, SSR, JSON API, and SPA overlap heavily** — adopting all of them would be the most complex option for a feature set that mostly doesn't need it. One mechanism (server emits HTML fragments, HTMX swaps them) replaces all four. +3. **Lustre stays in the stack, but only for its HTML-builder API** (`element.to_document_string` / `element.to_string`). It's the most mature typed HTML generator in Gleam and gives clean server-side rendering. We never start a client runtime. +4. **World-class UI is still achievable**: DaisyUI + Tailwind hover/transition classes handle micro-interactions, and HTMX supports swap transitions. A sprinkle of Alpine.js is available if a tiny bit of client state is ever needed (probably not). +5. **No surface-area drift**: there's no JSON API contract to design and keep in sync with a separate client model. HTMX speaks form-encoded requests and gets HTML back. + +Path A (full SPA) is overkill given the requirement; Path C (Server Components) is appealing but younger and less battle-tested. Path B is the closest spiritual successor to the current Phoenix LiveView app at a fraction of the complexity. + +**Styling is preserved unchanged**: Tailwind v4 + DaisyUI with the existing light/dark themes. + +--- + +## 5. XML / RSS parsing — the one real risk area + +The current parser uses `SweetXml` (xmerl-backed) and handles **both RSS `` and Atom ``**, messy date formats (RFC822 with named timezones), and malformed feeds. Options: + +- **`xmlm`** — pure Gleam pull-based XML parser. Clean, but we'd hand-write the RSS/Atom extraction + the RFC822 date logic (the Elixir app already hand-wrote the latter, so this is ~portable). +- **`xmerl` via Erlang FFI** — the most robust against real-world broken feeds (same engine SweetXml uses). Pragmatic choice if `xmlm` struggles. +- **OPML** uses the same parser (it's XML with `outline` elements). + +**Plan**: Start with `xmlm`, port the existing RSS/Atom + RFC822 logic (it's already mostly framework-agnostic string munging), and fall back to an `xmerl` FFI if real-world feeds expose parser weaknesses. *(Note: the one known pure-Gleam RSS reader, billuc's, used nibble and reported 512MB / 3s for basic feeds — we will avoid a from-scratch nibble parser.)* + +--- + +## 6. Proposed Gleam project structure + +``` +feedreader/ (single gleam app, target = erlang) +├── gleam.toml +├── schema.sql # source of truth DDL (feeds, entries) +├── queries.sql # Parrot query source +├── src/ +│ ├── feedreader.gleam # app entry, supervision tree, config +│ ├── feedreader/ +│ │ ├── sql.gleam # ⚙ Parrot-generated (do not edit) +│ │ ├── db.gleam # typed CRUD over sql.gleam + sqlight (mirrors yard/db.gleam) +│ │ ├── feed.gleam # Feed type + business logic (add/delete/list/import_opml) +│ │ ├── entry.gleam # Entry type + reads (unread/starred/history) + toggles +│ │ ├── rss.gleam # RSS/Atom parse → List(EntryAttrs) +│ │ ├── opml.gleam # OPML parse → List(FeedAttrs) +│ │ ├── date.gleam # RFC822 + ISO8601 → birl +│ │ ├── http.gleam # feed fetcher (gleam_httpc) +│ │ ├── scheduler.gleam # gleam_otp actor: cron-ish tick → enqueue fetches +│ │ └── fetcher.gleam # gleam_otp actor(s): fetch+parse+upsert, log success/error +│ └── feedreader/web/ +│ ├── server.gleam # Mist + Wisp bootstrap +│ ├── router.gleam # routes: page handlers (full HTML) + HTMX fragment handlers + static assets +│ ├── pages.gleam # full-page render (Unread/Starred/History/Feeds) via Lustre element API +│ ├── fragments.gleam # HTMX partial responses (single card, feed row, toast, load-more) +│ └── html/ # Lustre element builders (layout, entry_card, feed_card, nav) +├── priv/ +│ ├── static/ +│ │ ├── app.css # Tailwind v4 + DaisyUI themes (ported from assets/css/app.css) +│ │ ├── htmx.min.js # vendored HTMX +│ │ └── (optional) alpine.min.js +└── test/ +``` + +### Supervision tree (mirrors current `Application`) +``` +Application +├── sqlight connection (opened once, held by a db actor / shared) +├── Scheduler actor ──(every N min)──▶ enqueues Feed(feed_id) msgs +├── Fetcher actor(s) ◀──(processes Feed(feed_id), fetches+parses+upserts) +└── Mist HTTP server (Wisp router: page handlers + HTMX fragments + static assets) +``` + +### Request flow (HTMX) +- **Full pages**: `GET /` → server renders full HTML document (nav + entry cards) via Lustre `element.to_document_string`. +- **Toggles**: `POST /entry//toggle-read` (HTMX) → server returns just that card's HTML fragment → HTMX swaps it in place. No full refresh, no JSON. +- **Load more**: `GET /?after=` (HTMX) → server returns the next batch of cards + the next "load more" button. +- **Forms / delete / upload**: HTMX POST/DELETE returning the relevant fragment. +- **Optional live badge**: `` polls a tiny count endpoint. Feeds refresh on a 10-min cycle, so polling is plenty. + +--- + +## 7. Feature parity checklist (what carries over) + +- [x] Feed CRUD (add / list / delete with cascade) +- [x] Entry reads: unread / starred / history, paginated +- [x] Toggle read / starred (no full refresh — HTMX swap; preserves the view-removal semantics from LiveView) +- [x] OPML import (file upload + parse + bulk add, grouped by category) +- [x] Background polling (cron scheduler + per-feed fetch workers, staggered) +- [x] RSS **and** Atom parsing, upsert-on-import (dedupe by external_id) +- [x] Date normalization (RFC822 + ISO8601) +- [x] Feed health display (last_fetched_at, fetch_error) +- [x] Real-time new-entry notification → simplified to optional 30s HTMX poll of unread count (no SSE) +- [x] Relative date humanization +- [x] Tailwind v4 + DaisyUI **dark mode only** (current app defines light/dark themes but has no theme switcher UI; rewrite drops light + the dead toggle plumbing entirely) +- [x] **No auth** (matches the Elixir deployment — auth is external, the app is open) +- [x] Docker build + CI + +**Intentionally simplified / dropped:** +- **Auth entirely** → the app is open; auth is the deployer's responsibility (reverse proxy / network boundary). The Elixir app's AshAuthentication is wired but protects no route. +- SSE/WebSocket real-time push → optional polling (feeds refresh on a 10-min cycle anyway). +- SPA / client Gleam / JSON API / hydration → server-rendered HTML + HTMX (per actual requirement). +- Gettext i18n → static strings. +- Oban Web / Ash Admin / LiveDashboard dev dashboards → optional minimal `/status` route. + +**Opportunities to improve during the rewrite:** +- True keyset/cursor pagination (current code uses offset). +- Parallel feed fetching via BEAM processes (cleaner than Oban queue for this workload). + +--- + +## 8. Recommended dependency set (from packages.gleam.run survey) + +### Core stack +| package | role | +|---|---| +| `gleam_stdlib` | stdlib | +| `gleam_erlang` | BEAM process/time interop | +| `gleam_otp` | actors + supervision (scheduler, fetchers) | +| `wisp` | web framework (routing, forms, uploads, sessions) | +| `mist` | HTTP server | +| `lustre` | HTML element API, **SSR-only** (`element.to_document_string` / `to_string_tree`) | +| `lustre_pipes` | pipe-operator builders for Lustre views — required for human + LLM-agent readability; composes with `hx` (targets `lustre < 6.0.0`) | +| ⭐ `hx` | typed HTMX attributes for Lustre + HTMX response headers for Wisp — the server↔client glue | +| `sqlight` | SQLite driver | +| `parrot` | typed SQL codegen (mandated by AGENTS.md, proven in `yard`) | +| `gleam_httpc` | HTTP client for feed fetching | +| `birl` | date/time (ISO8601 + normalization; used in `yard`) | +| `gluid` | UUID v4 (used in `yard`) | +| `envoy` | env vars, zero-dep (used in `yard`/`hermes`) | +| `gleam_json` | JSON (HTMX trigger payloads, any JSON needs) | +| `logging` | Erlang logger config | + +### XML / RSS +| package | role | +|---|---| +| **`xmerl` via Erlang FFI** (~40 lines) | **SOLE XML parser** — chosen after spike. Erlang's built-in XML engine (same as Elixir's SweetXml). Handles WordPress namespaces, entities, Unicode. No Gleam package dep. | + +> **XML spike result (resolved):** Tested `xmlm` v1.0.1 (pure Gleam) and `xmerl` FFI against real feeds: OPML (77 feeds), Lobsters RSS, Hacker News RSS (non-ASCII titles), xkcd Atom, GitHub Blog Atom (WordPress namespace), fasterthanli.me Atom (heavy Unicode). +> +> **`xmlm` FAILED** on `` — the WordPress namespace declaration. Error: `unknown namespace prefix ()`. This namespace appears on millions of WordPress blogs (GitHub Blog, countless sites). Hard blocker for a feed reader. +> +> **`xmerl` FFI succeeded on ALL feeds**: parsed GitHub Blog (10 items), preserved Unicode (HN smart quotes, em-dashes, accents), decoded entities (`'`→`'`). The FFI is ~40 lines of Erlang (`xmerl_ffi.erl`) that walks `#xmlElement{}`/`#xmlText{}` records into a simple `{element, Tag, Attrs, Children} | {text, String}` structure, with field-accessor functions for Gleam `@external` calls. Same battle-tested engine as Elixir's SweetXml. +> +> Other packages checked: `parsed_it` (decode-style API but documented Unicode-corruption bug on Erlang XML → rejected), `webls` (feed *generator* not parser → n/a), `htmgrrrl` (HTML SAX, not XML-purpose). + +### Forms / parsing +| package | role | +|---|---| +| ⭐ `formal` | type-safe HTML form decoding/validation (add-feed form, OPML upload) | +| `glentities` | HTML entity encoding (safe rendering of feed titles/content) | + +### Dev / testing +| package | role | +|---|---| +| `gleeunit` | test runner | +| ⭐ `birdie` | snapshot testing — pairs with Lustre `to_readable_string` for asserting HTMX fragment HTML | +| ⭐ `http_server_mock` | HTTP API mocking — stubs feed fetches in tests (replaces Elixir's `Req.Test`) | +| `mist_reload` | dev hot reload of the mist server | + +### Optional / later +| package | role | +|---|---| +| `plume` | security headers (helmet-style) | +| `dot_env` | load the existing `.env` | +| `ghtml` | *optional* HTML-markup → Gleam codegen layer; only if pipe-style Lustre still feels verbose after the spike (v0.1.1, generates Lustre) | +| `automata` | cron/RRULE (already a `yard` dep) — if we want richer scheduling than a simple timer | + +> Note on `lustre_pipes` (now a core dep): it's purely syntactic sugar over standard Lustre `Element`/`Attribute` types, so it composes with `hx` and any Lustre-compatible package. If it ever breaks or is abandoned, the fallback is a mechanical refactor back to nested calls or a tiny vendored pipe-wrapper module (~12 lines) — low blast radius. + +### Considered and rejected +- **nakai** — server HTML gen; fine alternative to Lustre SSR, but no HTMX companion and less momentum. Lustre wins. +- **rally / lightspeed / sprocket / lustre_server_components** — alternative server-framework/LiveView-style libs; rejected in favor of plain Wisp+Lustre+HTMX. +- **datastar_gleam / datastar_lustre** — Datastar is an HTMX alternative; we standardized on HTMX. +- **wisp_inertia** — Inertia.js (SPA-style rendering); not our pattern. +- **sketch** — CSS-in-Gleam; we use Tailwind. +- **kielet** — gettext; i18n dropped. +- **migrant** — DB migrations; Parrot schema + `IF NOT EXISTS` (as in `yard`) suffices. +- **miniflux_sdk** — we're building a reader, not consuming Miniflux. +- **mork / jot** — Markdown; entries link out, we don't render article bodies. + +--- + +## 9. Open unknowns to resolve before coding + +1. **Job durability**: do we need a `jobs` table (retry on crash) or is in-memory actor scheduling enough? Recommend in-memory for v1; feeds are idempotent to re-fetch. +2. **Live badge**: include the optional 30s unread-count poll, or drop live notification entirely? + +> Auth is settled: **no auth in the app** (external responsibility). The `User`/`Token` tables are dropped from the rewrite. + +--- + +## 9.5 Spike results (both retired) + +### XML parser spike +- **`xmlm`** (pure Gleam v1.0.1) — **rejected**: crashes on `` (WordPress namespace, millions of blogs incl. GitHub Blog). Error: `unknown namespace prefix ()`. +- **`xmerl` FFI** — **chosen**: parses ALL real test feeds (OPML 77 feeds, Lobsters/HN RSS, xkcd/GitHub/ftl Atom), preserves Unicode (HN smart quotes, em-dashes), decodes entities (`'`→`'`). FFI is ~40 lines Erlang (`xmerl_ffi.erl`) exposing `parse/1`, `node_kind/1`, `node_tag/1`, `node_attrs/1`, `node_children/1`, `node_text/1`. Same engine as Elixir's SweetXml. **Must convert binary→charlist for `xmerl_scan:string/2`.** +- Other packages checked & rejected: `parsed_it` (Unicode-corruption bug), `webls` (generator not parser), `htmgrrrl` (HTML not XML). + +### Web stack spike +Built minimal server: Wisp + Mist + Lustre (SSR) + lustre_pipes + hx. **All 5 packages compose.** Verified output: +- `GET /` → full HTML doc via `element.to_document_string`; Unicode intact (`·`); HTMX attrs emitted correctly. +- `POST /toggle/:id` → just the card fragment via `element.to_string`; `hx-trigger: showToast` header via `hx_header.trigger`. + +**Integration details to remember for the build:** +- `hx.*` functions (`hx.post(url:)`, `hx.target(hx.Closest(".card"))`, `hx.swap(hx.OuterHTML)`) return a Lustre `attribute.Attribute(msg)`, **not** a `lustre_pipes.element.Scaffold(msg)` fn. So they pipe via `a.add(scaffold, attr)`, not bare `hx.post(...)`. A 1-line `hx_attr` adapter makes them pipe bare if preferred. +- `wisp_mist.handler(handler, secret_key_base)` — requires a `secret_key_base: String` arg. Unused for auth (no sessions/cookies); pass any long random string to satisfy the API. +- `mist.start` (not `start_http`) on a `mist.new(handler) |> mist.port(n)` builder. +- `wisp.handle_head` callback receives the `Request`; `wisp.serve_static` / `wisp.log_request` callbacks receive nothing — mind the `use` arity. +- `lustre_pipes`: childless elements (``, ``, ` - - - - {@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..1bc508a 100644 --- a/mise.toml +++ b/mise.toml @@ -1,46 +1,48 @@ [tools] -elixir = "1.19.5" -erlang = "28.3" +# 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 = "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 7f372bf..0000000 Binary files a/priv/static/favicon.ico and /dev/null differ 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/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..9cf9b24 --- /dev/null +++ b/src/feedreader/date.gleam @@ -0,0 +1,53 @@ +//// 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/list +import gleam/option.{type Option, None, Some} +import gleam/result +import gleam/string + +/// 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 { + Some(raw) if raw != "" -> parse_raw(raw) + _ -> None + } +} + +/// 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 { + True -> Error(Nil) + False -> birl.from_http(normalized) + } +} + +/// 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..a1b1497 --- /dev/null +++ b/src/feedreader/fetcher.gleam @@ -0,0 +1,126 @@ +//// 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 gleam/result +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() + + // 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:) + } + } +} + +// ═══════════════════════════════════════════════════════════════ +// 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..5b3708a --- /dev/null +++ b/src/feedreader/rss.gleam @@ -0,0 +1,138 @@ +//// 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 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 > 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 -> 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..582132a --- /dev/null +++ b/src/feedreader/web/router.gleam @@ -0,0 +1,342 @@ +//// 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) + 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() + |> 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..3744997 --- /dev/null +++ b/src/feedreader/web/server.gleam @@ -0,0 +1,119 @@ +//// 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) -> { + 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. + 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 == "http://example.com/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)