cjk_index 0.2.0 → 1.0.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/exe/cjk_index ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "cjk_index/cli"
5
+
6
+ exit CJKIndex::CLI.run(ARGV)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "bridgetown-core"
5
+ require_relative "../cjk_index"
6
+
7
+ module CJKIndex
8
+ # Bridgetown plugin. Add `gem "cjk_index"` to the Gemfile, then in
9
+ # config/initializers.rb:
10
+ #
11
+ # Bridgetown.configure do |config|
12
+ # init :cjk_index,
13
+ # output: "cjk-index", # folder inside the output (default)
14
+ # variants: ["ja"], # variant tables: ja (default), zh
15
+ # indexes: [
16
+ # { name: "posts", # -> output/cjk-index/posts.json
17
+ # collection: "posts", # a collection (or a list of them), or...
18
+ # fields: ["title", "tags", "content"],
19
+ # boosts: { title: 3 },
20
+ # filters: { category: "value", date: "year" } },
21
+ # { name: "items", data: "metadata", ref: "id", fields: ["title"] } # ...rows of src/_data/metadata.csv
22
+ # ]
23
+ # end
24
+ #
25
+ # After each build the index files and the browser script (cjk-index.js)
26
+ # are written to the output folder. A resource is indexed by its URL; the
27
+ # field "content" is the source text of the resource (Markdown, before
28
+ # rendering), any other field is read from its front matter.
29
+ module Bridgetown
30
+ DEFAULT_OUTPUT = "cjk-index"
31
+ RUNTIME_NAME = "cjk-index.js"
32
+
33
+ module_function
34
+
35
+ # Writes the browser script and every index of the config (a Hash with
36
+ # symbol or string keys) into site's destination folder.
37
+ def write(site, config)
38
+ config = stringify(config)
39
+ output = config.fetch("output", DEFAULT_OUTPUT).to_s.gsub(%r{\A/+|/+\z}, "")
40
+ normalizer = Normalizer.new(tables: Array(config.fetch("variants", Normalizer::DEFAULT_TABLES)))
41
+ put(site, output, RUNTIME_NAME, Runtime.source(normalizer: normalizer))
42
+ Array(config["indexes"]).each do |spec|
43
+ builder = build(site, stringify(spec), normalizer)
44
+ put(site, output, "#{spec['name'] || spec[:name]}.json", builder.to_json)
45
+ ::Bridgetown.logger.info "cjk_index:", "#{output}/#{spec['name'] || spec[:name]}.json (#{builder.size} documents)"
46
+ end
47
+ end
48
+
49
+ def build(site, spec, normalizer)
50
+ builder = Builder.new(fields: Array(spec.fetch("fields")), boosts: spec.fetch("boosts", {}),
51
+ normalizer: normalizer, filters: spec.fetch("filters", {}))
52
+ if spec["collection"]
53
+ Array(spec["collection"]).each do |name|
54
+ collection = site.collections[name] or raise ArgumentError, "cjk_index: no collection #{name}"
55
+ collection.resources.each do |resource|
56
+ next unless resource.write?
57
+
58
+ builder.add(resource.relative_url, resource_fields(resource, builder.fields | builder.filters.keys))
59
+ end
60
+ end
61
+ elsif spec["data"]
62
+ ref = spec.fetch("ref", "id")
63
+ Array(site.data[spec["data"]]).each do |row|
64
+ row = row.to_h.transform_keys(&:to_s)
65
+ builder.add(row[ref], row) unless row[ref].to_s.empty?
66
+ end
67
+ else
68
+ raise ArgumentError, "cjk_index: index #{spec['name']} needs collection or data"
69
+ end
70
+ builder
71
+ end
72
+
73
+ def resource_fields(resource, fields)
74
+ fields.to_h { |f| [f, f == "content" ? resource.untransformed_content : plain(resource.data[f])] }
75
+ end
76
+
77
+ # A Time or Date as "2026-10-01": Builder applies Array() to values, which
78
+ # would turn a Time into its parts [sec, min, hour, ...].
79
+ def plain(value)
80
+ value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d") : value
81
+ end
82
+
83
+ def stringify(hash) = hash.to_h { |k, v| [k.to_s, v] }
84
+
85
+ # Skips the write when the file already has this content.
86
+ def put(site, dir, name, content)
87
+ path = site.in_dest_dir(dir, name)
88
+ return if File.exist?(path) && File.read(path, encoding: "UTF-8") == content
89
+
90
+ FileUtils.mkdir_p(File.dirname(path))
91
+ File.write(path, content)
92
+ end
93
+ end
94
+ end
95
+
96
+ Bridgetown.initializer :cjk_index do |_config, **options|
97
+ Bridgetown::Hooks.register_one :site, :post_write, reloadable: false do |site|
98
+ CJKIndex::Bridgetown.write(site, options)
99
+ end
100
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "digest"
5
+ require "fileutils"
4
6
 
