Add list sources with manual sync
Lists now get their items from sources. Each source kind is a class in a
hardcoded registry that turns a snapshot into entries, and Sync reconciles
a source's items with those entries: new entries become items, and items
that disappear are archived and hidden rather than deleted, so their
results survive if they come back.

The textarea is the first source kind (text), and existing lists are
backfilled with one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
change
commit 6993e6b8127af294b1ca6db56d7c14f09dcd5b34
author Claude <noreply@anthropic.com>
date
parent rstwlmuo
diff --git a/AGENTS.md b/AGENTS.md
index 2f2bae8..ecac0cf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,7 +21,10 @@ db/migrate/        — Sequel migrations
 lib/rank_king/     — app code
   config.rb        — Config data class, reads from ENV
   db.rb            — Sequel connection + migration runner
-  models.rb        — Sequel models (List, Item, Axis, Result)
+  models.rb        — Sequel models (List, Item, Source, Axis, Result)
+  sources.rb       — hardcoded registry of source kinds (Sources::ALL)
+  sources/         — one class per source kind
+  sync.rb          — reconciles a source's items with a fresh snapshot
   web.rb           — Roda routes
   views/           — Phlex view components
 lib/open_skill/    — OpenSkill rating library (pure Ruby)
@@ -32,8 +35,9 @@ public/            — static assets
 
 ## Domain model
 
-- **List** has many Items and Axes
-- **Item** belongs to a List
+- **List** has many Items, Sources, and Axes
+- **Source** belongs to a List and produces its Items; `kind` picks a class from `Sources::ALL`
+- **Item** belongs to a List and a Source; 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/002_create_sources.rb b/db/migrate/002_create_sources.rb
new file mode 100644
index 0000000..f1a905a
--- /dev/null
+++ b/db/migrate/002_create_sources.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+  up do
+    create_table(:sources) do
+      primary_key :id
+      foreign_key :list_id, :lists, null: false
+      String :kind, null: false
+      String :config, null: false, default: "{}"
+      DateTime :last_synced_at
+      DateTime :created_at
+      DateTime :updated_at
+    end
+
+    alter_table(:items) do
+      add_foreign_key :source_id, :sources
+      add_column :external_id, String
+      add_column :url, String
+      add_column :metadata, String, null: false, default: "{}"
+      add_column :archived_at, DateTime
+      add_index [:source_id, :external_id], unique: true
+    end
+
+    # Existing lists were created from the textarea, so give each one a text
+    # source. Duplicate names keep a nil external_id and are left alone by sync.
+    from(:lists).each do |list|
+      source_id = from(:sources).insert(
+        list_id: list[:id], kind: "text", config: "{}",
+        created_at: Time.now, updated_at: Time.now,
+      )
+      from(:items).where(list_id: list[:id]).update(source_id: source_id)
+
+      first_ids = from(:items).where(list_id: list[:id]).group(:name).select { min(:id) }
+      from(:items).where(id: first_ids).update(external_id: :name)
+    end
+  end
+
+  down do
+    alter_table(:items) do
+      drop_index [:source_id, :external_id]
+      drop_foreign_key :source_id
+      drop_column :external_id
+      drop_column :url
+      drop_column :metadata
+      drop_column :archived_at
+    end
+
+    drop_table(:sources)
+  end
+end
diff --git a/lib/rank_king/models.rb b/lib/rank_king/models.rb
index 6bc49b6..9fa838f 100644
--- a/lib/rank_king/models.rb
+++ b/lib/rank_king/models.rb
@@ -1,17 +1,32 @@
 # frozen_string_literal: true
 
 require_relative "db"
+require_relative "sources"
 
 Sequel::Model.plugin :timestamps, update_on_create: true
 
 module RankKing
   class List < Sequel::Model(DB[:lists])
-    one_to_many :items, class: "RankKing::Item"
+    # Archived items are hidden everywhere for now.
+    one_to_many :items, class: "RankKing::Item", conditions: { archived_at: nil }
     one_to_many :axes, class: "RankKing::Axis"
+    one_to_many :sources, class: "RankKing::Source"
   end
 
   class Item < Sequel::Model(DB[:items])
+    plugin :serialization, :json, :metadata
+
+    many_to_one :list, class: "RankKing::List"
+    many_to_one :source, class: "RankKing::Source"
+  end
+
+  class Source < Sequel::Model(DB[:sources])
+    plugin :serialization, :json, :config
+
     many_to_one :list, class: "RankKing::List"
+    one_to_many :items, class: "RankKing::Item"
+
+    def plugin = Sources.fetch(kind).new(config)
   end
 
   class Axis < Sequel::Model(DB[:axes])
