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
change
commit 006fd470168f3db7e2d36da15f10f32291a6d8f7
author Claude <noreply@anthropic.com>
date
parent fdefbbe9
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,