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.
data/lib/wp2txt/corpus.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "fileutils"
3
5
  require "json"
4
6
  require "time"
7
+ require "uri"
5
8
  require_relative "../wp2txt"
6
9
  require_relative "article"
7
10
  require_relative "utils"
@@ -110,7 +113,8 @@ module Wp2txt
110
113
  metadata_current: @metadata.built? && @metadata.valid_for?(@multistream_path),
111
114
  fulltext_current: fts.built? && fts.valid_for?(@multistream_path),
112
115
  stats: stats,
113
- fulltext: fts.built? ? fts.stats : nil
116
+ fulltext: fts.built? ? fts.stats : nil,
117
+ langlinks: @metadata.built? ? @metadata.langlinks_provenance : nil
114
118
  }
115
119
  end
116
120
 
@@ -300,8 +304,16 @@ module Wp2txt
300
304
  # Titles fetched/rendered per batch while streaming to disk
301
305
  EXTRACT_BATCH_SIZE = 200
302
306
 
307
+ # Max titles accepted by extract_corpus titles: (larger sets belong in
308
+ # filter-based extraction or a background job)
309
+ TITLES_MAX = 10_000
310
+
303
311
  # @param output_path [String] JSONL destination (sidecar .meta.json is added)
304
312
  # @param content [String] "sections" | "full" | "summary"
313
+ # @param titles [Array<String>, nil] explicit article titles to extract
314
+ # (e.g. a set determined via query_sql). Normalized, deduplicated, one
315
+ # redirect hop resolved; missing titles are reported as not_found.
316
+ # Mutually exclusive with the filter arguments
305
317
  # @param chunk_size [Integer, nil] split text into ~N-char chunks (RAG-ready records)
306
318
  # @param chunk_overlap [Integer] overlap between consecutive chunks
307
319
  # @param max_articles [Integer, nil] sync cap (nil = unlimited, for jobs)
@@ -309,7 +321,7 @@ module Wp2txt
309
321
  # @param cancel_check [Proc, nil] polled between batches; truthy return aborts with Cancelled
310
322
  def extract_corpus(output_path:, content: "sections", sections: nil, alias_set: nil,
311
323
  category: nil, depth: 0, categories: nil, category_match: nil,
312
- title_match: nil, limit: 0,
324
+ title_match: nil, limit: 0, titles: nil,
313
325
  chunk_size: nil, chunk_overlap: 0,
314
326
  max_articles: DEFAULT_MAX_SYNC_ARTICLES, num_processes: 4,
315
327
  progress: nil, cancel_check: nil)
@@ -319,17 +331,56 @@ module Wp2txt
319
331
  raise ArgumentError, "chunk_overlap must be smaller than chunk_size" if chunk_size && chunk_overlap >= chunk_size
320
332
  raise ArgumentError, "chunking is not supported for content: \"wikitext\"" if chunk_size && content == "wikitext"
321
333
 
334
+ if titles
335
+ raise ArgumentError, "titles must be an array of title strings" unless titles.is_a?(Array)
336
+ if titles.size > TITLES_MAX
337
+ raise ArgumentError, "titles accepts at most #{TITLES_MAX} titles (got #{titles.size}); " \
338
+ "use filter-based extraction or a background job for larger sets"
339
+ end
340
+ conflicts = []
341
+ conflicts << "category" if category
342
+ conflicts << "categories" if categories
343
+ conflicts << "category_match" if category_match
344
+ conflicts << "title_match" if title_match
345
+ unless conflicts.empty?
346
+ raise ArgumentError, "titles cannot be combined with #{conflicts.join(', ')} — the article " \
347
+ "set would be defined twice; perform set operations in query_sql and " \
348
+ "pass the resulting titles via titles:"
349
+ end
350
+ end
351
+
322
352
  filters = { category: category, depth: depth, categories: categories,
323
353
  category_match: category_match, sections: sections,
324
354
  alias_set: alias_set, title_match: title_match }
