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