resme 1.0.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Resme
4
+ #
5
+ # Validate a document
6
+ #
7
+ class DocumentValidator
8
+ def initialize(document)
9
+ @document = document
10
+ end
11
+
12
+ def validate
13
+ errors = []
14
+ ClassyHash.validate(
15
+ @document.data,
16
+ SCHEMA,
17
+ errors: errors,
18
+ strict: true,
19
+ raise_errors: true,
20
+ full: true
21
+ )
22
+ errors
23
+ rescue StandardError => e
24
+ puts "#{@document.filename} does not validate:"
25
+ e.entries.each do |error|
26
+ puts "- #{error[:full_path]}: #{error[:message]}"
27
+ end
28
+ end
29
+
30
+ OPTIONAL_STRING = [:optional, String, NilClass].freeze
31
+ OPTIONAL_PARTIAL_DATE = [:optional, Date, String, Integer, NilClass].freeze
32
+ PARTIAL_DATE = [Date, String, Integer].freeze
33
+
34
+ #
35
+ # This defines the structure of resume.yml
36
+ # We validate it with ClassyHash
37
+ #
38
+ SCHEMA = {
39
+ basics: {
40
+ first_name: String,
41
+ middle_name: OPTIONAL_STRING,
42
+ last_name: String,
43
+ title: OPTIONAL_STRING,
44
+ picture: OPTIONAL_STRING,
45
+ birthdate: [:optional, Date, NilClass],
46
+ nationality: OPTIONAL_STRING,
47
+ marital_status: OPTIONAL_STRING,
48
+ gender: OPTIONAL_STRING
49
+ },
50
+ contacts: [[{ label: String, value: String }]],
51
+ addresses: [[{
52
+ label: String,
53
+ street: OPTIONAL_STRING,
54
+ postal_code: [:optional, String, Integer, NilClass],
55
+ city: OPTIONAL_STRING,
56
+ region: OPTIONAL_STRING,
57
+ country_code: OPTIONAL_STRING,
58
+ country: OPTIONAL_STRING
59
+ }]],
60
+ web_presence: [:optional,
61
+ [[{ label: String, value: String }]],
62
+ NilClass
63
+ ],
64
+ summary: OPTIONAL_STRING,
65
+ work: [:optional,
66
+ [[{
67
+ name: OPTIONAL_STRING,
68
+ url: OPTIONAL_STRING,
69
+ address: OPTIONAL_STRING,
70
+ end_date: OPTIONAL_PARTIAL_DATE,
71
+ start_date: OPTIONAL_PARTIAL_DATE,
72
+ position: String,
73
+ summary: String,
74
+ details: OPTIONAL_STRING
75
+ }]],
76
+ NilClass
77
+ ],
78
+ teaching: [:optional,
79
+ [[{
80
+ name: String,
81
+ school: OPTIONAL_STRING,
82
+ address: OPTIONAL_STRING,
83
+ end_date: OPTIONAL_PARTIAL_DATE,
84
+ start_date: OPTIONAL_PARTIAL_DATE,
85
+ position: String,
86
+ subject: String,
87
+ summary: OPTIONAL_STRING,
88
+ details: OPTIONAL_STRING
89
+ }]],
90
+ NilClass
91
+ ],
92
+ projects: [:optional,
93
+ [[{
94
+ name: String,
95
+ size: OPTIONAL_STRING,
96
+ stakeholder: OPTIONAL_STRING,
97
+ end_date: OPTIONAL_PARTIAL_DATE,
98
+ start_date: OPTIONAL_PARTIAL_DATE,
99
+ position: String,
100
+ summary: OPTIONAL_STRING
101
+ }]],
102
+ NilClass
103
+ ],
104
+ other: [:optional,
105
+ [[{
106
+ name: OPTIONAL_STRING,
107
+ end_date: OPTIONAL_PARTIAL_DATE,
108
+ start_date: OPTIONAL_PARTIAL_DATE,
109
+ position: String,
110
+ summary: OPTIONAL_STRING
111
+ }]],
112
+ NilClass
113
+ ],
114
+ committees: [:optional,
115
+ [[{
116
+ name: String,
117
+ position: String,
118
+ editions: [String, Integer],
119
+ url: OPTIONAL_STRING
120
+ }]],
121
+ NilClass
122
+ ],
123
+ volunteer: [:optional,
124
+ [[{
125
+ name: String,
126
+ where: OPTIONAL_STRING,
127
+ date: OPTIONAL_PARTIAL_DATE,
128
+ end_date: OPTIONAL_PARTIAL_DATE,
129
+ start_date: OPTIONAL_PARTIAL_DATE,
130
+ position: String,
131
+ summary: OPTIONAL_STRING
132
+ }]],
133
+ NilClass
134
+ ],
135
+ visits: [:optional,
136
+ [[{
137
+ name: String,
138
+ address: OPTIONAL_STRING,
139
+ end_date: OPTIONAL_PARTIAL_DATE,
140
+ start_date: OPTIONAL_PARTIAL_DATE,
141
+ position: String,
142
+ summary: OPTIONAL_STRING
143
+ }]],
144
+ NilClass
145
+ ],
146
+ education: [:optional,
147
+ [[{
148
+ degree: OPTIONAL_STRING,
149
+ topic: OPTIONAL_STRING,
150
+ school: String,
151
+ address: OPTIONAL_STRING,
152
+ date: OPTIONAL_PARTIAL_DATE,
153
+ end_date: OPTIONAL_PARTIAL_DATE,
154
+ start_date: OPTIONAL_PARTIAL_DATE,
155
+ url: OPTIONAL_STRING,
156
+ publish: TrueClass,
157
+ score: [:optional, String, Integer, NilClass]
158
+ }]],
159
+ NilClass
160
+ ],
161
+ publications: [:optional,
162
+ [[{
163
+ title: String,
164
+ authors: String,
165
+ publisher: String,
166
+ date: PARTIAL_DATE,
167
+ url: OPTIONAL_STRING
168
+ }]],
169
+ NilClass
170
+ ],
171
+ talks: [:optional,
172
+ [[{
173
+ title: String,
174
+ venue: String,
175
+ date: PARTIAL_DATE,
176
+ url: OPTIONAL_STRING
177
+ }]],
178
+ NilClass
179
+ ],
180
+ awards: [:optional,
181
+ [[{
182
+ name: String,
183
+ address: OPTIONAL_STRING,
184
+ date: PARTIAL_DATE,
185
+ title: String,
186
+ summary: OPTIONAL_STRING
187
+ }]],
188
+ NilClass
189
+ ],
190
+ achievements: [:optional,
191
+ [[{
192
+ name: String,
193
+ address: OPTIONAL_STRING,
194
+ date: OPTIONAL_PARTIAL_DATE,
195
+ title: String,
196
+ summary: OPTIONAL_STRING
197
+ }]],
198
+ NilClass
199
+ ],
200
+ software: [:optional,
201
+ [[{
202
+ title: String,
203
+ url: OPTIONAL_STRING,
204
+ programming_language: OPTIONAL_STRING,
205
+ license: OPTIONAL_STRING,
206
+ position: OPTIONAL_STRING,
207
+ summary: OPTIONAL_STRING
208
+ }]],
209
+ NilClass
210
+ ],
211
+ skills: [:optional,
212
+ [[{
213
+ name: String,
214
+ level: OPTIONAL_STRING,
215
+ summary: OPTIONAL_STRING
216
+ }]],
217
+ NilClass
218
+ ],
219
+ driving: [:optional,
220
+ [[{ license: String }]],
221
+ NilClass
222
+ ],
223
+ languages: [:optional,
224
+ {
225
+ mother_tongues: [[{
226
+ code: OPTIONAL_STRING,
227
+ language: String,
228
+ }
229
+ ]],
230
+ foreign: [:optional,
231
+ [[{
232
+ code: OPTIONAL_STRING,
233
+ language: String,
234
+ level: OPTIONAL_STRING,
235
+ listening: OPTIONAL_STRING,
236
+ reading: OPTIONAL_STRING,
237
+ spoken_interaction: OPTIONAL_STRING,
238
+ spoken_production: OPTIONAL_STRING,
239
+ writing: OPTIONAL_STRING
240
+ }]],
241
+ NilClass
242
+ ]
243
+ },
244
+ NilClass
245
+ ],
246
+ interests: [:optional,
247
+ [[{
248
+ name: String,
249
+ level: OPTIONAL_STRING,
250
+ summary: OPTIONAL_STRING
251
+ }]],
252
+ NilClass
253
+ ],
254
+ references: [:optional,
255
+ [[{
256
+ name: String,
257
+ reference: String,
258
+ contacts: [[{ label: String, value: String }]]
259
+ }]],
260
+ NilClass
261
+ ]
262
+ }.freeze
263
+ end
264
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Resme
4
+ #
5
+ # Execute!
6
+ #
7
+ class Executor
8
+ # include all formatter helpers
9
+ include Resme::Helper
10
+
11
+ def initialize(input)
12
+ @input = input
13
+ @options = Cli.new(@input)
14
+ @options.parse
15
+ end
16
+
17
+ # rubocop:disable Metrics
18
+ def execute
19
+ case @options.command
20
+ when :init
21
+ resume_template = File.join __dir__, "../../templates/resume.yml"
22
+ puts File.read resume_template
23
+
24
+ when :validate
25
+ filename = @options.options[:filename]
26
+ document = Document.new(filename)
27
+ validator = DocumentValidator.new(document)
28
+ validator.validate
29
+
30
+ when :sections
31
+ filename = @options.options[:filename]
32
+ keys = Document.new(filename).data.keys
33
+ puts keys
34
+
35
+ when :build
36
+ filename = @options.options[:filename]
37
+ data = Document.new(filename).data
38
+
39
+ except = @options.options[:except] || []
40
+ @data = Document.new(filename).data.except(*except)
41
+
42
+ format = @options.options[:format]
43
+ template_name = File.join __dir__, "../../templates/#{format}.erb"
44
+ template = File.read(template_name)
45
+
46
+ # add the json format, if specified
47
+ if format.to_s == "json"
48
+ js = JsonResume.new Document.new(filename)
49
+ @json_resume = js.build
50
+ end
51
+
52
+ output = ERB.new(template, trim_mode: "-").result(binding)
53
+
54
+ # it is difficult to write readable ERBs with no empty lines... we use
55
+ # gsub to replace multiple empty lines with \n\n in the final output
56
+ output.gsub!(/([\t ]*\n){3,}/, "\n\n")
57
+ backup_and_write(output_filename(format), output)
58
+
59
+ when :list
60
+ templates = File.join __dir__, "../../templates/*.erb"
61
+ files = Dir.glob(templates).map { |x| "#{File.basename(x)} => #{x}" }
62
+ puts files.join("\n")
63
+
64
+ when :cat
65
+ template = @options.options[:template]
66
+ template_path = File.join __dir__, "../../templates/#{template}"
67
+ puts File.read template_path
68
+
69
+ when :help
70
+ # nothing to do, OptParser has the context
71
+
72
+ when :version
73
+ puts "This is version: #{Resme::VERSION}"
74
+
75
+ end
76
+ end
77
+ # rubocop:enable Metrics
78
+
79
+ private
80
+
81
+ def output_filename(format)
82
+ "resume-#{Date.today.iso8601}.#{format}"
83
+ end
84
+
85
+ def backup(filename)
86
+ FileUtils.cp "filename, "#{filename}~"
87
+ puts "Backup copy #{filename} created in #{filename}~."
88
+ end
89
+
90
+ def backup_and_write(filename, content)
91
+ backup(filename) if File.exist?(filename)
92
+ File.open(filename, "w") { |f| f.puts content }
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Resme
4
+ #
5
+ # Helper functions for renderers
6
+ #
7
+ module Helper
8
+ # Strip and replace extra spaces and special chars with a single space
9
+ def clean(string)
10
+ string.strip.gsub(/[\t\n ]+/, " ")
11
+ end
12
+
13
+ def full_name(data)
14
+ [
15
+ data.dig(:basics, :first_name),
16
+ data.dig(:basics, :middle_name),
17
+ data.dig(:basics, :last_name),
18
+ ].compact.join(" ")
19
+ end
20
+
21
+ # Break a string into substrings of length chars breaking at spaces
22
+ # and newlines
23
+ #
24
+ # - `string` string to reflow
25
+ # - `chars` number of characters to break the string at
26
+ #
27
+ # Returns an array of strings of length < chars (with exceptions for special
28
+ # cases).
29
+ #
30
+ # Special cases: if one line is longer than chars characters, then break at
31
+ # the first space after chars
32
+ #
33
+ # rubocop:disable Metrics/AbcSize
34
+ def reflow_a(string, line_break: 78)
35
+ # if the string is less than line_break chars, we are done
36
+ return [clean(string)] if string.length < line_break
37
+
38
+ # here the string is longer than line_break chars
39
+ #
40
+ # if the string has a space between 0 and line_break, we break the
41
+ # string and apply recursion
42
+ #
43
+ line_break.downto(0).each do |index|
44
+ if blank?(string[index])
45
+ return [clean(string[0..(index - 1)])] +
46
+ reflow_a(string[(index + 1)..], line_break:)
47
+ end
48
+ end
49
+
50
+ # here the string does not have a space between 0 and line_break
51
+ #
52
+ # In this case we try to break at the first blank after the string and
53
+ # apply recursion
54
+ #
55
+ line_break.upto(string.length).each do |index|
56
+ if blank?(string[index])
57
+ return [clean(string[0..(index - 1)])] +
58
+ reflow_a(string[(index + 1)..], line_break:)
59
+ end
60
+ end
61
+
62
+ # here string is longer than line_break and it does not have spaces
63
+ #
64
+ # The only thing left to do is returning the string as it is
65
+ #
66
+ [clean(string)]
67
+ end
68
+ # rubocop:enable Metrics/AbcSize
69
+
70
+ def blank?(char)
71
+ [" ", "\t", "\n"].include?(char)
72
+ end
73
+
74
+ # Reflow at +line_break+, returning a string with each line delimited by
75
+ # +delimiter+ (by default newline) and indented with +indent+ spaces.
76
+ #
77
+ def reflow_s(string, line_break: 75, delimiter: "\n", indent: 3)
78
+ indentation = " " * indent
79
+
80
+ # remember PARAGRAPH BREAKS
81
+ paragraphs = string.gsub("\n\n", "{{PBREAK}}").split("{{PBREAK}}")
82
+
83
+ formatted_pars = paragraphs.map do |paragraph|
84
+ reflow_a(paragraph, line_break:).join(delimiter).gsub(/^/, indentation)
85
+ end
86
+
87
+ formatted_pars.join("\n\n") + "\n"
88
+ end
89
+
90
+ # Return a string encoding a period (start - end) from a Hash, containing
91
+ # the specificatio of the period. Manage special cases (partial dates,
92
+ # optional dates).
93
+ #
94
+ # Entry is the input Hash, +from+ is the digging specification to get the
95
+ # "start" date, +to+ is the digging specification to get the "end" date.
96
+ # Fallback is a key we use if +from+ or +to+ are not present.
97
+ # All accept a Symbol or an Array.
98
+ #
99
+ # abstract dates at the year level, taking care of periods if from and
100
+ # till are in two different years
101
+ #
102
+ def period(entry, date: :date, from: :start_date, to: :end_date, separator: "--")
103
+ date = entry[date]
104
+
105
+ return format_date(date) if date.is_a?(Date)
106
+
107
+ from = entry.dig(*[from].flatten)
108
+ to = entry.dig(*[to].flatten)
109
+
110
+ [format_date(from), separator, format_date(to)].compact.join(" ")
111
+ end
112
+
113
+ # make an entry into an itemized list
114
+ #
115
+ # - +entry+ is a hash containing the specification of a position to itemize
116
+ # (e.g., a work period, a degree, ...)
117
+ #
118
+ # - +header+, is an array of symbols, whose values, comma-separated will
119
+ # generate the header line
120
+ #
121
+ # The output is a String along the lines of:
122
+ #
123
+ # - value of key1, value of key2
124
+ # period
125
+ # reflowed summary
126
+ #
127
+ def itemize(entry, header: %i[role who address], summary_key: :summary)
128
+ header = clean header.map { |x| entry[x] }.compact.join(", ")
129
+
130
+ %(- #{header}
131
+ #{period entry}
132
+ #{reflow_to_s entry[summary_key], line_break: 72, indent: 2}
133
+ ).gsub(/^ /, "")
134
+ end
135
+
136
+ # Make a Hash into a list of properties, optionally excluding some keys
137
+ # specified in +except:+ (by default +summary+ and +details+)
138
+ def propertify(entry, except: %i[summary details], indent: 3)
139
+ indentation = " " * indent
140
+
141
+ head = ":PROPERTIES:"
142
+ body = entry.except(*except).map { |k, v| ":#{k.upcase}: #{v}" }.join("\n")
143
+ tail = ":END:"
144
+
145
+ "#{head}\n#{body}\n#{tail}\n".gsub!(/^/, indentation)
146
+ end
147
+
148
+ def select(contacts, label: :email)
149
+ contact = (contacts || []).find { |x| x[:label] == label.to_s }
150
+ contact ? contact[:value] : nil
151
+ end
152
+
153
+ #
154
+ # Utility functions for managing dates in the form 2015-01-01 and partial
155
+ # dates (e.g., 2015-05, 2015)
156
+ #
157
+
158
+ # Format the input as a date, according to format. If the input is not
159
+ # a date, return it as it (assuming it represents a partial date (e.g.,
160
+ # 2012-01, 2012
161
+ #
162
+ # Special case of an empty date (date == nil) returns ""
163
+ #
164
+ def format_date(date, format: "%Y-%m-%d")
165
+ date.is_a?(Date) ? date.strftime(format) : date.to_s
166
+ end
167
+
168
+ def complete_date(incomplete_date, format: "%Y-%m-%d")
169
+ return incomplete_date.strftime(format) if incomplete_date.is_a?(Date)
170
+
171
+ if ymd?(incomplete_date.to_s)
172
+ incomplete_date.to_s
173
+ elsif ym?(incomplete_date.to_s)
174
+ "#{incomplete_date}-01"
175
+ elsif y?(incomplete_date.to_s)
176
+ "#{incomplete_date}-01-01"
177
+ end
178
+ end
179
+
180
+ # Return the year
181
+ def year(input)
182
+ input.is_a?(Date) ? input.strftime("%Y") : input.to_s[0..3]
183
+ end
184
+
185
+ # Return the month
186
+ def month(input)
187
+ input.is_a?(Date) ? input.strftime("%m") : input.to_s[5..6]
188
+ end
189
+
190
+ # Return the day
191
+ def day(input)
192
+ input.is_a?(Date) ? input.strftime("%d") : input.to_s[8..9]
193
+ end
194
+
195
+ # Is the input in the form YYYY-MM(-..)
196
+ def y?(input)
197
+ input.is_a?(Date) || input.to_s.size == 4
198
+ end
199
+
200
+ # Is the input in the form YYYY-MM(-..)
201
+ def ym?(input)
202
+ input.is_a?(Date) || input.to_s.size == 7
203
+ end
204
+
205
+ # Is the input in the form YYYY-MM-DD
206
+ def ymd?(input)
207
+ input.is_a?(Date) || input.to_s.size == 10
208
+ end
209
+ end
210
+ end