325
- total = @metadata.count_articles(**filters)
326
- cap = if limit.positive?
327
- max_articles ? [limit, max_articles].min : limit
328
- else
329
- max_articles || total
330
- end
331
- titles = @metadata.find_articles(**filters, limit: cap)
332
- truncated = total > titles.size
355
+ not_found = nil
356
+ titles_record = nil
357
+ if titles
358
+ normalized = titles.map { |t| MetadataIndex.normalize_title(t) }.reject(&:empty?).uniq
359
+ titles_record = { titles_count: normalized.size,
360
+ titles_sha256: Digest::SHA256.hexdigest(normalized.sort.join("\n")) }
361
+ titles_record[:titles] = normalized if normalized.size <= 100
362
+ total = normalized.size
363
+ resolved, missing = resolve_explicit_titles(normalized)
364
+ not_found = { count: missing.size, sample: missing.first(20) }
365
+ cap = if limit.positive?
366
+ max_articles ? [limit, max_articles].min : limit
367
+ else
368
+ max_articles || resolved.size
369
+ end
370
+ titles = resolved.first(cap)
371
+ # Truncated means "cut by the cap" only; shortfalls from missing
372
+ # titles are explained by not_found, not by this flag
373
+ truncated = resolved.size > titles.size
374
+ else
375
+ total = @metadata.count_articles(**filters)
376
+ cap = if limit.positive?
377
+ max_articles ? [limit, max_articles].min : limit
378
+ else
379
+ max_articles || total
380
+ end
381
+ titles = @metadata.find_articles(**filters, limit: cap)
382
+ truncated = total > titles.size
383
+ end
333
384
 
334
385
  resolved_sections = content == "summary" ? [SectionExtractor::SUMMARY_KEY] : expand_with_alias_set(sections, alias_set)
335
386
  alias_contents = alias_set ? get_alias_set(alias_set)&.dig(:groups) : nil
@@ -372,19 +423,56 @@ module Wp2txt
372
423
  tool: "wp2txt #{Wp2txt::VERSION}",
373
424
  dump: dump_name,
374
425
  generated_at: Time.now.utc.iso8601,
375
- query: filters.compact.merge(content: content, resolved_sections: resolved_sections,
376
- chunk_size: chunk_size, chunk_overlap: chunk_size ? chunk_overlap : nil).compact,
426
+ query: (titles_record || filters.compact).merge(
427
+ content: content, resolved_sections: resolved_sections,
428
+ chunk_size: chunk_size, chunk_overlap: chunk_size ? chunk_overlap : nil
429
+ ).compact,
377
430
  alias_set_contents: alias_contents,
378
431
  total_matching: total,
379
432
  articles_extracted: articles_extracted,
380
433
  records_written: records_written,
381
- truncated: truncated
434
+ truncated: truncated,
435
+ not_found: not_found
382
436
  ))
383
437
 
384
438
  { output_path: output_path, meta_path: meta_path, dump: dump_name,
385
439
  total_matching: total, articles_extracted: articles_extracted,
386
440
  records_written: records_written, truncated: truncated,
387
- bytes: File.size(output_path), sample: sample }
441
+ bytes: File.size(output_path), sample: sample,
442
+ not_found: not_found }.compact
443
+ end
444
+
445
+ # Resolve explicit titles against the pages table: existence plus one
446
+ # redirect hop (same rule as get_article). Input order is preserved.
447
+ # @return [Array(Array<String>, Array<String>)] [found_titles, missing_titles]
448
+ def resolve_explicit_titles(titles)
449
+ map = @metadata.redirect_map(titles)
450
+ targets = titles.filter_map { |t| map[t] }
451
+ .map { |t| MetadataIndex.normalize_title(t) }.uniq
452
+ target_map = targets.empty? ? {} : @metadata.redirect_map(targets)
453
+
454
+ found = []
455
+ missing = []
456
+ titles.each do |t|
457
+ if !map.key?(t)
458
+ missing << t
459
+ elsif (target = map[t])
460
+ # Redirect: extract under the resolved title; a redirect whose
461
+ # target does not exist counts as not found
462
+ target = MetadataIndex.normalize_title(target)
463
+ if target_map.key?(target)
464
+ found << target
465
+ else
466
+ missing << t
467
+ end
468
+ else
469
+ found << t
470
+ end
471
+ end
472
+ # Resolution can collapse distinct inputs onto one article (two aliases
473
+ # redirecting to the same target, or a direct title plus its alias):
474
+ # dedupe so no article is extracted twice, preserving first-seen order
475
+ [found.uniq, missing]
388
476
  end
