reading 0.6.0 → 0.7.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 +8 -8
- data/bin/readingfile +31 -0
- data/lib/reading/config.rb +115 -148
- data/lib/reading/errors.rb +11 -64
- data/lib/reading/item/time_length.rb +138 -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 +101 -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_struct.rb +1 -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 +18 -0
- metadata +58 -41
- 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 -67
- 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 -94
- data/lib/reading/row/row.rb +0 -88
@@ -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
|
@@ -0,0 +1,452 @@
|
|
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 not blank (i.e. when experiences should be extracted
|
9
|
+
# from History and not the Start Dates, End Dates, and Head columns).
|
10
|
+
class HistoryTransformer
|
11
|
+
using Util::HashArrayDeepFetch
|
12
|
+
using Util::NumericToIIfWhole
|
13
|
+
|
14
|
+
# Rational numbers are used here and in #distribute_amount_across_date_range
|
15
|
+
# below so as not to lose precision when dividing a small amount by
|
16
|
+
# many days, for example.
|
17
|
+
AVERAGE_DAYS_IN_A_MONTH = 30.437r
|
18
|
+
|
19
|
+
private attr_reader :parsed_row, :config
|
20
|
+
|
21
|
+
# @param parsed_row [Hash] a parsed row (the intermediate hash).
|
22
|
+
# @param config [Hash] an entire config
|
23
|
+
def initialize(parsed_row, config)
|
24
|
+
@parsed_row = parsed_row
|
25
|
+
@config = config
|
26
|
+
end
|
27
|
+
|
28
|
+
# Extracts experiences from the parsed row.
|
29
|
+
# @return [Array<Hash>] an array of experiences; see
|
30
|
+
# Config#default_config[:item_template][:experiences]
|
31
|
+
def transform
|
32
|
+
experiences = parsed_row[:history].map { |entries|
|
33
|
+
{
|
34
|
+
spans: spans_from_history_entries(entries),
|
35
|
+
group: entries.first[:group],
|
36
|
+
variant_index: (entries.first[:variant] || 1).to_i - 1,
|
37
|
+
}
|
38
|
+
}
|
39
|
+
|
40
|
+
# Raises an error if experiences overlap or are out of order.
|
41
|
+
Experiences::SpansValidator.validate(experiences, config, history_column: true)
|
42
|
+
|
43
|
+
experiences
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
# A shortcut to the span template.
|
49
|
+
# @return [Hash]
|
50
|
+
def span_template
|
51
|
+
@span_template ||= config.deep_fetch(:item_template, :experiences, 0, :spans).first
|
52
|
+
end
|
53
|
+
|
54
|
+
# The :spans sub-attribute for the given History column entries.
|
55
|
+
# @param entries [Array<Hash>] History entries for one experience.
|
56
|
+
# @return [Array<Hash>] an array of spans; see
|
57
|
+
# Config#default_config[:item_template][:experiences].first[:spans]
|
58
|
+
def spans_from_history_entries(entries)
|
59
|
+
daily_spans = {}
|
60
|
+
active = {
|
61
|
+
year: nil,
|
62
|
+
month: nil,
|
63
|
+
day: nil,
|
64
|
+
after_single_date: false,
|
65
|
+
open_range: false,
|
66
|
+
planned: false,
|
67
|
+
amount: nil,
|
68
|
+
last_start_year: nil,
|
69
|
+
last_start_month: nil,
|
70
|
+
}
|
71
|
+
|
72
|
+
entries.each do |entry|
|
73
|
+
if entry[:except_dates]
|
74
|
+
reject_exception_dates!(entry, daily_spans, active)
|
75
|
+
next
|
76
|
+
end
|
77
|
+
|
78
|
+
add_to_daily_spans!(entry, daily_spans, active)
|
79
|
+
end
|
80
|
+
|
81
|
+
spans = merge_daily_spans(daily_spans)
|
82
|
+
|
83
|
+
fix_open_ranges!(spans)
|
84
|
+
|
85
|
+
relativize_amounts_from_progress!(spans)
|
86
|
+
|
87
|
+
spans
|
88
|
+
end
|
89
|
+
|
90
|
+
# Removes the given entry's exception dates from daily_spans.
|
91
|
+
# @param entry [Hash] a History entry of exception dates ("not <list of dates>").
|
92
|
+
# @param daily_spans [Hash{Array(Date, String) => Hash}] one span per
|
93
|
+
# date-and-name combination.
|
94
|
+
# @param active [Hash] variables that persist across entries, such as
|
95
|
+
# amount and implied date.
|
96
|
+
def reject_exception_dates!(entry, daily_spans, active)
|
97
|
+
except_active = {
|
98
|
+
year: active[:last_start_year],
|
99
|
+
month: active[:last_start_month],
|
100
|
+
day: nil,
|
101
|
+
}
|
102
|
+
|
103
|
+
except_dates = entry[:except_dates].flat_map { |except_entry|
|
104
|
+
start_year = except_entry[:start_year]&.to_i
|
105
|
+
start_month = except_entry[:start_month]&.to_i
|
106
|
+
# (Start) day is required in an exception date.
|
107
|
+
except_active[:day] = except_entry[:start_day].to_i
|
108
|
+
|
109
|
+
# Increment year if month is earlier than previous regular entry's month.
|
110
|
+
if except_active[:month] && start_month && start_month < except_active[:month]
|
111
|
+
start_year ||= except_active[:year] + 1
|
112
|
+
end
|
113
|
+
|
114
|
+
except_active[:year] = start_year if start_year
|
115
|
+
except_active[:month] = start_month if start_month
|
116
|
+
|
117
|
+
date_range = date_range(except_entry, except_active)
|
118
|
+
|
119
|
+
date_range&.to_a ||
|
120
|
+
Date.new(except_active[:year], except_active[:month], except_active[:day])
|
121
|
+
}
|
122
|
+
|
123
|
+
daily_spans.reject! do |(date, _name), _span|
|
124
|
+
except_dates.include?(date)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Expands the given entry into one span per day, then adds them to daily_spans.
|
129
|
+
# @param entry [Hash] a regular History entry (not exception dates).
|
130
|
+
# @param daily_spans [Hash{Array(Date, String) => Hash}] one span per
|
131
|
+
# date-and-name combination.
|
132
|
+
# @param active [Hash] variables that persist across entries, such as
|
133
|
+
# amount and implied date.
|
134
|
+
def add_to_daily_spans!(entry, daily_spans, active)
|
135
|
+
start_year = entry[:start_year]&.to_i
|
136
|
+
start_month = entry[:start_month]&.to_i
|
137
|
+
start_day = entry[:start_day]&.to_i
|
138
|
+
|
139
|
+
# Increment year if start month is earlier than previous entry's month.
|
140
|
+
if active[:month] && start_month && start_month < active[:month]
|
141
|
+
start_year ||= active[:year] + 1
|
142
|
+
end
|
143
|
+
|
144
|
+
active[:year] = start_year if start_year
|
145
|
+
active[:last_start_year] = active[:year]
|
146
|
+
active[:month] = start_month if start_month
|
147
|
+
active[:last_start_month] = active[:month]
|
148
|
+
if start_day
|
149
|
+
active[:open_range] = false
|
150
|
+
active[:day] = start_day
|
151
|
+
end
|
152
|
+
|
153
|
+
unless active[:day] && active[:month] && active[:year]
|
154
|
+
raise InvalidHistoryError, "Missing or incomplete first date"
|
155
|
+
end
|
156
|
+
|
157
|
+
duplicate_open_range = !start_day && active[:open_range]
|
158
|
+
date_range = date_range(entry, active, duplicate_open_range:)
|
159
|
+
|
160
|
+
# A startless date range (i.e. with an implied start date) appearing
|
161
|
+
# immediately after a single date has its start date bumped forward
|
162
|
+
# by one, so that its start date is not the same as the single date.
|
163
|
+
if date_range && !start_day && active[:after_single_date]
|
164
|
+
date_range = (date_range.begin + 1)..date_range.end
|
165
|
+
end
|
166
|
+
active[:after_single_date] = !date_range
|
167
|
+
|
168
|
+
amount =
|
169
|
+
Attributes::Shared.length(entry, key_name: :amount, ignore_repetitions: true) ||
|
170
|
+
Attributes::Shared.length(parsed_row[:length], episodic: true)
|
171
|
+
active[:amount] = amount if amount
|
172
|
+
|
173
|
+
progress = Attributes::Shared.progress(entry)
|
174
|
+
|
175
|
+
# If the entry has no amount and the item has no episodic length,
|
176
|
+
# then use progress as amount instead. The typical scenario for this
|
177
|
+
# is when tracking fixed-length items such as books. See
|
178
|
+
# https://github.com/fpsvogel/reading/blob/main/doc/csv-format.md#history-pages-and-stopping-points-books
|
179
|
+
if !amount && progress
|
180
|
+
if progress.is_a? Float
|
181
|
+
total_length = Attributes::Shared.length(parsed_row[:length])
|
182
|
+
amount = total_length * progress
|
183
|
+
else
|
184
|
+
amount = progress
|
185
|
+
end
|
186
|
+
amount_from_progress = true
|
187
|
+
end
|
188
|
+
|
189
|
+
repetitions = entry[:repetitions]&.to_i || 1
|
190
|
+
frequency = entry[:frequency]
|
191
|
+
|
192
|
+
amounts_by_date = distribute_amount_across_date_range(
|
193
|
+
date_range || Date.new(active[:year], active[:month], active[:day]),
|
194
|
+
amount || active[:amount],
|
195
|
+
repetitions,
|
196
|
+
frequency,
|
197
|
+
)
|
198
|
+
|
199
|
+
in_open_range = active[:open_range] || duplicate_open_range
|
200
|
+
|
201
|
+
daily_spans_from_entry = amounts_by_date.map { |date, daily_amount|
|
202
|
+
span_without_dates = {
|
203
|
+
dates: nil,
|
204
|
+
amount: daily_amount || span_template[:amount],
|
205
|
+
progress: (progress unless amount_from_progress) || span_template[:progress],
|
206
|
+
name: entry[:name] || span_template[:name],
|
207
|
+
favorite?: !!entry[:favorite] || span_template[:favorite?],
|
208
|
+
# Temporary keys (not in the final item data) for marking
|
209
|
+
# spans to ...
|
210
|
+
# ... be distributed evenly across an open date range.
|
211
|
+
in_open_range: in_open_range,
|
212
|
+
# ... have their amounts adjusted to be relative to previous progress.
|
213
|
+
amount_from_progress: amount_from_progress,
|
214
|
+
}
|
215
|
+
|
216
|
+
if entry[:planned] || (active[:planned] && !start_day)
|
217
|
+
date = nil
|
218
|
+
active[:planned] = true
|
219
|
+
end
|
220
|
+
|
221
|
+
key = [date, span_without_dates[:name]]
|
222
|
+
|
223
|
+
# When any entry in an open range lacks a name, add a random
|
224
|
+
# number to the key so that it does not overwrite a different
|
225
|
+
# entry in the open range that also lacks a name.
|
226
|
+
if in_open_range && !entry[:name]
|
227
|
+
key << rand
|
228
|
+
end
|
229
|
+
|
230
|
+
[key, span_without_dates]
|
231
|
+
}.to_h
|
232
|
+
|
233
|
+
daily_spans.merge!(daily_spans_from_entry)
|
234
|
+
end
|
235
|
+
|
236
|
+
# Changes daily spans into normal spans, with chunks of daily spans
|
237
|
+
# with contiguous dates compressed into single spans with date ranges.
|
238
|
+
# @param daily_spans [Hash{Array(Date, String) => Hash}] one span per
|
239
|
+
# date-and-name combination.
|
240
|
+
# @return [Array<Hash>]
|
241
|
+
def merge_daily_spans(daily_spans)
|
242
|
+
daily_spans
|
243
|
+
.chunk_while { |((date_a, _name_a), span_a), ((date_b, _name_b), span_b)|
|
244
|
+
date_b && date_a && # in case of planned entry
|
245
|
+
date_b == date_a.next_day &&
|
246
|
+
span_b == span_a
|
247
|
+
}.map { |chunked_spans|
|
248
|
+
# or chunked_spans.to_h.values.first.first
|
249
|
+
span = chunked_spans.first[1]
|
250
|
+
first_daily_date = chunked_spans.first[0][0]
|
251
|
+
last_daily_date = chunked_spans.last[0][0]
|
252
|
+
|
253
|
+
unless chunked_spans.first[0][0].nil? # in case of planned entry
|
254
|
+
# or chunked_spans.to_h.keys.first[0]..chunked_spans.to_h.keys.last[0]
|
255
|
+
span[:dates] = first_daily_date..last_daily_date
|
256
|
+
end
|
257
|
+
|
258
|
+
span[:amount] *= chunked_spans.count
|
259
|
+
|
260
|
+
span
|
261
|
+
}.reject { |span|
|
262
|
+
span[:amount].zero?
|
263
|
+
}
|
264
|
+
end
|
265
|
+
|
266
|
+
# Builds the date range for the given entry, if any. Also may update
|
267
|
+
# the dates in `active` according to the entry's end date.
|
268
|
+
# @param entry [Hash] a History entry.
|
269
|
+
# @param active [Hash]variables that persist across entries, such as
|
270
|
+
# amount and implied date.
|
271
|
+
# @param duplicate_open_range [Boolean] whether this entry is a
|
272
|
+
# continuation of an open range, in which case it doesn't need to
|
273
|
+
# be a range explicitly.
|
274
|
+
# @return [Range<Date>]
|
275
|
+
def date_range(entry, active, duplicate_open_range: false)
|
276
|
+
return nil unless entry[:range] || duplicate_open_range
|
277
|
+
|
278
|
+
if entry[:end_day]
|
279
|
+
active[:open_range] = false
|
280
|
+
|
281
|
+
end_year = entry[:end_year]&.to_i
|
282
|
+
end_month = entry[:end_month]&.to_i
|
283
|
+
end_day = entry[:end_day].to_i
|
284
|
+
|
285
|
+
# Increment year if end month is earlier than start month.
|
286
|
+
if active[:month] && end_month && end_month < active[:month]
|
287
|
+
end_year ||= active[:year] + 1
|
288
|
+
end
|
289
|
+
|
290
|
+
date_range = Date.new(active[:year], active[:month], active[:day])..
|
291
|
+
Date.new(end_year || active[:year], end_month || active[:month], end_day)
|
292
|
+
|
293
|
+
active[:day] = end_day + 1
|
294
|
+
active[:month] = end_month if end_month
|
295
|
+
active[:year] = end_year if end_year
|
296
|
+
else # either starting or continuing (duplicating) an open range
|
297
|
+
active[:open_range] ||= true
|
298
|
+
date_range = Date.new(active[:year], active[:month], active[:day])..Date.today
|
299
|
+
end
|
300
|
+
|
301
|
+
date_range
|
302
|
+
end
|
303
|
+
|
304
|
+
# Distributes an amount across the given date(s).
|
305
|
+
# @param date_or_range [Date, Range<Date>] the date or range across
|
306
|
+
# which the amount will be split up.
|
307
|
+
# @param amount [Float, Integer, Reading::Item::TimeLength] amount in
|
308
|
+
# pages or time.
|
309
|
+
# @param repetitions [Integer] e.g. "x4" in a History entry.
|
310
|
+
# @param frequency [Integer] e.g. "/week" in a History entry.
|
311
|
+
# @return [Hash{Date => Float, Integer, Reading::Item::TimeLength}]
|
312
|
+
def distribute_amount_across_date_range(date_or_range, amount, repetitions, frequency)
|
313
|
+
unless amount
|
314
|
+
raise InvalidHistoryError, "Missing length or amount"
|
315
|
+
end
|
316
|
+
|
317
|
+
if date_or_range.is_a? Date
|
318
|
+
if frequency
|
319
|
+
# e.g. " -- x1/week"
|
320
|
+
date_range = date_or_range..Date.today
|
321
|
+
else
|
322
|
+
date_range = date_or_range..date_or_range
|
323
|
+
end
|
324
|
+
else
|
325
|
+
date_range = date_or_range
|
326
|
+
end
|
327
|
+
|
328
|
+
total_amount = amount * repetitions
|
329
|
+
|
330
|
+
case frequency
|
331
|
+
when "month"
|
332
|
+
months = date_range.count / AVERAGE_DAYS_IN_A_MONTH
|
333
|
+
total_amount *= months
|
334
|
+
when "week"
|
335
|
+
weeks = date_range.count / 7r
|
336
|
+
total_amount *= weeks
|
337
|
+
when "day"
|
338
|
+
days = date_range.count
|
339
|
+
total_amount *= days
|
340
|
+
end
|
341
|
+
|
342
|
+
days ||= date_range.count
|
343
|
+
if days.zero?
|
344
|
+
raise InvalidHistoryError,
|
345
|
+
"Backward date range in the History column: #{date_range}"
|
346
|
+
end
|
347
|
+
|
348
|
+
amount_per_date = (total_amount / days.to_r).to_i_if_whole if total_amount
|
349
|
+
|
350
|
+
amounts_by_date = date_range.to_a.map { |date|
|
351
|
+
[date, amount_per_date]
|
352
|
+
}.to_h
|
353
|
+
|
354
|
+
amounts_by_date
|
355
|
+
end
|
356
|
+
|
357
|
+
# Set each open date range's last end date (wherever it's today, i.e.
|
358
|
+
# it wasn't defined) to the day before the next entry's start date.
|
359
|
+
# At the same time, distribute each open range's spans evenly.
|
360
|
+
# Lastly, remove the :in_open_range key from spans.
|
361
|
+
# @param spans [Array<Hash>] spans after being merged from daily_spans.
|
362
|
+
# @return [Array<Hash>]
|
363
|
+
def fix_open_ranges!(spans)
|
364
|
+
chunked_by_open_range = spans.chunk_while { |a, b|
|
365
|
+
a[:dates] && b[:dates] && # in case of planned entry
|
366
|
+
a[:dates].begin == b[:dates].begin &&
|
367
|
+
a[:in_open_range] == b[:in_open_range]
|
368
|
+
}
|
369
|
+
|
370
|
+
next_chunk_start_date = nil
|
371
|
+
chunked_by_open_range
|
372
|
+
.reverse_each { |chunk|
|
373
|
+
unless chunk.first[:in_open_range] && chunk.any? { _1[:dates].end == Date.today }
|
374
|
+
# safe nav. in case of planned entry
|
375
|
+
next_chunk_start_date = chunk.first[:dates]&.begin
|
376
|
+
next
|
377
|
+
end
|
378
|
+
|
379
|
+
# Set last end date.
|
380
|
+
if chunk.last[:dates].end == Date.today && next_chunk_start_date
|
381
|
+
chunk.last[:dates] = chunk.last[:dates].begin..next_chunk_start_date.prev_day
|
382
|
+
end
|
383
|
+
next_chunk_start_date = chunk.first[:dates].begin
|
384
|
+
|
385
|
+
# Distribute spans across the open date range.
|
386
|
+
total_amount = chunk.sum { |c| c[:amount] }
|
387
|
+
dates = chunk.last[:dates]
|
388
|
+
amount_per_day = total_amount / dates.count.to_f
|
389
|
+
|
390
|
+
# Save the last end date to restore it after it becomes nil below.
|
391
|
+
last_end_date = chunk.last[:dates].end
|
392
|
+
|
393
|
+
span = nil
|
394
|
+
amount_acc = 0
|
395
|
+
span_needing_end = nil
|
396
|
+
dates.each do |date|
|
397
|
+
if span_needing_end && amount_acc < amount_per_day
|
398
|
+
span_needing_end[:dates] = span_needing_end[:dates].begin..date
|
399
|
+
span_needing_end = nil
|
400
|
+
end
|
401
|
+
|
402
|
+
while amount_acc < amount_per_day
|
403
|
+
break if chunk.empty?
|
404
|
+
span = chunk.shift
|
405
|
+
amount_acc += span[:amount]
|
406
|
+
|
407
|
+
if amount_acc < amount_per_day
|
408
|
+
end_date = date
|
409
|
+
else
|
410
|
+
end_date = nil
|
411
|
+
span_needing_end = span
|
412
|
+
end
|
413
|
+
|
414
|
+
span[:dates] = date..end_date
|
415
|
+
end
|
416
|
+
|
417
|
+
amount_acc -= amount_per_day
|
418
|
+
end
|
419
|
+
|
420
|
+
span[:dates] = span[:dates].begin..last_end_date
|
421
|
+
}
|
422
|
+
|
423
|
+
spans.each do |span|
|
424
|
+
span.delete(:in_open_range)
|
425
|
+
end
|
426
|
+
end
|
427
|
+
|
428
|
+
# Changes amounts taken from progress, from absolute to relative,
|
429
|
+
# e.g. at p20 on 2/11 then at p30 on 2/12 (absolute) to
|
430
|
+
# 20 pages on 2/11 then 10 pages on 2/12 (relative). Also, remove the
|
431
|
+
# :amount_from_progress key key from spans.
|
432
|
+
# @param spans [Array<Hash>] spans after being merged from daily_spans.
|
433
|
+
# @return [Array<Hash>]
|
434
|
+
def relativize_amounts_from_progress!(spans)
|
435
|
+
amount_acc = 0
|
436
|
+
spans.each do |span|
|
437
|
+
if span[:amount_from_progress]
|
438
|
+
span[:amount] -= amount_acc
|
439
|
+
end
|
440
|
+
|
441
|
+
amount_acc += span[:amount]
|
442
|
+
end
|
443
|
+
|
444
|
+
spans.each do |span|
|
445
|
+
span.delete(:amount_from_progress)
|
446
|
+
end
|
447
|
+
end
|
448
|
+
end
|
449
|
+
end
|
450
|
+
end
|
451
|
+
end
|
452
|
+
end
|