Make Text a manual source
Text items are added and removed by id on the list page, with no sync
and no table of their own. Items lose external_id: synced kinds match
items through their own tables keyed by item_id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
diff --git a/AGENTS.md b/AGENTS.md
index 411ded5..11f6d22 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,7 +26,7 @@ lib/rank_king/ — app code
models.rb — Sequel models (User, List, Item, Axis, Result)
sources.rb — Sources.fetch: a list's source_kind names a class in Sources
sources/ — one class per source kind
- sync.rb — reconciles a list's items with a fresh snapshot from its source
+ sync.rb — reconciles a synced list's items with a fresh snapshot from its source
proxy_auth.rb — reads the login from Remote-User
stub_login.rb — writes a fixed login where there is no proxy (demo.ru)
web.rb — Roda routes
@@ -45,8 +45,8 @@ 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`, e.g. `Text`; a kind that needs configuring keeps it in its own table keyed by `list_id`) that produces its Items
-- **Item** belongs to a List; items missing from the latest sync are archived (`archived_at`) and hidden everywhere
+- **List** belongs to a User and has many Items and Axes, and one source (`source_kind` names a class in `Sources`). `Text` is manual: items are added and removed by hand. A synced kind keeps its config in its own table keyed by `list_id` and its per-item details in a table keyed 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
- **Result** belongs to an Axis, a winner (Item), and a loser (Item)
diff --git a/db/migrate/004_add_list_sources.rb b/db/migrate/004_add_list_sources.rb
index 11280b4..4984a96 100644
--- a/db/migrate/004_add_list_sources.rb
+++ b/db/migrate/004_add_list_sources.rb
@@ -11,23 +11,17 @@ Sequel.migration do
add_column :synced_at, DateTime
end
+ # Items keep only what any source has. How a kind recognizes its items
+ # across syncs, and anything else it knows about them, lives in its own
+ # table keyed by item_id; Text recognizes items by name.
alter_table(:items) do
- add_column :external_id, String
add_column :url, String
add_column :archived_at, DateTime
- add_index [:list_id, :external_id], unique: true
end
-
- # A text item's external id is its name. Duplicate names keep a nil
- # external_id and are left alone by sync.
- first_ids = from(:items).group(:list_id, :name).select { min(:id) }
- from(:items).where(id: first_ids).update(external_id: :name)
end
down do
alter_table(:items) do
- drop_index [:list_id, :external_id]
- drop_column :external_id
drop_column :url
drop_column :archived_at
end
diff --git a/lib/rank_king/sources.rb b/lib/rank_king/sources.rb
index 20c49b3..cd08a41 100644
--- a/lib/rank_king/sources.rb
+++ b/lib/rank_king/sources.rb
@@ -3,12 +3,11 @@
module RankKing
module Sources
# What every source produces: one entry per item currently in the source.
- # external_id is how sync recognizes the same item across snapshots.
- # Only what any source has goes here; a kind's own item details belong
- # in its own table.
+ # key is how the source recognizes the same item across syncs (see
+ # items_by_key); name and url are the item fields any source has.
# Signed in sig/rank_king/sources.rbs: a Data class.
# @rbs skip
- Entry = Data.define(:external_id, :name, :url)
+ Entry = Data.define(:key, :name, :url)
end
end
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index 8caa4c9..4d15369 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -2,21 +2,11 @@
module RankKing
module Sources
- # A push source: the snapshot is pasted text, one item per line.
+ # A manual source: items are added and removed by hand on the list page,
+ # each by its own id, so there is nothing to sync.
class Text
#: () -> bool
- def self.push? = true
-
- # Text has no configuration, so every list's text source is the same.
- #: (List list) -> Text
- def self.for(list) = new
-
- #: (untyped snapshot) -> Array[Entry]
- def entries(snapshot)
- snapshot.to_s.lines.map(&:strip).reject(&:empty?).uniq.map {|line|
- Entry.new(external_id: line, name: line, url: nil)
- }
- end
+ def self.manual? = true
end
end
end
diff --git a/lib/rank_king/sync.rb b/lib/rank_king/sync.rb
index e660bcf..3c73739 100644
--- a/lib/rank_king/sync.rb
+++ b/lib/rank_king/sync.rb
@@ -12,21 +12,22 @@ module RankKing
# @rbs skip
Summary = Data.define(:added, :restored, :archived)
- #: (List list, Array[Sources::Entry] entries, ?now: Time) -> Summary
- def self.call(list, entries, now: Time.now)
+ #: (List list, Array[Sources::Entry] entries, ?source: Sources::_Source, ?now: Time) -> Summary
+ def self.call(list, entries, source: list.source, now: Time.now)
DB.transaction do
- existing = Item.where(list_id: list.id).exclude(external_id: nil).to_hash(:external_id)
+ existing = source.items_by_key
added = restored = 0
entries.each do |entry|
attrs = { name: entry.name, url: entry.url, archived_at: nil }
- if (item = existing.delete(entry.external_id))
+ if (item = existing.delete(entry.key))
restored += 1 if item.archived_at
item.update(**attrs)
else
- Item.create(**attrs, list_id: list.id, external_id: entry.external_id)
+ item = Item.create(**attrs, list_id: list.id)
added += 1
end
+ source.saved(item, entry)
end
gone = existing.values.select {|item| item.archived_at.nil? }
diff --git a/lib/rank_king/views/list/show.rb b/lib/rank_king/views/list/show.rb
index cb04a81..9a65f09 100644
--- a/lib/rank_king/views/list/show.rb
+++ b/lib/rank_king/views/list/show.rb
@@ -29,34 +29,51 @@ module RankKing
private
def render_items
+ manual = Sources.fetch(@list.source_kind).manual?
+
section(class: "section") do
h2 { "Items" }
ul(class: "item-list") do
@list.items.each do |item|
- li { item.name }
+ li do
+ plain item.name
+ render_archive(item) if manual
+ end
end
end
+ render_add_item if manual
+ end
+ end
+
+ def render_archive(item)
+ action = "/lists/#{@list.id}/items/#{item.id}/archive"
+ form(method: "post", action:, class: "inline") do
+ input(type: "hidden", name: "_csrf", value: @csrf.call(action))
+ button(type: "submit", class: "btn-link") { "Remove" }
end
end
+ def render_add_item
+ action = "/lists/#{@list.id}/items"
+ form(method: "post", action:, class: "form") do
+ input(type: "hidden", name: "_csrf", value: @csrf.call(action))
+ div(class: "field") do
+ label(for: "item-name") { "Add item" }
+ input(type: "text", name: "name", id: "item-name", required: true)
+ end
+ button(type: "submit", class: "btn-primary") { "Add" }
+ end
+ end
+
+ # Synced sources get a Sync button; manual ones are edited above.
def render_source
- source = @list.source
- action = "/lists/#{@list.id}/sync"
+ return if Sources.fetch(@list.source_kind).manual?
+ action = "/lists/#{@list.id}/sync"
section(class: "section") do
h2 { "Source" }
form(method: "post", action:, class: "form") do
input(type: "hidden", name: "_csrf", value: @csrf.call(action))
-
- if source.class.push?
- div(class: "field") do
- label(for: "snapshot") { "Items (one per line)" }
- textarea(name: "snapshot", id: "snapshot", rows: 6) do
- @list.items_dataset.order(:id).map(:name).join("\n")
- end
- end
- end
-
button(type: "submit", class: "btn-primary") { "Sync" }
end
end
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index d282ff9..ee77af7 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -35,15 +35,15 @@ module RankKing
name = r.params["name"].to_s.strip
r.halt 422 if name.empty?
- entries = Sources::Text.new.entries(r.params["items"])
- r.halt 422 if entries.size < 2
+ item_names = r.params["items"].to_s.lines.map(&:strip).reject(&:empty?)
+ r.halt 422 if item_names.size < 2
axes_text = r.params["axes"].to_s.strip
axis_names = axes_text.lines.map(&:strip).reject(&:empty?)
list = DB.transaction do
l = List.create(name: name, user_id: user.id, source_kind: "Text")
- Sync.call(l, entries)
+ item_names.each { |n| Item.create(list_id: l.id, name: n) }
axis_names.each { |n| Axis.create(list_id: l.id, name: n) }
l
end
@@ -65,12 +65,40 @@ module RankKing
r.on "sync" do
r.post do
- Sync.call(list, list.source.entries(r.params["snapshot"]))
+ r.halt 422 if Sources.fetch(list.source_kind).manual?
+
+ source = list.source
+ Sync.call(list, source.entries, source:)
r.redirect "/lists/#{list.id}"
end
end
+ # A manual source's items are edited one at a time, by id.
+ r.on "items" do
+ r.halt 422 unless Sources.fetch(list.source_kind).manual?
+
+ r.is do
+ r.post do
+ name = r.params["name"].to_s.strip
+ r.halt 422 if name.empty?
+
+ Item.create(list_id: list.id, name:)
+ r.redirect "/lists/#{list.id}"
+ end
+ end
+
+ r.on Integer, "archive" do |item_id|
+ r.post do
+ item = list.items_dataset.where(id: item_id).first
+ r.halt 404 unless item
+
+ item.update(archived_at: Time.now)
+ r.redirect "/lists/#{list.id}"
+ end
+ end
+ end
+
r.on "results" do
r.post do
axis_id = r.params["axis_id"].to_i
diff --git a/public/css/app.css b/public/css/app.css
index b101399..db9105e 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -119,6 +119,22 @@ textarea {
background: #333;
}
+.btn-link {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: #666;
+ font: inherit;
+ font-size: 0.875em;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+form.inline {
+ display: inline;
+ margin-inline-start: var(--space-2xs);
+}
+
/* -------------------- */
/* Matchup */
/* -------------------- */
diff --git a/sig/rank_king/models.rbs b/sig/rank_king/models.rbs
index 37e0877..94c9ef4 100644
--- a/sig/rank_king/models.rbs
+++ b/sig/rank_king/models.rbs
@@ -25,7 +25,6 @@ module RankKing
def name: () -> String
def list_id: () -> Integer
def list: () -> List
- def external_id: () -> String?
def url: () -> String?
def archived_at: () -> Time?
end
diff --git a/sig/rank_king/sources.rbs b/sig/rank_king/sources.rbs
index aaef0ad..1f3a94a 100644
--- a/sig/rank_king/sources.rbs
+++ b/sig/rank_king/sources.rbs
@@ -4,23 +4,33 @@
module RankKing
module Sources
class Entry
- attr_reader external_id: String
+ attr_reader key: String
attr_reader name: String
attr_reader url: String?
- def self.new: (external_id: String, name: String, url: String?) -> instance
+ def self.new: (key: String, name: String, url: String?) -> instance
end
- # A source instance: turns a snapshot (the pasted text for a push
- # source, ignored by a pull source) into entries.
+ # A synced source instance, for one list.
interface _Source
- def entries: (untyped snapshot) -> Array[Entry]
+ # The items currently in the source.
+ def entries: () -> Array[Entry]
+
+ # Every item of the list, archived ones included, by the key its
+ # entries carry.
+ 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.
+ def saved: (Item item, Entry entry) -> void
end
# A source kind: a class in Sources, named by a list's source_kind.
+ # A manual kind (Text) has no instance: its items are edited by hand.
interface _SourceClass
- def push?: () -> bool
- # The source for a list, configured from the kind's own table.
+ def manual?: () -> bool
+ # The source for a list, configured from the kind's own table. Only
+ # kinds that sync define it.
def for: (List list) -> _Source
end
end
diff --git a/test/test_sync.rb b/test/test_sync.rb
index da88ef6..a9df977 100644
--- a/test/test_sync.rb
+++ b/test/test_sync.rb
@@ -9,6 +9,23 @@ require_relative "../lib/rank_king/sync"
class TestSync < Minitest::Test
include RankKing
+ # A synced source that recognizes its items by URL, standing in for a
+ # kind that keeps its keys in its own table.
+ class FakeSource
+ attr_reader :calls
+
+ def initialize(list)
+ @list = list
+ @calls = []
+ end
+
+ def items_by_key = RankKing::Item.where(list_id: @list.id).exclude(url: nil).to_hash(:url)
+
+ def saved(item, entry)
+ @calls << [item.id, entry.key]
+ end
+ end
+
def setup
DB[:results].delete
DB[:axes].delete
@@ -17,54 +34,55 @@ class TestSync < Minitest::Test
DB[:users].delete
user = User.create(login: "alpha@example.com")
- @list = List.create(name: "Videos", user_id: user.id)
+ @list = List.create(name: "Videos", user_id: user.id, source_kind: "Fake")
+ @source = FakeSource.new(@list)
end
def test_adds_new_entries
- summary = sync("A", "B")
+ summary = sync("a", "b")
assert_equal Sync::Summary.new(added: 2, restored: 0, archived: 0), summary
- assert_equal %w[A B], names
+ assert_equal %w[a b], names
refute_nil @list.reload.synced_at
end
def test_archives_missing_entries_and_keeps_their_rows
- sync("A", "B")
- summary = sync("A")
+ sync("a", "b")
+ summary = sync("a")
assert_equal 1, summary.archived
- assert_equal %w[A], names
- refute_nil Item.first(external_id: "B").archived_at
+ assert_equal %w[a], names
+ refute_nil Item.first(url: "https://example.com/b").archived_at
end
def test_restores_entries_that_come_back
- sync("A", "B")
- b = Item.first(external_id: "B")
- sync("A")
- summary = sync("A", "B")
+ sync("a", "b")
+ b = Item.first(url: "https://example.com/b")
+ sync("a")
+ summary = sync("a", "b")
assert_equal 1, summary.restored
- assert_equal b.id, Item.first(external_id: "B").id
- assert_equal %w[A B], names
+ assert_equal b.id, Item.first(url: "https://example.com/b").id
+ assert_equal %w[a b], names
end
- def test_refreshes_name_and_url
- Sync.call(@list, [Sources::Entry.new(external_id: "v1", name: "Old", url: nil)])
- Sync.call(@list, [Sources::Entry.new(external_id: "v1", name: "New", url: "https://example.com/v1")])
+ def test_refreshes_names_and_tells_the_source
+ sync("a")
+ item = Item.first
+ Sync.call(@list, [entry("a", name: "Renamed")], source: @source)
- item = Item.first(external_id: "v1")
- assert_equal "New", item.name
- assert_equal "https://example.com/v1", item.url
- end
-
- def test_text_source_strips_blank_and_duplicate_lines
- entries = Sources::Text.new.entries(" A \n\nB\nA\n")
- assert_equal %w[A B], entries.map(&:external_id)
+ assert_equal "Renamed", item.reload.name
+ assert_equal [[item.id, "https://example.com/a"]] * 2, @source.calls
end
private
- def sync(*lines) = Sync.call(@list, Sources::Text.new.entries(lines.join("\n")))
+ def entry(key, name: key)
+ url = "https://example.com/#{key}"
+ Sources::Entry.new(key: url, name:, url:)
+ end
+
+ def sync(*keys) = Sync.call(@list, keys.map {|key| entry(key) }, source: @source)
def names = @list.items_dataset.order(:id).map(:name)
end
diff --git a/test/test_web.rb b/test/test_web.rb
index e5253f9..6729156 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -130,28 +130,45 @@ class TestWeb < Minitest::Test
list = RankKing::List.first
assert_equal "Text", list.source_kind
- assert_equal %w[Pizza Sushi], list.items.map(&:external_id)
+ assert_equal %w[Pizza Sushi], list.items.map(&:name)
end
- def test_sync_adds_and_hides_items
- create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
+ def test_add_item_to_text_list
+ create_list(name: "Foods", items: "Pizza\nSushi")
list = RankKing::List.first
- csrf_post "/lists/#{list.id}/sync", { snapshot: "Pizza\nTacos" }
+ csrf_post "/lists/#{list.id}/items", { name: "Tacos" }
assert last_response.redirect?
+ assert_equal %w[Pizza Sushi Tacos], list.items_dataset.order(:id).map(:name)
+ end
+
+ def test_archived_items_are_hidden
+ create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
+ list = RankKing::List.first
+ sushi = list.items.find { |i| i.name == "Sushi" }
- assert_equal %w[Pizza Tacos], list.items_dataset.order(:id).map(:name)
+ csrf_post "/lists/#{list.id}/items/#{sushi.id}/archive"
+ assert last_response.redirect?
+ refute_nil sushi.reload.archived_at
get "/lists/#{list.id}"
- assert_includes last_response.body, "Tacos"
+ assert_includes last_response.body, "Pizza"
refute_includes last_response.body, "Sushi"
end
+ def test_text_lists_have_no_sync
+ create_list(name: "Foods", items: "Pizza\nSushi")
+ list = RankKing::List.first
+
+ get "/lists/#{list.id}"
+ refute_includes last_response.body, "/lists/#{list.id}/sync"
+ end
+
def test_record_result_rejects_archived_item
create_list(name: "Foods", items: "Pizza\nSushi\nTacos", axes: "Taste")
list = RankKing::List.first
sushi = list.items.find { |i| i.name == "Sushi" }
- RankKing::Sync.call(list, RankKing::Sources::Text.new.entries("Pizza\nTacos"))
+ sushi.update(archived_at: Time.now)
csrf_post "/lists/#{list.id}/results", {
axis_id: list.axes.first.id,