389
477
 
390
478
  # ------------------------------------------------------------------
@@ -396,6 +484,11 @@ module Wp2txt
396
484
  SQL_TIMEOUT_SECONDS = 30
397
485
  SQL_FORBIDDEN = /\b(ATTACH|DETACH|PRAGMA|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE|VACUUM|REINDEX)\b/i
398
486
 
487
+ # File-output mode (query_sql output_path:): hard row cap and per-cell
488
+ # clip (insurance against runaway blobs, not context economy)
489
+ SQL_FILE_ROW_LIMIT = 5_000_000
490
+ SQL_FILE_CELL_LIMIT = 64 * 1024
491
+
399
492
  # Run a read-only SELECT against the metadata DB (with the FTS DB attached
400
493
  # as `fts` when built). Defense layers: keyword screening (outside string
401
494
  # literals), an SQLITE_OPEN_READONLY connection so writes are impossible at
@@ -404,19 +497,41 @@ module Wp2txt
404
497
  # can only be stopped by killing the process running it. On timeout, the
405
498
  # error message includes an EXPLAIN QUERY PLAN diagnosis when a likely
406
499
  # cause (nested full scans, unbounded recursion) is recognizable.
407
- def query_sql(sql, limit: SQL_ROW_LIMIT, timeout: SQL_TIMEOUT_SECONDS)
500
+ #
501
+ # @param attach [Array<String>] language codes of other locally installed
502
+ # dumps to ATTACH read-only as {lang}_meta / {lang}_fts (e.g. ["en"] →
503
+ # en_meta.pages). Codes are validated and resolved server-side; user SQL
504
+ # itself can never contain ATTACH (SQL_FORBIDDEN).
505
+ # @param output_path [String, nil] when given, write ALL rows (up to
506
+ # SQL_FILE_ROW_LIMIT) to a JSONL file plus a .meta.json sidecar, and
507
+ # return only a summary + 3-row sample; `limit` is ignored in this mode
508
+ # @param overwrite [Boolean] replace an existing output file (default: refuse)
509
+ def query_sql(sql, limit: SQL_ROW_LIMIT, timeout: SQL_TIMEOUT_SECONDS, attach: [],
510
+ output_path: nil, overwrite: false)
408
511
  raise ArgumentError, "only SELECT/WITH queries are allowed" unless sql =~ /\A\s*(SELECT|WITH)\b/i
409
512
  # Screen keywords outside string literals and quoted identifiers only, so
410
513
  # legitimate data values (e.g. title LIKE '%Update%') are not rejected
411
514
  screened = sql.gsub(/'(?:[^']|'')*'/m, "''").gsub(/"(?:[^"]|"")*"/m, '""')
412
515
  raise ArgumentError, "query contains a forbidden keyword" if screened.match?(SQL_FORBIDDEN)
413
516
 
517
+ attachments = resolve_attachments(attach)
518
+ return query_sql_to_file(sql, timeout, attachments, output_path, overwrite) if output_path
519
+
414
520
  limit = [[limit.to_i, 1].max, 1000].min
415
- if Process.respond_to?(:fork)
416
- run_sql_in_subprocess(sql, limit, timeout)
417
- else
418
- run_sql_on(readonly_db, sql, limit)
419
- end
521
+ result = if Process.respond_to?(:fork)
522
+ run_sql_in_subprocess(sql, limit, timeout, attachments)
523
+ elsif attachments.empty?
524
+ run_sql_on(readonly_db, sql, limit)
525
+ else
526
+ db = build_readonly_connection(attach_fts: fts.built?, attachments: attachments)
527
+ begin
528
+ run_sql_on(db, sql, limit)
529
+ ensure
530
+ db.close
531
+ end
532
+ end
533
+ result[:attached] = attachments_summary(attachments) unless attachments.empty?
534
+ result
420
535
  rescue SQLite3::Exception => e
421
536
  raise ArgumentError, "SQL error: #{e.message}"
422
537
  end
@@ -448,14 +563,75 @@ module Wp2txt
448
563
  @readonly_db ||= build_readonly_connection(attach_fts: fts.built?)
449
564
  end
450
565
 