diff --git a/lib/rank_king/sources.rb b/lib/rank_king/sources.rb
new file mode 100644
index 0000000..9e65d66
--- /dev/null
+++ b/lib/rank_king/sources.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+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.
+    Entry = Data.define(:external_id, :name, :url, :metadata) do
+      def initialize(external_id:, name:, url: nil, metadata: {})
+        super
+      end
+    end
+  end
+end
+
+require_relative "sources/text"
+
+module RankKing
+  module Sources
+    ALL = {
+      Text.kind => Text,
+    }.freeze
+
+    def self.fetch(kind) = ALL.fetch(kind)
+  end
+end
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
new file mode 100644
index 0000000..875136a
--- /dev/null
+++ b/lib/rank_king/sources/text.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module RankKing
+  module Sources
+    # A push source: the snapshot is pasted text, one item per line.
+    class Text
+      def self.kind = "text"
+      def self.push? = true
+
+      def initialize(config = {})
+        @config = config
+      end
+
+      def entries(snapshot)
+        snapshot.to_s.lines.map(&:strip).reject(&:empty?).uniq.map {|line|
+          Entry.new(external_id: line, name: line)
+        }
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/sync.rb b/lib/rank_king/sync.rb
new file mode 100644
index 0000000..02f07ce
--- /dev/null
+++ b/lib/rank_king/sync.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require_relative "models"
+
+module RankKing
+  # Reconciles a source's items with a fresh snapshot of entries. Items missing
+  # from the snapshot are archived rather than deleted so their results survive,
+  # and come back with their history if they reappear.
+  module Sync
+    Summary = Data.define(:added, :restored, :archived)
+
+    def self.call(source, entries, now: Time.now)
+      DB.transaction do
+        existing = source.items_dataset.exclude(external_id: nil).to_hash(:external_id)
+        added = restored = 0
+
+        entries.each do |entry|
+          attrs = { name: entry.name, url: entry.url, metadata: entry.metadata, archived_at: nil }
+          if (item = existing.delete(entry.external_id))
+            restored += 1 if item.archived_at
+            item.update(attrs)
+          else
+            Item.create(attrs.merge(list_id: source.list_id, source_id: source.id, external_id: entry.external_id))
+            added += 1
+          end
+        end
+
+        gone = existing.values.select {|item| item.archived_at.nil? }
+        gone.each {|item| item.update(archived_at: now) }
+
+        source.update(last_synced_at: now)
+        Summary.new(added:, restored:, archived: gone.size)
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/views/list/show.rb b/lib/rank_king/views/list/show.rb
index 2629d5c..050855c 100644
--- a/lib/rank_king/views/list/show.rb
+++ b/lib/rank_king/views/list/show.rb
@@ -18,6 +18,7 @@ module RankKing
             div(class: "list-show") do
               h1 { @list.name }
               render_items
+              render_sources
               render_axes
               render_matchup
               render_ratings
@@ -38,6 +39,33 @@ module RankKing
           end
         end
 
+        def render_sources
+          section(class: "section") do
+            h2 { "Sources" }
+            @list.sources.each do |source|
+              render_source(source)
+            end
+          end
+        end
+
+        def render_source(source)
+          action = "/lists/#{@list.id}/sources/#{source.id}/sync"
+          form(method: "post", action:, class: "form") do
+            input(type: "hidden", name: "_csrf", value: @csrf.call(action))
+
+            if source.plugin.class.push?
+              div(class: "field") do
+                label(for: "snapshot-#{source.id}") { "#{source.kind.capitalize} (one per line)" }
+                textarea(name: "snapshot", id: "snapshot-#{source.id}", rows: 6) do
+                  source.items_dataset.where(archived_at: nil).order(:id).map(:name).join("\n")
+                end
+              end
+            end
+
+            button(type: "submit", class: "btn-primary") { "Sync" }
+          end
+        end
+
         def render_axes
           section(class: "section") do
             h2 { "Axes" }
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index 5a539ac..17fd33b 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -3,6 +3,7 @@
 require "roda"
 
 require_relative "models"
+require_relative "sync"
 require_relative "views/home"
 require_relative "views/list/show"
 
@@ -27,18 +28,16 @@ module RankKing
             name = r.params["name"].to_s.strip
             r.halt 422 if name.empty?
 
-            items_text = r.params["items"].to_s.strip
-            r.halt 422 if items_text.empty?
-
-            item_names = items_text.lines.map(&:strip).reject(&:empty?)
-            r.halt 422 if item_names.size < 2
+            entries = Sources::Text.new.entries(r.params["items"])
+            r.halt 422 if entries.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)
-              item_names.each { |n| Item.create(list_id: l.id, name: n) }
+              source = Source.create(list_id: l.id, kind: Sources::Text.kind)
+              Sync.call(source, entries)
               axis_names.each { |n| Axis.create(list_id: l.id, name: n) }
               l
             end
