reading 0.6.1 → 0.8.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.
- checksums.yaml +4 -4
- data/bin/reading +5 -5
- data/bin/readingfile +31 -0
- data/lib/reading/config.rb +96 -108
- data/lib/reading/errors.rb +10 -66
- data/lib/reading/filter.rb +95 -0
- data/lib/reading/item/time_length.rb +140 -0
- data/lib/reading/item/view.rb +121 -0
- data/lib/reading/item.rb +117 -0
- data/lib/reading/parsing/attributes/attribute.rb +26 -0
- data/lib/reading/parsing/attributes/author.rb +15 -0
- data/lib/reading/parsing/attributes/experiences/dates_and_head_transformer.rb +106 -0
- data/lib/reading/parsing/attributes/experiences/history_transformer.rb +452 -0
- data/lib/reading/parsing/attributes/experiences/spans_validator.rb +149 -0
- data/lib/reading/parsing/attributes/experiences.rb +27 -0
- data/lib/reading/parsing/attributes/genres.rb +16 -0
- data/lib/reading/parsing/attributes/notes.rb +22 -0
- data/lib/reading/parsing/attributes/rating.rb +17 -0
- data/lib/reading/parsing/attributes/shared.rb +62 -0
- data/lib/reading/parsing/attributes/title.rb +21 -0
- data/lib/reading/parsing/attributes/variants.rb +77 -0
- data/lib/reading/parsing/csv.rb +112 -0
- data/lib/reading/parsing/parser.rb +292 -0
- data/lib/reading/parsing/rows/column.rb +131 -0
- data/lib/reading/parsing/rows/comment.rb +26 -0
- data/lib/reading/parsing/rows/compact_planned.rb +30 -0
- data/lib/reading/parsing/rows/compact_planned_columns/head.rb +60 -0
- data/lib/reading/parsing/rows/regular.rb +33 -0
- data/lib/reading/parsing/rows/regular_columns/end_dates.rb +20 -0
- data/lib/reading/parsing/rows/regular_columns/genres.rb +20 -0
- data/lib/reading/parsing/rows/regular_columns/head.rb +45 -0
- data/lib/reading/parsing/rows/regular_columns/history.rb +143 -0
- data/lib/reading/parsing/rows/regular_columns/length.rb +35 -0
- data/lib/reading/parsing/rows/regular_columns/notes.rb +32 -0
- data/lib/reading/parsing/rows/regular_columns/rating.rb +15 -0
- data/lib/reading/parsing/rows/regular_columns/sources.rb +94 -0
- data/lib/reading/parsing/rows/regular_columns/start_dates.rb +35 -0
- data/lib/reading/parsing/transformer.rb +70 -0
- data/lib/reading/util/hash_compact_by_template.rb +1 -0
- data/lib/reading/util/hash_deep_merge.rb +1 -1
- data/lib/reading/util/hash_to_data.rb +30 -0
- data/lib/reading/util/numeric_to_i_if_whole.rb +12 -0
- data/lib/reading/util/string_truncate.rb +13 -4
- data/lib/reading/version.rb +1 -1
- data/lib/reading.rb +49 -0
- metadata +76 -42
- data/lib/reading/attribute/all_attributes.rb +0 -83
- data/lib/reading/attribute/attribute.rb +0 -25
- data/lib/reading/attribute/experiences/dates_validator.rb +0 -94
- data/lib/reading/attribute/experiences/experiences_attribute.rb +0 -74
- data/lib/reading/attribute/experiences/progress_subattribute.rb +0 -48
- data/lib/reading/attribute/experiences/spans_subattribute.rb +0 -82
- data/lib/reading/attribute/variants/extra_info_subattribute.rb +0 -44
- data/lib/reading/attribute/variants/length_subattribute.rb +0 -45
- data/lib/reading/attribute/variants/series_subattribute.rb +0 -57
- data/lib/reading/attribute/variants/sources_subattribute.rb +0 -78
- data/lib/reading/attribute/variants/variants_attribute.rb +0 -69
- data/lib/reading/csv.rb +0 -76
- data/lib/reading/line.rb +0 -23
- data/lib/reading/row/blank_row.rb +0 -23
- data/lib/reading/row/compact_planned_row.rb +0 -130
- data/lib/reading/row/regular_row.rb +0 -99
- data/lib/reading/row/row.rb +0 -88
- data/lib/reading/util/hash_to_struct.rb +0 -29
@@ -0,0 +1,121 @@
|
|
1
|
+
module Reading
|
2
|
+
class Item
|
3
|
+
# A view object for an Item, providing shortcuts to information that is handy
|
4
|
+
# to show (for example) on a webpage.
|
5
|
+
class View
|
6
|
+
using Util::HashArrayDeepFetch
|
7
|
+
|
8
|
+
attr_reader :name, :rating, :type_emoji, :genres, :date_or_status,
|
9
|
+
:isbn, :url, :experience_count, :groups, :blurb, :public_notes
|
10
|
+
|
11
|
+
# @param item [Item] the Item from which to extract view information.
|
12
|
+
# @param config [Hash] an entire config.
|
13
|
+
def initialize(item, config)
|
14
|
+
@genres = item.genres
|
15
|
+
@rating = extract_star_or_rating(item, config)
|
16
|
+
@isbn, @url, variant = extract_first_source_info(item, config)
|
17
|
+
@name = extract_name(item, variant, config)
|
18
|
+
@type_emoji = extract_type_emoji(variant&.format, config)
|
19
|
+
@date_or_status = extract_date_or_status(item)
|
20
|
+
@experience_count = item.experiences.count
|
21
|
+
@groups = item.experiences.map(&:group).compact
|
22
|
+
@blurb = item.notes.find(&:blurb?)&.content
|
23
|
+
@public_notes = item.notes.reject(&:private?).reject(&:blurb?).map(&:content)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
# A star (or nil if the item doesn't make the cut), or the number rating if
|
29
|
+
# star ratings are disabled.
|
30
|
+
# @param item [Item]
|
31
|
+
# @param config [Hash] an entire config.
|
32
|
+
# @return [String, Integer, Float]
|
33
|
+
def extract_star_or_rating(item, config)
|
34
|
+
minimum_rating = config.deep_fetch(:item, :view, :minimum_rating_for_star)
|
35
|
+
if minimum_rating
|
36
|
+
"⭐" if item.rating && item.rating >= minimum_rating
|
37
|
+
else
|
38
|
+
item.rating
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
# The ISBN/ASIN, URL, format, and extra info of the first variant that has
|
44
|
+
# an ISBN/ASIN or URL. If an ISBN/ASIN is found first, it is used to build a
|
45
|
+
# Goodreads URL. If a URL is found first, the ISBN/ASIN is nil.
|
46
|
+
# @param item [Item]
|
47
|
+
# @param config [Hash] an entire config.
|
48
|
+
# @return [Array(String, String, Symbol, Array<String>)]
|
49
|
+
def extract_first_source_info(item, config)
|
50
|
+
item.variants.map { |variant|
|
51
|
+
isbn = variant.isbn
|
52
|
+
if isbn
|
53
|
+
url = config.deep_fetch(:item, :view, :url_from_isbn).sub('%{isbn}', isbn)
|
54
|
+
else
|
55
|
+
url = variant.sources.map { |source| source.url }.compact.first
|
56
|
+
end
|
57
|
+
|
58
|
+
[isbn, url, variant]
|
59
|
+
}
|
60
|
+
.select { |isbn, url, _variant| isbn || url }
|
61
|
+
.first || [nil, nil, item.variants.first]
|
62
|
+
end
|
63
|
+
|
64
|
+
# The view name of the item.
|
65
|
+
# @param item [Item]
|
66
|
+
# @param variant [Data, nil] a variant from the Item.
|
67
|
+
# @param config [Hash] an entire config.
|
68
|
+
# @return [String]
|
69
|
+
def extract_name(item, variant, config)
|
70
|
+
author_and_title = "#{item.author + " – " if item.author}#{item.title}"
|
71
|
+
return author_and_title if variant.nil?
|
72
|
+
|
73
|
+
unless variant.series.empty? && variant.extra_info.empty?
|
74
|
+
pretty_series = variant.series.map { |series|
|
75
|
+
if series.volume
|
76
|
+
"#{series.name}, ##{series.volume}"
|
77
|
+
else
|
78
|
+
"in #{series.name}"
|
79
|
+
end
|
80
|
+
}
|
81
|
+
|
82
|
+
name_separator = config.deep_fetch(:item, :view, :name_separator)
|
83
|
+
series_and_extra_info = name_separator +
|
84
|
+
(pretty_series + variant.extra_info).join(name_separator)
|
85
|
+
end
|
86
|
+
|
87
|
+
author_and_title + (series_and_extra_info || "")
|
88
|
+
end
|
89
|
+
|
90
|
+
# The emoji for the type that represents (encompasses) a given format.
|
91
|
+
# @param format [Symbol, nil]
|
92
|
+
# @param config [Hash] an entire config.
|
93
|
+
# @return [String]
|
94
|
+
def extract_type_emoji(format, config)
|
95
|
+
types = config.deep_fetch(:item, :view, :types)
|
96
|
+
|
97
|
+
return types.deep_fetch(format, :emoji) if types.has_key?(format)
|
98
|
+
|
99
|
+
type = types
|
100
|
+
.find { |type, hash| hash[:from_formats]&.include?(format) }
|
101
|
+
&.first # key
|
102
|
+
|
103
|
+
types.deep_fetch(
|
104
|
+
type || config.deep_fetch(:item, :view, :default_type),
|
105
|
+
:emoji,
|
106
|
+
)
|
107
|
+
end
|
108
|
+
|
109
|
+
# The date (if done) or status, stringified.
|
110
|
+
# @param item [Item]
|
111
|
+
# @return [String]
|
112
|
+
def extract_date_or_status(item)
|
113
|
+
if item.done?
|
114
|
+
item.last_end_date&.strftime("%Y-%m-%d")
|
115
|
+
else
|
116
|
+
item.status.to_s.gsub('_', ' ')
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
data/lib/reading/item.rb
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
require "forwardable"
|
2
|
+
|
3
|
+
require_relative "item/view"
|
4
|
+
require_relative "config"
|
5
|
+
require_relative "util/hash_to_data"
|
6
|
+
require_relative "util/hash_array_deep_fetch"
|
7
|
+
|
8
|
+
module Reading
|
9
|
+
# A wrapper for an item parsed from a CSV reading log, providing convenience
|
10
|
+
# methods beyond what the parser's raw Hash output can provide.
|
11
|
+
class Item
|
12
|
+
using Util::HashToData
|
13
|
+
using Util::HashArrayDeepFetch
|
14
|
+
extend Forwardable
|
15
|
+
|
16
|
+
ATTRIBUTES = %i[rating author title genres variants experiences notes]
|
17
|
+
|
18
|
+
private attr_reader :attributes, :config
|
19
|
+
attr_reader :view, :status, :last_end_date
|
20
|
+
|
21
|
+
def_delegators :attributes, *ATTRIBUTES
|
22
|
+
|
23
|
+
# @param item_hash [Hash] a parsed item like the template in
|
24
|
+
# Config#default_config[:item][:template].
|
25
|
+
# @param config [Hash] an entire config.
|
26
|
+
# @param view [Class, nil, Boolean] the class that will be used to build the
|
27
|
+
# view object, or nil/false if no view object should be built. If you use
|
28
|
+
# a custom view class, the only requirement is that its #initialize take
|
29
|
+
# an Item and a full config as arguments.
|
30
|
+
def initialize(item_hash, config: Config.new.hash, view: Item::View)
|
31
|
+
item_hash = item_hash.dup
|
32
|
+
|
33
|
+
add_missing_attributes_with_filler_values(item_hash, config)
|
34
|
+
|
35
|
+
@attributes = item_hash.to_data
|
36
|
+
@config = config
|
37
|
+
|
38
|
+
@status, @last_end_date = get_status_and_last_end_date
|
39
|
+
@view = view.new(self, config) if view
|
40
|
+
end
|
41
|
+
|
42
|
+
# Whether this item is done.
|
43
|
+
# @return [Boolean]
|
44
|
+
def done?
|
45
|
+
status == :done
|
46
|
+
end
|
47
|
+
|
48
|
+
# Whether this item has a fixed length, such as a book or audiobook (as
|
49
|
+
# opposed to an ongoing podcast).
|
50
|
+
# @return [Boolean]
|
51
|
+
def definite_length?
|
52
|
+
attributes.variants.any? { |variant| !!variant.length }
|
53
|
+
end
|
54
|
+
|
55
|
+
# Equality to another Item.
|
56
|
+
# @other [Item]
|
57
|
+
# @return [Boolean]
|
58
|
+
def ==(other)
|
59
|
+
unless other.is_a?(Item)
|
60
|
+
raise ArgumentError, "An Item can be compared only with another Item."
|
61
|
+
end
|
62
|
+
|
63
|
+
attributes == other.send(:attributes)
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
# For each missing item attribute (key in config[:item][:template]) in
|
69
|
+
# item_hash, adds the key and a filler value.
|
70
|
+
# @param item_hash [Hash]
|
71
|
+
# @param config [Hash] an entire config.
|
72
|
+
def add_missing_attributes_with_filler_values(item_hash, config)
|
73
|
+
config.deep_fetch(:item, :template).each do |k, v|
|
74
|
+
next if item_hash.has_key?(k)
|
75
|
+
|
76
|
+
filler = v.is_a?(Array) ? [] : nil
|
77
|
+
item_hash[k] = filler
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Determines the status and the last end date. Note: for an item of indefinite
|
82
|
+
# length (e.g. podcast) there is a grace period during which the status
|
83
|
+
# remains :in_progress after the last activity. If that grace period is over,
|
84
|
+
# the status is :done. It's :planned if there are no spans with dates.
|
85
|
+
# @return [Array(Symbol, Date)]
|
86
|
+
def get_status_and_last_end_date
|
87
|
+
return [:planned, nil] if experiences.none? || experiences.flat_map(&:spans).none?
|
88
|
+
|
89
|
+
experiences_with_spans_with_dates = experiences
|
90
|
+
.select { |experience| experience.spans.any? { |span| span.dates } }
|
91
|
+
|
92
|
+
return [:planned, nil] unless experiences_with_spans_with_dates.any?
|
93
|
+
|
94
|
+
last_end_date = experiences_with_spans_with_dates
|
95
|
+
.last
|
96
|
+
.spans
|
97
|
+
.select { |span| span.dates }
|
98
|
+
.last
|
99
|
+
.dates
|
100
|
+
.end
|
101
|
+
|
102
|
+
return [:in_progress, nil] unless last_end_date
|
103
|
+
|
104
|
+
if definite_length?
|
105
|
+
[:done, last_end_date]
|
106
|
+
else
|
107
|
+
grace_period = config.deep_fetch(:item, :indefinite_in_progress_grace_period_days)
|
108
|
+
indefinite_in_progress_grace_period_is_over =
|
109
|
+
(Date.today - grace_period) > last_end_date
|
110
|
+
|
111
|
+
return [:done, last_end_date] if indefinite_in_progress_grace_period_is_over
|
112
|
+
|
113
|
+
[:in_progress, last_end_date]
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
# The base class for all the attribute in parsing/attributes, each of which
|
5
|
+
# extracts an attribute from a parsed row. Together they transform the
|
6
|
+
# parsed row (an intermediate hash) into item attributes, as in
|
7
|
+
# Config#default_config[:item][:template].
|
8
|
+
class Attribute
|
9
|
+
private attr_reader :config
|
10
|
+
|
11
|
+
# @param config [Hash] an entire config.
|
12
|
+
def initialize(config)
|
13
|
+
@config = config
|
14
|
+
end
|
15
|
+
|
16
|
+
# Extracts this attribute's value from a parsed row.
|
17
|
+
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
18
|
+
# @param head_index [Integer] current item's position in the Head column.
|
19
|
+
# @return [Object]
|
20
|
+
def transform_from_parsed(parsed_row, head_index)
|
21
|
+
raise NotImplementedError, "#{self.class} should have implemented #{__method__}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Reading
|
2
|
+
module Parsing
|
3
|
+
module Attributes
|
4
|
+
# Transformer for the :author item attribute.
|
5
|
+
class Author < Attribute
|
6
|
+
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
7
|
+
# @param head_index [Integer] current item's position in the Head column.
|
8
|
+
# @return [String]
|
9
|
+
def transform_from_parsed(parsed_row, head_index)
|
10
|
+
parsed_row[:head][head_index][:author]
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
require_relative "spans_validator"
|
2
|
+
|
3
|
+
module Reading
|
4
|
+
module Parsing
|
5
|
+
module Attributes
|
6
|
+
class Experiences < Attribute
|
7
|
+
# Experiences#transform_from_parsed delegates to this class when the
|
8
|
+
# History column is blank (i.e. when experiences should be extracted
|
9
|
+
# from the Start Dates, End Dates, and Head columns).
|
10
|
+
class DatesAndHeadTransformer
|
11
|
+
using Util::HashArrayDeepFetch
|
12
|
+
|
13
|
+
private attr_reader :config, :parsed_row, :head_index
|
14
|
+
|
15
|
+
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
16
|
+
# @param head_index [Integer] current item's position in the Head column.
|
17
|
+
# @param config [Hash] an entire config.
|
18
|
+
def initialize(parsed_row, head_index, config)
|
19
|
+
@config = config
|
20
|
+
@parsed_row = parsed_row
|
21
|
+
@head_index = head_index
|
22
|
+
end
|
23
|
+
|
24
|
+
# Extracts experiences from the parsed row.
|
25
|
+
# @return [Array<Hash>] an array of experiences; see
|
26
|
+
# Config#default_config[:item][:template][:experiences]
|
27
|
+
def transform
|
28
|
+
size = [parsed_row[:start_dates]&.count || 0, parsed_row[:end_dates]&.count || 0].max
|
29
|
+
# Pad start dates with {} and end dates with nil up to the size of
|
30
|
+
# the larger of the two.
|
31
|
+
start_dates = Array.new(size) { |i| parsed_row[:start_dates]&.dig(i) || {} }
|
32
|
+
end_dates = Array.new(size) { |i| parsed_row[:end_dates]&.dig(i) || nil }
|
33
|
+
|
34
|
+
start_end_dates = start_dates.zip(end_dates).presence || [[{}, nil]]
|
35
|
+
|
36
|
+
experiences_with_dates = start_end_dates.map { |start_entry, end_entry|
|
37
|
+
{
|
38
|
+
spans: spans(start_entry, end_entry),
|
39
|
+
group: start_entry[:group],
|
40
|
+
variant_index: (start_entry[:variant] || 1).to_i - 1,
|
41
|
+
}.map { |k, v| [k, v || template.fetch(k)] }.to_h
|
42
|
+
}.presence
|
43
|
+
|
44
|
+
if experiences_with_dates
|
45
|
+
# Raises an error if any sequence of dates does not make sense.
|
46
|
+
Experiences::SpansValidator.validate(experiences_with_dates, config)
|
47
|
+
end
|
48
|
+
|
49
|
+
experiences_with_dates
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
# A shortcut to the experience template.
|
55
|
+
# @return [Hash]
|
56
|
+
def template
|
57
|
+
config.deep_fetch(:item, :template, :experiences).first
|
58
|
+
end
|
59
|
+
|
60
|
+
# A shortcut to the span template.
|
61
|
+
# @return [Hash]
|
62
|
+
def span_template
|
63
|
+
config.deep_fetch(:item, :template, :experiences, 0, :spans).first
|
64
|
+
end
|
65
|
+
|
66
|
+
# The :spans sub-attribute for the given pair of date entries.
|
67
|
+
# single span in an array.
|
68
|
+
# @param start_entry [Hash] a parsed entry in the Start Dates column.
|
69
|
+
# @param end_entry [Hash] a parsed entry in the End Dates column.
|
70
|
+
# @return [Array(Hash)] an array containing a single span representing
|
71
|
+
# the start and end date.
|
72
|
+
def spans(start_entry, end_entry)
|
73
|
+
if !start_entry&.dig(:date) && !end_entry&.dig(:date)
|
74
|
+
dates = nil
|
75
|
+
else
|
76
|
+
dates = [start_entry, end_entry].map { |date_hash|
|
77
|
+
begin
|
78
|
+
Date.parse(date_hash[:date]) if date_hash&.dig(:date)
|
79
|
+
rescue Date::Error
|
80
|
+
raise InvalidDateError, "Unparsable date \"#{date_hash[:date]}\""
|
81
|
+
end
|
82
|
+
}
|
83
|
+
dates = dates[0]..dates[1]
|
84
|
+
end
|
85
|
+
|
86
|
+
variant_index = (start_entry[:variant] || 1).to_i - 1
|
87
|
+
length = Attributes::Shared.length(parsed_row[:sources]&.dig(variant_index)) ||
|
88
|
+
Attributes::Shared.length(parsed_row[:length])
|
89
|
+
|
90
|
+
[
|
91
|
+
{
|
92
|
+
dates: dates,
|
93
|
+
amount: (length if dates),
|
94
|
+
progress: Attributes::Shared.progress(start_entry) ||
|
95
|
+
Attributes::Shared.progress(parsed_row[:head][head_index]) ||
|
96
|
+
(1.0 if end_entry),
|
97
|
+
name: span_template.fetch(:name),
|
98
|
+
favorite?: span_template.fetch(:favorite?),
|
99
|
+
}.map { |k, v| [k, v || span_template.fetch(k)] }.to_h
|
100
|
+
]
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|