451
- def build_readonly_connection(attach_fts:, fts_path: fts.db_path)
566
+ def build_readonly_connection(attach_fts:, fts_path: fts.db_path, attachments: [])
452
567
  db = SQLite3::Database.new(@metadata.db_path, readonly: true)
453
568
  db.busy_timeout = 5000
454
569
  # Attached databases inherit the main connection's read-only flag
455
570
  db.execute("ATTACH DATABASE ? AS fts", [fts_path]) if attach_fts
571
+ # Cross-dump attachments: aliases and paths come only from validated
572
+ # language codes resolved server-side (resolve_attachments), never from
573
+ # user SQL; opened via mode=ro URIs (belt-and-braces with the
574
+ # inherited read-only flag)
575
+ attachments.each do |a|
576
+ db.execute("ATTACH DATABASE ? AS #{a[:alias]}_meta", [readonly_uri(a[:meta_path])])
577
+ db.execute("ATTACH DATABASE ? AS #{a[:alias]}_fts", [readonly_uri(a[:fts_path])]) if a[:fts_path]
578
+ end
456
579
  db
457
580
  end
458
581
 
582
+ # Language codes accepted by query_sql's attach argument
583
+ ATTACH_LANG_REGEX = /\A[a-z][a-z0-9-]{1,11}\z/
584
+
585
+ # Validate attach language codes and resolve them to local index DBs.
586
+ # The argument carries language codes only — never file paths; path
587
+ # resolution (glob the cache dir, inspect dump_name/built state) is
588
+ # server-side. Selection rule: prefer the dump with the same date as the
589
+ # main DB; otherwise take the most recently built one and flag the entry
590
+ # with dump_mismatch so the response must note it.
591
+ def resolve_attachments(attach)
592
+ Array(attach).compact.map(&:to_s).uniq.map do |lang|
593
+ unless lang.match?(ATTACH_LANG_REGEX)
594
+ raise ArgumentError, "invalid language code for attach: #{lang.inspect}"
595
+ end
596
+ if lang == own_lang
597
+ raise ArgumentError, "cannot attach '#{lang}': it is the language of the main database"
598
+ end
599
+
600
+ candidates = MetadataIndex.cached_candidates(lang, cache_dir: @cache_dir)
601
+ if candidates.empty?
602
+ raise ArgumentError,
603
+ "no installed index found for '#{lang}' (build it with: wp2txt --build-index -L #{lang})"
604
+ end
605
+
606
+ main_date = dump_name[/\d{8}\z/]
607
+ pick = candidates.find { |c| c[:dump_name].to_s.end_with?(main_date.to_s) }
608
+ mismatch = pick.nil?
609
+ pick ||= candidates.first
610
+
611
+ fts_path = pick[:db_path].sub(/#{MetadataIndex::CACHE_SUFFIX}\z/, FtsIndex::CACHE_SUFFIX)
612
+ has_fts = File.exist?(fts_path) && fts_db_built?(fts_path)
613
+
614
+ { lang: lang, alias: lang.tr("-", "_"),
615
+ meta_path: pick[:db_path], fts_path: has_fts ? fts_path : nil,
616
+ dump_name: pick[:dump_name], built_with: pick[:built_with],
617
+ fts: has_fts, dump_mismatch: mismatch }
618
+ end
619
+ end
620
+
621
+ def fts_db_built?(path)
622
+ meta = MetadataIndex.read_metadata_file(path)
623
+ meta && meta[:schema_version].to_i == FtsIndex::SCHEMA_VERSION && !meta[:built_at].nil?
624
+ end
625
+
626
+ # "jawiki-20260701" => "ja"
627
+ def own_lang
628
+ dump_name[/\A([a-z0-9_-]+?)wiki/, 1]
629
+ end
630
+
631
+ def readonly_uri(path)
632
+ "file:#{URI::DEFAULT_PARSER.escape(File.expand_path(path))}?mode=ro"
633
+ end
634
+
459
635
  # Row extraction shared by the inline (Windows fallback) and subprocess paths
460
636
  def run_sql_on(db, sql, limit)
461
637
  columns = nil
@@ -475,14 +651,14 @@ module Wp2txt
475
651
  # its own read-only connection (no inherited handles), runs the query, and
476
652
  # ships the result back over a pipe; a query that outlives the deadline is
477
653
  # SIGKILLed — the only reliable abort while the gem holds the GVL.
478
- def run_sql_in_subprocess(sql, limit, timeout)
654
+ def run_sql_in_subprocess(sql, limit, timeout, attachments = [])
479
655
  attach_fts = fts.built?
480
656
  fts_path = fts.db_path
481
657
  reader_io, writer_io = IO.pipe
482
658
  pid = Process.fork do
483
659
  reader_io.close
484
660
  outcome = begin
485
- db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path)
661
+ db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
486
662
  { ok: run_sql_on(db, sql, limit) }
