wp2txt 2.1.2 → 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.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/CHANGELOG.md +27 -0
- data/Dockerfile +3 -0
- data/Gemfile +2 -0
- data/README.md +60 -2
- data/Rakefile +4 -1
- data/bin/wp2txt +9 -0
- data/bin/wp2txt-mcp +395 -0
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +110 -0
- data/lib/wp2txt/corpus.rb +1057 -0
- data/lib/wp2txt/corpus_jobs.rb +106 -0
- data/lib/wp2txt/fts_index.rb +445 -0
- data/lib/wp2txt/index_cache.rb +17 -0
- data/lib/wp2txt/index_commands.rb +427 -0
- data/lib/wp2txt/langlinks_importer.rb +273 -0
- data/lib/wp2txt/metadata_index.rb +738 -0
- data/lib/wp2txt/multistream.rb +38 -5
- data/lib/wp2txt/output_path.rb +27 -0
- data/lib/wp2txt/version.rb +1 -1
- data/spec/auto_download_spec.rb +77 -0
- data/spec/corpus_spec.rb +503 -0
- data/spec/fts_index_spec.rb +245 -0
- data/spec/langlinks_importer_spec.rb +308 -0
- data/spec/metadata_index_spec.rb +208 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/support/multistream_fixture.rb +66 -0
- data/spec/titles_output_path_spec.rb +338 -0
- metadata +27 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "open3"
|
|
7
|
+
require_relative "support/multistream_fixture"
|
|
8
|
+
require_relative "../lib/wp2txt/metadata_index"
|
|
9
|
+
require_relative "../lib/wp2txt/multistream"
|
|
10
|
+
require_relative "../lib/wp2txt/cli"
|
|
11
|
+
|
|
12
|
+
RSpec.describe "Wp2txt Metadata Index" do
|
|
13
|
+
include MultistreamFixture
|
|
14
|
+
|
|
15
|
+
describe Wp2txt::MetadataIndex do
|
|
16
|
+
describe ".normalize_category" do
|
|
17
|
+
it "replaces underscores, trims, and capitalizes the first letter" do
|
|
18
|
+
expect(described_class.normalize_category(" japanese_films ")).to eq("Japanese films")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "leaves non-ASCII names unchanged" do
|
|
22
|
+
expect(described_class.normalize_category("日本の映画作品")).to eq("日本の映画作品")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe ".clean_heading" do
|
|
27
|
+
it "strips bold markup and resolves links" do
|
|
28
|
+
expect(described_class.clean_heading("'''Bold''' [[link|Label]]")).to eq("Bold Label")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "strips HTML tags" do
|
|
32
|
+
expect(described_class.clean_heading("<small>Notes</small>")).to eq("Notes")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe ".expand_section_names" do
|
|
37
|
+
it "expands a canonical name to its alias group" do
|
|
38
|
+
expect(described_class.expand_section_names("Plot")).to include("Plot", "Synopsis")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "expands an alias back to the full group (bidirectional)" do
|
|
42
|
+
expect(described_class.expand_section_names("Synopsis")).to include("Plot", "Synopsis")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "returns the name itself when no alias group matches" do
|
|
46
|
+
expect(described_class.expand_section_names("Nonexistent Section")).to eq(["Nonexistent Section"])
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe ".path_for" do
|
|
51
|
+
it "builds a cache path keyed to the dump file" do
|
|
52
|
+
path = described_class.path_for("/dumps/jawiki-20260101-pages-articles-multistream.xml.bz2", cache_dir: "/cache")
|
|
53
|
+
expect(path).to start_with("/cache/jawiki-20260101-pages-articles-multistream")
|
|
54
|
+
expect(path).to end_with("_meta.sqlite3")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe "build and query" do
|
|
60
|
+
around do |example|
|
|
61
|
+
Dir.mktmpdir do |dir|
|
|
62
|
+
@dir = dir
|
|
63
|
+
@multistream_path, @index_path = create_fixture(dir)
|
|
64
|
+
@db_path = File.join(dir, "meta.sqlite3")
|
|
65
|
+
ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
|
|
66
|
+
builder = Wp2txt::MetadataIndexBuilder.new(
|
|
67
|
+
@multistream_path, ms_index.stream_offsets,
|
|
68
|
+
db_path: @db_path, num_processes: 0
|
|
69
|
+
)
|
|
70
|
+
@index = builder.build
|
|
71
|
+
example.run
|
|
72
|
+
@index.close
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "records all pages and counts articles excluding redirects and category pages" do
|
|
77
|
+
stats = @index.stats
|
|
78
|
+
expect(stats[:page_count]).to eq(8)
|
|
79
|
+
expect(stats[:article_count]).to eq(3)
|
|
80
|
+
expect(stats[:dump_name]).to eq("testwiki-20260101")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "finds articles by exact category" do
|
|
84
|
+
expect(@index.find_articles(category: "Japanese films")).to eq(["Film A"])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "finds articles through subcategories with depth" do
|
|
88
|
+
expect(@index.find_articles(category: "Films", depth: 1)).to contain_exactly("Film A", "Film B")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "does not include subcategory members at depth 0" do
|
|
92
|
+
expect(@index.find_articles(category: "Films", depth: 0)).to be_empty
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "matches sections through aliases" do
|
|
96
|
+
titles = @index.find_articles(category: "Films", depth: 1, has_section: "Plot")
|
|
97
|
+
expect(titles).to contain_exactly("Film A", "Film B")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "matches sections exactly when aliases are disabled" do
|
|
101
|
+
titles = @index.find_articles(category: "Films", depth: 1, has_section: "Plot", use_aliases: false)
|
|
102
|
+
expect(titles).to eq(["Film A"])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "excludes redirects from results" do
|
|
106
|
+
expect(@index.find_articles(title_match: "Old Film")).to be_empty
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "filters by title substring" do
|
|
110
|
+
expect(@index.find_articles(title_match: "Film")).to contain_exactly("Film A", "Film B")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "applies limit and reports full count separately" do
|
|
114
|
+
titles = @index.find_articles(title_match: "Film", limit: 1)
|
|
115
|
+
expect(titles.size).to eq(1)
|
|
116
|
+
expect(@index.count_articles(title_match: "Film")).to eq(2)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "returns the category tree with depths" do
|
|
120
|
+
tree = @index.category_tree("Films", depth: 1)
|
|
121
|
+
expect(tree).to include({ name: "Films", depth: 0 },
|
|
122
|
+
{ name: "Japanese films", depth: 1 },
|
|
123
|
+
{ name: "French films", depth: 1 })
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "collects section statistics" do
|
|
127
|
+
stats = @index.section_stats
|
|
128
|
+
expect(stats).to include(["Plot", 1], ["Synopsis", 1], ["Career", 1])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "scopes section statistics to a category" do
|
|
132
|
+
stats = @index.section_stats(category: "Films", depth: 1)
|
|
133
|
+
headings = stats.map(&:first)
|
|
134
|
+
expect(headings).to include("Plot", "Synopsis")
|
|
135
|
+
expect(headings).not_to include("Career")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it "records redirect targets" do
|
|
139
|
+
db = SQLite3::Database.new(@db_path)
|
|
140
|
+
target = db.get_first_value("SELECT redirect_to FROM pages WHERE title = 'Old Film'")
|
|
141
|
+
db.close
|
|
142
|
+
expect(target).to eq("Film A")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "validates against the source dump file" do
|
|
146
|
+
expect(@index.built?).to be true
|
|
147
|
+
expect(@index.valid_for?(@multistream_path)).to be true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "records the wp2txt version it was built with" do
|
|
151
|
+
expect(@index.stats[:built_with]).to eq(Wp2txt::VERSION)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it "sees headings even when a trailing HTML comment follows the closing markers" do
|
|
155
|
+
expect(@index.find_articles(has_section: "Career", use_aliases: false)).to eq(["Person X"])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "ignores commented-out category links" do
|
|
159
|
+
expect(@index.categories_of("Person X")).to eq(["Japanese actors"])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it "builds atomically: the existing index survives an unfinished rebuild" do
|
|
163
|
+
expect(@index.built?).to be true
|
|
164
|
+
rebuilding = Wp2txt::MetadataIndex.new(@db_path)
|
|
165
|
+
rebuilding.prepare_build!
|
|
166
|
+
rebuilding.insert_batch(pages: [[99, "Partial", 0, nil, 10]], categories: [], sections: [], hierarchy: [])
|
|
167
|
+
rebuilding.close # abandon before finalize
|
|
168
|
+
|
|
169
|
+
survivor = Wp2txt::MetadataIndex.new(@db_path)
|
|
170
|
+
expect(survivor.built?).to be true
|
|
171
|
+
expect(survivor.find_articles(title_match: "Film A")).to eq(["Film A"])
|
|
172
|
+
expect(survivor.find_articles(title_match: "Partial")).to be_empty
|
|
173
|
+
survivor.close
|
|
174
|
+
FileUtils.rm_f(Dir.glob("#{@db_path}.building*"))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it "detects a changed source dump file" do
|
|
178
|
+
File.binwrite(@multistream_path, File.binread(@multistream_path) + "x")
|
|
179
|
+
expect(@index.valid_for?(@multistream_path)).to be false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "reports not built for a missing index file" do
|
|
183
|
+
missing = Wp2txt::MetadataIndex.new(File.join(@dir, "nope.sqlite3"))
|
|
184
|
+
expect(missing.built?).to be false
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
describe "CLI option validation" do
|
|
189
|
+
it "rejects --in-category without --find-articles" do
|
|
190
|
+
expect do
|
|
191
|
+
Wp2txt::CLI.parse_options(["-L", "ja", "--in-category", "Films"])
|
|
192
|
+
end.to raise_error(SystemExit)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it "rejects combining --build-index with --find-articles" do
|
|
196
|
+
expect do
|
|
197
|
+
Wp2txt::CLI.parse_options(["-L", "ja", "--build-index", "--find-articles"])
|
|
198
|
+
end.to raise_error(SystemExit)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it "accepts --find-articles with filters" do
|
|
202
|
+
opts = Wp2txt::CLI.parse_options(["-L", "ja", "--find-articles", "--in-category", "Films", "--has-section", "Plot"])
|
|
203
|
+
expect(opts[:find_articles]).to be true
|
|
204
|
+
expect(opts[:in_category]).to eq("Films")
|
|
205
|
+
expect(opts[:has_section]).to eq("Plot")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
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
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
# Synthetic two-stream multistream dump used by metadata index / corpus specs.
|
|
6
|
+
# Stream 1 holds articles (ns 0), stream 2 holds category pages (ns 14).
|
|
7
|
+
module MultistreamFixture
|
|
8
|
+
def page_xml(id:, ns:, title:, text:)
|
|
9
|
+
escaped = text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
10
|
+
<<~XML
|
|
11
|
+
<page>
|
|
12
|
+
<title>#{title}</title>
|
|
13
|
+
<ns>#{ns}</ns>
|
|
14
|
+
<id>#{id}</id>
|
|
15
|
+
<revision>
|
|
16
|
+
<id>#{id * 100}</id>
|
|
17
|
+
<text bytes="#{text.bytesize}">#{escaped}</text>
|
|
18
|
+
</revision>
|
|
19
|
+
</page>
|
|
20
|
+
XML
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def bzip2(data)
|
|
24
|
+
out, status = Open3.capture2("bzip2", "-c", stdin_data: data)
|
|
25
|
+
raise "bzip2 failed" unless status.success?
|
|
26
|
+
|
|
27
|
+
out
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Array(String, String)] [multistream_path, index_path]
|
|
31
|
+
def create_fixture(dir)
|
|
32
|
+
stream1_pages = [
|
|
33
|
+
page_xml(id: 1, ns: 0, title: "Film A",
|
|
34
|
+
text: "Intro.\n== Plot ==\nStory here.\n== Reception ==\nGood.\n[[Category:Japanese films]]\n"),
|
|
35
|
+
page_xml(id: 2, ns: 0, title: "Film B",
|
|
36
|
+
text: "Intro.\n== Synopsis ==\nStory here.\n[[Category:French films|B]]\n"),
|
|
37
|
+
page_xml(id: 3, ns: 0, title: "Person X",
|
|
38
|
+
text: "Bio.\n== Career == <!-- legacy anchor -->\nActing.\n== '''Style''' ==\nDistinct prose.\n<!-- [[Category:Hidden]] -->\n[[Category:Japanese actors]]\n"),
|
|
39
|
+
page_xml(id: 4, ns: 0, title: "Old Film",
|
|
40
|
+
text: "#REDIRECT [[Film A]]\n[[Category:Japanese films]]\n")
|
|
41
|
+
]
|
|
42
|
+
stream2_pages = [
|
|
43
|
+
page_xml(id: 5, ns: 14, title: "Category:Japanese films", text: "[[Category:Films]]\n"),
|
|
44
|
+
page_xml(id: 6, ns: 14, title: "Category:French films", text: "[[Category:Films]]\n"),
|
|
45
|
+
page_xml(id: 7, ns: 14, title: "Category:Films", text: "Top category.\n"),
|
|
46
|
+
page_xml(id: 8, ns: 14, title: "Category:Japanese actors", text: "[[Category:People]]\n")
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
stream1 = bzip2(stream1_pages.join)
|
|
50
|
+
stream2 = bzip2(stream2_pages.join)
|
|
51
|
+
|
|
52
|
+
multistream_path = File.join(dir, "testwiki-20260101-pages-articles-multistream.xml.bz2")
|
|
53
|
+
File.binwrite(multistream_path, stream1 + stream2)
|
|
54
|
+
|
|
55
|
+
offset2 = stream1.bytesize
|
|
56
|
+
index_lines = [
|
|
57
|
+
"0:1:Film A", "0:2:Film B", "0:3:Person X", "0:4:Old Film",
|
|
58
|
+
"#{offset2}:5:Category:Japanese films", "#{offset2}:6:Category:French films",
|
|
59
|
+
"#{offset2}:7:Category:Films", "#{offset2}:8:Category:Japanese actors"
|
|
60
|
+
]
|
|
61
|
+
index_path = File.join(dir, "testwiki-20260101-pages-articles-multistream-index.txt")
|
|
62
|
+
File.write(index_path, index_lines.join("\n") + "\n")
|
|
63
|
+
|
|
64
|
+
[multistream_path, index_path]
|
|
65
|
+
end
|
|
66
|
+
end
|