uniword 1.7.0 → 1.7.2
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/CHANGELOG.md +6 -0
- data/lib/uniword/accessibility/rules/image_alt_text_rule.rb +36 -13
- data/lib/uniword/builder/image_builder.rb +2 -0
- data/lib/uniword/builder/paragraph_builder.rb +5 -6
- data/lib/uniword/builder/sdt_builder.rb +12 -4
- data/lib/uniword/builder/style_builder.rb +5 -5
- data/lib/uniword/builder/table_builder.rb +2 -2
- data/lib/uniword/builder/table_cell_builder.rb +3 -3
- data/lib/uniword/builder/table_row_builder.rb +2 -1
- data/lib/uniword/cli/comments_cli.rb +173 -0
- data/lib/uniword/cli/diff_cli.rb +60 -0
- data/lib/uniword/cli/main.rb +104 -0
- data/lib/uniword/cli/plain_text_command.rb +101 -0
- data/lib/uniword/cli/review_cli.rb +173 -34
- data/lib/uniword/cli/revisions_cli.rb +88 -0
- data/lib/uniword/cli/template_cli.rb +81 -0
- data/lib/uniword/converter/plain_text.rb +426 -0
- data/lib/uniword/converter/plain_text_result.rb +112 -0
- data/lib/uniword/converter.rb +11 -0
- data/lib/uniword/diff/document_differ.rb +2 -88
- data/lib/uniword/diff/lcs.rb +101 -0
- data/lib/uniword/diff/redline_result.rb +94 -0
- data/lib/uniword/diff/redliner.rb +319 -0
- data/lib/uniword/diff.rb +3 -0
- data/lib/uniword/docx/package_integrity_checker.rb +1 -1
- data/lib/uniword/errors.rb +33 -0
- data/lib/uniword/mhtml/word_css.rb +16 -8
- data/lib/uniword/ooxml/types/ooxml_boolean.rb +19 -7
- data/lib/uniword/ooxml/types/ooxml_boolean_optional.rb +4 -8
- data/lib/uniword/properties/boolean_element_factory.rb +48 -3
- data/lib/uniword/properties/boolean_formatting.rb +4 -0
- data/lib/uniword/properties/outline.rb +4 -13
- data/lib/uniword/quality/rules/image_alt_text_rule.rb +22 -43
- data/lib/uniword/review/review_manager.rb +128 -1
- data/lib/uniword/template/fill_result.rb +121 -0
- data/lib/uniword/template/filler.rb +292 -0
- data/lib/uniword/template.rb +2 -0
- data/lib/uniword/transformation/mhtml_element_renderer.rb +9 -3
- data/lib/uniword/transformation/ooxml_to_html_converter.rb +8 -3
- data/lib/uniword/validation/opc_validator.rb +3 -3
- data/lib/uniword/validation/validators/xml_schema_validator.rb +1 -1
- data/lib/uniword/version.rb +1 -1
- data/lib/uniword/wordprocessingml/deleted_paragraph_mark.rb +32 -0
- data/lib/uniword/wordprocessingml/document_root.rb +40 -6
- data/lib/uniword/wordprocessingml/drawing.rb +51 -0
- data/lib/uniword/wordprocessingml/inserted_paragraph_mark.rb +32 -0
- data/lib/uniword/wordprocessingml/paragraph_properties.rb +128 -34
- data/lib/uniword/wordprocessingml/run_properties/conversion.rb +30 -9
- data/lib/uniword/wordprocessingml/run_properties/predicates.rb +25 -50
- data/lib/uniword/wordprocessingml/run_properties/yaml_transforms.rb +61 -44
- data/lib/uniword/wordprocessingml/run_properties.rb +10 -0
- data/lib/uniword/wordprocessingml/structured_document_tag/showing_placeholder_header.rb +13 -2
- data/lib/uniword/wordprocessingml/structured_document_tag/temporary.rb +13 -2
- data/lib/uniword/wordprocessingml/style.rb +43 -40
- data/lib/uniword/wordprocessingml/table_row_properties.rb +16 -12
- data/lib/uniword/wordprocessingml/yaml_writer.rb +24 -0
- data/lib/uniword/wordprocessingml.rb +17 -4
- data/lib/uniword.rb +9 -0
- metadata +20 -6
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Uniword
|
|
4
|
+
module Cli
|
|
5
|
+
# Mechanics for the top-level `to` / `from` CLI verbs (issue #80):
|
|
6
|
+
# format validation, document loading, conversion through
|
|
7
|
+
# Converter::PlainText, default output path derivation, and output
|
|
8
|
+
# writing. Presentation stays in CLI so in-process specs see the
|
|
9
|
+
# same output as the executable.
|
|
10
|
+
module PlainTextCommand
|
|
11
|
+
FORMATS = { "md" => :markdown, "adoc" => :asciidoc }.freeze
|
|
12
|
+
|
|
13
|
+
EXTENSIONS = { markdown: ".md", asciidoc: ".adoc" }.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Convert a DOCX file to a plain-text format.
|
|
18
|
+
#
|
|
19
|
+
# @param format_key [String] "md" or "adoc"
|
|
20
|
+
# @param input_path [String] path to the .docx input
|
|
21
|
+
# @param strict [Boolean] fail on fidelity loss
|
|
22
|
+
# @return [Converter::PlainTextResult]
|
|
23
|
+
def convert_to(format_key, input_path, strict: false)
|
|
24
|
+
format = normalize_format!(format_key)
|
|
25
|
+
document = load_document!(input_path)
|
|
26
|
+
converter = Converter::PlainText.new
|
|
27
|
+
if format == :markdown
|
|
28
|
+
converter.to_markdown_result(document, strict: strict)
|
|
29
|
+
else
|
|
30
|
+
converter.to_asciidoc_result(document, strict: strict)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Convert a plain-text file to a DocumentRoot.
|
|
35
|
+
#
|
|
36
|
+
# @param format_key [String] "md" or "adoc"
|
|
37
|
+
# @param input_path [String] path to the .md/.adoc input
|
|
38
|
+
# @param strict [Boolean] fail on fidelity loss
|
|
39
|
+
# @return [Converter::PlainTextResult]
|
|
40
|
+
def convert_from(format_key, input_path, strict: false)
|
|
41
|
+
format = normalize_format!(format_key)
|
|
42
|
+
raise FileNotFoundError.new(input_path) unless File.exist?(input_path)
|
|
43
|
+
|
|
44
|
+
text = File.read(input_path)
|
|
45
|
+
converter = Converter::PlainText.new
|
|
46
|
+
if format == :markdown
|
|
47
|
+
converter.from_markdown_result(text, strict: strict)
|
|
48
|
+
else
|
|
49
|
+
converter.from_asciidoc_result(text, strict: strict)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param format_key [String] "md" or "adoc"
|
|
54
|
+
# @return [String] the canonical file extension (".md"/".adoc")
|
|
55
|
+
# @raise [UnsupportedOperationError] for unknown formats
|
|
56
|
+
def extension_for(format_key)
|
|
57
|
+
format = normalize_format!(format_key)
|
|
58
|
+
EXTENSIONS.fetch(format)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Default output path: the input's basename with a new
|
|
62
|
+
# extension, written next to the input.
|
|
63
|
+
#
|
|
64
|
+
# @param input_path [String]
|
|
65
|
+
# @param extension [String] e.g. ".md" or ".docx"
|
|
66
|
+
# @return [String]
|
|
67
|
+
def default_output(input_path, extension)
|
|
68
|
+
directory = File.dirname(input_path)
|
|
69
|
+
basename = File.basename(input_path, ".*")
|
|
70
|
+
File.join(directory, "#{basename}#{extension}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Write converted text to a file, or to stdout for "-".
|
|
74
|
+
#
|
|
75
|
+
# @param result [Converter::PlainTextResult]
|
|
76
|
+
# @param output_path [String] path or "-"
|
|
77
|
+
def write_text(result, output_path)
|
|
78
|
+
if output_path == "-"
|
|
79
|
+
$stdout.puts result.text
|
|
80
|
+
else
|
|
81
|
+
File.write(output_path, result.text)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def normalize_format!(format_key)
|
|
86
|
+
FORMATS.fetch(format_key.to_s.downcase) do
|
|
87
|
+
raise UnsupportedOperationError.new(
|
|
88
|
+
"format #{format_key.inspect}",
|
|
89
|
+
"supported: #{FORMATS.keys.join(', ')}",
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def load_document!(path)
|
|
95
|
+
raise FileNotFoundError.new(path) unless File.exist?(path)
|
|
96
|
+
|
|
97
|
+
DocumentFactory.from_file(path)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "thor"
|
|
4
|
+
require "time"
|
|
5
|
+
require "date"
|
|
4
6
|
|
|
5
7
|
module Uniword
|
|
6
8
|
# Review subcommands for Uniword CLI.
|
|
@@ -23,9 +25,12 @@ module Uniword
|
|
|
23
25
|
Examples:
|
|
24
26
|
$ uniword review comments document.docx
|
|
25
27
|
$ uniword review comments document.docx --verbose
|
|
28
|
+
$ uniword review comments document.docx --json
|
|
26
29
|
DESC
|
|
27
30
|
option :verbose, aliases: "-v", desc: "Show detailed information",
|
|
28
31
|
type: :boolean, default: false
|
|
32
|
+
option :json, desc: "Output as JSON (machine-readable)",
|
|
33
|
+
type: :boolean, default: false
|
|
29
34
|
def comments(path)
|
|
30
35
|
doc = load_document(path)
|
|
31
36
|
manager = Review::ReviewManager.new(doc)
|
|
@@ -36,6 +41,21 @@ module Uniword
|
|
|
36
41
|
return
|
|
37
42
|
end
|
|
38
43
|
|
|
44
|
+
if options[:json]
|
|
45
|
+
require "json"
|
|
46
|
+
anchors = manager.comment_anchor_texts
|
|
47
|
+
puts JSON.pretty_generate(comment_list.map do |comment|
|
|
48
|
+
{
|
|
49
|
+
id: comment.comment_id,
|
|
50
|
+
author: comment.author,
|
|
51
|
+
date: comment.date,
|
|
52
|
+
text: comment.text,
|
|
53
|
+
anchored_text: anchors[comment.comment_id.to_s],
|
|
54
|
+
}
|
|
55
|
+
end)
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
39
59
|
say "Comments (#{comment_list.count}):", :green
|
|
40
60
|
comment_list.each_with_index do |comment, idx|
|
|
41
61
|
say " #{idx + 1}. ID: #{comment.comment_id}"
|
|
@@ -64,12 +84,19 @@ module Uniword
|
|
|
64
84
|
$ uniword review changes document.docx
|
|
65
85
|
$ uniword review changes document.docx --author "John Doe"
|
|
66
86
|
$ uniword review changes document.docx --type insert
|
|
87
|
+
$ uniword review changes document.docx --since 2026-01-01 --json
|
|
67
88
|
DESC
|
|
68
89
|
option :author, desc: "Filter revisions by author", type: :string
|
|
69
90
|
option :type, desc: "Filter by type (insert/delete/format_change)",
|
|
70
91
|
type: :string
|
|
92
|
+
option :since, desc: "Only revisions dated on/after (ISO 8601)",
|
|
93
|
+
type: :string
|
|
94
|
+
option :until, desc: "Only revisions dated on/before (ISO 8601)",
|
|
95
|
+
type: :string
|
|
71
96
|
option :verbose, aliases: "-v", desc: "Show detailed information",
|
|
72
97
|
type: :boolean, default: false
|
|
98
|
+
option :json, desc: "Output as JSON (machine-readable)",
|
|
99
|
+
type: :boolean, default: false
|
|
73
100
|
def changes(path)
|
|
74
101
|
doc = load_document(path)
|
|
75
102
|
manager = Review::ReviewManager.new(doc)
|
|
@@ -81,6 +108,20 @@ module Uniword
|
|
|
81
108
|
return
|
|
82
109
|
end
|
|
83
110
|
|
|
111
|
+
if options[:json]
|
|
112
|
+
require "json"
|
|
113
|
+
puts JSON.pretty_generate(revisions.map do |rev|
|
|
114
|
+
{
|
|
115
|
+
id: rev.revision_id,
|
|
116
|
+
author: rev.author,
|
|
117
|
+
date: rev.date,
|
|
118
|
+
type: rev.type.to_s,
|
|
119
|
+
text: rev.text,
|
|
120
|
+
}
|
|
121
|
+
end)
|
|
122
|
+
return
|
|
123
|
+
end
|
|
124
|
+
|
|
84
125
|
say "Tracked changes (#{revisions.count}):", :green
|
|
85
126
|
revisions.each_with_index do |rev, idx|
|
|
86
127
|
say " #{idx + 1}. ID: #{rev.revision_id}"
|
|
@@ -102,58 +143,53 @@ module Uniword
|
|
|
102
143
|
handle_error(e)
|
|
103
144
|
end
|
|
104
145
|
|
|
105
|
-
desc "accept FILE REVISION_ID",
|
|
146
|
+
desc "accept FILE [REVISION_ID]",
|
|
147
|
+
"Accept a revision, or all matching filters"
|
|
106
148
|
long_desc <<~DESC
|
|
107
|
-
Accept a tracked change by its revision ID.
|
|
149
|
+
Accept a tracked change by its revision ID. Without an ID, every
|
|
150
|
+
revision matching the --author/--since/--until filters is accepted
|
|
151
|
+
(default: all).
|
|
108
152
|
|
|
109
153
|
Examples:
|
|
110
154
|
$ uniword review accept document.docx 3
|
|
111
155
|
$ uniword review accept document.docx 3 --output accepted.docx
|
|
156
|
+
$ uniword review accept document.docx --author "John Doe" -o out.docx
|
|
157
|
+
$ uniword review accept document.docx --since 2026-01-01 -o out.docx
|
|
112
158
|
DESC
|
|
159
|
+
option :author, desc: "With no ID: only revisions by this author",
|
|
160
|
+
type: :string
|
|
161
|
+
option :since, desc: "With no ID: only revisions dated on/after",
|
|
162
|
+
type: :string
|
|
163
|
+
option :until, desc: "With no ID: only revisions dated on/before",
|
|
164
|
+
type: :string
|
|
113
165
|
option :output, aliases: "-o", desc: "Output file (overwrites if omitted)",
|
|
114
166
|
type: :string
|
|
115
|
-
def accept(path, revision_id)
|
|
116
|
-
|
|
117
|
-
manager = Review::ReviewManager.new(doc)
|
|
118
|
-
|
|
119
|
-
unless manager.accept(revision_id)
|
|
120
|
-
say "Revision '#{revision_id}' not found.", :red
|
|
121
|
-
exit 1
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
save_output(doc, path)
|
|
125
|
-
say "Accepted revision '#{revision_id}'.", :green
|
|
126
|
-
rescue Uniword::Error => e
|
|
127
|
-
handle_error(e)
|
|
128
|
-
rescue StandardError => e
|
|
129
|
-
handle_error(e)
|
|
167
|
+
def accept(path, revision_id = nil)
|
|
168
|
+
apply_decisions(path, revision_id, :accept, "Accepted")
|
|
130
169
|
end
|
|
131
170
|
|
|
132
|
-
desc "reject FILE REVISION_ID",
|
|
171
|
+
desc "reject FILE [REVISION_ID]",
|
|
172
|
+
"Reject a revision, or all matching filters"
|
|
133
173
|
long_desc <<~DESC
|
|
134
|
-
Reject a tracked change by its revision ID.
|
|
174
|
+
Reject a tracked change by its revision ID. Without an ID, every
|
|
175
|
+
revision matching the --author/--since/--until filters is rejected
|
|
176
|
+
(default: all).
|
|
135
177
|
|
|
136
178
|
Examples:
|
|
137
179
|
$ uniword review reject document.docx 3
|
|
138
180
|
$ uniword review reject document.docx 3 --output rejected.docx
|
|
181
|
+
$ uniword review reject document.docx --author "John Doe" -o out.docx
|
|
139
182
|
DESC
|
|
183
|
+
option :author, desc: "With no ID: only revisions by this author",
|
|
184
|
+
type: :string
|
|
185
|
+
option :since, desc: "With no ID: only revisions dated on/after",
|
|
186
|
+
type: :string
|
|
187
|
+
option :until, desc: "With no ID: only revisions dated on/before",
|
|
188
|
+
type: :string
|
|
140
189
|
option :output, aliases: "-o", desc: "Output file (overwrites if omitted)",
|
|
141
190
|
type: :string
|
|
142
|
-
def reject(path, revision_id)
|
|
143
|
-
|
|
144
|
-
manager = Review::ReviewManager.new(doc)
|
|
145
|
-
|
|
146
|
-
unless manager.reject(revision_id)
|
|
147
|
-
say "Revision '#{revision_id}' not found.", :red
|
|
148
|
-
exit 1
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
save_output(doc, path)
|
|
152
|
-
say "Rejected revision '#{revision_id}'.", :green
|
|
153
|
-
rescue Uniword::Error => e
|
|
154
|
-
handle_error(e)
|
|
155
|
-
rescue StandardError => e
|
|
156
|
-
handle_error(e)
|
|
191
|
+
def reject(path, revision_id = nil)
|
|
192
|
+
apply_decisions(path, revision_id, :reject, "Rejected")
|
|
157
193
|
end
|
|
158
194
|
|
|
159
195
|
desc "accept-all FILE", "Accept all tracked changes"
|
|
@@ -277,6 +313,60 @@ module Uniword
|
|
|
277
313
|
|
|
278
314
|
private
|
|
279
315
|
|
|
316
|
+
# Apply accept/reject to one revision ID or to all revisions
|
|
317
|
+
# matching the author/date filters, then save.
|
|
318
|
+
#
|
|
319
|
+
# @param path [String] document path
|
|
320
|
+
# @param revision_id [String, nil] single target ID (filters unused)
|
|
321
|
+
# @param action [Symbol] :accept or :reject
|
|
322
|
+
# @param label [String] past-tense verb for the report line
|
|
323
|
+
# @return [void]
|
|
324
|
+
def apply_decisions(path, revision_id, action, label)
|
|
325
|
+
doc = load_document(path)
|
|
326
|
+
manager = Review::ReviewManager.new(doc)
|
|
327
|
+
|
|
328
|
+
if revision_id
|
|
329
|
+
found = decide(manager, action, revision_id)
|
|
330
|
+
unless found
|
|
331
|
+
say "Revision '#{revision_id}' not found.", :red
|
|
332
|
+
exit 1
|
|
333
|
+
end
|
|
334
|
+
count = 1
|
|
335
|
+
else
|
|
336
|
+
targets = filtered_revisions(manager)
|
|
337
|
+
# Capture the count first: deciding a revision splices it out
|
|
338
|
+
# of the facade's list, which `targets` may alias.
|
|
339
|
+
count = targets.count
|
|
340
|
+
targets.each { |rev| decide(manager, action, rev.revision_id) }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
save_output(doc, path)
|
|
344
|
+
|
|
345
|
+
if count.positive?
|
|
346
|
+
say "#{label} #{count} revision(s).", :green
|
|
347
|
+
else
|
|
348
|
+
say "No matching revisions found.", :yellow
|
|
349
|
+
end
|
|
350
|
+
rescue Uniword::Error => e
|
|
351
|
+
handle_error(e)
|
|
352
|
+
rescue StandardError => e
|
|
353
|
+
handle_error(e)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Dispatch one revision decision to the review manager.
|
|
357
|
+
#
|
|
358
|
+
# @param manager [Review::ReviewManager]
|
|
359
|
+
# @param action [Symbol] :accept or :reject
|
|
360
|
+
# @param revision_id [String]
|
|
361
|
+
# @return [Boolean, nil] outcome reported by the manager
|
|
362
|
+
def decide(manager, action, revision_id)
|
|
363
|
+
if action == :accept
|
|
364
|
+
manager.accept(revision_id)
|
|
365
|
+
else
|
|
366
|
+
manager.reject(revision_id)
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
280
370
|
# Filter revisions based on CLI options.
|
|
281
371
|
#
|
|
282
372
|
# @param manager [Review::ReviewManager] The review manager
|
|
@@ -290,6 +380,9 @@ module Uniword
|
|
|
290
380
|
end
|
|
291
381
|
end
|
|
292
382
|
|
|
383
|
+
revisions = filter_by_date_range(revisions, options[:since],
|
|
384
|
+
options[:until])
|
|
385
|
+
|
|
293
386
|
if options[:type]
|
|
294
387
|
type = options[:type].to_sym
|
|
295
388
|
revisions = revisions.select { |r| r.type == type }
|
|
@@ -298,6 +391,52 @@ module Uniword
|
|
|
298
391
|
revisions
|
|
299
392
|
end
|
|
300
393
|
|
|
394
|
+
# Keep revisions whose date falls within [since, until]. Dates are
|
|
395
|
+
# ISO 8601; unparseable revision dates are dropped when a date
|
|
396
|
+
# filter is active. Date-only bounds are interpreted as UTC days
|
|
397
|
+
# (inclusive on both ends).
|
|
398
|
+
#
|
|
399
|
+
# @param revisions [Array<Uniword::Revision>]
|
|
400
|
+
# @param since [String, nil] inclusive lower bound (ISO 8601)
|
|
401
|
+
# @param until_date [String, nil] inclusive upper bound (ISO 8601)
|
|
402
|
+
# @return [Array<Uniword::Revision>]
|
|
403
|
+
def filter_by_date_range(revisions, since, until_date)
|
|
404
|
+
lower = parse_filter_time(since)
|
|
405
|
+
upper = parse_filter_time(until_date, end_of_day: true)
|
|
406
|
+
return revisions unless lower || upper
|
|
407
|
+
|
|
408
|
+
revisions.select do |revision|
|
|
409
|
+
date = parse_filter_time(revision.date)
|
|
410
|
+
next false unless date
|
|
411
|
+
|
|
412
|
+
(lower.nil? || date >= lower) && (upper.nil? || date <= upper)
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Parse an ISO 8601 timestamp or date (2026-01-31 or
|
|
417
|
+
# 2026-01-31T12:00:00Z); nil when blank, exit 1 when unparseable.
|
|
418
|
+
# Date-only values resolve to the start of the UTC day, or to the
|
|
419
|
+
# last second of that day with end_of_day.
|
|
420
|
+
#
|
|
421
|
+
# @param value [String, nil]
|
|
422
|
+
# @param end_of_day [Boolean] resolve date-only values to 23:59:59Z
|
|
423
|
+
# @return [Time, nil]
|
|
424
|
+
def parse_filter_time(value, end_of_day: false)
|
|
425
|
+
return nil if value.nil? || value.to_s.empty?
|
|
426
|
+
|
|
427
|
+
Time.iso8601(value.to_s)
|
|
428
|
+
rescue ArgumentError
|
|
429
|
+
date = begin
|
|
430
|
+
Date.iso8601(value.to_s)
|
|
431
|
+
rescue ArgumentError
|
|
432
|
+
say "Invalid date: '#{value}' (expected ISO 8601, " \
|
|
433
|
+
"e.g. 2026-01-31 or 2026-01-31T12:00:00Z)", :red
|
|
434
|
+
exit 1
|
|
435
|
+
end
|
|
436
|
+
start_of_day = Time.utc(date.year, date.month, date.day)
|
|
437
|
+
end_of_day ? start_of_day + 86_399 : start_of_day
|
|
438
|
+
end
|
|
439
|
+
|
|
301
440
|
# Format revision type for display.
|
|
302
441
|
#
|
|
303
442
|
# @param type [Symbol] The revision type
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Uniword
|
|
6
|
+
# Revisions subcommands for the Uniword CLI.
|
|
7
|
+
#
|
|
8
|
+
# The issue-#79 spelling of the tracked-changes verbs: `revisions
|
|
9
|
+
# list` / `revisions accept` / `revisions reject` delegate to the
|
|
10
|
+
# `review changes|accept|reject` implementations, so both groups
|
|
11
|
+
# share one behavior surface (filters, JSON, exit codes).
|
|
12
|
+
class RevisionsCLI < Thor
|
|
13
|
+
include CLIHelpers
|
|
14
|
+
|
|
15
|
+
desc "list FILE", "List tracked changes (alias of review changes)"
|
|
16
|
+
long_desc <<~DESC
|
|
17
|
+
List tracked changes with id, type, author, date, and text -
|
|
18
|
+
identical to `uniword review changes`, under the revisions name.
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
$ uniword revisions list document.docx
|
|
22
|
+
$ uniword revisions list document.docx --author "John Doe" --json
|
|
23
|
+
$ uniword revisions list document.docx --since 2026-01-01
|
|
24
|
+
DESC
|
|
25
|
+
option :author, desc: "Filter revisions by author", type: :string
|
|
26
|
+
option :type, desc: "Filter by type (insert/delete/format_change)",
|
|
27
|
+
type: :string
|
|
28
|
+
option :since, desc: "Only revisions dated on/after (ISO 8601)",
|
|
29
|
+
type: :string
|
|
30
|
+
option :until, desc: "Only revisions dated on/before (ISO 8601)",
|
|
31
|
+
type: :string
|
|
32
|
+
option :verbose, aliases: "-v", desc: "Show detailed information",
|
|
33
|
+
type: :boolean, default: false
|
|
34
|
+
option :json, desc: "Output as JSON (machine-readable)",
|
|
35
|
+
type: :boolean, default: false
|
|
36
|
+
def list(path)
|
|
37
|
+
ReviewCLI.new.invoke(:changes, [path], options.to_h)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
desc "accept FILE [REVISION_ID]",
|
|
41
|
+
"Accept revisions (alias of review accept)"
|
|
42
|
+
long_desc <<~DESC
|
|
43
|
+
Accept one revision by id, or every filter-matching revision when
|
|
44
|
+
no id is given. Identical to `uniword review accept`.
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
$ uniword revisions accept document.docx
|
|
48
|
+
$ uniword revisions accept document.docx 4
|
|
49
|
+
$ uniword revisions accept document.docx --author "John Doe" -o out.docx
|
|
50
|
+
DESC
|
|
51
|
+
option :author, desc: "With no ID: only revisions by this author",
|
|
52
|
+
type: :string
|
|
53
|
+
option :since, desc: "With no ID: only revisions dated on/after",
|
|
54
|
+
type: :string
|
|
55
|
+
option :until, desc: "With no ID: only revisions dated on/before",
|
|
56
|
+
type: :string
|
|
57
|
+
option :output, aliases: "-o", desc: "Output file (overwrites if omitted)",
|
|
58
|
+
type: :string
|
|
59
|
+
def accept(path, revision_id = nil)
|
|
60
|
+
ReviewCLI.new.invoke(:accept, [path, revision_id].compact,
|
|
61
|
+
options.to_h)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
desc "reject FILE [REVISION_ID]",
|
|
65
|
+
"Reject revisions (alias of review reject)"
|
|
66
|
+
long_desc <<~DESC
|
|
67
|
+
Reject one revision by id, or every filter-matching revision when
|
|
68
|
+
no id is given. Identical to `uniword review reject`.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
$ uniword revisions reject document.docx
|
|
72
|
+
$ uniword revisions reject document.docx 4
|
|
73
|
+
$ uniword revisions reject document.docx --author "John Doe" -o out.docx
|
|
74
|
+
DESC
|
|
75
|
+
option :author, desc: "With no ID: only revisions by this author",
|
|
76
|
+
type: :string
|
|
77
|
+
option :since, desc: "With no ID: only revisions dated on/after",
|
|
78
|
+
type: :string
|
|
79
|
+
option :until, desc: "With no ID: only revisions dated on/before",
|
|
80
|
+
type: :string
|
|
81
|
+
option :output, aliases: "-o", desc: "Output file (overwrites if omitted)",
|
|
82
|
+
type: :string
|
|
83
|
+
def reject(path, revision_id = nil)
|
|
84
|
+
ReviewCLI.new.invoke(:reject, [path, revision_id].compact,
|
|
85
|
+
options.to_h)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -151,8 +151,83 @@ module Uniword
|
|
|
151
151
|
handle_error(e, verbose: options[:verbose])
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
desc "fill DOCUMENT", "Fill placeholders in a document with data"
|
|
155
|
+
long_desc <<~DESC
|
|
156
|
+
Fill placeholders in a DOCX document (mail merge) and save the
|
|
157
|
+
result as a new DOCX file.
|
|
158
|
+
|
|
159
|
+
Placeholder sources, in precedence order:
|
|
160
|
+
1. Content controls (w:sdt) whose alias or tag names a variable
|
|
161
|
+
2. MERGEFIELD simple fields (w:fldSimple)
|
|
162
|
+
3. {{var}} text placeholders inside run text
|
|
163
|
+
|
|
164
|
+
Data is loaded from a YAML or JSON file (--data, required). Nested
|
|
165
|
+
data keys are addressed with dotted paths ({{client.name}}).
|
|
166
|
+
Placeholders without data are left untouched unless --strict,
|
|
167
|
+
which fails with the list of missing variable names.
|
|
168
|
+
|
|
169
|
+
Examples:
|
|
170
|
+
$ uniword template fill letter.docx --data data.yml
|
|
171
|
+
$ uniword template fill letter.docx --data data.json -o out.docx
|
|
172
|
+
$ uniword template fill letter.docx --data data.yml --strict
|
|
173
|
+
$ uniword template fill letter.docx --data data.yml --json
|
|
174
|
+
DESC
|
|
175
|
+
option :data, desc: "Path to YAML or JSON data file", type: :string,
|
|
176
|
+
required: true
|
|
177
|
+
option :output, aliases: "-o",
|
|
178
|
+
desc: "Output DOCX path (default: <name>.filled.docx)",
|
|
179
|
+
type: :string
|
|
180
|
+
option :strict, desc: "Fail when a placeholder variable has no data",
|
|
181
|
+
type: :boolean, default: false
|
|
182
|
+
option :json, desc: "Report as JSON", type: :boolean, default: false
|
|
183
|
+
option :verbose, aliases: "-v", desc: "Verbose output",
|
|
184
|
+
type: :boolean, default: false
|
|
185
|
+
def fill(input_path)
|
|
186
|
+
validate_input_file(input_path)
|
|
187
|
+
|
|
188
|
+
document = DocumentFactory.from_file(input_path)
|
|
189
|
+
data = parse_data_file(options[:data])
|
|
190
|
+
log_fill_start(data) if options[:verbose]
|
|
191
|
+
report_fill(document, data, input_path)
|
|
192
|
+
rescue Uniword::Error => e
|
|
193
|
+
handle_error(e)
|
|
194
|
+
rescue StandardError => e
|
|
195
|
+
handle_error(e, verbose: options[:verbose])
|
|
196
|
+
end
|
|
197
|
+
|
|
154
198
|
private
|
|
155
199
|
|
|
200
|
+
def validate_input_file(path)
|
|
201
|
+
return if File.exist?(path)
|
|
202
|
+
|
|
203
|
+
say "File not found: #{path}", :red
|
|
204
|
+
exit 1
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def log_fill_start(data)
|
|
208
|
+
return unless data.any?
|
|
209
|
+
|
|
210
|
+
say " Data keys: #{data.keys.join(', ')}", :cyan
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def report_fill(document, data, input_path)
|
|
214
|
+
result = fill_document(document, data)
|
|
215
|
+
output_path = options[:output] || derive_filled_path(input_path)
|
|
216
|
+
document.save(output_path)
|
|
217
|
+
|
|
218
|
+
if options[:json]
|
|
219
|
+
puts result.to_json
|
|
220
|
+
else
|
|
221
|
+
say result.summary, result.complete? ? :green : :yellow
|
|
222
|
+
say "Filled document -> #{output_path}", :green
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def fill_document(document, data)
|
|
227
|
+
Uniword::Template::Filler.new(document)
|
|
228
|
+
.fill(data, strict: options[:strict])
|
|
229
|
+
end
|
|
230
|
+
|
|
156
231
|
def load_template_data
|
|
157
232
|
data = {}
|
|
158
233
|
|
|
@@ -189,5 +264,11 @@ module Uniword
|
|
|
189
264
|
|
|
190
265
|
data
|
|
191
266
|
end
|
|
267
|
+
|
|
268
|
+
# Default output path: "<name>.filled.docx" next to the input.
|
|
269
|
+
def derive_filled_path(path)
|
|
270
|
+
ext = File.extname(path)
|
|
271
|
+
"#{path.delete_suffix(ext)}.filled#{ext}"
|
|
272
|
+
end
|
|
192
273
|
end
|
|
193
274
|
end
|