class Application:
    """Manage a running instance of moneyGuru.

    Mostly, it handles app-wide user preferences. It doesn't hold a reference to a list of open
    :class:`.Document` instances. These instances are auto-sufficient and their reference are held
    directly by the UI layer.

    But otherwise, it acts as an app-wide preference repository. As such, it provides useful
    utilities, such as :meth:`format_amount`, :meth:`format_date`, :meth:`parse_amount` and
    :meth:`parse_date`, which are dependent on user preferences.

    Its initialization arguments are such preferences.

    :param view: An OS-specific outlet from the UI layer.
    :type view: :class:`ApplicationView`
    :param str date_format: The date format to use throughout the app. "ISO" format type (see
                            :class:`.DateFormat` on that topic).
    :param str decimal_sep: Decimal separator to use when formatting/parsing amounts.
    :param str grouping_sep: Thousands grouping separator to use when formatting/parsing amounts.
    :param default_currency: Most of the time, we have more precise default currency values to use
                             than this one (account level, document level), but when all else fail
                             use this currency when parsing an amount for which we don't know the
                             currency.
    :type default_currency: :class:`.Currency`
    :param str cache_path: The path (a folder) in which we put our "cache" stuff, that is, the
                           SQLite currency rate cache DB and autosaved files. If ``None``, the
                           currency cache will be in-memory and autosaves will not happen.
    """

    APP_NAME = "moneyGuru"
    NAME = APP_NAME

    def __init__(
            self, view, date_format='dd/MM/yyyy', decimal_sep='.', grouping_sep='',
            default_currency='USD', cache_path=None):
        self.view = view
        self.cache_path = cache_path
        # cache_path is required, but for tests, we don't want to bother specifying it. When
        # cache_path is kept as None, the path of the currency db will be ':memory:'
        if cache_path:
            if not op.exists(cache_path):
                os.makedirs(cache_path)
            db_path = op.join(cache_path, 'currency.db')
        else:
            db_path = ':memory:'
        currency.initialize_db(db_path)
        self._default_currency = default_currency
        self._date_format = date_format
        amount_configure(decimal_sep, grouping_sep)
        self._autosave_interval = self.get_default(PreferenceNames.AutoSaveInterval, 10)
        self._auto_decimal_place = self.get_default(PreferenceNames.AutoDecimalPlace, False)
        self._day_first_date_entry = self.get_default(PreferenceNames.DayFirstDateEntry, True)
        self._show_schedule_scope_dialog = self.get_default(PreferenceNames.ShowScheduleScopeDialog, True)
        self._hook_currency_providers()
        self._update_date_entry_order()

    # --- Private
    def _update_date_entry_order(self):
        DateWidget.setDMYEntryOrder(self._day_first_date_entry)

    def _hook_currency_providers(self):
        for p in get_providers():
            Currencies.get_rates_db().register_rate_provider(p().wrapped_get_currency_rates)

    # --- Public
    def format_date(self, date):
        """Returns a formatted date using app-wide preferences.

        This simply wraps :func:`core.model.date.format_date` and adds default values.
        """
        return format_date(date, self._date_format)

    def parse_date(self, date):
        """Returns a parsed date using app-wide preferences.

        This simply wraps :func:`core.model.date.parse_date` and adds default values.
        """
        return parse_date(date, self._date_format)

    def get_default(self, key, fallback_value=None):
        """Returns moneyGuru user pref for ``key``.

        .. seealso:: :meth:`ApplicationView.get_default`

        :param str key: The key of the prefence to return.
        :param fallback_value: if the pref doesn't exist or isn't of the same type as the
                               fallback value, return the fallback. Therefore, you can use the
                               fallback value as a way to tell "I expect preferences of this type".
        """
        result = nonone(self.view.get_default(key), fallback_value)
        if fallback_value is not None and not isinstance(result, type(fallback_value)):
            # we don't want to end up with garbage values from the prefs
            try:
                result = type(fallback_value)(result)
            except Exception:
                result = fallback_value
        return result

    def set_default(self, key, value):
        """Sets moneyGuru user pref to ``value`` for ``key``.

        .. seealso:: :meth:`ApplicationView.set_default`

        :param str key: The key of the prefence to set.
        :param value: The value to set the pref to.
        """
        self.view.set_default(key, value)

    @property
    def date_format(self):
        """Default, app-wide date format."""
        return self._date_format

    # --- Preferences
    @property
    def autosave_interval(self):
        """*get/set int*. Interval (in minutes) at which we perform autosave."""
        return self._autosave_interval

    @autosave_interval.setter
    def autosave_interval(self, value):
        if value == self._autosave_interval:
            return
        self._autosave_interval = value
        self.set_default(PreferenceNames.AutoSaveInterval, value)

    @property
    def auto_decimal_place(self):
        """*get/set bool*. Whether we automatically place decimal sep when parsing amounts.

        .. seealso:: :func:`core.model.amount.parse_amount`
        """
        return self._auto_decimal_place

    @auto_decimal_place.setter
    def auto_decimal_place(self, value):
        if value == self._auto_decimal_place:
            return
        self._auto_decimal_place = value
        self.set_default(PreferenceNames.AutoDecimalPlace, value)

    @property
    def day_first_date_entry(self):
        """*get/set bool*. Whether the user wants to enter dates in day -> month -> year order or
        the natural left-to-right order in the user's date format.
        """
        return self._day_first_date_entry

    @day_first_date_entry.setter
    def day_first_date_entry(self, value):
        if value == self._day_first_date_entry:
            return
        self._day_first_date_entry = value
        self.set_default(PreferenceNames.DayFirstDateEntry, value)
        self._update_date_entry_order()

    @property
    def show_schedule_scope_dialog(self):
        """*get/set bool*. Whether we prompt the user for schedule editing scope.

        When editing a schedule spawn, we need to know if the user intends this edit to be local or
        global. When this pref is true, we ask the user every time. When it's false, we always do
        local changes unless Shift is held.

        .. seealso:: :class:`core.model.recurrence.Recurrence`
        """
        return self._show_schedule_scope_dialog

    @show_schedule_scope_dialog.setter
    def show_schedule_scope_dialog(self, value):
        if value == self._show_schedule_scope_dialog:
            return
        self._show_schedule_scope_dialog = value
        self.set_default(PreferenceNames.ShowScheduleScopeDialog, value) class PreferenceNames:
    """Holds a list of preference key constants used in moneyGuru.

    * ``AutoSaveInterval``
    * ``AutoDecimalPlace``
    * ``CustomRanges``
    * ``ShowScheduleScopeDialog``
    """
    AutoSaveInterval = 'AutoSaveInterval'
    AutoDecimalPlace = 'AutoDecimalPlace'
    DayFirstDateEntry = 'DayFirstDateEntry'
    ShowScheduleScopeDialog = 'ShowScheduleScopeDialog' class Document(GUIObject):
    """Manages everything (including views) about an opened document.

    If there's one core class in moneyGuru, this is it. It represents a new or opened document and
    holds all model instances associated to it (accounts, transactions, etc.). The ``Document`` is
    also responsible for notifying all gui instances of changes. While it's OK for GUI instances to
    directly access models (through :attr:`transactions` and :attr:`accounts`, for example), any
    modification to those models have to go through ``Document``'s public methods.

    Another important role of the ``Document`` is to manage undo points. For undo to work properly,
    every mutative action must be properly recorded, and that's what the ``Document`` does.

    When calling methods that take "hard values" (dates, descriptions, etc..), it is expected that
    these values have already been parsed (it's the role of the GUI instances to parse data). So
    dates are ``datetime.date`` instances, amounts are :class:`Amount` instances, indexes are
    ``int``.
    """

    def __init__(self, app):
        GUIObject.__init__(self)
        self.app = app
        self._properties = {
            'default_currency': self.app._default_currency,
            'first_weekday': 0,
            'ahead_months': 3,
            'year_start_month': 1,
        }
        #: :class:`.AccountList` containing all accounts of the document.
        self.accounts = AccountList(self.default_currency)
        #: :class:`.TransactionList` containing all transactions of the document.
        self.transactions = TransactionList()
        # I did not manage to create a repeatable test for it, but self.schedules has to be
        # an ordered collection because the order in which the spawns are created must stay the
        # same
        self.schedules = []
        self.oven = Oven(self.accounts, self.transactions, self.schedules)
        self.step = 1
        #: Set of accounts that are currently in "excluded" state.
        self.excluded_accounts = set()
        # Keep track of newly added groups between refreshes
        self.newgroups = set()
        self._undoer = Undoer(self.accounts, self.transactions, self.schedules)
        self._date_range = YearRange(datetime.date.today())
        self._document_id = None
        self._dirty_flag = False

    # --- Private
    def _add_transactions(self, transactions):
        if not transactions:
            return
        for txn in transactions:
            self.transactions.add(txn)
        min_date = min(t.date for t in transactions)
        self._cook(from_date=min_date)

    def _autosave(self):
        existing_names = [name for name in os.listdir(self.app.cache_path) if name.startswith('autosave')]
        existing_names.sort()
        timestamp = int(time.time())
        autosave_name = 'autosave{0}.moneyguru'.format(timestamp)
        while autosave_name in existing_names:
            timestamp += 1
            autosave_name = 'autosave{0}.moneyguru'.format(timestamp)
        self.save_to_xml(op.join(self.app.cache_path, autosave_name), autosave=True)
        if len(existing_names) >= AUTOSAVE_BUFFER_COUNT:
            os.remove(op.join(self.app.cache_path, existing_names[0]))

    def _change_transaction(self, transaction, global_scope=False, **kwargs):
        date = kwargs.get('date', NOEDIT)
        date_changed = date is not NOEDIT and date != transaction.date
        kws = {k: v for k, v in kwargs.items() if v is not NOEDIT}
        transaction.change(**kws)
        if transaction.is_spawn:
            schedule = find_schedule_of_spawn(transaction, self.schedules)
            assert schedule is not None
            if global_scope:
                schedule.change_globally(transaction)
            else:
                schedule.delete_at(transaction.recurrence_date)
                materialized = transaction.materialize()
                self.transactions.add(materialized)
        else:
            if transaction not in self.transactions:
                self.transactions.add(transaction)
            elif date_changed:
                self.transactions.move_last(transaction)
        self.transactions.clear_cache()

    def _cook(self, from_date=None):
        self.oven.cook(from_date=from_date, until_date=self.date_range.end)
        # Whenever we cook, we touch. That saves us some touch() repetitions.
        self.touch()

    def _get_action_from_changed_transactions(self, transactions, global_scope=False):
        if len(transactions) == 1 and not transactions[0].is_spawn \
                and transactions[0] not in self.transactions:
            action = Action(tr('Add transaction'))
            action.added_transactions.add(transactions[0])
        else:
            action = Action(tr('Change transaction'))
            action.change_transactions(transactions, self.schedules)
        if global_scope:
            spawns, txns = extract(lambda x: x.is_spawn, transactions)
            action.change_transactions(spawns, self.schedules)
        return action

    def _query_for_scope_if_needed(self, transactions):
        """Queries the UI for change scope if there's any Spawn among transactions.

        Returns whether the chosen scope is global
        Raises OperationAborted if the user cancels the operation.
        """
        if any(txn.is_spawn for txn in transactions):
            scope = self.view.query_for_schedule_scope()
            if scope == ScheduleScope.Cancel:
                raise OperationAborted()
            return scope == ScheduleScope.Global
        else:
            return False

    def _restore_preferences_after_load(self):
        # some preference need the file loaded before attempting a restore
        logging.debug('restore_preferences_after_load() beginning')
        excluded_account_names = set(nonone(self.get_default(EXCLUDED_ACCOUNTS_PREFERENCE), []))
        self.excluded_accounts = {a for a in self.accounts if a.name in excluded_account_names}

    def _save_preferences(self):
        excluded_account_names = [a.name for a in self.excluded_accounts]
        self.set_default(EXCLUDED_ACCOUNTS_PREFERENCE, excluded_account_names)

    # --- Account
    def change_accounts(
            self, accounts, name=NOEDIT, type=NOEDIT, currency=NOEDIT,
            groupname=NOEDIT, account_number=NOEDIT, inactive=NOEDIT,
            notes=NOEDIT):
        """Properly sets properties for ``accounts``.

        Sets ``accounts``' properties in a proper manner. Attributes
        corresponding to arguments set to ``NOEDIT`` will not be touched.

        :param accounts: List of :class:`.Account` to be changed.
        :param name: ``str``
        :param type: :class:`.AccountType`
        :param currency: :class:`.Currency`
        :param groupname: ``str``
        :param account_number: ``str``
        :param inactive: ``bool``
        :param notes: ``str``
        """
        assert all(a is not None for a in accounts)
        # Check for name clash
        for account in accounts:
            if name is not NOEDIT and name:
                other = self.accounts.find(name)
                if (other is not None) and (other != account):
                    return False
        action = Action(tr('Change account'))
        action.change_accounts(accounts)
        self._undoer.record(action)
        for account in accounts:
            kwargs = {}
            if name is not NOEDIT and name:
                self.accounts.rename_account(account, name.strip())
            if (type is not NOEDIT) and (type != account.type):
                kwargs.update({'type': type, 'groupname': None})
            if currency is not NOEDIT:
                entries = self.accounts.entries_for_account(account)
                assert not any(e.reconciled for e in entries)
                kwargs['currency'] = currency
            if groupname is not NOEDIT:
                kwargs['groupname'] = groupname
            if account_number is not NOEDIT:
                kwargs['account_number'] = account_number
            if inactive is not NOEDIT:
                kwargs['inactive'] = inactive
            if notes is not NOEDIT:
                kwargs['notes'] = notes
            if kwargs:
                account.change(**kwargs)
        self._cook()
        self.transactions.clear_cache()
        return True

    def delete_accounts(self, accounts, reassign_to=None):
        """Removes ``accounts`` from the document.

        If the account has entries assigned to it, these entries will be reassigned to the
        ``reassign_to`` account.

        :param accounts: List of :class:`.Account` to be removed.
        :param accounts: :class:`.Account` to use for reassignment.
        """
        # Recording the "Remove account" action into the Undoer is quite something...
        action = Action(tr('Remove account'))
        accounts = set(accounts)
        action.deleted_accounts |= accounts
        all_entries = flatten(self.accounts.entries_for_account(a) for a in accounts)
        if reassign_to is None:
            transactions = {e.transaction for e in all_entries if not e.transaction.is_spawn}
            transactions = {t for t in transactions if not t.affected_accounts() - accounts}
            action.deleted_transactions |= transactions
        action.change_entries(all_entries, self.schedules)
        affected_schedules = [s for s in self.schedules if accounts & s.affected_accounts()]
        for schedule in affected_schedules:
            action.change_schedule(schedule)
        self._undoer.record(action)
        for account in accounts:
            self.transactions.reassign_account(account, reassign_to)
            for schedule in affected_schedules:
                schedule.reassign_account(account, reassign_to)
            self.accounts.remove(account)
        self._cook()

    def new_account(self, type, groupname):
        """Create a new account in the document.

        Creates a new account of type ``type``, within the ``group`` (which can be ``None`` to
        indicate no group). The new account will have a unique name based on the string
        "New Account" (if it exists already, a unique number will be appended to it). Once created,
        the account is added to the account list.

        :param type: :class:`.AccountType`
        :param group: :class:`.Group`
        :rtype: :class:`.Account`
        """
        name = self.accounts.new_name(tr('New account'))
        account = self.accounts.create(name, self.default_currency, type)
        if groupname:
            account.change(groupname=groupname)
        action = Action(tr('Add account'))
        action.added_accounts.add(account)
        self._undoer.record(action)
        self.touch()
        return account

    def toggle_accounts_exclusion(self, accounts):
        """Toggles "excluded" state for ``accounts``.

        If the current excluded state for ``accounts`` is not homogenous, we set all non-excluded
        accounts as excluded and leave excluded accounts in their current state.

        :param accounts: a ``set`` of :class:`.Account`
        """
        if accounts <= self.excluded_accounts: # all accounts are already excluded. re-include all
            self.excluded_accounts -= accounts
        else:
            self.excluded_accounts |= accounts

    # --- Transaction
    def can_move_transactions(self, transactions, before, after):
        """Returns whether ``transactions`` can be be moved (re-ordered).

        Transactions can only be moved when all transactions are of the same date, and that the date
        of those transaction is between the date of ``before`` and ``after``. When ``before`` or
        ``after`` is ``None``, it means that it's the end or beginning of the list.

        :param transactions: a collection of :class:`Transaction`
        :param before: :class:`Transaction`
        :param after: :class:`Transaction`
        :rtype: ``bool``
        """
        assert transactions
        if any(txn.is_spawn for txn in transactions):
            return False
        if not allsame(txn.date for txn in transactions):
            return False
        from_date = transactions[0].date
        before_date = before.date if before else None
        after_date = after.date if after else None
        return from_date in (before_date, after_date)

    @handle_abort
    def change_transaction(self, original, new):
        """Changes the attributes of ``original`` so that they match those of ``new``.

        Adds undo recording, global scope querying, date range adjustments and UI triggers.

        :param original: :class:`.Transaction`
        :param new: :class:`.Transaction`, a modified copy of ``original``.
        """
        global_scope = self._query_for_scope_if_needed([original])
        action = Action(tr('Change transaction'))
        action.change_transactions([original], self.schedules)
        self._undoer.record(action)
        # don't forget that account up here is an external instance. Even if an account of
        # the same name exists in self.accounts, it's not gonna be the same instance.
        for split in new.splits:
            if split.account is not None:
                split.account = self.accounts.find(split.account.name, split.account.type)
        original.change(splits=new.splits)
        min_date = min(original.date, new.date)
        self._change_transaction(
            original, date=new.date, description=new.description,
            payee=new.payee, checkno=new.checkno, notes=new.notes, global_scope=global_scope
        )
        self._cook(from_date=min_date)
        self.accounts.clean_empty_categories()
        self.date_range = self.date_range.around(original.date)

    @handle_abort
    def change_transactions(
            self, transactions, date=NOEDIT, description=NOEDIT, payee=NOEDIT, checkno=NOEDIT,
            from_=NOEDIT, to=NOEDIT, amount=NOEDIT, currency=NOEDIT):
        """Properly sets properties for ``transactions``.

        Adds undo recording, global scope querying, date range adjustments and UI triggers.

        :param date: ``datetime.date``
        :param description: ``str``
        :param payee: ``str``
        :param checkno: ``str``
        :param from_: ``str`` (account name)
        :param to: ``str`` (account name)
        :param amount: :class:`.Amount`
        :param currency: :class:`.Currency`
        """
        if len(transactions) == 1:
            global_scope = self._query_for_scope_if_needed(transactions)
        else:
            global_scope = False
        action = self._get_action_from_changed_transactions(transactions, global_scope)
        self._undoer.record(action)
        if from_ is not NOEDIT:
            from_ = self.accounts.find(from_, AccountType.Income) if from_ else None
        if to is not NOEDIT:
            to = self.accounts.find(to, AccountType.Expense) if to else None
        if date is not NOEDIT and amount is not NOEDIT and amount != 0:
            currencies_to_ensure = [amount.currency_code, self.default_currency]
            Currencies.get_rates_db().ensure_rates(date, currencies_to_ensure)

        min_date = date if date is not NOEDIT else datetime.date.max
        for transaction in transactions:
            min_date = min(min_date, transaction.date)
            self._change_transaction(
                transaction, date=date, description=description, payee=payee, checkno=checkno,
                from_=from_, to=to, amount=amount, currency=currency, global_scope=global_scope
            )
        self._cook(from_date=min_date)
        self.accounts.clean_empty_categories()
        self.date_range = self.date_range.around(transactions[-1].date)

    @handle_abort
    def delete_transactions(self, transactions, from_account=None):
        """Removes every transaction in ``transactions`` from the document.

        Adds undo recording, global scope querying, date range adjustments and UI triggers.

        :param transactions: a collection of :class:`.Transaction`.
        :param from_account: the :class:`.Account` from which the operation takes place, if any.
        """
        action = Action(tr('Remove transaction'))
        spawns, txns = extract(lambda x: x.is_spawn, transactions)
        global_scope = self._query_for_scope_if_needed(spawns)
        action.change_transactions(spawns, self.schedules)
        action.deleted_transactions |= set(txns)
        self._undoer.record(action)
        # to avoid sweeping twn refs from under the rug of other spawns, we
        # perform schedule deletions at the end of the loop.
        schedule_deletions = []
        for txn in transactions:
            if txn.is_spawn:
                schedule = find_schedule_of_spawn(txn, self.schedules)
                assert schedule is not None
                if global_scope:
                    schedule.change(stop_date=txn.recurrence_date - datetime.timedelta(1))
                else:
                    schedule_deletions.append((schedule, txn.recurrence_date))
            else:
                self.transactions.remove(txn)
        for schedule, recurrence_date in schedule_deletions:
            schedule.delete_at(recurrence_date)
        min_date = min(t.date for t in transactions)
        self._cook(from_date=min_date)
        self.accounts.clean_empty_categories(from_account)

    def duplicate_transactions(self, transactions):
        """Create copies of ``transactions`` in the document.

        Adds undo recording and UI triggers.

        :param transactions: a collection of :class:`.Transaction` to duplicate.
        """
        if not transactions:
            return
        action = Action(tr('Duplicate transactions'))
        duplicated = [txn.replicate() for txn in transactions]
        action.added_transactions |= set(duplicated)
        self._undoer.record(action)
        self._add_transactions(duplicated)

    def materialize_spawn(self, spawn):
        assert spawn.is_spawn
        schedule = find_schedule_of_spawn(spawn, self.schedules)
        assert schedule is not None
        action = Action(tr('Materialize transaction'))
        action.change_schedule(schedule)
        materialized = spawn.materialize()
        action.added_transactions |= {materialized}
        self._undoer.record(action)
        schedule.delete_at(spawn.recurrence_date)
        self.transactions.add(materialized)
        self._cook(from_date=materialized.date)

    def move_transactions(self, transactions, to_transaction):
        """Re-orders ``transactions`` so that they are right before ``to_transaction``.

        Adds undo recording and UI triggers.

        :param transactions: a collection of :class:`.Transaction` to move.
        :param to_transaction: target :class:`.Transaction` to move to.
        """
        affected = set(transactions)
        affected_date = transactions[0].date
        affected |= set(self.transactions.transactions_at_date(affected_date))
        action = Action(tr('Move transaction'))
        action.change_transactions(affected, self.schedules)
        self._undoer.record(action)
        for transaction in transactions:
            self.transactions.move_before(transaction, to_transaction)
        self._cook()

    # --- Entry
    @handle_abort
    def change_entry(
            self, entry, date=NOEDIT, reconciliation_date=NOEDIT, description=NOEDIT, payee=NOEDIT,
            checkno=NOEDIT, transfer=NOEDIT, amount=NOEDIT):
        """Properly sets properties for ``entry``.

        Adds undo recording, global scope querying, date range adjustments and UI triggers.

        :param entry: :class:`.Entry`
        :param date: ``datetime.date``
        :param reconciliation_date: ``datetime.date``
        :param description: ``str``
        :param payee: ``str``
        :param checkno: ``str``
        :param transfer: ``str`` (name of account)
        :param amount: :class:`.Amount`
        """
        assert entry is not None
        if date is not NOEDIT and amount is not NOEDIT and amount != 0:
            Currencies.get_rates_db().ensure_rates(date, [amount.currency_code, entry.account.currency])
        if reconciliation_date is NOEDIT:
            global_scope = self._query_for_scope_if_needed([entry.transaction])
        else:
            global_scope = False # It doesn't make sense to set a reconciliation date globally
        action = self._get_action_from_changed_transactions([entry.transaction], global_scope)
        # schedules have to be deleted *after* the undo action record
        schedules_to_delete = []
        if reconciliation_date is not NOEDIT and entry.transaction.is_spawn:
            schedule = find_schedule_of_spawn(entry.transaction, self.schedules)
            assert schedule is not None
            action.change_schedule(schedule)
            schedules_to_delete.append((schedule, entry.transaction.recurrence_date))
            newtxn = entry.transaction.materialize()
            self.transactions.add(newtxn)
            split_index = entry.transaction.splits.index(entry.split)
            newsplit = newtxn.splits[split_index]
            newsplit.reconciliation_date = reconciliation_date
            entry = Entry(newsplit, newtxn)
            action.added_transactions.add(newtxn)
        self._undoer.record(action)
        for schedule, recurrence_date in schedules_to_delete:
            schedule.delete_at(recurrence_date)
        candidate_dates = [entry.date, date, reconciliation_date, entry.reconciliation_date]
        min_date = min(d for d in candidate_dates if d is not NOEDIT and d is not None)
        if reconciliation_date is not NOEDIT:
            entry.split.reconciliation_date = reconciliation_date
        if (amount is not NOEDIT) and (len(entry.splits) == 1):
            entry.change_amount(amount)
        if (transfer is not NOEDIT) and (len(entry.splits) == 1) and (transfer != entry.transfer):
            auto_create_type = AccountType.Expense if entry.amount < 0 else AccountType.Income
            transfer_account = self.accounts.find(transfer, auto_create_type) if transfer else None
            entry.splits[0].account = transfer_account
        self._change_transaction(
            entry.transaction, date=date, description=description,
            payee=payee, checkno=checkno, global_scope=global_scope
        )
        entry_date = entry.date # don't use entry past cooking.
        self._cook(from_date=min_date)
        self.accounts.clean_empty_categories()
        self.date_range = self.date_range.around(entry_date)

    def delete_entries(self, entries):
        """Remove transactions in which ``entries`` belong from the document's transaction list.

        :param entries: list of :class:`.Entry`
        """
        from_account = first(entries).account
        transactions = dedupe(e.transaction for e in entries)
        self.delete_transactions(transactions, from_account=from_account)

    def toggle_entries_reconciled(self, entries):
        """Toggle the reconcile flag of `entries`.

        Sets the ``reconciliation_date`` to entries' date, or unset it when turning the flag off.

        :param entries: list of :class:`.Entry`
        """
        if not entries:
            return
        all_reconciled = not entries or all(entry.reconciled for entry in entries)
        newvalue = not all_reconciled
        action = Action(tr('Change reconciliation'))
        action.change_entries(entries, self.schedules)
        min_date = min(entry.date for entry in entries)
        spawns, entries = extract(lambda e: e.transaction.is_spawn, entries)
        action.change_transactions({e.transaction for e in spawns}, self.schedules)
        # spawns have to be processed before the action's recording, but
        # record() has to be called before we change the entries. This is why
        # we have this rather convulted code.
        schedules_to_delete = []
        if newvalue:
            for spawn in spawns:
                schedule = find_schedule_of_spawn(spawn.transaction, self.schedules)
                assert schedule is not None
                schedules_to_delete.append((schedule, spawn))
                materialized = spawn.transaction.materialize()
                self.transactions.add(materialized)
                action.added_transactions.add(materialized)
                split_index = spawn.transaction.splits.index(spawn.split)
                newsplit = materialized.splits[split_index]
                newsplit.reconciliation_date = materialized.date
        self._undoer.record(action)
        for schedule, spawn in schedules_to_delete:
            schedule.delete_at(spawn.transaction.recurrence_date)
        if newvalue:
            for entry in entries:
                # XXX update transaction selection
                entry.split.reconciliation_date = entry.transaction.date
        else:
            for entry in entries:
                entry.split.reconciliation_date = None
        self._cook(from_date=min_date)

    # --- Schedule
    def change_schedule(self, schedule, new_ref, repeat_type, repeat_every, stop_date):
        """Change attributes of ``schedule``.

        ``new_ref`` is a reference transaction that the schedule is going to repeat.

        :param schedule: :class:`.Schedule`
        :param new_ref: :class:`.Transaction`
        :param repeat_type: :class:`.RepeatType`
        :param stop_date: ``datetime.date``
        """
        for split in new_ref.splits:
            if split.account is not None:
                # same as in change_transaction()
                split.account = self.accounts.find(split.account.name, split.account.type)
        if schedule in self.schedules:
            action = Action(tr('Change Schedule'))
            action.change_schedule(schedule)
        else:
            action = Action(tr('Add Schedule'))
            action.added_schedules.add(schedule)
        self._undoer.record(action)
        original = schedule.ref
        min_date = min(original.date, new_ref.date)
        original.change(
            description=new_ref.description, payee=new_ref.payee,
            checkno=new_ref.checkno, notes=new_ref.notes, splits=new_ref.splits
        )
        schedule.change(
            start_date=new_ref.date,
            stop_date=stop_date,
            repeat_type=repeat_type,
            repeat_every=repeat_every)
        if schedule not in self.schedules:
            self.schedules.append(schedule)
        self._cook(from_date=min_date)

    def delete_schedules(self, schedules):
        """Removes ``schedules`` from the document.

        :param schedules: list of :class:`.Schedule`
        """
        if not schedules:
            return
        action = Action(tr('Remove Schedule'))
        action.deleted_schedules |= set(schedules)
        self._undoer.record(action)
        for schedule in schedules:
            self.schedules.remove(schedule)
        min_date = min(s.ref.date for s in schedules)
        self._cook(from_date=min_date)

    # --- Load / Save / Import
    def load_from_xml(self, filename):
        """Clears the document and loads data from ``filename``.

        ``filename`` must be a path to a moneyGuru XML document.

        :param filename: ``str``
        """
        loader = native.Loader(self.default_currency)
        try:
            loader.parse(filename)
        except FileFormatError:
            raise FileFormatError(tr('"%s" is not a moneyGuru file') % filename)
        loader.load()
        self.clear()
        self._document_id = loader.document_id
        for propname in self._properties:
            if propname in loader.properties:
                self._properties[propname] = loader.properties[propname]
        self.accounts = loader.accounts
        self.oven._accounts = self.accounts
        self._undoer._accounts = self.accounts
        for transaction in loader.transactions:
            self.transactions.add(transaction, True)
        for recurrence in loader.schedules:
            self.schedules.append(recurrence)
        self.accounts.default_currency = self.default_currency
        self._cook()
        self._undoer.set_save_point()
        self._restore_preferences_after_load()

    def save_to_xml(self, filename, autosave=False):
        """Saves the document to ``filename``.

        ``filename`` must be a path to a moneyGuru XML document.

        If ``autosave`` is true, the operation will not affect the document's
        modified state.

        :param filename: ``str``
        :param autosave: ``bool``
        """
        if self._document_id is None:
            self._document_id = uuid.uuid4().hex
        save_native(
            filename, self._document_id, self._properties, self.accounts,
            self.transactions, self.schedules
        )
        if not autosave:
            self._undoer.set_save_point()
            self._dirty_flag = False

    def import_entries(self, target_account, ref_account, matches):
        """Imports entries in ``mathes`` into ``target_account``.

        ``target_account`` can be either an existing account in the document or not.

        ``ref_account`` is a reference to the temporary :class:`.Account` created by the loader.

        ``matches`` is a list of tuples ``(entry, ref)`` with ``entry`` being the entry being
        imported and ``ref`` being an existing entry in the ``target_account`` bound to ``entry``.
        ``ref`` can be ``None`` and it's only possible to have a ``ref`` side when the target
        account already exists in the document.
        """
        # Matches is a list of 2 sized tuples (entry, ref), ref being the existing entry that 'entry'
        # has been matched with. 'ref' can be None
        # PREPARATION
        # We have to look which transactions are added, which are changed. We have to see which
        # accounts will be added. Those with a name clash must be replaced (in the splits).

        added_transactions = set()
        added_accounts = set()
        to_unreconcile = set()

        def internalize(account):
            if account in self.accounts:
                return account
            if account.reference:
                found = self.accounts.find_reference(account.reference)
                if found:
                    return found
            found = self.accounts.find(account.name)
            if found:
                return found
            else:
                # we never internalize groupname
                # it doesn't hurt to change the account's groupname because
                # groups have no effect in the import window
                account.change(groupname=None)
                result = self.accounts.create_from(account)
                added_accounts.add(result)
                return result

        if target_account is None:
            target_account = internalize(ref_account)
        for entry, ref in matches:
            entry.split.account = target_account
            if ref is not None:
                to_unreconcile.add(ref.transaction)
            else:
                if entry.transaction not in self.transactions:
                    added_transactions.add(entry.transaction)
                    entry.transaction.mtime = time.time()
            for split in entry.splits:
                if split.account is None:
                    continue
                split.account = internalize(split.account)
        action = Action(tr('Import'))
        action.added_accounts |= added_accounts
        action.added_transactions |= added_transactions
        action.change_transactions(to_unreconcile, self.schedules)
        self._undoer.record(action)

        for txn in to_unreconcile:
            for split in txn.splits:
                split.reconciliation_date = None
        if ref_account.reference is not None:
            target_account.change(reference=ref_account.reference)
        for entry, ref in matches:
            for split in entry.transaction.splits:
                assert not split.account or split.account in self.accounts
            if ref is not None:
                ref.transaction.date = entry.date
                ref.split.amount = entry.split.amount
                ref.transaction.balance(ref.split, True)
                ref.split.reference = entry.split.reference
            else:
                if entry.transaction not in self.transactions:
                    self.transactions.add(entry.transaction)
        self._cook()

    def is_dirty(self):
        """Returns whether the document has been modified since the last time it was saved."""
        return self._dirty_flag or self._undoer.modified

    def set_dirty(self):
        # is_dirty() is determined by the undoer's save point, but some actions are not undoable but
        # make the document dirty (ok, it's just one action: setting doc props). That's what this
        # flag is for.
        self._dirty_flag = True

    # --- Date Range
    @property
    def date_range(self):
        """*get/set*. Current date range of the document.

        The date range is very influential of how data is displayed in the UI. For transaction and
        entry list, it tells which are shown and which are not. For reports (net worth, profit),
        it tells the scope of it (we want a net worth report for... this month? this year?
        last year?).
        """
        return self._date_range

    @date_range.setter
    def date_range(self, date_range):
        if date_range == self._date_range:
            return
        self._date_range = date_range
        self.oven.continue_cooking(date_range.end)

    # --- Undo
    def can_undo(self):
        """Returns whether the document has something to undo."""
        return self._undoer.can_undo()

    def undo_description(self):
        """Returns a string describing what would be undone if :meth:`undo` was called."""
        return self._undoer.undo_description()

    def undo(self):
        """Undo the last undoable action."""
        self._undoer.undo()
        self._cook()

    def can_redo(self):
        """Returns whether the document has something to redo."""
        return self._undoer.can_redo()

    def redo_description(self):
        """Returns a string describing what would be redone if :meth:`redo` was called."""
        return self._undoer.redo_description()

    def redo(self):
        """Redo the last redoable action."""
        self._undoer.redo()
        self._cook()

    # --- Misc
    def clear(self):
        self._document_id = None
        del self.schedules[:]
        self._undoer.clear()
        self._dirty_flag = False
        self.excluded_accounts = set()
        self.newgroups = set()
        self.accounts.clear()
        self.transactions.clear()
        self._cook()

    def close(self):
        self._save_preferences()

    def can_restore_from_prefs(self):
        """Returns whether the document has preferences to restore from.

        In other words, returns whether we have a document ID.
        """
        return self._document_id is not None

    def format_amount(self, amount, force_explicit_currency=False, **kwargs):
        if force_explicit_currency:
            default_currency = None
        else:
            default_currency = self.default_currency
        return amount_format(amount, default_currency or '', **kwargs)

    def parse_amount(self, amount, default_currency=None, **kwargs):
        if default_currency is None:
            default_currency = self.default_currency
        return amount_parse(
            amount, default_currency,
            auto_decimal_place=self.app._auto_decimal_place, **kwargs)

    def is_amount_native(self, amount):
        if amount == 0:
            return True
        return amount.currency_code == self.default_currency

    def touch(self):
        self.step += 1
        if self.app.autosave_interval and self.step % self.app.autosave_interval == 0:
            self._autosave()

    def get_default(self, key, fallback_value=None):
        if self._document_id is None:
            return fallback_value
        key = 'Doc{0}.{1}'.format(self._document_id, key)
        return self.app.get_default(key, fallback_value=fallback_value)

    def set_default(self, key, value):
        if self._document_id is None:
            return
        key = 'Doc{0}.{1}'.format(self._document_id, key)
        self.app.set_default(key, value)

    # --- Properties
    # 0=monday 6=sunday
    @property
    def first_weekday(self):
        return self._properties['first_weekday']

    @first_weekday.setter
    def first_weekday(self, value):
        if value == self._properties['first_weekday']:
            return
        self._properties['first_weekday'] = value
        self.set_dirty()

    @property
    def default_currency(self):
        return self._properties['default_currency']

    @default_currency.setter
    def default_currency(self, value):
        if value == self._properties['default_currency']:
            return
        self._properties['default_currency'] = value
        self.set_dirty()
        self.accounts.default_currency = value
        self.touch() class ScheduleScope:
    Local = 0
    Global = 1
    Cancel = 2 class AccountType:
    """Enum of all possible account types.

    * ``Asset``
    * ``Liability``
    * ``Income``
    * ``Expense``

    Special values:

    * ``All``: set containing all account types
    * ``InOrder``: all account type in "sort" order.
    """
    Asset = 'asset'
    Liability = 'liability'
    Income = 'income'
    Expense = 'expense'
    InOrder = [Asset, Liability, Income, Expense]
    All = set(InOrder) class PaneType:
    NetWorth = 0
    Profit = 1
    Transaction = 2
    Account = 3
    Schedule = 4
    GeneralLedger = 7
    DocProps = 8
    PluginList = 9
    Empty = 100
    Plugin = 1000
    ReadOnlyTablePlugin = 1001 class MainWindow(DocumentGUIObject):
    # --- model -> view calls:
    # change_current_pane()
    # get_panel_view(model)
    # refresh_panes()
    # refresh_status_line()
    # refresh_undo_actions()
    # restore_window_frame(frame)
    # save_window_frame() -> frame
    # show_message(message)
    # view_closed(index)
    # update_area_visibility()

    def __init__(self, document):
        super().__init__(document)
        self._current_pane = None
        self._selected_transactions = []
        self._explicitly_selected_transactions = []
        self._selected_schedules = []
        self._selected_budgets = []
        self._account2visibleentries = {}
        self._filter_string = ''
        self._filter_type = None
        self.panes = []
        self.hidden_areas = set()

        self.search_field = SearchField(self)
        self.daterange_selector = DateRangeSelector(self)
        self.account_lookup = AccountLookup(self)
        self.completion_lookup = CompletionLookup(self)

    # --- Private
    def _add_pane(self, pane):
        self.panes.append(pane)
        self.view.refresh_panes()
        self.current_pane_index = len(self.panes) - 1

    def _apply_filter(self):
        self._invalidate_visible_entries()
        is_txn_pane = self._current_pane.view.VIEW_TYPE in {PaneType.Transaction, PaneType.Account}
        if self.filter_string and not is_txn_pane:
            self.select_pane_of_type(PaneType.Transaction, clear_filter=False)
        self._current_pane.view.apply_filter()
        self._invalidate_hidden_panes()
        self.search_field.refresh()

    def _change_current_pane(self, pane):
        if self._current_pane is pane:
            return
        if self._current_pane is not None:
            self._current_pane.view.hide()
        self._current_pane = pane
        self._current_pane.view.show()
        self.view.change_current_pane()
        self.update_status_line()

    def _close_irrelevant_account_panes(self, close_all=False):
        # close all is if we want to close all accounts, not only "irrelevant"
        # ones
        indexes_to_close = []
        for index, pane in enumerate(self.panes):
            if pane.view.VIEW_TYPE == PaneType.Account:
                if close_all or pane.account not in self.document.accounts:
                    indexes_to_close.append(index)
        if self.current_pane_index in indexes_to_close:
            self.select_pane_of_type(PaneType.NetWorth)
        for index in reversed(indexes_to_close):
            self.close_pane(index)

    def _create_pane(self, pane_type, account=None):
        view = self._get_view_for_pane_type(pane_type, account)
        if pane_type == PaneType.Account:
            return ViewPane(view, account.name)
        else:
            return ViewPane(view, PANETYPE2LABEL[pane_type])

    def _create_pane_from_plugin(self, plugin):
        plugin_inst = plugin(self)
        return ViewPane(plugin_inst.view, plugin_inst.NAME)

    def _ensure_selection_valid(self):
        self._explicitly_selected_transactions = [
            t for t in self._explicitly_selected_transactions
            if t in self.document.transactions]
        self._selected_transactions = [
            t for t in self._selected_transactions
            if t in self.document.transactions]

    def _get_view_for_pane_type(self, pane_type, account):
        if pane_type == PaneType.Account: # we don't cache Account panes
            result = AccountView(self, account)
            return result
        for pane in self.panes:
            if pane.view.VIEW_TYPE == pane_type:
                return pane.view
        if pane_type == PaneType.NetWorth:
            result = NetWorthView(self)
        elif pane_type == PaneType.Profit:
            result = ProfitView(self)
        elif pane_type == PaneType.Transaction:
            result = TransactionView(self)
        elif pane_type == PaneType.Schedule:
            result = ScheduleView(self)
        elif pane_type == PaneType.GeneralLedger:
            result = GeneralLedgerView(self)
        elif pane_type == PaneType.DocProps:
            result = DocPropsView(self)
        elif pane_type == PaneType.Empty:
            result = EmptyView(self)
        else:
            raise ValueError("Cannot create view of type {}".format(pane_type))
        return result

    def _invalidate_hidden_panes(self):
        for pane in self.panes:
            if pane is not self._current_pane:
                pane.view.invalidate()

    def _invalidate_visible_entries(self):
        self._account2visibleentries = {}

    def _perform_if_possible(self, action_name):
        current_view = self._current_pane.view
        if current_view.can_perform(action_name):
            return getattr(current_view, action_name)()

    def _restore_default_panes(self):
        pane_types = [
            PaneType.NetWorth, PaneType.Profit, PaneType.Transaction,
            PaneType.Schedule
        ]
        pane_data = list(zip(pane_types, [None] * len(pane_types)))
        self._set_panes(pane_data)

    def _restore_opened_panes(self):
        stored_panes = self.document.get_default(Preference.OpenedPanes)
        logging.debug('Restoring panes from data %r', stored_panes)
        if not stored_panes:
            return
        pane_data = []
        for data in stored_panes:
            pane_type = data['pane_type']
            if pane_type == PaneType.Account:
                account_name = str(data.get('account_name', '')) # str() because the value might be an int
                account = self.document.accounts.find(account_name)
                if account is None:
                    continue
                arg = account
            elif pane_type >= PaneType.Plugin:
                arg = str(data.get('plugin_name', ''))
            else:
                arg = None
            pane_data.append((pane_type, arg))
        if pane_data:
            self._set_panes(pane_data)
            selected_pane_index = self.document.get_default(Preference.SelectedPane)
            if selected_pane_index is not None:
                self.current_pane_index = selected_pane_index

    def _save_preferences(self):
        opened_panes = []
        for pane in self.panes:
            data = {}
            data['pane_type'] = pane.view.VIEW_TYPE
            if pane.account is not None:
                data['account_name'] = pane.account.name
            if pane.view.VIEW_TYPE >= PaneType.Plugin:
                data['plugin_name'] = pane.view.plugin.plugin_id()
            opened_panes.append(data)
        logging.debug('Saving panes with data %r', opened_panes)
        self.document.set_default(Preference.OpenedPanes, opened_panes)
        self.document.set_default(Preference.SelectedPane, self._current_pane_index)
        self.document.set_default(Preference.HiddenAreas, list(self.hidden_areas))
        window_frame = self.view.save_window_frame()
        if window_frame:
            self.document.set_default(Preference.WindowFrame, list(window_frame))

    def _set_panes(self, pane_data):
        # Replace opened panes with new panes from `pane_data`, which is a [(pane_type, arg)]
        self._current_pane = None
        self._current_pane_index = -1
        self.panes = []
        for pane_type, arg in pane_data:
            try:
                self.panes.append(self._create_pane(pane_type, account=arg))
            except ValueError:
                self.panes.append(self._create_pane(PaneType.NetWorth))
        self.view.refresh_panes()
        self.current_pane_index = 0

    def _set_current_pane(self, newpane):
        index = self.current_pane_index
        self.panes[index] = newpane
        self.view.refresh_panes()
        self._change_current_pane(newpane)

    def _update_area_visibility(self):
        if self._current_pane is not None:
            self._current_pane.view.update_visibility()
        self.view.update_area_visibility()

    def _visible_entries_for_account(self, account):
        date_range = self.document.date_range
        entries = self.document.accounts.entries_for_account(account)
        entries = [e for e in entries if e.date in date_range]
        query_string = self.filter_string
        filter_type = self.filter_type
        if query_string:
            query = self.parse_search_query(query_string)
            entries = [e for e in entries if txn_matches(e.transaction, query)]
        if filter_type is FilterType.Unassigned:
            entries = [e for e in entries if not e.transfer]
        elif (filter_type is FilterType.Income) or (filter_type is FilterType.Expense):
            if account.is_credit_account():
                want_positive = filter_type is FilterType.Expense
            else:
                want_positive = filter_type is FilterType.Income
            if want_positive:
                entries = [e for e in entries if e.amount > 0]
            else:
                entries = [e for e in entries if e.amount < 0]
        elif filter_type is FilterType.Transfer:
            entries = [
                e for e in entries if
                any(s.account is not None and s.account.is_balance_sheet_account() for s in e.splits)
            ]
        elif filter_type is FilterType.Reconciled:
            entries = [e for e in entries if e.reconciled]
        elif filter_type is FilterType.NotReconciled:
            entries = [e for e in entries if not e.reconciled]
        return entries

    # --- Override
    def _revalidate(self):
        self.stop_editing()
        self._invalidate_visible_entries()
        self._close_irrelevant_account_panes()
        self._ensure_selection_valid()
        self.view.refresh_undo_actions()
        self._current_pane.view.revalidate()
        # An account might have been renamed. If so, update pane metadata.
        tochange = first(p for p in self.panes if p.account is not None and p.account.name != p.label)
        if tochange is not None:
            tochange.label = tochange.account.name
            self.view.refresh_panes()

    def _view_updated(self):
        self.daterange_selector.refresh()
        self.daterange_selector.refresh_custom_ranges()
        self.restore_view()
        if not self.panes:
            self._restore_default_panes()

    # --- Public
    def apply_date_range(self, new_date_range, prev_date_range):
        self._invalidate_visible_entries()
        if self._current_pane is not None:
            view = self._current_pane.view
            view.apply_date_range(new_date_range, prev_date_range)
        # we also need to invalidate all other panes.
        self.document.touch()

    def clear(self):
        self.document.clear()
        self.revalidate()

    def close(self):
        """Cleanup the document and close it.

        Saves preferences and tells GUI elements about the document closing (so that they can save
        their own preferences if needed).
        """
        self.document.close()
        self.daterange_selector.save_preferences()
        self._save_preferences()
        for pane in self.panes:
            pane.view.save_preferences()
        if self._current_pane.view.VIEW_TYPE == PaneType.Account:
            # if our current pane is an account view, we need to hide it for it to save its prefs.
            # Since account panes are closed with the document, it doesn't matter if we hide them.
            # However, it's a bit of a kludge and if hide() is called on another type of pane, you
            # risk getting view refresh bugs under Qt because in there, closing a document doesn't
            # always mean closing the window (unlike under Cocoa).
            self._current_pane.view.hide()

    def close_pane(self, index):
        if self.pane_count == 1: # don't close the last pane
            return
        if index == self.current_pane_index:
            # we must select another pane before we close it.
            if index == len(self.panes)-1:
                self.current_pane_index -= 1
            else:
                self.current_pane_index += 1
        pane = self.panes[index]
        del self.panes[index]
        if not any(p.view is pane.view for p in self.panes):
            pane.view.save_preferences()
        self.view.view_closed(index)
        # The index of the current view might have changed
        newindex = self.panes.index(self._current_pane)
        if newindex != self._current_pane_index:
            self._current_pane_index = newindex
            self.view.change_current_pane()

    def delete_item(self):
        return self._perform_if_possible('delete_item')

    def duplicate_item(self):
        return self._perform_if_possible('duplicate_item')

    def edit_item(self):
        try:
            return self._perform_if_possible('edit_item')
        except OperationAborted:
            pass

    def export(self):
        accounts = [a for a in self.document.accounts if a.is_balance_sheet_account()]
        panel = ExportPanel(self)
        panel.view = weakref.proxy(self.view.get_panel_view(panel))
        panel.load(accounts)

    def jump_to_account(self):
        self.account_lookup.show()

    def load_from_xml(self, filename):
        self._close_irrelevant_account_panes(close_all=True)
        self.document.load_from_xml(filename)
        self.restore_view()
        self.revalidate()

    def load_parsed_file_for_import(self, target_account=None):
        """Load a parsed file for import and trigger the opening of the Import window.

        When the document's ``loader`` has finished parsing (either after having done CSV
        configuration or directly after :meth:`parse_file_for_import`), call this method to load the
        parsed data into model instances, ready to be shown in the Import window.
        """
        self.loader.load()
        if any(a.is_balance_sheet_account() for a in self.loader.accounts) and self.loader.transactions:
            panel = ImportWindow(self, target_account)
            panel.view = weakref.proxy(self.view.get_panel_view(panel))
            panel.view.show()
            return panel
        else:
            raise FileFormatError('This file does not contain any account to import.')

    def make_schedule_from_selected(self):
        current_view = self._current_pane.view
        if current_view.VIEW_TYPE in {PaneType.Transaction, PaneType.Account}:
            if not self.selected_transactions:
                return
            # There's no test case for this, but select_pane_of_type() must happen before
            # new_schedule_from_transaction() or else the sctable's selection upon view switch will
            # overwrite our selection.
            self.select_pane_of_type(PaneType.Schedule)
            ref = self.selected_transactions[0].replicate()
            ref.date = inc_date(ref.date, RepeatType.Monthly, 1)
            schedule = Recurrence(ref, RepeatType.Monthly, 1)
            self.selected_schedules = [schedule]
            return self.edit_item()

    def move_down(self):
        self._perform_if_possible('move_down')

    def move_up(self):
        self._perform_if_possible('move_up')

    def move_pane(self, pane_index, dest_index, refresh_panes=True):
        pane = self.panes[pane_index]
        del self.panes[pane_index]
        self.panes.insert(dest_index, pane)
        self.current_pane_index = self.panes.index(self._current_pane)
        if refresh_panes:
            self.view.refresh_panes()

    def navigate_back(self):
        self._perform_if_possible('navigate_back')

    def new_item(self):
        try:
            return self._perform_if_possible('new_item')
        except OperationAborted as e:
            if e.message:
                self.view.show_message(e.message)

    def new_group(self):
        self._perform_if_possible('new_group')

    def new_tab(self):
        self.panes.append(self._create_pane(PaneType.Empty))
        self.view.refresh_panes()
        self.current_pane_index = len(self.panes) - 1

    def open_account(self, account):
        if account is not None:
            # Try to find a suitable pane, or add a new one
            index = first(i for i, p in enumerate(self.panes) if p.account == account)
            if index is None:
                self._add_pane(self._create_pane(PaneType.Account, account))
            else:
                self.current_pane_index = index
        elif self._current_pane.view.VIEW_TYPE == PaneType.Account:
            self.select_pane_of_type(PaneType.NetWorth)

    def pane_label(self, index):
        pane = self.panes[index]
        return pane.label

    def pane_type(self, index):
        pane = self.panes[index]
        return pane.view.VIEW_TYPE

    def pane_view(self, index):
        return self.panes[index].view

    def parse_file_for_import(self, filename):
        """Parses ``filename`` in preparation for importing.

        Opens and parses ``filename`` and try to determine its format by successively trying to read
        is as a moneyGuru file, an OFX, a QIF and finally a CSV. Once parsed, take the appropriate
        action for the file which is either to show the CSV options window or to call
        :meth:`load_parsed_file_for_import`.
        """
        default_date_format = DateFormat(self.app.date_format).sys_format
        for loaderclass in (native.Loader, ofx.Loader, qif.Loader, csv.Loader):
            try:
                loader = loaderclass(
                    self.document.default_currency, default_date_format=default_date_format
                )
                loader.parse(filename)
                break
            except FileFormatError:
                pass
        else:
            # No file fitted
            raise FileFormatError(tr('%s is of an unknown format.') % filename)
        self.loader = loader
        if isinstance(self.loader, csv.Loader):
            panel = CSVOptions(self)
            panel.view = weakref.proxy(self.view.get_panel_view(panel))
            panel.show()
            return panel
        else:
            return self.load_parsed_file_for_import()

    def parse_search_query(self, query_string):
        """Parses ``query_string`` into something that can be used to filter transactions.

        :param str query_string: Search string that comes straight from the user through the search
                                 box.
        :rtype: a dict of query arguments
        """
        query_string = query_string.strip().lower()
        ALL_QUERY_TYPES = ['account', 'group', 'amount', 'description', 'checkno', 'payee', 'memo']
        RE_TARGETED_SEARCH = re.compile(r'({}):(.*)'.format('|'.join(ALL_QUERY_TYPES)))
        m = RE_TARGETED_SEARCH.match(query_string)
        if m is not None:
            qtype, qargs = m.groups()
            qtypes = [qtype]
        else:
            qtypes = ALL_QUERY_TYPES
            qargs = query_string
        query = {}
        for qtype in qtypes:
            if qtype in {'account', 'group'}:
                # account and group args are comma-splitted
                query[qtype] = {s.strip() for s in qargs.split(',')}
            elif qtype == 'amount':
                try:
                    query['amount'] = abs(self.document.parse_amount(qargs, with_expression=False))
                except ValueError:
                    pass
            else:
                query[qtype] = qargs
        return query

    def redo(self):
        self.document.redo()
        self.revalidate()

    def restore_view(self):
        self.daterange_selector.restore_view()
        window_frame = self.document.get_default(Preference.WindowFrame)
        if window_frame:
            self.view.restore_window_frame(tuple(window_frame))
        self._restore_opened_panes()
        self.hidden_areas = set(self.document.get_default(Preference.HiddenAreas, fallback_value=[]))
        self._update_area_visibility()
        for pane in self.panes:
            pane.view.restore_view()

    def save_to_xml(self, filename):
        self.stop_editing()
        self.document.save_to_xml(filename)

    def select_pane_of_type(self, pane_type, clear_filter=True):
        if clear_filter:
            self.filter_string = ''
        index = first(i for i, p in enumerate(self.panes) if p.view.VIEW_TYPE == pane_type)
        if index is None:
            self._add_pane(self._create_pane(pane_type))
        else:
            self.current_pane_index = index

    def select_next_view(self):
        if self.current_pane_index == len(self.panes) - 1:
            return
        self.current_pane_index += 1

    def select_previous_view(self):
        if self.current_pane_index == 0:
            return
        self.current_pane_index -= 1

    def set_current_pane_type(self, pane_type):
        self._set_current_pane(self._create_pane(pane_type))

    def set_current_pane_with_plugin(self, plugin):
        self._set_current_pane(self._create_pane_from_plugin(plugin))

    def show_account(self):
        """Shows the currently selected account in the Account view.

        This action has a different meaning depending on the active view. If a sheet is selected,
        the selected account will be shown. If the Transaction or Account view is selected, the
        related account (From, To, Transfer) of the selected transaction will be shown.
        """
        current_view = self._current_pane.view
        if hasattr(current_view, 'show_account'):
            current_view.show_account()

    def show_message(self, message):
        self.view.show_message(message)

    def stop_editing(self):
        if self._current_pane is not None:
            self._current_pane.view.stop_editing()

    def toggle_area_visibility(self, area):
        if area in self.hidden_areas:
            self.hidden_areas.remove(area)
        else:
            self.hidden_areas.add(area)
        self._update_area_visibility()

    def undo(self):
        self.document.undo()
        self.revalidate()

    def update_status_line(self):
        self.view.refresh_status_line()

    def visible_entries_for_account(self, account):
        if account is None:
            return []
        if account not in self._account2visibleentries:
            self._account2visibleentries[account] = self._visible_entries_for_account(account)
        return self._account2visibleentries[account]

    # Column menu
    def column_menu_items(self):
        # Returns a list of (display_name, marked) items for each optional column in the current
        # view (marked means that it's visible).
        if not hasattr(self._current_pane.view, 'columns'):
            return None
        return self._current_pane.view.columns.menu_items()

    def toggle_column_menu_item(self, index):
        if not hasattr(self._current_pane.view, 'columns'):
            return None
        self._current_pane.view.columns.toggle_menu_item(index)

    # --- Properties
    @property
    def current_pane_index(self):
        return self._current_pane_index

    @current_pane_index.setter
    def current_pane_index(self, value):
        if value == self._current_pane_index:
            return
        self.stop_editing()
        value = minmax(value, 0, len(self.panes)-1)
        pane = self.panes[value]
        self._current_pane_index = value
        self._change_current_pane(pane)

    @property
    def filter_string(self):
        """*get/set*. Restrict visible elements in lists to those matching the string.

        When set to an non empty string, it restricts visible transactions/entries in
        :class:`.TransactionTable` and :class:`.EntryTable` to those matching with the string.
        """
        return self._filter_string

    @filter_string.setter
    def filter_string(self, value):
        value = nonone(value, '').strip()
        if value == self._filter_string:
            return
        self._filter_string = value
        self._apply_filter()

    # use FilterType.* consts or None
    @property
    def filter_type(self):
        """*get/set*. Restrict visible elements in lists to those matching the type.

        When set to something else than ``None``, it restricts visible transactions/entries in
        :class:`.TransactionTable` and :class:`.EntryTable` to those matching having the specified
        :class:`.FilterType`
        """
        return self._filter_type

    @filter_type.setter
    def filter_type(self, value):
        if value is self._filter_type:
            return
        self.stop_editing()
        self._filter_type = value
        self._apply_filter()

    @property
    def pane_count(self):
        return len(self.panes)

    @property
    def selected_schedules(self):
        return self._selected_schedules

    @selected_schedules.setter
    def selected_schedules(self, schedules):
        self._selected_schedules = schedules

    @property
    def selected_budgets(self):
        return self._selected_budgets

    @selected_budgets.setter
    def selected_budgets(self, budgets):
        self._selected_budgets = budgets

    @property
    def selected_transactions(self):
        return self._selected_transactions

    @selected_transactions.setter
    def selected_transactions(self, transactions):
        self._selected_transactions = transactions
        if self._current_pane is not None:
            self._current_pane.view.update_transaction_selection(transactions)

    @property
    def explicitly_selected_transactions(self):
        return self._explicitly_selected_transactions

    @explicitly_selected_transactions.setter
    def explicitly_selected_transactions(self, transactions):
        self._explicitly_selected_transactions = transactions
        self.selected_transactions = transactions

    @property
    def status_line(self):
        return self._current_pane.view.status_line

    # --- Event callbacks
    def document_changed(self):
        self._revalidate() BASE_DATE_FORMATS = [
    '%m/%d/%y', '%m/%d/%Y', '%d/%m/%Y', '%d/%m/%y', '%Y/%m/%d', '%d/%b/%Y', '%d/%b/%y'
]
EXTRA_DATE_SEPS = ['.', '-', ' ']
DATE_FORMATS = BASE_DATE_FORMATS[:]
POSSIBLE_PATTERNS = [
    r'[\d/.\- ]{6,10}',
    r'\d{1,2}[/.\- ]\w{3}[/.\- ]\d{2,4}',
    r"\d{1,2}/\d{1,2}'\d{2,4}",
]
    FILE_OPEN_MODE = 'rt'
    FILE_ENCODING = 'utf-8'
    NATIVE_DATE_FORMAT = None
    EXTRA_DATE_FORMATS = None
