portage-cli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +33 -4
  4. data/known-stores/categories.yml +6841 -18
  5. data/known-stores/category-stoplist.yml +40 -0
  6. data/known-stores/category-synonyms.yml +15 -0
  7. data/lib/portage/cli/browser_import/categorize.rb +5 -2
  8. data/lib/portage/cli/buy.rb +3 -24
  9. data/lib/portage/cli/check.rb +164 -0
  10. data/lib/portage/cli/check_next_step.rb +51 -0
  11. data/lib/portage/cli/classifier/ranking.rb +135 -0
  12. data/lib/portage/cli/classifier/table.rb +63 -0
  13. data/lib/portage/cli/classifier.rb +23 -44
  14. data/lib/portage/cli/doctor.rb +16 -6
  15. data/lib/portage/cli/find.rb +5 -3
  16. data/lib/portage/cli/handoff_host.rb +33 -0
  17. data/lib/portage/cli/index/builder.rb +49 -12
  18. data/lib/portage/cli/index/database.rb +150 -0
  19. data/lib/portage/cli/index/entry_product.rb +37 -0
  20. data/lib/portage/cli/index/legacy_import.rb +54 -0
  21. data/lib/portage/cli/index/product_store.rb +73 -35
  22. data/lib/portage/cli/index/schema.rb +70 -0
  23. data/lib/portage/cli/index/search.rb +73 -0
  24. data/lib/portage/cli/index/sources/storefront_products/mapper.rb +127 -0
  25. data/lib/portage/cli/index/sources/storefront_products/pages.rb +114 -0
  26. data/lib/portage/cli/index/sources/storefront_products/robots.rb +70 -0
  27. data/lib/portage/cli/index/sources/storefront_products.rb +147 -0
  28. data/lib/portage/cli/index/sources.rb +5 -2
  29. data/lib/portage/cli/index/store.rb +23 -47
  30. data/lib/portage/cli/index.rb +1 -0
  31. data/lib/portage/cli/offer_sources.rb +17 -2
  32. data/lib/portage/cli/version.rb +1 -1
  33. data/lib/portage/cli.rb +142 -12
  34. metadata +34 -4
@@ -42,7 +42,8 @@ module Portage
42
42
  # those four warnings were noise on every fresh install.
43
43
  def initialize(adapter_class: nil, proxy_settings: ProxySettings.new, install_doctor: InstallDoctor.new,
44
44
  seller: true, dot_env_path: DotEnv.loaded_path, index_stores: Index::Store.new,
45
- index_products: Index::ProductStore.new, known_cache: Index::KnownCache.new)
45
+ index_products: Index::ProductStore.new, known_cache: Index::KnownCache.new,
46
+ index_database: Index::Database.new(path: Index::Database.path_for(Index::Store::PATH)))
46
47
  @adapter_class = adapter_class
47
48
  @proxy_settings = proxy_settings
48
49
  @install_doctor = install_doctor
@@ -51,6 +52,7 @@ module Portage
51
52
  @index_stores = index_stores
52
53
  @index_products = index_products
53
54
  @known_cache = known_cache
55
+ @index_database = index_database
54
56
  end
55
57
 
56
58
  # `portage setup` always offers its wizard on a TTY; a bare `portage
@@ -210,12 +212,20 @@ module Portage
210
212
  # leaves behind.
211
213
  def index_finding
212
214
  refresh_known_cache_if_stale
213
- return Finding.new(check: "index", level: "info", message: "#{index_message}\n#{known_cache_message}") \
214
- if @index_stores.exists?
215
+ first = @index_stores.exists? ? index_message : no_index_message
216
+ database = @index_database.info
217
+ Finding.new(check: "index", level: "info", details: { database: database },
218
+ message: [first, database_message(database), known_cache_message].join("\n"))
219
+ end
220
+
221
+ def no_index_message
222
+ "No local index yet — run `portage index build` to give `find` a list of " \
223
+ "stores/products on top of stores.yml and web search."
224
+ end
215
225
 
