thefox-wallet 0.17.1 → 0.18.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.
data/lib/wallet/entry.rb CHANGED
@@ -3,141 +3,141 @@ require 'uuid'
3
3
  require 'date'
4
4
 
5
5
  module TheFox
6
- module Wallet
7
-
8
- class Entry
9
-
10
- attr_reader :id
11
- attr_reader :title
12
- attr_reader :date
13
- attr_reader :revenue
14
- attr_reader :expense
15
- attr_reader :balance
16
- attr_reader :category
17
- attr_reader :comment
18
-
19
- def initialize(id = nil, title = nil, date = nil, revenue = nil, expense = nil, category = nil, comment = nil)
20
- if !id
21
- uuid = UUID.new
22
- id = uuid.generate
23
- end
24
- date ||= Date.today
25
- revenue ||= 0.0
26
- expense ||= 0.0
27
- category ||= 'default'
28
-
29
- self.id = id
30
- self.title = title
31
- self.date = date
32
-
33
- @revenue = 0.0
34
- @expense = 0.0
35
- @balance = 0.0
36
-
37
- revenue_t = revenue.to_f
38
- expense_t = expense.to_f
39
- if revenue_t < 0 && expense_t == 0
40
- # Revenue is minus and no expense was provided.
41
- self.revenue = 0.0
42
- self.expense = revenue_t
43
- else
44
- self.revenue = revenue_t
45
- self.expense = expense_t
46
- end
47
-
48
- self.category = category
49
- self.comment = comment
50
- end
51
-
52
- def id=(id)
53
- @id = id
54
- end
55
-
56
- def title=(title)
57
- @title = title.to_s
58
- end
59
-
60
- def date=(date)
61
- case date
62
- when String
63
- # String
64
- @date = Date.parse(date)
65
- when Fixnum
66
- # Fixnum
67
- @date = Time.at(date).to_date
68
- when Date
69
- # Date
70
- @date = date
71
- else
72
- raise ArgumentError, "Wrong class: #{date.class}"
73
- end
74
- end
75
-
76
- def revenue=(revenue)
77
- revenue_t = revenue.to_f
78
-
79
- if revenue_t < 0
80
- raise RangeError, "revenue (#{revenue_t}) cannot be < 0. use expense instead!"
81
- end
82
-
83
- @revenue = revenue_t
84
- calc_balance
85
- end
86
-
87
- def expense=(expense)
88
- expense_t = expense.to_f
89
-
90
- if expense_t > 0
91
- raise RangeError, "expense (#{expense_t}) cannot be > 0. use revenue instead!"
92
- end
93
-
94
- @expense = expense_t
95
- calc_balance
96
- end
97
-
98
- def category=(category)
99
- @category = category.nil? ? 'default' : category.to_s
100
- end
101
-
102
- def comment=(comment)
103
- @comment = comment.nil? ? '' : comment.to_s
104
- end
105
-
106
- # Convert Entry to a Hash.
107
- def to_h
108
- {
109
- 'id' => @id,
110
- 'title' => @title,
111
- 'date' => @date.to_s,
112
- 'revenue' => @revenue,
113
- 'expense' => @expense,
114
- 'balance' => @balance,
115
- 'category' => @category,
116
- 'comment' => @comment,
117
- }
118
- end
119
-
120
- # Restore a Entry from a Hash.
121
- def self.from_h(h)
122
- id = h['id']
123
- title = h['title']
124
- date = h['date']
125
- revenue = h['revenue']
126
- expense = h['expense']
127
- # balance = h['balance']
128
- category = h['category']
129
- comment = h['comment']
130
-
131
- self.new(id, title, date, revenue, expense, category, comment)
132
- end
133
-
134
- private
135
-
136
- def calc_balance
137
- @balance = (@revenue.round(NUMBER_ROUND) + @expense.round(NUMBER_ROUND)).to_f.round(NUMBER_ROUND)
138
- end
139
-
140
- end
141
-
142
- end
6
+ module Wallet
7
+
8
+ class Entry
9
+
10
+ attr_reader :id
11
+ attr_reader :title
12
+ attr_reader :date
13
+ attr_reader :revenue
14
+ attr_reader :expense
15
+ attr_reader :balance
16
+ attr_reader :category
17
+ attr_reader :comment
18
+
19
+ def initialize(id = nil, title = nil, date = nil, revenue = nil, expense = nil, category = nil, comment = nil)
20
+ if !id
21
+ uuid = UUID.new
22
+ id = uuid.generate
23
+ end
24
+ date ||= Date.today
25
+ revenue ||= 0.0
26
+ expense ||= 0.0
27
+ category ||= 'default'
28
+
29
+ self.id = id
30
+ self.title = title
31
+ self.date = date
32
+
33
+ @revenue = 0.0
34
+ @expense = 0.0
35
+ @balance = 0.0
36
+
37
+ revenue_t = revenue.to_f
38
+ expense_t = expense.to_f
39
+ if revenue_t < 0 && expense_t == 0
40
+ # Revenue is minus and no expense was provided.
41
+ self.revenue = 0.0
42
+ self.expense = revenue_t
43
+ else
44
+ self.revenue = revenue_t
45
+ self.expense = expense_t
46
+ end
47
+
48
+ self.category = category
49
+ self.comment = comment
50
+ end
51
+
52
+ def id=(id)
53
+ @id = id
54
+ end
55
+
56
+ def title=(title)
57
+ @title = title.to_s
58
+ end
59
+
60
+ def date=(date)
61
+ case date
62
+ when String
63
+ # String
64
+ @date = Date.parse(date)
65
+ when Fixnum
66
+ # Fixnum
67
+ @date = Time.at(date).to_date
68
+ when Date
69
+ # Date
70
+ @date = date
71
+ else
72
+ raise ArgumentError, "Wrong class: #{date.class}"
73
+ end
74
+ end
75
+
76
+ def revenue=(revenue)
77
+ revenue_t = revenue.to_f
78
+
79
+ if revenue_t < 0
80
+ raise RangeError, "revenue (#{revenue_t}) cannot be < 0. use expense instead!"
81
+ end
82
+
83
+ @revenue = revenue_t
84
+ calc_balance
85
+ end
86
+
87
+ def expense=(expense)
88
+ expense_t = expense.to_f
89
+
90
+ if expense_t > 0
91
+ raise RangeError, "expense (#{expense_t}) cannot be > 0. use revenue instead!"
92
+ end
93
+
94
+ @expense = expense_t
95
+ calc_balance
96
+ end
97
+
98
+ def category=(category)
99
+ @category = category.nil? ? 'default' : category.to_s
100
+ end
101
+
102
+ def comment=(comment)
103
+ @comment = comment.nil? ? '' : comment.to_s
104
+ end
105
+
106
+ # Convert Entry to a Hash.
107
+ def to_h
108
+ {
109
+ 'id' => @id,
110
+ 'title' => @title,
111
+ 'date' => @date.to_s,
112
+ 'revenue' => @revenue,
113
+ 'expense' => @expense,
114
+ 'balance' => @balance,
115
+ 'category' => @category,
116
+ 'comment' => @comment,
117
+ }
118
+ end
119
+
120
+ # Restore a Entry from a Hash.
121
+ def self.from_h(h)
122
+ id = h['id']
123
+ title = h['title']
124
+ date = h['date']
125
+ revenue = h['revenue']
126
+ expense = h['expense']
127
+ # balance = h['balance']
128
+ category = h['category']
129
+ comment = h['comment']
130
+
131
+ self.new(id, title, date, revenue, expense, category, comment)
132
+ end
133
+
134
+ private
135
+
136
+ def calc_balance
137
+ @balance = (@revenue.round(NUMBER_ROUND) + @expense.round(NUMBER_ROUND)).to_f.round(NUMBER_ROUND)
138
+ end
139
+
140
+ end
141
+
142
+ end
143
143
  end
@@ -1,12 +1,12 @@
1
1
 
2
2
  module TheFox
3
- module Wallet
4
- NAME = 'Wallet'
5
- VERSION = '0.17.1'
6
- DATE = '2017-08-13'
7
- HOMEPAGE = 'https://github.com/TheFox/wallet'
8
-
9
- NUMBER_FORMAT = '%.2f'
10
- NUMBER_ROUND = 5
11
- end
3
+ module Wallet
4
+ NAME = 'Wallet'
5
+ VERSION = '0.18.0'
6
+ DATE = '2018-08-13'
7
+ HOMEPAGE = 'https://github.com/TheFox/wallet'
8
+
9
+ NUMBER_FORMAT = '%.2f'
10
+ NUMBER_ROUND = 5
11
+ end
12
12
  end
data/lib/wallet/wallet.rb CHANGED
@@ -12,1055 +12,1055 @@ require 'fileutils'
12
12
  require 'ostruct'
13
13
 
14
14
  module TheFox
