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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +33 -4
- data/known-stores/categories.yml +6841 -18
- data/known-stores/category-stoplist.yml +40 -0
- data/known-stores/category-synonyms.yml +15 -0
- data/lib/portage/cli/browser_import/categorize.rb +5 -2
- data/lib/portage/cli/buy.rb +3 -24
- data/lib/portage/cli/check.rb +164 -0
- data/lib/portage/cli/check_next_step.rb +51 -0
- data/lib/portage/cli/classifier/ranking.rb +135 -0
- data/lib/portage/cli/classifier/table.rb +63 -0
- data/lib/portage/cli/classifier.rb +23 -44
- data/lib/portage/cli/doctor.rb +16 -6
- data/lib/portage/cli/find.rb +5 -3
- data/lib/portage/cli/handoff_host.rb +33 -0
- data/lib/portage/cli/index/builder.rb +49 -12
- data/lib/portage/cli/index/database.rb +150 -0
- data/lib/portage/cli/index/entry_product.rb +37 -0
- data/lib/portage/cli/index/legacy_import.rb +54 -0
- data/lib/portage/cli/index/product_store.rb +73 -35
- data/lib/portage/cli/index/schema.rb +70 -0
- data/lib/portage/cli/index/search.rb +73 -0
- data/lib/portage/cli/index/sources/storefront_products/mapper.rb +127 -0
- data/lib/portage/cli/index/sources/storefront_products/pages.rb +114 -0
- data/lib/portage/cli/index/sources/storefront_products/robots.rb +70 -0
- data/lib/portage/cli/index/sources/storefront_products.rb +147 -0
- data/lib/portage/cli/index/sources.rb +5 -2
- data/lib/portage/cli/index/store.rb +23 -47
- data/lib/portage/cli/index.rb +1 -0
- data/lib/portage/cli/offer_sources.rb +17 -2
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +142 -12
- metadata +34 -4
data/lib/portage/cli/doctor.rb
CHANGED
|
@@ -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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
eligible.
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
@
|
|
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) =
|
|
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
|
-
|
|
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
|
-
|
|
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
|