def parse_amount(string, currency, **kwargs):
def get_account_type(type):
def get_account(accounts, accountname, auto_create_type):
def process_split(accounts, accountname, str_amount, currency=None, strict_currency=False):
def clean_date(str_date):
def guess_date_format(str_dates, formats_to_try):
def parse_date_str(date_str, date_format):
    def __init__(self, default_currency, default_date_format=None):
    def _fetch_currencies(self):
    def _parse(self, infile):
    def _load(self):
    def _post_load(self):
    def guess_date_format(self, str_dates):
    def get_currency(self, code):
    def parse(self, filename):
    def load(self):
    def __init__(self):
    def is_valid(self):
    def load(self, accounts):
    def add_split(self, account, amount, memo):
class Loader:
class TransactionInfo: class DateFormat:
    """Bridge "system" date formats (``%d-%m-%Y``) and "ISO" date formats (``dd-MM-yyyy``).

    We only support simple short and numerical date formats, but we can freely convert each of them
    from/to iso/sys, which is rather convenient.

    This class also supports date format inputs with a moderate amount of garbage in it. It looks
    for a separator, a day, a month and a year element, checks their order and precision, and
    ignores the rest. In case of an unreadable format, it defaults to ``dd/MM/yyyy``.

    The default initialization takes an ISO format. If you want to create a date format from a sys
    format, use :meth:`from_sysformat`.
    """
    ISO2SYS = {'yyyy': '%Y', 'yy': '%y', 'MMM': '%b', 'MM': '%m', 'M': '%m', 'dd': '%d', 'd': '%d'}
    SYS2ISO = {'%Y': 'yyyy', '%y': 'yy', '%m': 'MM', '%b': 'MMM', '%d': 'dd'}

    def __init__(self, format):
        if format is None:
            format = ''
        # Default values in case we can't parse
        self.separator = '/'
        self.elements = ['dd', 'MM', 'yyyy']
        m_separators = re_separators.search(format)
        if m_separators:
            self.separator = m_separators.group()
            elements = format.split(self.separator)
            # macOS (and possibly others) use 'y' to denote the year with a non-zero filled century.
            # Since there is no % equivalent, we'll just use 'yyyy' here instead.
            elements = ['yyyy' if x == 'y' else x for x in elements]
            if all(elem in self.ISO2SYS for elem in elements):
                self.elements = elements

    @staticmethod
    def from_sysformat(format):
        """Creates a new instance from a "sys" format (``%d-%m-%Y``)."""
        if format is None:
            format = ''
        for key, value in DateFormat.SYS2ISO.items():
            format = format.replace(key, value)
        return DateFormat(format)

    def copy(self):
        """Returns a copy of self."""
        return DateFormat(self.iso_format)

    def parse_date(self, string):
        """Parses ``string`` to a ``datetime.date``."""
        # From ticket #381, in some cases the user may input a date field which is in an
        # intermediate editing state such as '1 /12/2012'. They may either accept that or continue
        # to edit to another valid date such as '12/12/2012'. Instead of trying to make the system
        # communicate the end of editing back to the UI level, we simply remove the spurious space
        # characters in the model.
        if self.separator == ' ':
            # In instances where the separator is a space, we can not remove all space characters.
            # Instead, we replace double spaces with a single space.
            string = string.replace('  ', ' ')
        else:
            string = string.replace(' ', '')
        return datetime.strptime(string, self.sys_format).date()

    def make_numerical(self):
        """If the date format contains a non-numerical month, change it to a numerical one."""
        if 'MMM' in self.elements:
            self.elements[self.elements.index('MMM')] = 'MM'

    @property
    def iso_format(self):
        """Returns the format as ISO (``dd-MM-yyyy``)."""
        return self.separator.join(self.elements)

    @property
    def sys_format(self):
        """Returns the format as sys (``%d-%m-%Y``)."""
        repl_elems = [self.ISO2SYS[elem] for elem in self.elements]
        return self.separator.join(repl_elems) def flatten(iterables, start_with=None):
    """Takes a list of lists ``iterables`` and returns a list containing elements of every list.

    If ``start_with`` is not ``None``, the result will start with ``start_with`` items, exactly as
    if ``start_with`` would be the first item of lists.
    """
    result = []
    if start_with:
        result.extend(start_with)
    for iterable in iterables:
        result.extend(iterable)
    return result