487
663
  rescue SQLite3::Exception => e
488
664
  { err: "SQL error: #{e.message}" }
@@ -510,6 +686,165 @@ module Wp2txt
510
686
  reader_io&.close
511
687
  end
512
688
 
689
+ # ------------------------------------------------------------------
690
+ # query_sql file-output mode (D4 generalized to SQL: the full result
691
+ # goes to disk, the caller receives a summary plus a small sample)
692
+ # ------------------------------------------------------------------
693
+
694
+ # Provenance summary of resolved attachments, shared by the interactive
695
+ # and file-output responses
696
+ def attachments_summary(attachments)
697
+ attachments.map do |a|
698
+ entry = { lang: a[:lang], dump_name: a[:dump_name], built_with: a[:built_with], fts: a[:fts] }
699
+ entry[:dump_mismatch] = true if a[:dump_mismatch]
700
+ entry
701
+ end
702
+ end
703
+
704
+ # Write the full query result to output_path as JSONL. Atomicity: the
705
+ # child (or inline fallback) writes "#{output_path}.partial"; the parent
706
+ # renames it into place only on success and removes it on every failure
707
+ # path (child crash, timeout kill, error over the pipe) — a partially
708
+ # written file is never presented as a result. The .meta.json sidecar is
709
+ # written by the parent after the rename succeeds.
710
+ def query_sql_to_file(sql, timeout, attachments, output_path, overwrite)
711
+ if File.exist?(output_path) && !overwrite
712
+ raise ArgumentError, "output file already exists: #{output_path} (pass overwrite: true to replace it)"
713
+ end
714
+
715
+ partial = "#{output_path}.partial"
716
+ FileUtils.rm_f(partial)
717
+ outcome = begin
718
+ if Process.respond_to?(:fork)
719
+ run_sql_file_in_subprocess(sql, timeout, attachments, partial)
720
+ else
721
+ db = build_readonly_connection(attach_fts: fts.built?, attachments: attachments)
722
+ begin
723
+ run_sql_file_on(db, sql, partial)
724
+ ensure
725
+ db.close
726
+ end
727
+ end
728
+ rescue StandardError
729
+ FileUtils.rm_f(partial)
730
+ raise
731
+ end
732
+
733
+ File.rename(partial, output_path)
734
+ write_sql_sidecar(output_path, sql, attachments, outcome)
735
+
736
+ result = { output_path: output_path, meta_path: "#{output_path}.meta.json",
737
+ columns: outcome[:columns], row_count: outcome[:row_count],
738
+ truncated: outcome[:truncated], cells_clipped: outcome[:cells_clipped],
739
+ sample: outcome[:sample], bytes: File.size(output_path) }
740
+ result[:attached] = attachments_summary(attachments) unless attachments.empty?
741
+ result
742
+ end
743
+
744
+ # Stream the query result into partial_path as JSONL, one object per row
745
+ # keyed by (deduplicated) column names. Runs in the forked child for the
746
+ # subprocess path: the 30s SIGKILL deadline covers the writing too.
747
+ def run_sql_file_on(db, sql, partial_path)
748
+ columns = nil
749
+ row_count = 0
750
+ cells_clipped = 0
751
+ truncated = false
752
+ sample = []
753
+
754
+ File.open(partial_path, "w") do |f|
755
+ db.query(sql) do |result|
756
+ columns = unique_columns(result.columns)
757
+ result.each do |row|
758
+ if row_count >= SQL_FILE_ROW_LIMIT
759
+ truncated = true
760
+ break
761
+ end
762
+
763
+ record = {}
764
+ row.each_with_index do |value, i|
765
+ if value.is_a?(String) && value.length > SQL_FILE_CELL_LIMIT
766
+ value = "#{value[0, SQL_FILE_CELL_LIMIT]}…"
767
+ cells_clipped += 1
768
+ end
769
+ record[columns[i]] = value
770
+ end
771
+ f.puts(JSON.generate(record))
772
+ sample << record if sample.size < 3
773
+ row_count += 1
774
+ end
775
+ end
776
+ end
777
+
778
+ { columns: columns, row_count: row_count, truncated: truncated,
779
+ cells_clipped: cells_clipped, sample: sample }
780
+ end
781
+
782
+ # Duplicate result column names (SELECT 1 AS x, 2 AS x) are suffixed
783
+ # (_2, _3, ...) so every JSONL record key is unique
784
+ def unique_columns(columns)
785
+ seen = Hash.new(0)
786
+ columns.map do |c|
787
+ seen[c] += 1
788
+ seen[c] == 1 ? c : "#{c}_#{seen[c]}"
789
+ end
790
+ end
791
+
792
+ # Subprocess driver for file-output mode; same fork/pipe/SIGKILL
793
+ # structure as run_sql_in_subprocess, but the child writes the rows to
794
+ # partial_path and ships back only the summary
795
+ def run_sql_file_in_subprocess(sql, timeout, attachments, partial_path)
796
+ attach_fts = fts.built?
797
+ fts_path = fts.db_path
798
+ reader_io, writer_io = IO.pipe
799
+ pid = Process.fork do
800
+ reader_io.close
801
+ outcome = begin
802
+ db = build_readonly_connection(attach_fts: attach_fts, fts_path: fts_path, attachments: attachments)
803
+ { ok: run_sql_file_on(db, sql, partial_path) }
804
+ rescue SQLite3::Exception => e
805
+ { err: "SQL error: #{e.message}" }
806
+ rescue StandardError => e
807
+ { err: "#{e.class}: #{e.message}" }
808
+ end
809
+ Marshal.dump(outcome, writer_io)
810
+ writer_io.close
811
+ exit!(0)
812
+ end
813
+ writer_io.close
814
+
815
+ unless IO.select([reader_io], nil, nil, timeout)
816
+ Process.kill("KILL", pid)
817
+ Process.waitpid(pid)
818
+ raise ArgumentError, "query exceeded the #{timeout}s time limit#{explain_plan_hint(sql)}"
819
+ end
820
+ payload = reader_io.read
821
+ Process.waitpid(pid)
822
+ raise ArgumentError, "query failed: the child process died without a result" if payload.empty?
823
+
824
+ outcome = Marshal.load(payload)
825
+ raise ArgumentError, outcome[:err] if outcome[:err]
826
+
827
+ outcome[:ok]
828
+ ensure
829
+ reader_io&.close
830
+ end
831
+
832
+ # Reproducibility sidecar, written by the parent after the atomic rename
833
+ def write_sql_sidecar(output_path, sql, attachments, outcome)
834
+ File.write("#{output_path}.meta.json", JSON.pretty_generate(
835
+ tool: "query_sql",
836
+ dump: dump_name,
837
+ built_with: @metadata.stats&.dig(:built_with),
838
+ sql: sql,
839
+ attached: attachments.map { |a| { lang: a[:lang], dump_name: a[:dump_name], built_with: a[:built_with] } },
840
+ row_count: outcome[:row_count],
841
+ truncated: outcome[:truncated],
842
+ cells_clipped: outcome[:cells_clipped],
843
+ generated_at: Time.now.utc.iso8601,
844
+ wp2txt_version: Wp2txt::VERSION
845
+ ))
846
+ end
847
+
513
848
  # Best-effort post-mortem for a timed-out query: EXPLAIN QUERY PLAN is
