snibbets 2.0.29 → 2.0.31

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,420 @@
1
+ #!/usr/bin/env ruby
2
+ # Snibbets 2.0.0
3
+
4
+ require 'optparse'
5
+ require 'readline'
6
+ require 'json'
7
+ require 'cgi'
8
+ require 'shellwords'
9
+
10
+ $search_path = File.expand_path("~/Desktop/Code/Snippets")
11
+
12
+ class String
13
+ # Are there multiple snippets (indicated by ATX headers)
14
+ def multiple?
15
+ return self.scan(/^#+/).length > 1
16
+ end
17
+
18
+ # Is the snippet in this block fenced?
19
+ def fenced?
20
+ count = self.scan(/^```/).length
21
+ return count > 1 && count.even?
22
+ end
23
+
24
+ def rx
25
+ return ".*" + self.gsub(/\s+/,'.*') + ".*"
26
+ end
27
+
28
+ # remove outside comments, fences, and indentation
29
+ def clean_code
30
+ block = self
31
+
32
+ # if it's a fenced code block, just discard the fence and everything
33
+ # outside it
34
+ if block.fenced?
35
+ return block.gsub(/(?:^|.*?\n)(`{3,})(\w+)?(.*?)\n\1.*/m) {|m| $3.strip }
36
+ end
37
+
38
+ # assume it's indented code, discard non-indented lines and outdent
39
+ # the rest
40
+ indent = nil
41
+ inblock = false
42
+ code = []
43
+ block.split(/\n/).each {|line|
44
+ if line =~ /^\s*$/ && inblock
45
+ code.push(line)
46
+ elsif line =~ /^( {4,}|\t+)/
47
+ inblock = true
48
+ indent ||= Regexp.new("^#{$1}")
49
+ code.push(line.sub(indent,''))
50
+ else
51
+ inblock = false
52
+ end
53
+ }
54
+ code.join("\n")
55
+ end
56
+
57
+ # Returns an array of snippets. Single snippets are returned without a
58
+ # title, multiple snippets get titles from header lines
59
+ def snippets
60
+ content = self.dup
61
+ # If there's only one snippet, just clean it and return
62
+ unless self.multiple?
63
+ return [{"title" => "", "code" => content.clean_code.strip}]
64
+ end
65
+
66
+ # Split content by ATX headers. Everything on the line after the #
67
+ # becomes the title, code is gleaned from text between that and the
68
+ # next ATX header (or end)
69
+ sections = []
70
+ parts = content.split(/^#+/)
71
+ parts.shift
72
+
73
+ parts.each do |p|
74
+ lines = p.split(/\n/)
75
+ title = lines.shift.strip.sub(/[.:]$/, '')
76
+ block = lines.join("\n")
77
+ code = block.clean_code
78
+ next unless code && !code.empty?
79
+
80
+ sections << {
81
+ 'title' => title,
82
+ 'code' => code.strip
83
+ }
84
+ end
85
+ return sections
86
+ end
87
+ end
88
+
89
+ def gum_menu(executable, res, title)
90
+ options = res.map { |m| m['title'] }
91
+ puts title
92
+ selection = `echo #{Shellwords.escape(options.join("\n"))} | #{executable} choose --limit 1`.strip
93
+ Process.exit 1 if selection.empty?
94
+
95
+ res.select { |m| m['title'] =~ /#{selection}/ }[0]
96
+ end
97
+
98
+ def fzf_menu(executable, res, title)
99
+ options = res.map { |m| m['title'] }
100
+ args = [
101
+ "--height=#{options.count + 2}",
102
+ %(--prompt="#{title} > "),
103
+ '-1'
104
+ ]
105
+ selection = `echo #{Shellwords.escape(options.join("\n"))} | #{executable} #{args.join(' ')}`.strip
106
+ Process.exit 1 if selection.empty?
107
+
108
+ res.select { |m| m['title'] =~ /#{selection}/ }[0]
109
+ end
110
+
111
+ def menu(res, title = 'Select one')
112
+ fzf = `which fzf`.strip
113
+ return fzf_menu(fzf, res, title) unless fzf.empty?
114
+
115
+ gum = `which gum`.strip
116
+ return gum_menu(gum, res, title) unless gum.empty?
117
+
118
+ console_menu(res, title)
119
+ end
120
+
121
+ # Generate a numbered menu, items passed must have a title property
122
+ def console_menu(res, title)
123
+ stty_save = `stty -g`.chomp
124
+
125
+ trap('INT') do
126
+ system('stty', stty_save)
127
+ Process.exit 1
128
+ end
129
+
130
+ # Generate a numbered menu, items passed must have a title property('INT') { system('stty', stty_save); exit }
131
+ counter = 1
132
+ $stderr.puts
133
+ res.each do |m|
134
+ $stderr.printf("%<counter>2d) %<title>s\n", counter: counter, title: m['title'])
135
+ counter += 1
136
+ end
137
+ $stderr.puts
138
+
139
+ begin
140
+ $stderr.printf(title.sub(/:?$/, ': '), res.length)
141
+ while (line = Readline.readline('', true))
142
+ unless line =~ /^[0-9]/
143
+ system('stty', stty_save) # Restore
144
+ exit
145
+ end
146
+ line = line.to_i
147
+ return res[line - 1] if line.positive? && line <= res.length
148
+
149
+ warn 'Out of range'
150
+ menu(res, title)
151
+ end
152
+ rescue Interrupt
153
+ system('stty', stty_save)
154
+ exit
155
+ end
156
+ end
157
+
158
+ # Search the snippets directory for query using find and grep
159
+ def search(query, folder, try = 0)
160
+ # First try only search by filenames
161
+ # Second try search with grep
162
+ # Third try search with Spotlight name only
163
+ # Fourth try search with Spotlight all contents
164
+ cmd = case try
165
+ when 0
166
+ %(find "#{folder}" -iregex '#{query.rx}')
167
+ when 1
168
+ %(grep -iEl '#{query.rx}' "#{folder}/"*.md)
169
+ when 2
170
+ %(mdfind -onlyin "#{folder}" -name '#{query}' 2>/dev/null)
171
+ when 3
172
+ %(mdfind -onlyin "#{folder}" '#{query}' 2>/dev/null)
173
+ end
174
+
175
+ matches = `#{cmd}`.strip
176
+
177
+ results = []
178
+
179
+ if !matches.empty?
180
+ lines = matches.split(/\n/)
181
+ lines.each do |l|
182
+ results << {
183
+ 'title' => File.basename(l, '.*'),
184
+ 'path' => l
185
+ }
186
+ end
187
+ results
188
+ else
189
+ return [] if try > 2
190
+
191
+ # if no results on the first try, try again searching all text
192
+ search(query, folder, try + 1)
193
+ end
194
+ end
195
+
196
+ def highlight_pygments(executable, code, syntax, theme)
197
+ syntax = syntax.empty? ? '-g' : "-l #{syntax}"
198
+ `echo #{Shellwords.escape(code)} | #{executable} #{syntax}`
199
+ end
200
+
201
+ def highlight_skylight(executable, code, syntax, theme)
202
+ return code if syntax.empty?
203
+
204
+ `echo #{Shellwords.escape(code)} | #{executable} --syntax #{syntax}`
205
+ end
206
+
207
+ def highlight(code, filename, theme = 'monokai')
208
+ syntax = syntax_from_extension(filename)
209
+
210
+ skylight = `which skylighting`.strip
211
+ return highlight_skylight(skylight, code, syntax, theme) unless skylight.empty?
212
+
213
+ pygments = `which pygmentize`.strip
214
+ return highlight_pygments(pygments, code, syntax, theme) unless pygments.empty?
215
+
216
+ code
217
+ end
218
+
219
+ def ext_to_lang(ext)
220
+ case ext
221
+ when /^(as|applescript|scpt)$/
222
+ 'applescript'
223
+ when /^m$/
224
+ 'objective-c'
225
+ when /^(pl|perl)$/
226
+ 'perl'
227
+ when /^py$/
228
+ 'python'
229
+ when /^(js|jq(uery)?|jxa)$/
230
+ 'javascript'
231
+ when /^rb$/
232
+ 'ruby'
233
+ when /^cc$/
234
+ 'c'
235
+ when /^(ba|fi|z|c)?sh$/
236
+ 'bash'
237
+ when /^pl$/
238
+ 'perl'
239
+ else
240
+ if %w[awk sed css sass scss less cpp php c sh swift html erb json xpath sql htaccess].include?(ext)
241
+ ext
242
+ else
243
+ ''
244
+ end
245
+ end
246
+ end
247
+
248
+ def syntax_from_extension(filename)
249
+ ext_to_lang(filename.split(/\./)[1])
250
+ end
251
+
252
+ options = {
253
+ interactive: true,
254
+ launchbar: false,
255
+ output: 'raw',
256
+ source: $search_path,
257
+ highlight: false,
258
+ all: false
259
+ }
260
+
261
+ optparse = OptionParser.new do |opts|
262
+ opts.banner = "Usage: #{File.basename(__FILE__)} [options] query"
263
+
264
+ opts.on('-q', '--quiet', 'Skip menus and display first match') do
265
+ options[:interactive] = false
266
+ options[:launchbar] = false
267
+ end
268
+
269
+ opts.on('-a', '--all', 'If a file contains multiple snippets, output all of them (no menu)') do
270
+ options[:all] = true
271
+ end
272
+
273
+ opts.on('-o', '--output FORMAT', 'Output format (launchbar or raw)') do |outformat|
274
+ valid = %w[json launchbar lb raw]
275
+ if outformat.downcase =~ /(launchbar|lb)/
276
+ options[:launchbar] = true
277
+ options[:interactive] = false
278
+ elsif valid.include?(outformat.downcase)
279
+ options[:output] = outformat.downcase
280
+ end
281
+ end
282
+
283
+ opts.on('-s', '--source FOLDER', 'Snippets folder to search') do |folder|
284
+ options[:source] = File.expand_path(folder)
285
+ end
286
+
287
+ opts.on('--highlight', 'Use pygments or skylighting to syntax highlight (if installed)') do
288
+ options[:highlight] = true
289
+ end
290
+
291
+ opts.on('-h', '--help', 'Display this screen') do
292
+ puts optparse
293
+ Process.exit 0
294
+ end
295
+ end
296
+
297
+ optparse.parse!
298
+
299
+ query = ''
300
+
301
+ if options[:launchbar]
302
+ query = if $stdin.stat.size.positive?
303
+ $stdin.read.force_encoding('utf-8')
304
+ else
305
+ ARGV.join(' ')
306
+ end
307
+ elsif ARGV.length
308
+ query = ARGV.join(' ')
309
+ end
310
+
311
+ query = CGI.unescape(query)
312
+
313
+ if query.strip.empty?
314
+ puts 'No search query'
315
+ puts optparse
316
+ Process.exit 1
317
+ end
318
+
319
+ results = search(query, options[:source], 0)
320
+
321
+ if options[:launchbar]
322
+ output = []
323
+
324
+ if results.empty?
325
+ out = {
326
+ 'title' => 'No matching snippets found'
327
+ }.to_json
328
+ puts out
329
+ Process.exit
330
+ end
331
+
332
+ results.each do |result|
333
+ input = IO.read(result['path'])
334
+ snippets = input.snippets
335
+ next if snippets.empty?
336
+
337
+ children = []
338
+
339
+ if snippets.length == 1
340
+ output << {
341
+ 'title' => result['title'],
342
+ 'path' => result['path'],
343
+ 'action' => 'copyIt',
344
+ 'actionArgument' => snippets[0]['code'],
345
+ 'label' => 'Copy'
346
+ }
347
+ next
348
+ end
349
+
350
+ snippets.each { |s|
351
+ children << {
352
+ 'title' => s['title'],
353
+ 'path' => result['path'],
354
+ 'action' => 'copyIt',
355
+ 'actionArgument' => s['code'],
356
+ 'label' => 'Copy'
357
+ }
358
+ }
359
+
360
+ output << {
361
+ 'title' => result['title'],
362
+ 'path' => result['path'],
363
+ 'children' => children
364
+ }
365
+ end
366
+
367
+ puts output.to_json
368
+ else
369
+ filepath = nil
370
+ if results.empty?
371
+ warn 'No results'
372
+ Process.exit 0
373
+ elsif results.length == 1 || !options[:interactive]
374
+ filepath = results[0]['path']
375
+ input = IO.read(filepath)
376
+ else
377
+ answer = menu(results, 'Select a file')
378
+ filepath = answer['path']
379
+ input = IO.read(filepath)
380
+ end
381
+
382
+ snippets = input.snippets
383
+
384
+ if snippets.empty?
385
+ warn 'No snippets found'
386
+ Process.exit 0
387
+ elsif snippets.length == 1 || !options[:interactive]
388
+ if options[:output] == 'json'
389
+ $stdout.puts snippets.to_json
390
+ else
391
+ snippets.each do |snip|
392
+ code = snip['code']
393
+ code = highlight(code, filepath) if options[:highlight]
394
+ $stdout.puts code
395
+ end
396
+ end
397
+ elsif snippets.length > 1
398
+ if options[:all]
399
+ if options[:output] == 'json'
400
+ $stdout.puts snippets.to_json
401
+ else
402
+ snippets.each do |snippet|
403
+ $stdout.puts snippet['title']
404
+ $stdout.puts '------'
405
+ $stdout.puts snippet['code']
406
+ $stdout.puts
407
+ end
408
+ end
409
+ else
410
+ answer = menu(snippets, 'Select snippet')
411
+ if options[:output] == 'json'
412
+ $stdout.puts answer.to_json
413
+ else
414
+ code = answer['code']
415
+ code = highlight(code, filepath) if options[:highlight]
416
+ $stdout.puts code
417
+ end
418
+ end
419
+ end
420
+ end
data/bin/snibbets CHANGED
@@ -46,6 +46,10 @@ module Snibbets
46
46
  options[:name_only] = v
47
47
  end
48
48
 
49
+ opts.on('--[no-]notes', 'Display the full content of the snippet') do |v|
50
+ options[:all_notes] = v
51
+ end
52
+
49
53
  opts.on('-o', '--output FORMAT', 'Output format (json|launchbar|*raw)') do |outformat|
50
54
  valid = %w[json launchbar lb raw]
51
55
  if outformat.downcase =~ /(launchbar|lb)/
@@ -4,6 +4,10 @@ module Snibbets
4
4
  select { |el| el =~ /^<block\d+>$/ }.count
5
5
  end
6
6
 
7
+ def notes
8
+ select { |el| el !~ /^<block\d+>$/ && el !~ /^```/ && !el.strip.empty? }.count
9
+ end
10
+
7
11
  def strip_empty
8
12
  remove_leading_empty_elements.remove_trailing_empty_elements
9
13
  end
@@ -17,7 +21,7 @@ module Snibbets
17
21
 
18
22
  in_leader = true
19
23
  each do |line|
20
- if (line =~ /^\s*$/ || line.empty?) && in_leader
24
+ if (line.strip.empty?) && in_leader
21
25
  next
22
26
  else
23
27
  in_leader = false
@@ -14,6 +14,7 @@ module Snibbets
14
14
 
15
15
  DEFAULT_OPTIONS = {
16
16
  all: false,
17
+ all_notes: false,
17
18
  copy: false,
18
19
  editor: nil,
19
20
  extension: 'md',
@@ -41,8 +41,24 @@ module Snibbets
41
41
  # `echo #{Shellwords.escape(code)} | #{executable} #{theme}--syntax #{syntax}`
42
42
  end
43
43
 
44
+ def highlight_fences(code, filename, syntax)
45
+ content = code.dup
46
+
47
+ content.fences.each do |f|
48
+ rx = Regexp.new(Regexp.escape(f[:code]))
49
+ highlighted = highlight(f[:code].gsub(/\\k</, '\k\<'), filename, f[:lang] || syntax).strip
50
+ code.sub!(/#{rx}/, highlighted)
51
+ end
52
+
53
+ Snibbets.options[:all_notes] ? code.gsub(/k\\</, 'k<') : code.gsub(/k\\</, 'k<').clean_code
54
+ end
55
+
44
56
  def highlight(code, filename, syntax, theme = nil)
45
- return code unless $stdout.isatty
57
+ unless $stdout.isatty
58
+ return Snibbets.options[:all_notes] && code.replace_blocks[0].notes? ? code : code.clean_code
59
+ end
60
+
61
+ return highlight_fences(code, filename, syntax) if code.fenced?
46
62
 
47
63
  theme ||= Snibbets.options[:highlight_theme] || 'monokai'
48
64
  syntax ||= Lexers.syntax_from_extension(filename)
data/lib/snibbets/menu.rb CHANGED
@@ -14,9 +14,7 @@ module Snibbets
14
14
  def remove_items_without_query(filename, res, query)
15
15
  q = find_query_in_options(filename, res, query).split(/ /)
16
16
  res.delete_if do |opt|
17
- q.none? do |word|
18
- "#{filename} #{opt['title']}" =~ /#{word}/i
19
- end
17
+ q.none? { |word| "#{filename} #{opt['title']}" =~ /#{word}/i }
20
18
  end
21
19
  res
22
20
  end
@@ -28,16 +26,14 @@ module Snibbets
28
26
  end
29
27
 
30
28
  if res.count.zero?
31
- warn 'No matches found'
29
+ warn 'No matches found' if Snibbets.options[:interactive]
32
30
  Process.exit 1
33
31
  end
34
32
 
35
33
  options = res.map { |m| m['title'] }
36
34
 
37
35
  puts title
38
- args = [
39
- "--height=#{options.count}"
40
- ]
36
+ args = ["--height=#{options.count}"]
41
37
  selection = `echo #{Shellwords.escape(options.join("\n"))} | #{executable} filter #{args.join(' ')}`.strip
42
38
  Process.exit 1 if selection.empty?
43
39
 
@@ -82,7 +78,7 @@ module Snibbets
82
78
  end
83
79
 
84
80
  if res.count.zero?
85
- warn 'No matches found'
81
+ warn 'No matches found' if Snibbets.options[:interactive]
86
82
  Process.exit 1
87
83
  end
88
84
 
data/lib/snibbets/os.rb CHANGED
@@ -31,12 +31,12 @@ module Snibbets
31
31
  os = RbConfig::CONFIG['target_os']
32
32
  case os
33
33
  when /darwin.*/i
34
- `pbpaste -pboard general -Prefer txt`
34
+ `pbpaste -pboard general -Prefer txt`.strip_newlines
35
35
  else
36
36
  if TTY::Which.exist?('xclip')
37
- `xclip -o -sel c`
37
+ `xclip -o -sel c`.strip_newlines
38
38
  elsif TTY::Which.exist('xsel')
39
- `xsel -ob`
39
+ `xsel -ob`.strip_newlines
40
40
  else
41
41
  puts 'Paste not supported on this system, please install xclip or xsel.'
42
42
  end
@@ -47,6 +47,28 @@ module Snibbets
47
47
  count > 1 && count.even?
48
48
  end
49
49
 
50
+ def blocks
51
+ replace_blocks[1].count
52
+ end
53
+
54
+ def blocks?
55
+ blocks.positive?
56
+ end
57
+
58
+ def notes?
59
+ replace_blocks[0].split("\n").notes.positive?
60
+ end
61
+
62
+ # Return array of fenced code blocks
63
+ def fences
64
+ return [] unless fenced?
65
+
66
+ rx = /(?mi)^(?<fence>`{3,})(?<lang> *\S+)? *\n(?<code>[\s\S]*?)\n\k<fence> *(?=\n|\Z)/
67
+ matches = []
68
+ scan(rx) { matches << Regexp.last_match }
69
+ matches.each_with_object([]) { |m, fenced| fenced.push({ code: m['code'], lang: m['lang'] }) }
70
+ end
71
+
50
72
  def indented?
51
73
  self =~ /^( {4,}|\t+)/
52
74
  end
@@ -90,11 +112,9 @@ module Snibbets
90
112
 
91
113
  indent = code[0].match(/^( {4,}|\t+)(?=\S)/)
92
114
 
93
- if indent
94
- code.map! { |line| line.sub(/(?mi)^#{indent[1]}/, '') }.join("\n")
95
- else
96
- self
97
- end
115
+ return self if indent.nil?
116
+
117
+ code.map! { |line| line.sub(/(?mi)^#{indent[1]}/, '') }.join("\n")
98
118
  end
99
119
 
100
120
  def replace_blocks
@@ -129,50 +149,44 @@ module Snibbets
129
149
  [sans_blocks, code_blocks]
130
150
  end
131
151
 
132
- # Returns an array of snippets. Single snippets are returned without a
133
- # title, multiple snippets get titles from header lines
134
- def snippets
135
- content = dup.remove_meta
136
- # If there's only one snippet, just clean it and return
137
- # return [{ 'title' => '', 'code' => content.clean_code.strip }] unless multiple?
138
-
139
- # Split content by ATX headers. Everything on the line after the #
140
- # becomes the title, code is gleaned from text between that and the
141
- # next ATX header (or end)
142
- sections = []
143
-
144
- sans_blocks, code_blocks = content.replace_blocks
145
-
146
- content = []
147
- if sans_blocks =~ /<block\d+>/
148
- sans_blocks.each_line do |line|
149
- content << line if line =~ /^#/ || line =~ /<block\d+>/
150
- end
151
-
152
- parts = content.join("\n").split(/^#+/)
153
- else
154
- parts = sans_blocks.gsub(/\n{2,}/, "\n\n").split(/^#+/)
152
+ def parse_lang_marker(block)
153
+ lang = nil
154
+ if block =~ /<lang:(.*?)>/
155
+ lang = Regexp.last_match(1)
156
+ block = block.gsub(/<lang:.*?>\n+/, '').strip_empty
155
157
  end
156
158
 
157
- # parts.shift if parts.count > 1
159
+ [lang, block]
160
+ end
161
+
162
+ def restore_blocks(parts, code_blocks)
163
+ sections = []
158
164
 
159
165
  parts.each do |part|
160
166
  lines = part.split(/\n/).strip_empty
161
- next if lines.blocks == 0
162
167
 
163
- title = lines.count > 1 && lines[0] !~ /<block\d+>/ ? lines.shift.strip.sub(/[.:]$/, '') : 'Default snippet'
164
- block = lines.join("\n").gsub(/<(block\d+)>/) { code_blocks[Regexp.last_match(1)] }
168
+ notes = part.notes?
169
+
170
+ next if lines.blocks.zero? && !notes
171
+
172
+ title = if lines.count > 1 && lines[0] !~ /<block\d+>/ && lines[0] =~ /^ +/
173
+ lines.shift.strip.sub(/[.:]$/, '')
174
+ else
175
+ 'Default snippet'
176
+ end
165
177
 
166
- lang = nil
167
- if block =~ /<lang:(.*?)>/
168
- lang = Regexp.last_match(1)
169
- block.gsub!(/<lang:.*?>\n/, '')
178
+ block = lines.join("\n").gsub(/<(block\d+)>/) do
179
+ code = code_blocks[Regexp.last_match(1)].strip_empty
180
+ lang, code = parse_lang_marker(code)
181
+ "\n```#{lang}\n#{code.strip}\n```"
170
182
  end
171
183
 
172
- code = block.clean_code
184
+ lang, code = parse_lang_marker(block)
173
185
 
174
186
  next unless code && !code.empty?
175
187
 
188
+ # code = code.clean_code unless notes || code.fences.count > 1
189
+
176
190
  sections << {
177
191
  'title' => title,
178
192
  'code' => code.strip_empty,
@@ -182,5 +196,32 @@ module Snibbets
182
196
 
183
197
  sections
184
198
  end
199
+
200
+ # Returns an array of snippets. Single snippets are returned without a
201
+ # title, multiple snippets get titles from header lines
202
+ def snippets
203
+ content = dup.remove_meta
204
+ # If there's only one snippet, just clean it and return
205
+ # return [{ 'title' => '', 'code' => content.clean_code.strip }] unless multiple?
206
+
207
+ # Split content by ATX headers. Everything on the line after the #
208
+ # becomes the title, code is gleaned from text between that and the
209
+ # next ATX header (or end)
210
+ sans_blocks, code_blocks = content.replace_blocks
211
+
212
+ parts = if Snibbets.options[:all_notes]
213
+ sans_blocks.split(/^#+/)
214
+ elsif sans_blocks =~ /<block\d+>/
215
+ sans_blocks.split(/\n/).each_with_object([]) do |line, arr|
216
+ arr << line if line =~ /^#/ || line =~ /<block\d+>/
217
+ end.join("\n").split(/^#+/)
218
+ else
219
+ sans_blocks.gsub(/\n{2,}/, "\n\n").split(/^#+/)
220
+ end
221
+
222
+ # parts.shift if parts.count > 1
223
+
224
+ restore_blocks(parts, code_blocks)
225
+ end
185
226
  end
186
227
  end