fontist 3.0.25 → 3.0.27
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/lib/fontist/cache/store.rb +66 -52
- data/lib/fontist/indexes/directory_snapshot.rb +6 -2
- data/lib/fontist/system_index.rb +71 -6
- data/lib/fontist/version.rb +1 -1
- data/lib/fontist.rb +1 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4bca0e4898879c116eb90bccf581f82177caafe4b981ac97d2ed974dbb9c19d2
|
|
4
|
+
data.tar.gz: 409088146ecdb388d8efd008f0b7d1d908863fb8d27b6d58aa5371812f5b93bb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 49bbea30d8a19d69ad796f7dd7fe49c7c6bf940d38a9aeef07aae568f7700cb1b4a9d39e1427f1002dca02837ed41768975fdc6e9baa37d8a4921235a6a6071d
|
|
7
|
+
data.tar.gz: adc3b7fe62335de562352d1c516d0c9c71298bf0b5c5764ab2ae8bd64fc921dc55814602e78e40440c6c916e15a3b0ca6649af8ba5f9b88f1ff672ed31f95be3
|
data/lib/fontist/cache/store.rb
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
require "digest"
|
|
3
|
+
require "json"
|
|
3
4
|
|
|
4
5
|
module Fontist
|
|
5
6
|
module Cache
|
|
6
|
-
# Backend store
|
|
7
|
+
# Backend store persisting entries as inert JSON.
|
|
8
|
+
#
|
|
9
|
+
# The previous Marshal backend was removed deliberately: Marshal
|
|
10
|
+
# load of a file from a user-writable path executes arbitrary
|
|
11
|
+
# object instantiation on tampered content, and fontist runs
|
|
12
|
+
# embedded in long-lived host processes where that escalation
|
|
13
|
+
# lands. Values must be
|
|
14
|
+
# JSON-native (hashes with string keys, arrays, strings, numbers,
|
|
15
|
+
# booleans, nil); anything else skips the write and reads back as
|
|
16
|
+
# a miss. Files written by the previous Marshal backend carry a
|
|
17
|
+
# different extension and are simply never read; #clear removes
|
|
18
|
+
# them.
|
|
7
19
|
class Store
|
|
20
|
+
FORMAT_VERSION = 1
|
|
21
|
+
|
|
8
22
|
def initialize(cache_dir)
|
|
9
23
|
@cache_dir = cache_dir.to_s
|
|
10
24
|
ensure_cache_dir
|
|
@@ -26,6 +40,10 @@ module Fontist
|
|
|
26
40
|
ensure_cache_dir
|
|
27
41
|
entry = CacheEntry.new(value, ttl)
|
|
28
42
|
write_entry(key, entry)
|
|
43
|
+
rescue TypeError, JSON::GeneratorError
|
|
44
|
+
# Values outside the JSON type system (Time, Date, arbitrary
|
|
45
|
+
# objects) skip the write and read back as a miss
|
|
46
|
+
false
|
|
29
47
|
end
|
|
30
48
|
|
|
31
49
|
def delete(key)
|
|
@@ -35,20 +53,29 @@ module Fontist
|
|
|
35
53
|
end
|
|
36
54
|
|
|
37
55
|
def clear
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
nil
|
|
42
|
-
end
|
|
56
|
+
glob_delete("*.json")
|
|
57
|
+
# Sweep entries written by the pre-JSON Marshal backend
|
|
58
|
+
glob_delete("*.marshal")
|
|
43
59
|
end
|
|
44
60
|
|
|
45
61
|
def cleanup_temp_files
|
|
46
|
-
# Clean up orphaned .tmp files from interrupted writes
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
# Clean up orphaned .tmp files from interrupted writes: this
|
|
63
|
+
# happens when a process crashes between write and rename
|
|
64
|
+
glob_delete("*.tmp")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Cache entry with TTL support
|
|
68
|
+
class CacheEntry
|
|
69
|
+
attr_reader :value, :expires_at
|
|
70
|
+
|
|
71
|
+
def initialize(value, ttl = nil, expires_at: nil)
|
|
72
|
+
@value = value
|
|
73
|
+
@expires_at = expires_at
|
|
74
|
+
@expires_at ||= (Time.now + ttl).to_i if ttl
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def expired?
|
|
78
|
+
!!(@expires_at && Time.now.to_i > @expires_at)
|
|
52
79
|
end
|
|
53
80
|
end
|
|
54
81
|
|
|
@@ -61,7 +88,7 @@ module Fontist
|
|
|
61
88
|
end
|
|
62
89
|
|
|
63
90
|
def cache_path(key)
|
|
64
|
-
File.join(@cache_dir, "#{sanitize_key(key)}.
|
|
91
|
+
File.join(@cache_dir, "#{sanitize_key(key)}.json")
|
|
65
92
|
end
|
|
66
93
|
|
|
67
94
|
def sanitize_key(key)
|
|
@@ -75,57 +102,44 @@ module Fontist
|
|
|
75
102
|
end
|
|
76
103
|
end
|
|
77
104
|
|
|
105
|
+
def glob_delete(pattern)
|
|
106
|
+
Dir.glob(File.join(@cache_dir, pattern)).each do |f|
|
|
107
|
+
File.delete(f)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
78
113
|
def read_entry(key)
|
|
79
114
|
return nil unless File.exist?(cache_path(key))
|
|
80
115
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
File.delete(cache_path(key))
|
|
90
|
-
rescue StandardError
|
|
91
|
-
nil
|
|
92
|
-
end
|
|
93
|
-
nil
|
|
94
|
-
end
|
|
116
|
+
payload = JSON.parse(File.read(cache_path(key)))
|
|
117
|
+
CacheEntry.new(payload["value"], expires_at: payload["expires_at"])
|
|
118
|
+
rescue JSON::ParserError, TypeError, EOFError
|
|
119
|
+
# Corrupted file - delete it and return nil. This can happen
|
|
120
|
+
# on Windows when a file is read while being written, or when
|
|
121
|
+
# cache files from previous runs are corrupted
|
|
122
|
+
delete(key)
|
|
123
|
+
nil
|
|
95
124
|
end
|
|
96
125
|
|
|
97
126
|
def write_entry(key, entry)
|
|
98
|
-
|
|
99
|
-
# This ensures readers never see partial writes, even on Windows
|
|
100
|
-
temp_path = "#{cache_path(key)}.tmp"
|
|
127
|
+
payload = entry_payload(entry)
|
|
101
128
|
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
# File.rename is atomic on all platforms for same filesystem
|
|
129
|
+
# Temp file + atomic rename: readers never see partial writes
|
|
130
|
+
temp_path = "#{cache_path(key)}.tmp"
|
|
131
|
+
File.write(temp_path, payload)
|
|
106
132
|
File.rename(temp_path, cache_path(key))
|
|
107
133
|
rescue StandardError => e
|
|
108
|
-
# Clean up temp file if rename fails
|
|
109
|
-
|
|
110
|
-
File.delete(temp_path)
|
|
111
|
-
rescue StandardError
|
|
112
|
-
nil
|
|
113
|
-
end
|
|
134
|
+
# Clean up temp file if the write or rename fails
|
|
135
|
+
delete(temp_path)
|
|
114
136
|
raise e
|
|
115
137
|
end
|
|
116
138
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def initialize(value, ttl)
|
|
122
|
-
@value = value
|
|
123
|
-
@expires_at = ttl ? (Time.now + ttl).to_i : nil
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def expired?
|
|
127
|
-
!!(@expires_at && Time.now.to_i > @expires_at)
|
|
128
|
-
end
|
|
139
|
+
def entry_payload(entry)
|
|
140
|
+
JSON.dump("version" => FORMAT_VERSION,
|
|
141
|
+
"expires_at" => entry.expires_at,
|
|
142
|
+
"value" => entry.value)
|
|
129
143
|
end
|
|
130
144
|
end
|
|
131
145
|
end
|
|
@@ -13,11 +13,15 @@ module Fontist
|
|
|
13
13
|
new(directory_path, files, Time.now.to_i)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# Create from existing data (for cache restoration)
|
|
16
|
+
# Create from existing data (for cache restoration). The cache
|
|
17
|
+
# store serializes to JSON, so keys arrive as strings; normalize
|
|
18
|
+
# them back to the symbol-keyed shape used throughout this class
|
|
17
19
|
def self.from_hash(hash)
|
|
20
|
+
hash = (hash || {}).transform_keys(&:to_sym)
|
|
21
|
+
files = Array(hash[:files]).map { |file| file.transform_keys(&:to_sym) }
|
|
18
22
|
new(
|
|
19
23
|
hash[:directory_path].to_s,
|
|
20
|
-
|
|
24
|
+
files,
|
|
21
25
|
hash[:scanned_at],
|
|
22
26
|
)
|
|
23
27
|
end
|
data/lib/fontist/system_index.rb
CHANGED
|
@@ -140,6 +140,33 @@ module Fontist
|
|
|
140
140
|
end
|
|
141
141
|
end
|
|
142
142
|
|
|
143
|
+
# Scan metadata for a system index collection, persisted in a
|
|
144
|
+
# companion file beside the index (an index file is a flat entry
|
|
145
|
+
# list and cannot carry scalars). Restoring it across processes is
|
|
146
|
+
# what makes the 30-minute scan threshold and the directory-mtime
|
|
147
|
+
# change check work at all.
|
|
148
|
+
class SystemIndexMetadata < Lutaml::Model::Serializable
|
|
149
|
+
attribute :last_scan_time, :integer, default: -> { 0 }
|
|
150
|
+
attribute :directory_mtimes, :string, collection: true, default: -> { [] }
|
|
151
|
+
|
|
152
|
+
key_value do
|
|
153
|
+
map "last_scan_time", to: :last_scan_time
|
|
154
|
+
map "directory_mtimes", to: :directory_mtimes
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def self.load(path)
|
|
158
|
+
return new unless File.exist?(path)
|
|
159
|
+
|
|
160
|
+
from_yaml(File.read(path))
|
|
161
|
+
rescue Lutaml::Model::Error, Psych::SyntaxError, TypeError
|
|
162
|
+
new
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def save(path)
|
|
166
|
+
File.write(path, to_yaml)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
143
170
|
class SystemIndexFontCollection < Lutaml::Model::Collection
|
|
144
171
|
include Utils::Locking
|
|
145
172
|
|
|
@@ -151,14 +178,40 @@ module Fontist
|
|
|
151
178
|
attribute :directory_mtimes, :string, collection: true, default: -> { [] }
|
|
152
179
|
|
|
153
180
|
key_value do
|
|
181
|
+
# A bare entry list: the scan metadata lives in a companion
|
|
182
|
+
# metadata file (see SystemIndexMetadata), because a flat list
|
|
183
|
+
# cannot carry scalars and lutaml ignores scalar maps declared
|
|
184
|
+
# alongside map_instances
|
|
154
185
|
map_instances to: :fonts
|
|
155
|
-
map "last_scan_time", to: :last_scan_time
|
|
156
|
-
map "directory_mtimes", to: :directory_mtimes
|
|
157
186
|
end
|
|
158
187
|
|
|
159
188
|
# Don't rebuild index more frequently than this (in seconds)
|
|
160
189
|
INDEX_REBUILD_THRESHOLD = 30 * 60 # 30 minutes
|
|
161
190
|
|
|
191
|
+
def metadata_path
|
|
192
|
+
"#{@path}.metadata.yml"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def persist_metadata
|
|
196
|
+
return unless @path
|
|
197
|
+
|
|
198
|
+
SystemIndexMetadata
|
|
199
|
+
.new(last_scan_time: last_scan_time,
|
|
200
|
+
directory_mtimes: directory_mtimes)
|
|
201
|
+
.save(metadata_path)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def restore_metadata
|
|
205
|
+
return unless @path
|
|
206
|
+
return unless last_scan_time.to_i.zero?
|
|
207
|
+
|
|
208
|
+
meta = SystemIndexMetadata.load(metadata_path)
|
|
209
|
+
return unless meta.last_scan_time.to_i.positive?
|
|
210
|
+
|
|
211
|
+
self.last_scan_time = meta.last_scan_time
|
|
212
|
+
self.directory_mtimes = meta.directory_mtimes
|
|
213
|
+
end
|
|
214
|
+
|
|
162
215
|
def set_path(path)
|
|
163
216
|
@path = path
|
|
164
217
|
end
|
|
@@ -172,12 +225,17 @@ module Fontist
|
|
|
172
225
|
return new.set_content(path, paths_loader) unless File.exist?(path)
|
|
173
226
|
|
|
174
227
|
from_yaml(File.read(path)).set_content(path, paths_loader)
|
|
228
|
+
rescue Lutaml::Model::Error, Psych::SyntaxError, TypeError
|
|
229
|
+
# Corrupt or unreadable index files degrade to an empty
|
|
230
|
+
# collection; the next rebuild rewrites them
|
|
231
|
+
new.set_content(path, paths_loader)
|
|
175
232
|
end
|
|
176
233
|
|
|
177
234
|
def set_content(path, paths_loader)
|
|
178
235
|
tap do |content|
|
|
179
236
|
content.set_path(path)
|
|
180
237
|
content.set_path_loader(paths_loader)
|
|
238
|
+
content.restore_metadata
|
|
181
239
|
end
|
|
182
240
|
end
|
|
183
241
|
|
|
@@ -351,7 +409,8 @@ module Fontist
|
|
|
351
409
|
|
|
352
410
|
def build(forced: false, verbose: false, stats: nil)
|
|
353
411
|
if forced
|
|
354
|
-
return rebuild_with_lock(verbose: verbose, stats: stats
|
|
412
|
+
return rebuild_with_lock(verbose: verbose, stats: stats,
|
|
413
|
+
honor_recent: false)
|
|
355
414
|
end
|
|
356
415
|
|
|
357
416
|
previous_index = load_index
|
|
@@ -372,14 +431,18 @@ module Fontist
|
|
|
372
431
|
|
|
373
432
|
private
|
|
374
433
|
|
|
375
|
-
def rebuild_with_lock(verbose: false, stats: nil)
|
|
434
|
+
def rebuild_with_lock(verbose: false, stats: nil, honor_recent: true)
|
|
376
435
|
# Use file locking to prevent concurrent rebuilds across processes
|
|
377
436
|
lock_path = index_lock_path
|
|
378
437
|
start_time = Time.now
|
|
379
438
|
|
|
380
439
|
lock(lock_path) do
|
|
381
|
-
# Re-check if another process already rebuilt while we waited
|
|
382
|
-
|
|
440
|
+
# Re-check if another process already rebuilt while we waited
|
|
441
|
+
# for lock. Forced rebuilds (add_font, explicit rebuild) carry
|
|
442
|
+
# write intent and must always rescan: honoring the recent
|
|
443
|
+
# rebuild here served the pre-write index and silently dropped
|
|
444
|
+
# the font that triggered the rebuild
|
|
445
|
+
if honor_recent && File.exist?(@path)
|
|
383
446
|
existing = self.class.from_file(path: @path,
|
|
384
447
|
paths_loader: @paths_loader)
|
|
385
448
|
|
|
@@ -407,6 +470,7 @@ module Fontist
|
|
|
407
470
|
self.last_scan_time = Time.now.to_i
|
|
408
471
|
self.directory_mtimes = scan_directory_mtimes.map { |k, v| "#{k}:#{v}" }
|
|
409
472
|
to_file(@path)
|
|
473
|
+
persist_metadata
|
|
410
474
|
|
|
411
475
|
# Show completion message for large collections
|
|
412
476
|
elapsed = Time.now - start_time
|
|
@@ -505,6 +569,7 @@ module Fontist
|
|
|
505
569
|
|
|
506
570
|
# Save to file
|
|
507
571
|
to_file(@path)
|
|
572
|
+
persist_metadata
|
|
508
573
|
end
|
|
509
574
|
|
|
510
575
|
def detect_paths(paths, verbose: false, stats: nil)
|
data/lib/fontist/version.rb
CHANGED
data/lib/fontist.rb
CHANGED
|
@@ -25,6 +25,7 @@ module Fontist
|
|
|
25
25
|
autoload :FontPath, "#{__dir__}/fontist/font_path"
|
|
26
26
|
autoload :FontStyle, "#{__dir__}/fontist/font_style"
|
|
27
27
|
autoload :Fontconfig, "#{__dir__}/fontist/fontconfig"
|
|
28
|
+
autoload :FormatSpec, "#{__dir__}/fontist/format_spec"
|
|
28
29
|
autoload :Formula, "#{__dir__}/fontist/formula"
|
|
29
30
|
autoload :FormulaCollection, "#{__dir__}/fontist/formula"
|
|
30
31
|
autoload :FormulaPicker, "#{__dir__}/fontist/formula_picker"
|