5
7
  module CJKIndex
6
8
  # Builds a prebuilt inverted index that the browser runtime loads as JSON.
@@ -8,37 +10,67 @@ module CJKIndex
8
10
  # builder = CJKIndex::Builder.new(fields: %w[title creator], boosts: { "title" => 3 },
9
11
  # filters: { "type" => "value", "date" => "year" })
10
12
  # builder.add("item1.html", "title" => "東京帝國大學", "creator" => "…", "type" => "地図", "date" => "1900")
11
- # File.write("search-index.json", builder.to_json)
13
+ # builder.write("search-index.json") # one file (the same as File.write(path, builder.to_json))
14
+ # builder.write_split("search-index") # or a manifest and shards, loaded as a query needs them
12
15
  #
13
- # Format (version 3):
16
+ # Format (version 4), one file:
14
17
  # {
15
- # "format": "cjk_index/3",
18
+ # "format": "cjk_index/4",
16
19
  # "variants": ["ja"],
17
20
  # "fields": ["title", "creator"],
18
21
  # "boosts": [3, 1],
19
22
  # "refs": ["item1.html", ...],
20
23
  # "lengths": [len(doc0.title), len(doc0.creator), len(doc1.title), ...],
21
- # "tokens": { "東京": [doc, field, n, pos1, ..., posn, doc, field, n, ...], ... },
24
+ # "tokens": { "東京": "#$#'+…", ... },
22
25
  # "filters": {
23
26
  # "type": { "kind": "value", "values": ["地図", ...], "docs": [[0], [], ...] },
24
27
  # "date": { "kind": "year", "docs": [1900, null, ...] }
25
28
  # }
26
29
  # }
27
- # doc and field are positions in "refs" and "fields"; n is the number of
28
- # occurrences and pos are character offsets in the normalized field text.
30
+ # Each token maps to its posting list: for each document and field where it
31
+ # occurs, the number of occurrences and their character offsets in the
32
+ # normalized field text, written compactly as a string (see Postings).
29
33
  # Offsets let the runtime require the bigrams of a query to be adjacent
30
34
  # (exact substring match); lengths feed BM25 ranking.
31
35
  # "filters" is present only when filters were configured. A value filter
32
36
  # stores each distinct value once (by its folded form, as first written)
33
37
  # and lists the values of each document by number; a year filter stores one
34
- # year or null per document. Version 2 is the same without "filters".
38
+ # year or null per document.
39
+ #
40
+ # Split (write_split): a directory with the manifest index.json, which is
41
+ # the same without "tokens" and adds
42
+ # "latin": "9c1d…07.json", file of the Latin tokens
43
+ # "shards": [["一", "3f2a…e1.json"], ...] first token and file of each shard
44
+ # the shard files, each a JSON object of tokens => posting lists, and the
45
+ # file of Latin tokens: a JSON array of every token without CJK characters,
46
+ # sorted, that lets the runtime expand Latin prefixes and typos. The
47
+ # tokens are sorted by code point and cut into shards of about shard_size
48
+ # bytes, so a token is in the last shard whose first token is not greater.
49
+ # A shard file is named after a digest of its content, so a browser may
50
+ # cache it: a changed shard gets a new name. The runtime loads the manifest
51
+ # (refs, lengths, filters: what a blank query and facets need), and fetches
52
+ # only the shards of the tokens of a query, and the Latin tokens when the
53
+ # query has a Latin word (on a technical blog they are most of the
54
+ # manifest's size otherwise).
55
+ #
56
+ # Older formats, still read by Builder.load, Searcher and the runtime:
57
+ # version 3 writes each posting list as a JSON array
58
+ # [doc, field, n, pos1, ..., posn, doc, field, n, ...] (doc and field are
59
+ # positions in "refs" and "fields"); version 2 is version 3 without "filters".
35
60
  #
