portage-cli 0.10.0 → 0.12.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 +129 -0
  3. data/README.md +107 -21
  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 +217 -39
  9. data/lib/portage/cli/check.rb +11 -1
  10. data/lib/portage/cli/classifier/ranking.rb +135 -0
  11. data/lib/portage/cli/classifier/table.rb +63 -0
  12. data/lib/portage/cli/classifier.rb +23 -44
  13. data/lib/portage/cli/confidence_check.rb +27 -7
  14. data/lib/portage/cli/confidence_state.rb +109 -0
  15. data/lib/portage/cli/doctor.rb +16 -6
  16. data/lib/portage/cli/find.rb +69 -4
  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 +111 -12
  34. metadata +30 -2
@@ -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
@@ -0,0 +1,73 @@
1
+ require "uri"
2
+
3
+ module Portage
4
+ module Cli
5
+ module Index
6
+ # The SQL behind ProductStore#search: an FTS5 MATCH over products_fts
7
+ # (Index::Schema) ranked by bm25, or the same filters as a LIKE scan
8
+ # when the database has no FTS table.
9
+ module Search
10
+ # bm25 weights for products_fts's columns: key (unindexed), title,
11
+ # brand, category, aliases. A title hit outranks a brand-only one.
12
+ BM25 = "bm25(products_fts, 0.0, 10.0, 3.0, 1.0, 5.0)".freeze
13
+ LIKE_COLUMNS = %w[$.title $.brand $.category $.aliases].freeze
14
+ HOST_LIKE = Array.new(4) { "s.origin LIKE ?" }.join(" OR ").freeze
15
+
16
+ module_function
17
+
18
+ # Letters and digits only, so nothing the user types is FTS5 syntax.
19
+ # A trailing plural ending is dropped and each word matched as a
20
+ # prefix, so "lights" finds "Light" and "Lighting".
21
+ def words(query)
22
+ query.to_s.downcase.scan(/\p{Alnum}+/).map { |w| w.length > 3 ? w.sub(/(?:ies|es|s)\z/, "") : w }
23
+ end
24
+
25
+ def host_of(store)
26
+ return nil if store.to_s.strip.empty?
27
+
28
+ text = store.to_s.strip
29
+ (text.include?("://") ? URI.parse(text).host : text.split(%r{[/?]}).first).to_s.downcase
30
+ rescue URI::InvalidURIError
31
+ text.downcase
32
+ end
33
+
34
+ # @return [Array(String, Array)] sql and binds.
35
+ def sql(words, category:, host:, limit:, fts:)
36
+ match_sql, binds = fts ? fts_match(words) : like_match(words)
37
+ filters = [match_sql]
38
+ if category
39
+ filters << "json_extract(p.data, '$.category') = ?"
40
+ binds << category.to_s
41
+ end
42
+ if host
43
+ filters << "EXISTS (SELECT 1 FROM product_stores s WHERE s.key = p.key AND (#{HOST_LIKE}))"
44
+ binds.concat(host_patterns(host))
45
+ end
46
+ [select(fts, filters.join(" AND ")), binds + [limit.to_i]]
47
+ end
48
+
49
+ # The host itself or any subdomain of it, with or without a port —
50
+ # so `--store jbhifi.com.au` finds https://www.jbhifi.com.au.
51
+ def host_patterns(host) = ["%://#{host}", "%://#{host}:%", "%.#{host}", "%.#{host}:%"]
52
+
53
+ def select(fts, where)
54
+ if fts
55
+ "SELECT p.data FROM products_fts JOIN products p ON p.id = products_fts.rowid " \
56
+ "WHERE #{where} ORDER BY #{BM25} LIMIT ?"
57
+ else
58
+ "SELECT p.data FROM products p WHERE #{where} ORDER BY p.id LIMIT ?"
59
+ end
60
+ end
61
+
62
+ def fts_match(words)
63
+ ["products_fts MATCH ?", [words.map { |w| %("#{w}"*) }.join(" ")]]
64
+ end
65
+
66
+ def like_match(words)
67
+ any_column = LIKE_COLUMNS.map { |path| "lower(json_extract(p.data, '#{path}')) LIKE ?" }.join(" OR ")
68
+ [words.map { "(#{any_column})" }.join(" AND "), words.flat_map { |w| ["%#{w}%"] * LIKE_COLUMNS.length }]
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,127 @@
1
+ require "portage/ucp"
2
+
3
+ require_relative "../../../classifier"
4
+
5
+ module Portage
6
+ module Cli
7
+ module Index
8
+ module Sources
9
+ class StorefrontProducts
10
+ # One Shopify `/products.json` product -> Portage::Ucp::Product
11
+ # (docs/plans/local-catalogue.md Phase 2), then the index sighting
12
+ # taken from that Product. The product shape is UCP's, never a
13
+ # parallel "card" vocabulary.
14
+ #
15
+ # Not portage-ucp-shopify's Mapper: that one reads Storefront
16
+ # GraphQL nodes (camelCase, gids, MoneyV2 with a currency), not the
17
+ # REST products.json shape, and portage-cli doesn't depend on that
18
+ # gem.
19
+ #
20
+ # The Product carries what the store sent, price and availability
21
+ # included (products.json has no currency, so Price#currency is
22
+ # nil). #sighting is what the index persists, and it takes only
23
+ # identity fields from the Product: price and availability never
24
+ # reach it.
25
+ module Mapper
26
+ MAX_CATEGORIES = 3
27
+ TAXONOMY = "google_product_category".freeze
28
+ # Shopify's own placeholder for a product with no real options.
29
+ PLACEHOLDER_OPTION = { "name" => "Title", "values" => ["Default Title"] }.freeze
30
+
31
+ module_function
32
+
33
+ # @param raw [Hash] one entry of products.json's "products".
34
+ # @param classify [#call] text -> category ids (Classifier).
35
+ def product(raw, origin:, classify: Classifier.method(:categories_for))
36
+ options = product_options(raw)
37
+ Portage::Ucp::Product.new(
38
+ id: "gid://shopify/Product/#{raw['id']}", title: raw["title"].to_s,
39
+ description: Portage::Ucp::Description.new(html: raw["body_html"]),
40
+ price_range: price_range(raw), variants: Array(raw["variants"]).map { |v| variant(v, options) },
41
+ handle: raw["handle"], url: "#{origin}/products/#{raw['handle']}",
42
+ categories: categories(raw, classify), media: Array(raw["images"]).map { |i| media(i) },
43
+ options: options.map { |o| option(o) }, tags: Array(raw["tags"])
44
+ )
45
+ end
46
+
47
+ # @return [Hash] an Index::Builder sighting: the ProductStore
48
+ # fields plus `product:`, the extra entry fields this source
49
+ # adds (handle, url, image_url, options, variant_ids).
50
+ def sighting(raw, origin:, classify: Classifier.method(:categories_for))
51
+ product = product(raw, origin: origin, classify: classify)
52
+ { origin: origin, url: product.url, title: product.title, brand: brand(raw), gtin: nil,
53
+ categories: product.categories.select { |c| c.taxonomy == TAXONOMY }.map(&:value),
54
+ product: { handle: product.handle, url: product.url, image_url: product.media.first&.url,
55
+ options: product.options.map(&:to_wire_h), variant_ids: product.variants.map(&:id) } }
56
+ end
57
+
58
+ def brand(raw)
59
+ vendor = raw["vendor"].to_s.strip
60
+ vendor.empty? ? nil : vendor
61
+ end
62
+
63
+ def product_options(raw)
64
+ Array(raw["options"]).reject { |o| o.slice("name", "values") == PLACEHOLDER_OPTION }
65
+ end
66
+
67
+ def option(raw)
68
+ values = Array(raw["values"]).map { |label| Portage::Ucp::OptionValue.new(label: label.to_s) }
69
+ Portage::Ucp::ProductOption.new(name: raw["name"].to_s, values: values)
70
+ end
71
+
72
+ def variant(raw, options)
73
+ Portage::Ucp::Variant.new(
74
+ id: "gid://shopify/ProductVariant/#{raw['id']}", title: raw["title"].to_s,
75
+ description: Portage::Ucp::Description.new(plain: raw["title"].to_s), price: price(raw["price"]),
76
+ sku: raw["sku"].to_s.empty? ? nil : raw["sku"],
77
+ list_price: raw["compare_at_price"] ? price(raw["compare_at_price"]) : nil,
78
+ availability: raw.key?("available") ? { "available" => raw["available"] } : nil,
79
+ options: selected_options(raw, options), media: variant_media(raw)
80
+ )
81
+ end
82
+
83
+ # option1..option3 line up with the product's options by
84
+ # position; the placeholder option is already gone, so a
85
+ # "Default Title" variant selects nothing.
86
+ def selected_options(raw, options)
87
+ options.each_with_index.filter_map do |opt, i|
88
+ label = raw["option#{i + 1}"]
89
+ Portage::Ucp::SelectedOption.new(name: opt["name"].to_s, label: label.to_s) if label
90
+ end
91
+ end
92
+
93
+ def variant_media(raw) = raw["featured_image"] ? [media(raw["featured_image"])] : []
94
+
95
+ def media(raw)
96
+ Portage::Ucp::Media.new(type: "image", url: raw["src"], alt_text: raw["alt"], width: raw["width"],
97
+ height: raw["height"])
98
+ end
99
+
100
+ def price(amount)
101
+ Portage::Ucp::Price.new(amount: Portage::Ucp::Support::Amounts.decimal_to_minor(amount), currency: nil)
102
+ end
103
+
104
+ def price_range(raw)
105
+ amounts = Array(raw["variants"]).map { |v| v["price"] }.compact
106
+ amounts = ["0"] if amounts.empty?
107
+ minors = amounts.map { |a| Portage::Ucp::Support::Amounts.decimal_to_minor(a) }
108
+ Portage::Ucp::PriceRange.new(min: Portage::Ucp::Price.new(amount: minors.min, currency: nil),
109
+ max: Portage::Ucp::Price.new(amount: minors.max, currency: nil))
110
+ end
111
+
112
+ # Classifier on product_type and tags (the plan's inputs), top
113
+ # three ids, then the store's own product_type as a merchant
114
+ # category.
115
+ def categories(raw, classify)
116
+ text = [raw["product_type"], *Array(raw["tags"])].compact.join(" ")
117
+ ids = text.strip.empty? ? [] : classify.call(text).first(MAX_CATEGORIES)
118
+ google = ids.map { |id| Portage::Ucp::Category.new(value: id, taxonomy: TAXONOMY) }
119
+ type = raw["product_type"].to_s.strip
120
+ type.empty? ? google : google + [Portage::Ucp::Category.new(value: type, taxonomy: "merchant")]
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end