/* * Copyright (c) 2013 - 2015 Ngewi Fet <ngewif@gmail.com> * Copyright (c) 2014 - 2015 Yongxin Wang <fefe.wyx@gmail.com> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.gnucash.android.importer; import android.database.sqlite.SQLiteDatabase; import android.support.annotation.NonNull; import android.util.Log; import com.crashlytics.android.Crashlytics; import org.gnucash.android.app.GnuCashApplication; import org.gnucash.android.db.DatabaseHelper; import org.gnucash.android.db.adapter.AccountsDbAdapter; import org.gnucash.android.db.adapter.BooksDbAdapter; import org.gnucash.android.db.adapter.BudgetAmountsDbAdapter; import org.gnucash.android.db.adapter.BudgetsDbAdapter; import org.gnucash.android.db.adapter.CommoditiesDbAdapter; import org.gnucash.android.db.adapter.DatabaseAdapter; import org.gnucash.android.db.adapter.PricesDbAdapter; import org.gnucash.android.db.adapter.RecurrenceDbAdapter; import org.gnucash.android.db.adapter.ScheduledActionDbAdapter; import org.gnucash.android.db.adapter.SplitsDbAdapter; import org.gnucash.android.db.adapter.TransactionsDbAdapter; import org.gnucash.android.export.xml.GncXmlHelper; import org.gnucash.android.model.Account; import org.gnucash.android.model.AccountType; import org.gnucash.android.model.BaseModel; import org.gnucash.android.model.Book; import org.gnucash.android.model.Budget; import org.gnucash.android.model.BudgetAmount; import org.gnucash.android.model.Commodity; import org.gnucash.android.model.Money; import org.gnucash.android.model.PeriodType; import org.gnucash.android.model.Price; import org.gnucash.android.model.Recurrence; import org.gnucash.android.model.ScheduledAction; import org.gnucash.android.model.Split; import org.gnucash.android.model.Transaction; import org.gnucash.android.model.TransactionType; import org.xml.sax.Attributes; import org.xml.sax.SAXException; import org.xml.sax.helpers.DefaultHandler; import java.math.BigDecimal; import java.sql.Timestamp; import java.text.ParseException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Stack; import java.util.regex.Pattern; /** * Handler for parsing the GnuCash XML file. * The discovered accounts and transactions are automatically added to the database * * @author Ngewi Fet <ngewif@gmail.com> * @author Yongxin Wang <fefe.wyx@gmail.com> */ public class GncXmlHandler extends DefaultHandler { /** * ISO 4217 currency code for "No Currency" */ private static final String NO_CURRENCY_CODE = "XXX"; /** * Tag for logging */ private static final String LOG_TAG = "GnuCashAccountImporter"; /* ^ anchor for start of string # the literal # ( start of group ?: indicate a non-capturing group that doesn't generate back-references [0-9a-fA-F] hexadecimal digit {3} three times ) end of group {2} repeat twice $ anchor for end of string */ /** * Regular expression for validating color code strings. * Accepts #rgb and #rrggbb */ //TODO: Allow use of #aarrggbb format as well public static final String ACCOUNT_COLOR_HEX_REGEX = "^#(?:[0-9a-fA-F]{3}){2}$"; /** * Adapter for saving the imported accounts */ AccountsDbAdapter mAccountsDbAdapter; /** * StringBuilder for accumulating characters between XML tags */ StringBuilder mContent; /** * Reference to account which is built when each account tag is parsed in the XML file */ Account mAccount; /** * All the accounts found in a file to be imported, used for bulk import mode */ List<Account> mAccountList; /** * List of all the template accounts found */ List<Account> mTemplatAccountList; /** * Map of the tempate accounts to the template transactions UIDs */ Map<String, String> mTemplateAccountToTransactionMap; /** * Account map for quick referencing from UID */ HashMap<String, Account> mAccountMap; /** * ROOT account of the imported book */ Account mRootAccount; /** * Transaction instance which will be built for each transaction found */ Transaction mTransaction; /** * All the transaction instances found in a file to be inserted, used in bulk mode */ List<Transaction> mTransactionList; /** * All the template transactions found during parsing of the XML */ List<Transaction> mTemplateTransactions; /** * Accumulate attributes of splits found in this object */ Split mSplit; /** * (Absolute) quantity of the split, which uses split account currency */ BigDecimal mQuantity; /** * (Absolute) value of the split, which uses transaction currency */ BigDecimal mValue; /** * price table entry */ Price mPrice; boolean mPriceCommodity; boolean mPriceCurrency; List<Price> mPriceList; /** * Whether the quantity is negative */ boolean mNegativeQuantity; /** * The list for all added split for autobalancing */ List<Split> mAutoBalanceSplits; /** * Ignore certain elements in GnuCash XML file, such as "<gnc:template-transactions>" */ String mIgnoreElement = null; /** * {@link ScheduledAction} instance for each scheduled action parsed */ ScheduledAction mScheduledAction; /** * List of scheduled actions to be bulk inserted */ List<ScheduledAction> mScheduledActionsList; /** * List of budgets which have been parsed from XML */ List<Budget> mBudgetList; Budget mBudget; Recurrence mRecurrence; BudgetAmount mBudgetAmount; boolean mInColorSlot = false; boolean mInPlaceHolderSlot = false; boolean mInFavoriteSlot = false; boolean mISO4217Currency = false; boolean mIsDatePosted = false; boolean mIsDateEntered = false; boolean mIsNote = false; boolean mInDefaultTransferAccount = false; boolean mInExported = false; boolean mInTemplates = false; boolean mInSplitAccountSlot = false; boolean mInCreditNumericSlot = false; boolean mInDebitNumericSlot = false; boolean mIsScheduledStart = false; boolean mIsScheduledEnd = false; boolean mIsLastRun = false; boolean mIsRecurrenceStart = false; boolean mInBudgetSlot = false; /** * Saves the attribute of the slot tag * Used for determining where we are in the budget amounts */ String mSlotTagAttribute = null; String mBudgetAmountAccountUID = null; /** * Multiplier for the recurrence period type. e.g. period type of week and multiplier of 2 means bi-weekly */ int mRecurrenceMultiplier = 1; /** * Flag which says to ignore template transactions until we successfully parse a split amount * Is updated for each transaction template split parsed */ boolean mIgnoreTemplateTransaction = true; /** * Flag which notifies the handler to ignore a scheduled action because some error occurred during parsing */ boolean mIgnoreScheduledAction = false; /** * Used for parsing old backup files where recurrence was saved inside the transaction. * Newer backup files will not require this * @deprecated Use the new scheduled action elements instead */ @Deprecated private long mRecurrencePeriod = 0; private TransactionsDbAdapter mTransactionsDbAdapter; private ScheduledActionDbAdapter mScheduledActionsDbAdapter; private CommoditiesDbAdapter mCommoditiesDbAdapter; private PricesDbAdapter mPricesDbAdapter; private Map<String, Integer> mCurrencyCount; private BudgetsDbAdapter mBudgetsDbAdapter; private Book mBook; private SQLiteDatabase mainDb; /** * Creates a handler for handling XML stream events when parsing the XML backup file */ public GncXmlHandler() { init(); } /** * Initialize the GnuCash XML handler */ private void init() { mBook = new Book(); DatabaseHelper databaseHelper = new DatabaseHelper(GnuCashApplication.getAppContext(), mBook.getUID()); mainDb = databaseHelper.getWritableDatabase(); mTransactionsDbAdapter = new TransactionsDbAdapter(mainDb, new SplitsDbAdapter(mainDb)); mAccountsDbAdapter = new AccountsDbAdapter(mainDb, mTransactionsDbAdapter); RecurrenceDbAdapter recurrenceDbAdapter = new RecurrenceDbAdapter(mainDb); mScheduledActionsDbAdapter = new ScheduledActionDbAdapter(mainDb, recurrenceDbAdapter); mCommoditiesDbAdapter = new CommoditiesDbAdapter(mainDb); mPricesDbAdapter = new PricesDbAdapter(mainDb); mBudgetsDbAdapter = new BudgetsDbAdapter(mainDb, new BudgetAmountsDbAdapter(mainDb), recurrenceDbAdapter); mContent = new StringBuilder(); mAccountList = new ArrayList<>(); mAccountMap = new HashMap<>(); mTransactionList = new ArrayList<>(); mScheduledActionsList = new ArrayList<>(); mBudgetList = new ArrayList<>(); mTemplatAccountList = new ArrayList<>(); mTemplateTransactions = new ArrayList<>(); mTemplateAccountToTransactionMap = new HashMap<>(); mAutoBalanceSplits = new ArrayList<>(); mPriceList = new ArrayList<>(); mCurrencyCount = new HashMap<>(); } @Override public void startElement(String uri, String localName, String qualifiedName, Attributes attributes) throws SAXException { switch (qualifiedName){ case GncXmlHelper.TAG_ACCOUNT: mAccount = new Account(""); // dummy name, will be replaced when we find name tag mISO4217Currency = false; break; case GncXmlHelper.TAG_TRANSACTION: mTransaction = new Transaction(""); // dummy name will be replaced mTransaction.setExported(true); // default to exported when import transactions mISO4217Currency = false; break; case GncXmlHelper.TAG_TRN_SPLIT: mSplit = new Split(Money.getZeroInstance(), ""); break; case GncXmlHelper.TAG_DATE_POSTED: mIsDatePosted = true; break; case GncXmlHelper.TAG_DATE_ENTERED: mIsDateEntered = true; break; case GncXmlHelper.TAG_TEMPLATE_TRANSACTIONS: mInTemplates = true; break; case GncXmlHelper.TAG_SCHEDULED_ACTION: //default to transaction type, will be changed during parsing mScheduledAction = new ScheduledAction(ScheduledAction.ActionType.TRANSACTION); break; case GncXmlHelper.TAG_SX_START: mIsScheduledStart = true; break; case GncXmlHelper.TAG_SX_END: mIsScheduledEnd = true; break; case GncXmlHelper.TAG_SX_LAST: mIsLastRun = true; break; case GncXmlHelper.TAG_RX_START: mIsRecurrenceStart = true; break; case GncXmlHelper.TAG_PRICE: mPrice = new Price(); break; case GncXmlHelper.TAG_PRICE_CURRENCY: mPriceCurrency = true; mPriceCommodity = false; mISO4217Currency = false; break; case GncXmlHelper.TAG_PRICE_COMMODITY: mPriceCurrency = false; mPriceCommodity = true; mISO4217Currency = false; break; case GncXmlHelper.TAG_BUDGET: mBudget = new Budget(); break; case GncXmlHelper.TAG_GNC_RECURRENCE: case GncXmlHelper.TAG_BUDGET_RECURRENCE: mRecurrenceMultiplier = 1; mRecurrence = new Recurrence(PeriodType.MONTH); break; case GncXmlHelper.TAG_BUDGET_SLOTS: mInBudgetSlot = true; break; case GncXmlHelper.TAG_SLOT: if (mInBudgetSlot){ mBudgetAmount = new BudgetAmount(mBudget.getUID(), mBudgetAmountAccountUID); } break; case GncXmlHelper.TAG_SLOT_VALUE: mSlotTagAttribute = attributes.getValue(GncXmlHelper.ATTR_KEY_TYPE); break; } } @Override public void endElement(String uri, String localName, String qualifiedName) throws SAXException { // FIXME: 22.10.2015 First parse the number of accounts/transactions and use the numer to init the array lists String characterString = mContent.toString().trim(); if (mIgnoreElement != null) { // Ignore everything inside if (qualifiedName.equals(mIgnoreElement)) { mIgnoreElement = null; } mContent.setLength(0); return; } switch (qualifiedName) { case GncXmlHelper.TAG_ACCT_NAME: mAccount.setName(characterString); mAccount.setFullName(characterString); break; case GncXmlHelper.TAG_ACCT_ID: mAccount.setUID(characterString); break; case GncXmlHelper.TAG_ACCT_TYPE: AccountType accountType = AccountType.valueOf(characterString); mAccount.setAccountType(accountType); mAccount.setHidden(accountType == AccountType.ROOT); //flag root account as hidden break; case GncXmlHelper.TAG_COMMODITY_SPACE: if (characterString.equals("ISO4217")) { mISO4217Currency = true; } else { // price of non-ISO4217 commodities cannot be handled mPrice = null; } break; case GncXmlHelper.TAG_COMMODITY_ID: String currencyCode = mISO4217Currency ? characterString : NO_CURRENCY_CODE; Commodity commodity = mCommoditiesDbAdapter.getCommodity(currencyCode); if (mAccount != null) { if (commodity != null) { mAccount.setCommodity(commodity); } else { throw new SAXException("Commodity with '" + currencyCode + "' currency code not found in the database"); } if (mCurrencyCount.containsKey(currencyCode)) { mCurrencyCount.put(currencyCode, mCurrencyCount.get(currencyCode) + 1); } else { mCurrencyCount.put(currencyCode, 1); } } if (mTransaction != null) { mTransaction.setCommodity(commodity); } if (mPrice != null) { if (mPriceCommodity) { mPrice.setCommodityUID(mCommoditiesDbAdapter.getCommodityUID(currencyCode)); mPriceCommodity = false; } if (mPriceCurrency) { mPrice.setCurrencyUID(mCommoditiesDbAdapter.getCommodityUID(currencyCode)); mPriceCurrency = false; } } break; case GncXmlHelper.TAG_ACCT_DESCRIPTION: mAccount.setDescription(characterString); break; case GncXmlHelper.TAG_PARENT_UID: mAccount.setParentUID(characterString); break; case GncXmlHelper.TAG_ACCOUNT: if (!mInTemplates) { //we ignore template accounts, we have no use for them. FIXME someday and import the templates too mAccountList.add(mAccount); mAccountMap.put(mAccount.getUID(), mAccount); // check ROOT account if (mAccount.getAccountType() == AccountType.ROOT) { if (mRootAccount == null) { mRootAccount = mAccount; } else { throw new SAXException("Multiple ROOT accounts exist in book"); } } // prepare for next input mAccount = null; //reset ISO 4217 flag for next account mISO4217Currency = false; } break; case GncXmlHelper.TAG_SLOT: break; case GncXmlHelper.TAG_SLOT_KEY: switch (characterString) { case GncXmlHelper.KEY_PLACEHOLDER: mInPlaceHolderSlot = true; break; case GncXmlHelper.KEY_COLOR: mInColorSlot = true; break; case GncXmlHelper.KEY_FAVORITE: mInFavoriteSlot = true; break; case GncXmlHelper.KEY_NOTES: mIsNote = true; break; case GncXmlHelper.KEY_DEFAULT_TRANSFER_ACCOUNT: mInDefaultTransferAccount = true; break; case GncXmlHelper.KEY_EXPORTED: mInExported = true; break; case GncXmlHelper.KEY_SPLIT_ACCOUNT_SLOT: mInSplitAccountSlot = true; break; case GncXmlHelper.KEY_CREDIT_NUMERIC: mInCreditNumericSlot = true; break; case GncXmlHelper.KEY_DEBIT_NUMERIC: mInDebitNumericSlot = true; break; } if (mInBudgetSlot && mBudgetAmountAccountUID == null){ mBudgetAmountAccountUID = characterString; mBudgetAmount.setAccountUID(characterString); } else if (mInBudgetSlot){ mBudgetAmount.setPeriodNum(Long.parseLong(characterString)); } break; case GncXmlHelper.TAG_SLOT_VALUE: if (mInPlaceHolderSlot) { //Log.v(LOG_TAG, "Setting account placeholder flag"); mAccount.setPlaceHolderFlag(Boolean.parseBoolean(characterString)); mInPlaceHolderSlot = false; } else if (mInColorSlot) { //Log.d(LOG_TAG, "Parsing color code: " + characterString); String color = characterString.trim(); //Gnucash exports the account color in format #rrrgggbbb, but we need only #rrggbb. //so we trim the last digit in each block, doesn't affect the color much if (!color.equals("Not Set")) { // avoid known exception, printStackTrace is very time consuming if (!Pattern.matches(ACCOUNT_COLOR_HEX_REGEX, color)) color = "#" + color.replaceAll(".(.)?", "$1").replace("null", ""); try { if (mAccount != null) mAccount.setColor(color); } catch (IllegalArgumentException ex) { //sometimes the color entry in the account file is "Not set" instead of just blank. So catch! Log.e(LOG_TAG, "Invalid color code '" + color + "' for account " + mAccount.getName()); Crashlytics.logException(ex); } } mInColorSlot = false; } else if (mInFavoriteSlot) { mAccount.setFavorite(Boolean.parseBoolean(characterString)); mInFavoriteSlot = false; } else if (mIsNote) { if (mTransaction != null) { mTransaction.setNote(characterString); mIsNote = false; } } else if (mInDefaultTransferAccount) { mAccount.setDefaultTransferAccountUID(characterString); mInDefaultTransferAccount = false; } else if (mInExported) { if (mTransaction != null) { mTransaction.setExported(Boolean.parseBoolean(characterString)); mInExported = false; } } else if (mInTemplates && mInSplitAccountSlot) { mSplit.setAccountUID(characterString); mInSplitAccountSlot = false; } else if (mInTemplates && mInCreditNumericSlot) { handleEndOfTemplateNumericSlot(characterString, TransactionType.CREDIT); } else if (mInTemplates && mInDebitNumericSlot) { handleEndOfTemplateNumericSlot(characterString, TransactionType.DEBIT); } else if (mInBudgetSlot){ if (mSlotTagAttribute.equals(GncXmlHelper.ATTR_VALUE_NUMERIC)) { try { BigDecimal bigDecimal = GncXmlHelper.parseSplitAmount(characterString); //currency doesn't matter since we don't persist it in the budgets table mBudgetAmount.setAmount(new Money(bigDecimal, Commodity.DEFAULT_COMMODITY)); } catch (ParseException e) { mBudgetAmount.setAmount(Money.getZeroInstance()); //just put zero, in case it was a formula we couldnt parse e.printStackTrace(); } finally { mBudget.addBudgetAmount(mBudgetAmount); } mSlotTagAttribute = GncXmlHelper.ATTR_VALUE_FRAME; } else { mBudgetAmountAccountUID = null; } } break; case GncXmlHelper.TAG_BUDGET_SLOTS: mInBudgetSlot = false; break; //================ PROCESSING OF TRANSACTION TAGS ===================================== case GncXmlHelper.TAG_TRX_ID: mTransaction.setUID(characterString); break; case GncXmlHelper.TAG_TRN_DESCRIPTION: mTransaction.setDescription(characterString); break; case GncXmlHelper.TAG_TS_DATE: try { if (mIsDatePosted && mTransaction != null) { mTransaction.setTime(GncXmlHelper.parseDate(characterString)); mIsDatePosted = false; } if (mIsDateEntered && mTransaction != null) { Timestamp timestamp = new Timestamp(GncXmlHelper.parseDate(characterString)); mTransaction.setCreatedTimestamp(timestamp); mIsDateEntered = false; } if (mPrice != null) { mPrice.setDate(new Timestamp(GncXmlHelper.parseDate(characterString))); } } catch (ParseException e) { Crashlytics.logException(e); String message = "Unable to parse transaction time - " + characterString; Log.e(LOG_TAG, message + "\n" + e.getMessage()); Crashlytics.log(message); throw new SAXException(message, e); } break; case GncXmlHelper.TAG_RECURRENCE_PERIOD: //for parsing of old backup files mRecurrencePeriod = Long.parseLong(characterString); mTransaction.setTemplate(mRecurrencePeriod > 0); break; case GncXmlHelper.TAG_SPLIT_ID: mSplit.setUID(characterString); break; case GncXmlHelper.TAG_SPLIT_MEMO: mSplit.setMemo(characterString); break; case GncXmlHelper.TAG_SPLIT_VALUE: try { // The value and quantity can have different sign for custom currency(stock). // Use the sign of value for split, as it would not be custom currency String q = characterString; if (q.charAt(0) == '-') { mNegativeQuantity = true; q = q.substring(1); } else { mNegativeQuantity = false; } mValue = GncXmlHelper.parseSplitAmount(characterString).abs(); // use sign from quantity } catch (ParseException e) { String msg = "Error parsing split quantity - " + characterString; Crashlytics.log(msg); Crashlytics.logException(e); throw new SAXException(msg, e); } break; case GncXmlHelper.TAG_SPLIT_QUANTITY: // delay the assignment of currency when the split account is seen try { mQuantity = GncXmlHelper.parseSplitAmount(characterString).abs(); } catch (ParseException e) { String msg = "Error parsing split quantity - " + characterString; Crashlytics.log(msg); Crashlytics.logException(e); throw new SAXException(msg, e); } break; case GncXmlHelper.TAG_SPLIT_ACCOUNT: if (!mInTemplates) { //this is intentional: GnuCash XML formats split amounts, credits are negative, debits are positive. mSplit.setType(mNegativeQuantity ? TransactionType.CREDIT : TransactionType.DEBIT); //the split amount uses the account currency mSplit.setQuantity(new Money(mQuantity, getCommodityForAccount(characterString))); //the split value uses the transaction currency mSplit.setValue(new Money(mValue, mTransaction.getCommodity())); mSplit.setAccountUID(characterString); } else { if (!mIgnoreTemplateTransaction) mTemplateAccountToTransactionMap.put(characterString, mTransaction.getUID()); } break; //todo: import split reconciled state and date case GncXmlHelper.TAG_TRN_SPLIT: mTransaction.addSplit(mSplit); break; case GncXmlHelper.TAG_TRANSACTION: mTransaction.setTemplate(mInTemplates); Split imbSplit = mTransaction.createAutoBalanceSplit(); if (imbSplit != null) { mAutoBalanceSplits.add(imbSplit); } if (mInTemplates){ if (!mIgnoreTemplateTransaction) mTemplateTransactions.add(mTransaction); } else { mTransactionList.add(mTransaction); } if (mRecurrencePeriod > 0) { //if we find an old format recurrence period, parse it mTransaction.setTemplate(true); ScheduledAction scheduledAction = ScheduledAction.parseScheduledAction(mTransaction, mRecurrencePeriod); mScheduledActionsList.add(scheduledAction); } mRecurrencePeriod = 0; mIgnoreTemplateTransaction = true; mTransaction = null; break; case GncXmlHelper.TAG_TEMPLATE_TRANSACTIONS: mInTemplates = false; break; // ========================= PROCESSING SCHEDULED ACTIONS ================================== case GncXmlHelper.TAG_SX_ID: mScheduledAction.setUID(characterString); break; case GncXmlHelper.TAG_SX_NAME: if (characterString.equals(ScheduledAction.ActionType.BACKUP.name())) mScheduledAction.setActionType(ScheduledAction.ActionType.BACKUP); else mScheduledAction.setActionType(ScheduledAction.ActionType.TRANSACTION); break; case GncXmlHelper.TAG_SX_ENABLED: mScheduledAction.setEnabled(characterString.equals("y")); break; case GncXmlHelper.TAG_SX_AUTO_CREATE: mScheduledAction.setAutoCreate(characterString.equals("y")); break; //todo: export auto_notify, advance_create, advance_notify case GncXmlHelper.TAG_SX_NUM_OCCUR: mScheduledAction.setTotalPlannedExecutionCount(Integer.parseInt(characterString)); break; case GncXmlHelper.TAG_RX_MULT: mRecurrenceMultiplier = Integer.parseInt(characterString); break; case GncXmlHelper.TAG_RX_PERIOD_TYPE: try { PeriodType periodType = PeriodType.valueOf(characterString.toUpperCase()); mRecurrence.setPeriodType(periodType); mRecurrence.setMultiplier(mRecurrenceMultiplier); } catch (IllegalArgumentException ex){ //the period type constant is not supported String msg = "Unsupported period constant: " + characterString; Log.e(LOG_TAG, msg); Crashlytics.logException(ex); mIgnoreScheduledAction = true; } break; case GncXmlHelper.TAG_GDATE: try { long date = GncXmlHelper.DATE_FORMATTER.parse(characterString).getTime(); if (mIsScheduledStart && mScheduledAction != null) { mScheduledAction.setCreatedTimestamp(new Timestamp(date)); mIsScheduledStart = false; } if (mIsScheduledEnd && mScheduledAction != null) { mScheduledAction.setEndTime(date); mIsScheduledEnd = false; } if (mIsLastRun && mScheduledAction != null) { mScheduledAction.setLastRun(date); mIsLastRun = false; } if (mIsRecurrenceStart && mScheduledAction != null){ mRecurrence.setPeriodStart(new Timestamp(date)); mIsRecurrenceStart = false; } } catch (ParseException e) { String msg = "Error parsing scheduled action date " + characterString; Log.e(LOG_TAG, msg + e.getMessage()); Crashlytics.log(msg); Crashlytics.logException(e); throw new SAXException(msg, e); } break; case GncXmlHelper.TAG_SX_TEMPL_ACCOUNT: if (mScheduledAction.getActionType() == ScheduledAction.ActionType.TRANSACTION) { mScheduledAction.setActionUID(mTemplateAccountToTransactionMap.get(characterString)); } else { mScheduledAction.setActionUID(BaseModel.generateUID()); } break; case GncXmlHelper.TAG_GNC_RECURRENCE: if (mScheduledAction != null){ mScheduledAction.setRecurrence(mRecurrence); } break; case GncXmlHelper.TAG_SCHEDULED_ACTION: if (mScheduledAction.getActionUID() != null && !mIgnoreScheduledAction) { mScheduledActionsList.add(mScheduledAction); int count = generateMissedScheduledTransactions(mScheduledAction); Log.i(LOG_TAG, String.format("Generated %d transactions from scheduled action", count)); } mIgnoreScheduledAction = false; break; // price table case GncXmlHelper.TAG_PRICE_ID: mPrice.setUID(characterString); break; case GncXmlHelper.TAG_PRICE_SOURCE: if (mPrice != null) { mPrice.setSource(characterString); } break; case GncXmlHelper.TAG_PRICE_VALUE: if (mPrice != null) { String[] parts = characterString.split("/"); if (parts.length != 2) { String message = "Illegal price - " + characterString; Log.e(LOG_TAG, message); Crashlytics.log(message); throw new SAXException(message); } else { mPrice.setValueNum(Long.valueOf(parts[0])); mPrice.setValueDenom(Long.valueOf(parts[1])); Log.d(getClass().getName(), "price " + characterString + " .. " + mPrice.getValueNum() + "/" + mPrice.getValueDenom()); } } break; case GncXmlHelper.TAG_PRICE_TYPE: if (mPrice != null) { mPrice.setType(characterString); } break; case GncXmlHelper.TAG_PRICE: if (mPrice != null) { mPriceList.add(mPrice); mPrice = null; } break; case GncXmlHelper.TAG_BUDGET: if (mBudget.getBudgetAmounts().size() > 0) //ignore if no budget amounts exist for the budget mBudgetList.add(mBudget); break; case GncXmlHelper.TAG_BUDGET_NAME: mBudget.setName(characterString); break; case GncXmlHelper.TAG_BUDGET_DESCRIPTION: mBudget.setDescription(characterString); break; case GncXmlHelper.TAG_BUDGET_NUM_PERIODS: mBudget.setNumberOfPeriods(Long.parseLong(characterString)); break; case GncXmlHelper.TAG_BUDGET_RECURRENCE: mBudget.setRecurrence(mRecurrence); break; } //reset the accumulated characters mContent.setLength(0); } @Override public void characters(char[] chars, int start, int length) throws SAXException { mContent.append(chars, start, length); } @Override public void endDocument() throws SAXException { super.endDocument(); HashMap<String, String> mapFullName = new HashMap<>(mAccountList.size()); HashMap<String, Account> mapImbalanceAccount = new HashMap<>(); // The XML has no ROOT, create one if (mRootAccount == null) { mRootAccount = new Account("ROOT"); mRootAccount.setAccountType(AccountType.ROOT); mAccountList.add(mRootAccount); mAccountMap.put(mRootAccount.getUID(), mRootAccount); } String imbalancePrefix = AccountsDbAdapter.getImbalanceAccountPrefix(); // Add all account without a parent to ROOT, and collect top level imbalance accounts for(Account account:mAccountList) { mapFullName.put(account.getUID(), null); boolean topLevel = false; if (account.getParentUID() == null && account.getAccountType() != AccountType.ROOT) { account.setParentUID(mRootAccount.getUID()); topLevel = true; } if (topLevel || (mRootAccount.getUID().equals(account.getParentUID()))) { if (account.getName().startsWith(imbalancePrefix)) { mapImbalanceAccount.put(account.getName().substring(imbalancePrefix.length()), account); } } } // Set the account for created balancing splits to correct imbalance accounts for (Split split: mAutoBalanceSplits) { // XXX: yes, getAccountUID() returns a currency code in this case (see Transaction.createAutoBalanceSplit()) String currencyCode = split.getAccountUID(); Account imbAccount = mapImbalanceAccount.get(currencyCode); if (imbAccount == null) { imbAccount = new Account(imbalancePrefix + currencyCode, mCommoditiesDbAdapter.getCommodity(currencyCode)); imbAccount.setParentUID(mRootAccount.getUID()); imbAccount.setAccountType(AccountType.BANK); mapImbalanceAccount.put(currencyCode, imbAccount); mAccountList.add(imbAccount); } split.setAccountUID(imbAccount.getUID()); } java.util.Stack<Account> stack = new Stack<>(); for (Account account:mAccountList){ if (mapFullName.get(account.getUID()) != null) { continue; } stack.push(account); String parentAccountFullName; while (!stack.isEmpty()) { Account acc = stack.peek(); if (acc.getAccountType() == AccountType.ROOT) { // ROOT_ACCOUNT_FULL_NAME should ensure ROOT always sorts first mapFullName.put(acc.getUID(), AccountsDbAdapter.ROOT_ACCOUNT_FULL_NAME); stack.pop(); continue; } String parentUID = acc.getParentUID(); Account parentAccount = mAccountMap.get(parentUID); // ROOT account will be added if not exist, so now anly ROOT // has an empty parent if (parentAccount.getAccountType() == AccountType.ROOT) { // top level account, full name is the same as its name mapFullName.put(acc.getUID(), acc.getName()); stack.pop(); continue; } parentAccountFullName = mapFullName.get(parentUID); if (parentAccountFullName == null) { // non-top-level account, parent full name still unknown stack.push(parentAccount); continue; } mapFullName.put(acc.getUID(), parentAccountFullName + AccountsDbAdapter.ACCOUNT_NAME_SEPARATOR + acc.getName()); stack.pop(); } } for (Account account:mAccountList){ account.setFullName(mapFullName.get(account.getUID())); } String mostAppearedCurrency = ""; int mostCurrencyAppearance = 0; for (Map.Entry<String, Integer> entry : mCurrencyCount.entrySet()) { if (entry.getValue() > mostCurrencyAppearance) { mostCurrencyAppearance = entry.getValue(); mostAppearedCurrency = entry.getKey(); } } if (mostCurrencyAppearance > 0) { GnuCashApplication.setDefaultCurrencyCode(mostAppearedCurrency); } saveToDatabase(); } /** * Saves the imported data to the database * @return GUID of the newly created book, or null if not successful */ private void saveToDatabase() { BooksDbAdapter booksDbAdapter = BooksDbAdapter.getInstance(); mBook.setRootAccountUID(mRootAccount.getUID()); mBook.setDisplayName(booksDbAdapter.generateDefaultBookName()); //we on purpose do not set the book active. Only import. Caller should handle activation long startTime = System.nanoTime(); mAccountsDbAdapter.beginTransaction(); Log.d(getClass().getSimpleName(), "bulk insert starts"); try { // disable foreign key. The database structure should be ensured by the data inserted. // it will make insertion much faster. mAccountsDbAdapter.enableForeignKey(false); Log.d(getClass().getSimpleName(), "before clean up db"); mAccountsDbAdapter.deleteAllRecords(); Log.d(getClass().getSimpleName(), String.format("deb clean up done %d ns", System.nanoTime()-startTime)); long nAccounts = mAccountsDbAdapter.bulkAddRecords(mAccountList, DatabaseAdapter.UpdateMethod.insert); Log.d("Handler:", String.format("%d accounts inserted", nAccounts)); //We need to add scheduled actions first because there is a foreign key constraint on transactions //which are generated from scheduled actions (we do auto-create some transactions during import) long nSchedActions = mScheduledActionsDbAdapter.bulkAddRecords(mScheduledActionsList, DatabaseAdapter.UpdateMethod.insert); Log.d("Handler:", String.format("%d scheduled actions inserted", nSchedActions)); long nTempTransactions = mTransactionsDbAdapter.bulkAddRecords(mTemplateTransactions, DatabaseAdapter.UpdateMethod.insert); Log.d("Handler:", String.format("%d template transactions inserted", nTempTransactions)); long nTransactions = mTransactionsDbAdapter.bulkAddRecords(mTransactionList, DatabaseAdapter.UpdateMethod.insert); Log.d("Handler:", String.format("%d transactions inserted", nTransactions)); long nPrices = mPricesDbAdapter.bulkAddRecords(mPriceList, DatabaseAdapter.UpdateMethod.insert); Log.d(getClass().getSimpleName(), String.format("%d prices inserted", nPrices)); //// TODO: 01.06.2016 Re-enable import of Budget stuff when the UI is complete // long nBudgets = mBudgetsDbAdapter.bulkAddRecords(mBudgetList, DatabaseAdapter.UpdateMethod.insert); // Log.d(getClass().getSimpleName(), String.format("%d budgets inserted", nBudgets)); long endTime = System.nanoTime(); Log.d(getClass().getSimpleName(), String.format("bulk insert time: %d", endTime - startTime)); //if all of the import went smoothly, then add the book to the book db booksDbAdapter.addRecord(mBook, DatabaseAdapter.UpdateMethod.insert); mAccountsDbAdapter.setTransactionSuccessful(); } finally { mAccountsDbAdapter.enableForeignKey(true); mAccountsDbAdapter.endTransaction(); mainDb.close(); //close it after import } } /** * Returns the unique identifier of the just-imported book * @return GUID of the newly imported book */ public @NonNull String getBookUID(){ return mBook.getUID(); } /** * Returns the currency for an account which has been parsed (but not yet saved to the db) * <p>This is used when parsing splits to assign the right currencies to the splits</p> * @param accountUID GUID of the account * @return Commodity of the account */ private Commodity getCommodityForAccount(String accountUID){ try { return mAccountMap.get(accountUID).getCommodity(); } catch (Exception e) { Crashlytics.logException(e); return Commodity.DEFAULT_COMMODITY; } } /** * Handles the case when we reach the end of the template numeric slot * @param characterString Parsed characters containing split amount */ private void handleEndOfTemplateNumericSlot(String characterString, TransactionType splitType) { try { // HACK: Check for bug #562. If a value has already been set, ignore the one just read if (mSplit.getValue().equals( new Money(BigDecimal.ZERO, mSplit.getValue().getCommodity()))) { BigDecimal amountBigD = GncXmlHelper.parseSplitAmount(characterString); Money amount = new Money(amountBigD, getCommodityForAccount(mSplit.getAccountUID())); mSplit.setValue(amount.abs()); mSplit.setType(splitType); mIgnoreTemplateTransaction = false; //we have successfully parsed an amount } } catch (NumberFormatException | ParseException e) { String msg = "Error parsing template credit split amount " + characterString; Log.e(LOG_TAG, msg + "\n" + e.getMessage()); Crashlytics.log(msg); Crashlytics.logException(e); } finally { if (splitType == TransactionType.CREDIT) mInCreditNumericSlot = false; else mInDebitNumericSlot = false; } } /** * Generates the runs of the scheduled action which have been missed since the file was last opened. * @param scheduledAction Scheduled action for transaction * @return Number of transaction instances generated */ private int generateMissedScheduledTransactions(ScheduledAction scheduledAction){ //if this scheduled action should not be run for any reason, return immediately if (scheduledAction.getActionType() != ScheduledAction.ActionType.TRANSACTION || !scheduledAction.isEnabled() || !scheduledAction.shouldAutoCreate() || (scheduledAction.getEndTime() > 0 && scheduledAction.getEndTime() > System.currentTimeMillis()) || (scheduledAction.getTotalPlannedExecutionCount() > 0 && scheduledAction.getExecutionCount() >= scheduledAction.getTotalPlannedExecutionCount())){ return 0; } long lastRuntime = scheduledAction.getStartTime(); if (scheduledAction.getLastRunTime() > 0){ lastRuntime = scheduledAction.getLastRunTime(); } int generatedTransactionCount = 0; long period = scheduledAction.getPeriod(); final String actionUID = scheduledAction.getActionUID(); while ((lastRuntime = lastRuntime + period) <= System.currentTimeMillis()){ for (Transaction templateTransaction : mTemplateTransactions) { if (templateTransaction.getUID().equals(actionUID)){ Transaction transaction = new Transaction(templateTransaction, true); transaction.setTime(lastRuntime); transaction.setScheduledActionUID(scheduledAction.getUID()); mTransactionList.add(transaction); //autobalance splits are generated with the currency of the transactions as the GUID //so we add them to the mAutoBalanceSplits which will be updated to real GUIDs before saving List<Split> autoBalanceSplits = transaction.getSplits(transaction.getCurrencyCode()); mAutoBalanceSplits.addAll(autoBalanceSplits); scheduledAction.setExecutionCount(scheduledAction.getExecutionCount() + 1); ++generatedTransactionCount; break; } } } scheduledAction.setLastRun(lastRuntime); return generatedTransactionCount; } }