@@ -57,6 +56,17 @@ module RankKing
             end
           end
 
+          r.on "sources", Integer, "sync" do |source_id|
+            r.post do
+              source = @list.sources_dataset.where(id: source_id).first
+              r.halt 404 unless source
+
+              Sync.call(source, source.plugin.entries(r.params["snapshot"]))
+
+              r.redirect "/lists/#{@list.id}"
+            end
+          end
+
           r.on "results" do
             r.post do
               axis_id = r.params["axis_id"].to_i
diff --git a/test/test_sync.rb b/test/test_sync.rb
new file mode 100644
index 0000000..59c478d
--- /dev/null
+++ b/test/test_sync.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require_relative "test_helper"
+
+require "minitest/autorun"
+
+require_relative "../lib/rank_king/sync"
+
+class TestSync < Minitest::Test
+  include RankKing
+
+  def setup
+    DB[:results].delete
+    DB[:axes].delete
+    DB[:items].delete
+    DB[:sources].delete
+    DB[:lists].delete
+
+    @list = List.create(name: "Videos")
+    @source = Source.create(list_id: @list.id, kind: "text")
+  end
+
+  def test_adds_new_entries
+    summary = sync("A", "B")
+
+    assert_equal Sync::Summary.new(added: 2, restored: 0, archived: 0), summary
+    assert_equal %w[A B], names
+    refute_nil @source.reload.last_synced_at
+  end
+
+  def test_archives_missing_entries_and_keeps_their_rows
+    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
+  end
+
+  def test_restores_entries_that_come_back
+    sync("A", "B")
+    b = Item.first(external_id: "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
+  end
+
+  def test_refreshes_name_url_and_metadata
+    Sync.call(@source, [Sources::Entry.new(external_id: "v1", name: "Old")])
+    Sync.call(@source, [Sources::Entry.new(external_id: "v1", name: "New", url: "https://example.com/v1", metadata: { "thumb" => "t.jpg" })])
+
+    item = Item.first(external_id: "v1")
+    assert_equal "New", item.name
+    assert_equal "https://example.com/v1", item.url
+    assert_equal({ "thumb" => "t.jpg" }, item.metadata)
+  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)
+  end
+
+  private
+
+  def sync(*lines) = Sync.call(@source, Sources::Text.new.entries(lines.join("\n")))
+
+  def names = @list.items_dataset.order(:id).map(:name)
+end
diff --git a/test/test_web.rb b/test/test_web.rb
index 85d47f9..c08c568 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -16,6 +16,7 @@ class TestWeb < Minitest::Test
     RankKing::DB[:results].delete
     RankKing::DB[:axes].delete
     RankKing::DB[:items].delete
+    RankKing::DB[:sources].delete
     RankKing::DB[:lists].delete
   end
 
@@ -121,13 +122,50 @@ class TestWeb < Minitest::Test
     assert_equal 403, last_response.status
   end
 
+  def test_create_list_adds_a_text_source
+    create_list(name: "Foods", items: "Pizza\nSushi")
+    list = RankKing::List.first
+
+    assert_equal ["text"], list.sources.map(&:kind)
+    assert_equal %w[Pizza Sushi], list.sources.first.items.map(&:external_id)
+  end
+
+  def test_sync_adds_and_hides_items
+    create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
+    list = RankKing::List.first
+    source = list.sources.first
+
+    csrf_post "/lists/#{list.id}/sources/#{source.id}/sync", { snapshot: "Pizza\nTacos" }
+    assert last_response.redirect?
+
+    assert_equal %w[Pizza Tacos], list.items_dataset.order(:id).map(:name)
+
+    get "/lists/#{list.id}"
+    assert_includes last_response.body, "Tacos"
+    refute_includes last_response.body, "Sushi"
+  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.sources.first, RankKing::Sources::Text.new.entries("Pizza\nTacos"))
+
+    csrf_post "/lists/#{list.id}/results", {
+      axis_id: list.axes.first.id,
+      winner_id: sushi.id,
+      loser_id: list.items.first.id,
+    }
+    assert_equal 422, last_response.status
+  end
+
   private
 
   def csrf_post(path, params = {})
     get "/"
     token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     # The home page CSRF token is for /lists, we need one for the target path
-    get "/lists/#{path.split("/")[2]}" if path.include?("/results")
+    get "/lists/#{path.split("/")[2]}" if path.match?(%r{\A/lists/\d+/})
     token = last_response.body[/action="#{Regexp.escape(path)}".*?name="_csrf" value="([^"]+)"/m, 1] || token
     post path, params.merge("_csrf" => token)
   end