wp2txt 2.2.0 → 2.3.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.
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+ require "zlib"
6
+ require_relative "support/multistream_fixture"
7
+ require_relative "support/meta_db_fixture"
8
+ require_relative "../lib/wp2txt/langlinks_importer"
9
+ require_relative "../lib/wp2txt/multistream"
10
+
11
+ RSpec.describe Wp2txt::LanglinksImporter do
12
+ include MultistreamFixture
13
+ include MetaDbFixture
14
+
15
+ around do |example|
16
+ Dir.mktmpdir do |dir|
17
+ @dir = dir
18
+ @multistream_path, @index_path = create_fixture(dir)
19
+
20
+ ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
21
+ @db_path = Wp2txt::MetadataIndex.path_for(@multistream_path, cache_dir: dir)
22
+ Wp2txt::MetadataIndexBuilder.new(
23
+ @multistream_path, ms_index.stream_offsets,
24
+ db_path: @db_path, num_processes: 0
25
+ ).build
26
+ example.run
27
+ end
28
+ end
29
+
30
+ # MySQL dump content covering the tricky cases: escaped quotes/backslashes,
31
+ # commas and parens inside titles, underscores, multiple INSERT statements,
32
+ # and an INSERT for a different table (must be ignored)
33
+ LANGLINKS_SQL = <<~SQL
34
+ -- MySQL dump fixture
35
+ CREATE TABLE `langlinks` (
36
+ `ll_from` int unsigned NOT NULL DEFAULT 0,
37
+ `ll_lang` varbinary(20) NOT NULL DEFAULT '',
38
+ `ll_title` varbinary(255) NOT NULL DEFAULT ''
39
+ ) ENGINE=InnoDB DEFAULT CHARSET=binary;
40
+
41
+ INSERT INTO `langlinks` VALUES (1,'en','Film A'),(1,'de','Film A (Film)'),(2,'en','It\\'s a Film, Really (1984)');
42
+ INSERT INTO `langlinks` VALUES (3,'en','Back\\\\slash Title'),(3,'fr','Film B'),(4,'en','Underscore_title');
43
+ INSERT INTO `pagelinks` VALUES (1,'Foo',0);
44
+ UNLOCK TABLES;
45
+ SQL
46
+
47
+ def write_langlinks(name, content = LANGLINKS_SQL, gzip: false)
48
+ path = File.join(@dir, name)
49
+ if gzip
50
+ Zlib::GzipWriter.open(path) { |gz| gz.write(content) }
51
+ else
52
+ File.write(path, content)
53
+ end
54
+ path
55
+ end
56
+
57
+ def import(path, **opts)
58
+ described_class.new(@db_path, cache_dir: @dir).import!(path, **opts)
59
+ end
60
+
61
+ def langlinks_rows
62
+ db = SQLite3::Database.new(@db_path, readonly: true)
63
+ rows = db.execute("SELECT ll_from, ll_lang, ll_title FROM langlinks ORDER BY ll_from, ll_lang")
64
+ db.close
65
+ rows
66
+ end
67
+
68
+ describe "parsing and normalization" do
69
+ it "imports tuples with escapes, commas, parens, and multiple INSERT statements" do
70
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
71
+ result = import(path)
72
+
73
+ expect(result[:status]).to eq(:imported)
74
+ expect(result[:row_count]).to eq(6)
75
+ expect(langlinks_rows).to contain_exactly(
76
+ [1, "en", "Film A"],
77
+ [1, "de", "Film A (Film)"],
78
+ [2, "en", "It's a Film, Really (1984)"],
79
+ [3, "en", 'Back\slash Title'],
80
+ [3, "fr", "Film B"],
81
+ [4, "en", "Underscore title"]
82
+ )
83
+ end
84
+
85
+ it "reads .sql.gz files" do
86
+ path = write_langlinks("testwiki-20260101-langlinks.sql.gz", gzip: true)
87
+ expect(import(path)[:row_count]).to eq(6)
88
+ end
89
+
90
+ it "creates both indexes after the load" do
91
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
92
+ import(path)
93
+ db = SQLite3::Database.new(@db_path, readonly: true)
94
+ indexes = db.execute("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'langlinks'").flatten
95
+ db.close
96
+ expect(indexes).to contain_exactly("idx_langlinks_from", "idx_langlinks_lang_title")
97
+ end
98
+
99
+ it "keeps all rows across batch boundaries" do
100
+ stub_const("Wp2txt::LanglinksImporter::BATCH_SIZE", 2)
101
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
102
+ expect(import(path)[:row_count]).to eq(6)
103
+ expect(langlinks_rows.size).to eq(6)
104
+ end
105
+
106
+ it "imports multibyte (ja/zh/ko) titles, including escapes and underscores" do
107
+ sql = <<~SQL
108
+ INSERT INTO `langlinks` VALUES (1,'ja','宇宙戦艦ヤマト'),(2,'zh','粵語標題(電影)'),(3,'ko','한국어_제목'),(4,'ja','It\\'s 映画, Really(1984)');
109
+ SQL
110
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
111
+ result = import(path)
112
+
113
+ expect(result[:row_count]).to eq(4)
114
+ expect(langlinks_rows).to contain_exactly(
115
+ [1, "ja", "宇宙戦艦ヤマト"],
116
+ [2, "zh", "粵語標題(電影)"],
117
+ [3, "ko", "한국어 제목"],
118
+ [4, "ja", "It's 映画, Really(1984)"]
119
+ )
120
+ end
121
+
122
+ it "parses large multibyte INSERT lines fast enough (performance regression)" do
123
+ # A single ~200KB+ extended INSERT line full of multibyte titles: the
124
+ # old character-index parser was O(n²) here (minutes), regex scan is O(n)
125
+ titles = ["宇宙戦艦ヤマト(映画)", "粵語標題(電影, 1984)", "한국어 제목", "時間の旅, それから"]
126
+ tuples = Array.new(6_000) { |i| "(#{i + 1},'ja','#{titles[i % titles.size]}')" }
127
+ sql = +"INSERT INTO `langlinks` VALUES " << tuples.join(",") << ";\n"
128
+ expect(sql.bytesize).to be > 200_000
129
+
130
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
131
+ importer = described_class.new(@db_path, cache_dir: @dir)
132
+ count = 0
133
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
134
+ importer.send(:each_source_row, path) { count += 1 }
135
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
136
+
137
+ expect(count).to eq(6_000)
138
+ expect(elapsed).to be < 0.5
139
+ end
140
+
141
+ it "skips and counts rows whose title contains invalid UTF-8 bytes" do
142
+ # Real dumps contain historically corrupted bytes in ll_title (VARBINARY)
143
+ sql = +"INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Bad\xFFTitle'),(3,'fr','Film B');\n"
144
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql.b)
145
+ result = import(path)
146
+
147
+ expect(result[:status]).to eq(:imported)
148
+ expect(result[:row_count]).to eq(2)
149
+ expect(result[:skipped_invalid]).to eq(1)
150
+ expect(langlinks_rows).to contain_exactly([1, "en", "Film A"], [3, "fr", "Film B"])
151
+ expect(result[:provenance][:skipped_invalid]).to eq(1)
152
+ end
153
+
154
+ it "skips and counts rows whose language code contains invalid UTF-8 bytes" do
155
+ sql = +"INSERT INTO `langlinks` VALUES (1,'e\xFFn','Film A'),(2,'en','Film B');\n"
156
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql.b)
157
+ result = import(path)
158
+
159
+ expect(result[:row_count]).to eq(1)
160
+ expect(result[:skipped_invalid]).to eq(1)
161
+ expect(langlinks_rows).to eq([[2, "en", "Film B"]])
162
+ end
163
+
164
+ it "handles invalid bytes in .sql.gz input as well" do
165
+ sql = +"INSERT INTO `langlinks` VALUES (1,'en','Bad\xFFTitle'),(2,'en','Film B');\n"
166
+ path = write_langlinks("testwiki-20260101-langlinks.sql.gz", sql.b, gzip: true)
167
+ result = import(path)
168
+
169
+ expect(result[:row_count]).to eq(1)
170
+ expect(result[:skipped_invalid]).to eq(1)
171
+ end
172
+
173
+ it "filters target languages with the langs option" do
174
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
175
+ result = import(path, langs: %w[en fr])
176
+
177
+ expect(result[:row_count]).to eq(5)
178
+ expect(langlinks_rows.map { |r| r[1] }.uniq).to contain_exactly("en", "fr")
179
+ expect(result[:provenance][:lang_filter]).to eq("en,fr")
180
+ end
181
+ end
182
+
183
+ describe "version pinning" do
184
+ it "rejects a langlinks file whose dump name differs from the metadata DB" do
185
+ path = write_langlinks("testwiki-20260102-langlinks.sql")
186
+ expect { import(path) }.to raise_error(ArgumentError, /version mismatch/)
187
+ end
188
+
189
+ it "rejects a mismatched file even with force (no override)" do
190
+ path = write_langlinks("testwiki-20260102-langlinks.sql")
191
+ expect { import(path, force: true) }.to raise_error(ArgumentError, /version mismatch/)
192
+ end
193
+
194
+ it "rejects files whose name carries no dump name" do
195
+ path = write_langlinks("langlinks.sql")
196
+ expect { import(path) }.to raise_error(ArgumentError, /version mismatch/)
197
+ end
198
+ end
199
+
200
+ describe "re-import" do
201
+ it "is a no-op when already imported (reports imported_at)" do
202
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
203
+ first = import(path)
204
+ second = import(path)
205
+
206
+ expect(second[:status]).to eq(:already_imported)
207
+ expect(second[:imported_at]).to eq(first[:provenance][:imported_at])
208
+ expect(second[:row_count]).to eq(6)
209
+ end
210
+
211
+ it "drops and re-imports with force, refreshing the provenance" do
212
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
213
+ import(path)
214
+
215
+ smaller = <<~SQL
216
+ INSERT INTO `langlinks` VALUES (1,'en','Film A');
217
+ SQL
218
+ path2 = write_langlinks("testwiki-20260101-langlinks.sql", smaller)
219
+ result = import(path2, force: true)
220
+
221
+ expect(result[:status]).to eq(:imported)
222
+ expect(result[:row_count]).to eq(1)
223
+ expect(langlinks_rows).to eq([[1, "en", "Film A"]])
224
+ expect(result[:provenance][:row_count]).to eq(1)
225
+ end
226
+
227
+ it "clears stale provenance when a forced re-import fails midway" do
228
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
229
+ expect(import(path)[:status]).to eq(:imported)
230
+
231
+ # Simulate a failure during the load (corrupt bytes, disk full, ...),
232
+ # after the first batch has already been committed
233
+ stub_const("Wp2txt::LanglinksImporter::BATCH_SIZE", 1)
234
+ failing = described_class.new(@db_path, cache_dir: @dir)
235
+ allow(failing).to receive(:each_source_row) do |_source, &block|
236
+ block.call(1, "en", "Film A")
237
+ raise IOError, "simulated read failure"
238
+ end
239
+ expect { failing.import!(path, force: true) }.to raise_error(IOError)
240
+
241
+ # Partial table, but provenance is gone: judged as "not imported"
242
+ expect(langlinks_rows).to eq([[1, "en", "Film A"]])
243
+ meta = Wp2txt::MetadataIndex.new(@db_path)
244
+ expect(meta.langlinks_provenance).to be_nil
245
+ meta.close
246
+
247
+ # A non-force retry re-imports instead of reporting a stale success
248
+ retry_result = import(path)
249
+ expect(retry_result[:status]).to eq(:imported)
250
+ expect(retry_result[:row_count]).to eq(6)
251
+ end
252
+ end
253
+
254
+ describe "provenance" do
255
+ it "stamps source, size, time, tool version, filter, and row count" do
256
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
257
+ prov = import(path)[:provenance]
258
+
259
+ expect(prov[:source]).to eq("testwiki-20260101-langlinks.sql")
260
+ expect(prov[:source_size]).to eq(File.size(path))
261
+ expect(prov[:imported_at]).to match(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
262
+ expect(prov[:imported_with]).to eq(Wp2txt::VERSION)
263
+ expect(prov[:lang_filter]).to eq("all")
264
+ expect(prov[:row_count]).to eq(6)
265
+
266
+ meta = Wp2txt::MetadataIndex.new(@db_path)
267
+ expect(meta.langlinks_provenance[:row_count]).to eq(6)
268
+ meta.close
269
+ end
270
+ end
271
+
272
+ describe "sanity check" do
273
+ it "is skipped when the target language has no local meta DB" do
274
+ path = write_langlinks("testwiki-20260101-langlinks.sql")
275
+ expect(import(path)[:sanity]).to eq([])
276
+ end
277
+
278
+ it "reports the join rate against an installed target meta DB" do
279
+ create_meta_db(@dir, lang: "en", date: "20260101",
280
+ pages: [[101, "Film A", 0, nil, 10], [102, "Film B", 0, nil, 10]])
281
+ sql = <<~SQL
282
+ INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B');
283
+ SQL
284
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
285
+ result = import(path)
286
+
287
+ expect(result[:sanity].size).to eq(1)
288
+ check = result[:sanity].first
289
+ expect(check[:lang]).to eq("en")
290
+ expect(check[:match_rate]).to eq(1.0)
291
+ expect(check[:warning]).to be false
292
+ expect(check[:against]).to eq("enwiki-20260101")
293
+ end
294
+
295
+ it "warns when the join rate falls below 90%" do
296
+ create_meta_db(@dir, lang: "en", date: "20260101",
297
+ pages: [[101, "Something Else", 0, nil, 10]])
298
+ sql = <<~SQL
299
+ INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B');
300
+ SQL
301
+ path = write_langlinks("testwiki-20260101-langlinks.sql", sql)
302
+ check = import(path)[:sanity].first
303
+
304
+ expect(check[:match_rate]).to eq(0.0)
305
+ expect(check[:warning]).to be true
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "tmpdir"
5
+ require_relative "support/multistream_fixture"
6
+ require_relative "support/meta_db_fixture"
7
+ require_relative "../lib/wp2txt/corpus"
8
+ require_relative "../lib/wp2txt/langlinks_importer"
9
+
10
+ # query_sql's multi-dump ATTACH (design doc §2) and the langlinks join demo (§3)
11
+ RSpec.describe "multi-dump ATTACH" do
12
+ include MultistreamFixture
13
+ include MetaDbFixture
14
+
15
+ EN_PAGES = [
16
+ [101, "Film A", 0, nil, 100],
17
+ [102, "Film B", 0, nil, 100],
18
+ [103, "Person X (actor)", 0, nil, 100]
19
+ ].freeze
20
+
21
+ EN_SECTIONS = [
22
+ [101, "Plot", 2, 1],
23
+ [101, "Reception", 2, 2],
24
+ [101, "Production", 2, 3],
25
+ [102, "Synopsis", 2, 1]
26
+ ].freeze
27
+
28
+ around do |example|
29
+ Dir.mktmpdir do |dir|
30
+ @dir = dir
31
+ @multistream_path, @index_path = create_fixture(dir)
32
+
33
+ ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
34
+ @db_path = Wp2txt::MetadataIndex.path_for(@multistream_path, cache_dir: dir)
35
+ Wp2txt::MetadataIndexBuilder.new(
36
+ @multistream_path, ms_index.stream_offsets,
37
+ db_path: @db_path, num_processes: 0
38
+ ).build
39
+
40
+ # Same-date "en" dump (attachable), different-date "fr" dump (mismatch)
41
+ @en_meta = create_meta_db(dir, lang: "en", date: "20260101",
42
+ pages: EN_PAGES, sections: EN_SECTIONS)
43
+ @fr_meta = create_meta_db(dir, lang: "fr", date: "20260202",
44
+ pages: [[201, "Film A (fr)", 0, nil, 50]])
45
+
46
+ @corpus = Wp2txt::Corpus.for_input(@multistream_path, cache_dir: dir)
47
+ example.run
48
+ @corpus.close
49
+ end
50
+ end
51
+
52
+ describe "attach argument validation" do
53
+ it "rejects malformed language codes (paths, traversal, injection)" do
54
+ ["../x", "en/x", "en;DROP", "EN", "e", "x" * 20, ""].each do |bad|
55
+ expect { @corpus.query_sql("SELECT 1", attach: [bad]) }
56
+ .to raise_error(ArgumentError, /invalid language code/), "expected #{bad.inspect} to be rejected"
57
+ end
58
+ end
59
+
60
+ it "rejects languages with no installed index" do
61
+ expect { @corpus.query_sql("SELECT 1", attach: ["ko"]) }
62
+ .to raise_error(ArgumentError, /no installed index/)
63
+ end
64
+
65
+ it "rejects attaching the main database's own language" do
66
+ expect { @corpus.query_sql("SELECT 1", attach: ["test"]) }
67
+ .to raise_error(ArgumentError, /cannot attach 'test'/)
68
+ end
69
+ end
70
+
71
+ describe "read-only cross-dump queries" do
72
+ it "attaches another language's meta DB as {lang}_meta" do
73
+ result = @corpus.query_sql("SELECT title FROM en_meta.pages ORDER BY page_id", attach: ["en"])
74
+ expect(result[:rows].flatten).to eq(["Film A", "Film B", "Person X (actor)"])
75
+ end
76
+
77
+ it "attaches the FTS DB as {lang}_fts when built" do
78
+ create_fts_db(@en_meta)
79
+ result = @corpus.query_sql("SELECT name FROM en_fts.sqlite_master", attach: ["en"])
80
+ expect(result[:rows].flatten).to include("fts_map")
81
+ expect(result[:attached].first[:fts]).to be true
82
+ end
83
+
84
+ it "reports attached metadata (lang, dump_name, built_with, fts) in the response" do
85
+ result = @corpus.query_sql("SELECT 1", attach: ["en"])
86
+ expect(result[:attached]).to eq([
87
+ { lang: "en", dump_name: "enwiki-20260101", built_with: Wp2txt::VERSION, fts: false }
88
+ ])
89
+ end
90
+
91
+ it "flags dump_mismatch when only a different-date dump is installed" do
92
+ result = @corpus.query_sql("SELECT 1", attach: ["fr"])
93
+ entry = result[:attached].first
94
+ expect(entry[:dump_name]).to eq("frwiki-20260202")
95
+ expect(entry[:dump_mismatch]).to be true
96
+ end
97
+
98
+ it "makes attached databases read-only at the driver level" do
99
+ attachments = @corpus.send(:resolve_attachments, ["en"])
100
+ db = @corpus.send(:build_readonly_connection, attach_fts: false, attachments: attachments)
101
+ expect { db.execute("INSERT INTO en_meta.pages VALUES (999, 'x', 0, NULL, 0)") }
102
+ .to raise_error(SQLite3::Exception, /readonly/i)
103
+ db.close
104
+ end
105
+
106
+ it "maps hyphenated language codes to underscored aliases (zh-yue → zh_yue_meta)" do
107
+ create_meta_db(@dir, lang: "zh-yue", date: "20260101",
108
+ pages: [[301, "Cantonese Page", 0, nil, 10]])
109
+ result = @corpus.query_sql("SELECT title FROM zh_yue_meta.pages", attach: ["zh-yue"])
110
+ expect(result[:rows].flatten).to eq(["Cantonese Page"])
111
+ expect(result[:attached].first[:lang]).to eq("zh-yue")
112
+ end
113
+ end
114
+
115
+ describe "user SQL ATTACH/DETACH stays forbidden (regression)" do
116
+ it "rejects ATTACH in user SQL even when the attach argument is used" do
117
+ expect { @corpus.query_sql("SELECT 1; ATTACH DATABASE 'x' AS y", attach: ["en"]) }
118
+ .to raise_error(ArgumentError, /forbidden/)
119
+ end
120
+
121
+ it "rejects DETACH in user SQL" do
122
+ expect { @corpus.query_sql("SELECT 1; DETACH DATABASE en_meta", attach: ["en"]) }
123
+ .to raise_error(ArgumentError, /forbidden/)
124
+ end
125
+
126
+ it "still allows 'attach' inside string literals" do
127
+ result = @corpus.query_sql("SELECT 'please attach the file' AS note")
128
+ expect(result[:rows].first.first).to eq("please attach the file")
129
+ end
130
+ end
131
+
132
+ describe "langlinks join demo (design doc §3)" do
133
+ before do
134
+ path = File.join(@dir, "testwiki-20260101-langlinks.sql")
135
+ File.write(path, <<~SQL)
136
+ INSERT INTO `langlinks` VALUES (1,'en','Film A'),(2,'en','Film B'),(3,'en','Person X (actor)');
137
+ SQL
138
+ Wp2txt::LanglinksImporter.new(@db_path, cache_dir: @dir).import!(path)
139
+ end
140
+
141
+ it "compares section structures of article pairs across languages in one query" do
142
+ result = @corpus.query_sql(<<~SQL, attach: ["en"])
143
+ SELECT p.title AS ja_title, ll.ll_title AS en_title,
144
+ (SELECT COUNT(*) FROM page_sections s WHERE s.page_id = p.page_id) AS ja_sections,
145
+ (SELECT COUNT(*) FROM en_meta.page_sections s2
146
+ JOIN en_meta.pages p2 ON p2.page_id = s2.page_id
147
+ WHERE p2.title = ll.ll_title) AS en_sections
148
+ FROM pages p
149
+ JOIN langlinks ll ON ll.ll_from = p.page_id AND ll.ll_lang = 'en'
150
+ WHERE p.namespace = 0 AND p.redirect_to IS NULL
151
+ LIMIT 20
152
+ SQL
153
+
154
+ expect(result[:columns]).to eq(%w[ja_title en_title ja_sections en_sections])
155
+ expect(result[:rows]).to contain_exactly(
156
+ ["Film A", "Film A", 2, 3],
157
+ ["Film B", "Film B", 1, 1],
158
+ ["Person X", "Person X (actor)", 2, 0]
159
+ )
160
+ end
161
+
162
+ it "exposes langlinks provenance via dump_info" do
163
+ info = @corpus.dump_info
164
+ expect(info[:langlinks][:source]).to eq("testwiki-20260101-langlinks.sql")
165
+ expect(info[:langlinks][:row_count]).to eq(3)
166
+ end
167
+
168
+ it "includes the langlinks table in describe_schema (introspection-based)" do
169
+ schemas = @corpus.describe_schema[:meta].join("\n")
170
+ expect(schemas).to include("CREATE TABLE langlinks")
171
+ expect(schemas).to include("idx_langlinks_from")
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require_relative "../../lib/wp2txt/metadata_index"
5
+ require_relative "../../lib/wp2txt/fts_index"
6
+ require_relative "../../lib/wp2txt/version"
7
+
8
+ # Synthetic Tier 1 / Tier 2 index DBs for cross-dump (ATTACH) and langlinks
9
+ # specs: builds the SQLite files directly, without a real multistream dump
10
+ module MetaDbFixture
11
+ # Create a synthetic built metadata DB for LANG in DIR, mimicking what
12
+ # MetadataIndex.path_for + MetadataIndexBuilder produce.
13
+ # @param pages [Array] rows of [page_id, title, namespace, redirect_to, text_length]
14
+ # @param sections [Array] rows of [page_id, heading, level, ord]
15
+ # @return [String] db path
16
+ def create_meta_db(dir, lang:, date:, pages: [], sections: [],
17
+ built_with: Wp2txt::VERSION, built_at: "2026-01-02T00:00:00Z")
18
+ path = File.join(dir, "#{lang}wiki-#{date}-pages-articles-multistream_deadbeef#{Wp2txt::MetadataIndex::CACHE_SUFFIX}")
19
+ db = SQLite3::Database.new(path)
20
+ db.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)")
21
+ db.execute("CREATE TABLE pages (page_id INTEGER PRIMARY KEY, title TEXT, namespace INTEGER, redirect_to TEXT, text_length INTEGER)")
22
+ db.execute("CREATE TABLE page_categories (page_id INTEGER, category TEXT)")
23
+ db.execute("CREATE TABLE page_sections (page_id INTEGER, heading TEXT, level INTEGER, ord INTEGER)")
24
+ db.execute("CREATE TABLE category_hierarchy (child TEXT, parent TEXT)")
25
+ pages.each { |r| db.execute("INSERT INTO pages VALUES (?, ?, ?, ?, ?)", r) }
26
+ sections.each { |r| db.execute("INSERT INTO page_sections VALUES (?, ?, ?, ?)", r) }
27
+ {
28
+ "schema_version" => Wp2txt::MetadataIndex::SCHEMA_VERSION.to_s,
29
+ "wp2txt_version" => built_with,
30
+ "dump_name" => "#{lang}wiki-#{date}",
31
+ "built_at" => built_at
32
+ }.each { |k, v| db.execute("INSERT INTO metadata VALUES (?, ?)", [k, v]) }
33
+ db.close
34
+ path
35
+ end
36
+
37
+ # Create the synthetic FTS DB companion of a meta DB created above
38
+ # (same basename, _fts suffix)
39
+ # @return [String] db path
40
+ def create_fts_db(meta_db_path, built_with: Wp2txt::VERSION, built_at: "2026-01-02T00:00:00Z")
41
+ path = meta_db_path.sub(/#{Wp2txt::MetadataIndex::CACHE_SUFFIX}\z/, Wp2txt::FtsIndex::CACHE_SUFFIX)
42
+ db = SQLite3::Database.new(path)
43
+ db.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT)")
44
+ db.execute("CREATE TABLE fts_map (rowid INTEGER PRIMARY KEY, page_id INTEGER, heading TEXT, ord INTEGER)")
45
+ {
46
+ "schema_version" => Wp2txt::FtsIndex::SCHEMA_VERSION.to_s,
47
+ "wp2txt_version" => built_with,
48
+ "built_at" => built_at
49
+ }.each { |k, v| db.execute("INSERT INTO metadata VALUES (?, ?)", [k, v]) }
50
+ db.close
51
+ path
52
+ end
53
+ end