216
- Finding.new(check: "index", level: "info",
217
- message: "No local index yet — run `portage index build` to give `find` a list of " \
218
- "stores/products on top of stores.yml and web search.\n#{known_cache_message}")
226
+ def database_message(info)
227
+ "Index database: #{info[:path]} (#{info[:stores]} store row(s), #{info[:products]} product row(s), " \
228
+ "FTS5 #{info[:fts5] ? 'available' : 'not available'})."
219
229
  end
220
230
 
221
231
  def refresh_known_cache_if_stale
@@ -229,9 +229,11 @@ module Portage
229
229
  amount, currency = price_of(product)
230
230
  return nil if over_max_price?(amount)
231
231
 
232
- { store: store[:origin], source: store[:source], checkout: store[:checkout],
233
- product_id: field(product, "id"), title: field(product, "title"),
234
- amount: amount, currency: currency, url: field(product, "url") }
232
+ OfferSources.with_product(
233
+ { store: store[:origin], source: store[:source], checkout: store[:checkout],
234
+ product_id: field(product, "id"), title: field(product, "title"),
235
+ amount: amount, currency: currency, url: field(product, "url") }, product
236
+ )
235
237
  end
236
238
 
237
239
  # An unpriced offer stays in: no price isn't the same as too dear.
@@ -0,0 +1,33 @@
1
+ require_relative "handoff_only"
2
+ require_relative "offer_sources"
3
+
4
+ module Portage
5
+ module Cli
6
+ # The one answer to "is this host hand-off only?" that both `buy` and
7
+ # `check` give, so `portage check` never says something `portage buy`
8
+ # wouldn't do. Three sources, in the order Buy has always consulted them:
9
+ # the user's own list (HandoffOnly, Amazon by default), the built-in
10
+ # retail hand-off hosts (Walmart, eBay, Best Buy), and Etsy for an
11
+ # ordinary buyer — portage-ucp-etsy is a seller-side adapter, so a shop
12
+ # owner with their own ETSY_* credentials set is not hand-off only.
13
+ module HandoffHost
14
+ def self.restricted?(host, handoff_only: HandoffOnly.new)
15
+ return true if handoff_only.host?(host)
16
+ return true if OfferSources.retail_handoff_host?(host)
17
+
18
+ etsy_buyer_host?(host)
19
+ end
20
+
21
+ def self.etsy_buyer_host?(host)
22
+ HandoffOnly.matches_any?(host, %w[etsy.com]) && !etsy_adapter_configured?
23
+ end
24
+
25
+ def self.etsy_adapter_configured?
26
+ platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == "Etsy" }
27
+ return false unless platform
28
+
29
+ Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)).empty?
30
+ end
31
+ end
32
+ end
33
+ end
@@ -37,6 +37,9 @@ module Portage
37
37
  THROTTLE = 0.1
38
38
  STALE_AFTER = 7 * 24 * 60 * 60
39
39
  TOP_CATEGORIES = 5
40
+ # Product sightings per ProductStore#upsert_many transaction — one
41
+ # products.json page's worth (docs/plans/local-catalogue.md Phase 2).
42
+ WRITE_BATCH = 250
40
43
 
41
44
  # Public so BrowserImport::Importer (Phase 3) labels a probed
42
45
  # origin's capabilities exactly the way an index build does.
@@ -94,13 +97,19 @@ module Portage
94
97
  # HandoffOnly) is recorded without ever probing it — the user
95
98
  # explicitly named it, but that's still not a request this process
96
99
  # sends.
97
- def add(url)
100
+ #
101
+ # `crawl: true` (`index add URL --crawl`) then reads the store's own
102
+ # catalogue through Sources::StorefrontProducts into the index.
103
+ # Opt-in: a crawl is up to 21 more requests and 20s of pauses, where
104
+ # a plain add is one probe.
105
+ def add(url, crawl: false)
98
106
  origin = origin_of(url)
99
107
  return { added: false, message: "Not a valid http(s) URL: #{url}" } unless origin
100
108
  return store_manual_handoff_only(origin) if handoff_only_origin?(origin)
101
109
 
102
110
  session = probe(origin)
