syrup 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +6 -6
- data/.rspec +2 -1
- data/CHANGELOG.rdoc +2 -2
- data/Gemfile +4 -4
- data/README.rdoc +46 -28
- data/Rakefile +8 -10
- data/TODO.rdoc +15 -0
- data/lib/syrup/account.rb +95 -4
- data/lib/syrup/information_missing_error.rb +4 -0
- data/lib/syrup/institutions/institution_base.rb +111 -0
- data/lib/syrup/institutions/zions_bank.rb +173 -110
- data/lib/syrup/transaction.rb +13 -9
- data/lib/syrup/version.rb +3 -3
- data/lib/syrup.rb +27 -10
- data/spec/spec_helper.rb +15 -25
- data/spec/syrup/account_spec.rb +53 -0
- data/spec/syrup/institutions/institution_base_spec.rb +120 -0
- data/spec/syrup/institutions/zions_bank_spec.rb +25 -4
- data/spec/syrup/syrup_spec.rb +41 -0
- data/spec/syrup/transaction_spec.rb +20 -19
- data/syrup.gemspec +26 -26
- metadata +53 -63
- data/lib/syrup/extract.rb +0 -14
- data/lib/syrup/institutions/abstract_institution.rb +0 -26
- data/spec/syrup/extract_spec.rb +0 -12
data/.gitignore
CHANGED
@@ -1,6 +1,6 @@
|
|
1
|
-
*.swp
|
2
|
-
**/*.swp
|
3
|
-
*.gem
|
4
|
-
.bundle
|
5
|
-
Gemfile.lock
|
6
|
-
pkg/*
|
1
|
+
*.swp
|
2
|
+
**/*.swp
|
3
|
+
*.gem
|
4
|
+
.bundle
|
5
|
+
Gemfile.lock
|
6
|
+
pkg/*
|
data/.rspec
CHANGED
@@ -1 +1,2 @@
|
|
1
|
-
--color
|
1
|
+
--color
|
2
|
+
--format documentation
|
data/CHANGELOG.rdoc
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
0.0.1 (Date)
|
2
|
-
|
1
|
+
0.0.1 (Date)
|
2
|
+
|
3
3
|
* initial release
|
data/Gemfile
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
source "http://rubygems.org"
|
2
|
-
|
3
|
-
# Specify your gem's dependencies in syrup.gemspec
|
4
|
-
gemspec
|
1
|
+
source "http://rubygems.org"
|
2
|
+
|
3
|
+
# Specify your gem's dependencies in syrup.gemspec
|
4
|
+
gemspec
|
data/README.rdoc
CHANGED
@@ -1,28 +1,46 @@
|
|
1
|
-
= Syrup
|
2
|
-
|
3
|
-
Syrup
|
4
|
-
|
5
|
-
==
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
1
|
+
= Syrup
|
2
|
+
|
3
|
+
Syrup helps you to extract bank account information and transactions.
|
4
|
+
|
5
|
+
== Usage
|
6
|
+
|
7
|
+
# Setup an instance of the bank
|
8
|
+
zions_bank = Syrup.setup_institution('zions_bank') do |config|
|
9
|
+
config.username = 'user'
|
10
|
+
config.password = 'pass'
|
11
|
+
config.secret_questions = {'What is your secret question?' => "I don't know"}
|
12
|
+
end
|
13
|
+
|
14
|
+
# List accounts
|
15
|
+
zions_bank.accounts.each do |account|
|
16
|
+
puts "#{account.name} (#{account.current_balance}) # => "Checking (100.0)"
|
17
|
+
end
|
18
|
+
|
19
|
+
# Get transactions
|
20
|
+
account = zions_bank.find_account_by_id 123456
|
21
|
+
transactions = account.find_transactions(Date.today - 30) # => an array of Transactions from the last 30 days
|
22
|
+
transactions = account.find_transactions(Date.parse('2011-01-01'), Date.parse('2011-02-01') - 1) # => an array of Transactions from the month of January
|
23
|
+
|
24
|
+
== Installation
|
25
|
+
|
26
|
+
The latest version of Syrup can be installed with Rubygems:
|
27
|
+
|
28
|
+
[sudo] gem install "syrup"
|
29
|
+
|
30
|
+
In <b>Rails 3</b>, add this to your Gemfile and run the +bundle+ command.
|
31
|
+
|
32
|
+
gem "syrup"
|
33
|
+
|
34
|
+
In <b>Rails 2</b>, add this to your environment.rb file.
|
35
|
+
|
36
|
+
config.gem "syrup"
|
37
|
+
|
38
|
+
== Supported Institutions
|
39
|
+
|
40
|
+
Currently, only Zions Bank[http://zionsbank.com] is supported. I will be
|
41
|
+
implementing UCCU, USAA, and Wells Fargo (probably in that order). If you would
|
42
|
+
like support for a different bank, you have two options:
|
43
|
+
|
44
|
+
1. Get me the credentials to log into an account with that bank (you'd have to
|
45
|
+
trust me).
|
46
|
+
2. Implement it yourself and submit a pull request.
|
data/Rakefile
CHANGED
@@ -1,11 +1,9 @@
|
|
1
|
-
require 'bundler'
|
2
|
-
require 'rspec/core/rake_task'
|
3
|
-
|
4
|
-
Bundler::GemHelper.install_tasks
|
5
|
-
|
6
|
-
desc "Run
|
7
|
-
RSpec::Core::RakeTask.new
|
8
|
-
|
9
|
-
end
|
10
|
-
|
1
|
+
require 'bundler'
|
2
|
+
require 'rspec/core/rake_task'
|
3
|
+
|
4
|
+
Bundler::GemHelper.install_tasks
|
5
|
+
|
6
|
+
desc "Run tests"
|
7
|
+
RSpec::Core::RakeTask.new
|
8
|
+
|
11
9
|
task :default => :spec
|
data/TODO.rdoc
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
|
2
|
+
make sure that mechanize validates SSL certificates
|
3
|
+
|
4
|
+
pass in username, password, secret questions
|
5
|
+
|
6
|
+
zions.fetch_accounts
|
7
|
+
should I store things?
|
8
|
+
List accounts
|
9
|
+
* create an array of Account objects
|
10
|
+
|
11
|
+
account.transactions OR account.fetch_transactions
|
12
|
+
zions.
|
13
|
+
When getting transactions
|
14
|
+
* create an array of Transaction objects
|
15
|
+
* populate as many variables on Account as you can (eg. current_balance, etc.)
|
data/lib/syrup/account.rb
CHANGED
@@ -1,5 +1,96 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module Syrup
|
4
|
+
class Account
|
5
|
+
# known types are :deposit and :credit
|
6
|
+
attr_accessor :id
|
7
|
+
attr_writer :name, :type, :account_number, :current_balance, :available_balance, :prior_day_balance
|
8
|
+
|
9
|
+
def name
|
10
|
+
populate
|
11
|
+
@name
|
12
|
+
end
|
13
|
+
|
14
|
+
def type
|
15
|
+
populate
|
16
|
+
@type
|
17
|
+
end
|
18
|
+
|
19
|
+
def account_number
|
20
|
+
populate
|
21
|
+
@account_number
|
22
|
+
end
|
23
|
+
|
24
|
+
def current_balance
|
25
|
+
populate
|
26
|
+
@current_balance
|
27
|
+
end
|
28
|
+
|
29
|
+
def available_balance
|
30
|
+
populate
|
31
|
+
@available_balance
|
32
|
+
end
|
33
|
+
|
34
|
+
def prior_day_balance
|
35
|
+
populate
|
36
|
+
@prior_day_balance
|
37
|
+
end
|
38
|
+
|
39
|
+
def initialize(attr_hash = nil)
|
40
|
+
if attr_hash
|
41
|
+
attr_hash.each do |k, v|
|
42
|
+
instance_variable_set "@#{k}", v
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
@cached_transactions = []
|
47
|
+
end
|
48
|
+
|
49
|
+
def populated?
|
50
|
+
@populated
|
51
|
+
end
|
52
|
+
|
53
|
+
def populated=(value)
|
54
|
+
@populated = value
|
55
|
+
end
|
56
|
+
|
57
|
+
def populate
|
58
|
+
unless populated? || @institution.nil?
|
59
|
+
raise "The account id must not be nil when populating an account" if id.nil?
|
60
|
+
@institution.populate_account(id)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def ==(other_account)
|
65
|
+
other_account.id == self.id && other_account.is_a?(Account)
|
66
|
+
end
|
67
|
+
|
68
|
+
def find_transactions(starting_at, ending_at = Date.today)
|
69
|
+
return [] if starting_at > ending_at
|
70
|
+
|
71
|
+
@institution.fetch_transactions(self.id, starting_at, ending_at)
|
72
|
+
end
|
73
|
+
|
74
|
+
def merge!(account_with_info)
|
75
|
+
if account_with_info
|
76
|
+
account_with_info.instance_variables.each do |filled_var|
|
77
|
+
self.instance_variable_set(filled_var, account_with_info.instance_variable_get(filled_var))
|
78
|
+
end
|
79
|
+
end
|
80
|
+
self
|
81
|
+
end
|
82
|
+
|
83
|
+
def valid?
|
84
|
+
if @valid.nil?
|
85
|
+
populate
|
86
|
+
@valid = populated?
|
87
|
+
end
|
88
|
+
@valid
|
89
|
+
end
|
90
|
+
|
91
|
+
def valid=(validity)
|
92
|
+
@valid = validity
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
5
96
|
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
module Syrup
|
2
|
+
module Institutions
|
3
|
+
class InstitutionBase
|
4
|
+
|
5
|
+
class << self
|
6
|
+
def inherited(subclass)
|
7
|
+
@subclasses ||= []
|
8
|
+
@subclasses << subclass
|
9
|
+
end
|
10
|
+
|
11
|
+
def subclasses
|
12
|
+
@subclasses
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
attr_accessor :username, :password, :secret_questions
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@accounts = []
|
20
|
+
end
|
21
|
+
|
22
|
+
def setup
|
23
|
+
yield self
|
24
|
+
self
|
25
|
+
end
|
26
|
+
|
27
|
+
def populated?
|
28
|
+
@populated
|
29
|
+
end
|
30
|
+
|
31
|
+
def populated=(value)
|
32
|
+
@populated = value
|
33
|
+
end
|
34
|
+
|
35
|
+
def accounts
|
36
|
+
populate_accounts
|
37
|
+
@accounts
|
38
|
+
end
|
39
|
+
|
40
|
+
def find_account_by_id(account_id)
|
41
|
+
account = @accounts.find { |a| a.id == account_id }
|
42
|
+
unless account || populated?
|
43
|
+
account = Account.new(:id => account_id)
|
44
|
+
@accounts << account
|
45
|
+
end
|
46
|
+
account
|
47
|
+
end
|
48
|
+
|
49
|
+
def populate_account(account_id)
|
50
|
+
unless populated?
|
51
|
+
result = fetch_account(account_id)
|
52
|
+
return nil if result.nil?
|
53
|
+
|
54
|
+
if result.respond_to?(:each)
|
55
|
+
populate_accounts(result)
|
56
|
+
find_account_by_id(account_id)
|
57
|
+
else
|
58
|
+
result.populated = true
|
59
|
+
account = find_account_by_id(account_id)
|
60
|
+
account.merge! result if account
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def populate_accounts(populated_accounts = nil)
|
66
|
+
unless populated?
|
67
|
+
all_accounts = populated_accounts || fetch_accounts
|
68
|
+
|
69
|
+
# Remove any accounts that were added, that don't actually exist
|
70
|
+
@accounts.keep_if do |a|
|
71
|
+
if all_accounts.include?(a)
|
72
|
+
true
|
73
|
+
else
|
74
|
+
a.valid = false
|
75
|
+
false
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Add any additional account information
|
80
|
+
new_accounts = []
|
81
|
+
all_accounts.each do |filled_account|
|
82
|
+
account = @accounts.find { |a| a.id == filled_account.id }
|
83
|
+
|
84
|
+
filled_account.populated = true
|
85
|
+
|
86
|
+
# If we already had an account with this id, fill it with data
|
87
|
+
if account
|
88
|
+
account.merge! filled_account
|
89
|
+
else
|
90
|
+
new_accounts << filled_account
|
91
|
+
end
|
92
|
+
end
|
93
|
+
@accounts |= new_accounts # Uses set union
|
94
|
+
|
95
|
+
self.populated = true
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
protected
|
100
|
+
|
101
|
+
def agent
|
102
|
+
@agent ||= Mechanize.new
|
103
|
+
end
|
104
|
+
|
105
|
+
def parse_currency(currency)
|
106
|
+
currency.scan(/[0-9.]/).join.to_f
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -1,111 +1,174 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
json['accountBalance']['
|
30
|
-
new_account = Account.new
|
31
|
-
new_account.name = account['name']
|
32
|
-
new_account.
|
33
|
-
new_account.
|
34
|
-
new_account.
|
35
|
-
new_account.type = :
|
36
|
-
|
37
|
-
accounts << new_account
|
38
|
-
end
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
1
|
+
require 'date'
|
2
|
+
|
3
|
+
module Syrup
|
4
|
+
module Institutions
|
5
|
+
class ZionsBank < InstitutionBase
|
6
|
+
|
7
|
+
class << self
|
8
|
+
def name
|
9
|
+
"Zions Bank"
|
10
|
+
end
|
11
|
+
|
12
|
+
def id
|
13
|
+
"zions_bank"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def fetch_account(account_id)
|
18
|
+
fetch_accounts
|
19
|
+
end
|
20
|
+
|
21
|
+
def fetch_accounts
|
22
|
+
ensure_authenticated
|
23
|
+
|
24
|
+
# List accounts
|
25
|
+
page = agent.get('https://banking.zionsbank.com/ibuir/displayAccountBalance.htm')
|
26
|
+
json = ActiveSupport::JSON.decode(page.body)
|
27
|
+
|
28
|
+
accounts = []
|
29
|
+
json['accountBalance']['depositAccountList'].each do |account|
|
30
|
+
new_account = Account.new(:id => account['accountId'])
|
31
|
+
new_account.name = account['name']
|
32
|
+
new_account.account_number = account['number']
|
33
|
+
new_account.current_balance = parse_currency(account['currentAmt'])
|
34
|
+
new_account.available_balance = parse_currency(account['availableAmt'])
|
35
|
+
new_account.type = :deposit
|
36
|
+
|
37
|
+
accounts << new_account
|
38
|
+
end
|
39
|
+
json['accountBalance']['creditAccountList'].each do |account|
|
40
|
+
new_account = Account.new(:id => account['accountId'])
|
41
|
+
new_account.name = account['name']
|
42
|
+
new_account.account_number = account['number']
|
43
|
+
new_account.current_balance = parse_currency(account['balanceDueAmt'])
|
44
|
+
new_account.type = :credit
|
45
|
+
|
46
|
+
accounts << new_account
|
47
|
+
end
|
48
|
+
|
49
|
+
accounts
|
50
|
+
end
|
51
|
+
|
52
|
+
def fetch_transactions(account_id, starting_at, ending_at)
|
53
|
+
ensure_authenticated
|
54
|
+
|
55
|
+
transactions = []
|
56
|
+
|
57
|
+
post_vars = { "actAcct" => account_id, "dayRange.searchType" => "dates", "dayRange.startDate" => starting_at.strftime('%m/%d/%Y'), "dayRange.endDate" => ending_at.strftime('%m/%d/%Y'), "submit_view.x" => 11, "submit_view.y" => 11, "submit_view" => "view" }
|
58
|
+
|
59
|
+
page = agent.post("https://banking.zionsbank.com/zfnb/userServlet/app/bank/user/register_view_main?reSort=false&actAcct=#{account_id}", post_vars)
|
60
|
+
|
61
|
+
page.search('tr').each do |row_element|
|
62
|
+
data = []
|
63
|
+
datapart = row_element.css('.data')
|
64
|
+
if datapart
|
65
|
+
data += datapart
|
66
|
+
datapart = row_element.css('.curr')
|
67
|
+
data += datapart if datapart
|
68
|
+
end
|
69
|
+
|
70
|
+
datapart = row_element.css('.datagrey')
|
71
|
+
if datapart
|
72
|
+
data += datapart
|
73
|
+
datapart = row_element.css('.currgrey')
|
74
|
+
data += datapart if datapart
|
75
|
+
end
|
76
|
+
|
77
|
+
if data.size == 7
|
78
|
+
data.map! {|cell| cell.inner_html.strip.gsub(/[^ -~]/, '') }
|
79
|
+
|
80
|
+
transaction = Transaction.new
|
81
|
+
|
82
|
+
transaction.posted_at = Date.strptime(data[0], '%m/%d/%Y')
|
83
|
+
transaction.payee = data[2]
|
84
|
+
transaction.status = data[3].include?("Posted") ? :posted : :pending
|
85
|
+
unless data[4].empty?
|
86
|
+
transaction.amount = -parse_currency(data[4])
|
87
|
+
end
|
88
|
+
unless data[5].empty?
|
89
|
+
transaction.amount = parse_currency(data[5])
|
90
|
+
end
|
91
|
+
|
92
|
+
transactions << transaction
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# https://banking.zionsbank.com/zfnb/userServlet/app/bank/user/register_view_main?actAcct=498282&sortBy=Default&sortOrder=Default
|
97
|
+
# actAcct is the accountId (498282)
|
98
|
+
# dayRange.startDate, dayRange.endDate
|
99
|
+
# dayRange.searchType (dates or days, dates uses dayRange.startDate and dayRange.endDate, days uses dayRange.numberOfDays)
|
100
|
+
|
101
|
+
# The transactions table is messy. Cells we want either have data, curr, datagrey, or currgrey css class
|
102
|
+
# 1. The date (initiated or cleared? they're generally the same)
|
103
|
+
# 2. The type of transaction (Debit, Transfer Debit, ATM Debit, Deposit 3785596). This may be irrelevant because of the position of the transaction amount.)
|
104
|
+
# 3. The payee
|
105
|
+
# 4. The transaction status (Posted or ... Pending?)
|
106
|
+
# 5. The debit amount
|
107
|
+
# 6. The deposit amount
|
108
|
+
# 7. The then-current account balance
|
109
|
+
|
110
|
+
transactions
|
111
|
+
end
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def ensure_authenticated
|
116
|
+
|
117
|
+
# Check to see if already authenticated
|
118
|
+
page = agent.get('https://banking.zionsbank.com/ibuir')
|
119
|
+
if page.body.include?("SessionTimeOutException")
|
120
|
+
|
121
|
+
raise InformationMissingError, "Please supply a username" unless self.username
|
122
|
+
raise InformationMissingError, "Please supply a password" unless self.password
|
123
|
+
|
124
|
+
@agent = Mechanize.new
|
125
|
+
|
126
|
+
# Enter the username
|
127
|
+
page = agent.get('https://zionsbank.com')
|
128
|
+
form = page.form('logonForm')
|
129
|
+
form.publicCred1 = username
|
130
|
+
page = form.submit
|
131
|
+
|
132
|
+
# If the supplied username is incorrect, raise an exception
|
133
|
+
raise "Invalid username" if page.title == "Error Page"
|
134
|
+
|
135
|
+
# Go on to the next page
|
136
|
+
page = page.links.first.click
|
137
|
+
|
138
|
+
# Find the secret question
|
139
|
+
question = page.search('div.form_field')[2].css('div').inner_text
|
140
|
+
|
141
|
+
# If the answer to this question was not supplied, raise an exception
|
142
|
+
raise InformationMissingError, "Please answer the question, \"#{question}\"" unless secret_questions[question]
|
143
|
+
|
144
|
+
# Enter the answer to the secret question
|
145
|
+
form = page.forms.first
|
146
|
+
form["challengeEntry.answerText"] = secret_questions[question]
|
147
|
+
form.radiobutton_with(:value => 'false').check
|
148
|
+
submit_button = form.button_with(:name => '_eventId_submit')
|
149
|
+
page = form.submit(submit_button)
|
150
|
+
|
151
|
+
# If the supplied answer is incorrect, raise an exception
|
152
|
+
raise InformationMissingError, "\"#{secret_questions[question]}\" is not the correct answer to, \"#{question}\"" unless page.search('#errorComponent').empty?
|
153
|
+
|
154
|
+
# Enter the password
|
155
|
+
form = page.forms.first
|
156
|
+
form.privateCred1 = password
|
157
|
+
submit_button = form.button_with(:name => '_eventId_submit')
|
158
|
+
page = form.submit(submit_button)
|
159
|
+
|
160
|
+
# If the supplied password is incorrect, raise an exception
|
161
|
+
raise InformationMissingError, "An invalid password was supplied" unless page.search('#errorComponent').empty?
|
162
|
+
|
163
|
+
# Clicking this link logs us into the banking.zionsbank.com domain
|
164
|
+
page = page.links.first.click
|
165
|
+
|
166
|
+
raise "Unknown URL reached. Try logging in manually through a browser." if page.uri.to_s != "https://banking.zionsbank.com/ibuir/displayUserInterface.htm"
|
167
|
+
end
|
168
|
+
|
169
|
+
true
|
170
|
+
end
|
171
|
+
|
172
|
+
end
|
173
|
+
end
|
111
174
|
end
|