36
61
  # Documents can be removed and replaced (remove, update), also in an index
37
- # read back with Builder.load. to_h is then the same as for an index built
38
- # from scratch with the remaining documents in the same order.
62
+ # read back with Builder.load or Builder.read. to_h is then the same as for
63
+ # an index built from scratch with the remaining documents in the same order.
39
64
  class Builder
40
- FORMAT = "cjk_index/3"
41
- FORMATS = ["cjk_index/2", FORMAT].freeze
65
+ FORMAT = "cjk_index/4"
66
+ FORMATS = ["cjk_index/2", "cjk_index/3", FORMAT].freeze
67
+
68
+ # Name of the manifest in a split index.
69
+ MANIFEST = "index.json"
70
+ # Default size of a shard in bytes (of JSON, before compression).
71
+ SHARD_SIZE = 32_000
72
+ # Names of shard files: a digest of the content.
73
+ SHARD_NAME = /\A\h{16}\.json\z/
42
74
 
43
75
  # Gap between the values of a multi-valued field, so that a phrase cannot
44
76
  # match across two values.
@@ -63,13 +95,19 @@ module CJKIndex
63
95
  @fields = fields.map(&:to_s)
64
96
  @boosts = @fields.map { |f| (boosts[f] || boosts[f.to_sym] || 1).to_f }
65
97
  @filters = Builder.filter_specs(filters)
66
- # Indexed by slot; a removed document leaves nil until to_h renumbers.
98
+ # Indexed by slot; a removed document leaves nil until the slots are
99
+ # renumbered (compact).
67
100
  @refs = []
68
101
  @lengths = []
69
102
  @filter_values = []
70
- @doc_tokens = []
71
- @doc_tokens_complete = true
72
- @postings = Hash.new { |h, k| h[k] = [] }
103
+ # token => posting list in the encoding of Postings, with slots for
104
+ # documents (the same as the format while there are no removed slots).
105
+ @postings = {}
106
+ # token => key of its last entry, filled as entries are appended.
107
+ @last_keys = {}
108
+ # Tokens of each slot, kept only once a document has been removed or
109
+ # replaced (nil until then): a build that only adds does not need them.
110
+ @doc_tokens = nil
73
111
  @slots = Hash.new { |h, k| h[k] = [] }
74
112
  @count = 0
75
113
  @version = 0
@@ -89,20 +127,43 @@ module CJKIndex
89
127
  end
90
128
 
91
129
  # Reads an index written by to_json (String) or to_h back, to update it.
130
+ # Reads formats 2, 3 and 4; a split index is read with read.
92
131
  # A value filter keeps the form of each value that the index stored.
93
132
  def self.load(index)
94
133
  data = index.is_a?(String) ? JSON.parse(index) : index
95
134
  unless data.is_a?(Hash) && FORMATS.include?(data["format"])
96
135
  raise ArgumentError, "cjk_index: unsupported index format #{data.is_a?(Hash) ? data['format'].inspect : data.class}"
97
136
  end
137
+ raise ArgumentError, "cjk_index: a split index is read with Builder.read(directory)" unless data["tokens"]
98
138
 
99
139
  filters = (data["filters"] || {}).transform_values { |f| f.slice("kind", "separator") }
140
+ normalizer = Normalizer.new(tables: data["variants"])
141
+ normalizer.check!(data)
100
142
  builder = new(fields: data["fields"], boosts: data["fields"].zip(data["boosts"]).to_h,
101
- normalizer: Normalizer.new(tables: data["variants"]), filters: filters)
143
+ normalizer: normalizer, filters: filters)
102
144
  builder.send(:restore, data)
103
145
  builder
104
146
  end
105
147
 