15
- module Wallet
16
-
17
- class Wallet
18
-
19
- attr_writer :logger
20
- attr_reader :dir_path
21
-
22
- def initialize(dir_path = nil)
23
- @exit = false
24
- @logger = Logger.new(IO::NULL)
25
- @dir_path = dir_path || Pathname.new('wallet').expand_path
26
- @dir_path_basename = @dir_path.basename
27
- @dir_path_basename_s = @dir_path_basename.to_s
28
- @data_path = Pathname.new('data').expand_path(@dir_path)
29
- @tmp_path = Pathname.new('tmp').expand_path(@dir_path)
30
-
31
- # Internal path. Not the same as provided by --path option.
32
- @html_path = Pathname.new('html').expand_path(@dir_path)
33
-
34
- @has_transaction = false
35
- @transaction_files = Hash.new
36
-
37
- @entries_by_ids = nil
38
- @entries_index_file_path = Pathname.new('index.yml').expand_path(@data_path)
39
- @entries_index = Array.new
40
- @entries_index_is_loaded = false
41
-
42
- Signal.trap('SIGINT') do
43
- #@logger.warn('received SIGINT. break ...')
44
- @exit = true
45
- end
46
- end
47
-
48
- # Add an Entry to the wallet.
49
- # Used by Add Command.
50
- def add(entry, is_unique = false)
51
- if !entry.is_a?(Entry)
52
- raise ArgumentError, 'variable must be a Entry instance'
53
- end
54
-
55
- if is_unique && entry_exist?(entry)
56
- return false
57
- end
58
-
59
- create_dirs
60
-
61
- date = entry.date
62
- date_s = date.to_s
63
- dbfile_basename_s = "month_#{date.strftime('%Y_%m')}.yml"
64
- dbfile_basename_p = Pathname.new(dbfile_basename_s)
65
- dbfile_path = dbfile_basename_p.expand_path(@data_path)
66
- tmpfile_path = Pathname.new("#{dbfile_path}.tmp")
67
- file = {
68
- 'meta' => {
69
- 'version' => 1,
70
- 'created_at' => DateTime.now.to_s,
71
- 'updated_at' => DateTime.now.to_s,
72
- },
73
- 'days' => Hash.new,
74
- }
75
-
76
- @entries_index << entry.id
77
-
78
- if @has_transaction
79
- if @transaction_files[dbfile_basename_s]
80
- file = @transaction_files[dbfile_basename_s]['file']
81
- else
82
- if dbfile_path.exist?
83
- file = YAML.load_file(dbfile_path)
84
- file['meta']['updated_at'] = DateTime.now.to_s
85
- end
86
-
87
- @transaction_files[dbfile_basename_s] = {
88
- 'basename' => dbfile_basename_s,
89
- 'path' => dbfile_path.to_s,
90
- 'tmp_path' => tmpfile_path.to_s,
91
- 'file' => file,
92
- }
93
- end
94
-
95
- if file['days'].is_a?(Array)
96
- file['days'] = Hash.new
97
- end
98
- if !file['days'].has_key?(date_s)
99
- file['days'][date_s] = Array.new
100
- end
101
-
102
- file['days'][date_s].push(entry.to_h)
103
-
104
- @transaction_files[dbfile_basename_s]['file'] = file
105
- else
106
- if dbfile_path.exist?
107
- file = YAML.load_file(dbfile_path)
108
- file['meta']['updated_at'] = DateTime.now.to_s
109
- end
110
-
111
- if file['days'].is_a?(Array)
112
- file['days'] = Hash.new
113
- end
114
- if !file['days'].has_key?(date_s)
115
- file['days'][date_s] = Array.new
116
- end
117
-
118
- file['days'][date_s].push(entry.to_h)
119
-
120
- store = YAML::Store.new(tmpfile_path)
121
- store.transaction do
122
- store['meta'] = file['meta']
123
- store['days'] = file['days']
124
- end
125
-
126
- save_entries_index_file
127
-
128
- if tmpfile_path.exist?
129
- tmpfile_path.rename(dbfile_path)
130
- end
131
- end
132
-
133
- if @entries_by_ids.nil?
134
- @entries_by_ids = Hash.new
135
- end
136
- @entries_by_ids[entry.id] = entry
137
-
138
- true
139
- end
140
-
141
- def transaction_start
142
- @has_transaction = true
143
- @transaction_files = Hash.new
144
-
145
- create_dirs
146
- end
147
-
148
- def transaction_end
149
- catch(:done) do
150
- @transaction_files.each do |tr_file_key, tr_file_data|
151
- if @exit
152
- throw :done
153
- end
154
-
155
- store = YAML::Store.new(tr_file_data['tmp_path'])
156
- store.transaction do
157
- store['meta'] = tr_file_data['file']['meta']
158
- store['days'] = tr_file_data['file']['days']
159
- end
160
- @transaction_files.delete(tr_file_key)
161
-
162
- if File.exist?(tr_file_data['tmp_path'])
163
- File.rename(tr_file_data['tmp_path'], tr_file_data['path'])
164
- end
165
- end
166
- end
167
-
168
- save_entries_index_file
169
-
170
- @has_transaction = false
171
- @transaction_files = Hash.new
172
- end
173
-
174
- # Sums a year, a month, a day or a certain category.
175
- def sum(year = nil, month = nil, day = nil, category = nil)
176
- year_s = year.to_i.to_s
177
- month_f = '%02d' % month.to_i
178
- day_f = '%02d' % day.to_i
179
-
180
- revenue = 0.0
181
- expense = 0.0
182
- balance = 0.0
183
-
184
- glob = File.expand_path('month_', @data_path)
185
- if year == nil && month == nil
186
- glob << '*.yml'
187
- elsif year && month == nil
188
- glob << "#{year_s}_*.yml"
189
- elsif year && month
190
- glob << "#{year_s}_#{month_f}.yml"
191
- end
192
-
193
- Dir[glob].each do |file_path|
194
- data = YAML.load_file(file_path)
195
-
196
- if day
197
- day_key = "#{year_s}-#{month_f}-#{day_f}"
198
- if data['days'].has_key?(day_key)
199
- day_sum = calc_day(data['days'][day_key], category)
200
- revenue += day_sum[:revenue]
201
- expense += day_sum[:expense]
202
- balance += day_sum[:balance]
203
- end
204
- else
205
- data['days'].each do |day_name, day_items|
206
- day_sum = calc_day(day_items, category)
207
- revenue += day_sum[:revenue]
208
- expense += day_sum[:expense]
209
- balance += day_sum[:balance]
210
- end
211
- end
212
- end
213
-
214
- revenue = revenue.to_f.round(NUMBER_ROUND)
215
- expense = expense.to_f.round(NUMBER_ROUND)
216
- balance = (revenue + expense).round(NUMBER_ROUND)
217
-
218
- diff = revenue + expense - balance
219
- if diff != 0
220
- raise RuntimeError, "diff between revenue and expense to balance is #{diff}"
221
- end
222
-
223
- {
224
- :revenue => revenue,
225
- :expense => expense,
226
- :balance => balance,
227
- }
228
- end
229
-
230
- def sum_category(category)
231
- sum(nil, nil, nil, category)
232
- end
233
-
234
- # Get all entries.
235
- # Used by List Command.
236
- def entries(begin_date, category = nil)
237
- begin_year, begin_month, begin_day = begin_date.split('-') #.map{ |n| n.to_i }
238
-
239
- if begin_year.length > 4
240
- # When begin_date got not splitted by '-'.
241
- # YYYYM[MD[D]]
242
-
243
- begin_month = begin_year[4..-1]
244
- begin_year = begin_year[0..3]
245
-
246
- if begin_month.length > 2
247
- # YYYYMMD[D]
248
-
249
- begin_day = begin_month[2..-1]
250
- begin_month = begin_month[0..1]
251
- end
252
- end
253
-
254
- begin_year_s = begin_year.to_i.to_s
255
- begin_month_f = '%02d' % begin_month.to_i
256
- begin_day_f = '%02d' % begin_day.to_i
257
-
258
- glob = File.expand_path('month_', @data_path)
259
- if begin_year == nil && begin_month == nil
260
- glob << '*.yml'
261
- elsif begin_year && begin_month == nil
262
- glob << "#{begin_year_s}_*.yml"
263
- elsif begin_year && begin_month
264
- glob << "#{begin_year_s}_#{begin_month_f}.yml"
265
- end
266
-
267
- category = category.to_s.downcase
268
-
269
- entries_h = Hash.new
270
- Dir[glob].each do |file_path|
271
-
272
- data = YAML.load_file(file_path)
273
- if category.length == 0
274
- if begin_day
275
- day_key = "#{begin_year_s}-#{begin_month_f}-#{begin_day_f}"
276
- if data['days'].has_key?(day_key)
277
- entries_h[day_key] = data['days'][day_key]
278
- end
279
- else
280
- entries_h.merge!(data['days'])
281
- end
282
- else
283
- if begin_day
284
- day_key = "#{begin_year_s}-#{begin_month_f}-#{begin_day_f}"
285
- if data['days'].has_key?(day_key)
286
- entries_h[day_key] = data['days'][day_key].keep_if{ |day_item|
287
- day_item['category'].downcase == category
288
- }
289
- end
290
- else
291
- entries_h.merge!(data['days'].map{ |day_name, day_items|
292
- day_items.keep_if{ |day_item|
293
- day_item['category'].downcase == category
294
- }
295
- [day_name, day_items]
296
- }.to_h.keep_if{ |day_name, day_items|
297
- day_items.count > 0
298
- })
299
- end
300
- end
301
-
302
- end
303
- entries_h
304
- end
305
-
306
- # Get all used categories.
307
- # Used by Categories Command.
308
- def categories
309
- categories_h = Hash.new
310
- Dir[Pathname.new('month_*.yml').expand_path(@data_path)].each do |file_path|
311
- data = YAML.load_file(file_path)
312
-
313
- data['days'].each do |day_name, day_items|
314
- day_items.each do |entry|
315
- category_t = entry['category']
316
- if category_t.length > 0
317
- categories_h[category_t] = true
318
- end
319
- end
320
- end
321
- end
322
-
323
- categories_a = categories_h.keys.sort{ |a, b| a.downcase <=> b.downcase }
324
- default_index = categories_a.index('default')
325
- if !default_index.nil?
326
- categories_a.delete_at(categories_a.index('default'))
327
- end
328
- categories_a.unshift('default')
329
- categories_a
330
- end
331
-
332
- # Used by HTML Command.
333
- # Generate HTML files from date_start to date_end.
334
- def generate_html(html_path = nil, date_start = nil, date_end = nil, category = nil)
335
- # @FIXME use @exit on all loops in this function
336
-
337
- html_path ||= @html_path
338
-
339
- @logger.info("generate html to #{html_path} ...")
340
-
341
- create_dirs
342
-
343
- unless html_path.exist?
344
- html_path.mkpath
345
- end
346
-
347
- html_options_path = Pathname.new('options.yml').expand_path(html_path)
348
- html_options = {
349
- 'meta' => {
350
- 'version' => 1,
351
- 'created_at' => DateTime.now.to_s,
352
- 'updated_at' => DateTime.now.to_s,
353
- },
354
- 'changes' => Hash.new,
355
- }
356
- if html_path.exist?
357
- if html_options_path.exist?
358
- html_options = YAML.load_file(html_options_path)
359
- html_options['meta']['updated_at'] = DateTime.now.to_s
360
- end
361
- else
362
- html_path.mkpath
363
- end
364
-
365
- categories_available = categories
366
- if category
367
- filter_categories = category.split(',')
368
- categories_available &= filter_categories
369
- end
370
-
371
- categories_total_balance = Hash.new
372
- categories_available.map{ |item| categories_total_balance[item] = 0.0 }
373
-
374
- # Ignore the html directory.
375
- gitignore_file_path = Pathname.new('.gitignore').expand_path(html_path)
376
- gitignore_file = File.open(gitignore_file_path, 'w')
377
- gitignore_file.write('*')
378
- gitignore_file.close
379
-
380
- # Write CSS file.
381
- css_file_path = Pathname.new('style.css').expand_path(html_path)
382
- css_file = File.open(css_file_path, 'w')
383
- css_file.write('
384
- html {
385
- -webkit-text-size-adjust: none;
386
- }
387
- table.list, table.list th, table.list td {
388
- border: 1px solid black;
389
- }
390
- th.left, td.left {
391
- text-align: left;
392
- }
393
- th.right, td.right {
394
- text-align: right;
395
- }
396
- th.first_column {
397
- min-width: 180px;
398
- width: 180px;
399
- }
400
- th.red, td.red {
401
- color: #ff0000;
402
- }
403
- ')
404
- css_file.close
405
-
406
- # Use this for index.html.
407
- years_total = Hash.new
408
-
409
- # Iterate over all years.
410
- years(date_start, date_end).each do |year|
411
- year_s = year.to_s
412
- year_file_name_s = "year_#{year}.html"
413
- year_file_name_p = Pathname.new(year_file_name_s)
414
- year_file_path = year_file_name_p.expand_path(html_path)
415
-
416
- year_file = File.open(year_file_path, 'w')
417
- year_file.write('
418
- <html>
419
- <head>
420
- <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
421
- <title>' << year_s << ' - ' << @dir_path_basename_s << '</title>
422
- <link rel="stylesheet" href="style.css" type="text/css" />
423
- </head>
424
- <body>
425
- <h1><a href=".">' << @dir_path_basename_s << '</a></h1>
426
- <p>Generated @ ' << DateTime.now.strftime('%Y-%m-%d %H:%M:%S') << ' by <a href="' << HOMEPAGE << '">' << NAME << '</a> v' << VERSION << '</p>
427
-
428
- <h2>Year: ' << year_s << '</h2>
429
- <table class="list">
430
- <tr>
431
- <th class="left">Month</th>
432
- <th class="right">Revenue</th>
433
- <th class="right">Expense</th>
434
- <th class="right">Balance</th>
435
- <th colspan="' << categories_available.count.to_s << '">' << categories_available.count.to_s << ' Categories</th>
436
- </tr>
437
- <tr>
438
- <th colspan="4">&nbsp;</th>
439
- ')
440
- categories_available.each do |category|
441
- year_file.write(%(<th class="right">#{category}</th>))
442
- end
443
- year_file.write('</tr>')
444
-
445
- revenue_year = 0.0
446
- expense_year = 0.0
447
- balance_year = 0.0
448
- categories_year_balance = Hash.new
449
- categories_available.map{ |item| categories_year_balance[item] = 0.0 }
450
- year_total = Hash.new
451
-
452
- @logger.info("generate year #{year}")
453
-
454
- month_files = @data_path
455
- .children
456
- .sort
457
- .keep_if{ |a|
458
- a.extname == '.yml' && Regexp.new("^month_#{year}_").match(a.basename.to_s)
459
- }
460
-
461
- month_files.each do |file_path|
462
- file_name_p = file_path.basename
463
- file_name_s = file_name_p.to_s
464
-
465
- month_n = file_name_s[11, 2]
466
- month_file_name_s = "month_#{year}_#{month_n}.html"
467
- month_file_name_p = Pathname.new(month_file_name_s)
468
- month_file_path = month_file_name_p.expand_path(html_path)
469
-
470
- month_s = Date.parse("2015-#{month_n}-15").strftime('%B')
471
-
472
- if date_start && date_end
473
- file_date_start = Date.parse("#{year}-#{month_n}-01")
474
- file_date_end = Date.parse("#{year}-#{month_n}-01").next_month.prev_day
475
-
476
- if date_end < file_date_start ||
477
- date_start > file_date_end
478
- next
479
- end
480
- end
481
-
482
- revenue_month = 0.0
483
- expense_month = 0.0
484
- balance_month = 0.0
485
- categories_month_balance = Hash.new
486
- categories_available.map{ |item| categories_month_balance[item] = 0.0 }
487
-
488
- entry_n = 0
489
- data = YAML.load_file(file_path)
490
-
491
- # Determine if the html file should be updated.
492
- write_html = false
493
- if html_options['changes'][file_name_s]
494
- if html_options['changes'][file_name_s]['updated_at'] != data['meta']['updated_at']
495
- html_options['changes'][file_name_s]['updated_at'] = data['meta']['updated_at']
496
- write_html = true
497
- end
498
- else
499
- html_options['changes'][file_name_s] = {
500
- 'updated_at' => data['meta']['updated_at'],
501
- }
502
- write_html = true
503
- end
504
- unless month_file_path.exist?
505
- write_html = true
506
- end
507
-
508
- if write_html
509
- @logger.debug("file: #{month_file_name_s} (from #{file_name_s})")
510
-
511
- month_file = File.open(month_file_path, 'w')
512
- month_file.write('
513
- <html>
514
- <head>
515
- <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
516
- <title>' << month_s << ' ' << year_s << ' - ' << @dir_path_basename_s << '</title>
517
- <link rel="stylesheet" href="style.css" type="text/css" />
518
- </head>
519
- <body>
520
- <h1><a href=".">' << @dir_path_basename_s << '</a></h1>
521
- <p>Generated @ ' << DateTime.now.strftime('%Y-%m-%d %H:%M:%S') << ' by <a href="' << HOMEPAGE << '">' << NAME << '</a> v' << VERSION << ' from <code>' << file_name_s << '</code></p>
522
-
523
- <h2>Month: ' << month_s << ' <a href="' << year_file_name_s << '">' << year_s << '</a></h2>
524
- <table class="list">
525
- <tr>
526
- <th class="left">#</th>
527
- <th class="left">Date</th>
528
- <th class="left first_column">Title</th>
529
- <th class="right">Revenue</th>
530
- <th class="right">Expense</th>
531
- <th class="right">Balance</th>
532
- <th class="right">Category</th>
533
- <th class="left">Comment</th>
534
- </tr>
535
- ')
536
- end
537
-
538
- data['days'].sort.each do |day_name, day_items|
539
- day_items.each do |entry|
540
- entry_date = Date.parse(entry['date'])
541
- entry_date_s = entry_date.strftime('%d.%m.%y')
542
-
543
- if category && !categories_available.include?(entry['category'])
544
- next
545
- end
546
-
547
- entry_n += 1
548
- revenue_month += entry['revenue']
549
- expense_month += entry['expense']
550
- balance_month += entry['balance']
551
-
552
- categories_year_balance[entry['category']] += entry['balance']
553
- categories_month_balance[entry['category']] += entry['balance']
554
-
555
- revenue_out = entry['revenue'] > 0 ? NUMBER_FORMAT % entry['revenue'] : '&nbsp;'
556
- expense_out = entry['expense'] < 0 ? NUMBER_FORMAT % entry['expense'] : '&nbsp;'
557
- category_out = entry['category'] == 'default' ? '&nbsp;' : entry['category']
558
- comment_out = entry['comment'] == '' ? '&nbsp;' : entry['comment']
559
-
560
- if write_html
561
- month_file.write('
562
- <tr>
563
- <td valign="top" class="left">' << entry_n.to_s << '</td>
564
- <td valign="top" class="left">' << entry_date_s << '</td>
565
- <td valign="top" class="left">' << entry['title'][0, 50] << '</td>
566
- <td valign="top" class="right">' << revenue_out << '</td>
567
- <td valign="top" class="right red">' << expense_out << '</td>
568
- <td valign="top" class="right ' << (entry['balance'] < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % entry['balance'] << '</td>
569
- <td valign="top" class="right">' << category_out << '</td>
570
- <td valign="top" class="left">' << comment_out << '</td>
571
- </tr>
572
- ')
573
- end
574
- end
575
- end
576
-
577
- revenue_year += revenue_month
578
- expense_year += expense_month
579
- balance_year += balance_month
580
-
581
- revenue_month_r = revenue_month.round(NUMBER_ROUND)
582
- expense_month_r = expense_month.round(NUMBER_ROUND)
583
- balance_month_r = balance_month.round(NUMBER_ROUND)
584
-
585
- year_total[month_n] = ::OpenStruct.new({
586
- month: month_n.to_i,
587
- month_s: '%02d' % month_n.to_i,
588
- revenue: revenue_month_r,
589
- expense: expense_month_r,
590
- balance: balance_month_r,
591
- })
592
-
593
- balance_class = ''
594
- if balance_month < 0
595
- balance_class = 'red'
596
- end
597
- if write_html
598
- month_file.write('
599
- <tr>
600
- <th>&nbsp;</th>
601
- <th>&nbsp;</th>
602
- <th class="left"><b>TOTAL</b></th>
603
- <th class="right">' << NUMBER_FORMAT % revenue_month << '</th>
604
- <th class="right red">' << NUMBER_FORMAT % expense_month << '</th>
605
- <th class="right ' << balance_class << '">' << NUMBER_FORMAT % balance_month << '</th>
606
- <th>&nbsp;</th>
607
- <th>&nbsp;</th>
608
- </tr>
609
- </table>')
610
- month_file.write('</body></html>')
611
- month_file.close
612
- end
613
-
614
- year_file.write('
615
- <tr>
616
- <td class="left"><a href="' << month_file_name_s << '">' << month_s << '</a></td>
617
- <td class="right">' << NUMBER_FORMAT % revenue_month << '</td>
618
- <td class="right red">' << NUMBER_FORMAT % expense_month << '</td>
619
- <td class="right ' << balance_class << '">' << NUMBER_FORMAT % balance_month << '</td>')
620
- categories_available.each do |category|
621
- category_balance = categories_month_balance[category]
622
- year_file.write('<td class="right ' << (category_balance < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % category_balance << '</td>')
623
- end
624
- year_file.write('</tr>')
625
- end
626
-
627
- year_total
628
- .sort
629
- .inject(0.0){ |sum, item|
630
- item[1].balance_total = (sum + item[1].balance).round(NUMBER_ROUND)
631
- }
632
-
633
- year_file.write('
634
- <tr>
635
- <th class="left"><b>TOTAL</b></th>
636
- <th class="right">' << NUMBER_FORMAT % revenue_year << '</th>
637
- <th class="right red">' << NUMBER_FORMAT % expense_year << '</th>
638
- <th class="right ' << (balance_year < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % balance_year << '</th>')
639
- categories_available.each do |category|
640
- category_balance = categories_year_balance[category]
641
- year_file.write('<td class="right ' << (category_balance < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % category_balance << '</td>')
642
- end
643
-
644
- year_file.write('
645
- </tr>
646
- </table>
647
- ')
648
-
649
- year_file.write(%{<p><img src="year_#{year_s}.png"></p>})
650
- year_file.write('</body></html>')
651
- year_file.close
652
-
653
- yeardat_file_path = Pathname.new("year_#{year_s}.dat").expand_path(@tmp_path)
654
- yeardat_file = File.new(yeardat_file_path, 'w')
655
- yeardat_file.write(year_total
656
- .sort{ |a, b| a[0] <=> b[0] }
657
- .map{ |k, m| "#{year_s}-#{m.month_s} #{m.revenue} #{m.expense} #{m.balance} #{m.balance_total} #{m.balance_total}" }
658
- .join("\n"))
659
- yeardat_file.write("\n")
660
- yeardat_file.close
661
-
662
- gnuplot_file_path = Pathname.new("year_#{year_s}.gp").expand_path(@tmp_path)
663
- gnuplot_file = File.new(gnuplot_file_path, 'w')
664
- gnuplot_file.puts("set title 'Year #{year_s}'")
665
- gnuplot_file.puts("set xlabel 'Months'")
666
- gnuplot_file.puts("set ylabel 'Euro'")
667
- gnuplot_file.puts("set grid")
668
- gnuplot_file.puts("set key below center horizontal noreverse enhanced autotitle box dashtype solid")
669
- gnuplot_file.puts("set tics out nomirror")
670
- gnuplot_file.puts("set border 3 front linetype black linewidth 1.0 dashtype solid")
671
-
672
- gnuplot_file.puts("set timefmt '%Y-%m'")
673
- gnuplot_file.puts("set xdata time")
674
- gnuplot_file.puts("set format x '%b'")
675
- gnuplot_file.puts("set xrange ['#{year_s}-01-01':'#{year_s}-12-31']")
676
- gnuplot_file.puts("set xtics '#{year_s}-01-01', 2592000, '#{year_s}-12-31'")
677
- # gnuplot_file.puts("set yrange [-#{year_min_r}:#{year_max_r}]")
678
- gnuplot_file.puts("set autoscale y")
679
-
680
- gnuplot_file.puts("set style line 1 linecolor rgb '#00ff00' linewidth 2 linetype 1 pointtype 2")
681
- gnuplot_file.puts("set style line 2 linecolor rgb '#ff0000' linewidth 2 linetype 1 pointtype 2")
682
- gnuplot_file.puts("set style line 3 linecolor rgb '#000000' linewidth 2 linetype 1 pointtype 2")
683
- gnuplot_file.puts("set style line 4 linecolor rgb '#0000ff' linewidth 2 linetype 1 pointtype 2")
684
- gnuplot_file.puts("set style data linespoints")
685
- gnuplot_file.puts("set terminal png enhanced")
686
- gnuplot_file.puts("set output '" << File.expand_path("year_#{year_s}.png", html_path) << "'")
687
- gnuplot_file.puts("plot sum = 0, \\")
688
- gnuplot_file.puts("\t'#{yeardat_file_path}' using 1:2 linestyle 1 title 'Revenue', \\")
689
- gnuplot_file.puts("\t'' using 1:3 linestyle 2 title 'Expense', \\")
690
- gnuplot_file.puts("\t'' using 1:4 linestyle 3 title 'Balance', \\")
691
- gnuplot_file.puts("\t'' using 1:5 linestyle 4 title '∑ Balance'")
692
- gnuplot_file.close
693
- system("gnuplot #{gnuplot_file_path} &> /dev/null")
694
-
695
- years_total[year_s] = ::OpenStruct.new({
696
- year: year_s,
697
- revenue: revenue_year.round(NUMBER_ROUND),
698
- expense: expense_year.round(NUMBER_ROUND),
699
- balance: balance_year.round(NUMBER_ROUND),
700
- })
701
- end
702
-
703
- years_total.sort.inject(0.0){ |sum, item| item[1].balance_total = (sum + item[1].balance).round(NUMBER_ROUND) }
704
-
705
- index_file_path = Pathname.new('index.html').expand_path(html_path)
706
- index_file = File.open(index_file_path, 'w')
707
- index_file.write('
708
- <html>
709
- <head>
710
- <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
711
- <title>' << @dir_path_basename_s << '</title>
712
- <link rel="stylesheet" href="style.css" type="text/css" />
713
- </head>
714
- <body>
715
- <h1>' << @dir_path_basename_s << '</h1>
716
- <p>Generated @ ' << DateTime.now.strftime('%F %T') << ' by <a href="' << HOMEPAGE << '">' << NAME << '</a> v' << VERSION << '</p>
717
- ')
718
-
719
- # Write total to index.html file.
720
- index_file.write('
721
- <table class="list">
722
- <tr>
723
- <th class="left">Year</th>
724
- <th class="right">Revenue</th>
725
- <th class="right">Expense</th>
726
- <th class="right">Balance</th>
727
- <th class="right">Balance &#8721;</th>
728
- </tr>')
729
-
730
- # Write years total to index.html file.
731
- years_total.each do |year_name, year_data|
732
- index_file.write('
733
- <tr>
734
- <td class="left"><a href="year_' << year_name << '.html">' << year_name << '</a></td>
735
- <td class="right">' << NUMBER_FORMAT % year_data.revenue << '</td>
736
- <td class="right red">' << NUMBER_FORMAT % year_data.expense << '</td>
737
- <td class="right ' << (year_data.balance < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % year_data.balance << '</td>
738
- <td class="right ' << (year_data.balance_total < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % year_data.balance_total << '</td>
739
- </tr>')
740
- end
741
-
742
- balance_total = years_total.inject(0.0){ |sum, item| sum + item[1].balance }
743
-
744
- index_file.write('
745
- <tr>
746
- <th class="left"><b>TOTAL</b></th>
747
- <th class="right">' << NUMBER_FORMAT % years_total.inject(0.0){ |sum, item| sum + item[1].revenue } << '</th>
748
- <th class="right red">' << NUMBER_FORMAT % years_total.inject(0.0){ |sum, item| sum + item[1].expense } << '</th>
749
- <th class="right ' << (balance_total < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % balance_total << '</th>
750
- <th>&nbsp;</th>
751
- </tr>
752
- </table>
753
-
754
- <p><img src="total.png"></p>
755
- </body>
756
- </html>
757
- ')
758
- index_file.close
759
-
760
- store = YAML::Store.new(html_options_path)
761
- store.transaction do
762
- store['meta'] = html_options['meta']
763
- store['changes'] = html_options['changes']
764
- end
765
-
766
- # Convert Years Totals to DAT file rows.
767
- totaldat_file_c = years_total.map{ |k, y| "#{y.year} #{y.revenue} #{y.expense} #{y.balance} #{y.balance_total}" }
768
-
769
- # Print maximal 10 years on GNUPlot.
770
- if totaldat_file_c.count > 10
771
- totaldat_file_c = totaldat_file_c.slice(-10, 10)
772
- end
773
-
774
- # Convert DAT file rows to one String.
775
- totaldat_file_c = totaldat_file_c.join("\n")
776
-
777
- # DAT file for GNUPlot.
778
- totaldat_file_path = Pathname.new('total.dat').expand_path(@tmp_path)
779
- totaldat_file = File.new(totaldat_file_path, 'w')
780
- totaldat_file.write(totaldat_file_c)
781
- totaldat_file.close
782
-
783
- # Generate image with GNUPlot.
784
- png_file_path = Pathname.new('total.png').expand_path(html_path)
785
-
786
- gnuplot_file_path = Pathname.new('total.gp').expand_path(@tmp_path)
787
- gnuplot_file = File.new(gnuplot_file_path, 'w')
788
- gnuplot_file.puts("set title 'Total'")
789
- gnuplot_file.puts("set xlabel 'Years'")
790
- gnuplot_file.puts("set ylabel 'Euro'")
791
- gnuplot_file.puts("set grid")
792
- gnuplot_file.puts("set key below center horizontal noreverse enhanced autotitle box dashtype solid")
793
- gnuplot_file.puts("set tics out nomirror")
794
- gnuplot_file.puts("set border 3 front linetype black linewidth 1.0 dashtype solid")
795
- gnuplot_file.puts("set xtics 1")
796
- gnuplot_file.puts("set style line 1 linecolor rgb '#00ff00' linewidth 2 linetype 1 pointtype 2")
797
- gnuplot_file.puts("set style line 2 linecolor rgb '#ff0000' linewidth 2 linetype 1 pointtype 2")
798
- gnuplot_file.puts("set style line 3 linecolor rgb '#000000' linewidth 2 linetype 1 pointtype 2")
799
- gnuplot_file.puts("set style line 4 linecolor rgb '#0000ff' linewidth 2 linetype 1 pointtype 2")
800
- gnuplot_file.puts("set style data linespoints")
801
- gnuplot_file.puts("set terminal png enhanced")
802
- gnuplot_file.puts("set output '#{png_file_path}'")
803
- gnuplot_file.puts("plot sum = 0, \\")
804
- gnuplot_file.puts("\t'#{totaldat_file_path}' using 1:2 linestyle 1 title 'Revenue', \\")
805
- gnuplot_file.puts("\t'' using 1:3 linestyle 2 title 'Expense', \\")
806
- gnuplot_file.puts("\t'' using 1:4 linestyle 3 title 'Balance', \\")
807
- gnuplot_file.puts("\t'' using 1:5 linestyle 4 title '∑ Balance'")
808
- gnuplot_file.close
809
-
810
- system("gnuplot #{gnuplot_file_path} &> /dev/null")
811
-
812
- @logger.info('generate html done')
813
- end
814
-
815
- # Used by CSV Command.
816
- def import_csv_file(file_path)
817
- transaction_start
818
-
819
- row_n = 0
820
- csv_options = {
821
- :col_sep => ',',
822
- #:row_sep => "\n",
823
- :headers => true,
824
- :return_headers => false,
825
- :skip_blanks => true,
826
- # :encoding => 'UTF-8',
827
- }
828
- CSV.foreach(file_path, csv_options) do |row|
829
- if @exit
830
- break
831
- end
832
- row_n += 1
833
-
834
- id = row.field('id')
835
- date = row.field('date')
836
- title = row.field('title')
837
- revenue = row.field('revenue')
838
- expense = row.field('expense')
839
- # balance = row.field('balance')
840
- category = row.field('category')
841
- comment = row.field('comment')
842
-
843
- added = add(Entry.new(id, title, date, revenue, expense, category, comment), true)
844
-
845
- @logger.debug("import row '#{id}' -- #{added ? 'YES' : 'NO'}")
846
- end
847
-
848
- @logger.info('save data ...')
849
-
850
- transaction_end
851
- end
852
-
853
- # Used by CSV Command.
854
- def export_csv_file(file_path)
855
- csv_options = {
856
- :col_sep => ',',
857
- :row_sep => "\n",
858
- :headers => [
859
- 'id', 'date', 'title', 'revenue', 'expense', 'balance', 'category', 'comment',
860
- ],
861
- :write_headers => true,
862
- # :encoding => 'ISO-8859-1',
863
- }
864
- CSV.open(file_path, 'wb', csv_options) do |csv|
865
- Dir[Pathname.new('month_*.yml').expand_path(@data_path)].each do |yaml_file_path|
866
- @logger.info("export #{File.basename(yaml_file_path)}")
867
-
868
- data = YAML.load_file(yaml_file_path)
869
-
870
- data['days'].each do |day_name, day_items|
871
- day_items.each do |entry|
872
- csv << [
873
- entry['id'],
874
- entry['date'],
875
- entry['title'],
876
- NUMBER_FORMAT % entry['revenue'],
877
- NUMBER_FORMAT % entry['expense'],
878
- NUMBER_FORMAT % entry['balance'],
879
- entry['category'],
880
- entry['comment'],
881
- ]
882
- end
883
- end
884
- end
885
- end
886
- end
887
-
888
- def entry_exist?(entry)
889
- if !entry.is_a?(Entry)
890
- raise ArgumentError, 'variable must be an Entry instance'
891
- end
892
-
893
- if @entries_index.count == 0
894
- load_entries_index_file
895
- end
896
- @entries_index.include?(entry.id)
897
- end
898
-
899
- # Build an entry-by-id Hash.
900
- # ID => Entry
901
- def build_entry_by_id_index(force = false)
902
- if @entries_by_ids.nil? || force
903
- @logger.debug('build entry-by-id index')
904
-
905
- glob = Pathname.new('month_*.yml').expand_path(@data_path)
906
-
907
- @entries_by_ids = Dir[glob.to_s].map { |file_path|
908
- data = YAML.load_file(file_path)
909
- data['days'].map{ |day_name, day_items|
910
- day_items.map{ |entry|
911
- Entry.from_h(entry)
912
- }
913
- }
914
- }.flatten.map{ |entry|
915
- [entry.id, entry]
916
- }.to_h
917
- end
918
- end
919
-
920
- # Find an Entry by a given ID.
921
- def find_entry_by_id(id)
922
- build_entry_by_id_index
923
-
924
- @entries_by_ids[id]
925
- end
926
-
927
- # Used by Clear Command.
928
- def clear
929
- c = 0
930
-
931
- # Take the standard html path instead of --path option.
932
- # Do not provide the functionality to delete files from --path.
933
- # If a user uses --path to generate html files outside of the
934
- # wallet path the user needs to manual remove these files.
935
- children = @tmp_path.children + @html_path.children
936
-
937
- children.each do |child|
938
-
939
- if child.basename.to_s[0] == '.'
940
- # Ignore 'hidden' files like .gitignore.
941
- next
942
- end
943
-
944
- # puts "child #{child}"
945
- FileUtils.rm_rf(child)
946
-
947
- c += 1
948
- if c > 100
949
- # If something goes wrong do not delete to whole harddisk. ;)
950
- break
951
- end
952
- end
953
- end
954
-
955
- private
956
-
957
- # Create all needed subdirectories for this wallet.
958
- def create_dirs
959
- unless @dir_path.exist?
960
- @dir_path.mkpath
961
- end
962
-
963
- unless @data_path.exist?
964
- @data_path.mkpath
965
- end
966
-
967
- unless @tmp_path.exist?
968
- @tmp_path.mkpath
969
- end
970
-
971
- # Ignore all files in wallet/tmp.
972
- tmp_gitignore_path = Pathname.new('.gitignore').expand_path(@tmp_path)
973
- unless tmp_gitignore_path.exist?
974
- gitignore_file = File.open(tmp_gitignore_path, 'w')
975
- gitignore_file.write('*')
976
- gitignore_file.close
977
- end
978
-
979
- if @entries_index_file_path.exist?
980
- load_entries_index_file
981
- else
982
- # When the entries index file does not exist from an older Wallet version.
983
- build_entry_by_id_index(true)
984
- @entries_index = @entries_by_ids.keys
985
- save_entries_index_file
986
- end
987
- end
988
-
989
- # Get the sums for a given day.
990
- def calc_day(day, category = nil)
991
- revenue = 0
992
- expense = 0
993
- balance = 0
994
- if category
995
- category.to_s.downcase!
996
-
997
- day.each do |entry|
998
- if entry['category'] == category
999
- revenue += entry['revenue']
1000
- expense += entry['expense']
1001
- balance += entry['balance']
1002
- end
1003
- end
1004
- else
1005
- day.each do |entry|
1006
- revenue += entry['revenue']
1007
- expense += entry['expense']
1008
- balance += entry['balance']
1009
- end
1010
- end
1011
-
1012
- {
1013
- :revenue => revenue,
1014
- :expense => expense,
1015
- :balance => balance,
1016
- }
1017
- end
1018
-
1019
- # Get all used years.
1020
- # Range is optional.
1021
- def years(date_start = nil, date_end = nil)
1022
- files = Array.new
1023
- @data_path.each_child(false) do |file|
1024
- if file.extname == '.yml' && /^month_/.match(file.to_s)
1025
- files << file
1026
- end
1027
- end
1028
-
1029
- date_start_year = 0
1030
- date_start_year = date_start.year if date_start
1031
-
1032
- date_end_year = 9999
1033
- date_end_year = date_end.year if date_end
1034
-
1035
- files
1036
- .map{ |file| file.to_s[6, 4].to_i }
1037
- .uniq
1038
- .keep_if{ |year| year >= date_start_year && year <= date_end_year }
1039
- .sort
1040
- end
1041
-
1042
- # Load the entries index file only once.
1043
- def load_entries_index_file
1044
- if @entries_index_is_loaded
1045
- return
1046
- end
1047
-
1048
- @entries_index_is_loaded = true
1049
- if @entries_index_file_path.exist?
1050
- data = YAML.load_file(@entries_index_file_path.to_s)
1051
- @entries_index = data['index']
1052
- end
1053
- end
1054
-
1055
- # Save the entries index file.
1056
- def save_entries_index_file
1057
- store = YAML::Store.new(@entries_index_file_path.to_s)
1058
- store.transaction do
1059
- store['index'] = @entries_index
1060
- end
1061
- end
1062
-
1063
- end
1064
-
1065
- end
15
+ module Wallet
16
+
17
+ class Wallet
18
+
19
+ attr_writer :logger
20
+ attr_reader :dir_path
21
+
22
+ def initialize(dir_path = nil)
23
+ @exit = false
24
+ @logger = Logger.new(IO::NULL)
25
+ @dir_path = dir_path || Pathname.new('wallet').expand_path
26
+ @dir_path_basename = @dir_path.basename
27
+ @dir_path_basename_s = @dir_path_basename.to_s
28
+ @data_path = Pathname.new('data').expand_path(@dir_path)
29
+ @tmp_path = Pathname.new('tmp').expand_path(@dir_path)
30
+
31
+ # Internal path. Not the same as provided by --path option.
32
+ @html_path = Pathname.new('html').expand_path(@dir_path)
33
+
34
+ @has_transaction = false
35
+ @transaction_files = Hash.new
36
+
37
+ @entries_by_ids = nil
38
+ @entries_index_file_path = Pathname.new('index.yml').expand_path(@data_path)
39
+ @entries_index = Array.new
40
+ @entries_index_is_loaded = false
41
+
42
+ Signal.trap('SIGINT') do
43
+ #@logger.warn('received SIGINT. break ...')
44
+ @exit = true
45
+ end
46
+ end
47
+
48
+ # Add an Entry to the wallet.
49
+ # Used by Add Command.
50
+ def add(entry, is_unique = false)
51
+ if !entry.is_a?(Entry)
52
+ raise ArgumentError, 'variable must be a Entry instance'
53
+ end
54
+
55
+ if is_unique && entry_exist?(entry)
56
+ return false
57
+ end
58
+
59
+ create_dirs
60
+
61
+ date = entry.date
62
+ date_s = date.to_s
63
+ dbfile_basename_s = "month_#{date.strftime('%Y_%m')}.yml"
64
+ dbfile_basename_p = Pathname.new(dbfile_basename_s)
65
+ dbfile_path = dbfile_basename_p.expand_path(@data_path)
66
+ tmpfile_path = Pathname.new("#{dbfile_path}.tmp")
67
+ file = {
68
+ 'meta' => {
69
+ 'version' => 1,
70
+ 'created_at' => DateTime.now.to_s,
71
+ 'updated_at' => DateTime.now.to_s,
72
+ },
73
+ 'days' => Hash.new,
74
+ }
75
+
76
+ @entries_index << entry.id
77
+
78
+ if @has_transaction
79
+ if @transaction_files[dbfile_basename_s]
80
+ file = @transaction_files[dbfile_basename_s]['file']
81
+ else
82
+ if dbfile_path.exist?
83
+ file = YAML.load_file(dbfile_path)
84
+ file['meta']['updated_at'] = DateTime.now.to_s
85
+ end
86
+
87
+ @transaction_files[dbfile_basename_s] = {
88
+ 'basename' => dbfile_basename_s,
89
+ 'path' => dbfile_path.to_s,
90
+ 'tmp_path' => tmpfile_path.to_s,
91
+ 'file' => file,
92
+ }
93
+ end
94
+
95
+ if file['days'].is_a?(Array)
96
+ file['days'] = Hash.new
97
+ end
98
+ if !file['days'].has_key?(date_s)
99
+ file['days'][date_s] = Array.new
100
+ end
101
+
102
+ file['days'][date_s].push(entry.to_h)
103
+
104
+ @transaction_files[dbfile_basename_s]['file'] = file
105
+ else
106
+ if dbfile_path.exist?
107
+ file = YAML.load_file(dbfile_path)
108
+ file['meta']['updated_at'] = DateTime.now.to_s
109
+ end
110
+
111
+ if file['days'].is_a?(Array)
112
+ file['days'] = Hash.new
113
+ end
114
+ if !file['days'].has_key?(date_s)
115
+ file['days'][date_s] = Array.new
116
+ end
117
+
118
+ file['days'][date_s].push(entry.to_h)
119
+
120
+ store = YAML::Store.new(tmpfile_path)
121
+ store.transaction do
122
+ store['meta'] = file['meta']
123
+ store['days'] = file['days']
124
+ end
125
+
126
+ save_entries_index_file
127
+
128
+ if tmpfile_path.exist?
129
+ tmpfile_path.rename(dbfile_path)
130
+ end
131
+ end
132
+
133
+ if @entries_by_ids.nil?
134
+ @entries_by_ids = Hash.new
135
+ end
136
+ @entries_by_ids[entry.id] = entry
137
+
138
+ true
139
+ end
140
+
141
+ def transaction_start
142
+ @has_transaction = true
143
+ @transaction_files = Hash.new
144
+
145
+ create_dirs
146
+ end
147
+
148
+ def transaction_end
149
+ catch(:done) do
150
+ @transaction_files.each do |tr_file_key, tr_file_data|
151
+ if @exit
152
+ throw :done
153
+ end
154
+
155
+ store = YAML::Store.new(tr_file_data['tmp_path'])
156
+ store.transaction do
157
+ store['meta'] = tr_file_data['file']['meta']
158
+ store['days'] = tr_file_data['file']['days']
159
+ end
160
+ @transaction_files.delete(tr_file_key)
161
+
162
+ if File.exist?(tr_file_data['tmp_path'])
163
+ File.rename(tr_file_data['tmp_path'], tr_file_data['path'])
164
+ end
165
+ end
166
+ end
167
+
168
+ save_entries_index_file
169
+
170
+ @has_transaction = false
171
+ @transaction_files = Hash.new
172
+ end
173
+
174
+ # Sums a year, a month, a day or a certain category.
175
+ def sum(year = nil, month = nil, day = nil, category = nil)
176
+ year_s = year.to_i.to_s
177
+ month_f = '%02d' % month.to_i
178
+ day_f = '%02d' % day.to_i
179
+
180
+ revenue = 0.0
181
+ expense = 0.0
182
+ balance = 0.0
183
+
184
+ glob = File.expand_path('month_', @data_path)
185
+ if year == nil && month == nil
186
+ glob << '*.yml'
187
+ elsif year && month == nil
188
+ glob << "#{year_s}_*.yml"
189
+ elsif year && month
190
+ glob << "#{year_s}_#{month_f}.yml"
191
+ end
192
+
193
+ Dir[glob].each do |file_path|
194
+ data = YAML.load_file(file_path)
195
+
196
+ if day
197
+ day_key = "#{year_s}-#{month_f}-#{day_f}"
198
+ if data['days'].has_key?(day_key)
199
+ day_sum = calc_day(data['days'][day_key], category)
200
+ revenue += day_sum[:revenue]
201
+ expense += day_sum[:expense]
202
+ balance += day_sum[:balance]
203
+ end
204
+ else
205
+ data['days'].each do |day_name, day_items|
206
+ day_sum = calc_day(day_items, category)
207
+ revenue += day_sum[:revenue]
208
+ expense += day_sum[:expense]
209
+ balance += day_sum[:balance]
210
+ end
211
+ end
212
+ end
213
+
214
+ revenue = revenue.to_f.round(NUMBER_ROUND)
215
+ expense = expense.to_f.round(NUMBER_ROUND)
216
+ balance = (revenue + expense).round(NUMBER_ROUND)
217
+
218
+ diff = revenue + expense - balance
219
+ if diff != 0
220
+ raise RuntimeError, "diff between revenue and expense to balance is #{diff}"
221
+ end
222
+
223
+ {
224
+ :revenue => revenue,
225
+ :expense => expense,
226
+ :balance => balance,
227
+ }
228
+ end
229
+
230
+ def sum_category(category)
231
+ sum(nil, nil, nil, category)
232
+ end
233
+
234
+ # Get all entries.
235
+ # Used by List Command.
236
+ def entries(begin_date, category = nil)
237
+ begin_year, begin_month, begin_day = begin_date.split('-') #.map{ |n| n.to_i }
238
+
239
+ if begin_year.length > 4
240
+ # When begin_date got not splitted by '-'.
241
+ # YYYYM[MD[D]]
242
+
243
+ begin_month = begin_year[4..-1]
244
+ begin_year = begin_year[0..3]
245
+
246
+ if begin_month.length > 2
247
+ # YYYYMMD[D]
248
+
249
+ begin_day = begin_month[2..-1]
250
+ begin_month = begin_month[0..1]
251
+ end
252
+ end
253
+
254
+ begin_year_s = begin_year.to_i.to_s
255
+ begin_month_f = '%02d' % begin_month.to_i
256
+ begin_day_f = '%02d' % begin_day.to_i
257
+
258
+ glob = File.expand_path('month_', @data_path)
259
+ if begin_year == nil && begin_month == nil
260
+ glob << '*.yml'
261
+ elsif begin_year && begin_month == nil
262
+ glob << "#{begin_year_s}_*.yml"
263
+ elsif begin_year && begin_month
264
+ glob << "#{begin_year_s}_#{begin_month_f}.yml"
265
+ end
266
+
267
+ category = category.to_s.downcase
268
+
269
+ entries_h = Hash.new
270
+ Dir[glob].each do |file_path|
271
+
272
+ data = YAML.load_file(file_path)
273
+ if category.length == 0
274
+ if begin_day
275
+ day_key = "#{begin_year_s}-#{begin_month_f}-#{begin_day_f}"
276
+ if data['days'].has_key?(day_key)
277
+ entries_h[day_key] = data['days'][day_key]
278
+ end
279
+ else
280
+ entries_h.merge!(data['days'])
281
+ end
282
+ else
283
+ if begin_day
284
+ day_key = "#{begin_year_s}-#{begin_month_f}-#{begin_day_f}"
285
+ if data['days'].has_key?(day_key)
286
+ entries_h[day_key] = data['days'][day_key].keep_if{ |day_item|
287
+ day_item['category'].downcase == category
288
+ }
289
+ end
290
+ else
291
+ entries_h.merge!(data['days'].map{ |day_name, day_items|
292
+ day_items.keep_if{ |day_item|
293
+ day_item['category'].downcase == category
294
+ }
295
+ [day_name, day_items]
296
+ }.to_h.keep_if{ |day_name, day_items|
297
+ day_items.count > 0
298
+ })
299
+ end
300
+ end
301
+
302
+ end
303
+ entries_h
304
+ end
305
+
306
+ # Get all used categories.
307
+ # Used by Categories Command.
308
+ def categories
309
+ categories_h = Hash.new
310
+ Dir[Pathname.new('month_*.yml').expand_path(@data_path)].each do |file_path|
311
+ data = YAML.load_file(file_path)
312
+
313
+ data['days'].each do |day_name, day_items|
314
+ day_items.each do |entry|
315
+ category_t = entry['category']
316
+ if category_t.length > 0
317
+ categories_h[category_t] = true
318
+ end
319
+ end
320
+ end
321
+ end
322
+
323
+ categories_a = categories_h.keys.sort{ |a, b| a.downcase <=> b.downcase }
324
+ default_index = categories_a.index('default')
325
+ if !default_index.nil?
326
+ categories_a.delete_at(categories_a.index('default'))
327
+ end
328
+ categories_a.unshift('default')
329
+ categories_a
330
+ end
331
+
332
+ # Used by HTML Command.
333
+ # Generate HTML files from date_start to date_end.
334
+ def generate_html(html_path = nil, date_start = nil, date_end = nil, category = nil)
335
+ # @FIXME use @exit on all loops in this function
336
+
337
+ html_path ||= @html_path
338
+
339
+ @logger.info("generate html to #{html_path} ...")
340
+
341
+ create_dirs
342
+
343
+ unless html_path.exist?
344
+ html_path.mkpath
345
+ end
346
+
347
+ html_options_path = Pathname.new('options.yml').expand_path(html_path)
348
+ html_options = {
349
+ 'meta' => {
350
+ 'version' => 1,
351
+ 'created_at' => DateTime.now.to_s,
352
+ 'updated_at' => DateTime.now.to_s,
353
+ },
354
+ 'changes' => Hash.new,
355
+ }
356
+ if html_path.exist?
357
+ if html_options_path.exist?
358
+ html_options = YAML.load_file(html_options_path)
359
+ html_options['meta']['updated_at'] = DateTime.now.to_s
360
+ end
361
+ else
362
+ html_path.mkpath
363
+ end
364
+
365
+ categories_available = categories
366
+ if category
367
+ filter_categories = category.split(',')
368
+ categories_available &= filter_categories
369
+ end
370
+
371
+ categories_total_balance = Hash.new
372
+ categories_available.map{ |item| categories_total_balance[item] = 0.0 }
373
+
374
+ # Ignore the html directory.
375
+ gitignore_file_path = Pathname.new('.gitignore').expand_path(html_path)
376
+ gitignore_file = File.open(gitignore_file_path, 'w')
377
+ gitignore_file.write('*')
378
+ gitignore_file.close
379
+
380
+ # Write CSS file.
381
+ css_file_path = Pathname.new('style.css').expand_path(html_path)
382
+ css_file = File.open(css_file_path, 'w')
383
+ css_file.write('
384
+ html {
385
+ -webkit-text-size-adjust: none;
386
+ }
387
+ table.list, table.list th, table.list td {
388
+ border: 1px solid black;
389
+ }
390
+ th.left, td.left {
391
+ text-align: left;
392
+ }
393
+ th.right, td.right {
394
+ text-align: right;
395
+ }
396
+ th.first_column {
397
+ min-width: 180px;
398
+ width: 180px;
399
+ }
400
+ th.red, td.red {
401
+ color: #ff0000;
402
+ }
403
+ ')
404
+ css_file.close
405
+
406
+ # Use this for index.html.
407
+ years_total = Hash.new
408
+
409
+ # Iterate over all years.
410
+ years(date_start, date_end).each do |year|
411
+ year_s = year.to_s
412
+ year_file_name_s = "year_#{year}.html"
413
+ year_file_name_p = Pathname.new(year_file_name_s)
414
+ year_file_path = year_file_name_p.expand_path(html_path)
415
+
416
+ year_file = File.open(year_file_path, 'w')
417
+ year_file.write('
418
+ <html>
419
+ <head>
420
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
421
+ <title>' << year_s << ' - ' << @dir_path_basename_s << '</title>
422
+ <link rel="stylesheet" href="style.css" type="text/css" />
423
+ </head>
424
+ <body>
425
+ <h1><a href=".">' << @dir_path_basename_s << '</a></h1>
426
+ <p>Generated @ ' << DateTime.now.strftime('%Y-%m-%d %H:%M:%S') << ' by <a href="' << HOMEPAGE << '">' << NAME << '</a> v' << VERSION << '</p>
427
+
428
+ <h2>Year: ' << year_s << '</h2>
429
+ <table class="list">
430
+ <tr>
431
+ <th class="left">Month</th>
432
+ <th class="right">Revenue</th>
433
+ <th class="right">Expense</th>
434
+ <th class="right">Balance</th>
435
+ <th colspan="' << categories_available.count.to_s << '">' << categories_available.count.to_s << ' Categories</th>
436
+ </tr>
437
+ <tr>
438
+ <th colspan="4">&nbsp;</th>
439
+ ')
440
+ categories_available.each do |category|
441
+ year_file.write(%(<th class="right">#{category}</th>))
442
+ end
443
+ year_file.write('</tr>')
444
+
445
+ revenue_year = 0.0
446
+ expense_year = 0.0
447
+ balance_year = 0.0
448
+ categories_year_balance = Hash.new
449
+ categories_available.map{ |item| categories_year_balance[item] = 0.0 }
450
+ year_total = Hash.new
451
+
452
+ @logger.info("generate year #{year}")
453
+
454
+ month_files = @data_path
455
+ .children
456
+ .sort
457
+ .keep_if{ |a|
458
+ a.extname == '.yml' && Regexp.new("^month_#{year}_").match(a.basename.to_s)
459
+ }
460
+
461
+ month_files.each do |file_path|
462
+ file_name_p = file_path.basename
463
+ file_name_s = file_name_p.to_s
464
+
465
+ month_n = file_name_s[11, 2]
466
+ month_file_name_s = "month_#{year}_#{month_n}.html"
467
+ month_file_name_p = Pathname.new(month_file_name_s)
468
+ month_file_path = month_file_name_p.expand_path(html_path)
469
+
470
+ month_s = Date.parse("2015-#{month_n}-15").strftime('%B')
471
+
472
+ if date_start && date_end
473
+ file_date_start = Date.parse("#{year}-#{month_n}-01")
474
+ file_date_end = Date.parse("#{year}-#{month_n}-01").next_month.prev_day
475
+
476
+ if date_end < file_date_start ||
477
+ date_start > file_date_end
478
+ next
479
+ end
480
+ end
481
+
482
+ revenue_month = 0.0
483
+ expense_month = 0.0
484
+ balance_month = 0.0
485
+ categories_month_balance = Hash.new
486
+ categories_available.map{ |item| categories_month_balance[item] = 0.0 }
487
+
488
+ entry_n = 0
489
+ data = YAML.load_file(file_path)
490
+
491
+ # Determine if the html file should be updated.
492
+ write_html = false
493
+ if html_options['changes'][file_name_s]
494
+ if html_options['changes'][file_name_s]['updated_at'] != data['meta']['updated_at']
495
+ html_options['changes'][file_name_s]['updated_at'] = data['meta']['updated_at']
496
+ write_html = true
497
+ end
498
+ else
499
+ html_options['changes'][file_name_s] = {
500
+ 'updated_at' => data['meta']['updated_at'],
501
+ }
502
+ write_html = true
503
+ end
504
+ unless month_file_path.exist?
505
+ write_html = true
506
+ end
507
+
508
+ if write_html
509
+ @logger.debug("file: #{month_file_name_s} (from #{file_name_s})")
510
+
511
+ month_file = File.open(month_file_path, 'w')
512
+ month_file.write('
513
+ <html>
514
+ <head>
515
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
516
+ <title>' << month_s << ' ' << year_s << ' - ' << @dir_path_basename_s << '</title>
517
+ <link rel="stylesheet" href="style.css" type="text/css" />
518
+ </head>
519
+ <body>
520
+ <h1><a href=".">' << @dir_path_basename_s << '</a></h1>
521
+ <p>Generated @ ' << DateTime.now.strftime('%Y-%m-%d %H:%M:%S') << ' by <a href="' << HOMEPAGE << '">' << NAME << '</a> v' << VERSION << ' from <code>' << file_name_s << '</code></p>
522
+
523
+ <h2>Month: ' << month_s << ' <a href="' << year_file_name_s << '">' << year_s << '</a></h2>
524
+ <table class="list">
525
+ <tr>
526
+ <th class="left">#</th>
527
+ <th class="left">Date</th>
528
+ <th class="left first_column">Title</th>
529
+ <th class="right">Revenue</th>
530
+ <th class="right">Expense</th>
531
+ <th class="right">Balance</th>
532
+ <th class="right">Category</th>
533
+ <th class="left">Comment</th>
534
+ </tr>
535
+ ')
536
+ end
537
+
538
+ data['days'].sort.each do |day_name, day_items|
539
+ day_items.each do |entry|
540
+ entry_date = Date.parse(entry['date'])
541
+ entry_date_s = entry_date.strftime('%d.%m.%y')
542
+
543
+ if category && !categories_available.include?(entry['category'])
544
+ next
545
+ end
546
+
547
+ entry_n += 1
548
+ revenue_month += entry['revenue']
549
+ expense_month += entry['expense']
550
+ balance_month += entry['balance']
551
+
552
+ categories_year_balance[entry['category']] += entry['balance']
553
+ categories_month_balance[entry['category']] += entry['balance']
554
+
555
+ revenue_out = entry['revenue'] > 0 ? NUMBER_FORMAT % entry['revenue'] : '&nbsp;'
556
+ expense_out = entry['expense'] < 0 ? NUMBER_FORMAT % entry['expense'] : '&nbsp;'
557
+ category_out = entry['category'] == 'default' ? '&nbsp;' : entry['category']
558
+ comment_out = entry['comment'] == '' ? '&nbsp;' : entry['comment']
559
+
560
+ if write_html
561
+ month_file.write('
562
+ <tr>
563
+ <td valign="top" class="left">' << entry_n.to_s << '</td>
564
+ <td valign="top" class="left">' << entry_date_s << '</td>
565
+ <td valign="top" class="left">' << entry['title'][0, 50] << '</td>
566
+ <td valign="top" class="right">' << revenue_out << '</td>
567
+ <td valign="top" class="right red">' << expense_out << '</td>
568
+ <td valign="top" class="right ' << (entry['balance'] < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % entry['balance'] << '</td>
569
+ <td valign="top" class="right">' << category_out << '</td>
570
+ <td valign="top" class="left">' << comment_out << '</td>
571
+ </tr>
572
+ ')
573
+ end
574
+ end
575
+ end
576
+
577
+ revenue_year += revenue_month
578
+ expense_year += expense_month
579
+ balance_year += balance_month
580
+
581
+ revenue_month_r = revenue_month.round(NUMBER_ROUND)
582
+ expense_month_r = expense_month.round(NUMBER_ROUND)
583
+ balance_month_r = balance_month.round(NUMBER_ROUND)
584
+
585
+ year_total[month_n] = ::OpenStruct.new({
586
+ month: month_n.to_i,
587
+ month_s: '%02d' % month_n.to_i,
588
+ revenue: revenue_month_r,
589
+ expense: expense_month_r,
590
+ balance: balance_month_r,
591
+ })
592
+
593
+ balance_class = ''
594
+ if balance_month < 0
595
+ balance_class = 'red'
596
+ end
597
+ if write_html
598
+ month_file.write('
599
+ <tr>
600
+ <th>&nbsp;</th>
601
+ <th>&nbsp;</th>
602
+ <th class="left"><b>TOTAL</b></th>
603
+ <th class="right">' << NUMBER_FORMAT % revenue_month << '</th>
604
+ <th class="right red">' << NUMBER_FORMAT % expense_month << '</th>
605
+ <th class="right ' << balance_class << '">' << NUMBER_FORMAT % balance_month << '</th>
606
+ <th>&nbsp;</th>
607
+ <th>&nbsp;</th>
608
+ </tr>
609
+ </table>')
610
+ month_file.write('</body></html>')
611
+ month_file.close
612
+ end
613
+
614
+ year_file.write('
615
+ <tr>
616
+ <td class="left"><a href="' << month_file_name_s << '">' << month_s << '</a></td>
617
+ <td class="right">' << NUMBER_FORMAT % revenue_month << '</td>
618
+ <td class="right red">' << NUMBER_FORMAT % expense_month << '</td>
619
+ <td class="right ' << balance_class << '">' << NUMBER_FORMAT % balance_month << '</td>')
620
+ categories_available.each do |category|
621
+ category_balance = categories_month_balance[category]
622
+ year_file.write('<td class="right ' << (category_balance < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % category_balance << '</td>')
623
+ end
624
+ year_file.write('</tr>')
625
+ end
626
+
627
+ year_total
628
+ .sort
629
+ .inject(0.0){ |sum, item|
630
+ item[1].balance_total = (sum + item[1].balance).round(NUMBER_ROUND)
631
+ }
632
+
633
+ year_file.write('
634
+ <tr>
635
+ <th class="left"><b>TOTAL</b></th>
636
+ <th class="right">' << NUMBER_FORMAT % revenue_year << '</th>
637
+ <th class="right red">' << NUMBER_FORMAT % expense_year << '</th>
638
+ <th class="right ' << (balance_year < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % balance_year << '</th>')
639
+ categories_available.each do |category|
640
+ category_balance = categories_year_balance[category]
641
+ year_file.write('<td class="right ' << (category_balance < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % category_balance << '</td>')
642
+ end
643
+
644
+ year_file.write('
645
+ </tr>
646
+ </table>
647
+ ')
648
+
649
+ year_file.write(%{<p><img src="year_#{year_s}.png"></p>})
650
+ year_file.write('</body></html>')
651
+ year_file.close
652
+
653
+ yeardat_file_path = Pathname.new("year_#{year_s}.dat").expand_path(@tmp_path)
654
+ yeardat_file = File.new(yeardat_file_path, 'w')
655
+ yeardat_file.write(year_total
656
+ .sort{ |a, b| a[0] <=> b[0] }
657
+ .map{ |k, m| "#{year_s}-#{m.month_s} #{m.revenue} #{m.expense} #{m.balance} #{m.balance_total} #{m.balance_total}" }
658
+ .join("\n"))
659
+ yeardat_file.write("\n")
660
+ yeardat_file.close
661
+
662
+ gnuplot_file_path = Pathname.new("year_#{year_s}.gp").expand_path(@tmp_path)
663
+ gnuplot_file = File.new(gnuplot_file_path, 'w')
664
+ gnuplot_file.puts("set title 'Year #{year_s}'")
665
+ gnuplot_file.puts("set xlabel 'Months'")
666
+ gnuplot_file.puts("set ylabel 'Euro'")
667
+ gnuplot_file.puts("set grid")
668
+ gnuplot_file.puts("set key below center horizontal noreverse enhanced autotitle box dashtype solid")
669
+ gnuplot_file.puts("set tics out nomirror")
670
+ gnuplot_file.puts("set border 3 front linetype black linewidth 1.0 dashtype solid")
671
+
672
+ gnuplot_file.puts("set timefmt '%Y-%m'")
673
+ gnuplot_file.puts("set xdata time")
674
+ gnuplot_file.puts("set format x '%b'")
675
+ gnuplot_file.puts("set xrange ['#{year_s}-01-01':'#{year_s}-12-31']")
676
+ gnuplot_file.puts("set xtics '#{year_s}-01-01', 2592000, '#{year_s}-12-31'")
677
+ # gnuplot_file.puts("set yrange [-#{year_min_r}:#{year_max_r}]")
678
+ gnuplot_file.puts("set autoscale y")
679
+
680
+ gnuplot_file.puts("set style line 1 linecolor rgb '#00ff00' linewidth 2 linetype 1 pointtype 2")
681
+ gnuplot_file.puts("set style line 2 linecolor rgb '#ff0000' linewidth 2 linetype 1 pointtype 2")
682
+ gnuplot_file.puts("set style line 3 linecolor rgb '#000000' linewidth 2 linetype 1 pointtype 2")
683
+ gnuplot_file.puts("set style line 4 linecolor rgb '#0000ff' linewidth 2 linetype 1 pointtype 2")
684
+ gnuplot_file.puts("set style data linespoints")
685
+ gnuplot_file.puts("set terminal png enhanced")
686
+ gnuplot_file.puts("set output '" << File.expand_path("year_#{year_s}.png", html_path) << "'")
687
+ gnuplot_file.puts("plot sum = 0, \\")
688
+ gnuplot_file.puts("\t'#{yeardat_file_path}' using 1:2 linestyle 1 title 'Revenue', \\")
689
+ gnuplot_file.puts("\t'' using 1:3 linestyle 2 title 'Expense', \\")
690
+ gnuplot_file.puts("\t'' using 1:4 linestyle 3 title 'Balance', \\")
691
+ gnuplot_file.puts("\t'' using 1:5 linestyle 4 title '∑ Balance'")
692
+ gnuplot_file.close
693
+ system("gnuplot #{gnuplot_file_path} &> /dev/null")
694
+
695
+ years_total[year_s] = ::OpenStruct.new({
696
+ year: year_s,
697
+ revenue: revenue_year.round(NUMBER_ROUND),
698
+ expense: expense_year.round(NUMBER_ROUND),
699
+ balance: balance_year.round(NUMBER_ROUND),
700
+ })
701
+ end
702
+
703
+ years_total.sort.inject(0.0){ |sum, item| item[1].balance_total = (sum + item[1].balance).round(NUMBER_ROUND) }
704
+
705
+ index_file_path = Pathname.new('index.html').expand_path(html_path)
706
+ index_file = File.open(index_file_path, 'w')
707
+ index_file.write('
708
+ <html>
709
+ <head>
710
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
711
+ <title>' << @dir_path_basename_s << '</title>
712
+ <link rel="stylesheet" href="style.css" type="text/css" />
713
+ </head>
714
+ <body>
715
+ <h1>' << @dir_path_basename_s << '</h1>
716
+ <p>Generated @ ' << DateTime.now.strftime('%F %T') << ' by <a href="' << HOMEPAGE << '">' << NAME << '</a> v' << VERSION << '</p>
717
+ ')
718
+
719
+ # Write total to index.html file.
720
+ index_file.write('
721
+ <table class="list">
722
+ <tr>
723
+ <th class="left">Year</th>
724
+ <th class="right">Revenue</th>
725
+ <th class="right">Expense</th>
726
+ <th class="right">Balance</th>
727
+ <th class="right">Balance &#8721;</th>
728
+ </tr>')
729
+
730
+ # Write years total to index.html file.
731
+ years_total.each do |year_name, year_data|
732
+ index_file.write('
733
+ <tr>
734
+ <td class="left"><a href="year_' << year_name << '.html">' << year_name << '</a></td>
735
+ <td class="right">' << NUMBER_FORMAT % year_data.revenue << '</td>
736
+ <td class="right red">' << NUMBER_FORMAT % year_data.expense << '</td>
737
+ <td class="right ' << (year_data.balance < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % year_data.balance << '</td>
738
+ <td class="right ' << (year_data.balance_total < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % year_data.balance_total << '</td>
739
+ </tr>')
740
+ end
741
+
742
+ balance_total = years_total.inject(0.0){ |sum, item| sum + item[1].balance }
743
+
744
+ index_file.write('
745
+ <tr>
746
+ <th class="left"><b>TOTAL</b></th>
747
+ <th class="right">' << NUMBER_FORMAT % years_total.inject(0.0){ |sum, item| sum + item[1].revenue } << '</th>
748
+ <th class="right red">' << NUMBER_FORMAT % years_total.inject(0.0){ |sum, item| sum + item[1].expense } << '</th>
749
+ <th class="right ' << (balance_total < 0 ? 'red' : '') << '">' << NUMBER_FORMAT % balance_total << '</th>
750
+ <th>&nbsp;</th>
751
+ </tr>
752
+ </table>
753
+
754
+ <p><img src="total.png"></p>
755
+ </body>
756
+ </html>
757
+ ')
758
+ index_file.close
759
+
760
+ store = YAML::Store.new(html_options_path)
761
+ store.transaction do
762
+ store['meta'] = html_options['meta']
763
+ store['changes'] = html_options['changes']
764
+ end
765
+
766
+ # Convert Years Totals to DAT file rows.
767
+ totaldat_file_c = years_total.map{ |k, y| "#{y.year} #{y.revenue} #{y.expense} #{y.balance} #{y.balance_total}" }
768
+
769
+ # Print maximal 10 years on GNUPlot.
770
+ if totaldat_file_c.count > 10
771
+ totaldat_file_c = totaldat_file_c.slice(-10, 10)
772
+ end
773
+
774
+ # Convert DAT file rows to one String.
775
+ totaldat_file_c = totaldat_file_c.join("\n")
776
+
777
+ # DAT file for GNUPlot.
778
+ totaldat_file_path = Pathname.new('total.dat').expand_path(@tmp_path)
779
+ totaldat_file = File.new(totaldat_file_path, 'w')
780
+ totaldat_file.write(totaldat_file_c)
781
+ totaldat_file.close
782
+
783
+ # Generate image with GNUPlot.
784
+ png_file_path = Pathname.new('total.png').expand_path(html_path)
785
+
786
+ gnuplot_file_path = Pathname.new('total.gp').expand_path(@tmp_path)
787
+ gnuplot_file = File.new(gnuplot_file_path, 'w')
788
+ gnuplot_file.puts("set title 'Total'")
789
+ gnuplot_file.puts("set xlabel 'Years'")
790
+ gnuplot_file.puts("set ylabel 'Euro'")
791
+ gnuplot_file.puts("set grid")
792
+ gnuplot_file.puts("set key below center horizontal noreverse enhanced autotitle box dashtype solid")
793
+ gnuplot_file.puts("set tics out nomirror")
794
+ gnuplot_file.puts("set border 3 front linetype black linewidth 1.0 dashtype solid")
795
+ gnuplot_file.puts("set xtics 1")
796
+ gnuplot_file.puts("set style line 1 linecolor rgb '#00ff00' linewidth 2 linetype 1 pointtype 2")
797
+ gnuplot_file.puts("set style line 2 linecolor rgb '#ff0000' linewidth 2 linetype 1 pointtype 2")
798
+ gnuplot_file.puts("set style line 3 linecolor rgb '#000000' linewidth 2 linetype 1 pointtype 2")
799
+ gnuplot_file.puts("set style line 4 linecolor rgb '#0000ff' linewidth 2 linetype 1 pointtype 2")
800
+ gnuplot_file.puts("set style data linespoints")
801
+ gnuplot_file.puts("set terminal png enhanced")
802
+ gnuplot_file.puts("set output '#{png_file_path}'")
803
+ gnuplot_file.puts("plot sum = 0, \\")
804
+ gnuplot_file.puts("\t'#{totaldat_file_path}' using 1:2 linestyle 1 title 'Revenue', \\")
805
+ gnuplot_file.puts("\t'' using 1:3 linestyle 2 title 'Expense', \\")
806
+ gnuplot_file.puts("\t'' using 1:4 linestyle 3 title 'Balance', \\")
807
+ gnuplot_file.puts("\t'' using 1:5 linestyle 4 title '∑ Balance'")
808
+ gnuplot_file.close
809
+
810
+ system("gnuplot #{gnuplot_file_path} &> /dev/null")
811
+
812
+ @logger.info('generate html done')
813
+ end
814
+
815
+ # Used by CSV Command.
816
+ def import_csv_file(file_path)
817
+ transaction_start
818
+
819
+ row_n = 0
820
+ csv_options = {
821
+ :col_sep => ',',
822
+ #:row_sep => "\n",
823
+ :headers => true,
824
+ :return_headers => false,
825
+ :skip_blanks => true,
826
+ # :encoding => 'UTF-8',
827
+ }
828
+ CSV.foreach(file_path, csv_options) do |row|
829
+ if @exit
830
+ break
831
+ end
832
+ row_n += 1
833
+
834
+ id = row.field('id')
835
+ date = row.field('date')
836
+ title = row.field('title')
837
+ revenue = row.field('revenue')
838
+ expense = row.field('expense')
839
+ # balance = row.field('balance')
840
+ category = row.field('category')
841
+ comment = row.field('comment')
842
+
843
+ added = add(Entry.new(id, title, date, revenue, expense, category, comment), true)
844
+
845
+ @logger.debug("import row '#{id}' -- #{added ? 'YES' : 'NO'}")
846
+ end
847
+
848
+ @logger.info('save data ...')
849
+
850
+ transaction_end
851
+ end
852
+
853
+ # Used by CSV Command.
854
+ def export_csv_file(file_path)
855
+ csv_options = {
856
+ :col_sep => ',',
857
+ :row_sep => "\n",
858
+ :headers => [
859
+ 'id', 'date', 'title', 'revenue', 'expense', 'balance', 'category', 'comment',
860
+ ],
861
+ :write_headers => true,
862
+ # :encoding => 'ISO-8859-1',
863
+ }
864
+ CSV.open(file_path, 'wb', csv_options) do |csv|
865
+ Dir[Pathname.new('month_*.yml').expand_path(@data_path)].each do |yaml_file_path|
866
+ @logger.info("export #{File.basename(yaml_file_path)}")
867
+
868
+ data = YAML.load_file(yaml_file_path)
869
+
870
+ data['days'].each do |day_name, day_items|
871
+ day_items.each do |entry|
872
+ csv << [
873
+ entry['id'],
874
+ entry['date'],
875
+ entry['title'],
876
+ NUMBER_FORMAT % entry['revenue'],
877
+ NUMBER_FORMAT % entry['expense'],
878
+ NUMBER_FORMAT % entry['balance'],
879
+ entry['category'],
880
+ entry['comment'],
881
+ ]
882
+ end
883
+ end
884
+ end
885
+ end
886
+ end
887
+
888
+ def entry_exist?(entry)
889
+ if !entry.is_a?(Entry)
890
+ raise ArgumentError, 'variable must be an Entry instance'
891
+ end
892
+
893
+ if @entries_index.count == 0
894
+ load_entries_index_file
895
+ end
896
+ @entries_index.include?(entry.id)
897
+ end
898
+
899
+ # Build an entry-by-id Hash.
900
+ # ID => Entry
901
+ def build_entry_by_id_index(force = false)
902
+ if @entries_by_ids.nil? || force
903
+ @logger.debug('build entry-by-id index')
904
+
905
+ glob = Pathname.new('month_*.yml').expand_path(@data_path)
906
+
907
+ @entries_by_ids = Dir[glob.to_s].map { |file_path|
908
+ data = YAML.load_file(file_path)
909
+ data['days'].map{ |day_name, day_items|
910
+ day_items.map{ |entry|
911
+ Entry.from_h(entry)
912
+ }
913
+ }
914
+ }.flatten.map{ |entry|
915
+ [entry.id, entry]
916
+ }.to_h
917
+ end
918
+ end
919
+
920
+ # Find an Entry by a given ID.
921
+ def find_entry_by_id(id)
922
+ build_entry_by_id_index
923
+
924
+ @entries_by_ids[id]
925
+ end
926
+
927
+ # Used by Clear Command.
928
+ def clear
929
+ c = 0
930
+
931
+ # Take the standard html path instead of --path option.
932
+ # Do not provide the functionality to delete files from --path.
933
+ # If a user uses --path to generate html files outside of the
934
+ # wallet path the user needs to manual remove these files.
935
+ children = @tmp_path.children + @html_path.children
936
+
937
+ children.each do |child|
938
+
939
+ if child.basename.to_s[0] == '.'
940
+ # Ignore 'hidden' files like .gitignore.
941
+ next
942
+ end
943
+
944
+ # puts "child #{child}"
945
+ FileUtils.rm_rf(child)
946
+
947
+ c += 1
948
+ if c > 100
949
+ # If something goes wrong do not delete to whole harddisk. ;)
950
+ break
951
+ end
952
+ end
953
+ end
954
+
955
+ private
956
+
957
+ # Create all needed subdirectories for this wallet.
958
+ def create_dirs
959
+ unless @dir_path.exist?
960
+ @dir_path.mkpath
961
+ end
962
+
963
+ unless @data_path.exist?
964
+ @data_path.mkpath
965
+ end
966
+
967
+ unless @tmp_path.exist?
968
+ @tmp_path.mkpath
969
+ end
970
+
971
+ # Ignore all files in wallet/tmp.
972
+ tmp_gitignore_path = Pathname.new('.gitignore').expand_path(@tmp_path)
973
+ unless tmp_gitignore_path.exist?
974
+ gitignore_file = File.open(tmp_gitignore_path, 'w')
975
+ gitignore_file.write('*')
976
+ gitignore_file.close
977
+ end
978
+
979
+ if @entries_index_file_path.exist?
980
+ load_entries_index_file
981
+ else
982
+ # When the entries index file does not exist from an older Wallet version.
983
+ build_entry_by_id_index(true)
984
+ @entries_index = @entries_by_ids.keys
985
+ save_entries_index_file
986
+ end
987
+ end
988
+
989
+ # Get the sums for a given day.
990
+ def calc_day(day, category = nil)
991
+ revenue = 0
992
+ expense = 0
993
+ balance = 0
994
+ if category
995
+ category.to_s.downcase!
996
+
997
+ day.each do |entry|
998
+ if entry['category'] == category
999
+ revenue += entry['revenue']
1000
+ expense += entry['expense']
1001
+ balance += entry['balance']
1002
+ end
1003
+ end
1004
+ else
1005
+ day.each do |entry|
1006
+ revenue += entry['revenue']
1007
+ expense += entry['expense']
1008
+ balance += entry['balance']
1009
+ end
1010
+ end
1011
+
1012
+ {
1013
+ :revenue => revenue,
1014
+ :expense => expense,
1015
+ :balance => balance,
1016
+ }
1017
+ end
1018
+
1019
+ # Get all used years.
1020
+ # Range is optional.
1021
+ def years(date_start = nil, date_end = nil)
1022
+ files = Array.new
1023
+ @data_path.each_child(false) do |file|
1024
+ if file.extname == '.yml' && /^month_/.match(file.to_s)
1025
+ files << file
1026
+ end
1027
+ end
1028
+
1029
+ date_start_year = 0
1030
+ date_start_year = date_start.year if date_start
1031
+
1032
+ date_end_year = 9999
1033
+ date_end_year = date_end.year if date_end
1034
+
1035
+ files
1036
+ .map{ |file| file.to_s[6, 4].to_i }
1037
+ .uniq
1038
+ .keep_if{ |year| year >= date_start_year && year <= date_end_year }
1039
+ .sort
1040
+ end
1041
+
1042
+ # Load the entries index file only once.
1043
+ def load_entries_index_file
1044
+ if @entries_index_is_loaded
1045
+ return
1046
+ end
1047
+
1048
+ @entries_index_is_loaded = true
1049
+ if @entries_index_file_path.exist?
1050
+ data = YAML.load_file(@entries_index_file_path.to_s)
1051
+ @entries_index = data['index']
1052
+ end
1053
+ end
1054
+
1055
+ # Save the entries index file.
1056
+ def save_entries_index_file
1057
+ store = YAML::Store.new(@entries_index_file_path.to_s)
1058
+ store.transaction do
1059
+ store['index'] = @entries_index
1060
+ end
1061
+ end
1062
+
1063
+ end
1064
+
1065
+ end
1066
1066
  end