import os.path as op
from datetime import date
from operator import attrgetter
from ..app import Application, PreferenceNames
from ..document import Document, ScheduleScope
from ..exception import FileFormatError
from ..const import AccountType, PaneType
from ..gui.base import GUIObject
from ..gui.completable_edit import CompletableEdit
from ..gui.main_window import MainWindow
from ..gui.account_panel import AccountPanel
from ..loader import base
from ..model.sort import ACCOUNT_SORT_KEY
from ..model._ccore import amount_parse
from ..model.date import DateFormat
from ..util import flatten
from .testutil import eq_, CallLogger, TestApp as TestAppBase, TestData
        for elem in vars(model).values():
            if elem is model or elem in ignore:
                continue
            if isinstance(elem, GUIObject) and elem.view is None:
                elem.view = CallLogger()
                cls._logify(elem, ignore + [model])

    def get_panel_view(self, model):
        self._logify(model)
        self.current_panel = model
        # We have to hold onto this instance for a while
        self.current_panel_view = CallLogger()
        return self.current_panel_view


def log(method):
    def wrapper(self, *args, **kw):
        result = method(self, *args, **kw)
        self.calls.append(method.__name__)
        return result

    return wrapper

class ApplicationGUI(CallLogger):
    def __init__(self):
        CallLogger.__init__(self)
        # We don't want the autosave thread to mess up with testunits
        self.defaults = {PreferenceNames.AutoSaveInterval: 0}

    def get_default(self, key): # We don't want to log this one. It disturbs other test and is pointless to log
        return self.defaults.get(key)

    @log
    def set_default(self, key, value):
        self.defaults[key] = value


