doing 2.1.43 → 2.1.46
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/.irbrc +3 -0
- data/CHANGELOG.md +40 -0
- data/Gemfile.lock +4 -4
- data/README.md +1 -1
- data/bin/commands/grep.rb +10 -2
- data/bin/commands/on.rb +6 -1
- data/bin/commands/recent.rb +1 -0
- data/bin/commands/reset.rb +6 -4
- data/bin/commands/show.rb +6 -1
- data/bin/commands/since.rb +5 -2
- data/bin/commands/today.rb +1 -0
- data/bin/commands/views.rb +81 -20
- data/bin/doing +24 -1
- data/docs/doc/Array.html +12 -2
- data/docs/doc/BooleanTermParser/Clause.html +1 -1
- data/docs/doc/BooleanTermParser/Operator.html +1 -1
- data/docs/doc/BooleanTermParser/Query.html +1 -1
- data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
- data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
- data/docs/doc/BooleanTermParser.html +1 -1
- data/docs/doc/Doing/ArrayCleanup.html +1 -1
- data/docs/doc/Doing/ArrayNestedHash.html +1 -1
- data/docs/doc/Doing/ArrayTags.html +1 -1
- data/docs/doc/Doing/CSVExport.html +1 -1
- data/docs/doc/Doing/CalendarImport.html +1 -1
- data/docs/doc/Doing/Change.html +1 -1
- data/docs/doc/Doing/Changes.html +1 -1
- data/docs/doc/Doing/ChronifyArray.html +1 -1
- data/docs/doc/Doing/ChronifyNumeric.html +1 -1
- data/docs/doc/Doing/ChronifyString.html +1 -1
- data/docs/doc/Doing/Color.html +1 -1
- data/docs/doc/Doing/Completion/BashCompletions.html +1 -1
- data/docs/doc/Doing/Completion/FishCompletions.html +1 -1
- data/docs/doc/Doing/Completion/StringUtils.html +1 -1
- data/docs/doc/Doing/Completion/ZshCompletions.html +1 -1
- data/docs/doc/Doing/Completion.html +1 -1
- data/docs/doc/Doing/Configuration.html +1 -1
- data/docs/doc/Doing/DayOneRenderer.html +1 -1
- data/docs/doc/Doing/DayoneExport.html +1 -1
- data/docs/doc/Doing/DoingImport.html +1 -1
- data/docs/doc/Doing/Entry.html +1 -1
- data/docs/doc/Doing/Errors/DoingNoTraceError.html +1 -1
- data/docs/doc/Doing/Errors/DoingRuntimeError.html +1 -1
- data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
- data/docs/doc/Doing/Errors/EmptyInput.html +1 -1
- data/docs/doc/Doing/Errors/HistoryLimitError.html +1 -1
- data/docs/doc/Doing/Errors/InvalidPlugin.html +1 -1
- data/docs/doc/Doing/Errors/MissingBackupFile.html +1 -1
- data/docs/doc/Doing/Errors/NoResults.html +1 -1
- data/docs/doc/Doing/Errors/PluginException.html +1 -1
- data/docs/doc/Doing/Errors/UserCancelled.html +1 -1
- data/docs/doc/Doing/Errors/WrongCommand.html +1 -1
- data/docs/doc/Doing/Errors.html +1 -1
- data/docs/doc/Doing/HTMLExport.html +1 -1
- data/docs/doc/Doing/Hooks.html +1 -1
- data/docs/doc/Doing/Item.html +75 -36
- data/docs/doc/Doing/ItemDates.html +1 -1
- data/docs/doc/Doing/ItemQuery.html +1 -1
- data/docs/doc/Doing/ItemState.html +1 -1
- data/docs/doc/Doing/ItemTags.html +1 -1
- data/docs/doc/Doing/Items.html +129 -1
- data/docs/doc/Doing/JSONExport.html +1 -1
- data/docs/doc/Doing/Logger.html +1 -1
- data/docs/doc/Doing/MarkdownExport.html +1 -1
- data/docs/doc/Doing/Note.html +1 -1
- data/docs/doc/Doing/Pager.html +1 -1
- data/docs/doc/Doing/Plugins.html +1 -1
- data/docs/doc/Doing/Prompt.html +1 -1
- data/docs/doc/Doing/PromptChoose.html +1 -1
- data/docs/doc/Doing/PromptFZF.html +1 -1
- data/docs/doc/Doing/PromptInput.html +1 -1
- data/docs/doc/Doing/PromptSTD.html +1 -1
- data/docs/doc/Doing/PromptYN.html +1 -1
- data/docs/doc/Doing/Section.html +1 -1
- data/docs/doc/Doing/StringHighlight.html +1 -1
- data/docs/doc/Doing/StringNormalize.html +1 -1
- data/docs/doc/Doing/StringQuery.html +1 -1
- data/docs/doc/Doing/StringTags.html +1 -1
- data/docs/doc/Doing/StringTransform.html +1 -1
- data/docs/doc/Doing/StringTruncate.html +1 -1
- data/docs/doc/Doing/StringURL.html +1 -1
- data/docs/doc/Doing/SymbolNormalize.html +1 -1
- data/docs/doc/Doing/TaskPaperExport.html +1 -1
- data/docs/doc/Doing/TemplateExport.html +1 -1
- data/docs/doc/Doing/TemplateString.html +2 -2
- data/docs/doc/Doing/TimingImport.html +1 -1
- data/docs/doc/Doing/Types.html +23 -18
- data/docs/doc/Doing/Util/Backup.html +1 -1
- data/docs/doc/Doing/Util.html +2 -2
- data/docs/doc/Doing/Version.html +1 -1
- data/docs/doc/Doing/WWID.html +1 -1
- data/docs/doc/Doing.html +4 -4
- data/docs/doc/FalseClass.html +11 -1
- data/docs/doc/GLI/Commands/Help.html +1 -1
- data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
- data/docs/doc/GLI/Commands.html +1 -1
- data/docs/doc/GLI.html +1 -1
- data/docs/doc/Hash.html +1 -1
- data/docs/doc/Numeric.html +1 -1
- data/docs/doc/Object.html +1 -1
- data/docs/doc/PhraseParser/Operator.html +1 -1
- data/docs/doc/PhraseParser/PhraseClause.html +1 -1
- data/docs/doc/PhraseParser/Query.html +1 -1
- data/docs/doc/PhraseParser/QueryParser.html +1 -1
- data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
- data/docs/doc/PhraseParser/TermClause.html +1 -1
- data/docs/doc/PhraseParser.html +1 -1
- data/docs/doc/Status.html +1 -1
- data/docs/doc/String.html +65 -3
- data/docs/doc/Symbol.html +1 -1
- data/docs/doc/Time.html +68 -3
- data/docs/doc/TrueClass.html +11 -1
- data/docs/doc/_index.html +8 -1
- data/docs/doc/class_list.html +1 -1
- data/docs/doc/file.README.html +2 -2
- data/docs/doc/index.html +2 -2
- data/docs/doc/method_list.html +496 -440
- data/docs/doc/top-level-namespace.html +1 -1
- data/doing.rdoc +66 -5
- data/lib/completion/_doing.zsh +10 -10
- data/lib/completion/doing.bash +18 -18
- data/lib/completion/doing.fish +9 -0
- data/lib/doing/add_options.rb +6 -2
- data/lib/doing/configuration.rb +4 -0
- data/lib/doing/good.rb +18 -1
- data/lib/doing/item/item.rb +7 -8
- data/lib/doing/items/items.rb +24 -0
- data/lib/doing/plugins/export/json_export.rb +16 -2
- data/lib/doing/plugins/import/json_import.rb +93 -0
- data/lib/doing/string/string.rb +9 -0
- data/lib/doing/types.rb +9 -8
- data/lib/doing/version.rb +1 -1
- data/lib/doing/wwid/display.rb +4 -1
- data/lib/doing/wwid/filetools.rb +3 -2
- data/lib/doing/wwid/filter.rb +6 -3
- data/lib/doing.rb +10 -0
- metadata +3 -2
data/lib/doing/items/items.rb
CHANGED
|
@@ -35,6 +35,30 @@ module Doing
|
|
|
35
35
|
includes
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# Find an item by ID
|
|
39
|
+
#
|
|
40
|
+
# @param id The identifier to match
|
|
41
|
+
#
|
|
42
|
+
def find_id(id)
|
|
43
|
+
select { |item| item.id == id }[0]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
##
|
|
47
|
+
## Return the index for an entry matching ID
|
|
48
|
+
##
|
|
49
|
+
## @param id The identifier to match
|
|
50
|
+
##
|
|
51
|
+
def index_for_id(id)
|
|
52
|
+
i = nil
|
|
53
|
+
each_with_index do |item, idx|
|
|
54
|
+
if item.id == id
|
|
55
|
+
i = idx
|
|
56
|
+
break
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
i
|
|
60
|
+
end
|
|
61
|
+
|
|
38
62
|
# Output sections and items in Doing file format
|
|
39
63
|
def to_s
|
|
40
64
|
out = []
|
|
@@ -15,7 +15,18 @@ module Doing
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def self.render(wwid, items, variables: {})
|
|
18
|
-
|
|
18
|
+
if items.nil? || items.empty?
|
|
19
|
+
return case variables[:options][:output]
|
|
20
|
+
when 'json'
|
|
21
|
+
{
|
|
22
|
+
'section' => '',
|
|
23
|
+
'items' => [],
|
|
24
|
+
'timers' => ""
|
|
25
|
+
}.to_json
|
|
26
|
+
when 'timeline'
|
|
27
|
+
"<html></html>"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
19
30
|
|
|
20
31
|
opt = variables[:options]
|
|
21
32
|
opt[:output] = case opt[:output]
|
|
@@ -25,6 +36,7 @@ module Doing
|
|
|
25
36
|
'json'
|
|
26
37
|
end
|
|
27
38
|
items_out = []
|
|
39
|
+
|
|
28
40
|
last_date = items[-1].date + (60 * 60 * 24)
|
|
29
41
|
max = last_date.strftime('%F')
|
|
30
42
|
min = items[0].date.strftime('%F')
|
|
@@ -51,10 +63,12 @@ module Doing
|
|
|
51
63
|
date: i.date,
|
|
52
64
|
end_date: end_date,
|
|
53
65
|
title: title.strip, #+ " #{note}"
|
|
66
|
+
section: i.section,
|
|
54
67
|
note: note.to_s(prefix: ''),
|
|
55
68
|
time: interval.time_string(format: :clock),
|
|
56
69
|
duration: duration.time_string(format: :clock),
|
|
57
|
-
tags: tags
|
|
70
|
+
tags: tags,
|
|
71
|
+
id: i.id
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
attributes.each { |attr, val| i[attr.to_sym] = val }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# title: JSON Import
|
|
4
|
+
# description: Import entries from a Doing JSON export
|
|
5
|
+
# author: Brett Terpstra
|
|
6
|
+
# url: https://brettterpstra.com
|
|
7
|
+
module Doing
|
|
8
|
+
class JSONImport
|
|
9
|
+
include Doing::Util
|
|
10
|
+
|
|
11
|
+
def self.settings
|
|
12
|
+
{
|
|
13
|
+
trigger: 'json'
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
##
|
|
18
|
+
## Imports a Timing report
|
|
19
|
+
##
|
|
20
|
+
## @param wwid [WWID] The wwid object
|
|
21
|
+
## @param path [String] Path to JSON report
|
|
22
|
+
## file
|
|
23
|
+
## @param options [Hash] Additional Options
|
|
24
|
+
##
|
|
25
|
+
def self.import(wwid, path, options: {})
|
|
26
|
+
exit_now! 'Path to JSON export required' if path.nil?
|
|
27
|
+
options[:no_overlap] ||= false
|
|
28
|
+
options[:autotag] ||= Doing.auto_tag
|
|
29
|
+
|
|
30
|
+
exit_now! 'File not found' unless File.exist?(File.expand_path(path))
|
|
31
|
+
|
|
32
|
+
updated = 0
|
|
33
|
+
added = 0
|
|
34
|
+
skipped = 0
|
|
35
|
+
|
|
36
|
+
data = JSON.parse(IO.read(File.expand_path(path)))
|
|
37
|
+
new_items = []
|
|
38
|
+
new_section = options[:section] || Doing.setting('current_section')
|
|
39
|
+
|
|
40
|
+
data['items'].each do |entry|
|
|
41
|
+
title = entry['title']
|
|
42
|
+
date = Time.parse(entry['date'])
|
|
43
|
+
date ||= entry['date'].chronify
|
|
44
|
+
note = Doing::Note.new(entry['note'])
|
|
45
|
+
section = if entry['section'].empty?
|
|
46
|
+
new_section
|
|
47
|
+
else
|
|
48
|
+
entry['section']
|
|
49
|
+
end
|
|
50
|
+
id = entry.key?('id') ? entry['id'] : nil
|
|
51
|
+
|
|
52
|
+
new_item = Doing::Item.new(date, title, section, note, id)
|
|
53
|
+
|
|
54
|
+
is_match = true
|
|
55
|
+
|
|
56
|
+
if options[:search]
|
|
57
|
+
is_match = new_item.search(options[:search], case_type: options[:case], negate: options[:not])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if is_match && options[:date_filter]
|
|
61
|
+
is_match = start_time > options[:date_filter][0] && start_time < options[:date_filter][1]
|
|
62
|
+
is_match = options[:not] ? !is_match : is_match
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
unless is_match
|
|
66
|
+
skipped += 1
|
|
67
|
+
next
|
|
68
|
+
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if wwid.content.find_id(new_item.id)
|
|
72
|
+
old_index = wwid.content.index_for_id(entry['id'])
|
|
73
|
+
old_item = wwid.content[old_index].clone
|
|
74
|
+
wwid.content[old_index] = new_item
|
|
75
|
+
Hooks.trigger :post_entry_updated, self, new_item, old_item
|
|
76
|
+
updated += 1
|
|
77
|
+
else
|
|
78
|
+
Hooks.trigger :pre_entry_add, self, item
|
|
79
|
+
wwid.content << new_entry
|
|
80
|
+
Hooks.trigger :post_entry_added, self, item
|
|
81
|
+
added += 1
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
total = new_items.count
|
|
85
|
+
skipped = data.count - total
|
|
86
|
+
Doing.logger.debug('Skipped:', %(#{skipped} items)) if skipped.positive?
|
|
87
|
+
Doing.logger.info('Updated:', %(#{updated} items))
|
|
88
|
+
Doing.logger.info('Imported:', %(#{added} new items to #{new_section}))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
Doing::Plugins.register 'json', :import, self
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/doing/string/string.rb
CHANGED
|
@@ -16,6 +16,15 @@ class ::String
|
|
|
16
16
|
include Doing::StringTruncate
|
|
17
17
|
include Doing::StringURL
|
|
18
18
|
|
|
19
|
+
##
|
|
20
|
+
## Test if string is a valid 32-character MD5 id
|
|
21
|
+
##
|
|
22
|
+
## @return [Boolean] string is valid identifier
|
|
23
|
+
##
|
|
24
|
+
def valid_id?
|
|
25
|
+
strip =~ /^[a-z0-9]{32}$/ ? true : false
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
##
|
|
20
29
|
## Force UTF-8 encoding if available
|
|
21
30
|
##
|
data/lib/doing/types.rb
CHANGED
|
@@ -17,18 +17,19 @@ module Doing
|
|
|
17
17
|
InvalidExportType = Class.new(RuntimeError)
|
|
18
18
|
MissingConfigFile = Class.new(RuntimeError)
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
AgeSymbol = Class.new(String)
|
|
21
|
+
BooleanSymbol = Class.new(Symbol)
|
|
22
|
+
CaseSymbol = Class.new(Symbol)
|
|
22
23
|
DateBeginString = Class.new(DateTime)
|
|
23
24
|
DateEndString = Class.new(DateTime)
|
|
24
|
-
DateRangeString = Class.new(Array)
|
|
25
|
-
DateRangeOptionalString = Class.new(Array)
|
|
26
25
|
DateIntervalString = Class.new(DateTime)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
DateRangeOptionalString = Class.new(Array)
|
|
27
|
+
DateRangeString = Class.new(Array)
|
|
28
|
+
ExportTemplate = Class.new(String)
|
|
29
|
+
MatchingSymbol = Class.new(Symbol)
|
|
30
30
|
OrderSymbol = Class.new(Symbol)
|
|
31
|
+
TagArray = Class.new(Array)
|
|
31
32
|
TagSortSymbol = Class.new(Symbol)
|
|
32
|
-
|
|
33
|
+
TemplateName = Class.new(String)
|
|
33
34
|
end
|
|
34
35
|
end
|
data/lib/doing/version.rb
CHANGED
data/lib/doing/wwid/display.rb
CHANGED
|
@@ -357,7 +357,10 @@ module Doing
|
|
|
357
357
|
opt ||= {}
|
|
358
358
|
out = nil
|
|
359
359
|
|
|
360
|
-
|
|
360
|
+
unless opt[:output] =~ Plugins.plugin_regex(type: :export)
|
|
361
|
+
raise InvalidPlugin.new('Unknown output format', opt[:output])
|
|
362
|
+
|
|
363
|
+
end
|
|
361
364
|
|
|
362
365
|
export_options = { page_title: title, is_single: is_single, options: opt }
|
|
363
366
|
|
data/lib/doing/wwid/filetools.rb
CHANGED
|
@@ -40,7 +40,7 @@ module Doing
|
|
|
40
40
|
if line =~ /^(\S[\S ]+):\s*(@[\w\-_.]+\s*)*$/
|
|
41
41
|
section = Regexp.last_match(1)
|
|
42
42
|
@content.add_section(Section.new(section, original: line), log: false)
|
|
43
|
-
elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (
|
|
43
|
+
elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*?)(?: +<([a-z0-9]{32})>)? *$/
|
|
44
44
|
if section.nil?
|
|
45
45
|
section = 'Uncategorized'
|
|
46
46
|
@content.add_section(Section.new(section, original: 'Uncategorized:'), log: false)
|
|
@@ -48,7 +48,8 @@ module Doing
|
|
|
48
48
|
|
|
49
49
|
date = Regexp.last_match(1).strip
|
|
50
50
|
title = Regexp.last_match(2).strip
|
|
51
|
-
|
|
51
|
+
id = Regexp.last_match(3) || nil
|
|
52
|
+
item = Item.new(date, title, section, [], id)
|
|
52
53
|
@content.push(item)
|
|
53
54
|
elsif @content.count.zero?
|
|
54
55
|
# if content[section].items.length - 1 == current
|
data/lib/doing/wwid/filter.rb
CHANGED
|
@@ -146,21 +146,24 @@ module Doing
|
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
if keep && opt[:time_filter][0] || opt[:time_filter][1]
|
|
149
|
+
opt[:time_filter].map! { |v| v =~ /(12 *am|midnight)/i ? '00:00' : v }
|
|
150
|
+
|
|
149
151
|
start_string = if opt[:time_filter][0].nil?
|
|
150
|
-
"#{item.date.strftime('%Y-%m-%d')}
|
|
152
|
+
"#{item.date.strftime('%Y-%m-%d')} 00:00"
|
|
151
153
|
else
|
|
152
154
|
"#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}"
|
|
153
155
|
end
|
|
154
156
|
start_time = start_string.chronify(guess: :begin)
|
|
155
157
|
|
|
156
158
|
end_string = if opt[:time_filter][1].nil?
|
|
157
|
-
"#{item.date.to_datetime.next_day.strftime('%Y-%m-%d')}
|
|
159
|
+
"#{item.date.to_datetime.next_day.strftime('%Y-%m-%d')} 00:00"
|
|
158
160
|
else
|
|
159
161
|
"#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}"
|
|
160
162
|
end
|
|
161
|
-
end_time = end_string.chronify(guess: :end)
|
|
163
|
+
end_time = end_string.chronify(guess: :end) || Time.now
|
|
162
164
|
|
|
163
165
|
in_time_range = item.date >= start_time && item.date <= end_time
|
|
166
|
+
|
|
164
167
|
keep = false unless in_time_range
|
|
165
168
|
keep = opt[:not] ? !keep : keep
|
|
166
169
|
end
|
data/lib/doing.rb
CHANGED
|
@@ -16,6 +16,7 @@ require 'json'
|
|
|
16
16
|
require 'logger'
|
|
17
17
|
require 'safe_yaml/load'
|
|
18
18
|
require 'fcntl'
|
|
19
|
+
require 'digest'
|
|
19
20
|
|
|
20
21
|
require 'chronic'
|
|
21
22
|
require 'tty-link'
|
|
@@ -85,6 +86,15 @@ module Doing
|
|
|
85
86
|
config.settings
|
|
86
87
|
end
|
|
87
88
|
|
|
89
|
+
def original_options
|
|
90
|
+
@original_options ||= {
|
|
91
|
+
date_begin: nil,
|
|
92
|
+
date_end: nil,
|
|
93
|
+
date_range: nil,
|
|
94
|
+
date_interval: nil
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
88
98
|
##
|
|
89
99
|
## Fetch a config setting using a dot-separated keypath
|
|
90
100
|
## or array of keys
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: doing
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.1.
|
|
4
|
+
version: 2.1.46
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brett Terpstra
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2022-03-
|
|
11
|
+
date: 2022-03-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: github-markup
|
|
@@ -677,6 +677,7 @@ files:
|
|
|
677
677
|
- lib/doing/plugins/import/cal_to_json.scpt
|
|
678
678
|
- lib/doing/plugins/import/calendar_import.rb
|
|
679
679
|
- lib/doing/plugins/import/doing_import.rb
|
|
680
|
+
- lib/doing/plugins/import/json_import.rb
|
|
680
681
|
- lib/doing/plugins/import/timing_import.rb
|
|
681
682
|
- lib/doing/prompt/choose.rb
|
|
682
683
|
- lib/doing/prompt/fzf.rb
|