103
- store_manual(origin, session)
111
+ result = store_manual(origin, session)
112
+ crawl ? crawl_added(origin, result) : result
104
113
  end
105
114
 
106
115
  # `portage index remove HOST`
@@ -184,10 +193,29 @@ module Portage
184
193
  { added: true, origin: origin, message: "Added #{origin} — hand-off only, never probed." }
185
194
  end
186
195
 
196
+ # `store_fields:` on a sighting (StorefrontProducts' crawl note and
197
+ # platform) lands on the store row as-is.
187
198
  def update_existing(origin, group)
188
199
  existing = @stores.find(origin)
200
+ fields = group.filter_map { |g| g[:store_fields] }.reduce({}, :merge)
189
201
  @stores.upsert(origin, sources: merged_sources(existing, group),
190
- categories: merge_categories(existing["categories"], group))
202
+ categories: merge_categories(existing["categories"], group), **fields)
203
+ end
204
+
205
+ def crawl_added(origin, result)
206
+ sightings = Sources::StorefrontProducts.new(stores: @stores, handoff_only: @handoff_only)
207
+ .crawl(origin, platform: @stores.find(origin)&.dig("platform"))
208
+ tagged = sightings.map { |s| s.merge(source: "storefront_products") }
209
+ apply(tagged, dry_run: false)
210
+ note = tagged.last.dig(:store_fields, :crawl)
211
+ result.merge(crawl: note, message: "#{result[:message]} #{crawl_message(note)}")
212
+ end
213
+
214
+ def crawl_message(note)
215
+ return "Catalogue not crawled (#{note['reason']})." if note["status"] == "skipped"
216
+
217
+ "Crawled #{note['products']} product(s) from #{note['pages']} page(s)" \
218
+ "#{" (stopped: #{note['reason']})" if note['reason']}."
191
219
  end
192
220
 
193
221
  def store_new(origin, session, group)
@@ -220,7 +248,7 @@ module Portage
220
248
  group.each do |sighting|
221
249
  next unless sighting[:title]
222
250
 
223
- Classifier.categories_for(sighting[:title]).each { |id| tally[id] += 1 }
251
+ categories_of(sighting).each { |id| tally[id] += 1 }
224
252
  end
225
253
  tally.sort_by { |_id, weight| -weight }.first(TOP_CATEGORIES).to_h
226
254
  end
@@ -232,17 +260,26 @@ module Portage
232
260
  def capabilities_of(session) = self.class.capabilities_of(session)
233
261
 
234
262
  def store_products(sightings)
235
- eligible = sightings.select { |s| s[:title] && @stores.find(s[:origin]) }
236
- eligible.each { |sighting| store_product(sighting) }
263
+ known = Hash.new { |memo, origin| memo[origin] = !@stores.find(origin).nil? }
264
+ eligible = sightings.select { |s| s[:title] && known[s[:origin]] }
265
+ eligible.each_slice(WRITE_BATCH) { |batch| @products.upsert_many(batch.map { |s| product_row(s) }) }
237
266
  eligible.length
238
267
  end
239
268
 
240
- def store_product(sighting)
241
- key = product_key(sighting)
242
- category = Classifier.categories_for(sighting[:title]).first
243
- @products.upsert(key, origin: sighting[:origin], seen_at: @now.to_i, title: sighting[:title],
244
- brand: sighting[:brand], gtin: sighting[:gtin], category: category,
245
- sources: [sighting[:source]].compact)
269
+ # `product:` on a sighting (StorefrontProducts' handle, url,
270
+ # image_url, options, variant_ids) is stored alongside the usual
271
+ # fields.
272
+ def product_row(sighting)
273
+ { key: product_key(sighting), origin: sighting[:origin], seen_at: @now.to_i, title: sighting[:title],
274
+ brand: sighting[:brand], gtin: sighting[:gtin], category: categories_of(sighting).first,
275
+ sources: [sighting[:source]].compact, **sighting.fetch(:product, {}) }
276
+ end
277
+
278
+ # A source that already classified its sighting (StorefrontProducts,
279
+ # on product_type and tags) says so in `categories:`; otherwise the
280
+ # title is classified here.
281
+ def categories_of(sighting)
282
+ sighting[:categories] || Classifier.categories_for(sighting[:title])
246
283
  end
247
284
 
248
285
  # GTIN when a source has one (none do yet); otherwise a normalized
@@ -0,0 +1,150 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "sqlite3"
4
+ require_relative "schema"
5
+ require_relative "legacy_import"
6
+
7
+ module Portage
8
+ module Cli
9
+ module Index
10
+ # `~/.portage/index/index.sqlite3` — the one file behind Index::Store
11
+ # and Index::ProductStore (docs/plans/local-catalogue.md Phase 1).
12
+ # Plain SQL over the `sqlite3` gem, no ORM: every entry is stored as
13
+ # the same JSON object the old stores.json/products.json held, so the
14
+ # entry shapes documented on Store/ProductStore did not change.
15
+ #
16
+ # Posture: the file is created 0600 (and so are SQLite's -wal/-shm
17
+ # side files, which copy the main file's mode); it runs in WAL mode
18
+ # with a busy timeout; a failed write raises rather than being
19
+ # swallowed. Nothing is created until something is written — a read
20
+ # of an index that doesn't exist yet just comes back empty.
21
+ #
22
+ # Forward-only schema: MIGRATIONS[n] takes user_version n to n + 1,
23
+ # and a database stamped newer than this build knows is refused
24
+ # rather than guessed at.
25
+ #
26
+ # The open that creates the database also imports a legacy stores.json/
27
+ # products.json that sits beside it, in one transaction, then renames
28
+ # it to *.json.migrated. Only that one open does — later opens never
29
+ # look, even if the tables have since been emptied — and nothing is
30
+ # ever deleted.
31
+ class Database
32
+ class Error < StandardError; end
33
+
34
+ FILENAME = "index.sqlite3".freeze
35
+ BUSY_TIMEOUT_MS = 5_000
36
+
37
+ # table => its key column. The legacy json file for a table is
38
+ # "<table>.json".
39
+ TABLES = { "stores" => "origin", "products" => "key" }.freeze
40
+
41
+ # The database that lives beside a legacy stores.json/products.json.
42
+ def self.path_for(json_path) = File.join(File.dirname(json_path), FILENAME)
43
+
44
+ attr_reader :path
45
+
46
+ def initialize(path:)
47
+ @path = path
48
+ end
49
+
50
+ # True once there is something to read: the database itself, or a
51
+ # legacy json file that the next open will import.
52
+ def exists? = File.exist?(@path) || LegacyImport.files(File.dirname(@path)).any?
53
+
54
+ # @return [Hash] key => parsed entry, in insertion order.
55
+ def entries(table)
56
+ return {} unless exists?
57
+
58
+ execute("SELECT #{TABLES.fetch(table)}, data FROM #{table} ORDER BY rowid").to_h { |k, d| [k, JSON.parse(d)] }
59
+ end
60
+
61
+ def get(table, key)
62
+ return nil unless exists?
63
+
64
+ row = execute("SELECT data FROM #{table} WHERE #{TABLES.fetch(table)} = ?", [key]).first
65
+ row && JSON.parse(row.first)
66
+ end
67
+
68
+ def put(table, key, entry)
69
+ column = TABLES.fetch(table)
70
+ execute("INSERT INTO #{table} (#{column}, data) VALUES (?, ?) " \
71
+ "ON CONFLICT(#{column}) DO UPDATE SET data = excluded.data", [key, JSON.generate(entry)])
72
+ end
73
+
74
+ def delete(table, key)
75
+ execute("DELETE FROM #{table} WHERE #{TABLES.fetch(table)} = ?", [key])
76
+ end
77
+
78
+ def count(table)
79
+ return 0 unless exists?
80
+
81
+ execute("SELECT COUNT(*) FROM #{table}").first.first
82
+ end
83
+
84
+ # Reentrant: a nested call joins the outer transaction. IMMEDIATE
85
+ # takes the write lock up front, so a read-modify-write inside it
86
+ # can't interleave with another process's.
87
+ def transaction(&)
88
+ return yield if connection.transaction_active?
89
+
90
+ connection.transaction(:immediate, &)
91
+ end
92
+
93
+ # @return [Array<Array>] raw rows — for the specs and Phase 2's
94
+ # search; everything else goes through the entry methods above.
95
+ def execute(sql, binds = [])
96
+ connection.execute(sql, binds)
97
+ end
98
+
99
+ def pragma(name) = execute("PRAGMA #{name}").flatten.first
100
+
101
+ # What `portage doctor` reports.
102
+ def info
103
+ stores = count("stores")
104
+ products = count("products")
105
+ { path: @path, exists: File.exist?(@path), stores: stores, products: products,
106
+ fts5: Schema.fts5_available? }
107
+ end
108
+
109
+ private
110
+
111
+ def connection
112
+ @connection ||= open_connection
113
+ end
114
+
115
+ def open_connection
116
+ FileUtils.mkdir_p(File.dirname(@path))
117
+ File.open(@path, File::CREAT | File::WRONLY, 0o600) { nil }
118
+ FileUtils.chmod(0o600, @path)
119
+ db = SQLite3::Database.new(@path)
120
+ db.busy_timeout = BUSY_TIMEOUT_MS
121
+ db.execute("PRAGMA journal_mode = WAL")
122
+ db.execute("PRAGMA synchronous = NORMAL")
123
+ @connection = db
124
+ # One transaction, so a failed import leaves the database unstamped
125
+ # and the next open tries again rather than skipping the import.
126
+ transaction { LegacyImport.new(self, File.dirname(@path)).call if migrate.zero? }
127
+ db
128
+ rescue StandardError
129
+ @connection&.close
130
+ @connection = nil
131
+ raise
132
+ end
133
+
134
+ def migrate
135
+ version = @connection.get_first_value("PRAGMA user_version")
136
+ if version > Schema::MIGRATIONS.length
137
+ raise Error,
138
+ "#{@path} is schema v#{version}, newer than this portage understands"
139
+ end
140
+
141
+ Schema::MIGRATIONS.drop(version).each_with_index do |step, i|
142
+ step.call(@connection)
143
+ @connection.execute("PRAGMA user_version = #{version + i + 1}")
144
+ end
145
+ version
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,37 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ module Index
6
+ # A ProductStore entry as the persisted subset of a UCP Product wire
7
+ # hash (docs/plans/local-catalogue.md Phase 3), for `index search`'s
8
+ # `product` field. Built only from what the index keeps, so it has no
9
+ # id, description, price_range or availability: the index never stores
10
+ # a price or stock, and a missing field is left out rather than faked.
11
+ # The live Product comes from `find`.
12
+ module EntryProduct
13
+ TAXONOMY = "google_product_category".freeze
14
+
15
+ module_function
16
+
17
+ # @param entry [Hash] a ProductStore entry.
18
+ # @return [Hash] string-keyed, UCP Product shaped.
19
+ def wire(entry)
20
+ wire = { "title" => entry["title"], "handle" => entry["handle"], "url" => entry["url"],
21
+ "media" => media(entry["image_url"]), "options" => entry["options"],
22
+ "variants" => Array(entry["variant_ids"]).map { |id| { "id" => id } },
23
+ "categories" => category(entry["category"]) }
24
+ wire.reject { |_field, value| value.nil? || value == [] }
25
+ end
26
+
27
+ def media(url)
28
+ url.to_s.empty? ? nil : [Portage::Ucp::Media.new(type: "image", url: url).to_wire_h]
29
+ end
30
+
31
+ def category(id)
32
+ id.to_s.empty? ? nil : [Portage::Ucp::Category.new(value: id, taxonomy: TAXONOMY).to_wire_h]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,54 @@
1
+ require "json"
2
+
3
+ module Portage
4
+ module Cli
5
+ module Index
6
+ # Moves a pre-SQLite stores.json/products.json into an Index::Database
7
+ # (docs/plans/local-catalogue.md Phase 1): every object entry, in one
8
+ # transaction, then the file is renamed to *.json.migrated. Nothing
9
+ # is ever deleted, and an unreadable or non-object file is left
10
+ # untouched. Index::Database runs this only on the open that creates
11
+ # the database, so it happens at most once.
12
+ class LegacyImport
13
+ # @return [Array<String>] the legacy json files present in `dir`.
14
+ def self.files(dir)
15
+ Database::TABLES.keys.map { |table| File.join(dir, "#{table}.json") }.select { |file| File.file?(file) }
16
+ end
17
+
18
+ def initialize(database, dir)
19
+ @database = database
20
+ @dir = dir
21
+ end
22
+
23
+ def call
24
+ self.class.files(@dir).each do |file|
25
+ entries = read(file)
26
+ next unless entries
27
+
28
+ @database.transaction do
29
+ entries.each { |key, entry| @database.put(File.basename(file, ".json"), key, entry) if entry.is_a?(Hash) }
30
+ end
31
+ rename(file)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def read(file)
38
+ parsed = JSON.parse(File.read(file, encoding: "UTF-8"))
39
+ parsed.is_a?(Hash) ? parsed : nil
40
+ rescue JSON::ParserError, SystemCallError
41
+ nil
42
+ end
43
+
44
+ # The rows are already committed, so a rename that fails must not
45
+ # fail the open — the file is just left where it was.
46
+ def rename(file)
47
+ File.rename(file, "#{file}.migrated")
48
+ rescue SystemCallError
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -1,10 +1,12 @@
1
1
  require "json"
2
- require "fileutils"
2
+ require_relative "database"
3
+ require_relative "search"
3
4
 
4
5
  module Portage
5
6
  module Cli
6
7
  module Index
7
- # `~/.portage/index/products.json` — product identities the index has
8
+ # The `products` table of `~/.portage/index/index.sqlite3` (Index::Database;
9
+ # `products.json` before docs/plans/local-catalogue.md Phase 1) — product identities the index has
8
10
  # seen, one entry per GTIN/MPN (when a source gives one) or per
9
11
  # title+brand otherwise. **No prices or stock** — those are always
10
12
  # live, read straight from the store's own catalog at buy time
@@ -19,33 +21,92 @@ module Portage
19
21
  class ProductStore
20
22
  PATH = File.join(Dir.home, ".portage", "index", "products.json").freeze
21
23
 
24
+ # @param path [String] where the legacy products.json lives (or
25
+ # would live) — the database sits beside it, and a products.json
26
+ # found there is imported on first open.
22
27
  def initialize(path: PATH)
23
- @path = path
28
+ @db = Database.new(path: Database.path_for(path))
24
29
  end
25
30
 
26
- def all = entries.values
31
+ def all = @db.entries("products").values
27
32
 
28
- def find(key) = entries[key]
33
+ def find(key) = @db.get("products", key)
29
34
 
30
35
  # @param key [String] a stable key for this product — the source's
31
36
  # GTIN/MPN when it has one, else a normalized title+brand.
32
37
  # @param origin [String] the store this sighting came from.
33
38
  # @param seen_at [Integer] unix seconds.
34
39
  def upsert(key, origin:, seen_at:, **fields)
35
- existing = entries[key] || { "key" => key, "aliases" => [], "stores" => [] }
40
+ @db.transaction { merge_and_write(key, origin, seen_at, fields) }
41
+ end
42
+
43
+ # A batch of sightings in one transaction — all of them land or,
44
+ # if any raises, none do. Each row is a Hash: `key:`, `origin:`,
45
+ # `seen_at:` plus the same fields #upsert takes. Rows merge in
46
+ # order, so a key repeated in one batch accumulates like two
47
+ # #upsert calls would.
48
+ # @return [Array<Hash>] the merged entries, in row order.
49
+ def upsert_many(rows)
50
+ return [] if rows.empty?
51
+
52
+ @db.transaction do
53
+ rows.map do |row|
54
+ fields = row.except(:key, :origin, :seen_at)
55
+ merge_and_write(row.fetch(:key), row.fetch(:origin), row.fetch(:seen_at), fields)
56
+ end
57
+ end
58
+ end
59
+
60
+ def exists? = @db.exists?
61
+
62
+ def count = @db.count("products")
63
+
64
+ # `index show --products`: one page of entries, in insertion order.
65
+ def page(number, per_page:)
66
+ return [] unless exists?
67
+
68
+ offset = ([number.to_i, 1].max - 1) * per_page
69
+ rows = @db.execute("SELECT data FROM products ORDER BY id LIMIT ? OFFSET ?", [per_page, offset])
70
+ rows.map { |(data)| JSON.parse(data) }
71
+ end
72
+
73
+ # `portage index search` (docs/plans/local-catalogue.md Phase 2):
74
+ # every query word must match title, brand, category or an alias
75
+ # (as a prefix, after dropping a plural ending), best bm25 first.
76
+ # Without FTS5 (a system SQLite built without it) the same filters
77
+ # run as a LIKE scan, unranked. Untrusted seeds, same as every
78
+ # other entry: no price, no stock.
79
+ # @param store [String, nil] a host or URL; matches that host's origin.
80
+ # @return [Array<Hash>] entries.
81
+ def search(query, category: nil, store: nil, limit: 20)
82
+ words = Search.words(query)
83
+ return [] if words.empty? || !exists?
84
+
85
+ sql, binds = Search.sql(words, category: category, host: Search.host_of(store), limit: limit,
86
+ fts: search_engine == "fts5")
87
+ @db.execute(sql, binds).map { |(data)| JSON.parse(data) }
88
+ end
89
+
90
+ # "fts5", or "like" when the database has no products_fts table.
91
+ def search_engine
92
+ return Schema.fts5_available? ? "fts5" : "like" unless exists?
93
+
94
+ fts = @db.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'products_fts'")
95
+ fts.empty? ? "like" : "fts5"
96
+ end
97
+
98
+ private
99
+
100
+ def merge_and_write(key, origin, seen_at, fields)
101
+ existing = @db.get("products", key) || { "key" => key, "aliases" => [], "stores" => [] }
36
102
  aliases = merge_aliases(existing, fields[:title])
37
103
  merged = existing.merge(fields.transform_keys(&:to_s)) { |field, old, new| merge_field(field, old, new) }
38
104
  merged["aliases"] = aliases
39
105
  merged["stores"] = merge_stores(merged["stores"], origin, seen_at)
40
- entries[key] = merged
41
- write
106
+ @db.put("products", key, merged)
42
107
  merged
43
108
  end
44
109
 
45
- def exists? = File.exist?(@path)
46
-
47
- private
48
-
49
110
  # `aliases`/`stores` accumulate across upserts (merged separately
50
111
  # below), `sources` is the union of every sighting's; every other
51
112
  # field (title, brand, gtin, category) is just the latest
@@ -72,29 +133,6 @@ module Portage
72
133
  kept = Array(stores).reject { |s| s["origin"] == origin }
73
134
  (kept + [{ "origin" => origin, "last_seen" => seen_at }]).sort_by { |s| s["origin"] }
74
135
  end
75
-
76
- def entries
77
- @entries ||= read
78
- end
79
-
80
- def read
81
- return {} unless File.readable?(@path)
82
-
83
- # See Index::Store#read's comment — a product title routinely
84
- # carries non-ASCII bytes, so this has to read as UTF-8 rather
85
- # than whatever the process's default external encoding is.
86
- parsed = JSON.parse(File.read(@path, encoding: "UTF-8"))
87
- parsed.is_a?(Hash) ? parsed : {}
88
- rescue StandardError
89
- {}
90
- end
91
-
92
- def write
93
- FileUtils.mkdir_p(File.dirname(@path))
94
- File.write(@path, JSON.generate(@entries))
95
- rescue StandardError
96
- nil
97
- end
98
136
  end
99
137
  end
100
138
  end
@@ -0,0 +1,70 @@
1
+ require "sqlite3"
2
+
3
+ module Portage
4
+ module Cli
5
+ module Index
6
+ # The SQL behind Index::Database, forward-only: MIGRATIONS[n] takes
7
+ # user_version n to n + 1.
8
+ module Schema
9
+ # Kept minimal: `data` is the entry's JSON verbatim. `products`
10
+ # carries an integer id (rather than the key alone being the
11
+ # primary key) only so products_fts can share its rowid, which
12
+ # stays stable across VACUUM.
13
+ SCHEMA = <<~SQL.freeze
14
+ CREATE TABLE stores (origin TEXT PRIMARY KEY, data TEXT NOT NULL);
15
+ CREATE TABLE products (id INTEGER PRIMARY KEY, key TEXT NOT NULL UNIQUE, data TEXT NOT NULL);
16
+ CREATE TABLE product_stores (key TEXT NOT NULL, origin TEXT NOT NULL, last_seen INTEGER,
17
+ PRIMARY KEY (key, origin));
18
+ CREATE TRIGGER products_stores_ins AFTER INSERT ON products BEGIN
19
+ INSERT OR REPLACE INTO product_stores (key, origin, last_seen)
20
+ SELECT new.key, json_extract(value, '$.origin'), json_extract(value, '$.last_seen')
21
+ FROM json_each(new.data, '$.stores');
22
+ END;
23
+ CREATE TRIGGER products_stores_upd AFTER UPDATE OF data ON products BEGIN
24
+ DELETE FROM product_stores WHERE key = old.key;
25
+ INSERT OR REPLACE INTO product_stores (key, origin, last_seen)
26
+ SELECT new.key, json_extract(value, '$.origin'), json_extract(value, '$.last_seen')
27
+ FROM json_each(new.data, '$.stores');
28
+ END;
29
+ SQL
30
+
31
+ # Needs FTS5 compiled in, which the gem's bundled SQLite has and a
32
+ # system-libraries build might not — see fts5_available?.
33
+ FTS_SCHEMA = <<~SQL.freeze
34
+ CREATE VIRTUAL TABLE products_fts USING fts5(key UNINDEXED, title, brand, category, aliases);
35
+ CREATE TRIGGER products_fts_ins AFTER INSERT ON products BEGIN
36
+ INSERT INTO products_fts (rowid, key, title, brand, category, aliases)
37
+ VALUES (new.id, new.key, json_extract(new.data, '$.title'), json_extract(new.data, '$.brand'),
38
+ json_extract(new.data, '$.category'),
39
+ (SELECT group_concat(value, ' ') FROM json_each(new.data, '$.aliases')));
40
+ END;
41
+ CREATE TRIGGER products_fts_upd AFTER UPDATE OF data ON products BEGIN
42
+ DELETE FROM products_fts WHERE rowid = old.id;
43
+ INSERT INTO products_fts (rowid, key, title, brand, category, aliases)
44
+ VALUES (new.id, new.key, json_extract(new.data, '$.title'), json_extract(new.data, '$.brand'),
45
+ json_extract(new.data, '$.category'),
46
+ (SELECT group_concat(value, ' ') FROM json_each(new.data, '$.aliases')));
47
+ END;
48
+ CREATE TRIGGER products_fts_del AFTER DELETE ON products BEGIN
49
+ DELETE FROM products_fts WHERE rowid = old.id;
50
+ END;
51
+ SQL
52
+
53
+ # Each entry is a script run at that user_version. The FTS one is a
54
+ # no-op on a build without FTS5 (its version still advances).
55
+ MIGRATIONS = [
56
+ ->(db) { db.execute_batch(SCHEMA) },
57
+ ->(db) { db.execute_batch(FTS_SCHEMA) if Schema.fts5_available? }
58
+ ].freeze
59
+
60
+ # @return [Boolean] whether the linked SQLite was built with FTS5.
61
+ def self.fts5_available?
62
+ db = SQLite3::Database.new(":memory:")
63
+ db.execute("PRAGMA compile_options").flatten.include?("ENABLE_FTS5")
64
+ ensure
65
+ db&.close
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end