diff --git a/scripts/boomoney b/scripts/boomoney
new file mode 100644
index 0000000000000000000000000000000000000000..31e6eb97ee27ec8dcbb48419c3c29b5d5c97deb6
--- /dev/null
+++ b/scripts/boomoney
@@ -0,0 +1,27 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# vim: ft=python et softtabstop=4 cinoptions=4 shiftwidth=4 ts=4 ai
+
+# Written by Bruno Chabrier
+#
+# This file is part of weboob.
+#
+# weboob is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# weboob is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with weboob. If not, see .
+
+from __future__ import absolute_import
+from weboob.applications.boomoney import Boomoney
+
+
+if __name__ == '__main__':
+ Boomoney.run()
diff --git a/weboob/applications/boomoney/__init__.py b/weboob/applications/boomoney/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..41f7bdf395c6ec6093d3dc6d76695b0567b6d6ef
--- /dev/null
+++ b/weboob/applications/boomoney/__init__.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+
+# Written by Bruno Chabrier
+#
+# This file is part of weboob.
+#
+# weboob is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# weboob is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with weboob. If not, see .
+
+
+from .boomoney import Boomoney
+
+__all__ = ['Boomoney']
diff --git a/weboob/applications/boomoney/boomoney.py b/weboob/applications/boomoney/boomoney.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd3c3ef72eed98f012e84d983989b02ce886e93f
--- /dev/null
+++ b/weboob/applications/boomoney/boomoney.py
@@ -0,0 +1,533 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2009-2011 Romain Bignon, Christophe Benz
+#
+# This file is part of weboob.
+#
+# weboob is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# weboob is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with weboob. If not, see .
+
+from __future__ import print_function
+
+# start with:
+# set PYTHONPATH=D:\Dropbox\Projets\boomoney
+# python d:\Dropbox\Projets\boomoney\scripts\boomoney -N
+
+
+from pprint import pprint
+from threading import Thread, Lock
+import time
+import copy
+import sys
+import StringIO
+import os
+import re
+import subprocess
+import datetime
+from optparse import OptionGroup
+
+import shutil
+from colorama import init, Fore, Back, Style
+
+from weboob.exceptions import BrowserUnavailable
+from weboob.capabilities.bank import AccountNotFound
+from weboob.applications.boobank import Boobank
+from weboob.applications.boobank.boobank import OfxFormatter
+from weboob.tools.application.formatters.simple import SimpleFormatter
+
+
+__all__ = ['Boomoney']
+
+printMutex = Lock()
+numMutex = Lock()
+backupMutex = Lock()
+
+
+class MoneyOfxFormatter(OfxFormatter):
+ def __init__(self):
+ # MSMoney only supports CHECKING accounts
+ self.account_type = 'CHECKING'
+
+ def output(self, formatted):
+ if self.outfile != sys.stdout:
+ self.outfile.write(formatted + os.linesep)
+ else:
+ super.output(self, formatted)
+
+
+class ListFormatter(SimpleFormatter):
+ def output(self, formatted):
+ if self.outfile != sys.stdout:
+ self.outfile.write(formatted + os.linesep)
+ else:
+ super.output(self, formatted)
+
+
+class BoobankNoBackend(Boobank):
+ EXTRA_FORMATTERS = {'ops_list': MoneyOfxFormatter}
+ COMMANDS_FORMATTERS = {'history': 'ops_list'}
+
+ def load_default_backends(self):
+ pass
+
+ def bcall_error_handler(self, backend, error, backtrace):
+ handled = False
+ if isinstance(error, BrowserUnavailable):
+ handled = True
+ self.error = True
+ if isinstance(error, AccountNotFound):
+ handled = True
+ self.error = True
+ if isinstance(error, NotImplementedError):
+ handled = True
+ self.error = False
+ if not handled:
+ self.error = True
+ self.weboob.logger.error("Unsupported error %s in BoobankNoBackend" % type(error))
+ return super(Boobank, self).bcall_error_handler(backend, error, backtrace)
+
+
+class HistoryThread(Thread):
+ def __init__(self, boomoney, account):
+ Thread.__init__(self)
+ self.boomoney = boomoney
+ self.account = account
+ self.disabled = boomoney.config.get(account, 'disabled', default=False)
+ self.date_min = boomoney.config.get(account, 'date_min', default='')
+ self.last_date = boomoney.config.get(account, 'last_date', default='')
+
+ @property
+ def label(self):
+ if self.account in self.boomoney.labels:
+ return self.boomoney.labels[self.account]
+ else:
+ return self.boomoney.config.get(self.account, 'label', default='')
+
+ def dumpTransaction(self, output, fields, field):
+ output.write("\n")
+ if "DTUSER" in field and "DTPOSTED" in field and not field["DTUSER"] == field["DTPOSTED"]:
+ # the payment date is a deferred payment
+ # MSMoney takes DTPOSTED, which is the payment date
+ # I prefer to have the date of the operation, so I set DTPOSTED
+ # as DTUSER
+ field["DTPOSTED"] = field["DTUSER"]
+ for f in fields.strip().split(" "):
+ value = field[f]
+ if f == "NAME":
+ if value == "":
+ # MSMoney does not support empty NAME field
+ value = ""
+ else:
+ # MSMoney does not support NAME field longer than 64
+ value = value[:64]
+ output.write("<%s>%s\n" % (f, value))
+ output.write("\n")
+
+ def run(self):
+
+ now = datetime.datetime.now().strftime("%Y-%m-%d")
+
+ if self.boomoney.options.force:
+ from_date = self.date_min
+ else:
+ from_date = self.last_date
+
+ if from_date >= now:
+ self.boomoney.print(Style.BRIGHT + "%s (%s): Last import date is %s, no need to import again..." % (
+ self.account, self.label, self.last_date) + Style.RESET_ALL)
+ return
+
+ boobank = self.boomoney.createBoobank(self.account)
+ if boobank is None:
+ with numMutex:
+ self.boomoney.importIndex = self.boomoney.importIndex + 1
+ return
+
+ boobank.stderr = StringIO.StringIO()
+ boobank.stdout = boobank.stderr
+ id, backend = self.account.split("@")
+ module_name, foo = boobank.weboob.backends_config.get_backend(backend)
+ moduleHandler = "%s.bat" % os.path.join(os.path.dirname(self.boomoney.getMoneyFile()), module_name)
+ self.boomoney.logger.info("Starting history of %s (%s)..." % (self.account, self.label))
+
+ MAX_RETRIES = 3
+ count = 0
+ found = False
+ content = ''
+ boobank.error = False
+ while count <= MAX_RETRIES and not (found and not boobank.error):
+ boobank.options.outfile = StringIO.StringIO()
+ boobank.error = False
+
+ # executing history command
+ boobank.onecmd("history " + self.account + " " + from_date)
+
+ if count > 0:
+ self.boomoney.logger.info("Retrying %s (%s)... %i/%i" % (self.account, self.label, count, MAX_RETRIES))
+ found = re.match(r'^OFXHEADER:100', boobank.options.outfile.getvalue())
+ if found and not boobank.error:
+ content = boobank.options.outfile.getvalue()
+ boobank.options.outfile.close()
+ count = count + 1
+ if content == '':
+ # error occurred
+ with numMutex:
+ self.boomoney.importIndex = self.boomoney.importIndex + 1
+ index = self.boomoney.importIndex
+ self.boomoney.logger.error("(%i/%i) %s (%s): %saborting after %i retries.%s" % (
+ index, len(self.boomoney.threads),
+ self.account,
+ self.label,
+ Fore.RED + Style.BRIGHT,
+ MAX_RETRIES,
+ Style.RESET_ALL))
+ return
+
+ # postprocessing of the ofx content to match MSMoney expectations
+ content = re.sub(r'Not loaded', r'', content)
+ input = StringIO.StringIO(content)
+ output = StringIO.StringIO()
+ field = {}
+ fields = ' '
+ for line in input:
+ if re.match(r'^OFXHEADER:100', line):
+ inTransaction = False
+ if re.match(r'^', line):
+ inTransaction = True
+ if not inTransaction:
+ output.write(line)
+ if re.match(r'^', line):
+ # MSMoney expects CHECKNUM instead of NAME for CHECK transactions
+ if "TRNTYPE" in field and field["TRNTYPE"] == "CHECK":
+ if "NAME" in field and unicode(field["NAME"]).isnumeric():
+ field["CHECKNUM"] = field["NAME"]
+ del field["NAME"]
+ fields = fields.replace(' NAME ', ' CHECKNUM ')
+
+ # go through specific backend process if any
+ IGNORE = False
+ NEW = None
+ origfields = fields
+ origfield = field.copy()
+ if os.path.exists(moduleHandler):
+ self.boomoney.logger.info("Calling backend handler %s..." % moduleHandler)
+ # apply the transformations, in the form
+ # field_NAME=...
+ # field_MEMO=...
+ # field=...
+
+ cmd = 'cmd /C '
+ for f in field:
+ value = field[f]
+ cmd = cmd + 'set field_%s=%s& ' % (f, value)
+
+ cmd = cmd + '"' + moduleHandler + '"'
+ result = subprocess.check_output(cmd.encode(sys.stdout.encoding))
+
+ for line in re.split(r'[\r\n]+', result):
+ if not line == "":
+ f, value = line.split("=", 1)
+
+ if f == "IGNORE":
+ IGNORE = True
+ elif f == "NEW":
+ NEW = value
+ elif f.startswith('field_'):
+ f = re.sub(r'^field_', '', f)
+ if value == "":
+ if f in field:
+ del field[f]
+ fields = re.sub(" " + f + " ", " ", fields)
+ else:
+ field[f] = value
+ if f not in fields.strip().split(" "):
+ fields = fields + f + " "
+
+ if not IGNORE:
+ # dump transaction
+ self.dumpTransaction(output, fields, field)
+
+ if NEW is not None:
+ for n in NEW.strip().split(" "):
+ fields = origfields
+ field = origfield.copy()
+ field["FITID"] = origfield["FITID"] + "_" + n
+ for line in re.split(r'[\r\n]+', result):
+ if not line == "":
+ f, value = line.split("=", 1)
+
+ if f.startswith(n + '_field_'):
+ f = re.sub(r'^.*_field_', '', f)
+ field[f] = value
+ if f not in fields.strip().split(" "):
+ fields = fields + f + " "
+ # dump secondary transaction
+ self.dumpTransaction(output, fields, field)
+
+ inTransaction = False
+ if inTransaction:
+ if re.match(r'^', line):
+ field = {}
+ fields = ' '
+ else:
+ t = line.split(">", 1)
+ v = re.split(r'[\r\n]', t[1])
+ field[t[0][1:]] = v[0]
+ fields = fields + t[0][1:] + ' '
+
+ ofxcontent = output.getvalue()
+ stderrcontent = boobank.stderr.getvalue()
+ input.close()
+ output.close()
+ boobank.stderr.close()
+
+ if self.boomoney.options.display:
+ self.boomoney.print(Style.BRIGHT + ofxcontent + Style.RESET_ALL)
+
+ nbTransactions = ofxcontent.count('')
+
+ # create ofx file
+ fname = re.sub(r'[^\w@\. ]', '_', self.account + " " + self.label)
+ ofxfile = os.path.join(self.boomoney.getDownloadsPath(), fname + ".ofx")
+ with open(ofxfile, "w") as ofx_file:
+ ofx_file.write(re.sub(r'\r\n', r'\n', ofxcontent.encode(sys.stdout.encoding)))
+
+ with numMutex:
+ self.boomoney.write(stderrcontent)
+ self.boomoney.importIndex = self.boomoney.importIndex + 1
+ index = self.boomoney.importIndex
+ if not (self.boomoney.options.noimport or nbTransactions == 0):
+ self.boomoney.backupIfNeeded()
+ with printMutex:
+ if self.boomoney.options.noimport or nbTransactions == 0:
+ if nbTransactions == 0:
+ print(Style.BRIGHT + '(%i/%i) %s (%s) (no transaction).' % (
+ index, len(self.boomoney.threads),
+ self.account,
+ self.label
+ ) + Style.RESET_ALL)
+ else:
+ print(Fore.GREEN + Style.BRIGHT + '(%i/%i) %s (%s) (%i transaction(s)).' % (
+ index, len(self.boomoney.threads),
+ self.account,
+ self.label,
+ nbTransactions
+ ) + Style.RESET_ALL)
+ else:
+ # import into money
+ print(Fore.GREEN + Style.BRIGHT + '(%i/%i) Importing "%s" into MSMoney (%i transaction(s))...' % (
+ index, len(self.boomoney.threads),
+ ofxfile,
+ nbTransactions
+ ) + Style.RESET_ALL)
+ if not self.boomoney.options.noimport:
+ if nbTransactions > 0:
+ subprocess.check_call('"%s" %s' % (
+ os.path.join(self.boomoney.getMoneyPath(), "mnyimprt.exe"),
+ ofxfile))
+ self.last_date = now
+
+
+class Boomoney(Boobank):
+ APPNAME = 'boomoney'
+ VERSION = '1.4'
+ COPYRIGHT = 'Copyright(C) 2018-YEAR Bruno Chabrier'
+ DESCRIPTION = "Console application that imports bank accounts into Microsoft Money"
+ SHORT_DESCRIPTION = "import bank accounts into Microsoft Money"
+
+ EXTRA_FORMATTERS = {'list': ListFormatter}
+ COMMANDS_FORMATTERS = {'list': 'list'}
+
+ def __init__(self):
+ super(Boobank, self).__init__()
+ self.importIndex = 0
+ application_options = OptionGroup(self._parser, 'Boomoney Options')
+ application_options.add_option('-F', '--force', action='store_true', help='forces the retrieval of transactions (10 maximum), otherwise retrieves only the transactions newer than the previous retrieval date')
+ application_options.add_option('-A', '--account', help='retrieves only the specified account. By default, all accounts are retrieved')
+ application_options.add_option('-N', '--noimport', action='store_true', help='no import. Generates the files, but they are not imported in MSMoney. Last import dates are not modified')
+ application_options.add_option('-D', '--display', action='store_true', help='displays the generated OFX file')
+ application_options.add_option('-P', '--parallel', action='store_true', help='retrieves all accounts in parallel instead of one by one (experimental)')
+ self._parser.add_option_group(application_options)
+ self.labels = dict()
+
+ def print(self, *args):
+ with printMutex:
+ print(*args)
+
+ def write(self, *args):
+ with printMutex:
+ sys.stdout.write(*args)
+
+ def createBoobank(self, account):
+ accountId, backendName = account.split("@")
+
+ if not self.weboob.backends_config.backend_exists(backendName):
+ self.logger.warning("Unknown backend '%s' of account '%s' (not found in backends)" % (backendName, account))
+ return None
+
+ # create a Boobank instance
+ boobank = BoobankNoBackend()
+ boobank.options = copy.copy(self.options)
+
+ moduleName = self.weboob.backends_config._read_config().get(backendName, "_module")
+ module = self.weboob.modules_loader.loaded[moduleName]
+ backend = self.weboob.backend_instances[backendName]
+
+ params = {}
+ for param in backend.config:
+ params[param] = backend.config[param].get()
+ dedicatedBackendInstanceName = "backend instance for " + account
+ boobank.APP_NAME = "boobank app for " + account
+ instance = module.create_instance(self.weboob, dedicatedBackendInstanceName, params, storage=boobank.create_storage())
+
+ boobank.enabled_backends = set()
+ boobank.enabled_backends.add(instance)
+ boobank.weboob.backend_instances[dedicatedBackendInstanceName] = instance
+
+ boobank.selected_fields = ["$full"]
+ boobank.formatter = self.formatter
+
+ boobank._interactive = False
+ return boobank
+
+ def getHistory(self, account):
+ t = HistoryThread(self, account)
+ return t
+
+ def getDownloadsPath(self):
+ if not hasattr(self, '_downloadsPath'):
+ s = subprocess.check_output(
+ 'reg query "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders" /v "{374DE290-123F-4565-9164-39C4925E467B}"')
+ t = re.sub(r'^(.|\r|\n)+REG_EXPAND_SZ\s+([^\n\r]+)(.|\r|\n)*$', r'\2', s)
+ self._downloadsPath = os.path.expandvars(t).decode(sys.stdout.encoding)
+ return self._downloadsPath
+
+ def getMoneyPath(self):
+ if not hasattr(self, '_moneyPath'):
+ s = subprocess.check_output('reg query HKEY_CLASSES_ROOT\money\Shell\Open\Command /ve')
+ t = re.sub(r'^(.|\r|\n)+REG_SZ\s+([^\n\r]+)(.|\r|\n)*$', r'\2', s)
+ self._moneyPath = os.path.expandvars(os.path.dirname(t)).decode(sys.stdout.encoding)
+ return self._moneyPath
+
+ def getMoneyFile(self):
+ if not hasattr(self, '_moneyFile'):
+ s = subprocess.check_output('reg query HKEY_CURRENT_USER\\Software\\Microsoft\\Money\\14.0 /v CurrentFile')
+ t = re.sub(r'^(.|\r|\n)+REG_SZ\s+([^\n\r]+)(.|\r|\n)*$', r'\2', s)
+ self._moneyFile = os.path.expandvars(t).decode(sys.stdout.encoding)
+ return self._moneyFile
+
+ def backupIfNeeded(self):
+ if not (hasattr(self, '_backupDone') and self._backupDone):
+ with backupMutex:
+ # redo the test in mutual exclusion
+ if not (hasattr(self, '_backupDone') and self._backupDone):
+ file = self.getMoneyFile()
+ filename = os.path.splitext(os.path.basename(file))[0]
+ dir = os.path.dirname(file)
+ self.print(Fore.YELLOW + Style.BRIGHT + "Creating backup of %s..." % file + Style.RESET_ALL)
+ target = os.path.join(dir, filename + datetime.datetime.now().strftime("_%Y_%m_%d_%H%M%S.mny"))
+ shutil.copy2(file, target)
+ self._backupDone = True
+
+ def save_config(self):
+ for t in self.threads:
+ self.config.set(t.account, 'label', t.label)
+ self.config.set(t.account, 'disabled', t.disabled)
+ self.config.set(t.account, 'date_min', t.date_min)
+ self.config.set(t.account, 'last_date', t.last_date)
+
+ self.config.save()
+ return
+ # The above line doesn't work with unicode strings because the
+ # file is not open with the right encoding.
+ # The below is a copy of the INIConfig.save method but opens
+ # the file with the utf-8 encoding
+ from decimal import Decimal
+
+ def save_section(values, root_section=self.config.ROOTSECT):
+ for k, v in values.items():
+ if isinstance(v, (int, Decimal, float, basestring)):
+ if not self.config.config.has_section(root_section):
+ self.config.config.add_section(root_section)
+ self.config.config.set(root_section, k, unicode(v))
+ elif isinstance(v, dict):
+ new_section = ':'.join((root_section, k)) if (root_section != self.config.ROOTSECT or k == self.config.ROOTSECT) else k
+ if not self.config.config.has_section(new_section):
+ self.config.config.add_section(new_section)
+ save_section(v, new_section)
+ save_section(self.config.values)
+ import io
+ with io.open(self.config.path, 'w', encoding='utf-8') as f:
+ self.config.config.write(f)
+
+ def getList(self):
+ self.onecmd("select id label")
+ self.options.outfile = StringIO.StringIO()
+ self.onecmd("list")
+ listContent = self.options.outfile.getvalue()
+ self.options.outfile.close()
+ self.print(Style.BRIGHT + "Accounts:%s----------%s%s----------" % (
+ os.linesep,
+ os.linesep,
+ listContent) + Style.RESET_ALL)
+ for line in listContent.split(os.linesep):
+ if not line == "":
+ idspec, labelspec = line.split("\t")
+ notusedid, id = idspec.split("=")
+ notusedlabel, label = labelspec.split("=")
+ self.labels[id] = label
+
+ def checkNew(self):
+ new = set()
+ for account in self.labels:
+ if account not in self.config.config.sections():
+ new.add(HistoryThread(self, account))
+ return new
+
+ def main(self, argv):
+
+ init()
+
+ self.load_config()
+
+ self._interactive = False
+
+ self.threads = set()
+
+ self.logger.info(self.config.config.sections())
+ for account in self.config.config.sections():
+ if self.config.config.getboolean(account, "disabled") == False:
+ # time.sleep(3)
+ self.threads.add(self.getHistory(account))
+
+ if self.options.parallel:
+ self.print(Fore.MAGENTA + Style.BRIGHT + "Starting %i history threads..." % len(self.threads) + Style.RESET_ALL)
+ for t in self.threads:
+ t.start()
+ self.getList()
+ for t in self.checkNew():
+ t.start()
+ self.threads.add(t)
+ self.print(Fore.MAGENTA + Style.BRIGHT + "Waiting for %i threads to complete..." % len(self.threads) + Style.RESET_ALL)
+ for t in self.threads:
+ t.join()
+ else:
+ self.getList()
+ for t in self.checkNew():
+ self.threads.add(t)
+ for t in self.threads:
+ t.start()
+ t.join()
+
+ self.save_config()
+ return