gnucash 1.0.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/.gitignore +20 -0
- data/.rspec +2 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +29 -0
- data/Rakefile +23 -0
- data/gnucash.gemspec +27 -0
- data/lib/gnucash.rb +17 -0
- data/lib/gnucash/account.rb +91 -0
- data/lib/gnucash/account_transaction.rb +23 -0
- data/lib/gnucash/book.rb +84 -0
- data/lib/gnucash/transaction.rb +41 -0
- data/lib/gnucash/value.rb +97 -0
- data/lib/gnucash/version.rb +4 -0
- data/spec/books/sample-text.gnucash +30246 -0
- data/spec/books/sample.gnucash +0 -0
- data/spec/gnucash/account_spec.rb +40 -0
- data/spec/gnucash/book_spec.rb +39 -0
- data/spec/gnucash/transaction_spec.rb +38 -0
- data/spec/gnucash/value_spec.rb +96 -0
- data/spec/gnucash_spec.rb +12 -0
- data/spec/spec_helper.rb +5 -0
- metadata +172 -0
data/.gitignore
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
.bundle
|
4
|
+
.config
|
5
|
+
.yardoc
|
6
|
+
Gemfile.lock
|
7
|
+
InstalledFiles
|
8
|
+
_yardoc
|
9
|
+
coverage
|
10
|
+
doc/
|
11
|
+
lib/bundler/man
|
12
|
+
pkg
|
13
|
+
rdoc
|
14
|
+
spec/reports
|
15
|
+
test/tmp
|
16
|
+
test/version_tmp
|
17
|
+
tmp
|
18
|
+
test.rb
|
19
|
+
spec/books/*.gnucash.*.*
|
20
|
+
spec/books/*.LCK
|
data/.rspec
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Josh Holtrop
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# Gnucash
|
2
|
+
|
3
|
+
Ruby library for extracting data from GnuCash data files
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
gem 'gnucash'
|
10
|
+
|
11
|
+
And then execute:
|
12
|
+
|
13
|
+
$ bundle
|
14
|
+
|
15
|
+
Or install it yourself as:
|
16
|
+
|
17
|
+
$ gem install gnucash
|
18
|
+
|
19
|
+
## Usage
|
20
|
+
|
21
|
+
book = Gnucash.open("MyBook.gnucash")
|
22
|
+
|
23
|
+
## Contributing
|
24
|
+
|
25
|
+
1. Fork it
|
26
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
27
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
28
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
29
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
2
|
+
require "rake/clean"
|
3
|
+
require "rspec/core/rake_task"
|
4
|
+
require "rdoc/task"
|
5
|
+
require "yard"
|
6
|
+
|
7
|
+
CLEAN.include "rdoc"
|
8
|
+
CLEAN.include "pkg"
|
9
|
+
CLEAN.include "coverage"
|
10
|
+
|
11
|
+
YARD::Rake::YardocTask.new do |yard|
|
12
|
+
yard.files = ['lib/**/*.rb']
|
13
|
+
end
|
14
|
+
|
15
|
+
RSpec::Core::RakeTask.new("spec")
|
16
|
+
|
17
|
+
Rake::RDocTask.new(:rdoc) do |rdoc|
|
18
|
+
rdoc.rdoc_dir = 'rdoc'
|
19
|
+
rdoc.title = "Ruby library for extracting data from GnuCash data files"
|
20
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
21
|
+
end
|
22
|
+
|
23
|
+
task :default => :spec
|
data/gnucash.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'gnucash/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "gnucash"
|
8
|
+
gem.version = Gnucash::VERSION
|
9
|
+
gem.authors = ["Josh Holtrop"]
|
10
|
+
gem.email = ["jholtrop@gmail.com"]
|
11
|
+
gem.description = %q{Ruby library for extracting data from GnuCash data files}
|
12
|
+
gem.summary = %q{Extract data from GnuCash data files}
|
13
|
+
gem.homepage = ""
|
14
|
+
|
15
|
+
gem.files = `git ls-files`.split($/)
|
16
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
17
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
18
|
+
gem.require_paths = ["lib"]
|
19
|
+
|
20
|
+
gem.add_dependency "nokogiri"
|
21
|
+
|
22
|
+
gem.add_development_dependency "simplecov"
|
23
|
+
gem.add_development_dependency "rspec"
|
24
|
+
gem.add_development_dependency "rspec-core"
|
25
|
+
gem.add_development_dependency "rspec-expectations"
|
26
|
+
gem.add_development_dependency "rspec-mocks"
|
27
|
+
end
|
data/lib/gnucash.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require "gnucash/account"
|
2
|
+
require "gnucash/account_transaction"
|
3
|
+
require "gnucash/book"
|
4
|
+
require "gnucash/transaction"
|
5
|
+
require "gnucash/value"
|
6
|
+
require "gnucash/version"
|
7
|
+
|
8
|
+
# Namespace module for gnucash gem functionality
|
9
|
+
module Gnucash
|
10
|
+
# Open a GnuCash book from file.
|
11
|
+
# The file can be either a plain-text XML file or a gzipped XML file.
|
12
|
+
# === Arguments
|
13
|
+
# +fname+ _String_:: Name of the file to open.
|
14
|
+
def self.open(fname)
|
15
|
+
Book.new(fname)
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
module Gnucash
|
2
|
+
# Represent a GnuCash account object
|
3
|
+
class Account
|
4
|
+
# _String_: The name of the account (unqualified)
|
5
|
+
attr_accessor :name
|
6
|
+
|
7
|
+
# _String_: The account type (such as "EXPENSE")
|
8
|
+
attr_accessor :type
|
9
|
+
|
10
|
+
# _String_: The GUID of the account
|
11
|
+
attr_accessor :id
|
12
|
+
|
13
|
+
# _Array_: List of _AccountTransaction_ transactions associated with this
|
14
|
+
# account.
|
15
|
+
attr_accessor :transactions
|
16
|
+
|
17
|
+
# Create an Account object.
|
18
|
+
# === Arguments
|
19
|
+
# +book+ _Book_:: The Gnucash::Book containing the account
|
20
|
+
# +node+ _Nokogiri::XML::Node_:: Nokogiri XML node
|
21
|
+
def initialize(book, node)
|
22
|
+
@book = book
|
23
|
+
@node = node
|
24
|
+
@name = node.xpath('act:name').text
|
25
|
+
@type = node.xpath('act:type').text
|
26
|
+
@id = node.xpath('act:id').text
|
27
|
+
@parent_id = node.xpath('act:parent').text
|
28
|
+
@parent_id = nil if @parent_id == ""
|
29
|
+
@transactions = []
|
30
|
+
@balances = []
|
31
|
+
end
|
32
|
+
|
33
|
+
# Return the fully qualified account name
|
34
|
+
def full_name
|
35
|
+
prefix = ""
|
36
|
+
if @parent_id
|
37
|
+
parent = @book.find_account_by_id(@parent_id)
|
38
|
+
if parent and parent.type != 'ROOT'
|
39
|
+
prefix = parent.full_name + "::"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
prefix + name
|
43
|
+
end
|
44
|
+
|
45
|
+
# Internal method used to associate a transaction with the account
|
46
|
+
def add_transaction(act_txn)
|
47
|
+
@transactions << act_txn
|
48
|
+
end
|
49
|
+
|
50
|
+
# Internal method used to complete initialization of the Account after
|
51
|
+
# all transactions have been associated with it.
|
52
|
+
def finalize
|
53
|
+
@transactions.sort! { |a, b| a.date <=> b.date }
|
54
|
+
balance = Value.new(0)
|
55
|
+
@balances = @transactions.map do |act_txn|
|
56
|
+
balance += act_txn.value
|
57
|
+
{
|
58
|
+
date: act_txn.date,
|
59
|
+
value: balance,
|
60
|
+
}
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Return the final balance of the account as a _Gnucash::Value_
|
65
|
+
def final_balance
|
66
|
+
return Value.new(0) unless @balances.size > 0
|
67
|
+
@balances.last[:value]
|
68
|
+
end
|
69
|
+
|
70
|
+
# Return the balance of the account as of the date given as a
|
71
|
+
# _Gnucash::Value_. Transactions that occur on the given date are included
|
72
|
+
# in the returned balance.
|
73
|
+
def balance_on(date)
|
74
|
+
return Value.new(0) unless @balances.size > 0
|
75
|
+
return Value.new(0) if @balances.first[:date] > date
|
76
|
+
return @balances.last[:value] if date >= @balances.last[:date]
|
77
|
+
imin = 0
|
78
|
+
imax = @balances.size - 2
|
79
|
+
idx = imax / 2
|
80
|
+
until @balances[idx][:date] <= date and @balances[idx + 1][:date] > date
|
81
|
+
if @balances[idx][:date] <= date
|
82
|
+
imin = idx + 1
|
83
|
+
else
|
84
|
+
imax = idx
|
85
|
+
end
|
86
|
+
idx = (imin + imax) / 2
|
87
|
+
end
|
88
|
+
@balances[idx][:value]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Gnucash
|
2
|
+
# Class to link a transaction object to an Account.
|
3
|
+
class AccountTransaction
|
4
|
+
# _Gnucash::Value_: The transaction value for the linked account
|
5
|
+
attr_accessor :value
|
6
|
+
|
7
|
+
# Construct an AccountTransaction object.
|
8
|
+
# This method is used internally when building a Transaction object.
|
9
|
+
# === Arguments
|
10
|
+
# +real_txn+ _Gnucash::Transaction_:: The linked Transaction object
|
11
|
+
# +value+ _Gnucash::Value_::
|
12
|
+
# The value of the Transaction split for this account
|
13
|
+
def initialize(real_txn, value)
|
14
|
+
@real_txn = real_txn
|
15
|
+
@value = value
|
16
|
+
end
|
17
|
+
|
18
|
+
# Pass through any missing method calls to the linked Transaction object
|
19
|
+
def method_missing(*args)
|
20
|
+
@real_txn.send(*args)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
data/lib/gnucash/book.rb
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
require "zlib"
|
2
|
+
require "nokogiri"
|
3
|
+
|
4
|
+
module Gnucash
|
5
|
+
# Represent a GnuCash Book
|
6
|
+
class Book
|
7
|
+
# _Array_ of _Gnucash::Account_ objects in the book
|
8
|
+
attr_accessor :accounts
|
9
|
+
|
10
|
+
# _Array_ of _Gnucash::Transaction_ objects in the book
|
11
|
+
attr_accessor :transactions
|
12
|
+
|
13
|
+
# _String_ in "YYYY-MM-DD" format of the first transaction in the book
|
14
|
+
attr_accessor :start_date
|
15
|
+
|
16
|
+
# _String_ in "YYYY-MM-DD" format of the last transaction in the book
|
17
|
+
attr_accessor :end_date
|
18
|
+
|
19
|
+
# Construct a Book object.
|
20
|
+
# Normally called internally by Gnucash.open()
|
21
|
+
# === Arguments
|
22
|
+
# +fname+ _String_:: The file name of the GnuCash file to open.
|
23
|
+
def initialize(fname)
|
24
|
+
begin
|
25
|
+
@ng = Nokogiri.XML(Zlib::GzipReader.open(fname).read)
|
26
|
+
rescue Zlib::GzipFile::Error
|
27
|
+
@ng = Nokogiri.XML(File.read(fname))
|
28
|
+
end
|
29
|
+
book_nodes = @ng.xpath('/gnc-v2/gnc:book')
|
30
|
+
if book_nodes.count != 1
|
31
|
+
raise "Error: Expected to find one gnc:book entry"
|
32
|
+
end
|
33
|
+
@book_node = book_nodes.first
|
34
|
+
build_accounts
|
35
|
+
build_transactions
|
36
|
+
finalize
|
37
|
+
end
|
38
|
+
|
39
|
+
# Return a handle to the Account object that has the given GUID.
|
40
|
+
# === Arguments
|
41
|
+
# +id+ _String_:: GUID
|
42
|
+
# === Return
|
43
|
+
# _Gnucash::Account_ or +nil+
|
44
|
+
def find_account_by_id(id)
|
45
|
+
@accounts.find { |a| a.id == id }
|
46
|
+
end
|
47
|
+
|
48
|
+
# Return a handle to the Account object that has the given fully-qualified
|
49
|
+
# name.
|
50
|
+
# === Arguments
|
51
|
+
# +full_name+ _String_::
|
52
|
+
# Fully-qualified account name (ex: "Expenses::Auto::Gas")
|
53
|
+
# === Return
|
54
|
+
# _Gnucash::Account_ or +nil+
|
55
|
+
def find_account_by_full_name(full_name)
|
56
|
+
@accounts.find { |a| a.full_name == full_name }
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def build_accounts
|
62
|
+
@accounts = @book_node.xpath('gnc:account').map do |act_node|
|
63
|
+
Account.new(self, act_node)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def build_transactions
|
68
|
+
@start_date = nil
|
69
|
+
@end_date = nil
|
70
|
+
@transactions = @book_node.xpath('gnc:transaction').map do |txn_node|
|
71
|
+
Transaction.new(self, txn_node).tap do |txn|
|
72
|
+
@start_date = txn.date if @start_date.nil? or txn.date < @start_date
|
73
|
+
@end_date = txn.date if @end_date.nil? or txn.date > @end_date
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def finalize
|
79
|
+
@accounts.each do |account|
|
80
|
+
account.finalize
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module Gnucash
|
2
|
+
# Represent a GnuCash transaction.
|
3
|
+
# Transactions have multiple splits with individual values.
|
4
|
+
# Splits are created as AccountTransaction objects which are associated
|
5
|
+
# with an individual account.
|
6
|
+
class Transaction
|
7
|
+
# _String_: The date of the transaction, in ISO format ("YYYY-MM-DD")
|
8
|
+
attr_accessor :date
|
9
|
+
|
10
|
+
# _String_: The GUID of the transaction
|
11
|
+
attr_accessor :id
|
12
|
+
|
13
|
+
# _String_: The description of the transaction
|
14
|
+
attr_accessor :description
|
15
|
+
|
16
|
+
# Create a new Transaction object
|
17
|
+
# === Arguments
|
18
|
+
# +book+ _Book_:: The Gnucash::Book containing the transaction
|
19
|
+
# +node+ _Nokogiri::XML::Node_:: Nokogiri XML node
|
20
|
+
def initialize(book, node)
|
21
|
+
@book = book
|
22
|
+
@node = node
|
23
|
+
@id = node.xpath('trn:id').text
|
24
|
+
@date = node.xpath('trn:date-posted/ts:date').text.split(' ').first
|
25
|
+
@description = node.xpath('trn:description').text
|
26
|
+
@splits = node.xpath('trn:splits/trn:split').map do |split_node|
|
27
|
+
value = Value.new(split_node.xpath('split:value').text)
|
28
|
+
account_id = split_node.xpath('split:account').text
|
29
|
+
account = @book.find_account_by_id(account_id)
|
30
|
+
unless account
|
31
|
+
raise "Could not find account with ID #{account_id} for transaction #{@id}"
|
32
|
+
end
|
33
|
+
account.add_transaction(AccountTransaction.new(self, value))
|
34
|
+
{
|
35
|
+
account_id: account_id,
|
36
|
+
value: value,
|
37
|
+
}
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
module Gnucash
|
2
|
+
# Represent a currency value as an integer so that integer math can be used
|
3
|
+
# for accuracy in computations.
|
4
|
+
class Value
|
5
|
+
include Comparable
|
6
|
+
|
7
|
+
# _Fixnum_:: The raw, undivided integer value
|
8
|
+
attr_accessor :val
|
9
|
+
|
10
|
+
# Create a new Value object with value 0
|
11
|
+
def self.zero
|
12
|
+
Value.new(0)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Construct a Value object
|
16
|
+
# === Arguments
|
17
|
+
# +val+ _String_ or _Fixnum_::
|
18
|
+
# Either a _String_ in the form "1234/100" or an integer containing the
|
19
|
+
# raw value
|
20
|
+
# +div+ _Fixnum_::
|
21
|
+
# The divisor value to use (when +val+ is given as a _Fixnum_)
|
22
|
+
def initialize(val, div = 100)
|
23
|
+
if val.is_a?(String)
|
24
|
+
if val =~ /^(-?\d+)\/(\d+)$/
|
25
|
+
@val = $1.to_i
|
26
|
+
@div = $2.to_i
|
27
|
+
else
|
28
|
+
raise "Unexpected value string: #{val.inspect}"
|
29
|
+
end
|
30
|
+
elsif val.is_a?(Fixnum)
|
31
|
+
@val = val
|
32
|
+
@div = div
|
33
|
+
else
|
34
|
+
raise "Unexpected value type: #{val.class}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Add to a Value object
|
39
|
+
# +other+ can be another Value or a Numeric
|
40
|
+
def +(other)
|
41
|
+
if other.is_a?(Value)
|
42
|
+
Value.new(@val + other.val)
|
43
|
+
elsif other.is_a?(Numeric)
|
44
|
+
(to_f + other).round(2)
|
45
|
+
else
|
46
|
+
raise "Unexpected argument"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Subtract from a Value object
|
51
|
+
# +other+ can be another Value or a Numeric
|
52
|
+
def -(other)
|
53
|
+
if other.is_a?(Value)
|
54
|
+
Value.new(@val - other.val)
|
55
|
+
elsif other.is_a?(Numeric)
|
56
|
+
(to_f - other).round(2)
|
57
|
+
else
|
58
|
+
raise "Unexpected argument"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Multiply a Value object
|
63
|
+
# +other+ should be a Numeric
|
64
|
+
def *(other)
|
65
|
+
if other.is_a?(Numeric)
|
66
|
+
(to_f * other).round(2)
|
67
|
+
else
|
68
|
+
raise "Unexpected argument"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Divide a Value object
|
73
|
+
# +other+ should be a Numeric
|
74
|
+
def /(other)
|
75
|
+
if other.is_a?(Numeric)
|
76
|
+
(to_f / other).round(2)
|
77
|
+
else
|
78
|
+
raise "Unexpected argument"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Represent the Value as a string (two decimal places)
|
83
|
+
def to_s
|
84
|
+
sprintf("%.02f", to_f)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Convert the Value to a Float
|
88
|
+
def to_f
|
89
|
+
@val / @div.to_f
|
90
|
+
end
|
91
|
+
|
92
|
+
# Compare two Value objects
|
93
|
+
def <=>(other)
|
94
|
+
@val <=> other.val
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|