wp2txt 2.2.0 → 2.3.1
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/.dockerignore +2 -1
- data/.gitignore +3 -0
- data/CHANGELOG.md +19 -0
- data/DEVELOPMENT.md +2 -1
- data/DEVELOPMENT_ja.md +2 -1
- data/Dockerfile +10 -1
- data/README.md +36 -56
- data/README_ja.md +33 -1
- data/Rakefile +31 -5
- data/bin/wp2txt +1 -0
- data/bin/wp2txt-mcp +19 -20
- data/docs/RESEARCH.md +207 -0
- data/lib/wp2txt/cli.rb +43 -0
- data/lib/wp2txt/corpus.rb +385 -23
- data/lib/wp2txt/index_commands.rb +83 -0
- data/lib/wp2txt/langlinks_importer.rb +273 -0
- data/lib/wp2txt/metadata_index.rb +68 -2
- data/lib/wp2txt/multistream.rb +29 -0
- data/lib/wp2txt/output_path.rb +27 -0
- data/lib/wp2txt/regex.rb +7 -0
- data/lib/wp2txt/text_processing.rb +8 -0
- data/lib/wp2txt/utils.rb +17 -1
- data/lib/wp2txt/version.rb +1 -1
- data/spec/auto_download_spec.rb +77 -0
- data/spec/corpus_spec.rb +25 -0
- data/spec/docs_sync_spec.rb +39 -0
- data/spec/langlinks_importer_spec.rb +308 -0
- data/spec/multi_dump_attach_spec.rb +174 -0
- data/spec/spec_helper.rb +22 -0
- data/spec/support/meta_db_fixture.rb +53 -0
- data/spec/titles_output_path_spec.rb +338 -0
- data/spec/utils_spec.rb +129 -0
- metadata +14 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "support/multistream_fixture"
|
|
7
|
+
require_relative "support/meta_db_fixture"
|
|
8
|
+
require_relative "../lib/wp2txt/corpus"
|
|
9
|
+
require_relative "../lib/wp2txt/corpus_jobs"
|
|
10
|
+
require_relative "../lib/wp2txt/output_path"
|
|
11
|
+
|
|
12
|
+
# extract_corpus titles: and query_sql output_path: (design doc 04)
|
|
13
|
+
RSpec.describe "titles extraction and SQL file output" do
|
|
14
|
+
include MultistreamFixture
|
|
15
|
+
include MetaDbFixture
|
|
16
|
+
|
|
17
|
+
around do |example|
|
|
18
|
+
Dir.mktmpdir do |dir|
|
|
19
|
+
@dir = dir
|
|
20
|
+
@multistream_path, @index_path = create_fixture(dir)
|
|
21
|
+
|
|
22
|
+
ms_index = Wp2txt::MultistreamIndex.new(@index_path, use_cache: false, show_progress: false)
|
|
23
|
+
@db_path = Wp2txt::MetadataIndex.path_for(@multistream_path, cache_dir: dir)
|
|
24
|
+
Wp2txt::MetadataIndexBuilder.new(
|
|
25
|
+
@multistream_path, ms_index.stream_offsets,
|
|
26
|
+
db_path: @db_path, num_processes: 0
|
|
27
|
+
).build
|
|
28
|
+
|
|
29
|
+
@corpus = Wp2txt::Corpus.for_input(@multistream_path, cache_dir: dir)
|
|
30
|
+
example.run
|
|
31
|
+
@corpus.close
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def read_jsonl(path)
|
|
36
|
+
File.readlines(path).map { |l| JSON.parse(l) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe "extract_corpus titles:" do
|
|
40
|
+
it "extracts an explicit set with normalization, dedup, and input order" do
|
|
41
|
+
out = File.join(@dir, "t.jsonl")
|
|
42
|
+
result = @corpus.extract_corpus(
|
|
43
|
+
output_path: out, content: "summary",
|
|
44
|
+
titles: ["Film B", "Film_A", "film A", "Film B"], num_processes: 0
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
expect(result[:total_matching]).to eq(2) # normalized + deduplicated
|
|
48
|
+
expect(result[:articles_extracted]).to eq(2)
|
|
49
|
+
expect(read_jsonl(out).map { |r| r["title"] }).to eq(["Film B", "Film A"])
|
|
50
|
+
expect(result[:not_found][:count]).to eq(0)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "resolves one redirect hop and extracts under the resolved title" do
|
|
54
|
+
out = File.join(@dir, "t.jsonl")
|
|
55
|
+
result = @corpus.extract_corpus(
|
|
56
|
+
output_path: out, content: "summary", titles: ["Old Film"], num_processes: 0
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(result[:articles_extracted]).to eq(1)
|
|
60
|
+
expect(read_jsonl(out).first["title"]).to eq("Film A")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "reports missing titles in not_found (count + sample)" do
|
|
64
|
+
out = File.join(@dir, "t.jsonl")
|
|
65
|
+
result = @corpus.extract_corpus(
|
|
66
|
+
output_path: out, content: "summary",
|
|
67
|
+
titles: ["Film A", "Ghost One", "Ghost Two"], num_processes: 0
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
expect(result[:articles_extracted]).to eq(1)
|
|
71
|
+
expect(result[:not_found]).to eq({ count: 2, sample: ["Ghost One", "Ghost Two"] })
|
|
72
|
+
meta = JSON.parse(File.read(result[:meta_path]))
|
|
73
|
+
expect(meta["not_found"]["count"]).to eq(2)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "counts a redirect whose target does not exist as not found" do
|
|
77
|
+
db = SQLite3::Database.new(@db_path)
|
|
78
|
+
db.execute("INSERT INTO pages (page_id, title, namespace, redirect_to, text_length) VALUES (99, 'Broken Redirect', 0, 'Nowhere', 0)")
|
|
79
|
+
db.close
|
|
80
|
+
|
|
81
|
+
out = File.join(@dir, "t.jsonl")
|
|
82
|
+
result = @corpus.extract_corpus(
|
|
83
|
+
output_path: out, content: "summary", titles: ["Broken Redirect"], num_processes: 0
|
|
84
|
+
)
|
|
85
|
+
expect(result[:not_found]).to eq({ count: 1, sample: ["Broken Redirect"] })
|
|
86
|
+
expect(result[:articles_extracted]).to eq(0)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it "does not flag truncation for missing titles when the cap is not reached" do
|
|
90
|
+
out = File.join(@dir, "t.jsonl")
|
|
91
|
+
result = @corpus.extract_corpus(
|
|
92
|
+
output_path: out, content: "summary",
|
|
93
|
+
titles: ["Film A", "Ghost One", "Ghost Two"], num_processes: 0
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
expect(result[:not_found][:count]).to eq(2)
|
|
97
|
+
expect(result[:articles_extracted]).to eq(1)
|
|
98
|
+
expect(result[:truncated]).to be false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "deduplicates titles that resolve to the same article" do
|
|
102
|
+
db = SQLite3::Database.new(@db_path)
|
|
103
|
+
db.execute("INSERT INTO pages (page_id, title, namespace, redirect_to, text_length) VALUES (98, 'Ancient Film', 0, 'Film A', 0)")
|
|
104
|
+
db.close
|
|
105
|
+
|
|
106
|
+
# (a) two aliases redirecting to the same target
|
|
107
|
+
out1 = File.join(@dir, "t1.jsonl")
|
|
108
|
+
result1 = @corpus.extract_corpus(
|
|
109
|
+
output_path: out1, content: "summary",
|
|
110
|
+
titles: ["Old Film", "Ancient Film"], num_processes: 0
|
|
111
|
+
)
|
|
112
|
+
expect(result1[:articles_extracted]).to eq(1)
|
|
113
|
+
expect(read_jsonl(out1).map { |r| r["title"] }).to eq(["Film A"])
|
|
114
|
+
|
|
115
|
+
# (b) a direct title plus its redirect alias
|
|
116
|
+
out2 = File.join(@dir, "t2.jsonl")
|
|
117
|
+
result2 = @corpus.extract_corpus(
|
|
118
|
+
output_path: out2, content: "summary",
|
|
119
|
+
titles: ["Film A", "Old Film"], num_processes: 0
|
|
120
|
+
)
|
|
121
|
+
expect(result2[:articles_extracted]).to eq(1)
|
|
122
|
+
expect(read_jsonl(out2).map { |r| r["title"] }).to eq(["Film A"])
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "rejects combination with filter arguments (set is defined twice)" do
|
|
126
|
+
out = File.join(@dir, "t.jsonl")
|
|
127
|
+
[
|
|
128
|
+
{ category: "Japanese films" },
|
|
129
|
+
{ categories: ["Japanese films"] },
|
|
130
|
+
{ category_match: "films" },
|
|
131
|
+
{ title_match: "Film" }
|
|
132
|
+
].each do |filter|
|
|
133
|
+
expect do
|
|
134
|
+
@corpus.extract_corpus(output_path: out, content: "summary", titles: ["Film A"], **filter)
|
|
135
|
+
end.to raise_error(ArgumentError, /query_sql/), "expected #{filter.keys.first} to conflict"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "rejects more than 10,000 titles" do
|
|
140
|
+
expect do
|
|
141
|
+
@corpus.extract_corpus(output_path: File.join(@dir, "t.jsonl"), content: "summary",
|
|
142
|
+
titles: Array.new(10_001) { |i| "T#{i}" })
|
|
143
|
+
end.to raise_error(ArgumentError, /10_000|10000/)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it "interacts with limit and max_articles like the filter path" do
|
|
147
|
+
titles = ["Film A", "Film B", "Person X"]
|
|
148
|
+
limited = @corpus.extract_corpus(
|
|
149
|
+
output_path: File.join(@dir, "t1.jsonl"), content: "summary",
|
|
150
|
+
titles: titles, limit: 2, num_processes: 0
|
|
151
|
+
)
|
|
152
|
+
expect(limited[:articles_extracted]).to eq(2)
|
|
153
|
+
expect(limited[:truncated]).to be true
|
|
154
|
+
expect(limited[:total_matching]).to eq(3)
|
|
155
|
+
|
|
156
|
+
capped = @corpus.extract_corpus(
|
|
157
|
+
output_path: File.join(@dir, "t2.jsonl"), content: "summary",
|
|
158
|
+
titles: titles, max_articles: 1, num_processes: 0
|
|
159
|
+
)
|
|
160
|
+
expect(capped[:articles_extracted]).to eq(1)
|
|
161
|
+
expect(capped[:truncated]).to be true
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it "enumerates titles in .meta.json when 100 or fewer, and the sha is order-independent" do
|
|
165
|
+
result1 = @corpus.extract_corpus(
|
|
166
|
+
output_path: File.join(@dir, "t1.jsonl"), content: "summary",
|
|
167
|
+
titles: ["Film A", "Film B"], num_processes: 0
|
|
168
|
+
)
|
|
169
|
+
result2 = @corpus.extract_corpus(
|
|
170
|
+
output_path: File.join(@dir, "t2.jsonl"), content: "summary",
|
|
171
|
+
titles: ["Film B", "Film_A"], num_processes: 0
|
|
172
|
+
)
|
|
173
|
+
meta1 = JSON.parse(File.read(result1[:meta_path]))
|
|
174
|
+
meta2 = JSON.parse(File.read(result2[:meta_path]))
|
|
175
|
+
|
|
176
|
+
expect(meta1["query"]["titles"]).to eq(["Film A", "Film B"])
|
|
177
|
+
expect(meta1["query"]["titles_count"]).to eq(2)
|
|
178
|
+
expect(meta1["query"]["titles_sha256"]).to eq(meta2["query"]["titles_sha256"])
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it "records only count + sha256 in .meta.json when more than 100 titles" do
|
|
182
|
+
titles = ["Film A"] + Array.new(100) { |i| "Ghost #{i}" }
|
|
183
|
+
result = @corpus.extract_corpus(
|
|
184
|
+
output_path: File.join(@dir, "t.jsonl"), content: "summary",
|
|
185
|
+
titles: titles, num_processes: 0
|
|
186
|
+
)
|
|
187
|
+
meta = JSON.parse(File.read(result[:meta_path]))
|
|
188
|
+
|
|
189
|
+
expect(meta["query"]["titles_count"]).to eq(101)
|
|
190
|
+
expect(meta["query"]["titles_sha256"]).to match(/\A[0-9a-f]{64}\z/)
|
|
191
|
+
expect(meta["query"]).not_to have_key("titles")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it "passes titles through start_extract_job" do
|
|
195
|
+
out = File.join(@dir, "job.jsonl")
|
|
196
|
+
manager = Wp2txt::CorpusJobManager.new(
|
|
197
|
+
-> { Wp2txt::Corpus.for_input(@multistream_path, cache_dir: @dir) }
|
|
198
|
+
)
|
|
199
|
+
start = manager.start_extract(
|
|
200
|
+
output_path: out, content: "summary",
|
|
201
|
+
titles: ["Film A", "Ghost"], num_processes: 0
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
deadline = Time.now + 30
|
|
205
|
+
status = nil
|
|
206
|
+
loop do
|
|
207
|
+
status = manager.status(start[:job_id])
|
|
208
|
+
break unless status[:status] == "running"
|
|
209
|
+
raise "job did not finish in time" if Time.now > deadline
|
|
210
|
+
|
|
211
|
+
sleep 0.1
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
expect(status[:status]).to eq("completed")
|
|
215
|
+
expect(status[:result][:articles_extracted]).to eq(1)
|
|
216
|
+
expect(status[:result][:not_found][:count]).to eq(1)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
describe "query_sql output_path:" do
|
|
221
|
+
it "writes all rows as JSONL with NULLs and deduplicated column names" do
|
|
222
|
+
out = File.join(@dir, "q.jsonl")
|
|
223
|
+
result = @corpus.query_sql(
|
|
224
|
+
"SELECT title, NULL AS note, page_id AS x, page_id AS x FROM pages " \
|
|
225
|
+
"WHERE namespace = 0 AND redirect_to IS NULL ORDER BY page_id",
|
|
226
|
+
output_path: out
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
expect(result[:columns]).to eq(["title", "note", "x", "x_2"])
|
|
230
|
+
expect(result[:row_count]).to eq(3)
|
|
231
|
+
expect(result[:sample].size).to eq(3)
|
|
232
|
+
expect(result).not_to have_key(:rows)
|
|
233
|
+
|
|
234
|
+
records = read_jsonl(out)
|
|
235
|
+
expect(records.size).to eq(3)
|
|
236
|
+
expect(records.first).to eq({ "title" => "Film A", "note" => nil, "x" => 1, "x_2" => 1 })
|
|
237
|
+
expect(File.exist?("#{out}.partial")).to be false
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it "ignores limit in file-output mode" do
|
|
241
|
+
out = File.join(@dir, "q.jsonl")
|
|
242
|
+
result = @corpus.query_sql("SELECT title FROM pages", output_path: out, limit: 1)
|
|
243
|
+
expect(result[:row_count]).to eq(8)
|
|
244
|
+
expect(read_jsonl(out).size).to eq(8)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
it "refuses an existing output file unless overwrite is set" do
|
|
248
|
+
out = File.join(@dir, "q.jsonl")
|
|
249
|
+
File.write(out, "old")
|
|
250
|
+
expect { @corpus.query_sql("SELECT 1", output_path: out) }
|
|
251
|
+
.to raise_error(ArgumentError, /already exists/)
|
|
252
|
+
expect(File.read(out)).to eq("old")
|
|
253
|
+
|
|
254
|
+
@corpus.query_sql("SELECT 1 AS one", output_path: out, overwrite: true)
|
|
255
|
+
expect(read_jsonl(out)).to eq([{ "one" => 1 }])
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "removes .partial and leaves no output on SQL error" do
|
|
259
|
+
out = File.join(@dir, "q.jsonl")
|
|
260
|
+
expect { @corpus.query_sql("SELECT * FROM no_such_table", output_path: out) }
|
|
261
|
+
.to raise_error(ArgumentError, /SQL error/)
|
|
262
|
+
expect(File.exist?(out)).to be false
|
|
263
|
+
expect(File.exist?("#{out}.partial")).to be false
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
it "removes .partial and leaves no output on timeout kill" do
|
|
267
|
+
out = File.join(@dir, "q.jsonl")
|
|
268
|
+
runaway = "WITH RECURSIVE c(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM c) SELECT x FROM c"
|
|
269
|
+
expect { @corpus.query_sql(runaway, output_path: out, timeout: 1) }
|
|
270
|
+
.to raise_error(ArgumentError, /time limit/)
|
|
271
|
+
expect(File.exist?(out)).to be false
|
|
272
|
+
expect(File.exist?("#{out}.partial")).to be false
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
it "truncates at SQL_FILE_ROW_LIMIT" do
|
|
276
|
+
stub_const("Wp2txt::Corpus::SQL_FILE_ROW_LIMIT", 3)
|
|
277
|
+
out = File.join(@dir, "q.jsonl")
|
|
278
|
+
result = @corpus.query_sql("SELECT title FROM pages", output_path: out)
|
|
279
|
+
|
|
280
|
+
expect(result[:row_count]).to eq(3)
|
|
281
|
+
expect(result[:truncated]).to be true
|
|
282
|
+
expect(read_jsonl(out).size).to eq(3)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
it "clips cells over 64KB and counts them" do
|
|
286
|
+
out = File.join(@dir, "q.jsonl")
|
|
287
|
+
result = @corpus.query_sql(
|
|
288
|
+
"SELECT printf('%.70000d', 0) AS big",
|
|
289
|
+
output_path: out
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
expect(result[:cells_clipped]).to eq(1)
|
|
293
|
+
value = read_jsonl(out).first["big"]
|
|
294
|
+
expect(value.length).to eq(Wp2txt::Corpus::SQL_FILE_CELL_LIMIT + 1)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
it "writes a .meta.json sidecar with SQL, attach provenance, and counts" do
|
|
298
|
+
create_meta_db(@dir, lang: "en", date: "20260101",
|
|
299
|
+
pages: [[101, "Film A", 0, nil, 10]])
|
|
300
|
+
out = File.join(@dir, "q.jsonl")
|
|
301
|
+
result = @corpus.query_sql(
|
|
302
|
+
"SELECT COUNT(*) AS n FROM en_meta.pages", attach: ["en"], output_path: out
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
expect(result[:attached].first[:lang]).to eq("en")
|
|
306
|
+
meta = JSON.parse(File.read("#{out}.meta.json"))
|
|
307
|
+
expect(meta["tool"]).to eq("query_sql")
|
|
308
|
+
expect(meta["dump"]).to eq("testwiki-20260101")
|
|
309
|
+
expect(meta["sql"]).to include("en_meta.pages")
|
|
310
|
+
expect(meta["attached"]).to eq([
|
|
311
|
+
{ "lang" => "en", "dump_name" => "enwiki-20260101", "built_with" => Wp2txt::VERSION }
|
|
312
|
+
])
|
|
313
|
+
expect(meta["row_count"]).to eq(1)
|
|
314
|
+
expect(meta["truncated"]).to be false
|
|
315
|
+
expect(meta["cells_clipped"]).to eq(0)
|
|
316
|
+
expect(meta["generated_at"]).to match(/\A\d{4}-\d{2}-\d{2}T/)
|
|
317
|
+
expect(meta["wp2txt_version"]).to eq(Wp2txt::VERSION)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
describe Wp2txt::OutputPath do
|
|
322
|
+
it "confines paths under the server output directory" do
|
|
323
|
+
expect(described_class.confine("sub/out.jsonl", @dir)).to eq(File.join(@dir, "sub/out.jsonl"))
|
|
324
|
+
expect { described_class.confine("../escape.jsonl", @dir) }
|
|
325
|
+
.to raise_error(ArgumentError, /output directory/)
|
|
326
|
+
expect { described_class.confine("/etc/passwd", @dir) }
|
|
327
|
+
.to raise_error(ArgumentError, /output directory/)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it "refuses existing files unless overwrite is set" do
|
|
331
|
+
existing = File.join(@dir, "exists.jsonl")
|
|
332
|
+
File.write(existing, "x")
|
|
333
|
+
expect { described_class.confine("exists.jsonl", @dir) }
|
|
334
|
+
.to raise_error(ArgumentError, /already exists/)
|
|
335
|
+
expect(described_class.confine("exists.jsonl", @dir, overwrite: true)).to eq(existing)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
data/spec/utils_spec.rb
CHANGED
|
@@ -59,6 +59,135 @@ RSpec.describe "Wp2txt Utils" do
|
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# Regression: process_external_links used to strip the brackets of the
|
|
63
|
+
# [ref]/[/ref] markers (contents "ref" / "/ref" take the parts.size == 1
|
|
64
|
+
# branch), so remove_ref could no longer find them and the tag names plus
|
|
65
|
+
# reference body leaked into the extracted text. These tests exercise the
|
|
66
|
+
# composed make_reference -> format_wiki path, since testing remove_ref in
|
|
67
|
+
# isolation passes even with the bug.
|
|
68
|
+
describe "reference markers through format_wiki" do
|
|
69
|
+
it "removes <ref>...</ref> including the body" do
|
|
70
|
+
input = "Paris was founded.<ref>Patrick Boucheron, France in the World (2019) pp 81-86.</ref> The city grew."
|
|
71
|
+
result = format_wiki(make_reference(input))
|
|
72
|
+
expect(result).to eq "Paris was founded. The city grew."
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "removes named <ref name=\"x\">...</ref> including the body" do
|
|
76
|
+
input = "Fine dining.<ref name=\"lemonde\">Le Monde, 2 February 2015</ref> Paris has."
|
|
77
|
+
result = format_wiki(make_reference(input))
|
|
78
|
+
expect(result).to eq "Fine dining. Paris has."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "removes self-closing <ref name=\"x\"/>" do
|
|
82
|
+
input = "An asteroid,<ref name=\"x\"/> and a building."
|
|
83
|
+
result = format_wiki(make_reference(input))
|
|
84
|
+
expect(result).to eq "An asteroid, and a building."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "removes consecutive <ref>A</ref><ref>B</ref> without joining tag names" do
|
|
88
|
+
input = "Text<ref>A</ref><ref>B</ref> more."
|
|
89
|
+
result = format_wiki(make_reference(input))
|
|
90
|
+
expect(result).to eq "Text more."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "keeps [ref]...[/ref] markers when config[:ref] is true" do
|
|
94
|
+
input = "Paris was founded.<ref>Patrick Boucheron, France in the World (2019) pp 81-86.</ref> The city grew."
|
|
95
|
+
result = format_wiki(make_reference(input), ref: true)
|
|
96
|
+
expect(result).to eq "Paris was founded.[ref]Patrick Boucheron, France in the World (2019) pp 81-86.[/ref] The city grew."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Guards against the rejected fix of returning "[ref]" from the scanner
|
|
100
|
+
# block: that makes process_nested_single_pass re-detect the same spot
|
|
101
|
+
# until MAX_NESTING_ITERATIONS, leaving external links unprocessed.
|
|
102
|
+
it "still processes external links outside [ref] markers" do
|
|
103
|
+
input = "Claim.<ref>See [http://example.com the site] for detail.</ref> Next [http://foo.com Foo] end."
|
|
104
|
+
expect(format_wiki(make_reference(input))).to eq "Claim. Next Foo end."
|
|
105
|
+
expect(format_wiki(make_reference(input), ref: true)).to eq "Claim.[ref]See the site for detail.[/ref] Next Foo end."
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Regression: element splitting (Article#parse) breaks paragraphs at
|
|
110
|
+
# newlines, so a reference written across lines landed in separate elements
|
|
111
|
+
# with [ref] and [/ref] never visible to remove_ref at the same time. This
|
|
112
|
+
# also meant --extract-citations never fired for multi-line cite templates.
|
|
113
|
+
# These tests go through Article.new -> format_wiki per element, since
|
|
114
|
+
# passing a string to format_wiki directly skips element splitting and does
|
|
115
|
+
# not reproduce the bug.
|
|
116
|
+
describe "multi-line references through element splitting" do
|
|
117
|
+
def render_elements(wikitext, config = {})
|
|
118
|
+
Wp2txt::Article.new(wikitext).elements.map { |e| format_wiki(e[1], config) }.join
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "removes an empty reference spanning a blank line" do
|
|
122
|
+
result = render_elements("...in the Super League.<ref>\n\n</ref> In 2006, Catalans Dragons became...")
|
|
123
|
+
expect(result).not_to include("[ref]")
|
|
124
|
+
expect(result).not_to include("[/ref]")
|
|
125
|
+
expect(result).to include("Super League. In 2006,")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "removes a named empty reference spanning a blank line" do
|
|
129
|
+
result = render_elements("Claim.<ref name=\"x\">\n\n</ref> Next.")
|
|
130
|
+
expect(result).not_to include("[ref]")
|
|
131
|
+
expect(result).not_to include("[/ref]")
|
|
132
|
+
expect(result).to include("Claim. Next.")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "removes a non-empty reference spanning a blank line" do
|
|
136
|
+
result = render_elements("Claim.<ref>Author\n\nPublisher</ref> Next.")
|
|
137
|
+
expect(result).not_to include("[ref]")
|
|
138
|
+
expect(result).not_to include("[/ref]")
|
|
139
|
+
expect(result).to include("Claim. Next.")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "removes a multi-line cite template reference (with and without leading space)" do
|
|
143
|
+
["Claim.<ref> {{cite book\n |title=Foo\n |year=2019}}</ref> Next.",
|
|
144
|
+
"Claim.<ref>{{cite book\n|title=Foo\n|year=2019}}</ref> Next."].each do |input|
|
|
145
|
+
result = render_elements(input)
|
|
146
|
+
expect(result).not_to include("[ref]")
|
|
147
|
+
expect(result).not_to include("[/ref]")
|
|
148
|
+
expect(result).not_to include("cite book")
|
|
149
|
+
expect(result).to include("Claim. Next.")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The core of this fix: before, only the multi-line form leaked raw
|
|
154
|
+
# markup, so --extract-citations silently did nothing for it.
|
|
155
|
+
it "extracts citations identically from single-line and multi-line cite templates" do
|
|
156
|
+
config = { extract_citations: true, ref: true }
|
|
157
|
+
single = render_elements("Claim.<ref>{{cite book|title=Foo|year=2019}}</ref> Next.", config)
|
|
158
|
+
multi = render_elements("Claim.<ref>{{cite book\n|title=Foo\n|year=2019}}</ref> Next.", config)
|
|
159
|
+
expect(multi).to eq single
|
|
160
|
+
expect(single).to include("[ref]\"Foo\". 2019.[/ref]")
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Control: newlines outside references must still split paragraphs.
|
|
164
|
+
it "still splits ordinary paragraph boundaries at blank lines" do
|
|
165
|
+
elements = Wp2txt::Article.new("First para.\n\nSecond para.").elements
|
|
166
|
+
paragraphs = elements.select { |e| e[0] == :mw_paragraph }
|
|
167
|
+
expect(paragraphs.size).to eq 2
|
|
168
|
+
expect(paragraphs[0][1]).to include("First para.")
|
|
169
|
+
expect(paragraphs[1][1]).to include("Second para.")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# An unclosed <ref> must not pair with a later </ref> across paragraphs;
|
|
173
|
+
# the body text stays (a floating [ref] is the pre-existing behavior for
|
|
174
|
+
# this malformed markup).
|
|
175
|
+
it "does not swallow paragraphs when a <ref> is left unclosed" do
|
|
176
|
+
wikitext = "First para with <ref>unclosed reference.\n\nSecond para here.\n\n" \
|
|
177
|
+
"Third para with <ref>closed</ref> end."
|
|
178
|
+
result = render_elements(wikitext)
|
|
179
|
+
expect(result).to include("Second para here.")
|
|
180
|
+
expect(result).to include("First para with")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it "handles a multi-line reference with a group attribute" do
|
|
184
|
+
result = render_elements("Claim.<ref group=\"note\">Author\nTitle 2019</ref> Next.")
|
|
185
|
+
expect(result).not_to include("[ref]")
|
|
186
|
+
expect(result).not_to include("[/ref]")
|
|
187
|
+
expect(result).to include("Claim. Next.")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
62
191
|
describe "remove_table" do
|
|
63
192
|
it "removes table formated parts" do
|
|
64
193
|
str_before = "{| ... \n{| ... \n ...|}\n ...|}"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wp2txt
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yoichiro Hasebe
|
|
@@ -217,6 +217,7 @@ files:
|
|
|
217
217
|
- Rakefile
|
|
218
218
|
- bin/wp2txt
|
|
219
219
|
- bin/wp2txt-mcp
|
|
220
|
+
- docs/RESEARCH.md
|
|
220
221
|
- image/wp2txt-logo.svg
|
|
221
222
|
- image/wp2txt.svg
|
|
222
223
|
- lib/wp2txt.rb
|
|
@@ -242,10 +243,12 @@ files:
|
|
|
242
243
|
- lib/wp2txt/global_data_cache.rb
|
|
243
244
|
- lib/wp2txt/index_cache.rb
|
|
244
245
|
- lib/wp2txt/index_commands.rb
|
|
246
|
+
- lib/wp2txt/langlinks_importer.rb
|
|
245
247
|
- lib/wp2txt/magic_words.rb
|
|
246
248
|
- lib/wp2txt/memory_monitor.rb
|
|
247
249
|
- lib/wp2txt/metadata_index.rb
|
|
248
250
|
- lib/wp2txt/multistream.rb
|
|
251
|
+
- lib/wp2txt/output_path.rb
|
|
249
252
|
- lib/wp2txt/output_writer.rb
|
|
250
253
|
- lib/wp2txt/parser_functions.rb
|
|
251
254
|
- lib/wp2txt/ractor_worker.rb
|
|
@@ -273,6 +276,7 @@ files:
|
|
|
273
276
|
- spec/config_spec.rb
|
|
274
277
|
- spec/constants_spec.rb
|
|
275
278
|
- spec/corpus_spec.rb
|
|
279
|
+
- spec/docs_sync_spec.rb
|
|
276
280
|
- spec/file_utils_spec.rb
|
|
277
281
|
- spec/fixtures/samples.rb
|
|
278
282
|
- spec/formatter_sections_spec.rb
|
|
@@ -280,10 +284,12 @@ files:
|
|
|
280
284
|
- spec/global_data_cache_spec.rb
|
|
281
285
|
- spec/index_cache_spec.rb
|
|
282
286
|
- spec/integration_spec.rb
|
|
287
|
+
- spec/langlinks_importer_spec.rb
|
|
283
288
|
- spec/magic_words_spec.rb
|
|
284
289
|
- spec/markers_spec.rb
|
|
285
290
|
- spec/memory_monitor_spec.rb
|
|
286
291
|
- spec/metadata_index_spec.rb
|
|
292
|
+
- spec/multi_dump_attach_spec.rb
|
|
287
293
|
- spec/multistream_spec.rb
|
|
288
294
|
- spec/output_writer_spec.rb
|
|
289
295
|
- spec/parser_functions_spec.rb
|
|
@@ -292,11 +298,13 @@ files:
|
|
|
292
298
|
- spec/section_extractor_spec.rb
|
|
293
299
|
- spec/spec_helper.rb
|
|
294
300
|
- spec/stream_processor_spec.rb
|
|
301
|
+
- spec/support/meta_db_fixture.rb
|
|
295
302
|
- spec/support/multistream_fixture.rb
|
|
296
303
|
- spec/template_data_spec.rb
|
|
297
304
|
- spec/template_expander_spec.rb
|
|
298
305
|
- spec/template_processing_spec.rb
|
|
299
306
|
- spec/text_processing_spec.rb
|
|
307
|
+
- spec/titles_output_path_spec.rb
|
|
300
308
|
- spec/utils_spec.rb
|
|
301
309
|
- spec/wp2txt_spec.rb
|
|
302
310
|
- wp2txt.gemspec
|
|
@@ -334,6 +342,7 @@ test_files:
|
|
|
334
342
|
- spec/config_spec.rb
|
|
335
343
|
- spec/constants_spec.rb
|
|
336
344
|
- spec/corpus_spec.rb
|
|
345
|
+
- spec/docs_sync_spec.rb
|
|
337
346
|
- spec/file_utils_spec.rb
|
|
338
347
|
- spec/fixtures/samples.rb
|
|
339
348
|
- spec/formatter_sections_spec.rb
|
|
@@ -341,10 +350,12 @@ test_files:
|
|
|
341
350
|
- spec/global_data_cache_spec.rb
|
|
342
351
|
- spec/index_cache_spec.rb
|
|
343
352
|
- spec/integration_spec.rb
|
|
353
|
+
- spec/langlinks_importer_spec.rb
|
|
344
354
|
- spec/magic_words_spec.rb
|
|
345
355
|
- spec/markers_spec.rb
|
|
346
356
|
- spec/memory_monitor_spec.rb
|
|
347
357
|
- spec/metadata_index_spec.rb
|
|
358
|
+
- spec/multi_dump_attach_spec.rb
|
|
348
359
|
- spec/multistream_spec.rb
|
|
349
360
|
- spec/output_writer_spec.rb
|
|
350
361
|
- spec/parser_functions_spec.rb
|
|
@@ -353,10 +364,12 @@ test_files:
|
|
|
353
364
|
- spec/section_extractor_spec.rb
|
|
354
365
|
- spec/spec_helper.rb
|
|
355
366
|
- spec/stream_processor_spec.rb
|
|
367
|
+
- spec/support/meta_db_fixture.rb
|
|
356
368
|
- spec/support/multistream_fixture.rb
|
|
357
369
|
- spec/template_data_spec.rb
|
|
358
370
|
- spec/template_expander_spec.rb
|
|
359
371
|
- spec/template_processing_spec.rb
|
|
360
372
|
- spec/text_processing_spec.rb
|
|
373
|
+
- spec/titles_output_path_spec.rb
|
|
361
374
|
- spec/utils_spec.rb
|
|
362
375
|
- spec/wp2txt_spec.rb
|