148
+ # Reads an index from a file (written by write or to_json) or from the
149
+ # directory of a split index (written by write_split), to update it.
150
+ def self.read(path)
151
+ load(Builder.read_data(path))
152
+ end
153
+
154
+ # The index at path as one Hash with "tokens", for a file or a split
155
+ # index (its directory or its manifest).
156
+ def self.read_data(path) # :nodoc:
157
+ path = File.join(path, MANIFEST) if File.directory?(path)
158
+ data = JSON.parse(File.read(path, encoding: "UTF-8"))
159
+ return data unless data.is_a?(Hash) && data["shards"]
160
+
161
+ dir = File.dirname(path)
162
+ tokens = {}
163
+ data["shards"].each { |_, file| tokens.merge!(JSON.parse(File.read(File.join(dir, file), encoding: "UTF-8"))) }
164
+ data.reject { |k, _| %w[latin shards].include?(k) }.merge("tokens" => tokens)
165
+ end
166
+
106
167
  # Folded form of a value filter's value, used to compare values.
107
168
  def self.filter_key(value, normalizer)
108
169
  normalizer.normalize(value.to_s).gsub(WHITESPACE, " ").delete_prefix(" ").delete_suffix(" ")
@@ -111,6 +172,7 @@ module CJKIndex
111
172
  # The first four-digit number in a value (after folding full-width digits).
112
173
  def self.year(value, normalizer)
113
174
  return value.to_i if value.is_a?(Numeric)
175
+ return value.year if value.respond_to?(:year) # Time, Date (YAML front matter)
114
176
 
115
177
  normalizer.normalize(value.to_s)[/(?<!\d)\d{4}(?!\d)/]&.to_i
116
178
  end
@@ -155,17 +217,10 @@ module CJKIndex
155
217
  end
156
218
 
157
219
  def to_h
158
- live = holes? ? @refs.each_index.reject { |slot| @refs[slot].nil? } : @refs.each_index.to_a
159
- h = {
160
- "format" => FORMAT,
161
- "variants" => @normalizer.tables,
162
- "fields" => @fields,
163
- "boosts" => @boosts.map { |b| b == b.to_i ? b.to_i : b },
164
- "refs" => live.map { |slot| @refs[slot] },
165
- "lengths" => live.flat_map { |slot| @lengths[slot * @fields.length, @fields.length] },
166
- "tokens" => holes? ? renumbered_postings(live) : @postings.sort.to_h
167
- }
168
- h["filters"] = @filters.to_h { |name, spec| [name, filter_to_h(name, spec, live)] } unless @filters.empty?
220
+ compact
221
+ h = header
222
+ h["tokens"] = @postings.keys.sort!.to_h { |token| [token, @postings[token].dup] }
223
+ h["filters"] = filters_h unless @filters.empty?
169
224
  h
170
225
  end
171
226
 
@@ -173,10 +228,91 @@ module CJKIndex
173
228
  JSON.generate(to_h)
174
229
  end
175
230
 