514
849
  # instant and safe (it never executes the query), and the plan tree makes
515
850
  # the two common pathologies recognizable
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require_relative "metadata_index"
5
5
  require_relative "fts_index"
6
+ require_relative "langlinks_importer"
6
7
  require_relative "corpus"
7
8
  require_relative "multistream"
8
9
  require_relative "memory_monitor"
@@ -244,6 +245,88 @@ module Wp2txt
244
245
  CliUI::EXIT_SUCCESS
245
246
  end
246
247
 
248
+ # Import the official langlinks dump (interlanguage links) into the
249
+ # metadata index. Version pinning: the langlinks file must carry the same
250
+ # dump date as the built index (enforced by LanglinksImporter, no override)
251
+ def run_import_langlinks(opts)
252
+ manager = DumpManager.new(
253
+ opts[:lang],
254
+ cache_dir: opts[:cache_dir],
255
+ dump_expiry_days: CLI.config.dump_expiry_days
256
+ )
257
+ multistream = manager.cached_multistream_path
258
+ unless File.exist?(multistream)
259
+ print_error("No cached dump found for '#{opts[:lang]}'.")
260
+ print_info_message("Download and index it with: wp2txt --build-index -L #{opts[:lang]}")
261
+ return CliUI::EXIT_ERROR
262
+ end
263
+
264
+ db_path = MetadataIndex.path_for(multistream, cache_dir: opts[:cache_dir])
265
+ meta = MetadataIndex.new(db_path)
266
+ unless meta.built?
267
+ meta.close
268
+ print_error("Metadata index not found for this dump.")
269
+ print_info_message("Build it first with: wp2txt --build-index -L #{opts[:lang]}")
270
+ return CliUI::EXIT_ERROR
271
+ end
272
+
273
+ dump_name = meta.stats[:dump_name]
274
+ dump_date = dump_name[/\d{8}\z/]
275
+ meta.close
276
+
277
+ source = opts[:langlinks_file] || begin
278
+ print_header("Downloading langlinks for '#{opts[:lang]}' (#{dump_date})")
279
+ manager.download_langlinks(date: dump_date)
280
+ end
281
+
282
+ langs = opts[:langlinks_langs]&.split(",")&.map(&:strip)&.reject(&:empty?)
283
+ langs = nil if langs&.empty?
284
+
285
+ print_mode_banner("Import Langlinks", {
286
+ "Source" => File.basename(source),
287
+ "Metadata DB" => db_path,
288
+ "Languages" => langs ? langs.join(",") : "all"
289
+ })
290
+
291
+ importer = LanglinksImporter.new(db_path, cache_dir: opts[:cache_dir])
292
+ time_start = Time.now
293
+ last_report = Time.now
294
+ result = importer.import!(source, langs: langs, force: opts[:update_cache]) do |rows|
295
+ now = Time.now
296
+ if !quiet? && now - last_report >= DEFAULT_PROGRESS_INTERVAL
297
+ last_report = now
298
+ puts pastel.dim(format(" [%s] %d rows imported", now.strftime("%H:%M:%S"), rows))
299
+ end
300
+ end
301
+
302
+ if result[:status] == :already_imported
303
+ print_success("Langlinks already imported (at #{result[:imported_at]}, #{result[:row_count]} rows).")
304
+ print_info_message("Use -U/--update-cache to re-import.")
305
+ else
306
+ print_success("Langlinks imported: #{result[:row_count]} rows in #{format_duration(Time.now - time_start)}")
307
+ prov = result[:provenance]
308
+ print_info("Source", prov[:source].to_s)
309
+ print_info("Languages", prov[:lang_filter].to_s)
310
+ if result[:skipped_invalid].to_i.positive?
311
+ print_warning("Skipped #{result[:skipped_invalid]} rows containing invalid UTF-8 bytes (recorded as langlinks_skipped_invalid)")
312
+ end
313
+ (result[:sanity] || []).each do |check|
314
+ msg = format("join check ll_lang=%s: %d/%d titles found in %s (%.1f%%)",
315
+ check[:lang], check[:matched], check[:sampled],
316
+ check[:against], check[:match_rate] * 100)
317
+ if check[:warning]
318
+ print_warning("#{msg} — below 90%; title normalization may mismatch")
319
+ else
320
+ print_info_message(msg)
321
+ end
322
+ end
323
+ end
324
+ CliUI::EXIT_SUCCESS
325
+ rescue ArgumentError => e
326
+ print_error(e.message)
327
+ CliUI::EXIT_ERROR
328
+ end
329
+
247
330
  # Query the metadata index and print matching article titles
248
331
  def run_find_articles(opts)
249
332
  multistream_path, = resolve_dump_paths(opts, download: false)