class DocumentGUI(CallLogger):
    def __init__(self):
        CallLogger.__init__(self)
        self.query_for_schedule_scope_result = ScheduleScope.Local

    @log
    def query_for_schedule_scope(self):
        return self.query_for_schedule_scope_result


class ViewGUI(CallLogger):
    def __init__(self, panel_view_provider):
        CallLogger.__init__(self)
        self.panel_view_provider = panel_view_provider

    @log
    def get_panel_view(self, model):
        return self.panel_view_provider.get_panel_view(model)

class MainWindowGUI(CallLogger):
    def __init__(self, testapp):
        CallLogger.__init__(self)
        self.messages = []
        self.testapp = testapp

    @log
    def get_panel_view(self, model):
        return self.testapp.panel_view_provider.get_panel_view(model)

    @log
    def show_message(self, message):
        self.messages.append(message)

    # Link the view of lazily loaded elements.
    @log
    def refresh_panes(self):
        app = self.testapp
        for i in range(app.mw.pane_count):
            app.link_gui(
                app.mw.pane_view(i),
                ViewGUI(self.testapp.panel_view_provider)
            )

class DictLoader(base.Loader):
    """Used for fake_import"""
    def __init__(
            self, default_currency, infos, default_date_format='%d/%m/%Y'):
        # `infos` look like:
        # [{'name': 'account name',
        #   'reference': ...,
        #   'txns': [{'date': ..., 'description': ...}]
        # }]
        base.Loader.__init__(self, default_currency, default_date_format)
        self.infos = infos
        alltxns = flatten(info['txns'] for info in infos)
        str_dates = [txn['date'] for txn in alltxns]
        self.parsing_date_format = self.guess_date_format(str_dates)

    def _parse(self, infile):
        pass

    def _load(self):
        for info in self.infos:
            account = base.get_account(self.accounts, info['name'], None)
            account.change(reference=info.get('reference'))
            for txn in info['txns']:
                info = base.TransactionInfo()
                info.account = account.name
                for attr, value in txn.items():
                    if attr == 'date':
                        value = base.parse_date_str(value, self.parsing_date_format)
                    setattr(info, attr, value)
                self.transactions.add(info.load(self.accounts))

class TestApp(TestAppBase):
    __test__ = False
    def __init__(self, app=None, doc=None, appargs=None):
        TestAppBase.__init__(self)
        self.panel_view_provider = PanelViewProvider()
        link_gui = self.link_gui
        if app is None:
            if not appargs:
                appargs = {}

            app = Application(self.make_logger(ApplicationGUI()), **appargs)