231
+ # Writes the index to one file, the same as File.write(path, to_json),
232
+ # without first making the whole JSON in memory. The file is written to a
233
+ # temporary file and renamed, so a reader never sees it partly written.
234
+ def write(path)
235
+ compact
236
+ atomic_write(path) do |io|
237
+ io << JSON.generate(header).delete_suffix("}") << ',"tokens":{'
238
+ @postings.keys.sort!.each_with_index do |token, i|
239
+ io << "," if i.positive?
240
+ io << JSON.generate(token) << ':"' << @postings[token] << '"'
241
+ end
242
+ io << "}"
243
+ io << ',"filters":' << JSON.generate(filters_h) unless @filters.empty?
244
+ io << "}"
245
+ end
246
+ path
247
+ end
248
+
249
+ # Writes a split index to the directory dir: the manifest index.json and
250
+ # shard files of about shard_size bytes (see the format above). Shard
251
+ # files are written first and the manifest last (renamed into place), and
252
+ # shard files that the new manifest does not list are then removed.
253
+ # Returns the path of the manifest.
254
+ def write_split(dir, shard_size: SHARD_SIZE)
255
+ FileUtils.mkdir_p(dir)
256
+ names = []
257
+ manifest = nil
258
+ each_split_file(shard_size: shard_size) do |name, content|
259
+ if name == MANIFEST
260
+ manifest = content
261
+ else
262
+ path = File.join(dir, name)
263
+ atomic_write(path) { |io| io << content } unless File.exist?(path) && File.size(path) == content.bytesize
264
+ names << name
265
+ end
266
+ end
267
+ atomic_write(File.join(dir, MANIFEST)) { |io| io << manifest }
268
+ Dir.children(dir).each do |name|
269
+ File.delete(File.join(dir, name)) if name.match?(SHARD_NAME) && !names.include?(name)
270
+ end
271
+ File.join(dir, MANIFEST)
272
+ end
273
+
274
+ # Yields [name, content] for each file of a split index: the shards, the
275
+ # Latin tokens, then the manifest (MANIFEST). For writing them somewhere other than a
276
+ # directory on disk (the Jekyll plugin uses it).
277
+ def each_split_file(shard_size: SHARD_SIZE)
278
+ return enum_for(:each_split_file, shard_size: shard_size) unless block_given?
279
+
280
+ compact
281
+ shards = []
282
+ latin = []
283
+ part = []
284
+ bytes = 0
285
+ first = nil
286
+ flush = lambda do
287
+ content = "{#{part.join(',')}}"
288
+ name = "#{Digest::SHA256.hexdigest(content)[0, 16]}.json"
289
+ yield name, content
290
+ shards << [first, name]
291
+ part = []
292
+ bytes = 0
293
+ end
294
+ @postings.keys.sort!.each do |token|
295
+ latin << token unless token.match?(Tokenizer::CJK)
296
+ pair = "#{JSON.generate(token)}:\"#{@postings[token]}\""
297
+ flush.call if bytes.positive? && bytes + pair.bytesize > shard_size
298
+ first = token if part.empty?
299
+ part << pair
300
+ bytes += pair.bytesize + 1
301
+ end
302
+ flush.call unless part.empty?
303
+ content = JSON.generate(latin)
304
+ latin_name = "#{Digest::SHA256.hexdigest(content)[0, 16]}.json"
305
+ yield latin_name, content
306
+ manifest = header.merge("latin" => latin_name, "shards" => shards)
307
+ manifest["filters"] = filters_h unless @filters.empty?
308
+ yield MANIFEST, JSON.generate(manifest)
309
+ end
310
+
176
311
  # For Searcher: the current documents by slot, with nil for removed ones.
177
312
  def live_data # :nodoc:
178
313
  {
179
- "variants" => @normalizer.tables, "fields" => @fields, "boosts" => @boosts,
314
+ "format" => FORMAT, "variants" => @normalizer.tables, "variant_digest" => @normalizer.digest,
315
+ "fields" => @fields, "boosts" => @boosts,
180
316
  "refs" => @refs, "lengths" => @lengths, "tokens" => @postings, "count" => @count,
181
317
  "filters" => @filters.to_h do |name, spec|
182
318
  [name, { "kind" => spec["kind"], "docs" => @filter_values.map { |v| v && v[name] } }]
@@ -186,46 +322,71 @@ module CJKIndex
186
322
 
187
323
  private
188
324
 
325
+ def header
326
+ {
327
+ "format" => FORMAT,
328
+ "variants" => @normalizer.tables,
329
+ "variant_digest" => @normalizer.digest,
330
+ "fields" => @fields,
331
+ "boosts" => @boosts.map { |b| b == b.to_i ? b.to_i : b },
332
+ "refs" => @refs.dup,
333
+ "lengths" => @lengths.dup
334
+ }
335
+ end
336
+
337
+ def filters_h = @filters.to_h { |name, spec| [name, filter_to_h(name, spec)] }
338
+
339
+ def atomic_write(path)
340
+ tmp = "#{path}.#{Process.pid}.tmp"
341
+ File.open(tmp, "wb") { |io| yield io }
342
+ File.rename(tmp, path)
343
+ ensure
344
+ File.delete(tmp) if tmp && File.exist?(tmp)
345
+ end
346
+
189
347
  def index(slot, doc)
348
+ nf = @fields.length
349
+ # A document replaced in place goes before the later slots, so that
350
+ # every list stays in the order a fresh build writes.
190
351
  in_place = slot < @refs.length - 1
191
- tokens = []
352
+ tokens = @doc_tokens ? [] : nil
192
353
  @fields.each_with_index do |field, field_index|
193
354
  positions = Hash.new { |h, k| h[k] = [] }
194
355
  base = 0
195
356
  Array(value_of(doc, field)).each do |v|
196
- text = v.to_s
197
- Tokenizer.tokens_with_offsets(text, unigrams: true, normalizer: @normalizer)
198
- .each { |t, offset| positions[t] << (base + offset) }
199
- base += @normalizer.normalize(text).length + VALUE_GAP
357
+ text = @normalizer.normalize(v.to_s)
358
+ Tokenizer.each_token(text, unigrams: true) { |t, offset| positions[t] << (base + offset) }
359
+ base += text.length + VALUE_GAP
200
360
  end
201
- @lengths[(slot * @fields.length) + field_index] = [base - VALUE_GAP, 0].max
361
+ @lengths[(slot * nf) + field_index] = [base - VALUE_GAP, 0].max
362
+ key = (slot * nf) + field_index
202
363
  positions.each do |token, offsets|
203
- postings = @postings[token]
204
- # A document replaced in place goes before the later slots, so that
205
- # every list stays in the order a fresh build writes.
206
- at = in_place ? insertion_point(postings, slot, field_index) : postings.length
207
- postings.insert(at, slot, field_index, offsets.length, *offsets)
364
+ if in_place
365
+ @postings[token] = Postings.insert(@postings[token] || +"", key, offsets)
366
+ @last_keys.delete(token)
367
+ else
368
+ list = (@postings[token] ||= +"")
369
+ last = @last_keys.fetch(token) { Postings.last_key(list) }
370
+ Postings.append(list, key - last - 1, offsets)
371
+ @last_keys[token] = key
372
+ end
208
373
  end
209
- tokens.concat(positions.keys)
374
+ tokens&.concat(positions.keys)
210
375
  end
211
- @doc_tokens[slot] = tokens.uniq
376
+ @doc_tokens[slot] = tokens.uniq if tokens
212
377
  @filter_values[slot] = @filters.to_h { |name, spec| [name, filter_value(spec, value_of(doc, name))] }
213
378
  @version += 1
214
379
  end
215
380
 
216
381
  def unindex(slot)
382
+ nf = @fields.length
383
+ first = slot * nf
217
384
  doc_tokens(slot).each do |token|
218
- postings = @postings[token]
219
- kept = []
220
- i = 0
221
- while i < postings.length
222
- n = 3 + postings[i + 2]
223
- kept.concat(postings[i, n]) unless postings[i] == slot
224
- i += n
225
- end
385
+ kept = Postings.delete(@postings[token], first..(first + nf - 1))
226
386
  if kept.empty? then @postings.delete(token)
227
387
  else @postings[token] = kept
228
388
  end
389
+ @last_keys.delete(token)
229
390
  end
230
391
  @doc_tokens[slot] = nil
231
392
  @version += 1
@@ -235,26 +396,28 @@ module CJKIndex
235
396
 
236
397
  # Value filter: [[key, value as written], ...]; year filter: Integer or nil.
237
398
  def filter_value(spec, raw)
399
+ # Array() would split a Time into its parts
400
+ raw = raw.is_a?(Array) ? raw : [raw].compact
238
401
  if spec["kind"] == "year"
239
- Array(raw).each do |v|
402
+ raw.each do |v|
240
403
  year = Builder.year(v, @normalizer)
241
404
  return year if year
242
405
  end
243
406
  return nil
244
407
  end
245
408
 
246
- Array(raw).flat_map { |v| spec["separator"] ? v.to_s.split(spec["separator"]) : [v.to_s] }
409
+ raw.flat_map { |v| spec["separator"] ? v.to_s.split(spec["separator"]) : [v.to_s] }
247
410
  .map { |v| v.gsub(WHITESPACE, " ").strip }.reject(&:empty?)
248
411
  .map { |v| [Builder.filter_key(v, @normalizer), v] }.uniq(&:first)
249
412
  end
250
413
 
251
- def filter_to_h(name, spec, live)
252
- return { "kind" => "year", "docs" => live.map { |slot| @filter_values[slot][name] } } if spec["kind"] == "year"
414
+ def filter_to_h(name, spec)
415
+ return { "kind" => "year", "docs" => @filter_values.map { |v| v[name] } } if spec["kind"] == "year"
253
416
 
254
417
  ids = {}
255
418
  values = []
256
- docs = live.map do |slot|
257
- @filter_values[slot][name].map do |key, value|
419
+ docs = @filter_values.map do |v|
420
+ v[name].map do |key, value|
258
421
  ids.fetch(key) do
259
422
  values << value
260
423
  ids[key] = values.length - 1
@@ -266,45 +429,37 @@ module CJKIndex
266
429
 
267
430
  def holes? = @count < @refs.length
268
431
 
269
- # Where an entry for slot and field goes in a list ordered by slot, then field.
270
- def insertion_point(postings, slot, field_index)
271
- i = 0
272
- while i < postings.length && (postings[i] < slot || (postings[i] == slot && postings[i + 1] < field_index))
273
- i += 3 + postings[i + 2]
274
- end
275
- i
276
- end
432
+ # Renumbers the slots to positions among the live documents, dropping the
433
+ # removed ones. Renumbering keeps the order, so sorted lists stay sorted.
434
+ def compact
435
+ return unless holes?
277
436
 
278
- # Postings with slots renumbered to positions among the live documents.
279
- # Removed slots appear in no list any more, and renumbering keeps the
280
- # order, so sorted lists stay sorted.
281
- def renumbered_postings(live)
437
+ nf = @fields.length
438
+ live = @refs.each_index.reject { |slot| @refs[slot].nil? }
282
439
  number = []
283
440
  live.each_with_index { |slot, doc| number[slot] = doc }
284
- @postings.sort.to_h do |token, postings|
285
- postings = postings.dup
286
- i = 0
287
- while i < postings.length
288
- postings[i] = number[postings[i]]
289
- i += 3 + postings[i + 2]
290
- end
291
- [token, postings]
292
- end
441
+ @postings.each_key { |token| @postings[token] = Postings.renumber(@postings[token], nf, number) }
442
+ @last_keys.clear
443
+ @refs = live.map { |slot| @refs[slot] }
444
+ @lengths = live.flat_map { |slot| @lengths[slot * nf, nf] }
445
+ @filter_values = live.map { |slot| @filter_values[slot] }
446
+ @doc_tokens = live.map { |slot| @doc_tokens[slot] } if @doc_tokens
447
+ @slots.each_value { |slots| slots.map! { |slot| number[slot] } }
448
+ @version += 1
293
449
  end
294
450
 
295
- # Tokens of a slot. An index read with load does not record them, so the
296
- # first change scans the postings once.
451
+ # Tokens of a slot. They are not recorded until a document is first
452
+ # removed or replaced; that first change scans the postings once.
297
453
  def doc_tokens(slot)
298
- unless @doc_tokens_complete
299
- @postings.each do |token, postings|
300
- i = 0
301
- while i < postings.length
302
- (@doc_tokens[postings[i]] ||= []) << token
303
- i += 3 + postings[i + 2]
454
+ unless @doc_tokens
455
+ nf = @fields.length
456
+ @doc_tokens = []
457
+ @postings.each do |token, list|
458
+ Postings.each_key(list) do |key|
459
+ tokens = (@doc_tokens[key / nf] ||= [])
460
+ tokens << token unless tokens.last.equal?(token)
304
461
  end
305
462
  end
306
- @doc_tokens.each { |tokens| tokens&.uniq! }
307
- @doc_tokens_complete = true
308
463
  end
309
464
  @doc_tokens[slot] || []
310
465
  end
@@ -318,7 +473,12 @@ module CJKIndex
318
473
  raise ArgumentError, "cjk_index: index has #{@lengths.length} lengths for #{@count} documents"
319
474
  end
320
475
 
321
- data["tokens"].each { |token, postings| @postings[token] = postings.dup }
476
+ nf = @fields.length
477
+ if data["format"] == FORMAT
478
+ data["tokens"].each { |token, list| @postings[token] = +list }
479
+ else
480
+ data["tokens"].each { |token, flat| @postings[token] = Postings.encode(flat, nf) }
481
+ end
322
482
  filters = data["filters"] || {}
323
483
  @filter_values = Array.new(@count) do |slot|
324
484
  filters.to_h do |name, f|
@@ -328,8 +488,6 @@ module CJKIndex
328
488
  [name, value.map { |id| [Builder.filter_key(f["values"][id], @normalizer), f["values"][id]] }]
329
489
  end
330
490
  end
331
- @doc_tokens = []
332
- @doc_tokens_complete = false
333
491
  end
334
492
  end
335
493
  end