thefox-wallet 0.17.1 → 0.18.0

Sign up to get free protection for your applications and to get access to all the features.
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