sources: split each kind into a provider and its source
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/AGENTS.md b/AGENTS.md
index 4b280b7..4e7e93d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,8 +26,8 @@ lib/rank_king/ — app code
db.rb — Sequel connection + migration runner
models.rb — Sequel models (User, List, Item, Axis, Result)
inner_tube.rb — YouTube client: InnerTube, signed with a pasted browser cookie
- sources.rb — Sources.fetch: a list's source_kind names a class in Sources
- sources/ — one class per source kind
+ sources.rb — Sources.providers: a list's source_kind names one of them
+ sources/ — one class per source, each with the Provider that makes it
sync.rb — reconciles a list's items with a fresh snapshot from a fetching source
proxy_auth.rb — reads the login from Remote-User
seed.rb — sample lists for the stub login (demo.ru)
@@ -51,7 +51,7 @@ fly.toml — Fly config for the main demo and PR previews
## Domain model
- **User** has many Lists; identified by the login on the `Remote-User` header
-- **List** belongs to a User and has many Items and Axes, and one source (`source_kind` names a class in `Sources`). Each source renders its own sync dialog on the list page and applies what it posts: `Text`'s edits items by id, and a kind that fetches calls `Sync.call`. A fetching kind keeps its config in its own table keyed by `list_id` and its per-item details in a table keyed by `item_id`
+- **List** belongs to a User and has many Items and Axes, and one source (`source_kind` names its provider in `Sources.providers`). Each source renders its own sync dialog on the list page and applies what it posts: `Text`'s edits items by id, and a source that fetches calls `Sync.call`. A fetching source keeps its config in its own table keyed by `list_id` and its per-item details in a table keyed by `item_id`
- Sources: `Text` (typed in, edited by id) and `YouTube` (follows one of the owner's playlists, Watch Later included, with a youtube.com cookie pasted for each fetch and never kept; `youtube_sources` by `list_id`, `youtube_items` by `item_id`)
- **Item** belongs to a List; removed items, and items missing from the latest sync, are archived (`archived_at`) and hidden everywhere
- **Axis** belongs to a List, has many Results
@@ -87,9 +87,8 @@ fly.toml — Fly config for the main demo and PR previews
- `SESSION_SECRET` — session signing key (auto-generated if unset)
Tests set `DATABASE_URL=:memory:` in `test/test_helper.rb`. Clients that
-talk to the outside world live on `Config`, which the app hands to every
-source; a source types the argument as an interface naming only what it
-reads (`Sources::_InnerTubeConfig`). The YouTube tests subclass the app
+talk to the outside world live on `Config`; `Sources.providers` hands
+each provider only the clients it needs. The YouTube tests subclass the app
to override `config` with an `InnerTube` subclass whose `request`
answers without the network.
@@ -127,12 +126,13 @@ rank-king never stores it.
screen's own actions). List actions go in the list's ⋯ menu and list
options in its settings form, so a new feature adds an entry there
rather than a new button or screen.
-- A list's source is fixed when it is made. `Sources::KINDS` lists the
- kinds the new-list form offers, and each kind's `title` names it there
- and in settings. Its sync dialog opens from the list's ⋯ menu. Each
- kind also renders its own fields on the new-list form (`fields`),
- sets the new list up from them (`create`), and mounts any routes those
- fields post to (`routes`).
+- A list's source is fixed when it is made. `Sources.providers` lists
+ the providers the new-list form offers, and each provider's `title`
+ names it there and in settings. Its sync dialog opens from the list's ⋯
+ menu. Each provider also renders its own fields on the new-list form
+ (`fields`), sets the new list up from them (`create`), mounts any
+ routes those fields post to (`routes`), and opens a list's source
+ (`source`).
- Every request needs a `Remote-User` header or it gets a 401. That is safe only because a proxy (Tailscale identity) writes the header. `StubLogin` fills it in for `demo.ru` (dev server, Fly apps) and nowhere else: never in `config.ru`, and never in front of real data.
- Lists are private to their owner (404 for anyone else)
- Config is a frozen `Data.define` value object
diff --git a/lib/rank_king/sources.rb b/lib/rank_king/sources.rb
index b26d969..d95c7bd 100644
--- a/lib/rank_king/sources.rb
+++ b/lib/rank_king/sources.rb
@@ -9,7 +9,7 @@ module RankKing
# @rbs skip
Entry = Data.define(:key, :name)
- # Raised by a kind's create when the new-list form did not post enough
+ # Raised by a provider's create when the new-list form did not post enough
# to set the list up; the message says what is missing.
class Invalid < StandardError; end
end
@@ -20,12 +20,13 @@ require_relative "sources/youtube"
module RankKing
module Sources
- # Every kind a list can be made from, in the order the new-list form
- # offers them. A list keeps its kind for good.
- KINDS = %w[Text YouTube].freeze #: Array[String]
-
- # A list's source_kind names a class in this module.
- #: (String kind) -> _SourceClass
- def self.fetch(kind) = const_get(kind, false)
+ # Every provider a list can be made from, by the name a list's
+ # source_kind keeps, in the order the new-list form offers them. A list
+ # keeps its provider for good.
+ #: (Config config) -> Hash[String, _Provider]
+ def self.providers(config) = {
+ "Text" => Text::Provider.new,
+ "YouTube" => YouTube::Provider.new(config.inner_tube),
+ }
end
end
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index 1282e25..849eb39 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -8,27 +8,29 @@ module RankKing
# over the list's items, which matches them by id, so there is nothing
# to fetch and no table of its own.
class Text
- #: (List list, Config config) -> Text
- def self.for(list, config) = new(list)
+ class Provider
+ #: () -> String
+ def title = "Typed list"
- #: () -> String
- def self.title = "Typed list"
+ #: (List list) -> Text
+ def source(list) = Text.new(list)
- #: (csrf: Method) -> _Component
- def self.fields(csrf:) = Views::Sources::TextFields.new
+ #: (csrf: Method) -> _Component
+ def fields(csrf:) = Views::Sources::TextFields.new
- #: (Roda::RodaRequest r, Config config) -> void
- def self.routes(r, config) = nil
+ #: (Roda::RodaRequest r) -> void
+ def routes(r) = nil
- # Adds the items typed into the new-list form, of which there must
- # be at least two.
- #: (List list, Hash[String, untyped] params, Config config) -> void
- def self.create(list, params, config)
- names = params["items"].to_s.lines.map(&:strip).reject(&:empty?)
- raise Invalid, "A list needs at least two items." if names.size < 2
+ # Adds the items typed into the new-list form, of which there must
+ # be at least two.
+ #: (List list, Hash[String, untyped] params) -> void
+ def create(list, params)
+ names = params["items"].to_s.lines.map(&:strip).reject(&:empty?)
+ raise Invalid, "A list needs at least two items." if names.size < 2
- names.each do |name|
- Item.create(list_id: list.id, name:)
+ names.each do |name|
+ Item.create(list_id: list.id, name:)
+ end
end
end
diff --git a/lib/rank_king/sources/youtube.rb b/lib/rank_king/sources/youtube.rb
index 85aab21..241611d 100644
--- a/lib/rank_king/sources/youtube.rb
+++ b/lib/rank_king/sources/youtube.rb
@@ -19,31 +19,49 @@ module RankKing
# playlists.
PLAYLISTS_PATH = "/youtube/playlists"
- #: () -> String
- def self.title = "YouTube playlist"
+ class Provider
+ #: (InnerTube inner_tube) -> void
+ def initialize(inner_tube)
+ @inner_tube = inner_tube
+ end
- #: (List list, _InnerTubeConfig config) -> YouTube
- def self.for(list, config) = new(list, config.inner_tube)
+ #: () -> String
+ def title = "YouTube playlist"
- #: (csrf: Method) -> _Component
- def self.fields(csrf:) = Views::Sources::YouTubeFields.new(csrf:)
-
- # The new-list form's playlist picker, drawn from a pasted cookie. It
- # is a POST so the cookie stays out of URLs and logs.
- #: (Roda::RodaRequest r, _InnerTubeConfig config) -> void
- def self.routes(r, config)
- r.post PLAYLISTS_PATH.delete_prefix("/") do
- playlists(r.params["cookie"], config).call
+ #: (List list) -> YouTube
+ def source(list) = YouTube.new(list, @inner_tube)
+
+ #: (csrf: Method) -> _Component
+ def fields(csrf:) = Views::Sources::YouTubeFields.new(csrf:)
+
+ # The new-list form's playlist picker, drawn from a pasted cookie. It
+ # is a POST so the cookie stays out of URLs and logs.
+ #: (Roda::RodaRequest r) -> void
+ def routes(r)
+ r.post PLAYLISTS_PATH.delete_prefix("/") do
+ playlists(r.params["cookie"]).call
+ end
end
- end
- # The choice of playlists a pasted cookie opens. Whatever stops it
- # is said in the choice's place.
- #: (untyped text, _InnerTubeConfig config) -> _Component
- def self.playlists(text, config)
- Views::Sources::YouTubePlaylists.new(playlists: config.inner_tube.playlists(cookie(text)))
- rescue InnerTube::Error => e
- Views::Sources::YouTubePlaylists.new(playlists: nil, error: e.message)
+ # The choice of playlists a pasted cookie opens. Whatever stops it
+ # is said in the choice's place.
+ #: (untyped text) -> _Component
+ def playlists(text)
+ Views::Sources::YouTubePlaylists.new(playlists: @inner_tube.playlists(YouTube.cookie(text)))
+ rescue InnerTube::Error => e
+ Views::Sources::YouTubePlaylists.new(playlists: nil, error: e.message)
+ end
+
+ # Follows the playlist chosen on the new-list form and fetches it
+ # with the cookie pasted there.
+ #: (List list, Hash[String, untyped] params) -> void
+ def create(list, params)
+ playlist_id = params["playlist"].to_s[/\A[\w-]+\z/] or raise Invalid, "Choose a playlist to follow."
+ cookie = InnerTube.cookie(params["cookie"].to_s) or raise Invalid, "That cookie has no SAPISID to sign requests with."
+
+ DB[TABLE].insert(list_id: list.id, playlist_id:)
+ source(list).fetch(cookie)
+ end
end
#: (untyped text) -> String
@@ -51,17 +69,6 @@ module RankKing
InnerTube.cookie(text.to_s) or raise InnerTube::Error, "That cookie has no SAPISID to sign requests with."
end
- # Follows the playlist chosen on the new-list form and fetches it
- # with the cookie pasted there.
- #: (List list, Hash[String, untyped] params, _InnerTubeConfig config) -> void
- def self.create(list, params, config)
- playlist_id = params["playlist"].to_s[/\A[\w-]+\z/] or raise Invalid, "Choose a playlist to follow."
- cookie = InnerTube.cookie(params["cookie"].to_s) or raise Invalid, "That cookie has no SAPISID to sign requests with."
-
- DB[TABLE].insert(list_id: list.id, playlist_id:)
- new(list, config.inner_tube).fetch(cookie)
- end
-
#: (List list, InnerTube inner_tube) -> void
def initialize(list, inner_tube)
@list = list
diff --git a/lib/rank_king/sync.rb b/lib/rank_king/sync.rb
index b99ff10..a0b10f3 100644
--- a/lib/rank_king/sync.rb
+++ b/lib/rank_king/sync.rb
@@ -6,7 +6,7 @@ module RankKing
# Reconciles a list's items with a fresh snapshot of entries from its
# source. Items missing from the snapshot are archived rather than deleted
# so their results survive, and come back with their history if they
- # reappear. A kind that fetches its items calls this from its own sync.
+ # reappear. A source that fetches its items calls this from its own sync.
module Sync
# Signed in sig/rank_king/sync.rbs: a Data class.
# @rbs skip
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 1101777..73c402e 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -40,7 +40,7 @@ module RankKing
Views::Home.new(lists: lists).call
end
- Sources::KINDS.each { |kind| Sources.fetch(kind).routes(r, config) }
+ providers.each_value { |provider| provider.routes(r) }
r.on "lists" do
r.is do
@@ -49,7 +49,7 @@ module RankKing
r.halt 422 if name.empty?
source_kind = r.params["source"] || "Text"
- r.halt 422 unless Sources::KINDS.include?(source_kind)
+ provider = providers[source_kind] or r.halt 422
axis_names = names_param(r.params["axes"])
@@ -57,7 +57,7 @@ module RankKing
# nothing is made.
list = DB.transaction {
l = List.create(name: name, user_id: user.id, source_kind: source_kind)
- Sources.fetch(source_kind).create(l, r.params, config)
+ provider.create(l, r.params)
axis_names.each do |n|
Axis.create(list_id: l.id, name: n)
@@ -70,11 +70,10 @@ module RankKing
end
r.get "new" do
- kinds = Sources::KINDS.map { |kind|
- source = Sources.fetch(kind)
- [kind, source.title, source.fields(csrf: method(:csrf_token))] #: [String, String, Sources::_Component]
+ sources = providers.map { |name, provider|
+ [name, provider.title, provider.fields(csrf: method(:csrf_token))] #: [String, String, Sources::_Component]
}
- Views::ListViews::New.new(sources: kinds, csrf: method(:csrf_token)).call
+ Views::ListViews::New.new(sources:, csrf: method(:csrf_token)).call
end
r.on Integer do |list_id|
@@ -91,7 +90,7 @@ module RankKing
r.is "settings" do
r.get do
- source = Sources.fetch(list.source_kind).title
+ source = provider(list).title
Views::ListViews::Settings.new(list: list, source: source, csrf: method(:csrf_token)).call
end
@@ -168,8 +167,14 @@ module RankKing
#: () -> Config
def config = CONFIG
+ #: () -> Hash[String, Sources::_Provider]
+ def providers = @providers ||= Sources.providers(config)
+
+ #: (List list) -> Sources::_Provider
+ def provider(list) = providers.fetch(list.source_kind)
+
#: (List list) -> Sources::_Source
- def source(list) = Sources.fetch(list.source_kind).for(list, config)
+ def source(list) = provider(list).source(list)
# Names from a repeated field (`axes[]`), one per tag.
#: (untyped) -> Array[String]
diff --git a/sig/rank_king/sources.rbs b/sig/rank_king/sources.rbs
index 66e12e3..eb6a6e9 100644
--- a/sig/rank_king/sources.rbs
+++ b/sig/rank_king/sources.rbs
@@ -1,6 +1,6 @@
# Entry is a Data class, whose members the inline parser cannot read, and
-# the interfaces every source kind meets are named here so Sources.fetch
-# and List#source can be typed by them.
+# the interfaces every provider and source meets are named here so
+# Sources.providers can be typed by them.
module RankKing
module Sources
class Entry
@@ -29,35 +29,31 @@ module RankKing
def items_by_key: () -> Hash[String, Item]
# Called by sync for each entry once its item is saved, to keep
- # whatever the kind knows about the item in its own table.
+ # whatever the source knows about the item in its own table.
def saved: (Item item, Entry entry) -> void
end
- # A source kind: a class in Sources, named by a list's source_kind.
- interface _SourceClass
- # The source for a list, configured from the kind's own table and
- # the app's config.
- def for: (List list, Config config) -> _Source
+ # What a list is made from, holding whatever of the app's config its
+ # sources need, named by a list's source_kind.
+ interface _Provider
+ # The source for a list made from this provider.
+ def source: (List list) -> _Source
- # What the kind is called where a list's source is chosen or shown.
+ # What the provider is called where a list's source is chosen or
+ # shown.
def title: () -> String
- # The kind's own fields on the new-list form, which may post to
+ # The provider's own fields on the new-list form, which may post to
# routes of their own as they are filled in.
def fields: (csrf: Method) -> _Component
# Sets a new list up from what those fields posted, raising Invalid,
# and the list is not made, when they are not enough to go on.
- def create: (List list, Hash[String, untyped] params, Config config) -> void
+ def create: (List list, Hash[String, untyped] params) -> void
- # Mounts the routes the kind's own fields and dialogs post to, ahead
- # of the app's.
- def routes: (Roda::RodaRequest r, Config config) -> void
- end
-
- # All a YouTube source reads off the app's config.
- interface _InnerTubeConfig
- def inner_tube: () -> InnerTube
+ # Mounts the routes the provider's own fields and dialogs post to,
+ # ahead of the app's.
+ def routes: (Roda::RodaRequest r) -> void
end
# A view component, as a source's form is.
diff --git a/test/test_sync.rb b/test/test_sync.rb
index 20acff9..ad0f49b 100644
--- a/test/test_sync.rb
+++ b/test/test_sync.rb
@@ -10,7 +10,7 @@ class TestSync < Minitest::Test
include RankKing
# A synced source that keeps each item's key in memory, standing in for a
- # kind that keeps its keys in its own table keyed by item_id.
+ # source that keeps its keys in its own table keyed by item_id.
class FakeSource
attr_reader :calls