MTGProxyPrinter

Changes On Branch custom_card_import_dialog
Login

Changes On Branch custom_card_import_dialog

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Changes In Branch custom_card_import_dialog Excluding Merge-Ins

This is equivalent to a diff from 65f34c7bf4 to cab293fb6d

2025-04-30
17:39
Implement a custom card import dialog, improving the custom card workflow. check-in: 32022ea7c8 user: thomas tags: trunk
17:29
Fix failing tests Closed-Leaf check-in: cab293fb6d user: thomas tags: custom_card_import_dialog
17:12
When cards are selected in the CardListTable, clicking the "set copies" button in the custom card import dialog only sets the copy value of selected rows. When nothing is selected, it sets the value for all rows. check-in: b0ea5dc09a user: thomas tags: custom_card_import_dialog
15:39
Merge with trunk check-in: da453793dd user: thomas tags: custom_card_import_dialog
15:14
Formalize SQL query and parameter types. State that cached_dedent() propagates LiteralString via a type variable. check-in: 643c7f4f91 user: thomas tags: trunk
14:41
Fix environment-altering side-effect in test_settings.py. A test altered the global settings, causing tests that rely on specific default page layout settings to fail. check-in: 65f34c7bf4 user: thomas tags: trunk
2025-04-26
12:22
Updated project metadata in pyproject.toml according to [https://packaging.python.org/en/latest/guides/writing-pyproject-toml/] check-in: 236ffe53b0 user: thomas tags: trunk

Changes to .fossil-settings/ignore-glob.
26
27
28
29
30
31
32

*.spec
*.pdf
*.mtgproxies
mtg_proxy_printer/resources/translations/*.qm
mtg_proxy_printer/resources/translations/mtgproxyprinter_sources.ts
Screenshots/*.txt
*.png








>
26
27
28
29
30
31
32
33
*.spec
*.pdf
*.mtgproxies
mtg_proxy_printer/resources/translations/*.qm
mtg_proxy_printer/resources/translations/mtgproxyprinter_sources.ts
Screenshots/*.txt
*.png
requirements*.txt
Changes to doc/changelog.md.
1
2
3
4
5
6







7




8
9
10
11
12


13
14
15
16
17
18
19
# Changelog

# Next version (in development)

## Changed features








- Improved custom card support: It is now possible to save custom cards and empty slots in the apps save file format.




- The card table in the deck import wizard now has an editable Copies column to state the number of copies per card,
  instead of duplicating the card for that many rows. This makes it possible to edit the number of copies per card
- When splitting exported PDFs, zero-pad the sequence numbers appended to the file name 
  so that all have the same length. This gives a more consistent sorting of output files.
  - This avoids having output files sorted like "1.pdf", "11.pdf", "12.pdf", …, "2.pdf", "21.pdf", …



# Version 0.30.1 (2025-03-11)  <a name="v0_30_1"></a>

## Fixed issues

- Fixed that some start-up tasks were not run on the Windows 10+ build. Fixes that the deck list translation and 
  default card language setting in the application settings did not offer any language choices.




|

>
>
>
>
>
>
>
|
>
>
>
>





>
>







1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Changelog

# Next version (in development)

## New features

- Improved custom card support
  - Adding custom cards via drag & drop now opens a dialog to customize the import
    - Allows setting the number of copies to add for each card. Vastly improves the workflow when you want
      to print multiple copies. 
    - Shown card name is now derived from the file name, instead of defaulting to "Custom Card"
  - The import dialog can also be accessed from the File menu. Access to printing custom cards no longer
    requires the use of drag & drop.
  - It is now possible to save custom cards and empty slots in the apps save file format.
    Custom cards are no longer lost when saving.

## Changed features

- The card table in the deck import wizard now has an editable Copies column to state the number of copies per card,
  instead of duplicating the card for that many rows. This makes it possible to edit the number of copies per card
- When splitting exported PDFs, zero-pad the sequence numbers appended to the file name 
  so that all have the same length. This gives a more consistent sorting of output files.
  - This avoids having output files sorted like "1.pdf", "11.pdf", "12.pdf", …, "2.pdf", "21.pdf", …
- The page content table no longer uses a fancy multi selection behavior, as it interfered with editing entries.
  The new behavior is in line with how other applications allow selections in tables.

# Version 0.30.1 (2025-03-11)  <a name="v0_30_1"></a>

## Fixed issues

- Fixed that some start-up tasks were not run on the Windows 10+ build. Fixes that the deck list translation and 
  default card language setting in the application settings did not offer any language choices.
Changes to mtg-proxy-printer-runner.py.
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import sys

# Make sure to find this checkout, and not any system- or user-wide installed versions that may be present
root_path = pathlib.Path(__file__).parent.absolute().resolve()
sys.path.insert(0, str(root_path))

import mtg_proxy_printer.model.carddb
from mtg_proxy_printer.__main__ import main  # noqa


# These methods are wrapped by the profile() function
# if this script is run using the kernprof line profiler.
to_be_profiled_functions = {
    mtg_proxy_printer.model.carddb.CardDatabase: [
        "get_all_languages",







|







23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import sys

# Make sure to find this checkout, and not any system- or user-wide installed versions that may be present
root_path = pathlib.Path(__file__).parent.absolute().resolve()
sys.path.insert(0, str(root_path))

import mtg_proxy_printer.model.carddb
from mtg_proxy_printer.__main__ import main


# These methods are wrapped by the profile() function
# if this script is run using the kernprof line profiler.
to_be_profiled_functions = {
    mtg_proxy_printer.model.carddb.CardDatabase: [
        "get_all_languages",
Changes to mtg_proxy_printer/decklist_parser/common.py.
15
16
17
18
19
20
21
22

23
24
25
26
27
28
29


from abc import abstractmethod
import typing

from PyQt5.QtCore import QObject, pyqtSignal as Signal

from mtg_proxy_printer.model.carddb import Card, CardDatabase, CardIdentificationData

from mtg_proxy_printer.model.imagedb import ImageDatabase
import mtg_proxy_printer.settings
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

__all__ = [







|
>







15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30


from abc import abstractmethod
import typing

from PyQt5.QtCore import QObject, pyqtSignal as Signal

from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData
from mtg_proxy_printer.model.card import Card, AnyCardType
from mtg_proxy_printer.model.imagedb import ImageDatabase
import mtg_proxy_printer.settings
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

__all__ = [
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
    # noinspection PyUnresolvedReferences,PyUnboundLocalVariable
    profile
except NameError:
    # If not defined, use this identity decorator as a replacement
    def profile(func):
        return func

CardCounter = typing.Counter[Card]
ParsedDeck = typing.Tuple[CardCounter, typing.List[str]]


class ParserBase(QObject):

    @staticmethod
    def supported_file_types() -> typing.Dict[str, typing.List[str]]:







|







39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
    # noinspection PyUnresolvedReferences,PyUnboundLocalVariable
    profile
except NameError:
    # If not defined, use this identity decorator as a replacement
    def profile(func):
        return func

CardCounter = typing.Counter[AnyCardType]
ParsedDeck = typing.Tuple[CardCounter, typing.List[str]]


class ParserBase(QObject):

    @staticmethod
    def supported_file_types() -> typing.Dict[str, typing.List[str]]:
Changes to mtg_proxy_printer/decklist_parser/csv_parsers.py.
17
18
19
20
21
22
23
24

25
26
27
28
29
30
31
import abc
import collections
import csv
import typing

from PyQt5.QtCore import QObject, QCoreApplication

from mtg_proxy_printer.model.carddb import Card, CardDatabase, CardIdentificationData

from mtg_proxy_printer.model.imagedb import ImageDatabase

from .common import ParsedDeck, ParserBase
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger








|
>







17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import abc
import collections
import csv
import typing

from PyQt5.QtCore import QObject, QCoreApplication

from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData
from ..model.card import Card
from mtg_proxy_printer.model.imagedb import ImageDatabase

from .common import ParsedDeck, ParserBase
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

Changes to mtg_proxy_printer/decklist_parser/re_parsers.py.
17
18
19
20
21
22
23
24

25
26
27
28
29
30
31
from collections import Counter
import re
import typing

from PyQt5.QtCore import QObject, QCoreApplication

from mtg_proxy_printer.decklist_parser.common import ParsedDeck, ParserBase
from mtg_proxy_printer.model.carddb import Card, CardDatabase, CardIdentificationData

from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

MatchType = typing.Dict[str, str]








|
>







17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from collections import Counter
import re
import typing

from PyQt5.QtCore import QObject, QCoreApplication

from mtg_proxy_printer.decklist_parser.common import ParsedDeck, ParserBase
from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData
from mtg_proxy_printer.model.card import Card
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

MatchType = typing.Dict[str, str]

Changes to mtg_proxy_printer/document_controller/_interface.py.
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

    @abstractmethod
    def undo(self, document: "Document") -> Self:
        """
        Reverses the application of the action to the given document, undoing its effects.
        For this to work properly, this action must have been the most recent action applied to the document.
        """
        pass

    def __eq__(self, other) -> bool:
        return isinstance(other, self.__class__) and all(
            map(
                operator.eq,
                map((partial(getattr, self)), self.COMPARISON_ATTRIBUTES),
                map((partial(getattr, other)), self.COMPARISON_ATTRIBUTES)







|







66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

    @abstractmethod
    def undo(self, document: "Document") -> Self:
        """
        Reverses the application of the action to the given document, undoing its effects.
        For this to work properly, this action must have been the most recent action applied to the document.
        """
        return self

    def __eq__(self, other) -> bool:
        return isinstance(other, self.__class__) and all(
            map(
                operator.eq,
                map((partial(getattr, self)), self.COMPARISON_ATTRIBUTES),
                map((partial(getattr, other)), self.COMPARISON_ATTRIBUTES)
Changes to mtg_proxy_printer/document_controller/card_actions.py.
15
16
17
18
19
20
21
22

23
24
25

26
27
28
29
30
31
32


import functools
import itertools
import math
import typing

from mtg_proxy_printer.model.carddb import Card, AnyCardType

if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_page import Page

from ._interface import DocumentAction, IllegalStateError, Self, split_iterable
from .page_actions import ActionNewPage, ActionRemovePage
from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger
__all__ = [







|
>



>







15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34


import functools
import itertools
import math
import typing

from ..model.card import Card, AnyCardType

if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_page import Page
from mtg_proxy_printer.natsort import to_list_of_ranges
from ._interface import DocumentAction, IllegalStateError, Self, split_iterable
from .page_actions import ActionNewPage, ActionRemovePage
from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger
__all__ = [
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
    def as_str(self):
        card_count = sum(upper-lower+1 for lower, upper in self.card_ranges_to_remove)
        page_number = self.page_number+1
        return self.translate(
            "ActionRemoveCards", "Remove %n card(s) from page {page_number}",
            "Undo/redo tooltip text", card_count
        ).format(page_number=page_number)


def to_list_of_ranges(sequence: typing.Iterable[int]) -> typing.List[typing.Tuple[int, int]]:
    sequence = sorted(sequence)
    ranges: typing.List[typing.Tuple[int, int]] = []
    sequence = itertools.chain(sequence, (sentinel := object(),))
    lower = upper = next(sequence)
    for item in sequence:
        if item is sentinel or upper != item-1:
            ranges.append((lower, upper))
            lower = upper = item
        else:
            upper = item
    return ranges







<
<
<
<
<
<
<
<
<
<
<
<
<
<
216
217
218
219
220
221
222














    def as_str(self):
        card_count = sum(upper-lower+1 for lower, upper in self.card_ranges_to_remove)
        page_number = self.page_number+1
        return self.translate(
            "ActionRemoveCards", "Remove %n card(s) from page {page_number}",
            "Undo/redo tooltip text", card_count
        ).format(page_number=page_number)














Added mtg_proxy_printer/document_controller/edit_custom_card.py.














































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import functools
import typing

from PyQt5.QtCore import QModelIndex, Qt
if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_page import CardContainer, PageColumns
from ._interface import DocumentAction, Self

from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger
__all__ = [
    "ActionEditCustomCard",
]
ItemDataRole = Qt.ItemDataRole


class ActionEditCustomCard(DocumentAction):
    """
    Edits a field of a custom card. Ensures that the dataChanged signal is sent for all copies of the given card
    """
    COMPARISON_ATTRIBUTES = ["old_value", "new_value", "page", "row", "column"]

    def __init__(self, index: QModelIndex, value):
        self.page = index.parent().row()
        self.row = index.row()
        self.column = PageColumns(index.column())
        self.old_value = index.data(ItemDataRole.EditRole)
        self.new_value = value
        self.new_display_value = None
        document = index.model()
        self.header_text = document.headerData(self.column, Qt.Orientation.Horizontal, ItemDataRole.DisplayRole)

    def apply(self, document: "Document") -> Self:
        self._set_data_for_custom_card(document, self.new_value)
        index = document.index(self.row, self.column, document.index(self.page, 0))
        self.new_display_value = index.data(ItemDataRole.DisplayRole)
        return super().apply(document)

    def undo(self, document: "Document") -> Self:
        self._set_data_for_custom_card(document, self.old_value)
        return super().undo(document)

    def _set_data_for_custom_card(self, document: "Document", value: typing.Any):
        row, column = self.row, self.column
        index = document.index(row, column, document.index(self.page, 0))
        container: CardContainer = index.internalPointer()
        card = container.card
        logger.debug(f"Setting page data on custom card for {column=} to {value}")
        if column == PageColumns.CardName:
            # This also affects the page overview. find_relevant_index_ranges()
            # takes care of that by also returning relevant Page indices
            card.name = value
        elif column == PageColumns.CollectorNumber:
            card.collector_number = value
        elif column == PageColumns.Language:
            card.language = value
        elif column == PageColumns.IsFront:
            card.is_front = value
            card.face_number = int(not value)
        elif column == PageColumns.Set:
            card.set = value
        for lower, upper in document.find_relevant_index_ranges(card, column):
            document.dataChanged.emit(lower, upper, [ItemDataRole.DisplayRole, ItemDataRole.EditRole])

    @functools.cached_property
    def as_str(self):
        return self.translate(
            "ActionEditCustomCard", "Edit custom card, set {column_header_text} to {new_value}",
            "Undo/redo tooltip text").format(column_header_text=self.header_text, new_value=self.new_display_value)
Changes to mtg_proxy_printer/document_controller/load_document.py.
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import pathlib
import typing

if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.page_layout import PageLayoutSettings
    from mtg_proxy_printer.model.document import Document

from mtg_proxy_printer.model.carddb import CardList
from ._interface import DocumentAction, IllegalStateError, ActionList, Self
from .page_actions import ActionNewPage
from .card_actions import ActionAddCard
from .new_document import ActionNewDocument
from .edit_document_settings import ActionEditDocumentSettings

from mtg_proxy_printer.logger import get_logger







|







18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import pathlib
import typing

if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.page_layout import PageLayoutSettings
    from mtg_proxy_printer.model.document import Document

from mtg_proxy_printer.model.card import CardList
from ._interface import DocumentAction, IllegalStateError, ActionList, Self
from .page_actions import ActionNewPage
from .card_actions import ActionAddCard
from .new_document import ActionNewDocument
from .edit_document_settings import ActionEditDocumentSettings

from mtg_proxy_printer.logger import get_logger
Changes to mtg_proxy_printer/document_controller/page_actions.py.
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

import functools
import typing

if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_page import Page
from mtg_proxy_printer.model.carddb import AnyCardType
from ._interface import DocumentAction, IllegalStateError, Self
from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger
__all__ = [
    "ActionNewPage",







|







16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

import functools
import typing

if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_page import Page
from ..model.card import AnyCardType
from ._interface import DocumentAction, IllegalStateError, Self
from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger
__all__ = [
    "ActionNewPage",
Changes to mtg_proxy_printer/document_controller/replace_card.py.
15
16
17
18
19
20
21

22
23
24
25
26
27
28
29


import functools
import typing

from PyQt5.QtCore import Qt


from mtg_proxy_printer.model.carddb import Card
if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.document_page import CardContainer
    from mtg_proxy_printer.model.document import Document

from ._interface import DocumentAction, Self, ActionList
from .card_actions import ActionRemoveCards, ActionAddCard








>
|







15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30


import functools
import typing

from PyQt5.QtCore import Qt

from ..model.card import Card

if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.document_page import CardContainer
    from mtg_proxy_printer.model.document import Document

from ._interface import DocumentAction, Self, ActionList
from .card_actions import ActionRemoveCards, ActionAddCard

Changes to mtg_proxy_printer/document_controller/save_document.py.
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.document import Document

from ._interface import DocumentAction, Self
from mtg_proxy_printer.sqlite_helpers import open_database, cached_dedent
from mtg_proxy_printer.units_and_sizes import CardSizes, UUID
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.model.carddb import AnyCardType
from mtg_proxy_printer.model.document_loader import CardType
from mtg_proxy_printer.save_file_migrations import migrate_database

from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger







|







24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
if typing.TYPE_CHECKING:
    from mtg_proxy_printer.model.document import Document

from ._interface import DocumentAction, Self
from mtg_proxy_printer.sqlite_helpers import open_database, cached_dedent
from mtg_proxy_printer.units_and_sizes import CardSizes, UUID
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from ..model.card import AnyCardType, CustomCard
from mtg_proxy_printer.model.document_loader import CardType
from mtg_proxy_printer.save_file_migrations import migrate_database

from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102






103
104
105
106

107
108
109
110
111
112
113
114
115

116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139

140
141

142









143
144
145
146
147
148
149
150

151
152
153
154
155
156
157
158
159
160

161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
    def __init__(self, file_path: Path):
        super().__init__()
        self.file_path = file_path

    def apply(self, document: "Document") -> Self:
        logger.debug(f"About to save document to {self.file_path}")
        layout = document.page_layout
        with open_database(self.file_path, "document-v6") as db:
            db.execute("BEGIN IMMEDIATE TRANSACTION")
            migrate_database(db, layout)
            self._clear_cards_and_pages(db)
            self._save_pages(db, document)
            self._save_cards(db, document)
            self.save_settings(db, layout)
            self._clean_unused_custom_cards(db)
            db.commit()
            if db.execute(cached_dedent("""\
                SELECT cast(freelist_count AS real)/page_count > 0.1 AS "should vacuum" 
                  FROM pragma_page_count
                  INNER JOIN pragma_freelist_count""")).fetchone()[0]:

                db.execute("VACUUM")
        logger.debug("Database saved and closed.")

    @staticmethod
    def save_settings(save_file: sqlite3.Connection, layout: PageLayoutSettings):
        settings, dimensions = layout.to_save_file_data()
        save_file.executemany(
            'INSERT OR REPLACE INTO DocumentSettings ("key", value) VALUES (?, ?)\n',
            settings)
        save_file.executemany(
            'INSERT OR REPLACE INTO DocumentDimensions ("key", value) VALUES (?, ?)\n',
            dimensions)
        logger.debug("Written document settings")

    @staticmethod
    def _clear_cards_and_pages(save_file: sqlite3.Connection):
        save_file.execute("DELETE FROM Card")
        save_file.execute("DELETE FROM Page")

    @staticmethod
    def _save_pages(save_file: sqlite3.Connection, document: "Document"):
        pages = (
            (number, CardSizes.for_page_type(page.page_type()).to_save_data())
            for number, page in enumerate(document.pages, start=1)
            if page
        )
        save_file.executemany(
            "INSERT INTO Page (page, image_size) VALUES (?, ?)\n",
            pages
        )

    @staticmethod
    def _save_cards(save_file: sqlite3.Connection, document: "Document"):






        for page_number, page in enumerate(document.pages, start=1):
            for slot, container in enumerate(page, start=1):
                card = container.card
                if card.scryfall_id:

                    save_file.execute(
                        "INSERT INTO Card (page, slot, is_front, type, scryfall_id) VALUES (?, ?, ? ,?, ?)",
                        (page_number, slot, card.is_front, CardType.from_card(card), card.scryfall_id)
                    )
                elif card.image_file is not document.image_db.get_blank(card.size):
                    ActionSaveDocument._save_custom_card(save_file, page_number, slot, card)
                else:  # Empty slot
                    save_file.execute(
                        "INSERT INTO Card (page, slot, is_front, type) VALUES (?, ? ,?, ?)",

                        (page_number, slot, card.is_front, CardType.from_card(card))
                    )
        logger.debug(f"Written {save_file.execute('SELECT count(1) FROM Card').fetchone()[0]} cards.")

    @staticmethod
    def _save_custom_card(save_file: sqlite3.Connection, page_number: int, slot: int, card: AnyCardType):
        custom_card_id = ActionSaveDocument._save_custom_card_data(save_file, card)
        save_file.execute(
            "INSERT INTO Card (page, slot, is_front, type, custom_card_id) VALUES (?, ?, ? ,?, ?)",
            (page_number, slot, card.is_front, CardType.from_card(card), custom_card_id)
        )

    @staticmethod
    def _save_custom_card_data(save_file: sqlite3.Connection, card: AnyCardType) -> UUID:
        custom_card_id, image = ActionSaveDocument._serialize_card_image(card)
        if save_file.execute(
                "SELECT EXISTS (SELECT 1 FROM CustomCardData WHERE card_id = ?)",
                (custom_card_id,)).fetchone()[0]:
            return custom_card_id
        parameters = (
            custom_card_id, image, card.name, card.set.name, card.set.code,
            card.collector_number, card.is_front, card.is_oversized)
        save_file.execute(
            cached_dedent("""\

            INSERT INTO CustomCardData (card_id, image, name, set_name, set_code, collector_number, is_front, oversized)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)"""),

            parameters









        )
        return custom_card_id

    @staticmethod
    def _serialize_card_image(card: AnyCardType) -> typing.Tuple[UUID, bytes]:
        """
        Converts the card image into a byte array for storage in the save file.
        Returns the byte data and the UUID-formatted hash of the byte data.

        """
        pixmap = card.image_file
        buffer = QBuffer()
        buffer.open(QIODevice.OpenModeFlag.WriteOnly)
        pixmap.save(buffer, "PNG", quality=100)
        image_bytes = buffer.data().data()
        buffer.close()
        hd = hashlib.md5(image_bytes).hexdigest()  # TODO: Maybe use something else instead of md5?
        uuid = UUID(f"{hd[:8]}-{hd[8:12]}-{hd[12:16]}-{hd[16:20]}-{hd[20:]}")
        return uuid, image_bytes


    @staticmethod
    def _clean_unused_custom_cards(save_file: sqlite3.Connection):
        save_file.execute(cached_dedent("""\
            DELETE FROM CustomCardData
              WHERE card_id NOT IN (
                SELECT custom_card_id
                  FROM Card
                  WHERE custom_card_id IS NOT NULL
              )"""))

    def undo(self, document: "Document") -> Self:
        raise NotImplementedError("Undoing saving to disk is unsupported.")

    @functools.cached_property
    def as_str(self):
        return self.translate(
            "ActionSaveDocument", "Save document to '{save_file_path}'."
        ).format(save_file_path=self.file_path)







|
|








|

|
>
|






|


|





|
|









|





>
>
>
>
>
>



|
>

<
|

|

|

<
>
|





<
<
<
<
<
<
<
<
<
<
<
<
|
<
|
|
|
<
>
|
|
>
|
>
>
>
>
>
>
>
>
>
|
|
|
<
|
|
<
<
>
|
|
<
<
<
<
<
<
<
<
>




|



|
|









50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115

116
117
118
119
120
121

122
123
124
125
126
127
128












129

130
131
132

133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149

150
151


152
153
154








155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
    def __init__(self, file_path: Path):
        super().__init__()
        self.file_path = file_path

    def apply(self, document: "Document") -> Self:
        logger.debug(f"About to save document to {self.file_path}")
        layout = document.page_layout
        with open_database(self.file_path, "document-v7") as db:
            db.execute("BEGIN IMMEDIATE TRANSACTION  -- apply()\n")
            migrate_database(db, layout)
            self._clear_cards_and_pages(db)
            self._save_pages(db, document)
            self._save_cards(db, document)
            self.save_settings(db, layout)
            self._clean_unused_custom_cards(db)
            db.commit()
            if db.execute(cached_dedent("""\
                SELECT cast(freelist_count AS real)/page_count > 0.1 AS "should vacuum" -- apply() 
                  FROM pragma_page_count
                  INNER JOIN pragma_freelist_count
                """)).fetchone()[0]:
                db.execute("VACUUM -- apply()\n")
        logger.debug("Database saved and closed.")

    @staticmethod
    def save_settings(save_file: sqlite3.Connection, layout: PageLayoutSettings):
        settings, dimensions = layout.to_save_file_data()
        save_file.executemany(
            'INSERT OR REPLACE INTO DocumentSettings ("key", value) VALUES (?, ?) -- save_settings()\n',
            settings)
        save_file.executemany(
            'INSERT OR REPLACE INTO DocumentDimensions ("key", value) VALUES (?, ?) -- save_settings()\n',
            dimensions)
        logger.debug("Written document settings")

    @staticmethod
    def _clear_cards_and_pages(save_file: sqlite3.Connection):
        save_file.execute("DELETE FROM Card -- _clear_cards_and_pages()\n")
        save_file.execute("DELETE FROM Page -- _clear_cards_and_pages()\n")

    @staticmethod
    def _save_pages(save_file: sqlite3.Connection, document: "Document"):
        pages = (
            (number, CardSizes.for_page_type(page.page_type()).to_save_data())
            for number, page in enumerate(document.pages, start=1)
            if page
        )
        save_file.executemany(
            "INSERT INTO Page (page, image_size) VALUES (?, ?) -- _save_pages()\n",
            pages
        )

    @staticmethod
    def _save_cards(save_file: sqlite3.Connection, document: "Document"):
        empty_slot = cached_dedent("""\
        INSERT INTO Card (page, slot, is_front, type) -- _save_cards()
            VALUES (?, ? ,?, ?)\n""")
        official_card = cached_dedent("""\
        INSERT INTO Card (page, slot, is_front, type, scryfall_id) -- _save_cards()
            VALUES (?, ?, ? ,?, ?)\n""")
        for page_number, page in enumerate(document.pages, start=1):
            for slot, container in enumerate(page, start=1):
                card = container.card
                if card.image_file is document.image_db.get_blank(card.size):
                    # Empty slot
                    save_file.execute(

                        empty_slot,(page_number, slot, card.is_front, CardType.from_card(card))
                    )
                elif card.is_custom_card:
                    ActionSaveDocument._save_custom_card(save_file, page_number, slot, card)
                else:
                    save_file.execute(

                        official_card,
                        (page_number, slot, card.is_front, CardType.from_card(card), card.scryfall_id)
                    )
        logger.debug(f"Written {save_file.execute('SELECT count(1) FROM Card').fetchone()[0]} cards.")

    @staticmethod
    def _save_custom_card(save_file: sqlite3.Connection, page_number: int, slot: int, card: AnyCardType):












        custom_card_data = (

            card.scryfall_id, card.source_image_file, card.name, card.set.name, card.set_code,
            card.collector_number, card.is_front)
        save_file.execute(cached_dedent("""\

            INSERT INTO CustomCardData -- _save_custom_card()
                (card_id, image, name, set_name, set_code, collector_number, is_front)
                VALUES (?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT (card_id) DO UPDATE
                    SET name = excluded.name,
                        set_name = excluded.set_name,
                        set_code = excluded.set_code,
                        collector_number = excluded.collector_number,
                        is_front = excluded.is_front
                    WHERE name <> excluded.name
                       OR set_name <> excluded.set_name
                       OR set_code <> excluded.set_code
                       OR collector_number <> excluded.collector_number
                       OR is_front <> excluded.is_front
                """),
            custom_card_data
        )

        card_data = (page_number, slot, card.is_front, CardType.from_card(card), card.scryfall_id)
        save_file.execute(cached_dedent("""\


            INSERT INTO Card (page, slot, is_front, type, custom_card_id) -- _save_custom_card()
                VALUES (?, ?, ? ,?, ?)\n"""),
            card_data








        )

    @staticmethod
    def _clean_unused_custom_cards(save_file: sqlite3.Connection):
        save_file.execute(cached_dedent("""\
            DELETE FROM CustomCardData  -- _clean_unused_custom_cards()
              WHERE card_id NOT IN (
                SELECT custom_card_id
                  FROM Card
                  WHERE custom_card_id IS NOT NULL)
            """))

    def undo(self, document: "Document") -> Self:
        raise NotImplementedError("Undoing saving to disk is unsupported.")

    @functools.cached_property
    def as_str(self):
        return self.translate(
            "ActionSaveDocument", "Save document to '{save_file_path}'."
        ).format(save_file_path=self.file_path)
Changes to mtg_proxy_printer/document_controller/shuffle_document.py.
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
    from secrets import token_bytes as randbytes

import typing

from PyQt5.QtCore import Qt, QModelIndex

from ._interface import DocumentAction, IllegalStateError, Self
from mtg_proxy_printer.model.carddb import Card
from mtg_proxy_printer.model.document_page import CardContainer
from mtg_proxy_printer.model.document import Document, PageColumns
from mtg_proxy_printer.units_and_sizes import PageType
__all__ = [
    "ActionShuffleDocument",
]

IndexedCards = typing.List[typing.Tuple[int, Card]]
ModelIndexList = typing.List[QModelIndex]







|
|
|







22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
    from secrets import token_bytes as randbytes

import typing

from PyQt5.QtCore import Qt, QModelIndex

from ._interface import DocumentAction, IllegalStateError, Self
from ..model.card import Card
from mtg_proxy_printer.model.document_page import CardContainer, PageColumns
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.units_and_sizes import PageType
__all__ = [
    "ActionShuffleDocument",
]

IndexedCards = typing.List[typing.Tuple[int, Card]]
ModelIndexList = typing.List[QModelIndex]
Added mtg_proxy_printer/model/card.py.




























































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.

import dataclasses
import hashlib
import enum
import functools
import typing

from PyQt5.QtCore import QRect, QPoint, QSize, Qt, QPointF
from PyQt5.QtGui import QPixmap, QColor, QColorConstants, QPainter, QTransform

from mtg_proxy_printer.units_and_sizes import CardSize, PageType, CardSizes, UUID

ItemDataRole = Qt.ItemDataRole
RenderHint = QPainter.RenderHint
SmoothTransformation = Qt.TransformationMode.SmoothTransformation

@dataclasses.dataclass(frozen=True)
class MTGSet:
    code: str
    name: str

    def data(self, role: ItemDataRole):
        """data getter used for Qt Model API based access"""
        if role == ItemDataRole.EditRole:
            return self
        elif role == ItemDataRole.DisplayRole:
            return f"{self.name} ({self.code.upper()})"
        elif role == ItemDataRole.ToolTipRole:
            return self.name
        else:
            return None


@enum.unique
class CardCorner(enum.Enum):
    """
    The four corners of a card. Values are relative image positions in X and Y.
    These are fractions so that they work properly for both regular and oversized cards

    Values are tuned to return the top-left corner of a 10x10 area
    centered around (20,20) away from the respective corner.
    """
    TOP_LEFT = (15/745, 15/1040)
    TOP_RIGHT = (1-25/745, 15/1040)
    BOTTOM_LEFT = (15/745, 1-25/1040)
    BOTTOM_RIGHT = (1-25/745, 1-25/1040)


@dataclasses.dataclass(unsafe_hash=True)
class Card:
    name: str = dataclasses.field(compare=True)
    set: MTGSet = dataclasses.field(compare=True)
    collector_number: str = dataclasses.field(compare=True)
    language: str = dataclasses.field(compare=True)
    scryfall_id: str = dataclasses.field(compare=True)
    is_front: bool = dataclasses.field(compare=True)
    oracle_id: str = dataclasses.field(compare=True)
    image_uri: str = dataclasses.field(compare=True)
    highres_image: bool = dataclasses.field(compare=False)
    size: CardSize = dataclasses.field(compare=False)
    face_number: int = dataclasses.field(compare=True)
    is_dfc: bool = dataclasses.field(compare=True)
    image_file: typing.Optional[QPixmap] = dataclasses.field(default=None, compare=False)

    def set_image_file(self, image: QPixmap):
        self.image_file = image
        self.corner_color.cache_clear()

    def requested_page_type(self) -> PageType:
        if self.image_file is None:
            return PageType.OVERSIZED if self.is_oversized else PageType.REGULAR
        return PageType.OVERSIZED if self.image_file.size() == CardSizes.OVERSIZED.as_qsize_px() else PageType.REGULAR

    @functools.lru_cache(maxsize=len(CardCorner))
    def corner_color(self, corner: CardCorner) -> QColor:
        """Returns the color of the card at the given corner. """
        if self.image_file is None:
            return QColorConstants.Transparent
        sample_area = self.image_file.copy(QRect(
            QPoint(
                round(self.image_file.width() * corner.value[0]),
                round(self.image_file.height() * corner.value[1])),
            QSize(10, 10)
        ))
        average_color = sample_area.scaled(
            1, 1, transformMode=SmoothTransformation).toImage().pixelColor(0, 0)
        return average_color

    def display_string(self):
        return f'"{self.name}" [{self.set.code.upper()}:{self.collector_number}]'

    @property
    def set_code(self):  # Compatibility with CardIdentificationData
        return self.set.code

    @property
    def is_custom_card(self) -> bool:
        return False

    @property
    def is_oversized(self) -> bool:
        return self.size == CardSizes.OVERSIZED


@dataclasses.dataclass(unsafe_hash=True)
class CustomCard:
    name: str = dataclasses.field(compare=True)
    set: MTGSet = dataclasses.field(compare=True)
    collector_number: str = dataclasses.field(compare=True)
    language: str = dataclasses.field(compare=True)
    is_front: bool = dataclasses.field(compare=True)
    image_uri: str = dataclasses.field(compare=True)
    highres_image: bool = dataclasses.field(compare=False)
    size: CardSize = dataclasses.field(compare=False)
    face_number: int = dataclasses.field(compare=True)
    is_dfc: bool = dataclasses.field(compare=True)
    source_image_file: bytes = dataclasses.field(default=None, compare=False)

    def requested_page_type(self) -> PageType:
        if self.image_file is None:
            return PageType.OVERSIZED if self.is_oversized else PageType.REGULAR
        return PageType.OVERSIZED if self.image_file.size() == CardSizes.OVERSIZED.as_qsize_px() else PageType.REGULAR

    @functools.lru_cache(maxsize=len(CardCorner))
    def corner_color(self, corner: CardCorner) -> QColor:
        """Returns the color of the card at the given corner. """
        if self.image_file is None:
            return QColorConstants.Transparent
        sample_area = self.image_file.copy(QRect(
            QPoint(
                round(self.image_file.width() * corner.value[0]),
                round(self.image_file.height() * corner.value[1])),
            QSize(10, 10)
        ))
        average_color = sample_area.scaled(
            1, 1, transformMode=SmoothTransformation).toImage().pixelColor(0, 0)
        return average_color

    def display_string(self):
        return f'"{self.name}" [{self.set.code.upper()}:{self.collector_number}]'

    @property
    def oracle_id(self):
        return ""

    @property
    def set_code(self):  # Compatibility with CardIdentificationData
        return self.set.code

    @property
    def is_oversized(self) -> bool:
        return self.size == CardSizes.OVERSIZED

    @property
    def is_custom_card(self) -> bool:
        return True

    @functools.cached_property
    def image_file(self) -> QPixmap:
        source = QPixmap()
        source.loadFromData(self.source_image_file)
        target_size = self.size.as_qsize_px()
        return source if source.size() == target_size else source.scaled(target_size, transformMode=SmoothTransformation)

    @functools.cached_property
    def scryfall_id(self) -> UUID:
        hd = hashlib.md5(self.source_image_file).hexdigest()  # TODO: Maybe use something else instead of md5?
        return UUID(f"{hd[:8]}-{hd[8:12]}-{hd[12:16]}-{hd[16:20]}-{hd[20:]}")


@dataclasses.dataclass(unsafe_hash=True)
class CheckCard:
    front: Card
    back: Card

    @property
    def name(self) -> str:
        return f"{self.front.name} // {self.back.name}"

    @property
    def set(self) -> MTGSet:
        return self.front.set

    @set.setter
    def set(self, value: MTGSet):
        self.front.set = value
        self.back.set = value

    @property
    def collector_number(self) -> str:
        return self.front.collector_number

    @property
    def language(self) -> str:
        return self.front.language

    @property
    def scryfall_id(self) -> str:
        return self.front.scryfall_id

    @property
    def is_front(self) -> bool:
        return True

    @property
    def oracle_id(self) -> str:
        return self.front.oracle_id

    @property
    def size(self):
        return self.front.size

    @property
    def image_uri(self) -> str:
        return ""

    @property
    def set_code(self):
        return self.front.set_code

    @property
    def highres_image(self) -> bool:
        return self.front.highres_image and self.back.highres_image

    @property
    def is_oversized(self):
        return self.front.is_oversized

    @property
    def face_number(self) -> int:
        return 1

    @property
    def is_dfc(self) -> bool:
        return False

    @property
    def is_custom_card(self):
        return self.front.is_custom_card

    @property
    def image_file(self) -> typing.Optional[QPixmap]:
        if self.front.image_file is None or self.back.image_file is None:
            return None
        card_size = self.front.image_file.size()
        # Unlike metric paper sizes, the MTG card aspect ratio does not follow the golden ratio.
        # Cards thus can’t be scaled using a singular factor of sqrt(2) on both axis.
        # The scaled cards get a bit compressed horizontally.
        vertical_scaling_factor = card_size.width() / card_size.height()
        horizontal_scaling_factor = card_size.height() / (2 * card_size.width())
        combined_image = QPixmap(card_size)
        combined_image.fill(QColorConstants.Transparent)
        painter = QPainter(combined_image)
        painter.setRenderHints(RenderHint.SmoothPixmapTransform | RenderHint.HighQualityAntialiasing)
        transformation = QTransform()
        transformation.rotate(90)
        transformation.scale(horizontal_scaling_factor, vertical_scaling_factor)
        painter.setTransform(transformation)
        painter.drawPixmap(QPointF(card_size.width(), -card_size.height()), self.back.image_file)
        painter.drawPixmap(QPointF(0, -card_size.height()), self.front.image_file)

        return combined_image

    def requested_page_type(self) -> PageType:
        return self.front.requested_page_type()

    @functools.lru_cache(maxsize=len(CardCorner))
    def corner_color(self, corner: CardCorner) -> QColor:
        """Returns the color of the card at the given corner. """
        if corner == CardCorner.TOP_LEFT:
            return self.front.corner_color(CardCorner.BOTTOM_LEFT)
        elif corner == CardCorner.TOP_RIGHT:
            return self.front.corner_color(CardCorner.TOP_LEFT)
        elif corner == CardCorner.BOTTOM_LEFT:
            return self.back.corner_color(CardCorner.BOTTOM_RIGHT)
        elif corner == CardCorner.BOTTOM_RIGHT:
            return self.back.corner_color(CardCorner.TOP_RIGHT)
        return QColorConstants.Transparent

    def display_string(self):
        return f'"{self.name}" [{self.set.code.upper()}:{self.collector_number}]'


AnyCardType = typing.Union[Card, CheckCard, CustomCard]
CardList = typing.List[AnyCardType]
OptionalCard = typing.Optional[AnyCardType]
# Py3.8 compatibility hack, because isinstance(a, AnyCardType) fails on 3.8
AnyCardTypeForTypeCheck = typing.get_args(AnyCardType)
Changes to mtg_proxy_printer/model/card_list.py.
15
16
17
18
19
20
21
22
23
24





25
26

27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53


54

55







56
57
58
59
60
61
62
63
64

65
66
67
68
69
70
71
72
73
74
75

76
77
78
79
80
81
82
83

from collections import Counter
import dataclasses
import enum
import itertools
import typing

from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt, pyqtSlot as Slot, pyqtSignal as Signal, QItemSelection
from PyQt5.QtGui import QIcon






from mtg_proxy_printer.decklist_parser.common import CardCounter
from mtg_proxy_printer.model.carddb import Card, CardIdentificationData, CardDatabase, AnyCardType

from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger
ItemDataRole = Qt.ItemDataRole
ItemFlag = Qt.ItemFlag

__all__ = [
    "CardListColumns",
    "CardListModel",
]
INVALID_INDEX = QModelIndex()

@dataclasses.dataclass
class CardListModelRow:
    card: Card
    copies: int


class CardListColumns(enum.IntEnum):
    Copies = 0
    CardName = enum.auto()
    Set = enum.auto()
    CollectorNumber = enum.auto()
    Language = enum.auto()
    IsFront = enum.auto()





CardList = typing.List[CardListModelRow]








class CardListModel(QAbstractTableModel):
    """
    This is a model for holding a list of cards.
    """
    EDITABLE_COLUMNS = {
        CardListColumns.Copies, CardListColumns.Set, CardListColumns.CollectorNumber, CardListColumns.Language,
    }
    oversized_card_count_changed = Signal(int)


    def __init__(self, card_db: CardDatabase, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.header = {
            CardListColumns.Copies: self.tr("Copies"),
            CardListColumns.CardName: self.tr("Card name"),
            CardListColumns.Set: self.tr("Set"),
            CardListColumns.CollectorNumber: self.tr("Collector #"),
            CardListColumns.Language: self.tr("Language"),
            CardListColumns.IsFront: self.tr("Side"),
        }

        self.card_db = card_db
        self.rows: CardList = []
        self.oversized_card_count = 0
        self._oversized_icon = QIcon.fromTheme("data-warning")

    def rowCount(self, parent: QModelIndex = INVALID_INDEX) -> int:
        return 0 if parent.isValid() else len(self.rows)








|


>
>
>
>
>

|
>















|











>
>

>

>
>
>
>
>
>
>









>

|









>
|







15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101

from collections import Counter
import dataclasses
import enum
import itertools
import typing

from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt, pyqtSignal as Signal, QItemSelection
from PyQt5.QtGui import QIcon

from mtg_proxy_printer.document_controller import DocumentAction
from mtg_proxy_printer.document_controller.edit_custom_card import ActionEditCustomCard
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_page import PageColumns
from mtg_proxy_printer.ui.common import get_card_image_tooltip
from mtg_proxy_printer.decklist_parser.common import CardCounter
from mtg_proxy_printer.model.carddb import CardIdentificationData
from mtg_proxy_printer.model.card import Card, AnyCardType, CustomCard
from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger
ItemDataRole = Qt.ItemDataRole
ItemFlag = Qt.ItemFlag

__all__ = [
    "CardListColumns",
    "CardListModel",
]
INVALID_INDEX = QModelIndex()

@dataclasses.dataclass
class CardListModelRow:
    card: AnyCardType
    copies: int


class CardListColumns(enum.IntEnum):
    Copies = 0
    CardName = enum.auto()
    Set = enum.auto()
    CollectorNumber = enum.auto()
    Language = enum.auto()
    IsFront = enum.auto()

    def to_page_column(self):
        return CardListToPageColumnMapping[self]


CardList = typing.List[CardListModelRow]
CardListToPageColumnMapping = {
    CardListColumns.CardName: PageColumns.CardName,
    CardListColumns.Set: PageColumns.Set,
    CardListColumns.CollectorNumber: PageColumns.CollectorNumber,
    CardListColumns.Language: PageColumns.Language,
    CardListColumns.IsFront: PageColumns.IsFront,
}

class CardListModel(QAbstractTableModel):
    """
    This is a model for holding a list of cards.
    """
    EDITABLE_COLUMNS = {
        CardListColumns.Copies, CardListColumns.Set, CardListColumns.CollectorNumber, CardListColumns.Language,
    }
    oversized_card_count_changed = Signal(int)
    request_action = Signal(DocumentAction)

    def __init__(self, document: Document, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.header = {
            CardListColumns.Copies: self.tr("Copies"),
            CardListColumns.CardName: self.tr("Card name"),
            CardListColumns.Set: self.tr("Set"),
            CardListColumns.CollectorNumber: self.tr("Collector #"),
            CardListColumns.Language: self.tr("Language"),
            CardListColumns.IsFront: self.tr("Side"),
        }
        self.document = document
        self.card_db = document.card_db
        self.rows: CardList = []
        self.oversized_card_count = 0
        self._oversized_icon = QIcon.fromTheme("data-warning")

    def rowCount(self, parent: QModelIndex = INVALID_INDEX) -> int:
        return 0 if parent.isValid() else len(self.rows)

92
93
94
95
96
97
98
99
100

101
102
103
104
105
106
107
108
109
110

111
112
113
114

115
116
117
118
119
120
121
122
123


124




125


126
127

128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
































146







147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
        if role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole):
            if column == CardListColumns.Copies:
                return self.rows[row].copies
            elif column == CardListColumns.CardName:
                return card.name
            elif column == CardListColumns.Set:
                if role == ItemDataRole.EditRole:
                    return card.set.code
                else:

                    return f"{card.set.name} ({card.set.code.upper()})"
            elif column == CardListColumns.CollectorNumber:
                return card.collector_number
            elif column == CardListColumns.Language:
                return card.language
            elif column == CardListColumns.IsFront:
                if role == ItemDataRole.EditRole:
                    return card.is_front
                return self.tr("Front") if card.is_front else self.tr("Back")
        if card.is_oversized:

            if role == ItemDataRole.ToolTipRole:
                return self.tr("Beware: Potentially oversized card!\nThis card may not fit in your deck.")
            elif role == ItemDataRole.DecorationRole:
                return self._oversized_icon


    def flags(self, index: QModelIndex) -> Qt.ItemFlags:
        flags = super().flags(index)
        if index.column() in self.EDITABLE_COLUMNS:
            flags |= ItemFlag.ItemIsEditable
        return flags

    def setData(self, index: QModelIndex, value: typing.Any, role: ItemDataRole = ItemDataRole.EditRole) -> bool:
        row, column = index.row(), index.column()


        if role == ItemDataRole.EditRole and column in self.EDITABLE_COLUMNS:




            logger.debug(f"Setting card list model data for column {column} to {value}")


            container = self.rows[row]
            card = container.card

            if column == CardListColumns.Copies:
                old_value, container.copies = container.copies, value
                if card.is_oversized and (difference := value-old_value):
                    self.oversized_card_count += difference
                    self.oversized_card_count_changed.emit(self.oversized_card_count)
                return True
            elif column == CardListColumns.CollectorNumber:
                card_data = CardIdentificationData(
                    card.language, card.name, card.set.code, value, is_front=card.is_front)
            elif column == CardListColumns.Set:
                card_data = CardIdentificationData(
                    card.language, card.name, value, is_front=card.is_front
                )
            else:
                card_data = self.card_db.translate_card(card, value)
                if card_data == card:
                    return False
            return self._request_replacement_card(index, card_data)
































        return False








    def _request_replacement_card(
            self, index: QModelIndex, card_data: typing.Union[CardIdentificationData, AnyCardType]):
        row, column = index.row(), index.column()
        if isinstance(card_data, CardIdentificationData):
            logger.debug(f"Requesting replacement for {card_data}")
            result = self.card_db.get_cards_from_data(card_data)
        else:
            result = [card_data]
        if result:
            # Simply choose the first match. The user can’t make a choice at this point, so just use one of
            # the results.
            new_card = result[0]
            logger.debug(f"Replacing with {new_card}")
            top_left = index.sibling(row, column)
            bottom_right = top_left.siblingAtColumn(len(CardListColumns)-1)
            old_row = self.rows[row]
            self.rows[row] = new_row = CardListModelRow(new_card, old_row.copies)
            self.dataChanged.emit(







|

>
|








|
>
|
|
|
|
>



|





>
>
|
>
>
>
>
|
>
>
|
|
>
|
<
<
<
<
|
|
|
|
|
|
|
|
|
|
|
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>










|
<







110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158




159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222

223
224
225
226
227
228
229
        if role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole):
            if column == CardListColumns.Copies:
                return self.rows[row].copies
            elif column == CardListColumns.CardName:
                return card.name
            elif column == CardListColumns.Set:
                if role == ItemDataRole.EditRole:
                    return card.set
                else:
                    set_ = card.set
                    return f"{set_.name} ({set_.code.upper()})"
            elif column == CardListColumns.CollectorNumber:
                return card.collector_number
            elif column == CardListColumns.Language:
                return card.language
            elif column == CardListColumns.IsFront:
                if role == ItemDataRole.EditRole:
                    return card.is_front
                return self.tr("Front") if card.is_front else self.tr("Back")
        if card.is_custom_card and column == CardListColumns.CardName and role == ItemDataRole.ToolTipRole:
            return get_card_image_tooltip(card.source_image_file)
        elif card.is_oversized and role == ItemDataRole.ToolTipRole:
            return self.tr("Beware: Potentially oversized card!\nThis card may not fit in your deck.")
        if card.is_oversized and role == ItemDataRole.DecorationRole:
            return self._oversized_icon
        return None

    def flags(self, index: QModelIndex) -> Qt.ItemFlags:
        flags = super().flags(index)
        if index.column() in self.EDITABLE_COLUMNS or self.rows[index.row()].card.is_custom_card:
            flags |= ItemFlag.ItemIsEditable
        return flags

    def setData(self, index: QModelIndex, value: typing.Any, role: ItemDataRole = ItemDataRole.EditRole) -> bool:
        row, column = index.row(), index.column()
        container = self.rows[row]
        card = container.card
        if not card.is_custom_card and role == ItemDataRole.EditRole and column in self.EDITABLE_COLUMNS:
            return self._set_data_for_official_card(index, value)
        elif card.is_custom_card and role == ItemDataRole.EditRole:
            return self._set_data_for_custom_card(index, value)
        return False

    def _set_data_for_official_card(self, index: QModelIndex, value: typing.Any) -> bool:
        row, column = index.row(), index.column()
        container = self.rows[row]
        card = container.card
        logger.debug(f"Setting card list model data on official card for column {column} to {value}")
        if column == CardListColumns.Copies:




            return self._set_copies_value(container, card, value)
        elif column == CardListColumns.CollectorNumber:
            card_data = CardIdentificationData(
                card.language, card.name, card.set_code, value, is_front=card.is_front)
        elif column == CardListColumns.Set:
            card_data = CardIdentificationData(
                card.language, card.name, value.code, is_front=card.is_front
            )
        else:
            card_data = self.card_db.translate_card(card, value)
            if card_data == card:
                return False
        return self._request_replacement_card(index, card_data)

    def _set_data_for_custom_card(self, index: QModelIndex, value: typing.Any) -> bool:
        row, column = index.row(), CardListColumns(index.column())
        container = self.rows[row]
        card = container.card
        logger.debug(f"Setting card list model data on custom card for column {column} to {value}")
        action = None
        if document_indices := list(self.document.find_relevant_index_ranges(card, column.to_page_column())):
            # Create the action before updating the card to gather the old data for undo purposes
            # Take the first index found as the reference
            document_card_index = i if (i := document_indices[0][0]).parent().isValid() else document_indices[1][0]
            action = ActionEditCustomCard(document_card_index, value)

        if column == CardListColumns.Copies:
            return self._set_copies_value(container, card, value)
        if column == CardListColumns.CardName:
            card.name = value
        elif column == CardListColumns.CollectorNumber:
            card.collector_number = value
        elif column == CardListColumns.Language:
            card.language = value
        elif column == CardListColumns.IsFront:
            card.is_front = value
            card.face_number = int(not value)
        elif column == CardListColumns.Set:
            card.set = value
        if card_indices := list(self.document.find_relevant_index_ranges(card, column.to_page_column())):
            logger.info(
                f"Edited custom card present in {len(card_indices)} locations in the document."
                f"Applying the change to the current document.")
        if action is not None:
            self.request_action.emit(action)
        return True

    def _set_copies_value(self, container: CardListModelRow, card: AnyCardType, value: int) -> bool:
        old_value, container.copies = container.copies, value
        if card.is_oversized and (difference := value - old_value):
            self.oversized_card_count += difference
            self.oversized_card_count_changed.emit(self.oversized_card_count)
        return value != old_value

    def _request_replacement_card(
            self, index: QModelIndex, card_data: typing.Union[CardIdentificationData, AnyCardType]):
        row, column = index.row(), index.column()
        if isinstance(card_data, CardIdentificationData):
            logger.debug(f"Requesting replacement for {card_data}")
            result = self.card_db.get_cards_from_data(card_data)
        else:
            result = [card_data]
        if result:
            # Simply choose the first match. The user can’t make a choice at this point, so just use one of the results.

            new_card = result[0]
            logger.debug(f"Replacing with {new_card}")
            top_left = index.sibling(row, column)
            bottom_right = top_left.siblingAtColumn(len(CardListColumns)-1)
            old_row = self.rows[row]
            self.rows[row] = new_row = CardListModelRow(new_card, old_row.copies)
            self.dataChanged.emit(
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
            self.oversized_card_count_changed.emit(self.oversized_card_count)

    def _remove_card_handle_oversized_flag(self, row: CardListModelRow):
        if row.card.is_oversized:
            self.oversized_card_count -= row.copies
            self.oversized_card_count_changed.emit(self.oversized_card_count)

    @Slot(list)
    def remove_multi_selection(self, indices: QItemSelection) -> int:
        """
        Remove all cards in the given multi-selection.
        :return: Number of cards removed
        """

        selected_ranges = sorted(
            (selected_range.top(), selected_range.bottom()) for selected_range in indices
        )
        # This both minimizes the number of model changes needed and de-duplicates the data received from the
        # selection model. If the user selects a row, the UI returns a range for each cell selected, creating many
        # duplicates that have to be removed.
        selected_ranges = self._merge_ranges(selected_ranges)







<





<







254
255
256
257
258
259
260

261
262
263
264
265

266
267
268
269
270
271
272
            self.oversized_card_count_changed.emit(self.oversized_card_count)

    def _remove_card_handle_oversized_flag(self, row: CardListModelRow):
        if row.card.is_oversized:
            self.oversized_card_count -= row.copies
            self.oversized_card_count_changed.emit(self.oversized_card_count)


    def remove_multi_selection(self, indices: QItemSelection) -> int:
        """
        Remove all cards in the given multi-selection.
        :return: Number of cards removed
        """

        selected_ranges = sorted(
            (selected_range.top(), selected_range.bottom()) for selected_range in indices
        )
        # This both minimizes the number of model changes needed and de-duplicates the data received from the
        # selection model. If the user selects a row, the UI returns a range for each cell selected, creating many
        # duplicates that have to be removed.
        selected_ranges = self._merge_ranges(selected_ranges)
292
293
294
295
296
297
298
























            (index, index)
            for index, row in enumerate(self.rows)
            if row.card.oracle_id in basic_land_oracle_ids
        )
        merged = reversed(self._merge_ranges(to_remove_rows))
        removed_cards = sum(itertools.starmap(self.remove_cards, merged))
        logger.info(f"User requested removal of basic lands, removed {removed_cards} cards")































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
            (index, index)
            for index, row in enumerate(self.rows)
            if row.card.oracle_id in basic_land_oracle_ids
        )
        merged = reversed(self._merge_ranges(to_remove_rows))
        removed_cards = sum(itertools.starmap(self.remove_cards, merged))
        logger.info(f"User requested removal of basic lands, removed {removed_cards} cards")

    def set_copies_to(self, indices: QItemSelection, value: int):
        """
        Sets the number of copies for all selected cards to value.
        If no card is selected, set the count for all cards.
        """
        if indices.isEmpty():
            selected_ranges = [
                (0, self.rowCount()-1)
            ]
        else:
            selected_ranges = sorted(
                (selected_range.top(), selected_range.bottom()) for selected_range in indices
            )
            # This both minimizes the number of model changes needed and de-duplicates the data received from the
            # selection model. If the user selects a row, the UI returns a range for each cell selected, creating many
            # duplicates that have to be removed.
            selected_ranges = self._merge_ranges(selected_ranges)
        column = CardListColumns.Copies
        roles = [ItemDataRole.DisplayRole, ItemDataRole.EditRole]
        for top, bottom in selected_ranges:
            for item in self.rows[top:bottom+1]:
                item.copies = value
            self.dataChanged.emit(self.index(top, column), self.index(bottom, column), roles)
Changes to mtg_proxy_printer/model/carddb.py.
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import atexit
import dataclasses
import datetime
import enum
import itertools
import functools
import pathlib
import sqlite3
import threading
import typing
from itertools import starmap

from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QPixmap, QColor, QTransform, QPainter, QColorConstants
from PyQt5.QtCore import Qt, QPoint, QRect, QSize, QPointF, QObject, pyqtSignal as Signal, pyqtSlot as Slot


from mtg_proxy_printer.model.imagedb_files import CacheContent
import mtg_proxy_printer.app_dirs
from mtg_proxy_printer.natsort import natural_sorted
import mtg_proxy_printer.meta_data
from mtg_proxy_printer.sqlite_helpers import cached_dedent, open_database, validate_database_schema
import mtg_proxy_printer.settings
from mtg_proxy_printer.units_and_sizes import PageType, StringList, OptStr, CardSizes, CardSize
from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger

QueuedConnection = Qt.ConnectionType.QueuedConnection
OLD_DATABASE_LOCATION = mtg_proxy_printer.app_dirs.data_directories.user_cache_path / "CardDataCache.sqlite3"
DEFAULT_DATABASE_LOCATION = mtg_proxy_printer.app_dirs.data_directories.user_data_path / "CardDatabase.sqlite3"
ItemDataRole = Qt.ItemDataRole
RenderHint = QPainter.RenderHint
SCHEMA_NAME = "carddb"
# The card data is mostly stable, Scryfall recommends fetching the card bulk data only in larger intervals, like
# once per month or so.
MINIMUM_REFRESH_DELAY = datetime.timedelta(days=14)




__all__ = [
    "CardIdentificationData",
    "MTGSet",
    "CheckCard",
    "Card",
    "AnyCardType",
    "AnyCardTypeForTypeCheck",
    "CardCorner",
    "CardDatabase",
    "CardList",
    "OLD_DATABASE_LOCATION",
    "DEFAULT_DATABASE_LOCATION",
    "with_database_write_lock",
    "SCHEMA_NAME",
]


@dataclasses.dataclass
class CardIdentificationData:
    language: OptStr = None
    name: OptStr = None
    set_code: OptStr = None
    collector_number: OptStr = None
    scryfall_id: OptStr = None
    is_front: typing.Optional[bool] = None
    oracle_id: OptStr = None


@dataclasses.dataclass(frozen=True)
class MTGSet:
    code: str
    name: str

    def data(self, role: ItemDataRole):
        """data getter used for Qt Model API based access"""
        if role == ItemDataRole.EditRole:
            return self.code
        elif role == ItemDataRole.DisplayRole:
            return f"{self.name} ({self.code.upper()})"
        elif role == ItemDataRole.ToolTipRole:
            return self.name
        else:
            return None


@enum.unique
class CardCorner(enum.Enum):
    """
    The four corners of a card. Values are relative image positions in X and Y.
    These are fractions so that they work properly for both regular and oversized cards

    Values are tuned to return the top-left corner of a 10x10 area
    centered around (20,20) away from the respective corner.
    """
    TOP_LEFT = (15/745, 15/1040)
    TOP_RIGHT = (1-25/745, 15/1040)
    BOTTOM_LEFT = (15/745, 1-25/1040)
    BOTTOM_RIGHT = (1-25/745, 1-25/1040)


@dataclasses.dataclass(unsafe_hash=True)
class Card:
    name: str = dataclasses.field(compare=True)
    set: MTGSet = dataclasses.field(compare=True)
    collector_number: str = dataclasses.field(compare=True)
    language: str = dataclasses.field(compare=True)
    scryfall_id: str = dataclasses.field(compare=True)
    is_front: bool = dataclasses.field(compare=True)
    oracle_id: str = dataclasses.field(compare=True)
    image_uri: str = dataclasses.field(compare=False)
    highres_image: bool = dataclasses.field(compare=False)
    size: CardSize = dataclasses.field(compare=False)
    face_number: int = dataclasses.field(compare=False)
    is_dfc: bool = dataclasses.field(compare=False)
    image_file: typing.Optional[QPixmap] = dataclasses.field(default=None, compare=False)

    def set_image_file(self, image: QPixmap):
        self.image_file = image
        self.corner_color.cache_clear()

    def requested_page_type(self) -> PageType:
        if self.image_file is None:
            return PageType.OVERSIZED if self.is_oversized else PageType.REGULAR
        size = self.image_file.size()
        if (size.width(), size.height()) == (1040, 1490):
            return PageType.OVERSIZED
        return PageType.REGULAR

    @functools.lru_cache(maxsize=len(CardCorner))
    def corner_color(self, corner: CardCorner) -> QColor:
        """Returns the color of the card at the given corner. """
        if self.image_file is None:
            return QColorConstants.Transparent
        sample_area = self.image_file.copy(QRect(
            QPoint(
                round(self.image_file.width() * corner.value[0]),
                round(self.image_file.height() * corner.value[1])),
            QSize(10, 10)
        ))
        average_color = sample_area.scaled(
            1, 1, transformMode=Qt.TransformationMode.SmoothTransformation).toImage().pixelColor(0, 0)
        return average_color

    def display_string(self):
        return f'"{self.name}" [{self.set.code.upper()}:{self.collector_number}]'

    @property
    def set_code(self):
        return self.set.code

    @property
    def is_oversized(self) -> bool:
        return self.size is CardSizes.OVERSIZED


@dataclasses.dataclass(unsafe_hash=True)
class CheckCard:
    front: Card
    back: Card

    @property
    def name(self) -> str:
        return f"{self.front.name} // {self.back.name}"

    @property
    def set(self) -> MTGSet:
        return self.front.set

    @property
    def collector_number(self) -> str:
        return self.front.collector_number

    @property
    def language(self) -> str:
        return self.front.language

    @property
    def scryfall_id(self) -> str:
        return self.front.scryfall_id

    @property
    def is_front(self) -> bool:
        return True

    @property
    def oracle_id(self) -> str:
        return self.front.oracle_id

    @property
    def image_uri(self) -> str:
        return ""

    @property
    def highres_image(self) -> bool:
        return self.front.highres_image and self.back.highres_image

    @property
    def is_oversized(self):
        return self.front.is_oversized

    @property
    def face_number(self) -> int:
        return 0

    @property
    def is_dfc(self) -> bool:
        return False

    @property
    def image_file(self) -> typing.Optional[QPixmap]:
        if self.front.image_file is None or self.back.image_file is None:
            return None
        card_size = self.front.image_file.size()
        # Unlike metric paper sizes, the MTG card aspect ratio does not follow the golden ratio.
        # Cards thus can’t be scaled using a singular factor of sqrt(2) on both axis.
        # The scaled cards get a bit compressed horizontally.
        vertical_scaling_factor = card_size.width() / card_size.height()
        horizontal_scaling_factor = card_size.height()/(card_size.width()*2)
        combined_image = QPixmap(card_size)
        combined_image.fill(QColor.fromRgb(255, 255, 255, 0))  # Fill with fully transparent white
        painter = QPainter(combined_image)
        painter.setRenderHints(RenderHint.SmoothPixmapTransform | RenderHint.HighQualityAntialiasing)
        transformation = QTransform()
        transformation.rotate(90)
        transformation.scale(horizontal_scaling_factor, vertical_scaling_factor)
        painter.setTransform(transformation)
        painter.drawPixmap(QPointF(card_size.width(), -card_size.height()), self.back.image_file)
        painter.drawPixmap(QPointF(0, -card_size.height()), self.front.image_file)

        return combined_image

    def requested_page_type(self) -> PageType:
        if self.front.image_file is None or self.back.image_file is None:
            return PageType.OVERSIZED if self.is_oversized else PageType.REGULAR
        size = self.front.image_file.size()
        if (size.width(), size.height()) == (1040, 1490):
            return PageType.OVERSIZED
        return PageType.REGULAR

    @functools.lru_cache(maxsize=len(CardCorner))
    def corner_color(self, corner: CardCorner) -> QColor:
        """Returns the color of the card at the given corner. """
        if self.front.image_file is None or self.back.image_file is None:
            return QColorConstants.Transparent
        if corner == CardCorner.TOP_LEFT:
            self.front.corner_color(CardCorner.BOTTOM_LEFT)
        elif corner == CardCorner.TOP_RIGHT:
            self.front.corner_color(CardCorner.TOP_LEFT)
        elif corner == CardCorner.BOTTOM_LEFT:
            self.back.corner_color(CardCorner.BOTTOM_RIGHT)
        elif corner == CardCorner.BOTTOM_RIGHT:
            self.back.corner_color(CardCorner.TOP_RIGHT)
        return QColorConstants.Transparent

    def display_string(self):
        return f'"{self.name}" [{self.set.code.upper()}:{self.collector_number}]'


class ImageDatabaseCards(typing.NamedTuple):
    visible: typing.List[typing.Tuple[Card, CacheContent]] = []
    hidden: typing.List[typing.Tuple[Card, CacheContent]] = []
    unknown: typing.List[CacheContent] = []


OptionalCard = typing.Optional[Card]
CardList = typing.List[Card]
AnyCardType = typing.Union[Card, CheckCard]
# Py3.8 compatibility hack, because isinstance(a, AnyCardType) fails on 3.8
AnyCardTypeForTypeCheck = typing.get_args(AnyCardType)
T = typing.TypeVar("T", Card, CheckCard)
write_semaphore = threading.BoundedSemaphore()


def with_database_write_lock(semaphore: threading.BoundedSemaphore = write_semaphore):
    """Decorator managing the database lock. Used to serialize database write transactions."""
    def decorator(func):
        @functools.wraps(func)
        def wrapped(*args, **kwargs):







<
|

|


|
<


<
|

>






|





<


<
<




>
>
>



<
<
<
<
<
<

<














|



<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|
|
|
|
<
<
<
<
<
<
<
<
<







13
14
15
16
17
18
19

20
21
22
23
24
25

26
27

28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

43
44


45
46
47
48
49
50
51
52
53
54






55

56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73






























































































































































































74
75
76
77









78
79
80
81
82
83
84
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import atexit
import dataclasses
import datetime

from itertools import starmap
import functools
from pathlib import Path
import sqlite3
import threading
from typing import Literal, Union, Dict, List, Tuple, NamedTuple, TypeVar, Optional, Set, Sequence, Any


from PyQt5.QtWidgets import QApplication

from PyQt5.QtCore import QObject, pyqtSignal as Signal, pyqtSlot as Slot

from mtg_proxy_printer.model.card import MTGSet, Card, CheckCard, OptionalCard, CardList, CustomCard
from mtg_proxy_printer.model.imagedb_files import CacheContent
import mtg_proxy_printer.app_dirs
from mtg_proxy_printer.natsort import natural_sorted
import mtg_proxy_printer.meta_data
from mtg_proxy_printer.sqlite_helpers import cached_dedent, open_database, validate_database_schema
import mtg_proxy_printer.settings
from mtg_proxy_printer.units_and_sizes import StringList, OptStr, CardSizes, CardSize, UUID
from mtg_proxy_printer.logger import get_logger

logger = get_logger(__name__)
del get_logger


OLD_DATABASE_LOCATION = mtg_proxy_printer.app_dirs.data_directories.user_cache_path / "CardDataCache.sqlite3"
DEFAULT_DATABASE_LOCATION = mtg_proxy_printer.app_dirs.data_directories.user_data_path / "CardDatabase.sqlite3"


SCHEMA_NAME = "carddb"
# The card data is mostly stable, Scryfall recommends fetching the card bulk data only in larger intervals, like
# once per month or so.
MINIMUM_REFRESH_DELAY = datetime.timedelta(days=14)
T = TypeVar("T", Card, CheckCard, CustomCard)
write_semaphore = threading.BoundedSemaphore()


__all__ = [
    "CardIdentificationData",






    "CardDatabase",

    "OLD_DATABASE_LOCATION",
    "DEFAULT_DATABASE_LOCATION",
    "with_database_write_lock",
    "SCHEMA_NAME",
]


@dataclasses.dataclass
class CardIdentificationData:
    language: OptStr = None
    name: OptStr = None
    set_code: OptStr = None
    collector_number: OptStr = None
    scryfall_id: OptStr = None
    is_front: Optional[bool] = None
    oracle_id: OptStr = None
































































































































































































class ImageDatabaseCards(NamedTuple):
    visible: List[Tuple[Card, CacheContent]] = []
    hidden: List[Tuple[Card, CacheContent]] = []
    unknown: List[CacheContent] = []











def with_database_write_lock(semaphore: threading.BoundedSemaphore = write_semaphore):
    """Decorator managing the database lock. Used to serialize database write transactions."""
    def decorator(func):
        @functools.wraps(func)
        def wrapped(*args, **kwargs):
308
309
310
311
312
313
314

315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337

class CardDatabase(QObject):
    """
    Holds the connection to the local SQLite database that contains the relevant card data.
    Provides methods for data access.
    """
    card_data_updated = Signal()


    def __init__(self, db_path: typing.Union[str, pathlib.Path] = DEFAULT_DATABASE_LOCATION, parent: QObject = None,
                 check_same_thread: bool = True):
        """
        :param db_path: Path to the database file. May be “:memory:” to create an in-memory database for testing
            purposes.
        """
        super().__init__(parent)
        logger.info(f"Creating {self.__class__.__name__} instance.")
        self._db_check_same_thread = check_same_thread
        self.db_path = db_path
        self.db: sqlite3.Connection = None
        self._db_is_temporary = False
        self.reopen_database()
        self._exit_hook = None
        if db_path != ":memory:":
            self._register_exit_hook()

    @Slot()
    def reopen_database(self) -> None:
        logger.info(f"About to open card database from {self.db_path}")
        db = open_database(self.db_path, SCHEMA_NAME, check_same_thread=self._db_check_same_thread)
        outdated_on_disk = mtg_proxy_printer.sqlite_helpers.check_database_schema_version(db, SCHEMA_NAME) > 0







>

|
|












|







100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130

class CardDatabase(QObject):
    """
    Holds the connection to the local SQLite database that contains the relevant card data.
    Provides methods for data access.
    """
    card_data_updated = Signal()
    custom_cards: Dict[UUID, CustomCard] = {}

    def __init__(self, db_path: Union[Literal[":memory:"], Path] = DEFAULT_DATABASE_LOCATION, parent: QObject = None,
                 check_same_thread: bool = True, register_exit_hooks: bool = True):
        """
        :param db_path: Path to the database file. May be “:memory:” to create an in-memory database for testing
            purposes.
        """
        super().__init__(parent)
        logger.info(f"Creating {self.__class__.__name__} instance.")
        self._db_check_same_thread = check_same_thread
        self.db_path = db_path
        self.db: sqlite3.Connection = None
        self._db_is_temporary = False
        self.reopen_database()
        self._exit_hook = None
        if db_path != ":memory:" and register_exit_hooks:
            self._register_exit_hook()

    @Slot()
    def reopen_database(self) -> None:
        logger.info(f"About to open card database from {self.db_path}")
        db = open_database(self.db_path, SCHEMA_NAME, check_same_thread=self._db_check_same_thread)
        outdated_on_disk = mtg_proxy_printer.sqlite_helpers.check_database_schema_version(db, SCHEMA_NAME) > 0
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
    def begin_transaction(self):
        logger.info("Starting new read transaction")
        self.db.execute("BEGIN DEFERRED TRANSACTION; --begin_transaction()\n")

    def has_data(self) -> bool:
        return bool(self._read_optional_scalar_from_db("SELECT EXISTS(SELECT * FROM Card) -- has_data()\n"))

    def get_last_card_data_update_timestamp(self) -> typing.Optional[datetime.datetime]:
        """Returns the last card data update timestamp, or None, if no card data was ever imported"""
        query = "SELECT MAX(update_timestamp) FROM LastDatabaseUpdate -- get_last_card_data_update_timestamp\n"
        result: str = self._read_optional_scalar_from_db(query)
        return datetime.datetime.fromisoformat(result) if result else None

    def allow_updating_card_data(self) -> bool:
        """







|







176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
    def begin_transaction(self):
        logger.info("Starting new read transaction")
        self.db.execute("BEGIN DEFERRED TRANSACTION; --begin_transaction()\n")

    def has_data(self) -> bool:
        return bool(self._read_optional_scalar_from_db("SELECT EXISTS(SELECT * FROM Card) -- has_data()\n"))

    def get_last_card_data_update_timestamp(self) -> Optional[datetime.datetime]:
        """Returns the last card data update timestamp, or None, if no card data was ever imported"""
        query = "SELECT MAX(update_timestamp) FROM LastDatabaseUpdate -- get_last_card_data_update_timestamp\n"
        result: str = self._read_optional_scalar_from_db(query)
        return datetime.datetime.fromisoformat(result) if result else None

    def allow_updating_card_data(self) -> bool:
        """
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
            query = query.format(name_filter='AND card_name LIKE ?')
            parameters.append(f"{card_name_filter}%")
        else:
            query = query.format(name_filter='')
        return self._read_scalar_list_from_db(query, parameters)

    def get_basic_land_oracle_ids(
            self, include_wastes: bool = False, include_snow_basics: bool = False) -> typing.Set[str]:
        """Returns the oracle ids of all Basic lands."""
        names = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
        # Ordering matters: This order also supports Snow-Covered Wastes
        if include_wastes:
            names.append("Wastes")
        if include_snow_basics:
            names += [f"Snow-Covered {name}" for name in names]







|







218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
            query = query.format(name_filter='AND card_name LIKE ?')
            parameters.append(f"{card_name_filter}%")
        else:
            query = query.format(name_filter='')
        return self._read_scalar_list_from_db(query, parameters)

    def get_basic_land_oracle_ids(
            self, include_wastes: bool = False, include_snow_basics: bool = False) -> Set[str]:
        """Returns the oracle ids of all Basic lands."""
        names = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest']
        # Ordering matters: This order also supports Snow-Covered Wastes
        if include_wastes:
            names.append("Wastes")
        if include_snow_basics:
            names += [f"Snow-Covered {name}" for name in names]
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
        for related_oracle_id in self._read_scalar_list_from_db(query, (card.oracle_id,)):
            # Prefer same set over other sets, which is important for multi-component cards like Meld cards. If it
            # isn't available, take from any other set. As a last-ditch fallback, resort to English printings.
            # The last case is most likely hit with non-English token-producing cards,
            # as long as Scryfall does not provide localized tokens.
            related_cards = \
                self.get_cards_from_data(
                    CardIdentificationData(card.language, set_code=card.set.code, oracle_id=related_oracle_id),
                    order_by_print_count=True) or \
                self.get_cards_from_data(
                    CardIdentificationData(card.language, oracle_id=related_oracle_id),
                    order_by_print_count=True) or \
                self.get_cards_from_data(
                    CardIdentificationData("en", oracle_id=related_oracle_id),
                    order_by_print_count=True)







|







393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
        for related_oracle_id in self._read_scalar_list_from_db(query, (card.oracle_id,)):
            # Prefer same set over other sets, which is important for multi-component cards like Meld cards. If it
            # isn't available, take from any other set. As a last-ditch fallback, resort to English printings.
            # The last case is most likely hit with non-English token-producing cards,
            # as long as Scryfall does not provide localized tokens.
            related_cards = \
                self.get_cards_from_data(
                    CardIdentificationData(card.language, set_code=card.set_code, oracle_id=related_oracle_id),
                    order_by_print_count=True) or \
                self.get_cards_from_data(
                    CardIdentificationData(card.language, oracle_id=related_oracle_id),
                    order_by_print_count=True) or \
                self.get_cards_from_data(
                    CardIdentificationData("en", oracle_id=related_oracle_id),
                    order_by_print_count=True)
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
              AND set_code = ?
              AND card_name = ?
        ''')
        return natural_sorted(item for item, in self.db.execute(query, (language, set_code, card_name)))

    def find_sets_matching(
            self, card_name: str, language: str, set_name_filter: str = None,
            *, is_front: bool = None) -> typing.List[MTGSet]:
        """
        Finds all matching sets that the given card was printed in.

        :param card_name: Card name, matched exactly
        :param language: card language, matched exactly
        :param set_name_filter: If provided, only return sets with set code or full name beginning with this.
          Used as a LIKE pattern, supporting SQLite wildcards.







|







435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
              AND set_code = ?
              AND card_name = ?
        ''')
        return natural_sorted(item for item, in self.db.execute(query, (language, set_code, card_name)))

    def find_sets_matching(
            self, card_name: str, language: str, set_name_filter: str = None,
            *, is_front: bool = None) -> List[MTGSet]:
        """
        Finds all matching sets that the given card was printed in.

        :param card_name: Card name, matched exactly
        :param language: card language, matched exactly
        :param set_name_filter: If provided, only return sets with set code or full name beginning with this.
          Used as a LIKE pattern, supporting SQLite wildcards.
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
            size = CardSizes.from_bool(is_oversized)
            return Card(
                name, MTGSet(set_code, set_name), collector_number,
                language, scryfall_id, bool(is_front), oracle_id, image_uri,
                bool(highres_image), size, face_number, bool(is_dfc),
            )

    def get_all_cards_from_image_cache(self, cache_content: typing.List[CacheContent]) -> ImageDatabaseCards:
        """
        Partitions the content of the ImageDatabase disk cache into three lists:
        - All visible card printings
        - All hidden card printings
        - All unknown images

        Visible and invisible printings are returned as lists containing tuples (Card, CacheContent),







|







487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
            size = CardSizes.from_bool(is_oversized)
            return Card(
                name, MTGSet(set_code, set_name), collector_number,
                language, scryfall_id, bool(is_front), oracle_id, image_uri,
                bool(highres_image), size, face_number, bool(is_dfc),
            )

    def get_all_cards_from_image_cache(self, cache_content: List[CacheContent]) -> ImageDatabaseCards:
        """
        Partitions the content of the ImageDatabase disk cache into three lists:
        - All visible card printings
        - All hidden card printings
        - All unknown images

        Visible and invisible printings are returned as lists containing tuples (Card, CacheContent),
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
            SELECT scryfall_id, is_front
              FROM Printing
              JOIN CardFace USING (printing_id)
          )
        ''')
        cards = ImageDatabaseCards([], [], [])
        cards.unknown[:] = (
            CacheContent(scryfall_id, bool(is_front), bool(highres_on_disk), pathlib.Path(abs_path))
            for scryfall_id, is_front, highres_on_disk, abs_path
            in db.execute(unknown_images_query))
        for scryfall_id, is_front, highres_on_disk, abs_path, \
                name, set_code, set_name, collector_number, language, image_uri, oracle_id, \
                is_oversized, face_number, is_dfc, is_hidden \
                in db.execute(known_images_query):
            cache_item = CacheContent(scryfall_id, bool(is_front), bool(highres_on_disk), pathlib.Path(abs_path))
            size = CardSizes.from_bool(is_oversized)
            card = Card(
                name, MTGSet(set_code, set_name), collector_number,
                language, cache_item.scryfall_id, cache_item.is_front, oracle_id, image_uri,
                bool(highres_on_disk), size, face_number, is_dfc
            )
            if is_hidden:
                cards.hidden.append((card, cache_item))
            else:
                cards.visible.append((card, cache_item))
        db.execute("ROLLBACK TRANSACTION TO SAVEPOINT 'partition_image_cache' -- get_all_cards_from_image_cache()\n")
        return cards

    def get_opposing_face(self, card) -> OptionalCard:
        """
        Returns the opposing face for double faced cards, or None for single-faced cards.
        """
        return self.get_card_with_scryfall_id(card.scryfall_id, not card.is_front)

    def guess_language_from_name(self, name: str) -> typing.Optional[str]:
        """Guesses the card language from the card name. Returns None, if no result was found."""
        query = cached_dedent('''\
        SELECT "language" -- guess_language_from_name()
            FROM FaceName
            JOIN PrintLanguage USING (language_id)
            WHERE card_name = ?
            -- Assume English by default to not match other languages in case their entry misses the proper







|






|



















|







536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
            SELECT scryfall_id, is_front
              FROM Printing
              JOIN CardFace USING (printing_id)
          )
        ''')
        cards = ImageDatabaseCards([], [], [])
        cards.unknown[:] = (
            CacheContent(scryfall_id, bool(is_front), bool(highres_on_disk), Path(abs_path))
            for scryfall_id, is_front, highres_on_disk, abs_path
            in db.execute(unknown_images_query))
        for scryfall_id, is_front, highres_on_disk, abs_path, \
                name, set_code, set_name, collector_number, language, image_uri, oracle_id, \
                is_oversized, face_number, is_dfc, is_hidden \
                in db.execute(known_images_query):
            cache_item = CacheContent(scryfall_id, bool(is_front), bool(highres_on_disk), Path(abs_path))
            size = CardSizes.from_bool(is_oversized)
            card = Card(
                name, MTGSet(set_code, set_name), collector_number,
                language, cache_item.scryfall_id, cache_item.is_front, oracle_id, image_uri,
                bool(highres_on_disk), size, face_number, is_dfc
            )
            if is_hidden:
                cards.hidden.append((card, cache_item))
            else:
                cards.visible.append((card, cache_item))
        db.execute("ROLLBACK TRANSACTION TO SAVEPOINT 'partition_image_cache' -- get_all_cards_from_image_cache()\n")
        return cards

    def get_opposing_face(self, card) -> OptionalCard:
        """
        Returns the opposing face for double faced cards, or None for single-faced cards.
        """
        return self.get_card_with_scryfall_id(card.scryfall_id, not card.is_front)

    def guess_language_from_name(self, name: str) -> Optional[str]:
        """Guesses the card language from the card name. Returns None, if no result was found."""
        query = cached_dedent('''\
        SELECT "language" -- guess_language_from_name()
            FROM FaceName
            JOIN PrintLanguage USING (language_id)
            WHERE card_name = ?
            -- Assume English by default to not match other languages in case their entry misses the proper
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
        query = cached_dedent('''
        SELECT is_dfc -- is_dfc()
          FROM AllPrintings
          WHERE "scryfall_id" = ?
        ''')
        return bool(self._read_optional_scalar_from_db(query, (scryfall_id,)))

    def translate_card_name(self, card_data: typing.Union[CardIdentificationData, Card], target_language: str,
                            include_hidden_names: bool = False) -> OptStr:
        """
        Translates a card into the target_language. Uses the language in the card data as the source language, if given.
        If not, card names across all languages are searched.

        :return: String with the translated card name, or None, if either unknown or unavailable in the target language.
        """







|







597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
        query = cached_dedent('''
        SELECT is_dfc -- is_dfc()
          FROM AllPrintings
          WHERE "scryfall_id" = ?
        ''')
        return bool(self._read_optional_scalar_from_db(query, (scryfall_id,)))

    def translate_card_name(self, card_data: Union[CardIdentificationData, Card], target_language: str,
                            include_hidden_names: bool = False) -> OptStr:
        """
        Translates a card into the target_language. Uses the language in the card data as the source language, if given.
        If not, card names across all languages are searched.

        :return: String with the translated card name, or None, if either unknown or unavailable in the target language.
        """
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
              AND Printing.is_hidden IS FALSE
          )
          ORDER BY language ASC;
        """)
        parameters = card.language, card.oracle_id
        return self._read_scalar_list_from_db(query, parameters)

    def get_available_sets_for_card(self, card: Card) -> typing.List[MTGSet]:
        """
        Returns a list of MTG sets the card with the given Oracle ID is in, ordered by release date from old to new.
        """
        query = cached_dedent("""\
        SELECT DISTINCT set_code, set_name FROM ( -- get_available_sets_for_card()
          SELECT set_code, set_name, release_date
          FROM MTGSet







|







672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
              AND Printing.is_hidden IS FALSE
          )
          ORDER BY language ASC;
        """)
        parameters = card.language, card.oracle_id
        return self._read_scalar_list_from_db(query, parameters)

    def get_available_sets_for_card(self, card: Card) -> List[MTGSet]:
        """
        Returns a list of MTG sets the card with the given Oracle ID is in, ordered by release date from old to new.
        """
        query = cached_dedent("""\
        SELECT DISTINCT set_code, set_name FROM ( -- get_available_sets_for_card()
          SELECT set_code, set_name, release_date
          FROM MTGSet
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
          UNION ALL
          SELECT set_code, set_name, release_date
            FROM MTGSet
            WHERE set_code = ?
          )
          ORDER BY release_date ASC
        """)
        parameters = card.oracle_id, card.language, card.set.code
        result = list(starmap(MTGSet, self.db.execute(query, parameters)))
        if not result:
            result.append(card.set)
        return result

    def get_available_collector_numbers_for_card_in_set(self, card: Card) -> StringList:
        query = cached_dedent("""\







|







696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
          UNION ALL
          SELECT set_code, set_name, release_date
            FROM MTGSet
            WHERE set_code = ?
          )
          ORDER BY release_date ASC
        """)
        parameters = card.oracle_id, card.language, card.set_code
        result = list(starmap(MTGSet, self.db.execute(query, parameters)))
        if not result:
            result.append(card.set)
        return result

    def get_available_collector_numbers_for_card_in_set(self, card: Card) -> StringList:
        query = cached_dedent("""\
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
            WHERE Printing.is_hidden IS FALSE
              AND FaceName.is_hidden IS FALSE
              AND oracle_id = ?
              AND set_code = ?
              AND language = ?
          )
        """)
        parameters = (card.collector_number, card.oracle_id, card.set.code, card.language)
        return natural_sorted((number for number, in self.db.execute(query, parameters)))

    def _read_optional_scalar_from_db(self, query: str, parameters: typing.Sequence[typing.Any] = ()):
        """
        Runs the query with the given parameters that is expected to return either a singular value or None,
        and returns the result
        """
        if result := self.db.execute(query, parameters).fetchone():
            return result[0]
        else:
            return None

    def _read_scalar_list_from_db(
            self, query: str, parameters: typing.Sequence[typing.Any] = ()) -> typing.List[typing.Any]:
        """Runs the query with the given parameters, returning a list of singular items"""
        return [item for item, in self.db.execute(query, parameters)]

    def is_removed_printing(self, scryfall_id: str) -> bool:
        logger.debug(f"Query RemovedPrintings table for scryfall id {scryfall_id}")
        parameters = scryfall_id,
        query = cached_dedent("""\
        SELECT oracle_id -- is_removed_printing()
            FROM RemovedPrintings
            WHERE scryfall_id = ?
        """)
        return bool(self._read_optional_scalar_from_db(query, parameters))

    def cards_not_used_since(self, keys: typing.List[typing.Tuple[str, bool]], date: datetime.date) -> typing.List[int]:
        """
        Filters the given list of card keys (tuple scryfall_id, is_front). Returns a new list containing the indices
        into the input list that correspond to cards that were not used since the given date.
        """
        query = cached_dedent("""\
        SELECT last_use_date < ? AS last_use_was_before_threshold -- cards_not_used_since()
            FROM LastImageUseTimestamps
            WHERE scryfall_id = ?
              AND is_front = ?
        """)
        cards_not_used_since = []
        for index, (scryfall_id, is_front) in enumerate(keys):
            result = self._read_optional_scalar_from_db(query, (date.isoformat(), scryfall_id, is_front))
            if result is None or result:
                cards_not_used_since.append(index)
        return cards_not_used_since

    def cards_used_less_often_then(self,  keys: typing.List[typing.Tuple[str, bool]], count: int) -> typing.List[int]:
        """
        Filters the given list of card keys (tuple scryfall_id, is_front). Returns a new list containing the indices
        into the input list that correspond to cards that are used less often than the given count.
        If count is zero or less, returns an empty list.
        """
        if count <= 0:
            return []







|


|










|













|

















|







721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
            WHERE Printing.is_hidden IS FALSE
              AND FaceName.is_hidden IS FALSE
              AND oracle_id = ?
              AND set_code = ?
              AND language = ?
          )
        """)
        parameters = (card.collector_number, card.oracle_id, card.set_code, card.language)
        return natural_sorted((number for number, in self.db.execute(query, parameters)))

    def _read_optional_scalar_from_db(self, query: str, parameters: Sequence[Any] = ()):
        """
        Runs the query with the given parameters that is expected to return either a singular value or None,
        and returns the result
        """
        if result := self.db.execute(query, parameters).fetchone():
            return result[0]
        else:
            return None

    def _read_scalar_list_from_db(
            self, query: str, parameters: Sequence[Any] = ()) -> List[Any]:
        """Runs the query with the given parameters, returning a list of singular items"""
        return [item for item, in self.db.execute(query, parameters)]

    def is_removed_printing(self, scryfall_id: str) -> bool:
        logger.debug(f"Query RemovedPrintings table for scryfall id {scryfall_id}")
        parameters = scryfall_id,
        query = cached_dedent("""\
        SELECT oracle_id -- is_removed_printing()
            FROM RemovedPrintings
            WHERE scryfall_id = ?
        """)
        return bool(self._read_optional_scalar_from_db(query, parameters))

    def cards_not_used_since(self, keys: List[Tuple[str, bool]], date: datetime.date) -> List[int]:
        """
        Filters the given list of card keys (tuple scryfall_id, is_front). Returns a new list containing the indices
        into the input list that correspond to cards that were not used since the given date.
        """
        query = cached_dedent("""\
        SELECT last_use_date < ? AS last_use_was_before_threshold -- cards_not_used_since()
            FROM LastImageUseTimestamps
            WHERE scryfall_id = ?
              AND is_front = ?
        """)
        cards_not_used_since = []
        for index, (scryfall_id, is_front) in enumerate(keys):
            result = self._read_optional_scalar_from_db(query, (date.isoformat(), scryfall_id, is_front))
            if result is None or result:
                cards_not_used_since.append(index)
        return cards_not_used_since

    def cards_used_less_often_then(self,  keys: List[Tuple[str, bool]], count: int) -> List[int]:
        """
        Filters the given list of card keys (tuple scryfall_id, is_front). Returns a new list containing the indices
        into the input list that correspond to cards that are used less often than the given count.
        If count is zero or less, returns an empty list.
        """
        if count <= 0:
            return []
1056
1057
1058
1059
1060
1061
1062











            return None
        size = CardSizes.from_bool(is_oversized)
        return Card(
            name, MTGSet(set_code, set_name), collector_number,
            language_override, scryfall_id, card.is_front, card.oracle_id, image_uri,
            bool(highres_image), size, face_number, bool(is_dfc),
        )


















>
>
>
>
>
>
>
>
>
>
>
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
            return None
        size = CardSizes.from_bool(is_oversized)
        return Card(
            name, MTGSet(set_code, set_name), collector_number,
            language_override, scryfall_id, card.is_front, card.oracle_id, image_uri,
            bool(highres_image), size, face_number, bool(is_dfc),
        )

    def get_custom_card(
            self, name: str, set_code: str, set_name: str, collector_number: str,
            size: CardSize, is_front: bool, image: bytes) -> CustomCard:
        card = CustomCard(
            name, MTGSet(set_code, set_name), collector_number, "en",
            is_front, "", True, size, 1 + (not is_front), False, image)
        custom_card_id = card.scryfall_id
        card = self.custom_cards.get(custom_card_id, card)
        self.custom_cards[custom_card_id] = card
        return card
Changes to mtg_proxy_printer/model/document-v7.sql.
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
  card_id TEXT NOT NULL PRIMARY KEY CHECK (card_id GLOB '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]'),
  image BLOB NOT NULL,  -- The raw image content
  name TEXT NOT NULL DEFAULT '',
  set_name TEXT NOT NULL DEFAULT '',
  set_code TEXT NOT NULL DEFAULT '',
  collector_number TEXT NOT NULL DEFAULT '',
  is_front BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT TRUE,
  oversized BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT FALSE,
  other_face TEXT REFERENCES CustomCardData(card_id)  -- If this is a DFC, this references the other side
);

CREATE TABLE Page (
  page INTEGER NOT NULL PRIMARY KEY CHECK (page > 0),
  image_size TEXT NOT NULL CHECK(image_size <> '')
);







<







25
26
27
28
29
30
31

32
33
34
35
36
37
38
  card_id TEXT NOT NULL PRIMARY KEY CHECK (card_id GLOB '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]'),
  image BLOB NOT NULL,  -- The raw image content
  name TEXT NOT NULL DEFAULT '',
  set_name TEXT NOT NULL DEFAULT '',
  set_code TEXT NOT NULL DEFAULT '',
  collector_number TEXT NOT NULL DEFAULT '',
  is_front BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT TRUE,

  other_face TEXT REFERENCES CustomCardData(card_id)  -- If this is a DFC, this references the other side
);

CREATE TABLE Page (
  page INTEGER NOT NULL PRIMARY KEY CHECK (page > 0),
  image_size TEXT NOT NULL CHECK(image_size <> '')
);
Changes to mtg_proxy_printer/model/document.py.
21
22
23
24
25
26
27


28
29
30

31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import pathlib
import sys
import typing

from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt, pyqtSlot as Slot, pyqtSignal as Signal, \
    QPersistentModelIndex



from mtg_proxy_printer.model.document_page import CardContainer, Page, PageList
from mtg_proxy_printer.units_and_sizes import PageType, CardSizes, CardSize
from mtg_proxy_printer.model.carddb import AnyCardType, CardDatabase, CardIdentificationData, Card, MTGSet

from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.model.document_loader import DocumentLoader
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.logger import get_logger

from mtg_proxy_printer.document_controller import DocumentAction
from mtg_proxy_printer.document_controller.replace_card import ActionReplaceCard
from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument

logger = get_logger(__name__)
del get_logger

if sys.version_info[:2] >= (3, 9):
    Counter = collections.Counter
else:
    Counter = typing.Counter

__all__ = [
    "Document",
    "PageColumns",
]


class DocumentColumns(enum.IntEnum):
    Page = 0


class PageColumns(enum.IntEnum):
    CardName = 0
    Set = enum.auto()
    CollectorNumber = enum.auto()
    Language = enum.auto()
    IsFront = enum.auto()
    Image = enum.auto()


INVALID_INDEX = QModelIndex()
ActionStack = typing.Deque[DocumentAction]
AnyIndex = typing.Union[QModelIndex, QPersistentModelIndex]
ItemDataRole = Qt.ItemDataRole
Orientation = Qt.Orientation
ItemFlag = Qt.ItemFlag







>
>
|

|
>



















<






<
<
<
<
<
<
<
<
<







21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

53
54
55
56
57
58









59
60
61
62
63
64
65
import pathlib
import sys
import typing

from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt, pyqtSlot as Slot, pyqtSignal as Signal, \
    QPersistentModelIndex

from mtg_proxy_printer.natsort import to_list_of_ranges
from mtg_proxy_printer.document_controller.edit_custom_card import ActionEditCustomCard
from mtg_proxy_printer.model.document_page import CardContainer, Page, PageColumns
from mtg_proxy_printer.units_and_sizes import PageType, CardSizes, CardSize
from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData
from mtg_proxy_printer.model.card import MTGSet, Card, AnyCardType
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.model.document_loader import DocumentLoader
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.logger import get_logger

from mtg_proxy_printer.document_controller import DocumentAction
from mtg_proxy_printer.document_controller.replace_card import ActionReplaceCard
from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument

logger = get_logger(__name__)
del get_logger

if sys.version_info[:2] >= (3, 9):
    Counter = collections.Counter
else:
    Counter = typing.Counter

__all__ = [
    "Document",

]


class DocumentColumns(enum.IntEnum):
    Page = 0











INVALID_INDEX = QModelIndex()
ActionStack = typing.Deque[DocumentAction]
AnyIndex = typing.Union[QModelIndex, QPersistentModelIndex]
ItemDataRole = Qt.ItemDataRole
Orientation = Qt.Orientation
ItemFlag = Qt.ItemFlag
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
        self.redo_stack: ActionStack = collections.deque()
        self.save_file_path: typing.Optional[pathlib.Path] = None
        self.card_db = card_db
        self.image_db = image_db
        self.loader = DocumentLoader(self)
        self.loader.loading_state_changed.connect(self.loading_state_changed)
        self.loader.load_requested.connect(self.apply)
        self.pages: PageList = [first_page := Page()]
        # Mapping from page id() to list index in the page list
        self.page_index_cache: typing.Dict[int, int] = {id(first_page): 0}
        self.currently_edited_page = first_page
        self.page_layout = PageLayoutSettings.create_from_settings()
        logger.debug(f"Loaded document settings from configuration file: {self.page_layout}")
        logger.info(f"Created {self.__class__.__name__} instance")








|







100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
        self.redo_stack: ActionStack = collections.deque()
        self.save_file_path: typing.Optional[pathlib.Path] = None
        self.card_db = card_db
        self.image_db = image_db
        self.loader = DocumentLoader(self)
        self.loader.loading_state_changed.connect(self.loading_state_changed)
        self.loader.load_requested.connect(self.apply)
        self.pages: typing.List[Page] = [first_page := Page()]
        # Mapping from page id() to list index in the page list
        self.page_index_cache: typing.Dict[int, int] = {id(first_page): 0}
        self.currently_edited_page = first_page
        self.page_layout = PageLayoutSettings.create_from_settings()
        logger.debug(f"Loaded document settings from configuration file: {self.page_layout}")
        logger.info(f"Created {self.__class__.__name__} instance")

229
230
231
232
233
234
235
236
237
238
239
240
241
242


243
244

245

246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
        else:  # Page
            return self._data_page(index, role)

    def flags(self, index: AnyIndex) -> Qt.ItemFlags:
        index = self._to_index(index)
        data = index.internalPointer()
        flags = super().flags(index)
        if isinstance(data, CardContainer) and index.column() in self.EDITABLE_COLUMNS:
            flags |= ItemFlag.ItemIsEditable
        return flags

    def setData(self, index: AnyIndex, value: typing.Any, role: ItemDataRole = ItemDataRole.EditRole) -> bool:
        index = self._to_index(index)
        data = index.internalPointer()


        column = index.column()
        if isinstance(data, CardContainer) \

                and role == ItemDataRole.EditRole \

                and column in self.EDITABLE_COLUMNS:
            logger.debug(f"Setting model data for {column=} to {value}")
            card = data.card
            if column == PageColumns.CollectorNumber:
                card_data = CardIdentificationData(
                    card.language, card.name, card.set.code, value, is_front=card.is_front)
            elif column == PageColumns.Set:
                card_data = CardIdentificationData(
                    card.language, card.name, value, is_front=card.is_front
                )
            else:
                replacement = self.card_db.translate_card(card, value)
                if replacement != card:
                    action = ActionReplaceCard(replacement, index.parent().row(), index.row())
                    self.request_fill_image_for_action.emit(action)
                    return True







|





|
>
>

|
>
|
>
|
|
<





|







222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244

245
246
247
248
249
250
251
252
253
254
255
256
257
        else:  # Page
            return self._data_page(index, role)

    def flags(self, index: AnyIndex) -> Qt.ItemFlags:
        index = self._to_index(index)
        data = index.internalPointer()
        flags = super().flags(index)
        if isinstance(data, CardContainer) and (index.column() in self.EDITABLE_COLUMNS or data.card.is_custom_card):
            flags |= ItemFlag.ItemIsEditable
        return flags

    def setData(self, index: AnyIndex, value: typing.Any, role: ItemDataRole = ItemDataRole.EditRole) -> bool:
        index = self._to_index(index)
        data: CardContainer = index.internalPointer()
        if not isinstance(data, CardContainer) or role != ItemDataRole.EditRole:
            return False
        column = index.column()
        card = data.card
        if card.is_custom_card:
            self.apply(ActionEditCustomCard(index, value))
            return True
        elif column in self.EDITABLE_COLUMNS:
            logger.debug(f"Setting page data on official card for {column=} to {value}")

            if column == PageColumns.CollectorNumber:
                card_data = CardIdentificationData(
                    card.language, card.name, card.set.code, value, is_front=card.is_front)
            elif column == PageColumns.Set:
                card_data = CardIdentificationData(
                    card.language, card.name, value.code, is_front=card.is_front
                )
            else:
                replacement = self.card_db.translate_card(card, value)
                if replacement != card:
                    action = ActionReplaceCard(replacement, index.parent().row(), index.row())
                    self.request_fill_image_for_action.emit(action)
                    return True
425
426
427
428
429
430
431















        ))

    def recreate_page_index_cache(self):
        self.page_index_cache.clear()
        self.page_index_cache.update(
            (id(page), index) for index, page in enumerate(self.pages)
        )






















>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
        ))

    def recreate_page_index_cache(self):
        self.page_index_cache.clear()
        self.page_index_cache.update(
            (id(page), index) for index, page in enumerate(self.pages)
        )

    def find_relevant_index_ranges(self, to_find: AnyCardType, column: PageColumns):
        """Finds all indices relevant for the given card."""
        # TODO: This runs in O(n)
        for page_row, page in enumerate(self.pages):
            instance_rows = to_list_of_ranges(
                # Use is to find exact same instances
                (row for row, container in enumerate(page) if container.card is to_find)
            )
            if instance_rows:
                parent = self.index(page_row, 0)
                if column == PageColumns.CardName:
                    yield parent, parent
                for lower, upper in instance_rows:
                    yield self.index(lower, column, parent), self.index(upper, column, parent)
Changes to mtg_proxy_printer/model/document_loader.py.
16
17
18
19
20
21
22

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import collections
import enum
import functools
import itertools
import pathlib
import sqlite3
import textwrap

from typing import Counter, Dict, Iterable, List, NamedTuple, Optional, Tuple, TYPE_CHECKING, TypeVar
from unittest.mock import patch

import pint
from PyQt5.QtCore import QObject, pyqtSignal as Signal, QThreadPool, Qt
from PyQt5.QtGui import QPixmap
from hamcrest import assert_that, all_of, instance_of, greater_than_or_equal_to, matches_regexp, is_in, \
    has_properties, is_, any_of, none, has_item


try:
    from hamcrest import contains_exactly
except ImportError:
    # Compatibility with PyHamcrest < 1.10
    from hamcrest import contains as contains_exactly

import mtg_proxy_printer.settings
from mtg_proxy_printer.sqlite_helpers import cached_dedent, open_database, validate_database_schema
from mtg_proxy_printer.model.carddb import CardIdentificationData, CardList, Card, CheckCard, AnyCardType, SCHEMA_NAME, \
    MTGSet
from mtg_proxy_printer.model.imagedb import ImageDownloader
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.logger import get_logger
from mtg_proxy_printer.units_and_sizes import PageType, QuantityT, UUID, CardSizes, OptStr
from mtg_proxy_printer.document_controller import DocumentAction
from mtg_proxy_printer.runner import Runnable
from mtg_proxy_printer.save_file_migrations import migrate_database

if TYPE_CHECKING:
    from mtg_proxy_printer.model.document import Document
logger = get_logger(__name__)







>
|






|
<









|
|



|







16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import collections
import enum
import functools
import itertools
import pathlib
import sqlite3
import textwrap
from pathlib import Path
from typing import Counter, Dict, Iterable, List, NamedTuple, Optional, Tuple, TYPE_CHECKING, TypeVar, Union, Literal
from unittest.mock import patch

import pint
from PyQt5.QtCore import QObject, pyqtSignal as Signal, QThreadPool, Qt
from PyQt5.QtGui import QPixmap
from hamcrest import assert_that, all_of, instance_of, greater_than_or_equal_to, matches_regexp, is_in, \
    has_properties, is_, any_of, none, has_item, has_property, equal_to


try:
    from hamcrest import contains_exactly
except ImportError:
    # Compatibility with PyHamcrest < 1.10
    from hamcrest import contains as contains_exactly

import mtg_proxy_printer.settings
from mtg_proxy_printer.sqlite_helpers import cached_dedent, open_database, validate_database_schema
from mtg_proxy_printer.model.carddb import CardIdentificationData, SCHEMA_NAME, CardDatabase
from mtg_proxy_printer.model.card import MTGSet, Card, CheckCard, CardList, AnyCardType, CustomCard
from mtg_proxy_printer.model.imagedb import ImageDownloader
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.logger import get_logger
from mtg_proxy_printer.units_and_sizes import PageType, QuantityT, UUID, CardSizes, OptStr, unit_registry, CardSize
from mtg_proxy_printer.document_controller import DocumentAction
from mtg_proxy_printer.runner import Runnable
from mtg_proxy_printer.save_file_migrations import migrate_database

if TYPE_CHECKING:
    from mtg_proxy_printer.model.document import Document
logger = get_logger(__name__)
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98

class CardType(str, enum.Enum):
    REGULAR = "r"
    CHECK_CARD = "d"

    @classmethod
    def from_card(cls, card: AnyCardType) -> "CardType":
        if isinstance(card, Card):
            return cls.REGULAR
        elif isinstance(card, CheckCard):
            return cls.CHECK_CARD
        else:
            raise NotImplementedError()


class DatabaseLoadResult(NamedTuple):
    card: AnyCardType
    was_migrated: bool


class CardRow(NamedTuple):
    is_front: bool
    card_type: CardType
    scryfall_id: Optional[UUID]
    custom_card_id: Optional[UUID]



CustomCards = Dict[str, Card]
T = TypeVar("T")


def split_iterable(iterable: Iterable[T], chunk_size: int, /) -> Iterable[Tuple[T, ...]]:
    """Split the given iterable into chunks of size chunk_size. Does not add padding values to the last item."""
    iterable = iter(iterable)







|



















|







64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98

class CardType(str, enum.Enum):
    REGULAR = "r"
    CHECK_CARD = "d"

    @classmethod
    def from_card(cls, card: AnyCardType) -> "CardType":
        if isinstance(card, (Card, CustomCard)):
            return cls.REGULAR
        elif isinstance(card, CheckCard):
            return cls.CHECK_CARD
        else:
            raise NotImplementedError()


class DatabaseLoadResult(NamedTuple):
    card: AnyCardType
    was_migrated: bool


class CardRow(NamedTuple):
    is_front: bool
    card_type: CardType
    scryfall_id: Optional[UUID]
    custom_card_id: Optional[UUID]


sqlite3.register_adapter(CardType, lambda item: item.value)
CustomCards = Dict[str, Card]
T = TypeVar("T")


def split_iterable(iterable: Iterable[T], chunk_size: int, /) -> Iterable[Tuple[T, ...]]:
    """Split the given iterable into chunks of size chunk_size. Does not add padding values to the last item."""
    iterable = iter(iterable)
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140

    This class uses an internal worker to push that work off the GUI thread to keep the application
    responsive during a loading process.
    """

    loading_state_changed = Signal(bool)

    def __init__(self, document: "Document", db: sqlite3.Connection = None):  # db parameter used by test code
        super().__init__(None)
        self.document = document
        self.db = db
        disable_loading_state_on_completion = functools.partial(self.loading_state_changed.emit, False)
        self.finished.connect(disable_loading_state_on_completion, Qt.ConnectionType.DirectConnection)

    def load_document(self, save_file_path: pathlib.Path):
        logger.info(f"Loading document from {save_file_path}")
        self.loading_state_changed.emit(True)
        QThreadPool.globalInstance().start(LoaderRunner(save_file_path, self))







|


<







123
124
125
126
127
128
129
130
131
132

133
134
135
136
137
138
139

    This class uses an internal worker to push that work off the GUI thread to keep the application
    responsive during a loading process.
    """

    loading_state_changed = Signal(bool)

    def __init__(self, document: "Document"):
        super().__init__(None)
        self.document = document

        disable_loading_state_on_completion = functools.partial(self.loading_state_changed.emit, False)
        self.finished.connect(disable_loading_state_on_completion, Qt.ConnectionType.DirectConnection)

    def load_document(self, save_file_path: pathlib.Path):
        logger.info(f"Loading document from {save_file_path}")
        self.loading_state_changed.emit(True)
        QThreadPool.globalInstance().start(LoaderRunner(save_file_path, self))
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
            self.worker.load_document()
        finally:
            self.release_instance()

    def _create_worker(self):
        parent = self.parent
        worker = Worker(parent.document, self.path)
        if parent.db is not None:  # Used by tests to explicitly set the database
            worker._db = parent.db
        # The blocking connection causes the worker to wait for the document in the main thread to complete the loading
        worker.load_requested.connect(parent.load_requested, Qt.ConnectionType.BlockingQueuedConnection)
        worker.loading_file_failed.connect(parent.loading_file_failed)
        worker.unknown_scryfall_ids_found.connect(parent.unknown_scryfall_ids_found)
        worker.loading_file_successful.connect(parent.on_loading_file_successful)
        worker.network_error_occurred.connect(parent.network_error_occurred)
        worker.finished.connect(parent.finished)







<
<







162
163
164
165
166
167
168


169
170
171
172
173
174
175
            self.worker.load_document()
        finally:
            self.release_instance()

    def _create_worker(self):
        parent = self.parent
        worker = Worker(parent.document, self.path)


        # The blocking connection causes the worker to wait for the document in the main thread to complete the loading
        worker.load_requested.connect(parent.load_requested, Qt.ConnectionType.BlockingQueuedConnection)
        worker.loading_file_failed.connect(parent.loading_file_failed)
        worker.unknown_scryfall_ids_found.connect(parent.unknown_scryfall_ids_found)
        worker.loading_file_successful.connect(parent.on_loading_file_successful)
        worker.network_error_occurred.connect(parent.network_error_occurred)
        worker.finished.connect(parent.finished)
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225

226
227
228
229
230
231
232
233
234
    """
    loading_file_successful = Signal(pathlib.Path)

    def __init__(self, document: "Document", path: pathlib.Path):
        super().__init__(None)
        self.document = document
        self.save_path = path
        self.card_db = document.card_db
        self.image_db = image_db = document.image_db
        self._db: sqlite3.Connection = None
        # Create our own ImageDownloader, instead of using the ImageDownloader embedded in the ImageDatabase.
        # That one lives in its own thread and runs asynchronously and is thus unusable for loading documents.
        # So create a separate instance and use it synchronously inside this worker thread.
        self.image_loader = ImageDownloader(image_db, self)
        self.image_loader.download_begins.connect(image_db.card_download_starting)
        self.image_loader.download_finished.connect(image_db.card_download_finished)
        self.image_loader.download_progress.connect(image_db.card_download_progress)
        self.image_loader.network_error_occurred.connect(self.on_network_error_occurred)
        self.network_errors_during_load: Counter[str] = collections.Counter()
        self.finished.connect(self.propagate_errors_during_load)
        self.should_run: bool = True
        self.unknown_ids = 0
        self.migrated_ids = 0
        self.current_progress = 0
        self.prefer_already_downloaded = mtg_proxy_printer.settings.settings["decklist-import"].getboolean(
            "prefer-already-downloaded-images")

    @property
    def db(self) -> sqlite3.Connection:
        # Delay connection creation until first access.
        # Avoids opening connections that aren't actually used and opens the connection
        # in the thread that actually uses it.
        if self._db is None:

            self._db = open_database(self.card_db.db_path, SCHEMA_NAME)
        return self._db

    def propagate_errors_during_load(self):
        if error_count := sum(self.network_errors_during_load.values()):
            logger.warning(f"{error_count} errors occurred during document load, reporting to the user")
            self.network_error_occurred.emit(
                f"Some cards may be missing images, proceed with caution.\n"
                f"Error count: {error_count}. Most common error message:\n"







|

<



|
|
|
|
|









|
|
<
<
<
|
>
|
|







190
191
192
193
194
195
196
197
198

199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217



218
219
220
221
222
223
224
225
226
227
228
    """
    loading_file_successful = Signal(pathlib.Path)

    def __init__(self, document: "Document", path: pathlib.Path):
        super().__init__(None)
        self.document = document
        self.save_path = path
        self.card_db = self._open_carddb(document)
        self.image_db = image_db = document.image_db

        # Create our own ImageDownloader, instead of using the ImageDownloader embedded in the ImageDatabase.
        # That one lives in its own thread and runs asynchronously and is thus unusable for loading documents.
        # So create a separate instance and use it synchronously inside this worker thread.
        self.image_loader = image_loader = ImageDownloader(image_db, self)
        image_loader.download_begins.connect(image_db.card_download_starting)
        image_loader.download_finished.connect(image_db.card_download_finished)
        image_loader.download_progress.connect(image_db.card_download_progress)
        image_loader.network_error_occurred.connect(self.on_network_error_occurred)
        self.network_errors_during_load: Counter[str] = collections.Counter()
        self.finished.connect(self.propagate_errors_during_load)
        self.should_run: bool = True
        self.unknown_ids = 0
        self.migrated_ids = 0
        self.current_progress = 0
        self.prefer_already_downloaded = mtg_proxy_printer.settings.settings["decklist-import"].getboolean(
            "prefer-already-downloaded-images")

    def _open_carddb(self, document: "Document") -> CardDatabase:
        db_path = document.card_db.db_path



        card_db = CardDatabase(db_path, self, register_exit_hooks=False)
        if db_path == ":memory:":  # For testing, copy the in-memory database of the passed card database instance
            document.card_db.db.backup(card_db.db)
        return card_db

    def propagate_errors_during_load(self):
        if error_count := sum(self.network_errors_during_load.values()):
            logger.warning(f"{error_count} errors occurred during document load, reporting to the user")
            self.network_error_occurred.emit(
                f"Some cards may be missing images, proceed with caution.\n"
                f"Error count: {error_count}. Most common error message:\n"
247
248
249
250
251
252
253

254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275


276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294

295
296
297
298
299




300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
            self._load_document()
        except (AssertionError, sqlite3.DatabaseError) as e:
            logger.exception(
                "Selected file is not a known MTGProxyPrinter document or contains invalid data. Not loading it.")
            self.loading_file_failed.emit(self.save_path, str(e))
            self.finished.emit()  # Release UI in failure case. _load_document() emits this during regular operation
        finally:

            self.db.close()
            self._db = None

    def _complete_loading(self):
        if self.unknown_ids or self.migrated_ids:
            self.unknown_scryfall_ids_found.emit(self.unknown_ids, self.migrated_ids)
            self.unknown_ids = self.migrated_ids = 0
        self.loading_file_successful.emit(self.save_path)
        self.finished.emit()

    def _load_document(self):
        # Imported here to break a circular import. TODO: Investigate a better fix
        from mtg_proxy_printer.document_controller.load_document import ActionLoadDocument
        additional_steps = 2
        with patch.object(self.card_db, "db", self.db):
            save_db = self._open_validate_and_migrate_save_file(self.save_path)
            total_cards = save_db.execute("SELECT count(1) FROM Card").fetchone()[0]
            self.begin_loading_loop.emit(total_cards+additional_steps, "Loading document:")
            page_layout = self._load_document_settings(save_db)
            self._advance_progress()
            logger.debug(f"About to load {total_cards} cards.")
            pages = self._load_cards(save_db) if total_cards else []


            self._fix_mixed_pages(pages, page_layout)
            self._advance_progress()
        action = ActionLoadDocument(self.save_path, pages, page_layout)
        self.load_requested.emit(action)
        self._complete_loading()

    def _advance_progress(self):
        self.current_progress += 1
        self.progress_loading_loop.emit(self.current_progress)

    @staticmethod
    def _open_validate_and_migrate_save_file(save_path: pathlib.Path) -> sqlite3.Connection:
        """
        Opens the save database, validates the schema and migrates the content to the newest
        save file version.

        :param save_path: File system path to open
        :return: The opened database connection."""
        db = open_database(save_path, f"document-v7")

        user_version = Worker._validate_database_schema(db)
        if user_version not in range(2, 8):
            raise AssertionError(f"Unknown database schema version: {user_version}")
        logger.info(f"Save file version is {user_version}")
        migrate_database(db, PageLayoutSettings.create_from_settings())




        return db


    def _load_cards(self, save_db: sqlite3.Connection) -> List[CardList]:
        custom_cards: CustomCards = {}
        assert_that(
            save_db.execute("SELECT min(page) FROM Page").fetchone(),
            contains_exactly(all_of(instance_of(int), greater_than_or_equal_to(1))
        ))
        pages: List[CardList] = []
        allowed_sizes = {CardSizes.REGULAR.to_save_data(), CardSizes.OVERSIZED.to_save_data()}
        for page, expected_size in save_db.execute(
                "SELECT page, image_size FROM Page ORDER BY page ASC").fetchall():  # type: int, str
            assert_that(page, is_(instance_of(int)))
            assert_that(expected_size, is_in(allowed_sizes))
            pages.append(self._load_cards_on_page(save_db, page, expected_size, custom_cards))
        return pages


    def _load_cards_on_page(
            self, save_db: sqlite3.Connection, page: int, expected_size: str, custom_cards: CustomCards) -> CardList:

        query = textwrap.dedent("""\
            SELECT slot, is_front, type, scryfall_id, custom_card_id -- _load_cards_on_page()
                FROM Card
                WHERE page = ?
                ORDER BY page ASC, slot ASC""")
        db_data: Iterable[Tuple[int, bool, str, OptStr, OptStr]] = save_db.execute(query, (page,))
        valid_card_types = {v.value for v in CardType}
        is_positive_int = all_of(instance_of(int), greater_than_or_equal_to(1))
        result: CardList = []
        card_size = CardSizes.REGULAR if expected_size == CardSizes.REGULAR.to_save_data() else CardSizes.OVERSIZED
        for item in db_data:
            self._validate_save_db_card_row(is_positive_int, item, valid_card_types)
            slot, is_front, card_type_str, scryfall_id, custom_card_id = item
            card_row = CardRow(is_front, CardType(card_type_str), scryfall_id, custom_card_id)
            if custom_card_id:
                if custom_card_id in custom_cards:
                    result.append(custom_cards[custom_card_id])
                else:
                    card = self._load_custom_card_from_save(save_db, card_row)
                    if card.image_file:
                        result.append(card)
                        custom_cards[custom_card_id] = card
                    else:
                        logger.warning("Skipping loading custom card with invalid image")
                        continue

            elif scryfall_id:
                loaded = self._load_official_card_from_card_db(card_row)
                result.append(loaded.card)
                if loaded.was_migrated:
                    self.migrated_ids += 1
            else:
                result.append(self.document.get_empty_card_for_size(card_size))
            self._advance_progress()
        return result


    @staticmethod
    def _validate_save_db_card_row(is_positive_int, item, valid_card_types):
        assert_that(item, contains_exactly(
            is_positive_int,
            is_in({True, False}),
            is_in(valid_card_types),
            any_of(none(), matches_regexp(UUID.uuid_re.pattern)),
            any_of(none(), matches_regexp(UUID.uuid_re.pattern)),
        ))
        _, _, card_type_str, scryfall_id, custom_card_id = item
        card_type = CardType(card_type_str)
        if card_type == CardType.CHECK_CARD and custom_card_id:
            raise AssertionError("Check cards for custom DFCs currently not supported.")
        assert_that(
            (scryfall_id, custom_card_id), has_item(none()),
            "Scryfall ID and custom card ID must not be both present")


    def _load_official_card_from_card_db(self, data: CardRow) -> Optional[DatabaseLoadResult]:
        if data.card_type == CardType.CHECK_CARD:
            return self._load_check_card(data)
        else:
            return self._load_official_card(data)








>
|
|












<
|
|
|
|
|
|
|
>
>
|
|

















>
|
|
|
|
|
>
>
>
>


















<


<


















|

















<
















<







241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262

263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318

319
320

321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356

357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372

373
374
375
376
377
378
379
            self._load_document()
        except (AssertionError, sqlite3.DatabaseError) as e:
            logger.exception(
                "Selected file is not a known MTGProxyPrinter document or contains invalid data. Not loading it.")
            self.loading_file_failed.emit(self.save_path, str(e))
            self.finished.emit()  # Release UI in failure case. _load_document() emits this during regular operation
        finally:
            self.card_db.db.rollback()
            self.card_db.db.close()
            self.card_db = None

    def _complete_loading(self):
        if self.unknown_ids or self.migrated_ids:
            self.unknown_scryfall_ids_found.emit(self.unknown_ids, self.migrated_ids)
            self.unknown_ids = self.migrated_ids = 0
        self.loading_file_successful.emit(self.save_path)
        self.finished.emit()

    def _load_document(self):
        # Imported here to break a circular import. TODO: Investigate a better fix
        from mtg_proxy_printer.document_controller.load_document import ActionLoadDocument
        additional_steps = 2

        save_db = self._open_validate_and_migrate_save_file(self.save_path)
        total_cards = save_db.execute("SELECT count(1) FROM Card").fetchone()[0]
        self.begin_loading_loop.emit(total_cards+additional_steps, "Loading document:")
        page_layout = self._load_document_settings(save_db)
        self._advance_progress()
        logger.debug(f"About to load {total_cards} cards.")
        pages = self._load_cards(save_db) if total_cards else []
        save_db.rollback()
        save_db.close()
        self._fix_mixed_pages(pages, page_layout)
        self._advance_progress()
        action = ActionLoadDocument(self.save_path, pages, page_layout)
        self.load_requested.emit(action)
        self._complete_loading()

    def _advance_progress(self):
        self.current_progress += 1
        self.progress_loading_loop.emit(self.current_progress)

    @staticmethod
    def _open_validate_and_migrate_save_file(save_path: pathlib.Path) -> sqlite3.Connection:
        """
        Opens the save database, validates the schema and migrates the content to the newest
        save file version.

        :param save_path: File system path to open
        :return: The opened database connection."""
        db = open_database(save_path, f"document-v7")
        try:
            user_version = Worker._validate_database_schema(db)
            if user_version not in range(2, 8):
                raise AssertionError(f"Unknown database schema version: {user_version}")
            logger.info(f"Save file version is {user_version}")
            migrate_database(db, PageLayoutSettings.create_from_settings())
        except Exception:
            db.rollback()
            db.close()
            raise
        return db


    def _load_cards(self, save_db: sqlite3.Connection) -> List[CardList]:
        custom_cards: CustomCards = {}
        assert_that(
            save_db.execute("SELECT min(page) FROM Page").fetchone(),
            contains_exactly(all_of(instance_of(int), greater_than_or_equal_to(1))
        ))
        pages: List[CardList] = []
        allowed_sizes = {CardSizes.REGULAR.to_save_data(), CardSizes.OVERSIZED.to_save_data()}
        for page, expected_size in save_db.execute(
                "SELECT page, image_size FROM Page ORDER BY page ASC").fetchall():  # type: int, str
            assert_that(page, is_(instance_of(int)))
            assert_that(expected_size, is_in(allowed_sizes))
            pages.append(self._load_cards_on_page(save_db, page, expected_size, custom_cards))
        return pages


    def _load_cards_on_page(
            self, save_db: sqlite3.Connection, page: int, expected_size: str, custom_cards: CustomCards) -> CardList:

        query = textwrap.dedent("""\
            SELECT slot, is_front, type, scryfall_id, custom_card_id -- _load_cards_on_page()
                FROM Card
                WHERE page = ?
                ORDER BY page ASC, slot ASC""")
        db_data: Iterable[Tuple[int, bool, str, OptStr, OptStr]] = save_db.execute(query, (page,))
        valid_card_types = {v.value for v in CardType}
        is_positive_int = all_of(instance_of(int), greater_than_or_equal_to(1))
        result: CardList = []
        card_size = CardSizes.REGULAR if expected_size == CardSizes.REGULAR.to_save_data() else CardSizes.OVERSIZED
        for item in db_data:
            self._validate_save_db_card_row(is_positive_int, item, valid_card_types)
            slot, is_front, card_type_str, scryfall_id, custom_card_id = item
            card_row = CardRow(is_front, CardType(card_type_str), scryfall_id, custom_card_id)
            if custom_card_id:
                if custom_card_id in custom_cards:
                    result.append(custom_cards[custom_card_id])
                else:
                    card = self._load_custom_card_from_save(save_db, card_size, card_row)
                    if card.image_file:
                        result.append(card)
                        custom_cards[custom_card_id] = card
                    else:
                        logger.warning("Skipping loading custom card with invalid image")
                        continue

            elif scryfall_id:
                loaded = self._load_official_card_from_card_db(card_row)
                result.append(loaded.card)
                if loaded.was_migrated:
                    self.migrated_ids += 1
            else:
                result.append(self.document.get_empty_card_for_size(card_size))
            self._advance_progress()
        return result


    @staticmethod
    def _validate_save_db_card_row(is_positive_int, item, valid_card_types):
        assert_that(item, contains_exactly(
            is_positive_int,
            is_in({True, False}),
            is_in(valid_card_types),
            any_of(none(), matches_regexp(UUID.uuid_re.pattern)),
            any_of(none(), matches_regexp(UUID.uuid_re.pattern)),
        ))
        _, _, card_type_str, scryfall_id, custom_card_id = item
        card_type = CardType(card_type_str)
        if card_type == CardType.CHECK_CARD and custom_card_id:
            raise AssertionError("Check cards for custom DFCs currently not supported.")
        assert_that(
            (scryfall_id, custom_card_id), has_item(none()),
            "Scryfall ID and custom card ID must not be both present")


    def _load_official_card_from_card_db(self, data: CardRow) -> Optional[DatabaseLoadResult]:
        if data.card_type == CardType.CHECK_CARD:
            return self._load_check_card(data)
        else:
            return self._load_official_card(data)

432
433
434
435
436
437
438
439
440
441
442
443
444
445

446
447

448
449
450
451
452
453
454
455
456
457
458
459
460
            filtered_choices = []
            if prefer_already_downloaded:
                filtered_choices = self.image_db.filter_already_downloaded(choices)
            card = filtered_choices[0] if filtered_choices else choices[0]
            logger.info(f"Found suitable replacement card: {card}")
        return card

    @staticmethod
    def _load_custom_card_from_save(save_db: sqlite3.Connection, card_row: CardRow) -> Card:
        query = cached_dedent("""\
        SELECT name, set_code, set_name, collector_number, oversized, image
          FROM CustomCardData 
          WHERE card_id = ? AND is_front = ?""")
        result: Tuple[str, str, str, str, bool, bytes] = save_db.execute(query, (card_row.custom_card_id, card_row.is_front)).fetchone()

        name, set_code, set_name, collector_number, oversized, image_bytes = result
        image = QPixmap()

        image.loadFromData(image_bytes)
        # TODO: Improve this
        size = CardSizes.REGULAR if image.width() == 745 else CardSizes.OVERSIZED
        return Card(
            name, MTGSet(set_code, set_name), collector_number, "en", "",
            card_row.is_front, "", "", True, size, 1 + (not card_row.is_front), False, image)

    def _fix_mixed_pages(self, pages: List[CardList], page_settings: PageLayoutSettings):
        """
        Documents saved with older versions (or specifically crafted save files) can contain images with mixed
        sizes on the same page.
        This method is called when the document loading finishes and moves cards away from these mixed pages so that
        all pages only contain a single image size.







<
|

|

|
<
>
|
<
>
|
<
<
|
|
<







429
430
431
432
433
434
435

436
437
438
439
440

441
442

443
444


445
446

447
448
449
450
451
452
453
            filtered_choices = []
            if prefer_already_downloaded:
                filtered_choices = self.image_db.filter_already_downloaded(choices)
            card = filtered_choices[0] if filtered_choices else choices[0]
            logger.info(f"Found suitable replacement card: {card}")
        return card


    def _load_custom_card_from_save(self, save_db: sqlite3.Connection, card_size: CardSize, card_row: CardRow) -> CustomCard:
        query = cached_dedent("""\
        SELECT name, set_code, set_name, collector_number, image
          FROM CustomCardData 
          WHERE card_id = ? AND is_front = ?

        """)
        name, set_code, set_name, collector_number, image_bytes = save_db.execute(

            query, (card_row.custom_card_id, card_row.is_front)
        ).fetchone()  # type: str, str, str, str, bytes


        return self.card_db.get_custom_card(
            name, set_code, set_name, collector_number, card_size, card_row.is_front, image_bytes)


    def _fix_mixed_pages(self, pages: List[CardList], page_settings: PageLayoutSettings):
        """
        Documents saved with older versions (or specifically crafted save files) can contain images with mixed
        sizes on the same page.
        This method is called when the document loading finishes and moves cards away from these mixed pages so that
        all pages only contain a single image size.
508
509
510
511
512
513
514

515


516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
            f"'{key}'" for key, value in settings.__annotations__.items() if value is QuantityT)
        document_dimensions_query = textwrap.dedent(f"""\
            SELECT "key", value
                FROM DocumentDimensions
                WHERE "key" in ({keys})
            """)
        settings.update(db.execute(document_dimensions_query))

        is_number = instance_of(pint.Quantity)


        assert_that(
            settings,
            has_properties(
                card_bleed=is_number,
                page_height=is_number,
                page_width=is_number,
                margin_top=is_number,
                margin_bottom=is_number,
                margin_left=is_number,
                margin_right=is_number,
                row_spacing=is_number,
                column_spacing=is_number,
                draw_cut_markers=is_in(("True", "False")),
                draw_sharp_corners=is_in(("True", "False")),
                draw_page_numbers=is_in(("True", "False")),
                document_name=instance_of(str),
            ),
            "Document settings contain invalid data or data types"
        )

        for key, annotated_type in PageLayoutSettings.__annotations__.items():
            value = getattr(settings, key)
            if annotated_type is bool:
                value = mtg_proxy_printer.settings.settings._convert_to_boolean(value)
            elif annotated_type is QuantityT:
                # TODO: Handle invalid, non-length units
                # Ensure all floats are within the allowed bounds.
                value = mtg_proxy_printer.settings.clamp_to_supported_range(
                    value, mtg_proxy_printer.settings.MIN_SIZE, mtg_proxy_printer.settings.MAX_SIZE)
            elif annotated_type is str:
                 pass
            setattr(settings, key, value)
        assert_that(







>
|
>
>



|
|
|
|
|
|
|
|
|
|
|
|




<





<







501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530

531
532
533
534
535

536
537
538
539
540
541
542
            f"'{key}'" for key, value in settings.__annotations__.items() if value is QuantityT)
        document_dimensions_query = textwrap.dedent(f"""\
            SELECT "key", value
                FROM DocumentDimensions
                WHERE "key" in ({keys})
            """)
        settings.update(db.execute(document_dimensions_query))
        is_distance = all_of(
            instance_of(pint.Quantity),
            has_property("dimensionality", equal_to(unit_registry.mm.dimensionality)))
        is_bool_str = is_in(("True", "False"))
        assert_that(
            settings,
            has_properties(
                card_bleed=is_distance,
                page_height=is_distance,
                page_width=is_distance,
                margin_top=is_distance,
                margin_bottom=is_distance,
                margin_left=is_distance,
                margin_right=is_distance,
                row_spacing=is_distance,
                column_spacing=is_distance,
                draw_cut_markers=is_bool_str,
                draw_sharp_corners=is_bool_str,
                draw_page_numbers=is_bool_str,
                document_name=instance_of(str),
            ),
            "Document settings contain invalid data or data types"
        )

        for key, annotated_type in PageLayoutSettings.__annotations__.items():
            value = getattr(settings, key)
            if annotated_type is bool:
                value = mtg_proxy_printer.settings.settings._convert_to_boolean(value)
            elif annotated_type is QuantityT:

                # Ensure all floats are within the allowed bounds.
                value = mtg_proxy_printer.settings.clamp_to_supported_range(
                    value, mtg_proxy_printer.settings.MIN_SIZE, mtg_proxy_printer.settings.MAX_SIZE)
            elif annotated_type is str:
                 pass
            setattr(settings, key, value)
        assert_that(
Changes to mtg_proxy_printer/model/document_page.py.
11
12
13
14
15
16
17

18
19
20
21
22
23









24
25
26
27
28
29
30
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import dataclasses

from functools import partial
import typing

from mtg_proxy_printer.model.carddb import AnyCardType, AnyCardTypeForTypeCheck
from mtg_proxy_printer.units_and_sizes import PageType











@dataclasses.dataclass
class CardContainer:
    parent: "Page"
    card: AnyCardType









>



|


>
>
>
>
>
>
>
>
>







11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import dataclasses
import enum
from functools import partial
import typing

from mtg_proxy_printer.model.card import AnyCardType, AnyCardTypeForTypeCheck
from mtg_proxy_printer.units_and_sizes import PageType


class PageColumns(enum.IntEnum):
    CardName = 0
    Set = enum.auto()
    CollectorNumber = enum.auto()
    Language = enum.auto()
    IsFront = enum.auto()
    Image = enum.auto()


@dataclasses.dataclass
class CardContainer:
    parent: "Page"
    card: AnyCardType


65
66
67
68
69
70
71
72
73
74
        super().insert(__index, container)
        return container

    def append(self, __object: AnyCardType) -> CardContainer:
        container = CardContainer(self, __object)
        super().append(container)
        return container


PageList = typing.List[Page]







<
<
<
75
76
77
78
79
80
81



        super().insert(__index, container)
        return container

    def append(self, __object: AnyCardType) -> CardContainer:
        container = CardContainer(self, __object)
        super().append(container)
        return container



Changes to mtg_proxy_printer/model/imagedb.py.
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList
from mtg_proxy_printer.document_controller import DocumentAction
from .imagedb_files import ImageKey, CacheContent
import mtg_proxy_printer.app_dirs
import mtg_proxy_printer.downloader_base
import mtg_proxy_printer.http_file
from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize
from mtg_proxy_printer.model.carddb import Card, CheckCard, AnyCardType
from mtg_proxy_printer.runner import Runnable
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

ItemDataRole = Qt.ItemDataRole
DEFAULT_DATABASE_LOCATION = mtg_proxy_printer.app_dirs.data_directories.user_cache_path / "CardImages"







|







38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList
from mtg_proxy_printer.document_controller import DocumentAction
from .imagedb_files import ImageKey, CacheContent
import mtg_proxy_printer.app_dirs
import mtg_proxy_printer.downloader_base
import mtg_proxy_printer.http_file
from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize
from .card import Card, CheckCard, AnyCardType
from mtg_proxy_printer.runner import Runnable
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

ItemDataRole = Qt.ItemDataRole
DEFAULT_DATABASE_LOCATION = mtg_proxy_printer.app_dirs.data_directories.user_cache_path / "CardImages"
Changes to mtg_proxy_printer/model/page_layout.py.
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161

    def compute_page_column_count(self, page_type: PageType = PageType.REGULAR) -> int:
        """Returns the total number of card columns that fit on this page."""
        card_size: CardSize = CardSizes.for_page_type(page_type)
        card_width: QuantityT = card_size.width.to("mm", "print")
        available_width: QuantityT = self.page_width - (self.margin_left + self.margin_right)

        if available_width < card_width:
            return 0
        cards = 1 + math.floor(
            (available_width - card_width) /
            (card_width + self.column_spacing))
        return cards

    def compute_page_row_count(self, page_type: PageType = PageType.REGULAR) -> int:
        """Returns the total number of card rows that fit on this page."""
        card_size: CardSize = CardSizes.for_page_type(page_type)
        card_height: QuantityT = card_size.height.to("mm", "print")
        available_height: QuantityT = self.page_height - (self.margin_top + self.margin_bottom)

        if available_height < card_height:
            return 0
        cards = 1 + math.floor(
            (available_height - card_height) /
            (card_height + self.row_spacing)
        )
        return cards








|












|







134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161

    def compute_page_column_count(self, page_type: PageType = PageType.REGULAR) -> int:
        """Returns the total number of card columns that fit on this page."""
        card_size: CardSize = CardSizes.for_page_type(page_type)
        card_width: QuantityT = card_size.width.to("mm", "print")
        available_width: QuantityT = self.page_width - (self.margin_left + self.margin_right)

        if available_width <= card_width:
            return 0
        cards = 1 + math.floor(
            (available_width - card_width) /
            (card_width + self.column_spacing))
        return cards

    def compute_page_row_count(self, page_type: PageType = PageType.REGULAR) -> int:
        """Returns the total number of card rows that fit on this page."""
        card_size: CardSize = CardSizes.for_page_type(page_type)
        card_height: QuantityT = card_size.height.to("mm", "print")
        available_height: QuantityT = self.page_height - (self.margin_top + self.margin_bottom)

        if available_height <= card_height:
            return 0
        cards = 1 + math.floor(
            (available_height - card_height) /
            (card_height + self.row_spacing)
        )
        return cards

Changes to mtg_proxy_printer/model/string_list.py.
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import typing

from PyQt5.QtCore import QAbstractListModel, Qt, QObject, QModelIndex

from mtg_proxy_printer.model.carddb import MTGSet


__all__ = [
    "PrettySetListModel",
]
INVALID_INDEX = QModelIndex()
ItemDataRole = Qt.ItemDataRole
Orientation = Qt.Orientation







|
<







14
15
16
17
18
19
20
21

22
23
24
25
26
27
28
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import typing

from PyQt5.QtCore import QAbstractListModel, Qt, QObject, QModelIndex

from mtg_proxy_printer.model.card import MTGSet


__all__ = [
    "PrettySetListModel",
]
INVALID_INDEX = QModelIndex()
ItemDataRole = Qt.ItemDataRole
Orientation = Qt.Orientation
Changes to mtg_proxy_printer/natsort.py.
14
15
16
17
18
19
20

21
22
23
24
25
26
27
28
29

30
31
32
33
34
35
36
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


"""
Natural sorting for lists or other iterables of strings.
"""


import re
import typing

from PyQt5.QtCore import QSortFilterProxyModel, QModelIndex

__all__ = [
    "natural_sorted",
    "str_less_than",
    "NaturallySortedSortFilterProxyModel",

]

_NUMBER_GROUP_REG_EXP = re.compile(r"(\d+)")


def try_convert_int(s: str):
    try:







>









>







14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


"""
Natural sorting for lists or other iterables of strings.
"""

import itertools
import re
import typing

from PyQt5.QtCore import QSortFilterProxyModel, QModelIndex

__all__ = [
    "natural_sorted",
    "str_less_than",
    "NaturallySortedSortFilterProxyModel",
    "to_list_of_ranges"
]

_NUMBER_GROUP_REG_EXP = re.compile(r"(\d+)")


def try_convert_int(s: str):
    try:
75
76
77
78
79
80
81














        return super().lessThan(left, right)

    def row_sort_order(self) -> typing.List[int]:
        """Returns the row numbers of the source model in the current sort order."""
        return [
            self.mapToSource(self.index(row, 0)).row() for row in range(self.rowCount())
        ]





















>
>
>
>
>
>
>
>
>
>
>
>
>
>
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
        return super().lessThan(left, right)

    def row_sort_order(self) -> typing.List[int]:
        """Returns the row numbers of the source model in the current sort order."""
        return [
            self.mapToSource(self.index(row, 0)).row() for row in range(self.rowCount())
        ]


def to_list_of_ranges(sequence: typing.Iterable[int]) -> typing.List[typing.Tuple[int, int]]:
    sequence = sorted(sequence)
    ranges: typing.List[typing.Tuple[int, int]] = []
    sequence = itertools.chain(sequence, (sentinel := object(),))
    lower = upper = next(sequence)
    for item in sequence:
        if item is sentinel or upper != item-1:
            ranges.append((lower, upper))
            lower = upper = item
        else:
            upper = item
    return ranges
Changes to mtg_proxy_printer/resources/translations/mtgproxyprinter_de-DE.ts.
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114









115
116
117
118
119
120
121
      <source>Third party licenses</source>
      <translation>Drittanbieter-Lizenzen</translation>
    </message>
  </context>
  <context>
    <name>ActionAddCard</name>
    <message numerus="yes">
      <location filename="../../document_controller/card_actions.py" line="159"/>
      <source>Add {count} × {card_display_string} to page {target}</source>
      <comment>Undo/redo tooltip text. Plural form refers to {target}, not {count}. {target} can be multiple ranges of multiple pages each</comment>
      <translation>
        <numerusform>Füge {count} × {card_display_string} zu Seite {target} hinzu</numerusform>
        <numerusform>Füge {count} × {card_display_string} zu Seiten {target} hinzu</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionCompactDocument</name>
    <message numerus="yes">
      <location filename="../../document_controller/compact_document.py" line="109"/>
      <source>Compact document, removing %n page(s)</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>
        <numerusform>Kompaktiere Dokument, entferne %n Seite</numerusform>
        <numerusform>Kompaktiere Dokument, entferne %n Seiten</numerusform>
      </translation>
    </message>
  </context>









  <context>
    <name>ActionEditDocumentSettings</name>
    <message>
      <location filename="../../document_controller/edit_document_settings.py" line="133"/>
      <source>Update document settings</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Dokumenteneinstellungen ändern</translation>







|




















>
>
>
>
>
>
>
>
>







87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
      <source>Third party licenses</source>
      <translation>Drittanbieter-Lizenzen</translation>
    </message>
  </context>
  <context>
    <name>ActionAddCard</name>
    <message numerus="yes">
      <location filename="../../document_controller/card_actions.py" line="161"/>
      <source>Add {count} × {card_display_string} to page {target}</source>
      <comment>Undo/redo tooltip text. Plural form refers to {target}, not {count}. {target} can be multiple ranges of multiple pages each</comment>
      <translation>
        <numerusform>Füge {count} × {card_display_string} zu Seite {target} hinzu</numerusform>
        <numerusform>Füge {count} × {card_display_string} zu Seiten {target} hinzu</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionCompactDocument</name>
    <message numerus="yes">
      <location filename="../../document_controller/compact_document.py" line="109"/>
      <source>Compact document, removing %n page(s)</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>
        <numerusform>Kompaktiere Dokument, entferne %n Seite</numerusform>
        <numerusform>Kompaktiere Dokument, entferne %n Seiten</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionEditCustomCard</name>
    <message>
      <location filename="../../document_controller/edit_custom_card.py" line="85"/>
      <source>Edit custom card, set {column_header_text} to {new_value}</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Inoffizielle Karte bearbeiten, {column_header_text} auf {new_value} setzen</translation>
    </message>
  </context>
  <context>
    <name>ActionEditDocumentSettings</name>
    <message>
      <location filename="../../document_controller/edit_document_settings.py" line="133"/>
      <source>Update document settings</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Dokumenteneinstellungen ändern</translation>
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
        <numerusform>Ersetze Dokument durch importierte Deckliste mit %n Karten</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionLoadDocument</name>
    <message numerus="yes">
      <location filename="../../document_controller/load_document.py" line="76"/>
      <source>Load document from &apos;{save_path}&apos;,
containing %n page(s) {cards_total}</source>
      <comment>Undo/redo tooltip text.</comment>
      <translation>
        <numerusform>Lade Dokument von &apos;{save_path}&apos;,
mit %n Seite {cards_total}</numerusform>
        <numerusform>Lade Dokument von &apos;{save_path}&apos;,
mit %n Seiten {cards_total}</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionLoadDocument. Card total</name>
    <message numerus="yes">
      <location filename="../../document_controller/load_document.py" line="72"/>
      <source>with %n card(s) total</source>
      <comment>Undo/redo tooltip text. Will be inserted as {cards_total}</comment>
      <translation>
        <numerusform>und insgesamt %n Karte</numerusform>
        <numerusform>und insgesamt %n Karten</numerusform>
      </translation>
    </message>







|














|







150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
        <numerusform>Ersetze Dokument durch importierte Deckliste mit %n Karten</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionLoadDocument</name>
    <message numerus="yes">
      <location filename="../../document_controller/load_document.py" line="77"/>
      <source>Load document from &apos;{save_path}&apos;,
containing %n page(s) {cards_total}</source>
      <comment>Undo/redo tooltip text.</comment>
      <translation>
        <numerusform>Lade Dokument von &apos;{save_path}&apos;,
mit %n Seite {cards_total}</numerusform>
        <numerusform>Lade Dokument von &apos;{save_path}&apos;,
mit %n Seiten {cards_total}</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionLoadDocument. Card total</name>
    <message numerus="yes">
      <location filename="../../document_controller/load_document.py" line="73"/>
      <source>with %n card(s) total</source>
      <comment>Undo/redo tooltip text. Will be inserted as {cards_total}</comment>
      <translation>
        <numerusform>und insgesamt %n Karte</numerusform>
        <numerusform>und insgesamt %n Karten</numerusform>
      </translation>
    </message>
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
        <numerusform>Seiten {pages} hinzufügen</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionRemoveCards</name>
    <message numerus="yes">
      <location filename="../../document_controller/card_actions.py" line="217"/>
      <source>Remove %n card(s) from page {page_number}</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>
        <numerusform>Entferne %n Karte von Seite {page_number}</numerusform>
        <numerusform>Entferne %n Karten von Seite {page_number}</numerusform>
      </translation>
    </message>







|







210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
        <numerusform>Seiten {pages} hinzufügen</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionRemoveCards</name>
    <message numerus="yes">
      <location filename="../../document_controller/card_actions.py" line="219"/>
      <source>Remove %n card(s) from page {page_number}</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>
        <numerusform>Entferne %n Karte von Seite {page_number}</numerusform>
        <numerusform>Entferne %n Karten von Seite {page_number}</numerusform>
      </translation>
    </message>
234
235
236
237
238
239
240
241
242
243
244
245
246








247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
        <numerusform>Seiten {formatted_pages} {formatted_card_count} entfernt</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionReplaceCard</name>
    <message>
      <location filename="../../document_controller/replace_card.py" line="98"/>
      <source>Replace card {old_card} on page {page_number} with {new_card}</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Ersetze {old_card} auf Seite {page_number} durch {new_card}</translation>
    </message>
  </context>








  <context>
    <name>ActionShuffleDocument</name>
    <message>
      <location filename="../../document_controller/shuffle_document.py" line="102"/>
      <source>Shuffle document</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Dokument mischen</translation>
    </message>
  </context>
  <context>
    <name>CacheCleanupWizard</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="472"/>
      <source>Cleanup locally stored card images</source>
      <comment>Dialog window title</comment>
      <translation>Lokal gespeicherte Kartenbilder bereinigen</translation>
    </message>
  </context>
  <context>
    <name>CardFilterPage</name>







|





>
>
>
>
>
>
>
>












|







243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
        <numerusform>Seiten {formatted_pages} {formatted_card_count} entfernt</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionReplaceCard</name>
    <message>
      <location filename="../../document_controller/replace_card.py" line="99"/>
      <source>Replace card {old_card} on page {page_number} with {new_card}</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Ersetze {old_card} auf Seite {page_number} durch {new_card}</translation>
    </message>
  </context>
  <context>
    <name>ActionSaveDocument</name>
    <message>
      <location filename="../../document_controller/save_document.py" line="172"/>
      <source>Save document to &apos;{save_file_path}&apos;.</source>
      <translation>Dokument in &apos;{save_file_path}&apos; speichern.</translation>
    </message>
  </context>
  <context>
    <name>ActionShuffleDocument</name>
    <message>
      <location filename="../../document_controller/shuffle_document.py" line="102"/>
      <source>Shuffle document</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Dokument mischen</translation>
    </message>
  </context>
  <context>
    <name>CacheCleanupWizard</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="457"/>
      <source>Cleanup locally stored card images</source>
      <comment>Dialog window title</comment>
      <translation>Lokal gespeicherte Kartenbilder bereinigen</translation>
    </message>
  </context>
  <context>
    <name>CardFilterPage</name>
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417























418
419
420
421
422
423
424
425
426
427
428
429
430
431
      <source>Unknown images:</source>
      <translation>Unbekannte Bilder:</translation>
    </message>
  </context>
  <context>
    <name>CardListModel</name>
    <message>
      <location filename="../../model/card_list.py" line="69"/>
      <source>Copies</source>
      <translation>Kopien</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="70"/>
      <source>Card name</source>
      <translation>Kartenname</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="71"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="72"/>
      <source>Collector #</source>
      <translation>Sammler #</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="73"/>
      <source>Language</source>
      <translation>Sprache</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="74"/>
      <source>Side</source>
      <translation>Seite</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="109"/>
      <source>Front</source>
      <translation>Vorderseite</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="109"/>
      <source>Back</source>
      <translation>Rückseite</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="112"/>
      <source>Beware: Potentially oversized card!
This card may not fit in your deck.</source>
      <translation>Achtung: Potenziell übergroße Karte!
Diese Karte könnte nicht in Ihr Deck passen.</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="259"/>
      <source>Double-click on entries to
switch the selected printing.</source>
      <translation>Doppelklicken Sie auf Einträge, um den Ausdruck
zu wechseln.</translation>
    </message>
  </context>
  <context>
    <name>CentralWidget</name>
    <message numerus="yes">
      <location filename="../../ui/central_widget.py" line="154"/>
      <source>Add %n copies</source>
      <comment>Context menu action: Add additional card copies to the document</comment>
      <translation>
        <numerusform>%n Kopie hinzufügen</numerusform>
        <numerusform>%n Kopien hinzufügen</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="160"/>
      <source>Add copies …</source>
      <comment>Context menu action: Add additional card copies to the document. User will be asked for a number</comment>
      <translation>Kopien hinzufügen …</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="166"/>
      <source>Generate DFC check card</source>
      <translation>Platzhalterkarte generieren</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="170"/>
      <source>All related cards</source>
      <translation>Alle zugehörigen Karten</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="178"/>
      <source>Add copies</source>
      <translation>Kopien hinzufügen</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="178"/>
      <source>Add copies of {card_name}</source>
      <comment>Asks the user for a number. Does not need plural forms</comment>
      <translation>Kopien von {card_name} hinzufügen</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="204"/>
      <source>Export image</source>
      <translation>Bild exportieren</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="219"/>
      <source>Save card image</source>
      <translation>Kartenbild speichern</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="219"/>
      <source>Images (*.png *.bmp *.jpg)</source>
      <translation>Bilder (*.png *.bmp *.jpg)</translation>
    </message>
  </context>
  <context>
    <name>ColumnarCentralWidget</name>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="64"/>
      <source>All pages:</source>
      <translation>Alle Seiten:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="71"/>
      <source>Current page:</source>
      <translation>Aktuelle Seite:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="81"/>























      <source>Remove selected</source>
      <translation>Ausgewählte entfernen</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="91"/>
      <source>Add new cards:</source>
      <translation>Karten hinzufügen:</translation>
    </message>
  </context>
  <context>
    <name>DatabaseImportWorker</name>
    <message>
      <location filename="../../card_info_downloader.py" line="424"/>
      <source>Error during import from file:







|
<
<
<
<
<




|




|




|




|




|




|




|






|





<
<
<
<
<
<
<
<
<
<
<
<

|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|
|

<
<
<
<
<
|
|
<
<
|
<

|
|
|


|
|
|





|




|




|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>




|
|
|







306
307
308
309
310
311
312
313





314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360












361
362
















363
364
365





366
367


368

369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
      <source>Unknown images:</source>
      <translation>Unbekannte Bilder:</translation>
    </message>
  </context>
  <context>
    <name>CardListModel</name>
    <message>
      <location filename="../../model/card_list.py" line="87"/>





      <source>Card name</source>
      <translation>Kartenname</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="88"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="89"/>
      <source>Collector #</source>
      <translation>Sammler #</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="90"/>
      <source>Language</source>
      <translation>Sprache</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="91"/>
      <source>Side</source>
      <translation>Seite</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="128"/>
      <source>Front</source>
      <translation>Vorderseite</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="128"/>
      <source>Back</source>
      <translation>Rückseite</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="132"/>
      <source>Beware: Potentially oversized card!
This card may not fit in your deck.</source>
      <translation>Achtung: Potenziell übergroße Karte!
Diese Karte könnte nicht in Ihr Deck passen.</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="322"/>
      <source>Double-click on entries to
switch the selected printing.</source>
      <translation>Doppelklicken Sie auf Einträge, um den Ausdruck
zu wechseln.</translation>
    </message>












    <message>
      <location filename="../../model/card_list.py" line="86"/>
















      <source>Copies</source>
      <translation>Kopien</translation>
    </message>





  </context>
  <context>


    <name>CardSideSelectionDelegate</name>

    <message>
      <location filename="../../ui/item_delegates.py" line="72"/>
      <source>Front</source>
      <translation>Vorderseite</translation>
    </message>
    <message>
      <location filename="../../ui/item_delegates.py" line="73"/>
      <source>Back</source>
      <translation>Rückseite</translation>
    </message>
  </context>
  <context>
    <name>ColumnarCentralWidget</name>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="61"/>
      <source>All pages:</source>
      <translation>Alle Seiten:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="68"/>
      <source>Current page:</source>
      <translation>Aktuelle Seite:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="78"/>
      <source>Remove selected</source>
      <translation>Ausgewählte entfernen</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="88"/>
      <source>Add new cards:</source>
      <translation>Karten hinzufügen:</translation>
    </message>
  </context>
  <context>
    <name>CustomCardImportDialog</name>
    <message>
      <location filename="../ui/custom_card_import_dialog.ui" line="14"/>
      <source>Import custom cards</source>
      <translation>Inoffizielle Karten importieren</translation>
    </message>
    <message>
      <location filename="../ui/custom_card_import_dialog.ui" line="20"/>
      <source>Set Copies to …</source>
      <translation>Kopien auf … setzen</translation>
    </message>
    <message>
      <location filename="../ui/custom_card_import_dialog.ui" line="40"/>
      <source>Remove selected</source>
      <translation>Ausgewählte entfernen</translation>
    </message>
    <message>
      <location filename="../ui/custom_card_import_dialog.ui" line="50"/>
      <source>Load images</source>
      <translation>Bilder laden</translation>
    </message>
  </context>
  <context>
    <name>DatabaseImportWorker</name>
    <message>
      <location filename="../../card_info_downloader.py" line="424"/>
      <source>Error during import from file:
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
      <source>Open the Cutelog homepage</source>
      <translation>Öffne die Cutelog-Homepage</translation>
    </message>
  </context>
  <context>
    <name>DeckImportWizard</name>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="620"/>
      <source>Import a deck list</source>
      <translation>Deckliste importieren</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="641"/>
      <source>Oversized cards present</source>
      <translation>Übergroße Karten vorhanden</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/deck_import_wizard.py" line="641"/>
      <source>There are %n possibly oversized cards in the deck list that may not fit into a deck, when printed out.

Continue and use these cards as-is?</source>
      <translation>
        <numerusform>Es gibt eine möglicherweise übergroße Karte in der Deckliste, die nach dem Ausdrucken nicht in ein Deck passen könnte.

Trotzdem mit der Deckliste fortfahren?</numerusform>
        <numerusform>Es gibt %n möglicherweise übergroße Karten in der Deckliste, die nach dem Ausdrucken nicht in ein Deck passen könnten.

Trotzdem mit der Deckliste fortfahren?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="652"/>
      <source>Incompatible file selected</source>
      <translation>Inkompatible Datei ausgewählt</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="652"/>
      <source>Unable to parse the given deck list, no results were obtained.
Maybe you selected the wrong deck list type?</source>
      <translation>Die gegebene Deck-Liste konnte nicht analysiert werden. Es wurden keine Ergebnisse abgerufen.
Vielleicht haben Sie den falschen Deck-Listentyp ausgewählt?</translation>
    </message>
  </context>
  <context>







|




|




|













|




|







583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
      <source>Open the Cutelog homepage</source>
      <translation>Öffne die Cutelog-Homepage</translation>
    </message>
  </context>
  <context>
    <name>DeckImportWizard</name>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="606"/>
      <source>Import a deck list</source>
      <translation>Deckliste importieren</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="628"/>
      <source>Oversized cards present</source>
      <translation>Übergroße Karten vorhanden</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/deck_import_wizard.py" line="628"/>
      <source>There are %n possibly oversized cards in the deck list that may not fit into a deck, when printed out.

Continue and use these cards as-is?</source>
      <translation>
        <numerusform>Es gibt eine möglicherweise übergroße Karte in der Deckliste, die nach dem Ausdrucken nicht in ein Deck passen könnte.

Trotzdem mit der Deckliste fortfahren?</numerusform>
        <numerusform>Es gibt %n möglicherweise übergroße Karten in der Deckliste, die nach dem Ausdrucken nicht in ein Deck passen könnten.

Trotzdem mit der Deckliste fortfahren?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="639"/>
      <source>Incompatible file selected</source>
      <translation>Inkompatible Datei ausgewählt</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="639"/>
      <source>Unable to parse the given deck list, no results were obtained.
Maybe you selected the wrong deck list type?</source>
      <translation>Die gegebene Deck-Liste konnte nicht analysiert werden. Es wurden keine Ergebnisse abgerufen.
Vielleicht haben Sie den falschen Deck-Listentyp ausgewählt?</translation>
    </message>
  </context>
  <context>
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
      <source>Default settings for new documents</source>
      <translation>Standardeinstellungen für neue Dokumente</translation>
    </message>
  </context>
  <context>
    <name>Document</name>
    <message>
      <location filename="../../model/document.py" line="99"/>
      <source>Card name</source>
      <translation>Kartenname</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="100"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="101"/>
      <source>Collector #</source>
      <translation>Sammler #</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="102"/>
      <source>Language</source>
      <translation>Sprache</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="103"/>
      <source>Image</source>
      <translation>Bild</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="104"/>
      <source>Side</source>
      <translation>Seite</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="182"/>
      <source>Double-click on entries to
switch the selected printing.</source>
      <translation>Doppelklicken Sie auf Einträge, um den Ausdruck
zu wechseln.</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="292"/>
      <source>Page {current}/{total}</source>
      <translation>Seite {current}/{total}</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="322"/>
      <source>Front</source>
      <translation>Vorderseite</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="322"/>
      <source>Back</source>
      <translation>Rückseite</translation>
    </message>
    <message numerus="yes">
      <location filename="../../model/document.py" line="327"/>
      <source>%n× {name}</source>
      <comment>Used to display a card name and amount of copies in the page overview. Only needs translation for RTL language support</comment>
      <translation>
        <numerusform>%n× {name}</numerusform>
        <numerusform>%n× {name}</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="415"/>
      <source>Empty Placeholder</source>
      <translation>Leerer Platzhalter</translation>
    </message>
  </context>
  <context>
    <name>DocumentAction</name>
    <message>
      <location filename="../../document_controller/_interface.py" line="105"/>
      <source>{first}-{last}</source>
      <comment>Inclusive, formatted number range, from first to last</comment>
      <translation>{first}-{last}</translation>
    </message>
  </context>
  <context>
    <name>DocumentSettingsDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="322"/>
      <source>These settings only affect the current document</source>
      <translation>Diese Einstellungen betreffen nur das aktuelle Dokument</translation>
    </message>
    <message>
      <location filename="../ui/document_settings_dialog.ui" line="6"/>
      <source>Set Document settings</source>
      <translation>Einstellungen dieses Dokuments</translation>







|




|




|




|




|




|




|






|




|




|




|








|
















|







778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
      <source>Default settings for new documents</source>
      <translation>Standardeinstellungen für neue Dokumente</translation>
    </message>
  </context>
  <context>
    <name>Document</name>
    <message>
      <location filename="../../model/document.py" line="91"/>
      <source>Card name</source>
      <translation>Kartenname</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="92"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="93"/>
      <source>Collector #</source>
      <translation>Sammler #</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="94"/>
      <source>Language</source>
      <translation>Sprache</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="95"/>
      <source>Image</source>
      <translation>Bild</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="96"/>
      <source>Side</source>
      <translation>Seite</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="174"/>
      <source>Double-click on entries to
switch the selected printing.</source>
      <translation>Doppelklicken Sie auf Einträge, um den Ausdruck
zu wechseln.</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="287"/>
      <source>Page {current}/{total}</source>
      <translation>Seite {current}/{total}</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="317"/>
      <source>Front</source>
      <translation>Vorderseite</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="317"/>
      <source>Back</source>
      <translation>Rückseite</translation>
    </message>
    <message numerus="yes">
      <location filename="../../model/document.py" line="322"/>
      <source>%n× {name}</source>
      <comment>Used to display a card name and amount of copies in the page overview. Only needs translation for RTL language support</comment>
      <translation>
        <numerusform>%n× {name}</numerusform>
        <numerusform>%n× {name}</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="379"/>
      <source>Empty Placeholder</source>
      <translation>Leerer Platzhalter</translation>
    </message>
  </context>
  <context>
    <name>DocumentAction</name>
    <message>
      <location filename="../../document_controller/_interface.py" line="105"/>
      <source>{first}-{last}</source>
      <comment>Inclusive, formatted number range, from first to last</comment>
      <translation>{first}-{last}</translation>
    </message>
  </context>
  <context>
    <name>DocumentSettingsDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="323"/>
      <source>These settings only affect the current document</source>
      <translation>Diese Einstellungen betreffen nur das aktuelle Dokument</translation>
    </message>
    <message>
      <location filename="../ui/document_settings_dialog.ui" line="6"/>
      <source>Set Document settings</source>
      <translation>Einstellungen dieses Dokuments</translation>
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="51"/>
      <source>Application language</source>
      <translation>Sprache der Anwendung</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="67"/>
      <source>Language choices will default to the chosen language here.
Entries use the language codes as listed on Scryfall.

Note: Cards in deck lists use the language as given by the deck list. To overwrite, use the deck list translation option.</source>
      <translation>Kartenauswahl wird standardmäßig die hier gewählte Sprache verwenden.
Einträge verwenden die Sprachcodes wie auf Scryfall.

Hinweis: Decklistenimports verwenden die Sprache, wie in der Deckliste angegeben. Zum Überschreiben verwenden Sie die Option der Decklistenübersetzung.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="77"/>
      <source>Double-faced cards</source>
      <translation>Doppelseitige Karten</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="83"/>
      <source>When adding double-faced cards, automatically add the same number of copies of the other side.
Uses the appropriate, matching other card side.
Uncheck to disable this automatism.</source>
      <translation>Beim Hinzufügen von doppelseitigen Karten automatisch die gleiche Anzahl von Kopien der anderen Seite hinzufügen.
Verwendet die zugehörige, passende andere Kartenseite.
Deaktivieren um diesen Automatismus zu deaktivieren.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="88"/>
      <source>Automatically add the other side of double-faced cards</source>
      <translation>Automatisch die andere Seite von doppelseitigen Karten hinzufügen</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="98"/>
      <source>Card language selected at application start and default language when enabling deck list translations</source>
      <translation>Beim Start der Anwendung ausgewählte Kartensprache und Standardsprache beim Aktivieren der Decklistenübersetzung</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="101"/>
      <source>Preferred card language:</source>
      <translation>Bevorzugte Kartensprache:</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="114"/>
      <source>Automatic update checks</source>







<
<
<
<
<
<
<
<
<
<
<



















<
<
<
<
<







1223
1224
1225
1226
1227
1228
1229











1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248





1249
1250
1251
1252
1253
1254
1255
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="51"/>
      <source>Application language</source>
      <translation>Sprache der Anwendung</translation>
    </message>
    <message>











      <location filename="../ui/settings_window/general_settings_page.ui" line="77"/>
      <source>Double-faced cards</source>
      <translation>Doppelseitige Karten</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="83"/>
      <source>When adding double-faced cards, automatically add the same number of copies of the other side.
Uses the appropriate, matching other card side.
Uncheck to disable this automatism.</source>
      <translation>Beim Hinzufügen von doppelseitigen Karten automatisch die gleiche Anzahl von Kopien der anderen Seite hinzufügen.
Verwendet die zugehörige, passende andere Kartenseite.
Deaktivieren um diesen Automatismus zu deaktivieren.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="88"/>
      <source>Automatically add the other side of double-faced cards</source>
      <translation>Automatisch die andere Seite von doppelseitigen Karten hinzufügen</translation>
    </message>
    <message>





      <location filename="../ui/settings_window/general_settings_page.ui" line="101"/>
      <source>Preferred card language:</source>
      <translation>Bevorzugte Kartensprache:</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="114"/>
      <source>Automatic update checks</source>
1323
1324
1325
1326
1327
1328
1329
















1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
      <translation>Wenn gesetzt, verwende dies als Standard-Speicherort für Dokumente.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="195"/>
      <source>Path to a directory</source>
      <translation>Pfad zu einem Verzeichnis</translation>
    </message>
















  </context>
  <context>
    <name>GroupedCentralWidget</name>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="58"/>
      <source>Remove selected</source>
      <translation>Ausgewählte entfernen</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="106"/>
      <source>All pages:</source>
      <translation>Alle Seiten:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="113"/>
      <source>Add new cards:</source>
      <translation>Karten hinzufügen:</translation>
    </message>
  </context>
  <context>
    <name>HidePrintingsPage</name>
    <message>







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>









|




|







1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
      <translation>Wenn gesetzt, verwende dies als Standard-Speicherort für Dokumente.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="195"/>
      <source>Path to a directory</source>
      <translation>Pfad zu einem Verzeichnis</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="67"/>
      <source>Language choices will default to the chosen language here.
Entries use the language codes as listed on Scryfall.

Note: Cards in deck lists use the language as given by the deck list. To overwrite, use the deck list translation option.</source>
      <translation>Kartenauswahl wird standardmäßig die hier gewählte Sprache verwenden.
Einträge verwenden die Sprachcodes wie auf Scryfall.

Hinweis: Decklistenimports verwenden die Sprache, wie in der Deckliste angegeben. Zum Überschreiben verwenden Sie die Option der Decklistenübersetzung.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="98"/>
      <source>Card language selected at application start and default language when enabling deck list translations</source>
      <translation>Beim Start der Anwendung ausgewählte Kartensprache und Standardsprache beim Aktivieren der Decklistenübersetzung</translation>
    </message>
  </context>
  <context>
    <name>GroupedCentralWidget</name>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="58"/>
      <source>Remove selected</source>
      <translation>Ausgewählte entfernen</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="103"/>
      <source>All pages:</source>
      <translation>Alle Seiten:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="110"/>
      <source>Add new cards:</source>
      <translation>Karten hinzufügen:</translation>
    </message>
  </context>
  <context>
    <name>HidePrintingsPage</name>
    <message>
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
      <source>Copies:</source>
      <translation>Kopien:</translation>
    </message>
  </context>
  <context>
    <name>ImageDownloader</name>
    <message>
      <location filename="../../model/imagedb.py" line="338"/>
      <source>Importing deck list</source>
      <comment>Progress bar label text</comment>
      <translation>Deckliste importieren</translation>
    </message>
    <message>
      <location filename="../../model/imagedb.py" line="358"/>
      <source>Fetching missing images</source>
      <comment>Progress bar label text</comment>
      <translation>Abrufen fehlender Bilder</translation>
    </message>
    <message>
      <location filename="../../model/imagedb.py" line="453"/>
      <source>Downloading &apos;{card_name}&apos;</source>
      <comment>Progress bar label text</comment>
      <translation>Lade '{card_name}' herunter</translation>
    </message>
  </context>
  <context>
    <name>KnownCardImageModel</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="147"/>
      <source>Name</source>
      <translation>Name</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="148"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="149"/>
      <source>Collector #</source>
      <translation>Sammler #</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="150"/>
      <source>Is Hidden</source>
      <translation>Versteckt</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="151"/>
      <source>Front/Back</source>
      <translation>Vorder-/Rückseite</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="152"/>
      <source>High resolution?</source>
      <translation>Hohe Qualität?</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="153"/>
      <source>Size</source>
      <translation>Größe</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="154"/>
      <source>Scryfall ID</source>
      <translation>Scryfall ID</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="155"/>
      <source>Path</source>
      <translation>Dateipfad</translation>
    </message>
  </context>
  <context>
    <name>KnownCardRow</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="126"/>
      <source>Yes</source>
      <translation>Ja</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="126"/>
      <source>No</source>
      <translation>Nein</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="114"/>
      <source>This printing is hidden by an enabled card filter
and is thus unavailable for printing.</source>
      <comment>Tooltip for cells with hidden cards</comment>
      <translation>Dieser Druck wird durch einen aktivierten Kartenfilter
versteckt und ist daher nicht verfügbar.</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="120"/>
      <source>Front</source>
      <comment>Card side</comment>
      <translation>Vorderseite</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="120"/>
      <source>Back</source>
      <comment>Card side</comment>
      <translation>Rückseite</translation>
    </message>
  </context>
  <context>
    <name>LoadDocumentDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="163"/>
      <source>Load MTGProxyPrinter document</source>
      <translation>MTGProxyPrinter-Dokument laden</translation>
    </message>
  </context>
  <context>
    <name>LoadListPage</name>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="121"/>
      <source>Supported websites:
{supported_sites}</source>
      <translation>Unterstützte Webseiten:
{supported_sites}</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="217"/>
      <source>Overwrite existing deck list?</source>
      <translation>Vorhandene Deckliste überschreiben?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="171"/>
      <source>Selecting a file will overwrite the existing deck list. Continue?</source>
      <translation>Das Auswählen einer Datei überschreibt die vorhandene Deckliste. Fortfahren?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="179"/>
      <source>Select deck file</source>
      <translation>Decklisten-Datei auswählen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="189"/>
      <source>All files (*)</source>
      <translation>Alle Dateien (*)</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="200"/>
      <source>All Supported </source>
      <translation>Alle unterstützten </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="217"/>
      <source>Downloading a deck list will overwrite the existing deck list. Continue?</source>
      <translation>Das Herunterladen einer Deckliste überschreibt die vorhandene Deckliste. Fortfahren?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="230"/>
      <source>Download failed with HTTP error {http_error_code}.

{bad_request_msg}</source>
      <translation>Download fehlgeschlagen mit HTTP-Fehler {http_error_code}.

{bad_request_msg}</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="241"/>
      <source>Deck list download failed</source>
      <translation>Download der Deckliste fehlgeschlagen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="236"/>
      <source>Download failed.

Check your internet connection, verify that the URL is valid, reachable, and that the deck list is set to public. This program cannot download private deck lists. If this persists, please report a bug in the issue tracker on the homepage.</source>
      <translation>Download fehlgeschlagen.

Überprüfen Sie Ihre Internetverbindung, ob die URL gültig und erreichbar ist, und dass die Deckliste öffentlich ist. Dieses Programm kann keine privaten Deck-Listen herunterladen. Falls das Problem weiterhin besteht, melden Sie bitte einen Fehler im Issue-Tracker auf der Homepage.</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="267"/>
      <source>Unable to read file content</source>
      <translation>Dateiinhalt konnte nicht gelesen werden</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="267"/>
      <source>Unable to read the content of file {file_path} as plain text.
Failed to load the content.</source>
      <translation>Kann den Inhalt der Datei {file_path} nicht als Text lesen.
Fehler beim Laden des Inhalts.</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="279"/>
      <source>Load large file?</source>
      <translation>Große Datei laden?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="279"/>
      <source>The selected file {file_path} is unexpectedly large ({formatted_size}). Load anyway?</source>
      <translation>Die ausgewählte Datei {file_path} ist mit {formatted_size} unerwartet groß. Trotzdem laden?</translation>
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/load_list_page.ui" line="17"/>
      <source>Import a deck list for printing</source>
      <translation>Deckliste zum Drucken importieren</translation>







|





|





|








|




|




|




|




|




|




|




|




|







|




|




|







|





|








|







|






|




|




|




|




|




|




|








|




|








|




|






|




|







1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
      <source>Copies:</source>
      <translation>Kopien:</translation>
    </message>
  </context>
  <context>
    <name>ImageDownloader</name>
    <message>
      <location filename="../../model/imagedb.py" line="309"/>
      <source>Importing deck list</source>
      <comment>Progress bar label text</comment>
      <translation>Deckliste importieren</translation>
    </message>
    <message>
      <location filename="../../model/imagedb.py" line="329"/>
      <source>Fetching missing images</source>
      <comment>Progress bar label text</comment>
      <translation>Abrufen fehlender Bilder</translation>
    </message>
    <message>
      <location filename="../../model/imagedb.py" line="424"/>
      <source>Downloading &apos;{card_name}&apos;</source>
      <comment>Progress bar label text</comment>
      <translation>Lade '{card_name}' herunter</translation>
    </message>
  </context>
  <context>
    <name>KnownCardImageModel</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="132"/>
      <source>Name</source>
      <translation>Name</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="133"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="134"/>
      <source>Collector #</source>
      <translation>Sammler #</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="135"/>
      <source>Is Hidden</source>
      <translation>Versteckt</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="136"/>
      <source>Front/Back</source>
      <translation>Vorder-/Rückseite</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="137"/>
      <source>High resolution?</source>
      <translation>Hohe Qualität?</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="138"/>
      <source>Size</source>
      <translation>Größe</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="139"/>
      <source>Scryfall ID</source>
      <translation>Scryfall ID</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="140"/>
      <source>Path</source>
      <translation>Dateipfad</translation>
    </message>
  </context>
  <context>
    <name>KnownCardRow</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="111"/>
      <source>Yes</source>
      <translation>Ja</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="111"/>
      <source>No</source>
      <translation>Nein</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="99"/>
      <source>This printing is hidden by an enabled card filter
and is thus unavailable for printing.</source>
      <comment>Tooltip for cells with hidden cards</comment>
      <translation>Dieser Druck wird durch einen aktivierten Kartenfilter
versteckt und ist daher nicht verfügbar.</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="105"/>
      <source>Front</source>
      <comment>Card side</comment>
      <translation>Vorderseite</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="105"/>
      <source>Back</source>
      <comment>Card side</comment>
      <translation>Rückseite</translation>
    </message>
  </context>
  <context>
    <name>LoadDocumentDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="164"/>
      <source>Load MTGProxyPrinter document</source>
      <translation>MTGProxyPrinter-Dokument laden</translation>
    </message>
  </context>
  <context>
    <name>LoadListPage</name>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="120"/>
      <source>Supported websites:
{supported_sites}</source>
      <translation>Unterstützte Webseiten:
{supported_sites}</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="216"/>
      <source>Overwrite existing deck list?</source>
      <translation>Vorhandene Deckliste überschreiben?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="170"/>
      <source>Selecting a file will overwrite the existing deck list. Continue?</source>
      <translation>Das Auswählen einer Datei überschreibt die vorhandene Deckliste. Fortfahren?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="178"/>
      <source>Select deck file</source>
      <translation>Decklisten-Datei auswählen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="188"/>
      <source>All files (*)</source>
      <translation>Alle Dateien (*)</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="199"/>
      <source>All Supported </source>
      <translation>Alle unterstützten </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="216"/>
      <source>Downloading a deck list will overwrite the existing deck list. Continue?</source>
      <translation>Das Herunterladen einer Deckliste überschreibt die vorhandene Deckliste. Fortfahren?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="229"/>
      <source>Download failed with HTTP error {http_error_code}.

{bad_request_msg}</source>
      <translation>Download fehlgeschlagen mit HTTP-Fehler {http_error_code}.

{bad_request_msg}</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="240"/>
      <source>Deck list download failed</source>
      <translation>Download der Deckliste fehlgeschlagen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="235"/>
      <source>Download failed.

Check your internet connection, verify that the URL is valid, reachable, and that the deck list is set to public. This program cannot download private deck lists. If this persists, please report a bug in the issue tracker on the homepage.</source>
      <translation>Download fehlgeschlagen.

Überprüfen Sie Ihre Internetverbindung, ob die URL gültig und erreichbar ist, und dass die Deckliste öffentlich ist. Dieses Programm kann keine privaten Deck-Listen herunterladen. Falls das Problem weiterhin besteht, melden Sie bitte einen Fehler im Issue-Tracker auf der Homepage.</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="266"/>
      <source>Unable to read file content</source>
      <translation>Dateiinhalt konnte nicht gelesen werden</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="266"/>
      <source>Unable to read the content of file {file_path} as plain text.
Failed to load the content.</source>
      <translation>Kann den Inhalt der Datei {file_path} nicht als Text lesen.
Fehler beim Laden des Inhalts.</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="278"/>
      <source>Load large file?</source>
      <translation>Große Datei laden?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="278"/>
      <source>The selected file {file_path} is unexpectedly large ({formatted_size}). Load anyway?</source>
      <translation>Die ausgewählte Datei {file_path} ist mit {formatted_size} unerwartet groß. Trotzdem laden?</translation>
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/load_list_page.ui" line="17"/>
      <source>Import a deck list for printing</source>
      <translation>Deckliste zum Drucken importieren</translation>
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152





2153
2154
2155
2156
2157
2158
2159
2160





2161
2162
2163
2164
2165
2166
2167
      <source>Download deck list</source>
      <translation>Deckliste herunterladen</translation>
    </message>
  </context>
  <context>
    <name>LoadSaveDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="120"/>
      <source>MTGProxyPrinter document (*.{default_save_suffix})</source>
      <comment>Human-readable file type name</comment>
      <translation>MTGProxyPrinter-Dokument (*.{default_save_suffix})</translation>
    </message>
  </context>
  <context>
    <name>MTGArenaParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="200"/>
      <source>Magic Arena deck file</source>
      <translation>Magic Arena Deckliste</translation>
    </message>
  </context>
  <context>
    <name>MTGOnlineParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="234"/>
      <source>Magic Online (MTGO) deck file</source>
      <translation>Magic Online (MTGO) Deckliste</translation>
    </message>
  </context>
  <context>
    <name>MagicWorkstationDeckDataFormatParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="178"/>
      <source>Magic Workstation Deck Data Format</source>
      <translation>Magic Workstation Deck Data (mwDeck)</translation>
    </message>
  </context>
  <context>
    <name>MainWindow</name>
    <message>
      <location filename="../../ui/main_window.py" line="219"/>
      <source>Undo:
{top_entry}</source>
      <translation>Rückgängig:
{top_entry}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="221"/>
      <source>Redo:
{top_entry}</source>
      <translation>Wiederholen:
{top_entry}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="277"/>
      <source>printing</source>
      <comment>This is passed as the {action} when asking the user about compacting the document if that can save pages</comment>
      <translation>dem Drucken</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="289"/>
      <source>exporting as a PDF</source>
      <comment>This is passed as the {action} when asking the user about compacting the document if that can save pages</comment>
      <translation>dem PDF-Export</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="305"/>
      <source>Network error</source>
      <translation>Netzwerkfehler</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="305"/>
      <source>Operation failed, because a network error occurred.
Check your internet connection. Reported error message:

{message}</source>
      <translation>Vorgang fehlgeschlagen, da ein Netzwerkfehler aufgetreten ist.
Überprüfen Sie Ihre Internetverbindung. Fehlermeldung:

{message}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="313"/>
      <source>Error</source>
      <translation>Fehler</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="313"/>
      <source>Operation failed, because an internal error occurred.
Reported error message:

{message}</source>
      <translation>Vorgang fehlgeschlagen, da ein interner Fehler aufgetreten ist.
Berichtete Fehlermeldung:

{message}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="322"/>
      <source>Saving pages possible</source>
      <translation>Einsparen von Seiten möglich</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="322"/>
      <source>It is possible to save %n pages when printing this document.
Do you want to compact the document now to minimize the page count prior to {action}?</source>
      <translation>
        <numerusform>Es ist möglich, %n Seite beim Drucken dieses Dokuments zu sparen.
Möchten Sie das Dokument jetzt komprimieren, um die Seitenanzahl vor {action} zu minimieren?</numerusform>
        <numerusform>Es ist möglich, %n Seiten beim Drucken dieses Dokuments zu sparen.
Möchten Sie das Dokument jetzt komprimieren, um die Seitenanzahl vor {action} zu minimieren?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="338"/>
      <source>Download required Card data from Scryfall?</source>
      <translation>Benötigte Kartendaten von Scryfall herunterladen?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="338"/>
      <source>This program requires downloading additional card data from Scryfall to operate the card search.
Download the required data from Scryfall now?
Without the data, you can only print custom cards by drag&amp;dropping the image files onto the main window.</source>
      <translation>Dieses Programm erfordert das Herunterladen zusätzlicher Kartendaten von Scryfall, um die Kartensuche zu ermöglichen.
Jetzt die benötigten Daten von Scryfall herunterladen?
Ohne die Daten können Sie nur nutzererstellte Karten drucken, indem Sie die Bilddateien per Drag &amp; Drop in das Hauptfenster ziehen.</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="386"/>
      <source>Document loading failed</source>
      <translation>Laden des Dokuments fehlgeschlagen</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="386"/>
      <source>Loading file &quot;{failed_path}&quot; failed. The file was not recognized as a {program_name} document. If you want to load a deck list, use the &quot;{function_text}&quot; function instead.
Reported failure reason: {reason}</source>
      <translation>Laden der Datei "{failed_path}" fehlgeschlagen. Die Datei wurde nicht als {program_name}-Dokument erkannt. Wenn Sie eine Deckliste laden möchten, verwenden Sie die "{function_text}"-Funktion stattdessen.
Berichteter Fehlergrund: {reason}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="399"/>
      <source>Unavailable printings replaced</source>
      <translation>Nicht verfügbare Drucke ersetzt</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="399"/>
      <source>The document contained %n unavailable printings of cards that were automatically replaced with other printings. The replaced printings are unavailable, because they match a configured card filter.</source>
      <translation>
        <numerusform>Das Dokument enthielt einen nicht verfügbaren Druck einer Karte, der automatisch durch einen anderen Druck ersetzt wurden. Der ausgetauschten Druck ist nicht verfügbar, da er mit einem konfigurierten Kartenfilter übereinstimmt.</numerusform>
        <numerusform>Das Dokument enthielt %n nicht verfügbare Drucke von Karten, die automatisch durch andere Drucke ersetzt wurden. Die ausgetauschten Drucke sind nicht verfügbar, da sie mit einem konfigurierten Kartenfilter übereinstimmen.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="408"/>
      <source>Unrecognized cards in loaded document found</source>
      <translation>Nicht erkannte Karten im geladenen Dokument gefunden</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="408"/>
      <source>Skipped %n unrecognized cards in the loaded document. Saving the document will remove these entries permanently.

The locally stored card data may be outdated or the document was tampered with.</source>
      <translation>
        <numerusform>Eine unbekannte Karte im geladenen Dokument übersprungen. Speichern des Dokuments wird diese dauerhaft entfernen.

Die lokalen Kartendaten sind möglicherweise veraltet oder das Dokument wurde manipuliert.</numerusform>
        <numerusform>%n unbekannte Karten im geladenen Dokument übersprungen. Speichern des Dokuments wird diese dauerhaft entfernen.

Die lokalen Kartendaten sind möglicherweise veraltet oder das Dokument wurde manipuliert.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="418"/>
      <source>Application update available. Visit website?</source>
      <translation>Anwendungsaktualisierung verfügbar. Website besuchen?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="418"/>
      <source>An application update is available: Version {newer_version}
You are currently using version {current_version}.

Open the {program_name} website in your web browser to download the new version?</source>
      <translation>Ein Anwendungs-Update ist verfügbar: Version {newer_version}
Sie verwenden derzeit Version {current_version}.

Die {program_name}-Webseite mit Ihrem Web-Browser besuchen, um die neue Version herunterzuladen?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="433"/>
      <source>New card data available</source>
      <translation>Neue Kartendaten verfügbar</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="433"/>
      <source>There are %n new printings available on Scryfall. Update the local data now?</source>
      <translation>
        <numerusform>Es ist %n neue Karte auf Scryfall verfügbar. Lokale Daten jetzt aktualisieren?</numerusform>
        <numerusform>Es sind %n neue Karten auf Scryfall verfügbar. Lokale Daten jetzt aktualisieren?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="449"/>
      <source>Check for application updates?</source>
      <translation>Nach Anwendungsaktualisierungen suchen?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="449"/>
      <source>Automatically check for application updates whenever you start {program_name}?</source>
      <translation>Beim Anwendungsstart automatisch nach Updates suchen?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="461"/>
      <source>Check for card data updates?</source>
      <translation>Suche nach Kartendaten-Updates?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="461"/>
      <source>Automatically check for card data updates on Scryfall whenever you start {program_name}?</source>
      <translation>Automatisch nach Kartenupdates auf Scryfall prüfen, wann immer Sie {program_name} starten?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="471"/>
      <source>{question}
You can change this later in the settings.</source>
      <translation>{question}
Sie können dies später in den Einstellungen ändern.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="14"/>
      <source>MTGProxyPrinter</source>
      <translation>MTGProxyPrinter</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="31"/>
      <source>Fi&amp;le</source>
      <translation>&amp;Datei</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="170"/>
      <source>Settings</source>
      <translation>Einstellungen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="59"/>
      <source>Edit</source>
      <translation>Bearbeiten</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="301"/>
      <source>Show toolbar</source>
      <translation>Werkzeugleiste anzeigen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="109"/>
      <source>&amp;Quit</source>
      <translation>&amp;Beenden</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="112"/>
      <source>Ctrl+Q</source>
      <translation>Strg+Q</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="123"/>
      <source>&amp;Print</source>
      <translation>&amp;Drucken</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="126"/>
      <source>Print the current document</source>
      <translation>Aktuelles Dokument drucken</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="129"/>
      <source>Ctrl+P</source>
      <translation>Strg+P</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="137"/>
      <source>&amp;Show print preview</source>
      <translation>Druckvorschau</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="140"/>
      <source>Show print preview window</source>
      <translation>Druckvorschau anzeigen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="148"/>
      <source>&amp;Create PDF</source>
      <translation>PDF erzeugen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="151"/>
      <source>Create a PDF document</source>
      <translation>Als PDF-Dokument exportieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="159"/>
      <source>Discard page</source>
      <translation>Seite verwerfen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="162"/>
      <source>Discard this page.</source>
      <translation>Diese Seite verwerfen.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="181"/>
      <source>Update card data</source>
      <translation>Kartendaten aktualisieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="189"/>
      <source>New Page</source>
      <translation>Neue Seite</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="192"/>
      <source>Add a new, empty page.</source>
      <translation>Neue, leere Seite hinzufügen.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="200"/>
      <source>Save</source>
      <translation>Speichern</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="203"/>
      <source>Ctrl+S</source>
      <translation>Strg+S</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="211"/>
      <source>New</source>
      <translation>Neu</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="214"/>
      <source>Ctrl+N</source>
      <translation>Strg+N</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="222"/>
      <source>Load</source>
      <translation>Laden</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="225"/>
      <source>Ctrl+L</source>
      <translation>Strg+L</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="233"/>
      <source>Save as …</source>
      <translation>Speichern unter …</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="238"/>
      <source>About …</source>
      <translation>Über …</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="246"/>
      <source>Show Changelog</source>
      <translation>Änderungsprotokoll anzeigen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="254"/>
      <source>Compact document</source>
      <translation>Dokument kompaktieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="257"/>
      <source>Minimize page count: Fill empty slots on pages by moving cards from the end of the document</source>
      <translation>Seitenzahl minimieren: Leerstellen auf Seiten durch das Verschieben von Karten vom Dokumentenende füllen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="265"/>
      <source>Edit document settings</source>
      <translation>Einstellungen dieses Dokuments</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="268"/>
      <source>Configure page size, margins, image spacings for the currently edited document.</source>
      <translation>Einstellungen des aktuellen Dokuments, wie Papiergröße, Rand- und Bildabstände anpassen.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="276"/>
      <source>Import Deck list</source>
      <translation>Deckliste importieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="279"/>
      <source>Import a deck list from online sources</source>
      <translation>Eine Deckliste aus dem Internet importieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="287"/>
      <source>Cleanup card images</source>
      <translation>Kartenbilder bereinigen/löschen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="290"/>
      <source>Delete locally stored card images you no longer need.</source>
      <translation>Nicht mehr benötigte, gespeicherte Kartenbilder löschen.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="304"/>
      <source>Ctrl+M</source>
      <translation>Strg+M</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="312"/>
      <source>Download missing card images</source>
      <translation>Fehlende Kartenbilder herunterladen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="320"/>
      <source>Shuffle document</source>
      <translation>Dokument mischen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="323"/>
      <source>Randomly rearrange all card image.
If you want to quickly print a full deck for playing,
use this to reduce the initial deck shuffling required</source>
      <translation>Alle Karten zufällig neu anordnen.
Wenn Sie schnell ein komplettes Deck für das Spielen drucken möchten,
können Sie dies verwenden, um den Aufwand beim initialen Mischen zu reduzieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="336"/>
      <source>Undo</source>
      <translation>Rückgängig</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="347"/>
      <source>Redo</source>
      <translation>Wiederholen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="355"/>





      <source>Add empty card to page</source>
      <translation>Leere Karte zur Seite hinzufügen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="358"/>
      <source>Add an empty spacer filling a card slot</source>
      <translation>Ein Feld auf der aktuellen Seite leer halten</translation>
    </message>





  </context>
  <context>
    <name>PDFSettingsPage</name>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="558"/>
      <source>PDF export settings</source>
      <translation>PDF-Exporteinstellungen</translation>







|








|







|







|







|






|






|





|





|




|










|




|










|




|










|




|








|




|






|




|







|




|













|




|










|




|







|




|




|




|




|
















|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|
<
<
<
<
<




|




|




|




|




|




|








|




|




|
>
>
>
>
>




|



>
>
>
>
>







1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097





2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
      <source>Download deck list</source>
      <translation>Deckliste herunterladen</translation>
    </message>
  </context>
  <context>
    <name>LoadSaveDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="121"/>
      <source>MTGProxyPrinter document (*.{default_save_suffix})</source>
      <comment>Human-readable file type name</comment>
      <translation>MTGProxyPrinter-Dokument (*.{default_save_suffix})</translation>
    </message>
  </context>
  <context>
    <name>MTGArenaParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="201"/>
      <source>Magic Arena deck file</source>
      <translation>Magic Arena Deckliste</translation>
    </message>
  </context>
  <context>
    <name>MTGOnlineParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="235"/>
      <source>Magic Online (MTGO) deck file</source>
      <translation>Magic Online (MTGO) Deckliste</translation>
    </message>
  </context>
  <context>
    <name>MagicWorkstationDeckDataFormatParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="179"/>
      <source>Magic Workstation Deck Data Format</source>
      <translation>Magic Workstation Deck Data (mwDeck)</translation>
    </message>
  </context>
  <context>
    <name>MainWindow</name>
    <message>
      <location filename="../../ui/main_window.py" line="220"/>
      <source>Undo:
{top_entry}</source>
      <translation>Rückgängig:
{top_entry}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="222"/>
      <source>Redo:
{top_entry}</source>
      <translation>Wiederholen:
{top_entry}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="286"/>
      <source>printing</source>
      <comment>This is passed as the {action} when asking the user about compacting the document if that can save pages</comment>
      <translation>dem Drucken</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="298"/>
      <source>exporting as a PDF</source>
      <comment>This is passed as the {action} when asking the user about compacting the document if that can save pages</comment>
      <translation>dem PDF-Export</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="314"/>
      <source>Network error</source>
      <translation>Netzwerkfehler</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="314"/>
      <source>Operation failed, because a network error occurred.
Check your internet connection. Reported error message:

{message}</source>
      <translation>Vorgang fehlgeschlagen, da ein Netzwerkfehler aufgetreten ist.
Überprüfen Sie Ihre Internetverbindung. Fehlermeldung:

{message}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="322"/>
      <source>Error</source>
      <translation>Fehler</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="322"/>
      <source>Operation failed, because an internal error occurred.
Reported error message:

{message}</source>
      <translation>Vorgang fehlgeschlagen, da ein interner Fehler aufgetreten ist.
Berichtete Fehlermeldung:

{message}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="331"/>
      <source>Saving pages possible</source>
      <translation>Einsparen von Seiten möglich</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="331"/>
      <source>It is possible to save %n pages when printing this document.
Do you want to compact the document now to minimize the page count prior to {action}?</source>
      <translation>
        <numerusform>Es ist möglich, %n Seite beim Drucken dieses Dokuments zu sparen.
Möchten Sie das Dokument jetzt komprimieren, um die Seitenanzahl vor {action} zu minimieren?</numerusform>
        <numerusform>Es ist möglich, %n Seiten beim Drucken dieses Dokuments zu sparen.
Möchten Sie das Dokument jetzt komprimieren, um die Seitenanzahl vor {action} zu minimieren?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="347"/>
      <source>Download required Card data from Scryfall?</source>
      <translation>Benötigte Kartendaten von Scryfall herunterladen?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="347"/>
      <source>This program requires downloading additional card data from Scryfall to operate the card search.
Download the required data from Scryfall now?
Without the data, you can only print custom cards by drag&amp;dropping the image files onto the main window.</source>
      <translation>Dieses Programm erfordert das Herunterladen zusätzlicher Kartendaten von Scryfall, um die Kartensuche zu ermöglichen.
Jetzt die benötigten Daten von Scryfall herunterladen?
Ohne die Daten können Sie nur nutzererstellte Karten drucken, indem Sie die Bilddateien per Drag &amp; Drop in das Hauptfenster ziehen.</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="395"/>
      <source>Document loading failed</source>
      <translation>Laden des Dokuments fehlgeschlagen</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="395"/>
      <source>Loading file &quot;{failed_path}&quot; failed. The file was not recognized as a {program_name} document. If you want to load a deck list, use the &quot;{function_text}&quot; function instead.
Reported failure reason: {reason}</source>
      <translation>Laden der Datei "{failed_path}" fehlgeschlagen. Die Datei wurde nicht als {program_name}-Dokument erkannt. Wenn Sie eine Deckliste laden möchten, verwenden Sie die "{function_text}"-Funktion stattdessen.
Berichteter Fehlergrund: {reason}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="408"/>
      <source>Unavailable printings replaced</source>
      <translation>Nicht verfügbare Drucke ersetzt</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="408"/>
      <source>The document contained %n unavailable printings of cards that were automatically replaced with other printings. The replaced printings are unavailable, because they match a configured card filter.</source>
      <translation>
        <numerusform>Das Dokument enthielt einen nicht verfügbaren Druck einer Karte, der automatisch durch einen anderen Druck ersetzt wurden. Der ausgetauschten Druck ist nicht verfügbar, da er mit einem konfigurierten Kartenfilter übereinstimmt.</numerusform>
        <numerusform>Das Dokument enthielt %n nicht verfügbare Drucke von Karten, die automatisch durch andere Drucke ersetzt wurden. Die ausgetauschten Drucke sind nicht verfügbar, da sie mit einem konfigurierten Kartenfilter übereinstimmen.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="417"/>
      <source>Unrecognized cards in loaded document found</source>
      <translation>Nicht erkannte Karten im geladenen Dokument gefunden</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="417"/>
      <source>Skipped %n unrecognized cards in the loaded document. Saving the document will remove these entries permanently.

The locally stored card data may be outdated or the document was tampered with.</source>
      <translation>
        <numerusform>Eine unbekannte Karte im geladenen Dokument übersprungen. Speichern des Dokuments wird diese dauerhaft entfernen.

Die lokalen Kartendaten sind möglicherweise veraltet oder das Dokument wurde manipuliert.</numerusform>
        <numerusform>%n unbekannte Karten im geladenen Dokument übersprungen. Speichern des Dokuments wird diese dauerhaft entfernen.

Die lokalen Kartendaten sind möglicherweise veraltet oder das Dokument wurde manipuliert.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="427"/>
      <source>Application update available. Visit website?</source>
      <translation>Anwendungsaktualisierung verfügbar. Website besuchen?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="427"/>
      <source>An application update is available: Version {newer_version}
You are currently using version {current_version}.

Open the {program_name} website in your web browser to download the new version?</source>
      <translation>Ein Anwendungs-Update ist verfügbar: Version {newer_version}
Sie verwenden derzeit Version {current_version}.

Die {program_name}-Webseite mit Ihrem Web-Browser besuchen, um die neue Version herunterzuladen?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="442"/>
      <source>New card data available</source>
      <translation>Neue Kartendaten verfügbar</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="442"/>
      <source>There are %n new printings available on Scryfall. Update the local data now?</source>
      <translation>
        <numerusform>Es ist %n neue Karte auf Scryfall verfügbar. Lokale Daten jetzt aktualisieren?</numerusform>
        <numerusform>Es sind %n neue Karten auf Scryfall verfügbar. Lokale Daten jetzt aktualisieren?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="458"/>
      <source>Check for application updates?</source>
      <translation>Nach Anwendungsaktualisierungen suchen?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="458"/>
      <source>Automatically check for application updates whenever you start {program_name}?</source>
      <translation>Beim Anwendungsstart automatisch nach Updates suchen?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="470"/>
      <source>Check for card data updates?</source>
      <translation>Suche nach Kartendaten-Updates?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="470"/>
      <source>Automatically check for card data updates on Scryfall whenever you start {program_name}?</source>
      <translation>Automatisch nach Kartenupdates auf Scryfall prüfen, wann immer Sie {program_name} starten?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="480"/>
      <source>{question}
You can change this later in the settings.</source>
      <translation>{question}
Sie können dies später in den Einstellungen ändern.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="14"/>
      <source>MTGProxyPrinter</source>
      <translation>MTGProxyPrinter</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="31"/>
      <source>Fi&amp;le</source>
      <translation>&amp;Datei</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="171"/>
      <source>Settings</source>
      <translation>Einstellungen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="60"/>
      <source>Edit</source>
      <translation>Bearbeiten</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="302"/>
      <source>Show toolbar</source>
      <translation>Werkzeugleiste anzeigen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="110"/>
      <source>&amp;Quit</source>
      <translation>&amp;Beenden</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="113"/>
      <source>Ctrl+Q</source>
      <translation>Strg+Q</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="124"/>
      <source>&amp;Print</source>
      <translation>&amp;Drucken</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="127"/>
      <source>Print the current document</source>
      <translation>Aktuelles Dokument drucken</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="130"/>
      <source>Ctrl+P</source>
      <translation>Strg+P</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="138"/>
      <source>&amp;Show print preview</source>
      <translation>Druckvorschau</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="141"/>
      <source>Show print preview window</source>
      <translation>Druckvorschau anzeigen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="149"/>
      <source>&amp;Create PDF</source>
      <translation>PDF erzeugen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="152"/>
      <source>Create a PDF document</source>
      <translation>Als PDF-Dokument exportieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="160"/>
      <source>Discard page</source>
      <translation>Seite verwerfen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="163"/>
      <source>Discard this page.</source>
      <translation>Diese Seite verwerfen.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="182"/>
      <source>Update card data</source>
      <translation>Kartendaten aktualisieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="190"/>
      <source>New Page</source>
      <translation>Neue Seite</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="193"/>
      <source>Add a new, empty page.</source>
      <translation>Neue, leere Seite hinzufügen.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="201"/>
      <source>Save</source>
      <translation>Speichern</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="204"/>
      <source>Ctrl+S</source>
      <translation>Strg+S</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="212"/>
      <source>New</source>
      <translation>Neu</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="215"/>
      <source>Ctrl+N</source>
      <translation>Strg+N</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="223"/>
      <source>Load</source>
      <translation>Laden</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="226"/>
      <source>Ctrl+L</source>
      <translation>Strg+L</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="234"/>
      <source>Save as …</source>
      <translation>Speichern unter …</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="239"/>
      <source>About …</source>
      <translation>Über …</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="247"/>
      <source>Show Changelog</source>
      <translation>Änderungsprotokoll anzeigen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="255"/>
      <source>Compact document</source>
      <translation>Dokument kompaktieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="258"/>
      <source>Minimize page count: Fill empty slots on pages by moving cards from the end of the document</source>
      <translation>Seitenzahl minimieren: Leerstellen auf Seiten durch das Verschieben von Karten vom Dokumentenende füllen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="266"/>
      <source>Edit document settings</source>
      <translation>Einstellungen dieses Dokuments</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="269"/>
      <source>Configure page size, margins, image spacings for the currently edited document.</source>
      <translation>Einstellungen des aktuellen Dokuments, wie Papiergröße, Rand- und Bildabstände anpassen.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="280"/>





      <source>Import a deck list from online sources</source>
      <translation>Eine Deckliste aus dem Internet importieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="288"/>
      <source>Cleanup card images</source>
      <translation>Kartenbilder bereinigen/löschen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="291"/>
      <source>Delete locally stored card images you no longer need.</source>
      <translation>Nicht mehr benötigte, gespeicherte Kartenbilder löschen.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="305"/>
      <source>Ctrl+M</source>
      <translation>Strg+M</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="313"/>
      <source>Download missing card images</source>
      <translation>Fehlende Kartenbilder herunterladen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="321"/>
      <source>Shuffle document</source>
      <translation>Dokument mischen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="324"/>
      <source>Randomly rearrange all card image.
If you want to quickly print a full deck for playing,
use this to reduce the initial deck shuffling required</source>
      <translation>Alle Karten zufällig neu anordnen.
Wenn Sie schnell ein komplettes Deck für das Spielen drucken möchten,
können Sie dies verwenden, um den Aufwand beim initialen Mischen zu reduzieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="337"/>
      <source>Undo</source>
      <translation>Rückgängig</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="348"/>
      <source>Redo</source>
      <translation>Wiederholen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="277"/>
      <source>Import deck list</source>
      <translation>Deckliste importieren</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="356"/>
      <source>Add empty card to page</source>
      <translation>Leere Karte zur Seite hinzufügen</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="359"/>
      <source>Add an empty spacer filling a card slot</source>
      <translation>Ein Feld auf der aktuellen Seite leer halten</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="367"/>
      <source>Add custom cards</source>
      <translation>Inoffizielle Karten hinzufügen</translation>
    </message>
  </context>
  <context>
    <name>PDFSettingsPage</name>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="558"/>
      <source>PDF export settings</source>
      <translation>PDF-Exporteinstellungen</translation>
2242
2243
2244
2245
2246
2247
2248






















































2249
2250
2251
2252
2253
2254
2255
    </message>
    <message>
      <location filename="../ui/settings_window/pdf_settings_page.ui" line="124"/>
      <source>Enable landscape workaround: Rotate landscape PDFs by 90°</source>
      <translation>Querformat-Workaround: Querformat-Dokumente um 90° drehen</translation>
    </message>
  </context>






















































  <context>
    <name>PageConfigPreviewArea</name>
    <message>
      <location filename="../ui/page_config_preview_area.ui" line="36"/>
      <source> cards</source>
      <translation> Karten</translation>
    </message>







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
    </message>
    <message>
      <location filename="../ui/settings_window/pdf_settings_page.ui" line="124"/>
      <source>Enable landscape workaround: Rotate landscape PDFs by 90°</source>
      <translation>Querformat-Workaround: Querformat-Dokumente um 90° drehen</translation>
    </message>
  </context>
  <context>
    <name>PageCardTableView</name>
    <message numerus="yes">
      <location filename="../../ui/page_card_table_view.py" line="128"/>
      <source>Add %n copies</source>
      <comment>Context menu action: Add additional card copies to the document</comment>
      <translation>
        <numerusform>Kopie hinzufügen</numerusform>
        <numerusform>%n Kopien hinzufügen</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="134"/>
      <source>Add copies …</source>
      <comment>Context menu action: Add additional card copies to the document. User will be asked for a number</comment>
      <translation>Kopien hinzufügen …</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="121"/>
      <source>Generate DFC check card</source>
      <translation>Platzhalterkarte generieren</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="148"/>
      <source>All related cards</source>
      <translation>Alle zugehörigen Karten</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="156"/>
      <source>Add copies</source>
      <translation>Kopien hinzufügen</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="156"/>
      <source>Add copies of {card_name}</source>
      <comment>Asks the user for a number. Does not need plural forms</comment>
      <translation>Kopien von {card_name} hinzufügen</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="182"/>
      <source>Export image</source>
      <translation>Bild exportieren</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="197"/>
      <source>Save card image</source>
      <translation>Kartenbild speichern</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="197"/>
      <source>Images (*.png *.bmp *.jpg)</source>
      <translation>Bilder (*.png *.bmp *.jpg)</translation>
    </message>
  </context>
  <context>
    <name>PageConfigPreviewArea</name>
    <message>
      <location filename="../ui/page_config_preview_area.ui" line="36"/>
      <source> cards</source>
      <translation> Karten</translation>
    </message>
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
      <source>Oversized</source>
      <translation>Übergroß</translation>
    </message>
  </context>
  <context>
    <name>PageConfigWidget</name>
    <message numerus="yes">
      <location filename="../../ui/page_config_widget.py" line="102"/>
      <source>%n regular card(s)</source>
      <comment>Display of the resulting page capacity for regular-sized cards</comment>
      <translation>
        <numerusform>%n reguläre Karte</numerusform>
        <numerusform>%n reguläre Karten</numerusform>
      </translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/page_config_widget.py" line="106"/>
      <source>%n oversized card(s)</source>
      <comment>Display of the resulting page capacity for oversized cards</comment>
      <translation>
        <numerusform>%n übergroße Karte</numerusform>
        <numerusform>%n übergroße Karten</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/page_config_widget.py" line="111"/>
      <source>{regular_text}, {oversized_text}</source>
      <comment>Combination of the page capacities for regular, and oversized cards</comment>
      <translation>{regular_text}, {oversized_text}</translation>
    </message>
    <message>
      <location filename="../ui/page_config_widget.ui" line="14"/>
      <source>Default settings for new documents</source>







|








|








|







2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
      <source>Oversized</source>
      <translation>Übergroß</translation>
    </message>
  </context>
  <context>
    <name>PageConfigWidget</name>
    <message numerus="yes">
      <location filename="../../ui/page_config_widget.py" line="101"/>
      <source>%n regular card(s)</source>
      <comment>Display of the resulting page capacity for regular-sized cards</comment>
      <translation>
        <numerusform>%n reguläre Karte</numerusform>
        <numerusform>%n reguläre Karten</numerusform>
      </translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/page_config_widget.py" line="105"/>
      <source>%n oversized card(s)</source>
      <comment>Display of the resulting page capacity for oversized cards</comment>
      <translation>
        <numerusform>%n übergroße Karte</numerusform>
        <numerusform>%n übergroße Karten</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/page_config_widget.py" line="110"/>
      <source>{regular_text}, {oversized_text}</source>
      <comment>Combination of the page capacities for regular, and oversized cards</comment>
      <translation>{regular_text}, {oversized_text}</translation>
    </message>
    <message>
      <location filename="../ui/page_config_widget.ui" line="14"/>
      <source>Default settings for new documents</source>
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571






























2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
Zoom in: {zoom_in_shortcuts}
Zoom aus: {zoom_out_shortcuts}</translation>
    </message>
  </context>
  <context>
    <name>ParserBase</name>
    <message>
      <location filename="../../decklist_parser/common.py" line="70"/>
      <source>All files (*)</source>
      <translation>Alle Dateien (*)</translation>
    </message>
  </context>
  <context>
    <name>PrettySetListModel</name>
    <message>
      <location filename="../../model/string_list.py" line="37"/>
      <source>Set</source>
      <comment>MTG set name</comment>
      <translation>Set</translation>
    </message>
  </context>
  <context>
    <name>PrinterSettingsPage</name>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="507"/>
      <source>Printer settings</source>
      <translation>Druckereinstellungen</translation>
    </message>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="507"/>
      <source>Configure the printer</source>
      <translation>Drucker konfigurieren</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="17"/>
      <source>Horizontal printing offset</source>
      <translation>Horizontaler Druckversatz</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="24"/>
      <source>Globally shifts the printing area to correct physical offsets in the printer.
Positive values shift to the right.
Negative offsets shift to the left.</source>
      <translation>Verschiebt den Druckbereich, um einen physikalischen Versatz im Drucker auszugleichen und die Zentrierung zu verbessern.
Positive Werte verschieben nach rechts, negative nach links.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="32"/>
      <source> mm</source>
      <translation> mm</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="48"/>
      <source>If enabled, print landscape documents in portrait mode with all content rotated by 90°.
Enable this, if printing landscape documents results in portrait printouts with cropped-off sides.</source>
      <translation>Wenn aktiviert, werden Querformat-Dokumente stattdessen im Hochformat mit allen Inhalten um 90° gedreht ausgedruckt.
Aktivieren Sie dies, wenn das Drucken von Querformat-Dokumenten zu Ausdrucken im Hochformat mit abgeschnittenen Seiten führt.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="52"/>
      <source>Enable landscape workaround: Rotate prints by 90°</source>
      <translation>Querformat-Workaround: Drucke um 90° drehen</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="62"/>
      <source>When enabled, instruct the printer to use borderless mode and let MTGProxyPrinter manage the printing margins.
Disable this, if your printer keeps scaling print-outs up or down.

When disabled, managing the page margins is delegated to the printer driver,
which should increase compatibility, at the expense of drawing shorter cut helper lines.</source>
      <translation>Wenn diese Option aktiviert ist, wird der Drucker angewiesen, den randlosen Modus zu verwenden und MTGProxyPrinter die Verwaltung der Druckränder zu überlassen.
Deaktivieren Sie diese Option, wenn Ihr Drucker die Ausdrucke ständig hoch- oder herunterskaliert.

Wenn deaktiviert, wird die Verwaltung der Seitenränder an den Druckertreiber delegiert.
Dies sollte die Kompatibilität erhöhen, allerdings auf Kosten des Zeichnens kürzerer Hilfslinien.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="69"/>
      <source>Configure printer for borderless printing</source>
      <translation>Drucker auf randloses Drucken einstellen</translation>
    </message>






























  </context>
  <context>
    <name>PrintingFilterUpdater.store_current_printing_filters()</name>
    <message>
      <location filename="../../printing_filter_updater.py" line="119"/>
      <source>Processing updated card filters:</source>
      <translation>Verarbeite aktualisierte Kartenfilter:</translation>
    </message>
  </context>
  <context>
    <name>SaveDocumentAsDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="133"/>
      <source>Save document as …</source>
      <translation>Dokument speichern unter …</translation>
    </message>
  </context>
  <context>
    <name>SavePDFDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="79"/>
      <source>Export as PDF</source>
      <translation>Als PDF exportieren</translation>
    </message>
    <message>
      <location filename="../../ui/dialogs.py" line="80"/>
      <source>PDF documents (*.pdf)</source>
      <translation>PDF-Dokument (*.pdf)</translation>
    </message>
  </context>
  <context>
    <name>ScryfallCSVParser</name>
    <message>
      <location filename="../../decklist_parser/csv_parsers.py" line="117"/>
      <source>Scryfall CSV export</source>
      <translation>Scryfall CSV-Export</translation>
    </message>
  </context>
  <context>
    <name>SelectDeckParserPage</name>
    <message>







|







|


















<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<

















>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>




|







|







|




|







|







2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582






























2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
Zoom in: {zoom_in_shortcuts}
Zoom aus: {zoom_out_shortcuts}</translation>
    </message>
  </context>
  <context>
    <name>ParserBase</name>
    <message>
      <location filename="../../decklist_parser/common.py" line="71"/>
      <source>All files (*)</source>
      <translation>Alle Dateien (*)</translation>
    </message>
  </context>
  <context>
    <name>PrettySetListModel</name>
    <message>
      <location filename="../../model/string_list.py" line="36"/>
      <source>Set</source>
      <comment>MTG set name</comment>
      <translation>Set</translation>
    </message>
  </context>
  <context>
    <name>PrinterSettingsPage</name>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="507"/>
      <source>Printer settings</source>
      <translation>Druckereinstellungen</translation>
    </message>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="507"/>
      <source>Configure the printer</source>
      <translation>Drucker konfigurieren</translation>
    </message>
    <message>






























      <location filename="../ui/settings_window/printer_settings_page.ui" line="62"/>
      <source>When enabled, instruct the printer to use borderless mode and let MTGProxyPrinter manage the printing margins.
Disable this, if your printer keeps scaling print-outs up or down.

When disabled, managing the page margins is delegated to the printer driver,
which should increase compatibility, at the expense of drawing shorter cut helper lines.</source>
      <translation>Wenn diese Option aktiviert ist, wird der Drucker angewiesen, den randlosen Modus zu verwenden und MTGProxyPrinter die Verwaltung der Druckränder zu überlassen.
Deaktivieren Sie diese Option, wenn Ihr Drucker die Ausdrucke ständig hoch- oder herunterskaliert.

Wenn deaktiviert, wird die Verwaltung der Seitenränder an den Druckertreiber delegiert.
Dies sollte die Kompatibilität erhöhen, allerdings auf Kosten des Zeichnens kürzerer Hilfslinien.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="69"/>
      <source>Configure printer for borderless printing</source>
      <translation>Drucker auf randloses Drucken einstellen</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="48"/>
      <source>If enabled, print landscape documents in portrait mode with all content rotated by 90°.
Enable this, if printing landscape documents results in portrait printouts with cropped-off sides.</source>
      <translation>Wenn aktiviert, werden Querformat-Dokumente stattdessen im Hochformat mit allen Inhalten um 90° gedreht ausgedruckt.
Aktivieren Sie dies, wenn das Drucken von Querformat-Dokumenten zu Ausdrucken im Hochformat mit abgeschnittenen Seiten führt.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="52"/>
      <source>Enable landscape workaround: Rotate prints by 90°</source>
      <translation>Querformat-Workaround: Drucke um 90° drehen</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="17"/>
      <source>Horizontal printing offset</source>
      <translation>Horizontaler Druckversatz</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="24"/>
      <source>Globally shifts the printing area to correct physical offsets in the printer.
Positive values shift to the right.
Negative offsets shift to the left.</source>
      <translation>Verschiebt den Druckbereich, um einen physikalischen Versatz im Drucker auszugleichen und die Zentrierung zu verbessern.
Positive Werte verschieben nach rechts, negative nach links.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="32"/>
      <source> mm</source>
      <translation> mm</translation>
    </message>
  </context>
  <context>
    <name>PrintingFilterUpdater.store_current_printing_filters()</name>
    <message>
      <location filename="../../printing_filter_updater.py" line="118"/>
      <source>Processing updated card filters:</source>
      <translation>Verarbeite aktualisierte Kartenfilter:</translation>
    </message>
  </context>
  <context>
    <name>SaveDocumentAsDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="134"/>
      <source>Save document as …</source>
      <translation>Dokument speichern unter …</translation>
    </message>
  </context>
  <context>
    <name>SavePDFDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="80"/>
      <source>Export as PDF</source>
      <translation>Als PDF exportieren</translation>
    </message>
    <message>
      <location filename="../../ui/dialogs.py" line="81"/>
      <source>PDF documents (*.pdf)</source>
      <translation>PDF-Dokument (*.pdf)</translation>
    </message>
  </context>
  <context>
    <name>ScryfallCSVParser</name>
    <message>
      <location filename="../../decklist_parser/csv_parsers.py" line="118"/>
      <source>Scryfall CSV export</source>
      <translation>Scryfall CSV-Export</translation>
    </message>
  </context>
  <context>
    <name>SelectDeckParserPage</name>
    <message>
2829
2830
2831
2832
2833
2834
2835













2836
2837
2838
2839
2840
2841
2842
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/select_deck_parser_page.ui" line="317"/>
      <source>Magic Workstation Deck Data (mwDeck)</source>
      <translation>Magic Workstation Deck Data (mwDeck)</translation>
    </message>
  </context>













  <context>
    <name>SettingsWindow</name>
    <message>
      <location filename="../../ui/settings_window.py" line="207"/>
      <source>Apply settings to the current document?</source>
      <translation>Einstellungen auf das aktuelle Dokument anwenden?</translation>
    </message>







>
>
>
>
>
>
>
>
>
>
>
>
>







2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/select_deck_parser_page.ui" line="317"/>
      <source>Magic Workstation Deck Data (mwDeck)</source>
      <translation>Magic Workstation Deck Data (mwDeck)</translation>
    </message>
  </context>
  <context>
    <name>SetEditor</name>
    <message>
      <location filename="../ui/set_editor_widget.ui" line="35"/>
      <source>Set name</source>
      <translation>Setname</translation>
    </message>
    <message>
      <location filename="../ui/set_editor_widget.ui" line="61"/>
      <source>CODE</source>
      <translation>CODE</translation>
    </message>
  </context>
  <context>
    <name>SettingsWindow</name>
    <message>
      <location filename="../../ui/settings_window.py" line="207"/>
      <source>Apply settings to the current document?</source>
      <translation>Einstellungen auf das aktuelle Dokument anwenden?</translation>
    </message>
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946










2947
2948
2949
2950
2951
2952
2953
      <location filename="../ui/settings_window/settings_window.ui" line="17"/>
      <source>Settings</source>
      <translation>Einstellungen</translation>
    </message>
  </context>
  <context>
    <name>SummaryPage</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="452"/>
      <source>Images about to be deleted: {count}</source>
      <translation>Zu löschende Bilder: {count}</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="453"/>
      <source>Disk space that will be freed: {disk_space_freed}</source>
      <translation>Frei werdender Speicherplatz: {disk_space_freed}</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/deck_import_wizard.py" line="470"/>
      <source>Beware: The card list currently contains %n potentially oversized card(s).</source>
      <comment>Warning emitted, if at least 1 card has the oversized flag set. The Scryfall server *may* still return a regular-sized image, so not *all* printings marked as oversized are actually so when fetched.</comment>
      <translation>
        <numerusform>Achtung: Die Deckliste enthält derzeit %n potenziell übergroße Karte.</numerusform>
        <numerusform>Achtung: Die Deckliste enthält derzeit %n potenziell übergroße Karten.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="490"/>
      <source>Replace document content with the identified cards</source>
      <translation>Dokumenteninhalt durch identifizierte Karten ersetzen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="493"/>
      <source>Append identified cards to the document</source>
      <translation>Identifizierte Karten an das Dokument anhängen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="545"/>
      <source>Remove basic lands</source>
      <translation>Standardländer entfernen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="546"/>
      <source>Remove all basic lands in the deck list above</source>
      <translation>Entferne alle Standardländer in der obrigen Deckliste</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="551"/>
      <source>Remove selected</source>
      <translation>Ausgewählte entfernen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="552"/>
      <source>Remove all selected cards in the deck list above</source>
      <translation>Entferne alle ausgewählten Karten in der obrigen Deckliste</translation>
    </message>










    <message>
      <location filename="../ui/cache_cleanup_wizard/summary_page.ui" line="14"/>
      <source>Summary</source>
      <translation>Überblick</translation>
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/parser_result_page.ui" line="14"/>







<
<
<
<
<
<
<
<
<
<

|








|




|




|




|




|




|



>
>
>
>
>
>
>
>
>
>







2962
2963
2964
2965
2966
2967
2968










2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
      <location filename="../ui/settings_window/settings_window.ui" line="17"/>
      <source>Settings</source>
      <translation>Einstellungen</translation>
    </message>
  </context>
  <context>
    <name>SummaryPage</name>










    <message numerus="yes">
      <location filename="../../ui/deck_import_wizard.py" line="474"/>
      <source>Beware: The card list currently contains %n potentially oversized card(s).</source>
      <comment>Warning emitted, if at least 1 card has the oversized flag set. The Scryfall server *may* still return a regular-sized image, so not *all* printings marked as oversized are actually so when fetched.</comment>
      <translation>
        <numerusform>Achtung: Die Deckliste enthält derzeit %n potenziell übergroße Karte.</numerusform>
        <numerusform>Achtung: Die Deckliste enthält derzeit %n potenziell übergroße Karten.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="494"/>
      <source>Replace document content with the identified cards</source>
      <translation>Dokumenteninhalt durch identifizierte Karten ersetzen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="497"/>
      <source>Append identified cards to the document</source>
      <translation>Identifizierte Karten an das Dokument anhängen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="533"/>
      <source>Remove basic lands</source>
      <translation>Standardländer entfernen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="534"/>
      <source>Remove all basic lands in the deck list above</source>
      <translation>Entferne alle Standardländer in der obrigen Deckliste</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="539"/>
      <source>Remove selected</source>
      <translation>Ausgewählte entfernen</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="540"/>
      <source>Remove all selected cards in the deck list above</source>
      <translation>Entferne alle ausgewählten Karten in der obrigen Deckliste</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="437"/>
      <source>Images about to be deleted: {count}</source>
      <translation>Zu löschende Bilder: {count}</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="438"/>
      <source>Disk space that will be freed: {disk_space_freed}</source>
      <translation>Frei werdender Speicherplatz: {disk_space_freed}</translation>
    </message>
    <message>
      <location filename="../ui/cache_cleanup_wizard/summary_page.ui" line="14"/>
      <source>Summary</source>
      <translation>Überblick</translation>
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/parser_result_page.ui" line="14"/>
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="43"/>
      <source>Current page</source>
      <translation>Aktuelle Seite</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="92"/>
      <source>Remove selected</source>
      <translation>Ausgewählte entfernen</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="103"/>
      <source>Preview</source>
      <translation>Vorschau</translation>
    </message>
  </context>
  <context>
    <name>TappedOutCSVParser</name>
    <message>
      <location filename="../../decklist_parser/csv_parsers.py" line="196"/>
      <source>Tappedout CSV export</source>
      <translation>Tappedout CSV-Export</translation>
    </message>
  </context>
  <context>
    <name>UnknownCardImageModel</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="270"/>
      <source>Scryfall ID</source>
      <translation>Scryfall ID</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="271"/>
      <source>Front/Back</source>
      <translation>Vorder-/Rückseite</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="272"/>
      <source>High resolution?</source>
      <translation>Hohe Qualität?</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="273"/>
      <source>Size</source>
      <translation>Größe</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="274"/>
      <source>Path</source>
      <translation>Dateipfad</translation>
    </message>
  </context>
  <context>
    <name>UnknownCardRow</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="244"/>
      <source>Front</source>
      <translation>Vorderseite</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="244"/>
      <source>Back</source>
      <translation>Rückseite</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="250"/>
      <source>Yes</source>
      <translation>Ja</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="250"/>
      <source>No</source>
      <translation>Nein</translation>
    </message>
  </context>
  <context>
    <name>VerticalAddCardWidget</name>
    <message>







|




|







|







|




|




|




|




|







|




|




|




|







3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="43"/>
      <source>Current page</source>
      <translation>Aktuelle Seite</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="89"/>
      <source>Remove selected</source>
      <translation>Ausgewählte entfernen</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="100"/>
      <source>Preview</source>
      <translation>Vorschau</translation>
    </message>
  </context>
  <context>
    <name>TappedOutCSVParser</name>
    <message>
      <location filename="../../decklist_parser/csv_parsers.py" line="197"/>
      <source>Tappedout CSV export</source>
      <translation>Tappedout CSV-Export</translation>
    </message>
  </context>
  <context>
    <name>UnknownCardImageModel</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="255"/>
      <source>Scryfall ID</source>
      <translation>Scryfall ID</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="256"/>
      <source>Front/Back</source>
      <translation>Vorder-/Rückseite</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="257"/>
      <source>High resolution?</source>
      <translation>Hohe Qualität?</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="258"/>
      <source>Size</source>
      <translation>Größe</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="259"/>
      <source>Path</source>
      <translation>Dateipfad</translation>
    </message>
  </context>
  <context>
    <name>UnknownCardRow</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="229"/>
      <source>Front</source>
      <translation>Vorderseite</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="229"/>
      <source>Back</source>
      <translation>Rückseite</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="235"/>
      <source>Yes</source>
      <translation>Ja</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="235"/>
      <source>No</source>
      <translation>Nein</translation>
    </message>
  </context>
  <context>
    <name>VerticalAddCardWidget</name>
    <message>
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
      <source>Copies:</source>
      <translation>Kopien:</translation>
    </message>
  </context>
  <context>
    <name>XMageParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="256"/>
      <source>XMage Deck file</source>
      <translation>XMage Deck-Datei</translation>
    </message>
  </context>
  <context>
    <name>format_size</name>
    <message>
      <location filename="../../ui/common.py" line="127"/>
      <source>{size} {unit}</source>
      <comment>A formatted file size in SI bytes</comment>
      <translation>{size} {unit}</translation>
    </message>
  </context>
</TS>







|







|






3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
      <source>Copies:</source>
      <translation>Kopien:</translation>
    </message>
  </context>
  <context>
    <name>XMageParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="257"/>
      <source>XMage Deck file</source>
      <translation>XMage Deck-Datei</translation>
    </message>
  </context>
  <context>
    <name>format_size</name>
    <message>
      <location filename="../../ui/common.py" line="138"/>
      <source>{size} {unit}</source>
      <comment>A formatted file size in SI bytes</comment>
      <translation>{size} {unit}</translation>
    </message>
  </context>
</TS>
Changes to mtg_proxy_printer/resources/translations/mtgproxyprinter_en-US.ts.
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117









118
119
120
121
122
123
124
      <source>Third party licenses</source>
      <translation>Third Party licenses</translation>
    </message>
  </context>
  <context>
    <name>ActionAddCard</name>
    <message numerus="yes">
      <location filename="../../document_controller/card_actions.py" line="159"/>
      <source>Add {count} × {card_display_string} to page {target}</source>
      <comment>Undo/redo tooltip text. Plural form refers to {target}, not {count}. {target} can be multiple ranges of multiple pages each</comment>
      <translation>
        <numerusform>Add {count} × {card_display_string} to page {target}</numerusform>
        <numerusform>Add {count} × {card_display_string} to pages {target}</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionCompactDocument</name>
    <message numerus="yes">
      <location filename="../../document_controller/compact_document.py" line="109"/>
      <source>Compact document, removing %n page(s)</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>
        <numerusform>Compact document, removing %n page</numerusform>
        <numerusform>Compact document, removing %n pages</numerusform>
      </translation>
    </message>
  </context>









  <context>
    <name>ActionEditDocumentSettings</name>
    <message>
      <location filename="../../document_controller/edit_document_settings.py" line="133"/>
      <source>Update document settings</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Update document settings</translation>







|




















>
>
>
>
>
>
>
>
>







90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
      <source>Third party licenses</source>
      <translation>Third Party licenses</translation>
    </message>
  </context>
  <context>
    <name>ActionAddCard</name>
    <message numerus="yes">
      <location filename="../../document_controller/card_actions.py" line="161"/>
      <source>Add {count} × {card_display_string} to page {target}</source>
      <comment>Undo/redo tooltip text. Plural form refers to {target}, not {count}. {target} can be multiple ranges of multiple pages each</comment>
      <translation>
        <numerusform>Add {count} × {card_display_string} to page {target}</numerusform>
        <numerusform>Add {count} × {card_display_string} to pages {target}</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionCompactDocument</name>
    <message numerus="yes">
      <location filename="../../document_controller/compact_document.py" line="109"/>
      <source>Compact document, removing %n page(s)</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>
        <numerusform>Compact document, removing %n page</numerusform>
        <numerusform>Compact document, removing %n pages</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionEditCustomCard</name>
    <message>
      <location filename="../../document_controller/edit_custom_card.py" line="85"/>
      <source>Edit custom card, set {column_header_text} to {new_value}</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Edit custom card, set {column_header_text} to {new_value}</translation>
    </message>
  </context>
  <context>
    <name>ActionEditDocumentSettings</name>
    <message>
      <location filename="../../document_controller/edit_document_settings.py" line="133"/>
      <source>Update document settings</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Update document settings</translation>
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
        <numerusform>Replace document with imported deck list containing %n cards</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionLoadDocument</name>
    <message numerus="yes">
      <location filename="../../document_controller/load_document.py" line="76"/>
      <source>Load document from &apos;{save_path}&apos;,
containing %n page(s) {cards_total}</source>
      <comment>Undo/redo tooltip text.</comment>
      <translation>
        <numerusform>Load document from &apos;{save_path}&apos;,
containing %n page {cards_total}</numerusform>
        <numerusform>Load document from &apos;{save_path}&apos;,
containing %n pages {cards_total}</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionLoadDocument. Card total</name>
    <message numerus="yes">
      <location filename="../../document_controller/load_document.py" line="72"/>
      <source>with %n card(s) total</source>
      <comment>Undo/redo tooltip text. Will be inserted as {cards_total}</comment>
      <translation>
        <numerusform>with %n card total</numerusform>
        <numerusform>with %n cards total</numerusform>
      </translation>
    </message>







|














|







153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
        <numerusform>Replace document with imported deck list containing %n cards</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionLoadDocument</name>
    <message numerus="yes">
      <location filename="../../document_controller/load_document.py" line="77"/>
      <source>Load document from &apos;{save_path}&apos;,
containing %n page(s) {cards_total}</source>
      <comment>Undo/redo tooltip text.</comment>
      <translation>
        <numerusform>Load document from &apos;{save_path}&apos;,
containing %n page {cards_total}</numerusform>
        <numerusform>Load document from &apos;{save_path}&apos;,
containing %n pages {cards_total}</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionLoadDocument. Card total</name>
    <message numerus="yes">
      <location filename="../../document_controller/load_document.py" line="73"/>
      <source>with %n card(s) total</source>
      <comment>Undo/redo tooltip text. Will be inserted as {cards_total}</comment>
      <translation>
        <numerusform>with %n card total</numerusform>
        <numerusform>with %n cards total</numerusform>
      </translation>
    </message>
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
        <numerusform>Add pages {pages}</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionRemoveCards</name>
    <message numerus="yes">
      <location filename="../../document_controller/card_actions.py" line="217"/>
      <source>Remove %n card(s) from page {page_number}</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>
        <numerusform>Remove %n card from page {page_number}</numerusform>
        <numerusform>Remove %n cards from page {page_number}</numerusform>
      </translation>
    </message>







|







213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
        <numerusform>Add pages {pages}</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionRemoveCards</name>
    <message numerus="yes">
      <location filename="../../document_controller/card_actions.py" line="219"/>
      <source>Remove %n card(s) from page {page_number}</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>
        <numerusform>Remove %n card from page {page_number}</numerusform>
        <numerusform>Remove %n cards from page {page_number}</numerusform>
      </translation>
    </message>
237
238
239
240
241
242
243
244
245
246
247
248
249








250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
        <numerusform>Remove pages {formatted_pages} containing {formatted_card_count}</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionReplaceCard</name>
    <message>
      <location filename="../../document_controller/replace_card.py" line="98"/>
      <source>Replace card {old_card} on page {page_number} with {new_card}</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Replace card {old_card} on page {page_number} with {new_card}</translation>
    </message>
  </context>








  <context>
    <name>ActionShuffleDocument</name>
    <message>
      <location filename="../../document_controller/shuffle_document.py" line="102"/>
      <source>Shuffle document</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Shuffle document</translation>
    </message>
  </context>
  <context>
    <name>CacheCleanupWizard</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="472"/>
      <source>Cleanup locally stored card images</source>
      <comment>Dialog window title</comment>
      <translation>Cleanup locally stored card images</translation>
    </message>
  </context>
  <context>
    <name>CardFilterPage</name>







|





>
>
>
>
>
>
>
>












|







246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
        <numerusform>Remove pages {formatted_pages} containing {formatted_card_count}</numerusform>
      </translation>
    </message>
  </context>
  <context>
    <name>ActionReplaceCard</name>
    <message>
      <location filename="../../document_controller/replace_card.py" line="99"/>
      <source>Replace card {old_card} on page {page_number} with {new_card}</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Replace card {old_card} on page {page_number} with {new_card}</translation>
    </message>
  </context>
  <context>
    <name>ActionSaveDocument</name>
    <message>
      <location filename="../../document_controller/save_document.py" line="172"/>
      <source>Save document to &apos;{save_file_path}&apos;.</source>
      <translation>Save document to &apos;{save_file_path}&apos;.</translation>
    </message>
  </context>
  <context>
    <name>ActionShuffleDocument</name>
    <message>
      <location filename="../../document_controller/shuffle_document.py" line="102"/>
      <source>Shuffle document</source>
      <comment>Undo/redo tooltip text</comment>
      <translation>Shuffle document</translation>
    </message>
  </context>
  <context>
    <name>CacheCleanupWizard</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="457"/>
      <source>Cleanup locally stored card images</source>
      <comment>Dialog window title</comment>
      <translation>Cleanup locally stored card images</translation>
    </message>
  </context>
  <context>
    <name>CardFilterPage</name>
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420























421
422
423
424
425
426
427
428
429
430
431
432
433
434
      <source>Unknown images:</source>
      <translation>Unknown images:</translation>
    </message>
  </context>
  <context>
    <name>CardListModel</name>
    <message>
      <location filename="../../model/card_list.py" line="69"/>
      <source>Copies</source>
      <translation>Copies</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="70"/>
      <source>Card name</source>
      <translation>Card name</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="71"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="72"/>
      <source>Collector #</source>
      <translation>Collector #</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="73"/>
      <source>Language</source>
      <translation>Language</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="74"/>
      <source>Side</source>
      <translation>Side</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="109"/>
      <source>Front</source>
      <translation>Front</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="109"/>
      <source>Back</source>
      <translation>Back</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="112"/>
      <source>Beware: Potentially oversized card!
This card may not fit in your deck.</source>
      <translation>Beware: Potentially oversized card!
This card may not fit in your deck.</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="259"/>
      <source>Double-click on entries to
switch the selected printing.</source>
      <translation>Double-click on entries to
switch the selected printing.</translation>
    </message>
  </context>
  <context>
    <name>CentralWidget</name>
    <message numerus="yes">
      <location filename="../../ui/central_widget.py" line="154"/>
      <source>Add %n copies</source>
      <comment>Context menu action: Add additional card copies to the document</comment>
      <translation>
        <numerusform>Add %n copy</numerusform>
        <numerusform>Add %n copies</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="160"/>
      <source>Add copies …</source>
      <comment>Context menu action: Add additional card copies to the document. User will be asked for a number</comment>
      <translation>Add copies …</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="166"/>
      <source>Generate DFC check card</source>
      <translation>Generate DFC check card</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="170"/>
      <source>All related cards</source>
      <translation>All related cards</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="178"/>
      <source>Add copies</source>
      <translation>Add copies</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="178"/>
      <source>Add copies of {card_name}</source>
      <comment>Asks the user for a number. Does not need plural forms</comment>
      <translation>Add copies of {card_name}</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="204"/>
      <source>Export image</source>
      <translation>Export image</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="219"/>
      <source>Save card image</source>
      <translation>Save card image</translation>
    </message>
    <message>
      <location filename="../../ui/central_widget.py" line="219"/>
      <source>Images (*.png *.bmp *.jpg)</source>
      <translation>Images (*.png *.bmp *.jpg)</translation>
    </message>
  </context>
  <context>
    <name>ColumnarCentralWidget</name>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="64"/>
      <source>All pages:</source>
      <translation>All pages:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="71"/>
      <source>Current page:</source>
      <translation>Current page:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="81"/>























      <source>Remove selected</source>
      <translation>Remove selected</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="91"/>
      <source>Add new cards:</source>
      <translation>Add new cards:</translation>
    </message>
  </context>
  <context>
    <name>DatabaseImportWorker</name>
    <message>
      <location filename="../../card_info_downloader.py" line="424"/>
      <source>Error during import from file:







|
<
<
<
<
<




|




|




|




|




|




|




|






|





<
<
<
|
|
|
<
|
<
<
<

<
<
<
<
<
|
|
<
<
<
<
<
<
<
|
<

|
|
|


|
<
<
<
<
<
<
|
|
<
<
<
<
<
<
<
<
<
<





|




|




|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>




|
|
|







309
310
311
312
313
314
315
316





317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363



364
365
366

367



368





369
370







371

372
373
374
375
376
377
378






379
380










381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
      <source>Unknown images:</source>
      <translation>Unknown images:</translation>
    </message>
  </context>
  <context>
    <name>CardListModel</name>
    <message>
      <location filename="../../model/card_list.py" line="87"/>





      <source>Card name</source>
      <translation>Card name</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="88"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="89"/>
      <source>Collector #</source>
      <translation>Collector #</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="90"/>
      <source>Language</source>
      <translation>Language</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="91"/>
      <source>Side</source>
      <translation>Side</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="128"/>
      <source>Front</source>
      <translation>Front</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="128"/>
      <source>Back</source>
      <translation>Back</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="132"/>
      <source>Beware: Potentially oversized card!
This card may not fit in your deck.</source>
      <translation>Beware: Potentially oversized card!
This card may not fit in your deck.</translation>
    </message>
    <message>
      <location filename="../../model/card_list.py" line="322"/>
      <source>Double-click on entries to
switch the selected printing.</source>
      <translation>Double-click on entries to
switch the selected printing.</translation>
    </message>



    <message>
      <location filename="../../model/card_list.py" line="86"/>
      <source>Copies</source>

      <translation>Copies</translation>



    </message>





  </context>
  <context>







    <name>CardSideSelectionDelegate</name>

    <message>
      <location filename="../../ui/item_delegates.py" line="72"/>
      <source>Front</source>
      <translation>Front</translation>
    </message>
    <message>
      <location filename="../../ui/item_delegates.py" line="73"/>






      <source>Back</source>
      <translation>Back</translation>










    </message>
  </context>
  <context>
    <name>ColumnarCentralWidget</name>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="61"/>
      <source>All pages:</source>
      <translation>All pages:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="68"/>
      <source>Current page:</source>
      <translation>Current page:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="78"/>
      <source>Remove selected</source>
      <translation>Remove selected</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/columnar.ui" line="88"/>
      <source>Add new cards:</source>
      <translation>Add new cards:</translation>
    </message>
  </context>
  <context>
    <name>CustomCardImportDialog</name>
    <message>
      <location filename="../ui/custom_card_import_dialog.ui" line="14"/>
      <source>Import custom cards</source>
      <translation>Import custom cards</translation>
    </message>
    <message>
      <location filename="../ui/custom_card_import_dialog.ui" line="20"/>
      <source>Set Copies to …</source>
      <translation>Set Copies to …</translation>
    </message>
    <message>
      <location filename="../ui/custom_card_import_dialog.ui" line="40"/>
      <source>Remove selected</source>
      <translation>Remove selected</translation>
    </message>
    <message>
      <location filename="../ui/custom_card_import_dialog.ui" line="50"/>
      <source>Load images</source>
      <translation>Load images</translation>
    </message>
  </context>
  <context>
    <name>DatabaseImportWorker</name>
    <message>
      <location filename="../../card_info_downloader.py" line="424"/>
      <source>Error during import from file:
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
      <source>Open the Cutelog homepage</source>
      <translation>Open the Cutelog homepage</translation>
    </message>
  </context>
  <context>
    <name>DeckImportWizard</name>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="620"/>
      <source>Import a deck list</source>
      <translation>Import a deck list</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="641"/>
      <source>Oversized cards present</source>
      <translation>Oversized cards present</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/deck_import_wizard.py" line="641"/>
      <source>There are %n possibly oversized cards in the deck list that may not fit into a deck, when printed out.

Continue and use these cards as-is?</source>
      <translation>
        <numerusform>There is %n possibly oversized card in the deck list that may not fit into a deck, when printed out.

Continue and use the card as-is?</numerusform>
        <numerusform>There are %n possibly oversized cards in the deck list that may not fit into a deck, when printed out.

Continue and use these cards as-is?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="652"/>
      <source>Incompatible file selected</source>
      <translation>Incompatible file selected</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="652"/>
      <source>Unable to parse the given deck list, no results were obtained.
Maybe you selected the wrong deck list type?</source>
      <translation>Unable to parse the given deck list, no results were obtained.
Maybe you selected the wrong deck list type?</translation>
    </message>
  </context>
  <context>







|




|




|













|




|







586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
      <source>Open the Cutelog homepage</source>
      <translation>Open the Cutelog homepage</translation>
    </message>
  </context>
  <context>
    <name>DeckImportWizard</name>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="606"/>
      <source>Import a deck list</source>
      <translation>Import a deck list</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="628"/>
      <source>Oversized cards present</source>
      <translation>Oversized cards present</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/deck_import_wizard.py" line="628"/>
      <source>There are %n possibly oversized cards in the deck list that may not fit into a deck, when printed out.

Continue and use these cards as-is?</source>
      <translation>
        <numerusform>There is %n possibly oversized card in the deck list that may not fit into a deck, when printed out.

Continue and use the card as-is?</numerusform>
        <numerusform>There are %n possibly oversized cards in the deck list that may not fit into a deck, when printed out.

Continue and use these cards as-is?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="639"/>
      <source>Incompatible file selected</source>
      <translation>Incompatible file selected</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="639"/>
      <source>Unable to parse the given deck list, no results were obtained.
Maybe you selected the wrong deck list type?</source>
      <translation>Unable to parse the given deck list, no results were obtained.
Maybe you selected the wrong deck list type?</translation>
    </message>
  </context>
  <context>
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
      <source>Default settings for new documents</source>
      <translation>Default settings for new documents</translation>
    </message>
  </context>
  <context>
    <name>Document</name>
    <message>
      <location filename="../../model/document.py" line="99"/>
      <source>Card name</source>
      <translation>Card name</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="100"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="101"/>
      <source>Collector #</source>
      <translation>Collector #</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="102"/>
      <source>Language</source>
      <translation>Language</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="103"/>
      <source>Image</source>
      <translation>Image</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="104"/>
      <source>Side</source>
      <translation>Side</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="182"/>
      <source>Double-click on entries to
switch the selected printing.</source>
      <translation>Double-click on entries to
switch the selected printing.</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="292"/>
      <source>Page {current}/{total}</source>
      <translation>Page {current}/{total}</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="322"/>
      <source>Front</source>
      <translation>Front</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="322"/>
      <source>Back</source>
      <translation>Back</translation>
    </message>
    <message numerus="yes">
      <location filename="../../model/document.py" line="327"/>
      <source>%n× {name}</source>
      <comment>Used to display a card name and amount of copies in the page overview. Only needs translation for RTL language support</comment>
      <translation>
        <numerusform>%n× {name}</numerusform>
        <numerusform>%n× {name}</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="415"/>
      <source>Empty Placeholder</source>
      <translation>Empty Placeholder</translation>
    </message>
  </context>
  <context>
    <name>DocumentAction</name>
    <message>
      <location filename="../../document_controller/_interface.py" line="105"/>
      <source>{first}-{last}</source>
      <comment>Inclusive, formatted number range, from first to last</comment>
      <translation>{first}-{last}</translation>
    </message>
  </context>
  <context>
    <name>DocumentSettingsDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="322"/>
      <source>These settings only affect the current document</source>
      <translation>These settings only affect the current document</translation>
    </message>
    <message>
      <location filename="../ui/document_settings_dialog.ui" line="6"/>
      <source>Set Document settings</source>
      <translation>Set Document settings</translation>







|




|




|




|




|




|




|






|




|




|




|








|
















|







782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
      <source>Default settings for new documents</source>
      <translation>Default settings for new documents</translation>
    </message>
  </context>
  <context>
    <name>Document</name>
    <message>
      <location filename="../../model/document.py" line="91"/>
      <source>Card name</source>
      <translation>Card name</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="92"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="93"/>
      <source>Collector #</source>
      <translation>Collector #</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="94"/>
      <source>Language</source>
      <translation>Language</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="95"/>
      <source>Image</source>
      <translation>Image</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="96"/>
      <source>Side</source>
      <translation>Side</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="174"/>
      <source>Double-click on entries to
switch the selected printing.</source>
      <translation>Double-click on entries to
switch the selected printing.</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="287"/>
      <source>Page {current}/{total}</source>
      <translation>Page {current}/{total}</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="317"/>
      <source>Front</source>
      <translation>Front</translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="317"/>
      <source>Back</source>
      <translation>Back</translation>
    </message>
    <message numerus="yes">
      <location filename="../../model/document.py" line="322"/>
      <source>%n× {name}</source>
      <comment>Used to display a card name and amount of copies in the page overview. Only needs translation for RTL language support</comment>
      <translation>
        <numerusform>%n× {name}</numerusform>
        <numerusform>%n× {name}</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../model/document.py" line="379"/>
      <source>Empty Placeholder</source>
      <translation>Empty Placeholder</translation>
    </message>
  </context>
  <context>
    <name>DocumentAction</name>
    <message>
      <location filename="../../document_controller/_interface.py" line="105"/>
      <source>{first}-{last}</source>
      <comment>Inclusive, formatted number range, from first to last</comment>
      <translation>{first}-{last}</translation>
    </message>
  </context>
  <context>
    <name>DocumentSettingsDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="323"/>
      <source>These settings only affect the current document</source>
      <translation>These settings only affect the current document</translation>
    </message>
    <message>
      <location filename="../ui/document_settings_dialog.ui" line="6"/>
      <source>Set Document settings</source>
      <translation>Set Document settings</translation>
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="51"/>
      <source>Application language</source>
      <translation>Application language</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="67"/>
      <source>Language choices will default to the chosen language here.
Entries use the language codes as listed on Scryfall.

Note: Cards in deck lists use the language as given by the deck list. To overwrite, use the deck list translation option.</source>
      <translation>Language choices will default to the chosen language here.
Entries use the language codes as listed on Scryfall.

Note: Cards in deck lists use the language as given by the deck list. To overwrite, use the deck list translation option.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="77"/>
      <source>Double-faced cards</source>
      <translation>Double-faced cards</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="83"/>
      <source>When adding double-faced cards, automatically add the same number of copies of the other side.
Uses the appropriate, matching other card side.
Uncheck to disable this automatism.</source>
      <translation>When adding double-faced cards, automatically add the same number of copies of the other side.
Uses the appropriate, matching other card side.
Uncheck to disable this automatism.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="88"/>
      <source>Automatically add the other side of double-faced cards</source>
      <translation>Automatically add the other side of double-faced cards</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="98"/>
      <source>Card language selected at application start and default language when enabling deck list translations</source>
      <translation>Card language selected at application start and default language when enabling deck list translations</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="101"/>
      <source>Preferred card language:</source>
      <translation>Preferred card language:</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="114"/>
      <source>Automatic update checks</source>







<
<
<
<
<
<
<
<
<
<
<



















<
<
<
<
<







1229
1230
1231
1232
1233
1234
1235











1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254





1255
1256
1257
1258
1259
1260
1261
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="51"/>
      <source>Application language</source>
      <translation>Application language</translation>
    </message>
    <message>











      <location filename="../ui/settings_window/general_settings_page.ui" line="77"/>
      <source>Double-faced cards</source>
      <translation>Double-faced cards</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="83"/>
      <source>When adding double-faced cards, automatically add the same number of copies of the other side.
Uses the appropriate, matching other card side.
Uncheck to disable this automatism.</source>
      <translation>When adding double-faced cards, automatically add the same number of copies of the other side.
Uses the appropriate, matching other card side.
Uncheck to disable this automatism.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="88"/>
      <source>Automatically add the other side of double-faced cards</source>
      <translation>Automatically add the other side of double-faced cards</translation>
    </message>
    <message>





      <location filename="../ui/settings_window/general_settings_page.ui" line="101"/>
      <source>Preferred card language:</source>
      <translation>Preferred card language:</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="114"/>
      <source>Automatic update checks</source>
1329
1330
1331
1332
1333
1334
1335
















1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
      <translation>If set, use this as the default location for saving documents.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="195"/>
      <source>Path to a directory</source>
      <translation>Path to a directory</translation>
    </message>
















  </context>
  <context>
    <name>GroupedCentralWidget</name>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="58"/>
      <source>Remove selected</source>
      <translation>Remove selected</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="106"/>
      <source>All pages:</source>
      <translation>All pages:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="113"/>
      <source>Add new cards:</source>
      <translation>Add new cards:</translation>
    </message>
  </context>
  <context>
    <name>HidePrintingsPage</name>
    <message>







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>









|




|







1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
      <translation>If set, use this as the default location for saving documents.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="195"/>
      <source>Path to a directory</source>
      <translation>Path to a directory</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="67"/>
      <source>Language choices will default to the chosen language here.
Entries use the language codes as listed on Scryfall.

Note: Cards in deck lists use the language as given by the deck list. To overwrite, use the deck list translation option.</source>
      <translation>Language choices will default to the chosen language here.
Entries use the language codes as listed on Scryfall.

Note: Cards in deck lists use the language as given by the deck list. To overwrite, use the deck list translation option.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/general_settings_page.ui" line="98"/>
      <source>Card language selected at application start and default language when enabling deck list translations</source>
      <translation>Card language selected at application start and default language when enabling deck list translations</translation>
    </message>
  </context>
  <context>
    <name>GroupedCentralWidget</name>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="58"/>
      <source>Remove selected</source>
      <translation>Remove selected</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="103"/>
      <source>All pages:</source>
      <translation>All pages:</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/grouped.ui" line="110"/>
      <source>Add new cards:</source>
      <translation>Add new cards:</translation>
    </message>
  </context>
  <context>
    <name>HidePrintingsPage</name>
    <message>
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
      <source>Copies:</source>
      <translation>Copies:</translation>
    </message>
  </context>
  <context>
    <name>ImageDownloader</name>
    <message>
      <location filename="../../model/imagedb.py" line="338"/>
      <source>Importing deck list</source>
      <comment>Progress bar label text</comment>
      <translation>Importing deck list</translation>
    </message>
    <message>
      <location filename="../../model/imagedb.py" line="358"/>
      <source>Fetching missing images</source>
      <comment>Progress bar label text</comment>
      <translation>Fetching missing images</translation>
    </message>
    <message>
      <location filename="../../model/imagedb.py" line="453"/>
      <source>Downloading &apos;{card_name}&apos;</source>
      <comment>Progress bar label text</comment>
      <translation>Downloading &apos;{card_name}&apos;</translation>
    </message>
  </context>
  <context>
    <name>KnownCardImageModel</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="147"/>
      <source>Name</source>
      <translation>Name</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="148"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="149"/>
      <source>Collector #</source>
      <translation>Collector #</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="150"/>
      <source>Is Hidden</source>
      <translation>Is Hidden</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="151"/>
      <source>Front/Back</source>
      <translation>Front/Back</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="152"/>
      <source>High resolution?</source>
      <translation>High resolution?</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="153"/>
      <source>Size</source>
      <translation>Size</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="154"/>
      <source>Scryfall ID</source>
      <translation>Scryfall ID</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="155"/>
      <source>Path</source>
      <translation>Path</translation>
    </message>
  </context>
  <context>
    <name>KnownCardRow</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="126"/>
      <source>Yes</source>
      <translation>Yes</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="126"/>
      <source>No</source>
      <translation>No</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="114"/>
      <source>This printing is hidden by an enabled card filter
and is thus unavailable for printing.</source>
      <comment>Tooltip for cells with hidden cards</comment>
      <translation>This printing is hidden by an enabled card filter
and is thus unavailable for printing.</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="120"/>
      <source>Front</source>
      <comment>Card side</comment>
      <translation>Front</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="120"/>
      <source>Back</source>
      <comment>Card side</comment>
      <translation>Back</translation>
    </message>
  </context>
  <context>
    <name>LoadDocumentDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="163"/>
      <source>Load MTGProxyPrinter document</source>
      <translation>Load MTGProxyPrinter document</translation>
    </message>
  </context>
  <context>
    <name>LoadListPage</name>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="121"/>
      <source>Supported websites:
{supported_sites}</source>
      <translation>Supported websites:
{supported_sites}</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="217"/>
      <source>Overwrite existing deck list?</source>
      <translation>Overwrite existing deck list?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="171"/>
      <source>Selecting a file will overwrite the existing deck list. Continue?</source>
      <translation>Selecting a file will overwrite the existing deck list. Continue?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="179"/>
      <source>Select deck file</source>
      <translation>Select deck file</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="189"/>
      <source>All files (*)</source>
      <translation>All files (*)</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="200"/>
      <source>All Supported </source>
      <translation>All Supported </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="217"/>
      <source>Downloading a deck list will overwrite the existing deck list. Continue?</source>
      <translation>Downloading a deck list will overwrite the existing deck list. Continue?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="230"/>
      <source>Download failed with HTTP error {http_error_code}.

{bad_request_msg}</source>
      <translation>Download failed with HTTP error {http_error_code}.

{bad_request_msg}</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="241"/>
      <source>Deck list download failed</source>
      <translation>Deck list download failed</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="236"/>
      <source>Download failed.

Check your internet connection, verify that the URL is valid, reachable, and that the deck list is set to public. This program cannot download private deck lists. If this persists, please report a bug in the issue tracker on the homepage.</source>
      <translation>Download failed.

Check your internet connection, verify that the URL is valid, reachable, and that the deck list is set to public. This program cannot download private deck lists. If this persists, please report a bug in the issue tracker on the homepage.</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="267"/>
      <source>Unable to read file content</source>
      <translation>Unable to read file content</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="267"/>
      <source>Unable to read the content of file {file_path} as plain text.
Failed to load the content.</source>
      <translation>Unable to read the content of file {file_path} as plain text.
Failed to load the content.</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="279"/>
      <source>Load large file?</source>
      <translation>Load large file?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="279"/>
      <source>The selected file {file_path} is unexpectedly large ({formatted_size}). Load anyway?</source>
      <translation>The selected file {file_path} is unexpectedly large ({formatted_size}). Load anyway?</translation>
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/load_list_page.ui" line="17"/>
      <source>Import a deck list for printing</source>
      <translation>Import a deck list for printing</translation>







|





|





|








|




|




|




|




|




|




|




|




|







|




|




|







|





|








|







|






|




|




|




|




|




|




|








|




|








|




|






|




|







1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
      <source>Copies:</source>
      <translation>Copies:</translation>
    </message>
  </context>
  <context>
    <name>ImageDownloader</name>
    <message>
      <location filename="../../model/imagedb.py" line="309"/>
      <source>Importing deck list</source>
      <comment>Progress bar label text</comment>
      <translation>Importing deck list</translation>
    </message>
    <message>
      <location filename="../../model/imagedb.py" line="329"/>
      <source>Fetching missing images</source>
      <comment>Progress bar label text</comment>
      <translation>Fetching missing images</translation>
    </message>
    <message>
      <location filename="../../model/imagedb.py" line="424"/>
      <source>Downloading &apos;{card_name}&apos;</source>
      <comment>Progress bar label text</comment>
      <translation>Downloading &apos;{card_name}&apos;</translation>
    </message>
  </context>
  <context>
    <name>KnownCardImageModel</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="132"/>
      <source>Name</source>
      <translation>Name</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="133"/>
      <source>Set</source>
      <translation>Set</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="134"/>
      <source>Collector #</source>
      <translation>Collector #</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="135"/>
      <source>Is Hidden</source>
      <translation>Is Hidden</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="136"/>
      <source>Front/Back</source>
      <translation>Front/Back</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="137"/>
      <source>High resolution?</source>
      <translation>High resolution?</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="138"/>
      <source>Size</source>
      <translation>Size</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="139"/>
      <source>Scryfall ID</source>
      <translation>Scryfall ID</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="140"/>
      <source>Path</source>
      <translation>Path</translation>
    </message>
  </context>
  <context>
    <name>KnownCardRow</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="111"/>
      <source>Yes</source>
      <translation>Yes</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="111"/>
      <source>No</source>
      <translation>No</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="99"/>
      <source>This printing is hidden by an enabled card filter
and is thus unavailable for printing.</source>
      <comment>Tooltip for cells with hidden cards</comment>
      <translation>This printing is hidden by an enabled card filter
and is thus unavailable for printing.</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="105"/>
      <source>Front</source>
      <comment>Card side</comment>
      <translation>Front</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="105"/>
      <source>Back</source>
      <comment>Card side</comment>
      <translation>Back</translation>
    </message>
  </context>
  <context>
    <name>LoadDocumentDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="164"/>
      <source>Load MTGProxyPrinter document</source>
      <translation>Load MTGProxyPrinter document</translation>
    </message>
  </context>
  <context>
    <name>LoadListPage</name>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="120"/>
      <source>Supported websites:
{supported_sites}</source>
      <translation>Supported websites:
{supported_sites}</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="216"/>
      <source>Overwrite existing deck list?</source>
      <translation>Overwrite existing deck list?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="170"/>
      <source>Selecting a file will overwrite the existing deck list. Continue?</source>
      <translation>Selecting a file will overwrite the existing deck list. Continue?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="178"/>
      <source>Select deck file</source>
      <translation>Select deck file</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="188"/>
      <source>All files (*)</source>
      <translation>All files (*)</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="199"/>
      <source>All Supported </source>
      <translation>All Supported </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="216"/>
      <source>Downloading a deck list will overwrite the existing deck list. Continue?</source>
      <translation>Downloading a deck list will overwrite the existing deck list. Continue?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="229"/>
      <source>Download failed with HTTP error {http_error_code}.

{bad_request_msg}</source>
      <translation>Download failed with HTTP error {http_error_code}.

{bad_request_msg}</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="240"/>
      <source>Deck list download failed</source>
      <translation>Deck list download failed</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="235"/>
      <source>Download failed.

Check your internet connection, verify that the URL is valid, reachable, and that the deck list is set to public. This program cannot download private deck lists. If this persists, please report a bug in the issue tracker on the homepage.</source>
      <translation>Download failed.

Check your internet connection, verify that the URL is valid, reachable, and that the deck list is set to public. This program cannot download private deck lists. If this persists, please report a bug in the issue tracker on the homepage.</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="266"/>
      <source>Unable to read file content</source>
      <translation>Unable to read file content</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="266"/>
      <source>Unable to read the content of file {file_path} as plain text.
Failed to load the content.</source>
      <translation>Unable to read the content of file {file_path} as plain text.
Failed to load the content.</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="278"/>
      <source>Load large file?</source>
      <translation>Load large file?</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="278"/>
      <source>The selected file {file_path} is unexpectedly large ({formatted_size}). Load anyway?</source>
      <translation>The selected file {file_path} is unexpectedly large ({formatted_size}). Load anyway?</translation>
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/load_list_page.ui" line="17"/>
      <source>Import a deck list for printing</source>
      <translation>Import a deck list for printing</translation>
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157





2158
2159
2160
2161
2162
2163
2164
2165





2166
2167
2168
2169
2170
2171
2172
      <source>Download deck list</source>
      <translation>Download deck list</translation>
    </message>
  </context>
  <context>
    <name>LoadSaveDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="120"/>
      <source>MTGProxyPrinter document (*.{default_save_suffix})</source>
      <comment>Human-readable file type name</comment>
      <translation>MTGProxyPrinter document (*.{default_save_suffix})</translation>
    </message>
  </context>
  <context>
    <name>MTGArenaParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="200"/>
      <source>Magic Arena deck file</source>
      <translation>Magic Arena deck file</translation>
    </message>
  </context>
  <context>
    <name>MTGOnlineParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="234"/>
      <source>Magic Online (MTGO) deck file</source>
      <translation>Magic Online (MTGO) deck file</translation>
    </message>
  </context>
  <context>
    <name>MagicWorkstationDeckDataFormatParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="178"/>
      <source>Magic Workstation Deck Data Format</source>
      <translation>Magic Workstation Deck Data Format</translation>
    </message>
  </context>
  <context>
    <name>MainWindow</name>
    <message>
      <location filename="../../ui/main_window.py" line="219"/>
      <source>Undo:
{top_entry}</source>
      <translation>Undo:
{top_entry}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="221"/>
      <source>Redo:
{top_entry}</source>
      <translation>Redo:
{top_entry}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="277"/>
      <source>printing</source>
      <comment>This is passed as the {action} when asking the user about compacting the document if that can save pages</comment>
      <translation>printing</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="289"/>
      <source>exporting as a PDF</source>
      <comment>This is passed as the {action} when asking the user about compacting the document if that can save pages</comment>
      <translation>exporting as a PDF</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="305"/>
      <source>Network error</source>
      <translation>Network error</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="305"/>
      <source>Operation failed, because a network error occurred.
Check your internet connection. Reported error message:

{message}</source>
      <translation>Operation failed, because a network error occurred.
Check your internet connection. Reported error message:

{message}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="313"/>
      <source>Error</source>
      <translation>Error</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="313"/>
      <source>Operation failed, because an internal error occurred.
Reported error message:

{message}</source>
      <translation>Operation failed, because an internal error occurred.
Reported error message:

{message}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="322"/>
      <source>Saving pages possible</source>
      <translation>Saving pages possible</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="322"/>
      <source>It is possible to save %n pages when printing this document.
Do you want to compact the document now to minimize the page count prior to {action}?</source>
      <translation>
        <numerusform>It is possible to save %n page when printing this document.
Do you want to compact the document now to minimize the page count prior to {action}?</numerusform>
        <numerusform>It is possible to save %n pages when printing this document.
Do you want to compact the document now to minimize the page count prior to {action}?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="338"/>
      <source>Download required Card data from Scryfall?</source>
      <translation>Download required Card data from Scryfall?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="338"/>
      <source>This program requires downloading additional card data from Scryfall to operate the card search.
Download the required data from Scryfall now?
Without the data, you can only print custom cards by drag&amp;dropping the image files onto the main window.</source>
      <translation>This program requires downloading additional card data from Scryfall to operate the card search.
Download the required data from Scryfall now?
Without the data, you can only print custom cards by drag&amp;dropping the image files onto the main window.</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="386"/>
      <source>Document loading failed</source>
      <translation>Document loading failed</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="386"/>
      <source>Loading file &quot;{failed_path}&quot; failed. The file was not recognized as a {program_name} document. If you want to load a deck list, use the &quot;{function_text}&quot; function instead.
Reported failure reason: {reason}</source>
      <translation>Loading file &quot;{failed_path}&quot; failed. The file was not recognized as a {program_name} document. If you want to load a deck list, use the &quot;{function_text}&quot; function instead.
Reported failure reason: {reason}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="399"/>
      <source>Unavailable printings replaced</source>
      <translation>Unavailable printings replaced</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="399"/>
      <source>The document contained %n unavailable printings of cards that were automatically replaced with other printings. The replaced printings are unavailable, because they match a configured card filter.</source>
      <translation>
        <numerusform>The document contained %n unavailable printing of a card that was automatically replaced with another printing. The replaced printing is unavailable, because it matches a configured card filter.</numerusform>
        <numerusform>The document contained %n unavailable printings of cards that were automatically replaced with other printings. The replaced printings are unavailable, because they match a configured card filter.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="408"/>
      <source>Unrecognized cards in loaded document found</source>
      <translation>Unrecognized cards in loaded document found</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="408"/>
      <source>Skipped %n unrecognized cards in the loaded document. Saving the document will remove these entries permanently.

The locally stored card data may be outdated or the document was tampered with.</source>
      <translation>
        <numerusform>Skipped %n unrecognized card in the loaded document. Saving the document will remove this entry permanently.

The locally stored card data may be outdated or the document was tampered with.</numerusform>
        <numerusform>Skipped %n unrecognized cards in the loaded document. Saving the document will remove these entries permanently.

The locally stored card data may be outdated or the document was tampered with.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="418"/>
      <source>Application update available. Visit website?</source>
      <translation>Application update available. Visit website?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="418"/>
      <source>An application update is available: Version {newer_version}
You are currently using version {current_version}.

Open the {program_name} website in your web browser to download the new version?</source>
      <translation>An application update is available: Version {newer_version}
You are currently using version {current_version}.

Open the {program_name} website in your web browser to download the new version?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="433"/>
      <source>New card data available</source>
      <translation>New card data available</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="433"/>
      <source>There are %n new printings available on Scryfall. Update the local data now?</source>
      <translation>
        <numerusform>There is %n new printing available on Scryfall. Update the local data now?</numerusform>
        <numerusform>There are %n new printings available on Scryfall. Update the local data now?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="449"/>
      <source>Check for application updates?</source>
      <translation>Check for application updates?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="449"/>
      <source>Automatically check for application updates whenever you start {program_name}?</source>
      <translation>Automatically check for application updates whenever you start {program_name}?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="461"/>
      <source>Check for card data updates?</source>
      <translation>Check for card data updates?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="461"/>
      <source>Automatically check for card data updates on Scryfall whenever you start {program_name}?</source>
      <translation>Automatically check for card data updates on Scryfall whenever you start {program_name}?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="471"/>
      <source>{question}
You can change this later in the settings.</source>
      <translation>{question}
You can change this later in the settings.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="14"/>
      <source>MTGProxyPrinter</source>
      <translation>MTGProxyPrinter</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="31"/>
      <source>Fi&amp;le</source>
      <translation>Fi&amp;le</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="170"/>
      <source>Settings</source>
      <translation>Settings</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="59"/>
      <source>Edit</source>
      <translation>Edit</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="301"/>
      <source>Show toolbar</source>
      <translation>Show toolbar</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="109"/>
      <source>&amp;Quit</source>
      <translation>&amp;Quit</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="112"/>
      <source>Ctrl+Q</source>
      <translation>Ctrl+Q</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="123"/>
      <source>&amp;Print</source>
      <translation>&amp;Print</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="126"/>
      <source>Print the current document</source>
      <translation>Print the current document</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="129"/>
      <source>Ctrl+P</source>
      <translation>Ctrl+P</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="137"/>
      <source>&amp;Show print preview</source>
      <translation>&amp;Show print preview</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="140"/>
      <source>Show print preview window</source>
      <translation>Show print preview window</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="148"/>
      <source>&amp;Create PDF</source>
      <translation>&amp;Create PDF</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="151"/>
      <source>Create a PDF document</source>
      <translation>Create a PDF document</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="159"/>
      <source>Discard page</source>
      <translation>Discard page</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="162"/>
      <source>Discard this page.</source>
      <translation>Discard this page.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="181"/>
      <source>Update card data</source>
      <translation>Update card data</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="189"/>
      <source>New Page</source>
      <translation>New Page</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="192"/>
      <source>Add a new, empty page.</source>
      <translation>Add a new, empty page.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="200"/>
      <source>Save</source>
      <translation>Save</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="203"/>
      <source>Ctrl+S</source>
      <translation>Ctrl+S</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="211"/>
      <source>New</source>
      <translation>New</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="214"/>
      <source>Ctrl+N</source>
      <translation>Ctrl+N</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="222"/>
      <source>Load</source>
      <translation>Load</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="225"/>
      <source>Ctrl+L</source>
      <translation>Ctrl+L</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="233"/>
      <source>Save as …</source>
      <translation>Save as …</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="238"/>
      <source>About …</source>
      <translation>About …</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="246"/>
      <source>Show Changelog</source>
      <translation>Show change-log</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="254"/>
      <source>Compact document</source>
      <translation>Compact document</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="257"/>
      <source>Minimize page count: Fill empty slots on pages by moving cards from the end of the document</source>
      <translation>Minimize page count: Fill empty slots on pages by moving cards from the end of the document</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="265"/>
      <source>Edit document settings</source>
      <translation>Edit document settings</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="268"/>
      <source>Configure page size, margins, image spacings for the currently edited document.</source>
      <translation>Configure page size, margins, image spacings for the currently edited document.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="276"/>
      <source>Import Deck list</source>
      <translation>Import Deck list</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="279"/>
      <source>Import a deck list from online sources</source>
      <translation>Import a deck list from online sources</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="287"/>
      <source>Cleanup card images</source>
      <translation>Cleanup card images</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="290"/>
      <source>Delete locally stored card images you no longer need.</source>
      <translation>Delete locally stored card images you no longer need.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="304"/>
      <source>Ctrl+M</source>
      <translation>Ctrl+M</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="312"/>
      <source>Download missing card images</source>
      <translation>Download missing card images</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="320"/>
      <source>Shuffle document</source>
      <translation>Shuffle document</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="323"/>
      <source>Randomly rearrange all card image.
If you want to quickly print a full deck for playing,
use this to reduce the initial deck shuffling required</source>
      <translation>Randomly rearrange all card image.
If you want to quickly print a full deck for playing,
use this to reduce the initial deck shuffling required</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="336"/>
      <source>Undo</source>
      <translation>Undo</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="347"/>
      <source>Redo</source>
      <translation>Redo</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="355"/>





      <source>Add empty card to page</source>
      <translation>Add empty card to page</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="358"/>
      <source>Add an empty spacer filling a card slot</source>
      <translation>Add an empty spacer filling a card slot</translation>
    </message>





  </context>
  <context>
    <name>PDFSettingsPage</name>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="558"/>
      <source>PDF export settings</source>
      <translation>PDF export settings</translation>







|








|







|







|







|






|






|





|





|




|










|




|










|




|










|




|








|




|






|




|







|




|













|




|










|




|







|




|




|




|




|
















|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|




|
<
<
<
<
<




|




|




|




|




|




|








|




|




|
>
>
>
>
>




|



>
>
>
>
>







1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102





2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
      <source>Download deck list</source>
      <translation>Download deck list</translation>
    </message>
  </context>
  <context>
    <name>LoadSaveDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="121"/>
      <source>MTGProxyPrinter document (*.{default_save_suffix})</source>
      <comment>Human-readable file type name</comment>
      <translation>MTGProxyPrinter document (*.{default_save_suffix})</translation>
    </message>
  </context>
  <context>
    <name>MTGArenaParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="201"/>
      <source>Magic Arena deck file</source>
      <translation>Magic Arena deck file</translation>
    </message>
  </context>
  <context>
    <name>MTGOnlineParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="235"/>
      <source>Magic Online (MTGO) deck file</source>
      <translation>Magic Online (MTGO) deck file</translation>
    </message>
  </context>
  <context>
    <name>MagicWorkstationDeckDataFormatParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="179"/>
      <source>Magic Workstation Deck Data Format</source>
      <translation>Magic Workstation Deck Data Format</translation>
    </message>
  </context>
  <context>
    <name>MainWindow</name>
    <message>
      <location filename="../../ui/main_window.py" line="220"/>
      <source>Undo:
{top_entry}</source>
      <translation>Undo:
{top_entry}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="222"/>
      <source>Redo:
{top_entry}</source>
      <translation>Redo:
{top_entry}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="286"/>
      <source>printing</source>
      <comment>This is passed as the {action} when asking the user about compacting the document if that can save pages</comment>
      <translation>printing</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="298"/>
      <source>exporting as a PDF</source>
      <comment>This is passed as the {action} when asking the user about compacting the document if that can save pages</comment>
      <translation>exporting as a PDF</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="314"/>
      <source>Network error</source>
      <translation>Network error</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="314"/>
      <source>Operation failed, because a network error occurred.
Check your internet connection. Reported error message:

{message}</source>
      <translation>Operation failed, because a network error occurred.
Check your internet connection. Reported error message:

{message}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="322"/>
      <source>Error</source>
      <translation>Error</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="322"/>
      <source>Operation failed, because an internal error occurred.
Reported error message:

{message}</source>
      <translation>Operation failed, because an internal error occurred.
Reported error message:

{message}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="331"/>
      <source>Saving pages possible</source>
      <translation>Saving pages possible</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="331"/>
      <source>It is possible to save %n pages when printing this document.
Do you want to compact the document now to minimize the page count prior to {action}?</source>
      <translation>
        <numerusform>It is possible to save %n page when printing this document.
Do you want to compact the document now to minimize the page count prior to {action}?</numerusform>
        <numerusform>It is possible to save %n pages when printing this document.
Do you want to compact the document now to minimize the page count prior to {action}?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="347"/>
      <source>Download required Card data from Scryfall?</source>
      <translation>Download required Card data from Scryfall?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="347"/>
      <source>This program requires downloading additional card data from Scryfall to operate the card search.
Download the required data from Scryfall now?
Without the data, you can only print custom cards by drag&amp;dropping the image files onto the main window.</source>
      <translation>This program requires downloading additional card data from Scryfall to operate the card search.
Download the required data from Scryfall now?
Without the data, you can only print custom cards by drag&amp;dropping the image files onto the main window.</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="395"/>
      <source>Document loading failed</source>
      <translation>Document loading failed</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="395"/>
      <source>Loading file &quot;{failed_path}&quot; failed. The file was not recognized as a {program_name} document. If you want to load a deck list, use the &quot;{function_text}&quot; function instead.
Reported failure reason: {reason}</source>
      <translation>Loading file &quot;{failed_path}&quot; failed. The file was not recognized as a {program_name} document. If you want to load a deck list, use the &quot;{function_text}&quot; function instead.
Reported failure reason: {reason}</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="408"/>
      <source>Unavailable printings replaced</source>
      <translation>Unavailable printings replaced</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="408"/>
      <source>The document contained %n unavailable printings of cards that were automatically replaced with other printings. The replaced printings are unavailable, because they match a configured card filter.</source>
      <translation>
        <numerusform>The document contained %n unavailable printing of a card that was automatically replaced with another printing. The replaced printing is unavailable, because it matches a configured card filter.</numerusform>
        <numerusform>The document contained %n unavailable printings of cards that were automatically replaced with other printings. The replaced printings are unavailable, because they match a configured card filter.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="417"/>
      <source>Unrecognized cards in loaded document found</source>
      <translation>Unrecognized cards in loaded document found</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="417"/>
      <source>Skipped %n unrecognized cards in the loaded document. Saving the document will remove these entries permanently.

The locally stored card data may be outdated or the document was tampered with.</source>
      <translation>
        <numerusform>Skipped %n unrecognized card in the loaded document. Saving the document will remove this entry permanently.

The locally stored card data may be outdated or the document was tampered with.</numerusform>
        <numerusform>Skipped %n unrecognized cards in the loaded document. Saving the document will remove these entries permanently.

The locally stored card data may be outdated or the document was tampered with.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="427"/>
      <source>Application update available. Visit website?</source>
      <translation>Application update available. Visit website?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="427"/>
      <source>An application update is available: Version {newer_version}
You are currently using version {current_version}.

Open the {program_name} website in your web browser to download the new version?</source>
      <translation>An application update is available: Version {newer_version}
You are currently using version {current_version}.

Open the {program_name} website in your web browser to download the new version?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="442"/>
      <source>New card data available</source>
      <translation>New card data available</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/main_window.py" line="442"/>
      <source>There are %n new printings available on Scryfall. Update the local data now?</source>
      <translation>
        <numerusform>There is %n new printing available on Scryfall. Update the local data now?</numerusform>
        <numerusform>There are %n new printings available on Scryfall. Update the local data now?</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="458"/>
      <source>Check for application updates?</source>
      <translation>Check for application updates?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="458"/>
      <source>Automatically check for application updates whenever you start {program_name}?</source>
      <translation>Automatically check for application updates whenever you start {program_name}?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="470"/>
      <source>Check for card data updates?</source>
      <translation>Check for card data updates?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="470"/>
      <source>Automatically check for card data updates on Scryfall whenever you start {program_name}?</source>
      <translation>Automatically check for card data updates on Scryfall whenever you start {program_name}?</translation>
    </message>
    <message>
      <location filename="../../ui/main_window.py" line="480"/>
      <source>{question}
You can change this later in the settings.</source>
      <translation>{question}
You can change this later in the settings.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="14"/>
      <source>MTGProxyPrinter</source>
      <translation>MTGProxyPrinter</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="31"/>
      <source>Fi&amp;le</source>
      <translation>Fi&amp;le</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="171"/>
      <source>Settings</source>
      <translation>Settings</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="60"/>
      <source>Edit</source>
      <translation>Edit</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="302"/>
      <source>Show toolbar</source>
      <translation>Show toolbar</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="110"/>
      <source>&amp;Quit</source>
      <translation>&amp;Quit</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="113"/>
      <source>Ctrl+Q</source>
      <translation>Ctrl+Q</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="124"/>
      <source>&amp;Print</source>
      <translation>&amp;Print</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="127"/>
      <source>Print the current document</source>
      <translation>Print the current document</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="130"/>
      <source>Ctrl+P</source>
      <translation>Ctrl+P</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="138"/>
      <source>&amp;Show print preview</source>
      <translation>&amp;Show print preview</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="141"/>
      <source>Show print preview window</source>
      <translation>Show print preview window</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="149"/>
      <source>&amp;Create PDF</source>
      <translation>&amp;Create PDF</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="152"/>
      <source>Create a PDF document</source>
      <translation>Create a PDF document</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="160"/>
      <source>Discard page</source>
      <translation>Discard page</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="163"/>
      <source>Discard this page.</source>
      <translation>Discard this page.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="182"/>
      <source>Update card data</source>
      <translation>Update card data</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="190"/>
      <source>New Page</source>
      <translation>New Page</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="193"/>
      <source>Add a new, empty page.</source>
      <translation>Add a new, empty page.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="201"/>
      <source>Save</source>
      <translation>Save</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="204"/>
      <source>Ctrl+S</source>
      <translation>Ctrl+S</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="212"/>
      <source>New</source>
      <translation>New</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="215"/>
      <source>Ctrl+N</source>
      <translation>Ctrl+N</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="223"/>
      <source>Load</source>
      <translation>Load</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="226"/>
      <source>Ctrl+L</source>
      <translation>Ctrl+L</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="234"/>
      <source>Save as …</source>
      <translation>Save as …</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="239"/>
      <source>About …</source>
      <translation>About …</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="247"/>
      <source>Show Changelog</source>
      <translation>Show change-log</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="255"/>
      <source>Compact document</source>
      <translation>Compact document</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="258"/>
      <source>Minimize page count: Fill empty slots on pages by moving cards from the end of the document</source>
      <translation>Minimize page count: Fill empty slots on pages by moving cards from the end of the document</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="266"/>
      <source>Edit document settings</source>
      <translation>Edit document settings</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="269"/>
      <source>Configure page size, margins, image spacings for the currently edited document.</source>
      <translation>Configure page size, margins, image spacings for the currently edited document.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="280"/>





      <source>Import a deck list from online sources</source>
      <translation>Import a deck list from online sources</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="288"/>
      <source>Cleanup card images</source>
      <translation>Cleanup card images</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="291"/>
      <source>Delete locally stored card images you no longer need.</source>
      <translation>Delete locally stored card images you no longer need.</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="305"/>
      <source>Ctrl+M</source>
      <translation>Ctrl+M</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="313"/>
      <source>Download missing card images</source>
      <translation>Download missing card images</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="321"/>
      <source>Shuffle document</source>
      <translation>Shuffle document</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="324"/>
      <source>Randomly rearrange all card image.
If you want to quickly print a full deck for playing,
use this to reduce the initial deck shuffling required</source>
      <translation>Randomly rearrange all card image.
If you want to quickly print a full deck for playing,
use this to reduce the initial deck shuffling required</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="337"/>
      <source>Undo</source>
      <translation>Undo</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="348"/>
      <source>Redo</source>
      <translation>Redo</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="277"/>
      <source>Import deck list</source>
      <translation>Import deck list</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="356"/>
      <source>Add empty card to page</source>
      <translation>Add empty card to page</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="359"/>
      <source>Add an empty spacer filling a card slot</source>
      <translation>Add an empty spacer filling a card slot</translation>
    </message>
    <message>
      <location filename="../ui/main_window.ui" line="367"/>
      <source>Add custom cards</source>
      <translation>Add custom cards</translation>
    </message>
  </context>
  <context>
    <name>PDFSettingsPage</name>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="558"/>
      <source>PDF export settings</source>
      <translation>PDF export settings</translation>
2247
2248
2249
2250
2251
2252
2253






















































2254
2255
2256
2257
2258
2259
2260
    </message>
    <message>
      <location filename="../ui/settings_window/pdf_settings_page.ui" line="124"/>
      <source>Enable landscape workaround: Rotate landscape PDFs by 90°</source>
      <translation>Enable landscape workaround: Rotate landscape PDFs by 90°</translation>
    </message>
  </context>






















































  <context>
    <name>PageConfigPreviewArea</name>
    <message>
      <location filename="../ui/page_config_preview_area.ui" line="36"/>
      <source> cards</source>
      <translation> cards</translation>
    </message>







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
    </message>
    <message>
      <location filename="../ui/settings_window/pdf_settings_page.ui" line="124"/>
      <source>Enable landscape workaround: Rotate landscape PDFs by 90°</source>
      <translation>Enable landscape workaround: Rotate landscape PDFs by 90°</translation>
    </message>
  </context>
  <context>
    <name>PageCardTableView</name>
    <message numerus="yes">
      <location filename="../../ui/page_card_table_view.py" line="128"/>
      <source>Add %n copies</source>
      <comment>Context menu action: Add additional card copies to the document</comment>
      <translation>
        <numerusform>Add copy</numerusform>
        <numerusform>Add %n copies</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="134"/>
      <source>Add copies …</source>
      <comment>Context menu action: Add additional card copies to the document. User will be asked for a number</comment>
      <translation>Add copies …</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="121"/>
      <source>Generate DFC check card</source>
      <translation>Generate DFC check card</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="148"/>
      <source>All related cards</source>
      <translation>All related cards</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="156"/>
      <source>Add copies</source>
      <translation>Add copies</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="156"/>
      <source>Add copies of {card_name}</source>
      <comment>Asks the user for a number. Does not need plural forms</comment>
      <translation>Add copies of {card_name}</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="182"/>
      <source>Export image</source>
      <translation>Export image</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="197"/>
      <source>Save card image</source>
      <translation>Save card image</translation>
    </message>
    <message>
      <location filename="../../ui/page_card_table_view.py" line="197"/>
      <source>Images (*.png *.bmp *.jpg)</source>
      <translation>Images (*.png *.bmp *.jpg)</translation>
    </message>
  </context>
  <context>
    <name>PageConfigPreviewArea</name>
    <message>
      <location filename="../ui/page_config_preview_area.ui" line="36"/>
      <source> cards</source>
      <translation> cards</translation>
    </message>
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
      <source>Oversized</source>
      <translation>Oversized</translation>
    </message>
  </context>
  <context>
    <name>PageConfigWidget</name>
    <message numerus="yes">
      <location filename="../../ui/page_config_widget.py" line="102"/>
      <source>%n regular card(s)</source>
      <comment>Display of the resulting page capacity for regular-sized cards</comment>
      <translation>
        <numerusform>%n regular card</numerusform>
        <numerusform>%n regular cards</numerusform>
      </translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/page_config_widget.py" line="106"/>
      <source>%n oversized card(s)</source>
      <comment>Display of the resulting page capacity for oversized cards</comment>
      <translation>
        <numerusform>%n oversized card</numerusform>
        <numerusform>%n oversized cards</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/page_config_widget.py" line="111"/>
      <source>{regular_text}, {oversized_text}</source>
      <comment>Combination of the page capacities for regular, and oversized cards</comment>
      <translation>{regular_text}, {oversized_text}</translation>
    </message>
    <message>
      <location filename="../ui/page_config_widget.ui" line="14"/>
      <source>Default settings for new documents</source>







|








|








|







2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
      <source>Oversized</source>
      <translation>Oversized</translation>
    </message>
  </context>
  <context>
    <name>PageConfigWidget</name>
    <message numerus="yes">
      <location filename="../../ui/page_config_widget.py" line="101"/>
      <source>%n regular card(s)</source>
      <comment>Display of the resulting page capacity for regular-sized cards</comment>
      <translation>
        <numerusform>%n regular card</numerusform>
        <numerusform>%n regular cards</numerusform>
      </translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/page_config_widget.py" line="105"/>
      <source>%n oversized card(s)</source>
      <comment>Display of the resulting page capacity for oversized cards</comment>
      <translation>
        <numerusform>%n oversized card</numerusform>
        <numerusform>%n oversized cards</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/page_config_widget.py" line="110"/>
      <source>{regular_text}, {oversized_text}</source>
      <comment>Combination of the page capacities for regular, and oversized cards</comment>
      <translation>{regular_text}, {oversized_text}</translation>
    </message>
    <message>
      <location filename="../ui/page_config_widget.ui" line="14"/>
      <source>Default settings for new documents</source>
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530






























2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
Zoom in: {zoom_in_shortcuts}
Zoom out: {zoom_out_shortcuts}</translation>
    </message>
  </context>
  <context>
    <name>ParserBase</name>
    <message>
      <location filename="../../decklist_parser/common.py" line="70"/>
      <source>All files (*)</source>
      <translation>All files (*)</translation>
    </message>
  </context>
  <context>
    <name>PrettySetListModel</name>
    <message>
      <location filename="../../model/string_list.py" line="37"/>
      <source>Set</source>
      <comment>MTG set name</comment>
      <translation>Set</translation>
    </message>
  </context>
  <context>
    <name>PrinterSettingsPage</name>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="507"/>
      <source>Printer settings</source>
      <translation>Printer settings</translation>
    </message>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="507"/>
      <source>Configure the printer</source>
      <translation>Configure the printer</translation>
    </message>






























    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="17"/>
      <source>Horizontal printing offset</source>
      <translation>Horizontal printing offset</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="24"/>
      <source>Globally shifts the printing area to correct physical offsets in the printer.
Positive values shift to the right.
Negative offsets shift to the left.</source>
      <translation>Globally shifts the printing area to correct physical offsets in the printer.
Positive values shift to the right.
Negative offsets shift to the left.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="32"/>
      <source> mm</source>
      <translation> mm</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="48"/>
      <source>If enabled, print landscape documents in portrait mode with all content rotated by 90°.
Enable this, if printing landscape documents results in portrait printouts with cropped-off sides.</source>
      <translation>If enabled, print landscape documents in portrait mode with all content rotated by 90°.
Enable this, if printing landscape documents results in portrait printouts with cropped-off sides.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="52"/>
      <source>Enable landscape workaround: Rotate prints by 90°</source>
      <translation>Enable landscape workaround: Rotate prints by 90°</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="62"/>
      <source>When enabled, instruct the printer to use borderless mode and let MTGProxyPrinter manage the printing margins.
Disable this, if your printer keeps scaling print-outs up or down.

When disabled, managing the page margins is delegated to the printer driver,
which should increase compatibility, at the expense of drawing shorter cut helper lines.</source>
      <translation>When enabled, instruct the printer to use borderless mode and let MTGProxyPrinter manage the printing margins.
Disable this, if your printer keeps scaling print-outs up or down.

When disabled, managing the page margins is delegated to the printer driver,
which should increase compatibility, at the expense of drawing shorter cut helper lines.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="69"/>
      <source>Configure printer for borderless printing</source>
      <translation>Configure printer for borderless printing</translation>
    </message>
  </context>
  <context>
    <name>PrintingFilterUpdater.store_current_printing_filters()</name>
    <message>
      <location filename="../../printing_filter_updater.py" line="119"/>
      <source>Processing updated card filters:</source>
      <translation>Processing updated card filters:</translation>
    </message>
  </context>
  <context>
    <name>SaveDocumentAsDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="133"/>
      <source>Save document as …</source>
      <translation>Save document as …</translation>
    </message>
  </context>
  <context>
    <name>SavePDFDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="79"/>
      <source>Export as PDF</source>
      <translation>Export as PDF</translation>
    </message>
    <message>
      <location filename="../../ui/dialogs.py" line="80"/>
      <source>PDF documents (*.pdf)</source>
      <translation>PDF documents (*.pdf)</translation>
    </message>
  </context>
  <context>
    <name>ScryfallCSVParser</name>
    <message>
      <location filename="../../decklist_parser/csv_parsers.py" line="117"/>
      <source>Scryfall CSV export</source>
      <translation>Scryfall CSV export</translation>
    </message>
  </context>
  <context>
    <name>SelectDeckParserPage</name>
    <message>







|







|

















>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>



















<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




|







|







|




|







|







2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637






























2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
Zoom in: {zoom_in_shortcuts}
Zoom out: {zoom_out_shortcuts}</translation>
    </message>
  </context>
  <context>
    <name>ParserBase</name>
    <message>
      <location filename="../../decklist_parser/common.py" line="71"/>
      <source>All files (*)</source>
      <translation>All files (*)</translation>
    </message>
  </context>
  <context>
    <name>PrettySetListModel</name>
    <message>
      <location filename="../../model/string_list.py" line="36"/>
      <source>Set</source>
      <comment>MTG set name</comment>
      <translation>Set</translation>
    </message>
  </context>
  <context>
    <name>PrinterSettingsPage</name>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="507"/>
      <source>Printer settings</source>
      <translation>Printer settings</translation>
    </message>
    <message>
      <location filename="../../ui/settings_window_pages.py" line="507"/>
      <source>Configure the printer</source>
      <translation>Configure the printer</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="62"/>
      <source>When enabled, instruct the printer to use borderless mode and let MTGProxyPrinter manage the printing margins.
Disable this, if your printer keeps scaling print-outs up or down.

When disabled, managing the page margins is delegated to the printer driver,
which should increase compatibility, at the expense of drawing shorter cut helper lines.</source>
      <translation>When enabled, instruct the printer to use borderless mode and let MTGProxyPrinter manage the printing margins.
Disable this, if your printer keeps scaling print-outs up or down.

When disabled, managing the page margins is delegated to the printer driver,
which should increase compatibility, at the expense of drawing shorter cut helper lines.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="69"/>
      <source>Configure printer for borderless printing</source>
      <translation>Configure printer for borderless printing</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="48"/>
      <source>If enabled, print landscape documents in portrait mode with all content rotated by 90°.
Enable this, if printing landscape documents results in portrait printouts with cropped-off sides.</source>
      <translation>If enabled, print landscape documents in portrait mode with all content rotated by 90°.
Enable this, if printing landscape documents results in portrait printouts with cropped-off sides.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="52"/>
      <source>Enable landscape workaround: Rotate prints by 90°</source>
      <translation>Enable landscape workaround: Rotate prints by 90°</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="17"/>
      <source>Horizontal printing offset</source>
      <translation>Horizontal printing offset</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="24"/>
      <source>Globally shifts the printing area to correct physical offsets in the printer.
Positive values shift to the right.
Negative offsets shift to the left.</source>
      <translation>Globally shifts the printing area to correct physical offsets in the printer.
Positive values shift to the right.
Negative offsets shift to the left.</translation>
    </message>
    <message>
      <location filename="../ui/settings_window/printer_settings_page.ui" line="32"/>
      <source> mm</source>
      <translation> mm</translation>
    </message>






























  </context>
  <context>
    <name>PrintingFilterUpdater.store_current_printing_filters()</name>
    <message>
      <location filename="../../printing_filter_updater.py" line="118"/>
      <source>Processing updated card filters:</source>
      <translation>Processing updated card filters:</translation>
    </message>
  </context>
  <context>
    <name>SaveDocumentAsDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="134"/>
      <source>Save document as …</source>
      <translation>Save document as …</translation>
    </message>
  </context>
  <context>
    <name>SavePDFDialog</name>
    <message>
      <location filename="../../ui/dialogs.py" line="80"/>
      <source>Export as PDF</source>
      <translation>Export as PDF</translation>
    </message>
    <message>
      <location filename="../../ui/dialogs.py" line="81"/>
      <source>PDF documents (*.pdf)</source>
      <translation>PDF documents (*.pdf)</translation>
    </message>
  </context>
  <context>
    <name>ScryfallCSVParser</name>
    <message>
      <location filename="../../decklist_parser/csv_parsers.py" line="118"/>
      <source>Scryfall CSV export</source>
      <translation>Scryfall CSV export</translation>
    </message>
  </context>
  <context>
    <name>SelectDeckParserPage</name>
    <message>
2839
2840
2841
2842
2843
2844
2845













2846
2847
2848
2849
2850
2851
2852
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/select_deck_parser_page.ui" line="317"/>
      <source>Magic Workstation Deck Data (mwDeck)</source>
      <translation>Magic Workstation Deck Data (mwDeck)</translation>
    </message>
  </context>













  <context>
    <name>SettingsWindow</name>
    <message>
      <location filename="../../ui/settings_window.py" line="207"/>
      <source>Apply settings to the current document?</source>
      <translation>Apply settings to the current document?</translation>
    </message>







>
>
>
>
>
>
>
>
>
>
>
>
>







2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/select_deck_parser_page.ui" line="317"/>
      <source>Magic Workstation Deck Data (mwDeck)</source>
      <translation>Magic Workstation Deck Data (mwDeck)</translation>
    </message>
  </context>
  <context>
    <name>SetEditor</name>
    <message>
      <location filename="../ui/set_editor_widget.ui" line="35"/>
      <source>Set name</source>
      <translation>Set name</translation>
    </message>
    <message>
      <location filename="../ui/set_editor_widget.ui" line="61"/>
      <source>CODE</source>
      <translation>CODE</translation>
    </message>
  </context>
  <context>
    <name>SettingsWindow</name>
    <message>
      <location filename="../../ui/settings_window.py" line="207"/>
      <source>Apply settings to the current document?</source>
      <translation>Apply settings to the current document?</translation>
    </message>
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956










2957
2958
2959
2960
2961
2962
2963
      <location filename="../ui/settings_window/settings_window.ui" line="17"/>
      <source>Settings</source>
      <translation>Settings</translation>
    </message>
  </context>
  <context>
    <name>SummaryPage</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="452"/>
      <source>Images about to be deleted: {count}</source>
      <translation>Images about to be deleted: {count}</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="453"/>
      <source>Disk space that will be freed: {disk_space_freed}</source>
      <translation>Disk space that will be freed: {disk_space_freed}</translation>
    </message>
    <message numerus="yes">
      <location filename="../../ui/deck_import_wizard.py" line="470"/>
      <source>Beware: The card list currently contains %n potentially oversized card(s).</source>
      <comment>Warning emitted, if at least 1 card has the oversized flag set. The Scryfall server *may* still return a regular-sized image, so not *all* printings marked as oversized are actually so when fetched.</comment>
      <translation>
        <numerusform>Beware: The card list currently contains %n potentially oversized card.</numerusform>
        <numerusform>Beware: The card list currently contains %n potentially oversized cards.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="490"/>
      <source>Replace document content with the identified cards</source>
      <translation>Replace document content with the identified cards</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="493"/>
      <source>Append identified cards to the document</source>
      <translation>Append identified cards to the document</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="545"/>
      <source>Remove basic lands</source>
      <translation>Remove basic lands</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="546"/>
      <source>Remove all basic lands in the deck list above</source>
      <translation>Remove all basic lands in the deck list above</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="551"/>
      <source>Remove selected</source>
      <translation>Remove selected</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="552"/>
      <source>Remove all selected cards in the deck list above</source>
      <translation>Remove all selected cards in the deck list above</translation>
    </message>










    <message>
      <location filename="../ui/cache_cleanup_wizard/summary_page.ui" line="14"/>
      <source>Summary</source>
      <translation>Summary</translation>
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/parser_result_page.ui" line="14"/>







<
<
<
<
<
<
<
<
<
<

|








|




|




|




|




|




|



>
>
>
>
>
>
>
>
>
>







2972
2973
2974
2975
2976
2977
2978










2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
      <location filename="../ui/settings_window/settings_window.ui" line="17"/>
      <source>Settings</source>
      <translation>Settings</translation>
    </message>
  </context>
  <context>
    <name>SummaryPage</name>










    <message numerus="yes">
      <location filename="../../ui/deck_import_wizard.py" line="474"/>
      <source>Beware: The card list currently contains %n potentially oversized card(s).</source>
      <comment>Warning emitted, if at least 1 card has the oversized flag set. The Scryfall server *may* still return a regular-sized image, so not *all* printings marked as oversized are actually so when fetched.</comment>
      <translation>
        <numerusform>Beware: The card list currently contains %n potentially oversized card.</numerusform>
        <numerusform>Beware: The card list currently contains %n potentially oversized cards.</numerusform>
      </translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="494"/>
      <source>Replace document content with the identified cards</source>
      <translation>Replace document content with the identified cards</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="497"/>
      <source>Append identified cards to the document</source>
      <translation>Append identified cards to the document</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="533"/>
      <source>Remove basic lands</source>
      <translation>Remove basic lands</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="534"/>
      <source>Remove all basic lands in the deck list above</source>
      <translation>Remove all basic lands in the deck list above</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="539"/>
      <source>Remove selected</source>
      <translation>Remove selected</translation>
    </message>
    <message>
      <location filename="../../ui/deck_import_wizard.py" line="540"/>
      <source>Remove all selected cards in the deck list above</source>
      <translation>Remove all selected cards in the deck list above</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="437"/>
      <source>Images about to be deleted: {count}</source>
      <translation>Images about to be deleted: {count}</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="438"/>
      <source>Disk space that will be freed: {disk_space_freed}</source>
      <translation>Disk space that will be freed: {disk_space_freed}</translation>
    </message>
    <message>
      <location filename="../ui/cache_cleanup_wizard/summary_page.ui" line="14"/>
      <source>Summary</source>
      <translation>Summary</translation>
    </message>
    <message>
      <location filename="../ui/deck_import_wizard/parser_result_page.ui" line="14"/>
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="43"/>
      <source>Current page</source>
      <translation>Current page</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="92"/>
      <source>Remove selected</source>
      <translation>Remove selected</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="103"/>
      <source>Preview</source>
      <translation>Preview</translation>
    </message>
  </context>
  <context>
    <name>TappedOutCSVParser</name>
    <message>
      <location filename="../../decklist_parser/csv_parsers.py" line="196"/>
      <source>Tappedout CSV export</source>
      <translation>Tappedout CSV export</translation>
    </message>
  </context>
  <context>
    <name>UnknownCardImageModel</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="270"/>
      <source>Scryfall ID</source>
      <translation>Scryfall ID</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="271"/>
      <source>Front/Back</source>
      <translation>Front/Back</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="272"/>
      <source>High resolution?</source>
      <translation>High resolution?</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="273"/>
      <source>Size</source>
      <translation>Size</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="274"/>
      <source>Path</source>
      <translation>Path</translation>
    </message>
  </context>
  <context>
    <name>UnknownCardRow</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="244"/>
      <source>Front</source>
      <translation>Front</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="244"/>
      <source>Back</source>
      <translation>Back</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="250"/>
      <source>Yes</source>
      <translation>Yes</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="250"/>
      <source>No</source>
      <translation>No</translation>
    </message>
  </context>
  <context>
    <name>VerticalAddCardWidget</name>
    <message>







|




|







|







|




|




|




|




|







|




|




|




|







3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="43"/>
      <source>Current page</source>
      <translation>Current page</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="89"/>
      <source>Remove selected</source>
      <translation>Remove selected</translation>
    </message>
    <message>
      <location filename="../ui/central_widget/tabbed_vertical.ui" line="100"/>
      <source>Preview</source>
      <translation>Preview</translation>
    </message>
  </context>
  <context>
    <name>TappedOutCSVParser</name>
    <message>
      <location filename="../../decklist_parser/csv_parsers.py" line="197"/>
      <source>Tappedout CSV export</source>
      <translation>Tappedout CSV export</translation>
    </message>
  </context>
  <context>
    <name>UnknownCardImageModel</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="255"/>
      <source>Scryfall ID</source>
      <translation>Scryfall ID</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="256"/>
      <source>Front/Back</source>
      <translation>Front/Back</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="257"/>
      <source>High resolution?</source>
      <translation>High resolution?</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="258"/>
      <source>Size</source>
      <translation>Size</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="259"/>
      <source>Path</source>
      <translation>Path</translation>
    </message>
  </context>
  <context>
    <name>UnknownCardRow</name>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="229"/>
      <source>Front</source>
      <translation>Front</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="229"/>
      <source>Back</source>
      <translation>Back</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="235"/>
      <source>Yes</source>
      <translation>Yes</translation>
    </message>
    <message>
      <location filename="../../ui/cache_cleanup_wizard.py" line="235"/>
      <source>No</source>
      <translation>No</translation>
    </message>
  </context>
  <context>
    <name>VerticalAddCardWidget</name>
    <message>
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
      <source>Copies:</source>
      <translation>Copies:</translation>
    </message>
  </context>
  <context>
    <name>XMageParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="256"/>
      <source>XMage Deck file</source>
      <translation>XMage Deck file</translation>
    </message>
  </context>
  <context>
    <name>format_size</name>
    <message>
      <location filename="../../ui/common.py" line="127"/>
      <source>{size} {unit}</source>
      <comment>A formatted file size in SI bytes</comment>
      <translation>{size} {unit}</translation>
    </message>
  </context>
</TS>







|







|






3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
      <source>Copies:</source>
      <translation>Copies:</translation>
    </message>
  </context>
  <context>
    <name>XMageParser</name>
    <message>
      <location filename="../../decklist_parser/re_parsers.py" line="257"/>
      <source>XMage Deck file</source>
      <translation>XMage Deck file</translation>
    </message>
  </context>
  <context>
    <name>format_size</name>
    <message>
      <location filename="../../ui/common.py" line="138"/>
      <source>{size} {unit}</source>
      <comment>A formatted file size in SI bytes</comment>
      <translation>{size} {unit}</translation>
    </message>
  </context>
</TS>
Changes to mtg_proxy_printer/resources/ui/central_widget/columnar.ui.
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
     </property>
     <property name="lineWidth">
      <number>0</number>
     </property>
     <property name="alternatingRowColors">
      <bool>true</bool>
     </property>
     <property name="selectionMode">
      <enum>QAbstractItemView::MultiSelection</enum>
     </property>
     <property name="selectionBehavior">
      <enum>QAbstractItemView::SelectRows</enum>
     </property>
     <attribute name="verticalHeaderVisible">
      <bool>false</bool>
     </attribute>
    </widget>







<
<
<







43
44
45
46
47
48
49



50
51
52
53
54
55
56
     </property>
     <property name="lineWidth">
      <number>0</number>
     </property>
     <property name="alternatingRowColors">
      <bool>true</bool>
     </property>



     <property name="selectionBehavior">
      <enum>QAbstractItemView::SelectRows</enum>
     </property>
     <attribute name="verticalHeaderVisible">
      <bool>false</bool>
     </attribute>
    </widget>
Changes to mtg_proxy_printer/resources/ui/central_widget/grouped.ui.
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
     </property>
     <property name="lineWidth">
      <number>0</number>
     </property>
     <property name="alternatingRowColors">
      <bool>true</bool>
     </property>
     <property name="selectionMode">
      <enum>QAbstractItemView::MultiSelection</enum>
     </property>
     <property name="selectionBehavior">
      <enum>QAbstractItemView::SelectRows</enum>
     </property>
     <attribute name="verticalHeaderVisible">
      <bool>false</bool>
     </attribute>
    </widget>







<
<
<







75
76
77
78
79
80
81



82
83
84
85
86
87
88
     </property>
     <property name="lineWidth">
      <number>0</number>
     </property>
     <property name="alternatingRowColors">
      <bool>true</bool>
     </property>



     <property name="selectionBehavior">
      <enum>QAbstractItemView::SelectRows</enum>
     </property>
     <attribute name="verticalHeaderVisible">
      <bool>false</bool>
     </attribute>
    </widget>
Changes to mtg_proxy_printer/resources/ui/central_widget/tabbed_vertical.ui.
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
         </property>
         <property name="lineWidth">
          <number>0</number>
         </property>
         <property name="alternatingRowColors">
          <bool>true</bool>
         </property>
         <property name="selectionMode">
          <enum>QAbstractItemView::MultiSelection</enum>
         </property>
         <property name="selectionBehavior">
          <enum>QAbstractItemView::SelectRows</enum>
         </property>
         <attribute name="verticalHeaderVisible">
          <bool>false</bool>
         </attribute>
        </widget>







<
<
<







68
69
70
71
72
73
74



75
76
77
78
79
80
81
         </property>
         <property name="lineWidth">
          <number>0</number>
         </property>
         <property name="alternatingRowColors">
          <bool>true</bool>
         </property>



         <property name="selectionBehavior">
          <enum>QAbstractItemView::SelectRows</enum>
         </property>
         <attribute name="verticalHeaderVisible">
          <bool>false</bool>
         </attribute>
        </widget>
Added mtg_proxy_printer/resources/ui/custom_card_import_dialog.ui.




































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>CustomCardImportDialog</class>
 <widget class="QDialog" name="CustomCardImportDialog">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>900</width>
    <height>500</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>Import custom cards</string>
  </property>
  <layout class="QGridLayout" name="gridLayout">
   <item row="6" column="3">
    <widget class="QPushButton" name="set_copies_to">
     <property name="text">
      <string>Set Copies to …</string>
     </property>
     <property name="icon">
      <iconset theme="document-edit"/>
     </property>
    </widget>
   </item>
   <item row="6" column="4">
    <widget class="QSpinBox" name="card_copies">
     <property name="minimum">
      <number>1</number>
     </property>
    </widget>
   </item>
   <item row="1" column="2" rowspan="11">
    <widget class="CardListTableView" name="card_table"/>
   </item>
   <item row="3" column="3" colspan="2">
    <widget class="QPushButton" name="remove_selected">
     <property name="text">
      <string>Remove selected</string>
     </property>
     <property name="icon">
      <iconset theme="edit-delete"/>
     </property>
    </widget>
   </item>
   <item row="2" column="3" colspan="2">
    <widget class="QPushButton" name="add_cards">
     <property name="text">
      <string>Load images</string>
     </property>
     <property name="icon">
      <iconset theme="document-import"/>
     </property>
    </widget>
   </item>
   <item row="8" column="3" colspan="2">
    <widget class="QDialogButtonBox" name="button_box">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Minimum" vsizetype="Maximum">
       <horstretch>0</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="orientation">
      <enum>Qt::Vertical</enum>
     </property>
     <property name="standardButtons">
      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
     </property>
    </widget>
   </item>
   <item row="7" column="3">
    <spacer name="verticalSpacer">
     <property name="orientation">
      <enum>Qt::Vertical</enum>
     </property>
     <property name="sizeHint" stdset="0">
      <size>
       <width>20</width>
       <height>40</height>
      </size>
     </property>
    </spacer>
   </item>
  </layout>
 </widget>
 <customwidgets>
  <customwidget>
   <class>CardListTableView</class>
   <extends>QTableView</extends>
   <header>mtg_proxy_printer.ui.card_list_table_view</header>
  </customwidget>
 </customwidgets>
 <resources/>
 <connections>
  <connection>
   <sender>button_box</sender>
   <signal>accepted()</signal>
   <receiver>CustomCardImportDialog</receiver>
   <slot>accept()</slot>
   <hints>
    <hint type="sourcelabel">
     <x>248</x>
     <y>254</y>
    </hint>
    <hint type="destinationlabel">
     <x>157</x>
     <y>274</y>
    </hint>
   </hints>
  </connection>
  <connection>
   <sender>button_box</sender>
   <signal>rejected()</signal>
   <receiver>CustomCardImportDialog</receiver>
   <slot>reject()</slot>
   <hints>
    <hint type="sourcelabel">
     <x>316</x>
     <y>260</y>
    </hint>
    <hint type="destinationlabel">
     <x>286</x>
     <y>274</y>
    </hint>
   </hints>
  </connection>
 </connections>
</ui>
Changes to mtg_proxy_printer/resources/ui/deck_import_wizard/parser_result_page.ui.
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
    <widget class="QLabel" name="parsed_cards_label">
     <property name="text">
      <string>These cards were successfully identified:</string>
     </property>
    </widget>
   </item>
   <item>
    <widget class="QTableView" name="parsed_cards_table">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
       <horstretch>0</horstretch>
       <verstretch>65</verstretch>
      </sizepolicy>
     </property>
     <property name="sizeAdjustPolicy">







|







35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
    <widget class="QLabel" name="parsed_cards_label">
     <property name="text">
      <string>These cards were successfully identified:</string>
     </property>
    </widget>
   </item>
   <item>
    <widget class="CardListTableView" name="parsed_cards_table">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
       <horstretch>0</horstretch>
       <verstretch>65</verstretch>
      </sizepolicy>
     </property>
     <property name="sizeAdjustPolicy">
87
88
89
90
91
92
93







94
95
96
     <property name="openLinks">
      <bool>false</bool>
     </property>
    </widget>
   </item>
  </layout>
 </widget>







 <resources/>
 <connections/>
</ui>







>
>
>
>
>
>
>



87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
     <property name="openLinks">
      <bool>false</bool>
     </property>
    </widget>
   </item>
  </layout>
 </widget>
 <customwidgets>
  <customwidget>
   <class>CardListTableView</class>
   <extends>QTableView</extends>
   <header>mtg_proxy_printer.ui.card_list_table_view</header>
  </customwidget>
 </customwidgets>
 <resources/>
 <connections/>
</ui>
Changes to mtg_proxy_printer/resources/ui/main_window.ui.
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

42
43
44
45
46
47
48
  <widget class="CentralWidget" name="central_widget"/>
  <widget class="QMenuBar" name="menubar">
   <property name="geometry">
    <rect>
     <x>0</x>
     <y>0</y>
     <width>1050</width>
     <height>30</height>
    </rect>
   </property>
   <widget class="QMenu" name="menu_file">
    <property name="title">
     <string>Fi&amp;le</string>
    </property>
    <addaction name="action_new_document"/>
    <addaction name="action_load_document"/>
    <addaction name="action_save_document"/>
    <addaction name="action_save_as"/>
    <addaction name="action_print_preview"/>
    <addaction name="action_print"/>
    <addaction name="action_print_pdf"/>
    <addaction name="separator"/>
    <addaction name="action_import_deck_list"/>

    <addaction name="separator"/>
    <addaction name="action_quit"/>
   </widget>
   <widget class="QMenu" name="menu_settings">
    <property name="title">
     <string>Settings</string>
    </property>







|















>







19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
  <widget class="CentralWidget" name="central_widget"/>
  <widget class="QMenuBar" name="menubar">
   <property name="geometry">
    <rect>
     <x>0</x>
     <y>0</y>
     <width>1050</width>
     <height>28</height>
    </rect>
   </property>
   <widget class="QMenu" name="menu_file">
    <property name="title">
     <string>Fi&amp;le</string>
    </property>
    <addaction name="action_new_document"/>
    <addaction name="action_load_document"/>
    <addaction name="action_save_document"/>
    <addaction name="action_save_as"/>
    <addaction name="action_print_preview"/>
    <addaction name="action_print"/>
    <addaction name="action_print_pdf"/>
    <addaction name="separator"/>
    <addaction name="action_import_deck_list"/>
    <addaction name="action_add_custom_cards"/>
    <addaction name="separator"/>
    <addaction name="action_quit"/>
   </widget>
   <widget class="QMenu" name="menu_settings">
    <property name="title">
     <string>Settings</string>
    </property>
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
   </property>
  </action>
  <action name="action_import_deck_list">
   <property name="icon">
    <iconset theme="document-import"/>
   </property>
   <property name="text">
    <string>Import Deck list</string>
   </property>
   <property name="toolTip">
    <string>Import a deck list from online sources</string>
   </property>
  </action>
  <action name="action_cleanup_local_image_cache">
   <property name="icon">







|







270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
   </property>
  </action>
  <action name="action_import_deck_list">
   <property name="icon">
    <iconset theme="document-import"/>
   </property>
   <property name="text">
    <string>Import deck list</string>
   </property>
   <property name="toolTip">
    <string>Import a deck list from online sources</string>
   </property>
  </action>
  <action name="action_cleanup_local_image_cache">
   <property name="icon">
353
354
355
356
357
358
359








360
361
362
363
364
365
366
   </property>
   <property name="text">
    <string>Add empty card to page</string>
   </property>
   <property name="toolTip">
    <string>Add an empty spacer filling a card slot</string>
   </property>








  </action>
 </widget>
 <customwidgets>
  <customwidget>
   <class>CentralWidget</class>
   <extends>QWidget</extends>
   <header>mtg_proxy_printer.ui.central_widget</header>







>
>
>
>
>
>
>
>







354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
   </property>
   <property name="text">
    <string>Add empty card to page</string>
   </property>
   <property name="toolTip">
    <string>Add an empty spacer filling a card slot</string>
   </property>
  </action>
  <action name="action_add_custom_cards">
   <property name="icon">
    <iconset theme="list-add"/>
   </property>
   <property name="text">
    <string>Add custom cards</string>
   </property>
  </action>
 </widget>
 <customwidgets>
  <customwidget>
   <class>CentralWidget</class>
   <extends>QWidget</extends>
   <header>mtg_proxy_printer.ui.central_widget</header>
Added mtg_proxy_printer/resources/ui/set_editor_widget.ui.
































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>SetEditor</class>
 <widget class="QWidget" name="SetEditor">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>280</width>
    <height>36</height>
   </rect>
  </property>
  <layout class="QHBoxLayout" name="horizontal_layout">
   <property name="spacing">
    <number>0</number>
   </property>
   <property name="leftMargin">
    <number>0</number>
   </property>
   <property name="topMargin">
    <number>0</number>
   </property>
   <property name="rightMargin">
    <number>0</number>
   </property>
   <item>
    <widget class="QLineEdit" name="name_editor">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>15</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="placeholderText">
      <string>Set name</string>
     </property>
    </widget>
   </item>
   <item>
    <widget class="QLabel" name="opening_parenthesis">
     <property name="text">
      <string notr="true">(</string>
     </property>
    </widget>
   </item>
   <item>
    <widget class="QLineEdit" name="code_edit">
     <property name="sizePolicy">
      <sizepolicy hsizetype="Expanding" vsizetype="Fixed">
       <horstretch>7</horstretch>
       <verstretch>0</verstretch>
      </sizepolicy>
     </property>
     <property name="inputMethodHints">
      <set>Qt::ImhUppercaseOnly</set>
     </property>
     <property name="maxLength">
      <number>6</number>
     </property>
     <property name="placeholderText">
      <string>CODE</string>
     </property>
    </widget>
   </item>
   <item>
    <widget class="QLabel" name="closing_parenthesis">
     <property name="text">
      <string notr="true">)</string>
     </property>
    </widget>
   </item>
  </layout>
 </widget>
 <tabstops>
  <tabstop>name_editor</tabstop>
  <tabstop>code_edit</tabstop>
 </tabstops>
 <resources/>
 <connections/>
</ui>
Changes to mtg_proxy_printer/save_file_migrations.py.
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
          card_id TEXT NOT NULL PRIMARY KEY CHECK (card_id GLOB '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]'),
          image BLOB NOT NULL,  -- The raw image content
          name TEXT NOT NULL DEFAULT '',
          set_name TEXT NOT NULL DEFAULT '',
          set_code TEXT NOT NULL DEFAULT '',
          collector_number TEXT NOT NULL DEFAULT '',
          is_front BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT TRUE,
          oversized BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT FALSE,
          other_face TEXT REFERENCES CustomCardData(card_id)  -- If this is a DFC, this references the other side
        )"""),
        "ALTER TABLE Card RENAME TO Card_old",
        textwrap.dedent("""\
        CREATE TABLE Page (
          page INTEGER NOT NULL PRIMARY KEY CHECK (page > 0),
          image_size TEXT NOT NULL CHECK(image_size <> '')







<







220
221
222
223
224
225
226

227
228
229
230
231
232
233
          card_id TEXT NOT NULL PRIMARY KEY CHECK (card_id GLOB '[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9]-[a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9][a-f0-9]'),
          image BLOB NOT NULL,  -- The raw image content
          name TEXT NOT NULL DEFAULT '',
          set_name TEXT NOT NULL DEFAULT '',
          set_code TEXT NOT NULL DEFAULT '',
          collector_number TEXT NOT NULL DEFAULT '',
          is_front BOOLEAN_INTEGER NOT NULL CHECK (is_front IN (TRUE, FALSE)) DEFAULT TRUE,

          other_face TEXT REFERENCES CustomCardData(card_id)  -- If this is a DFC, this references the other side
        )"""),
        "ALTER TABLE Card RENAME TO Card_old",
        textwrap.dedent("""\
        CREATE TABLE Page (
          page INTEGER NOT NULL PRIMARY KEY CHECK (page > 0),
          image_size TEXT NOT NULL CHECK(image_size <> '')
Changes to mtg_proxy_printer/sqlite_helpers.py.
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import re
import sqlite3
import sys
import textwrap
import typing

from hamcrest import assert_that, contains_exactly
import pint

from mtg_proxy_printer.units_and_sizes import unit_registry
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

__all__ = [







<







20
21
22
23
24
25
26

27
28
29
30
31
32
33
import re
import sqlite3
import sys
import textwrap
import typing

from hamcrest import assert_that, contains_exactly


from mtg_proxy_printer.units_and_sizes import unit_registry
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

__all__ = [
Changes to mtg_proxy_printer/ui/add_card.py.
16
17
18
19
20
21
22

23
24
25
26
27

28
29
30
31
32
33
34

from typing import Union, Type, Optional

from PyQt5.QtCore import QStringListModel, pyqtSlot as Slot, pyqtSignal as Signal, Qt, QItemSelectionModel, QItemSelection
from PyQt5.QtWidgets import QWidget, QDialogButtonBox
from PyQt5.QtGui import QIcon


from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
import mtg_proxy_printer.model.string_list
import mtg_proxy_printer.model.carddb
import mtg_proxy_printer.model.document
import mtg_proxy_printer.settings

from mtg_proxy_printer.ui.common import load_ui_from_file

from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

try:







>





>







16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

from typing import Union, Type, Optional

from PyQt5.QtCore import QStringListModel, pyqtSlot as Slot, pyqtSignal as Signal, Qt, QItemSelectionModel, QItemSelection
from PyQt5.QtWidgets import QWidget, QDialogButtonBox
from PyQt5.QtGui import QIcon

import mtg_proxy_printer.model.card
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
import mtg_proxy_printer.model.string_list
import mtg_proxy_printer.model.carddb
import mtg_proxy_printer.model.document
import mtg_proxy_printer.settings
from mtg_proxy_printer.model.card import MTGSet
from mtg_proxy_printer.ui.common import load_ui_from_file

from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

try:
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
            self.ui.collector_number_list.selectionModel().clearSelection()
            return
        logger.debug("Currently selected set changed.")
        current_model_index = current.indexes()[0]
        valid = current_model_index.isValid()
        self.ui.collector_number_box.setEnabled(valid)
        if valid:
            set_abbr = current_model_index.data(ItemDataRole.EditRole)
            collector_numbers = self.card_database.find_collector_numbers_matching(
                self.current_card_name, set_abbr, self.current_language
            )
            logger.debug(
                f'Selected: "{set_abbr}", language: {self.current_language}, matching {len(collector_numbers)} prints')
            self.collector_number_model.setStringList(collector_numbers)
            self.ui.collector_number_list.selectionModel().select(
                self.collector_number_model.createIndex(0, 0), QItemSelectionModel.ClearAndSelect)

    @Slot(QItemSelection)
    def collector_number_list_selection_changed(self, current: QItemSelection):
        self.ui.button_box.button(StandardButton.Ok).setEnabled(bool(current.indexes()))







|

|


|







144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
            self.ui.collector_number_list.selectionModel().clearSelection()
            return
        logger.debug("Currently selected set changed.")
        current_model_index = current.indexes()[0]
        valid = current_model_index.isValid()
        self.ui.collector_number_box.setEnabled(valid)
        if valid:
            mtg_set: MTGSet = current_model_index.data(ItemDataRole.EditRole)
            collector_numbers = self.card_database.find_collector_numbers_matching(
                self.current_card_name, mtg_set.code, self.current_language
            )
            logger.debug(
                f'Selected: "{mtg_set.code}", language: {self.current_language}, matching {len(collector_numbers)} prints')
            self.collector_number_model.setStringList(collector_numbers)
            self.ui.collector_number_list.selectionModel().select(
                self.collector_number_model.createIndex(0, 0), QItemSelectionModel.ClearAndSelect)

    @Slot(QItemSelection)
    def collector_number_list_selection_changed(self, current: QItemSelection):
        self.ui.button_box.button(StandardButton.Ok).setEnabled(bool(current.indexes()))
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
        if add_opposing_faces_enabled and (opposing_face := self.card_database.get_opposing_face(card)) is not None:
            logger.info(
                "Card is double faced and adding opposing faces is enabled, automatically adding the other face.")
            self._log_added_card(opposing_face, copies)
            self.request_action.emit(ActionAddCard(opposing_face, copies))

    @staticmethod
    def _log_added_card(card: mtg_proxy_printer.model.carddb.Card, copies: int):
        logger.debug(f"Adding {copies}× [{card.set.code.upper()}:{card.collector_number}] {card.name}")

    @Slot()
    def reset(self):
        logger.info("User hit the Reset button, resetting…")
        self.ui.collector_number_list.clearSelection()
        self.collector_number_model.setStringList([])







|







235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
        if add_opposing_faces_enabled and (opposing_face := self.card_database.get_opposing_face(card)) is not None:
            logger.info(
                "Card is double faced and adding opposing faces is enabled, automatically adding the other face.")
            self._log_added_card(opposing_face, copies)
            self.request_action.emit(ActionAddCard(opposing_face, copies))

    @staticmethod
    def _log_added_card(card: mtg_proxy_printer.model.card.Card, copies: int):
        logger.debug(f"Adding {copies}× [{card.set.code.upper()}:{card.collector_number}] {card.name}")

    @Slot()
    def reset(self):
        logger.info("User hit the Reset button, resetting…")
        self.ui.collector_number_list.clearSelection()
        self.collector_number_model.setStringList([])
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
        else:
            return None

    @property
    def current_set_name(self) -> Optional[str]:
        selected = self.ui.set_name_list.selectedIndexes()
        if selected:
            return selected[0].data(ItemDataRole.EditRole)
        else:
            return None

    @property
    def current_collector_number(self) -> Optional[str]:
        selected = self.ui.collector_number_list.selectedIndexes()
        if selected:







|







266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
        else:
            return None

    @property
    def current_set_name(self) -> Optional[str]:
        selected = self.ui.set_name_list.selectedIndexes()
        if selected:
            return selected[0].data(ItemDataRole.EditRole).code
        else:
            return None

    @property
    def current_collector_number(self) -> Optional[str]:
        selected = self.ui.collector_number_list.selectedIndexes()
        if selected:
Changes to mtg_proxy_printer/ui/cache_cleanup_wizard.py.
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

32
33
34
35
36
37
38
39
40
41
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import dataclasses
import datetime
import enum
import functools
import math
import pathlib
import typing

from PyQt5.QtCore import QAbstractTableModel, Qt, QModelIndex, QObject, QBuffer, QIODevice, QItemSelectionModel, QSize
from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtWidgets import QWidget, QWizard, QWizardPage

import mtg_proxy_printer.settings
from mtg_proxy_printer.natsort import NaturallySortedSortFilterProxyModel
from mtg_proxy_printer.model.carddb import CardDatabase, Card, MTGSet

from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.imagedb_files import CacheContent as ImageCacheContent, ImageKey
from mtg_proxy_printer.ui.common import load_ui_from_file, format_size, WizardBase
from mtg_proxy_printer.units_and_sizes import OptStr
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

try:
    from mtg_proxy_printer.ui.generated.cache_cleanup_wizard.card_filter_page import Ui_CardFilterPage







<




|
|




|
>


|







13
14
15
16
17
18
19

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import dataclasses
import datetime
import enum

import math
import pathlib
import typing

from PyQt5.QtCore import QAbstractTableModel, Qt, QModelIndex, QObject, QItemSelectionModel, QSize
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QWidget, QWizard, QWizardPage

import mtg_proxy_printer.settings
from mtg_proxy_printer.natsort import NaturallySortedSortFilterProxyModel
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.card import MTGSet, Card
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.imagedb_files import CacheContent as ImageCacheContent, ImageKey
from mtg_proxy_printer.ui.common import load_ui_from_file, format_size, WizardBase, get_card_image_tooltip
from mtg_proxy_printer.units_and_sizes import OptStr
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

try:
    from mtg_proxy_printer.ui.generated.cache_cleanup_wizard.card_filter_page import Ui_CardFilterPage
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
    "CacheCleanupWizard",
]
INVALID_INDEX = QModelIndex()
SelectRows = QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows
ItemDataRole = Qt.ItemDataRole
Orientation = Qt.Orientation


@functools.lru_cache(maxsize=256)
def get_image_for_tooltip_display(path: pathlib.Path, card_name: OptStr = None) -> str:
    scaling_factor = 3
    source = QPixmap(str(path))
    pixmap = source.scaled(
        source.width() // scaling_factor, source.height() // scaling_factor,
        Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
    buffer = QBuffer()
    buffer.open(QIODevice.OpenModeFlag.WriteOnly)
    pixmap.save(buffer, "PNG", quality=100)
    image = buffer.data().toBase64().data().decode()
    card_name = f'<p style="text-align:center">{card_name}</p><br>' if card_name else ""
    tooltip_text = f'{card_name}<img src="data:image/png;base64,{image}">'
    return tooltip_text


class KnownCardColumns(enum.IntEnum):
    Name = 0
    Set = enum.auto()
    CollectorNumber = enum.auto()
    IsHidden = enum.auto()
    IsFront = enum.auto()







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







50
51
52
53
54
55
56
















57
58
59
60
61
62
63
    "CacheCleanupWizard",
]
INVALID_INDEX = QModelIndex()
SelectRows = QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows
ItemDataRole = Qt.ItemDataRole
Orientation = Qt.Orientation


















class KnownCardColumns(enum.IntEnum):
    Name = 0
    Set = enum.auto()
    CollectorNumber = enum.auto()
    IsHidden = enum.auto()
    IsFront = enum.auto()
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
    def __post_init__(self):
        super().__init__(self._parent)  # Call QObject.__init__() without interfering with the dataclass internals

    def data(self, column: int, role: ItemDataRole):
        if column == KnownCardColumns.Name and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole):
            data = self.name
        elif column == KnownCardColumns.Name and role == ItemDataRole.ToolTipRole:
            data = get_image_for_tooltip_display(self.path, self.preferred_language_name)
        elif column == KnownCardColumns.Set:
            data = self.set.data(role)
        elif column == KnownCardColumns.CollectorNumber and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole):
            data = self.collector_number
        elif column == KnownCardColumns.IsHidden and role == ItemDataRole.DisplayRole:
            data = self.tr("Yes") if self.is_hidden else self.tr("No")
        elif column == KnownCardColumns.IsHidden and role == ItemDataRole.ToolTipRole and self.is_hidden:







|







84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
    def __post_init__(self):
        super().__init__(self._parent)  # Call QObject.__init__() without interfering with the dataclass internals

    def data(self, column: int, role: ItemDataRole):
        if column == KnownCardColumns.Name and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole):
            data = self.name
        elif column == KnownCardColumns.Name and role == ItemDataRole.ToolTipRole:
            data = get_card_image_tooltip(self.path, self.preferred_language_name)
        elif column == KnownCardColumns.Set:
            data = self.set.data(role)
        elif column == KnownCardColumns.CollectorNumber and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole):
            data = self.collector_number
        elif column == KnownCardColumns.IsHidden and role == ItemDataRole.DisplayRole:
            data = self.tr("Yes") if self.is_hidden else self.tr("No")
        elif column == KnownCardColumns.IsHidden and role == ItemDataRole.ToolTipRole and self.is_hidden:
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
            image.absolute_path.stat().st_size, image.absolute_path
        )

    def data(self, column: int, role: ItemDataRole):
        if column == UnknownCardColumns.ScryfallId and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole):
            data = self.scryfall_id
        elif column == UnknownCardColumns.ScryfallId and role == ItemDataRole.ToolTipRole:
            data = get_image_for_tooltip_display(self.path)
        elif column == UnknownCardColumns.IsFront and role == ItemDataRole.DisplayRole:
            data = self.tr("Front") if self.is_front else self.tr("Back")
        elif column == UnknownCardColumns.IsFront and role == ItemDataRole.EditRole:
            data = self.is_front
        elif column == UnknownCardColumns.HasHighResolution and role == ItemDataRole.EditRole:
            data = self.has_high_resolution
        elif column == UnknownCardColumns.HasHighResolution and role == ItemDataRole.DisplayRole:







|







220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
            image.absolute_path.stat().st_size, image.absolute_path
        )

    def data(self, column: int, role: ItemDataRole):
        if column == UnknownCardColumns.ScryfallId and role in (ItemDataRole.DisplayRole, ItemDataRole.EditRole):
            data = self.scryfall_id
        elif column == UnknownCardColumns.ScryfallId and role == ItemDataRole.ToolTipRole:
            data = get_card_image_tooltip(self.path)
        elif column == UnknownCardColumns.IsFront and role == ItemDataRole.DisplayRole:
            data = self.tr("Front") if self.is_front else self.tr("Back")
        elif column == UnknownCardColumns.IsFront and role == ItemDataRole.EditRole:
            data = self.is_front
        elif column == UnknownCardColumns.HasHighResolution and role == ItemDataRole.EditRole:
            data = self.has_high_resolution
        elif column == UnknownCardColumns.HasHighResolution and role == ItemDataRole.DisplayRole:
486
487
488
489
490
491
492
493
494
495
    def reject(self) -> None:
        super().reject()
        logger.info("User canceled the cache cleanup.")
        self._clear_tooltip_cache()

    @staticmethod
    def _clear_tooltip_cache():
        logger.debug(f"Tooltip cache efficiency: {get_image_for_tooltip_display.cache_info()}")
        # Free memory by clearing the cached, base64 encoded PNGs used for tooltip display
        get_image_for_tooltip_display.cache_clear()







|

|
470
471
472
473
474
475
476
477
478
479
    def reject(self) -> None:
        super().reject()
        logger.info("User canceled the cache cleanup.")
        self._clear_tooltip_cache()

    @staticmethod
    def _clear_tooltip_cache():
        logger.debug(f"Tooltip cache efficiency: {get_card_image_tooltip.cache_info()}")
        # Free memory by clearing the cached, base64 encoded PNGs used for tooltip display
        get_card_image_tooltip.cache_clear()
Added mtg_proxy_printer/ui/card_list_table_view.py.






































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.

import math

from PyQt5.QtCore import Qt, pyqtSignal as Signal, pyqtSlot as Slot
from PyQt5.QtWidgets import QTableView, QWidget

from mtg_proxy_printer.model.card_list import CardListColumns, CardListModel
from mtg_proxy_printer.natsort import NaturallySortedSortFilterProxyModel
from mtg_proxy_printer.ui.item_delegates import CollectorNumberEditorDelegate, BoundedCopiesSpinboxDelegate, \
    CardSideSelectionDelegate, SetEditorDelegate, LanguageEditorDelegate

from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger
ItemDataRole = Qt.ItemDataRole


class CardListTableView(QTableView):
    """
    This table view shows a CardListModel, and sets up all item delegates used for proper display and validation.

    """
    changed_selection_is_empty = Signal(bool)

    def __init__(self, parent: QWidget = None):
        super().__init__(parent)
        self._column_delegates = (
            self._setup_combo_box_item_delegate(),
            self._setup_language_delegate(),
            self._setup_copies_delegate(),
            self._setup_side_delegate(),
            self._setup_set_delegate(),
        )
        self.sort_model = NaturallySortedSortFilterProxyModel(self)

    def setModel(self, model: CardListModel):
        self.sort_model.setSourceModel(model)
        super().setModel(self.sort_model)
        # Has to be set up here, because setModel() implicitly creates the QItemSelectionModel
        self.selectionModel().selectionChanged.connect(self._on_selection_changed)
        # Now that the model is set and columns are discovered, set the column widths to reasonable values.
        self._setup_default_column_widths()

    @Slot()
    def _on_selection_changed(self):
        selection = self.selectionModel().selection()
        is_empty = selection.isEmpty()
        logger.debug(f"Selection changed: Currently selected cells: {selection.count()}")
        self.changed_selection_is_empty.emit(is_empty)

    def _setup_language_delegate(self):
        delegate = LanguageEditorDelegate(self)
        self.setItemDelegateForColumn(CardListColumns.Language, delegate)
        return delegate

    def _setup_combo_box_item_delegate(self) -> CollectorNumberEditorDelegate:
        delegate = CollectorNumberEditorDelegate(self)
        self.setItemDelegateForColumn(CardListColumns.CollectorNumber, delegate)
        return delegate

    def _setup_copies_delegate(self) -> BoundedCopiesSpinboxDelegate:
        delegate = BoundedCopiesSpinboxDelegate(self)
        self.setItemDelegateForColumn(CardListColumns.Copies, delegate)
        return delegate

    def _setup_side_delegate(self) -> CardSideSelectionDelegate:
        delegate = CardSideSelectionDelegate(self)
        self.setItemDelegateForColumn(CardListColumns.IsFront, delegate)
        return delegate

    def _setup_set_delegate(self) -> SetEditorDelegate:
        delegate = SetEditorDelegate(self)
        self.setItemDelegateForColumn(CardListColumns.Set, delegate)
        return delegate

    def _setup_default_column_widths(self):
        # These factors are empirically determined to give reasonable column sizes
        for column, scaling_factor in (
                (CardListColumns.Copies, 0.9),
                (CardListColumns.CardName, 2),
                (CardListColumns.Set, 2.75),
                (CardListColumns.CollectorNumber, 0.95),
                (CardListColumns.Language, 0.9)):
            new_size = math.floor(self.columnWidth(column) * scaling_factor)
            self.setColumnWidth(column, new_size)
Changes to mtg_proxy_printer/ui/common.py.
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

27
28
29
30
31
32
33
34
35
36
37
38
39
40

41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56























57
58
59
60
61
62
63
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import pathlib
import platform
import typing

from PyQt5.QtCore import QFile, QUrl, QObject, QSize, QCoreApplication
from PyQt5.QtWidgets import QLabel, QWizard, QWidget, QGraphicsColorizeEffect, QTextEdit
from PyQt5.QtGui import QIcon
# noinspection PyUnresolvedReferences
from PyQt5 import uic


from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

__all__ = [
    "RESOURCE_PATH_PREFIX",
    "ICON_PATH_PREFIX",
    "HAS_COMPILED_RESOURCES",
    "highlight_widget",
    "BlockedSignals",
    "set_url_label",
    "load_ui_from_file",
    "format_size",
    "WizardBase",

]

try:
    import mtg_proxy_printer.ui.compiled_resources
except ModuleNotFoundError:
    RESOURCE_PATH_PREFIX = str(pathlib.Path(__file__).resolve().parent.with_name("resources"))
    ICON_PATH_PREFIX = str(pathlib.Path(__file__).resolve().parent.with_name("resources") / "icons")
    HAS_COMPILED_RESOURCES = False
else:
    import atexit
    # Compiled resources found, so use it.
    RESOURCE_PATH_PREFIX = ":"
    ICON_PATH_PREFIX = ":/icons"
    HAS_COMPILED_RESOURCES = True
    atexit.register(mtg_proxy_printer.ui.compiled_resources.qCleanupResources)

























def highlight_widget(widget: QWidget) -> None:
    """Sets a visual highlight on the given widget to make it stand out"""
    palette = widget.palette()
    highlight_color = palette.color(palette.currentColorGroup(), palette.ColorRole.Highlight)
    effect = QGraphicsColorizeEffect(widget)
    effect.setColor(highlight_color)







|
|

|

|

|



>










<



>





|
|









>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.

import functools
from pathlib import Path
import platform
from typing import Union, Dict

from PyQt5.QtCore import QFile, QUrl, QObject, QSize, QCoreApplication, Qt, QBuffer, QIODevice
from PyQt5.QtWidgets import QLabel, QWizard, QWidget, QGraphicsColorizeEffect, QTextEdit
from PyQt5.QtGui import QIcon, QPixmap
# noinspection PyUnresolvedReferences
from PyQt5 import uic

from mtg_proxy_printer.units_and_sizes import OptStr
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

__all__ = [
    "RESOURCE_PATH_PREFIX",
    "ICON_PATH_PREFIX",
    "HAS_COMPILED_RESOURCES",
    "highlight_widget",
    "BlockedSignals",

    "load_ui_from_file",
    "format_size",
    "WizardBase",
    "get_card_image_tooltip",
]

try:
    import mtg_proxy_printer.ui.compiled_resources
except ModuleNotFoundError:
    RESOURCE_PATH_PREFIX = str(Path(__file__).resolve().parent.with_name("resources"))
    ICON_PATH_PREFIX = str(Path(__file__).resolve().parent.with_name("resources") / "icons")
    HAS_COMPILED_RESOURCES = False
else:
    import atexit
    # Compiled resources found, so use it.
    RESOURCE_PATH_PREFIX = ":"
    ICON_PATH_PREFIX = ":/icons"
    HAS_COMPILED_RESOURCES = True
    atexit.register(mtg_proxy_printer.ui.compiled_resources.qCleanupResources)


@functools.lru_cache(maxsize=256)
def get_card_image_tooltip(image: Union[bytes, Path], card_name: OptStr = None, scaling_factor: int = 3) -> str:
    """
    Returns a tooltip string showing a scaled down image for the given path.
    :param image: Filesystem path to the image file or raw image content as bytes
    :param card_name: Optional card name. If given, it is centered above the image
    :param scaling_factor: Scales the source by factor to 1/scaling_factor
    :return: HTML fragment with the image embedded as a base64 encoded PNG
    """
    if isinstance(image, bytes):
        source = QPixmap()
        source.loadFromData(image)
    else:
        source = QPixmap(str(image))
    pixmap = source.scaledToWidth(source.width() // scaling_factor, Qt.TransformationMode.SmoothTransformation)
    buffer = QBuffer()
    buffer.open(QIODevice.OpenModeFlag.WriteOnly)
    pixmap.save(buffer, "PNG", quality=100)
    image = buffer.data().toBase64().data().decode()
    card_name = f'<p style="text-align:center">{card_name}</p><br>' if card_name else ""
    return f'{card_name}<img src="data:image/png;base64,{image}">'


def highlight_widget(widget: QWidget) -> None:
    """Sets a visual highlight on the given widget to make it stand out"""
    palette = widget.palette()
    highlight_color = palette.color(palette.currentColorGroup(), palette.ColorRole.Highlight)
    effect = QGraphicsColorizeEffect(widget)
    effect.setColor(highlight_color)
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101

    def __enter__(self):
        self.qt_object.blockSignals(True)

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.qt_object.blockSignals(False)


def set_url_label(label: QLabel, path: pathlib.Path, display_text: str = None):

    url = QUrl.fromLocalFile(str(path.expanduser()))
    if not label.openExternalLinks():
        # The openExternalLinks property is not set in the UI file, so fail fast instead of doing workarounds.
        raise ValueError(
            f"QLabel with disabled openExternalLinks property used to display an external URL. This won’t work, so "
            f"fail now. Label: {label}, Text: {label.text()}")
    if not display_text:
        display_text = str(path)
    label.setText(f"""<a href="{url.path(QUrl.FullyEncoded):s}">{display_text:s}</a>""")


def load_ui_from_file(name: str):
    """
    Returns the Ui class type from uic.loadUiType(), loading the ui file with the given name.

    :param name: Path to the UI file
    :return: class implementing the requested Ui







<
<
<
<
<
<
<
<
<
<
<
<
<







99
100
101
102
103
104
105













106
107
108
109
110
111
112

    def __enter__(self):
        self.qt_object.blockSignals(True)

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.qt_object.blockSignals(False)















def load_ui_from_file(name: str):
    """
    Returns the Ui class type from uic.loadUiType(), loading the ui file with the given name.

    :param name: Path to the UI file
    :return: class implementing the requested Ui
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
            return template.format(size=f"{size:3.2f}", unit=unit)
        size /= 1024
    return template.format(size=f"{size:.2f}", unit="YiB")


class WizardBase(QWizard):
    """Base class for wizards based on QWizard"""
    BUTTON_ICONS: typing.Dict[QWizard.WizardButton, str] = {}

    def __init__(self, window_size: QSize, parent: QWidget, flags):
        super().__init__(parent, flags)
        if platform.system() == "Windows":
            # Avoid Aero style on Windows, which does not support dark mode
            target_style = QWizard.WizardStyle.ModernStyle
            logger.debug(f"Creating a QWizard on Windows, explicitly setting style to {target_style}")







|







142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
            return template.format(size=f"{size:3.2f}", unit=unit)
        size /= 1024
    return template.format(size=f"{size:.2f}", unit="YiB")


class WizardBase(QWizard):
    """Base class for wizards based on QWizard"""
    BUTTON_ICONS: Dict[QWizard.WizardButton, str] = {}

    def __init__(self, window_size: QSize, parent: QWidget, flags):
        super().__init__(parent, flags)
        if platform.system() == "Windows":
            # Avoid Aero style on Windows, which does not support dark mode
            target_style = QWizard.WizardStyle.ModernStyle
            logger.debug(f"Creating a QWizard on Windows, explicitly setting style to {target_style}")
Added mtg_proxy_printer/ui/custom_card_import_dialog.py.


























































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# Copyright (C) 2020-2024 Thomas Hess <thomas.hess@udo.edu>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from collections import Counter
from pathlib import Path
import typing

from PyQt5.QtCore import Qt, pyqtSignal as Signal, pyqtSlot as Slot
from PyQt5.QtGui import QDragEnterEvent, QDropEvent, QPixmap
from PyQt5.QtWidgets import QDialog, QWidget, QFileDialog, QPushButton

from mtg_proxy_printer.document_controller import DocumentAction
from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList
from mtg_proxy_printer.model.card import CustomCard
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.units_and_sizes import CardSizes

try:
    from mtg_proxy_printer.ui.generated.custom_card_import_dialog import Ui_CustomCardImportDialog
except ModuleNotFoundError:
    from mtg_proxy_printer.ui.common import load_ui_from_file
    Ui_CustomCardImportDialog = load_ui_from_file("custom_card_import_dialog")

from mtg_proxy_printer.model.card_list import CardListModel
import mtg_proxy_printer.units_and_sizes
from mtg_proxy_printer.app_dirs import data_directories
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger
TransformationMode = Qt.TransformationMode
EventTypes = typing.Union[QDragEnterEvent, QDropEvent]


class CustomCardImportDialog(QDialog):

    request_action = Signal(DocumentAction)

    def __init__(self, document: Document, parent: QWidget = None, flags=Qt.WindowFlags()):
        super().__init__(parent, flags)
        self.ui = ui = Ui_CustomCardImportDialog()
        ui.setupUi(self)
        self.ok_button.setEnabled(False)
        ui.remove_selected.setDisabled(True)
        self.model = model = CardListModel(document)
        model.request_action.connect(self.request_action)
        ui.card_table.setModel(model)
        ui.card_table.selectionModel().selectionChanged.connect(self.on_card_table_selection_changed)
        model.rowsInserted.connect(self.on_rows_inserted)
        model.rowsRemoved.connect(self.on_rows_removed)
        model.modelReset.connect(self.on_rows_removed)
        logger.info(f"Created {self.__class__.__name__} instance")

    @property
    def currently_selected_cards(self):
        return self.ui.card_table.selectionModel().selection()

    @property
    def ok_button(self) -> QPushButton:
        return self.ui.button_box.button(self.ui.button_box.StandardButton.Ok)

    @staticmethod
    def dragdrop_acceptable(event: EventTypes) -> bool:
        urls = event.mimeData().urls()
        local_paths = [Path(url.toLocalFile()) for url in urls]
        acceptable = local_paths and all((path.is_file() for path in local_paths))
        return acceptable

    @Slot()
    def on_card_table_selection_changed(self):
        cards_selected = self.currently_selected_cards.isEmpty()
        self.ui.remove_selected.setDisabled(cards_selected)

    @Slot()
    def on_rows_inserted(self):
        self.ok_button.setEnabled(True)

    @Slot()
    def on_rows_removed(self):
        has_cards = bool(self.model.rowCount())
        self.ok_button.setEnabled(has_cards)

    @Slot()
    def on_add_cards_clicked(self):
        logger.info("User about to add additional card images")
        default_path = getattr(data_directories, "user_pictures_dir", str(Path.home()))
        files, _ = QFileDialog.getOpenFileNames(self, self.tr("Import custom cards"), default_path)
        logger.debug(f"User selected {len(files)} paths")
        file_paths = list(map(Path, files))
        cards = self.create_cards(file_paths)
        self.model.add_cards(cards)
        logger.info(f"Added {len(cards)} cards from the selected files.")

    @Slot()
    def on_remove_selected_clicked(self):
        logger.info("User about to delete all selected cards from the card table")
        self.model.remove_multi_selection(self.currently_selected_cards)

    @Slot()
    def on_set_copies_to_clicked(self):
        value = self.ui.card_copies.value()
        selection = self.currently_selected_cards
        self.model.set_copies_to(selection, value)
        scope = "All" if selection.isEmpty() else "Selected"
        logger.info(f"{scope} copy counts set to {value}")

    def show_from_drop_event(self, event: QDropEvent):
        urls = event.mimeData().urls()
        local_paths = [Path(url.toLocalFile()) for url in urls]
        cards = self.create_cards(local_paths)
        self.model.add_cards(cards)
        self.show()

    def create_cards(self, paths: typing.List[Path]) -> typing.Counter[CustomCard]:
        result: typing.Counter[CustomCard] = Counter()
        regular = mtg_proxy_printer.units_and_sizes.CardSizes.REGULAR
        card_db = self.model.card_db
        for path in paths:
            if not QPixmap(str(path)).isNull():
                # This read should stay guarded by the Pixmap constructor to prevent accidental DoS by reading huge files
                pixmap_bytes = path.read_bytes()
                card = card_db.get_custom_card(
                    path.stem, "" , "", "", regular, True, pixmap_bytes)
                result[card] += 1
        return result

    def accept(self):
        action = ActionImportDeckList(self.model.as_cards(), False)
        self.request_action.emit(action)
        super().accept()
Changes to mtg_proxy_printer/ui/deck_import_wizard.py.
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.



import itertools
import math
import pathlib
import re
import typing
import urllib.error
import urllib.parse

from PyQt5.QtCore import pyqtSlot as Slot, pyqtSignal as Signal, pyqtProperty as Property, QStringListModel, Qt, \
    QItemSelection, QSize, QUrl
from PyQt5.QtGui import QValidator, QIcon, QDesktopServices
from PyQt5.QtWidgets import QWizard, QFileDialog, QMessageBox, QWizardPage, QWidget, QRadioButton


from mtg_proxy_printer.units_and_sizes import SectionProxy
import mtg_proxy_printer.settings
from mtg_proxy_printer.decklist_parser import re_parsers, common, csv_parsers
from mtg_proxy_printer.decklist_downloader import IsIdentifyingDeckUrlValidator, AVAILABLE_DOWNLOADERS, \
    get_downloader_class, ParserBase
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.card_list import CardListModel, CardListColumns
from mtg_proxy_printer.natsort import NaturallySortedSortFilterProxyModel
from mtg_proxy_printer.ui.common import load_ui_from_file, format_size, WizardBase, markdown_to_html
from mtg_proxy_printer.ui.item_delegates import CardListComboBoxItemDelegate, SpinboxItemDelegate
from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList

try:
    from mtg_proxy_printer.ui.generated.deck_import_wizard.load_list_page import Ui_LoadListPage
    from mtg_proxy_printer.ui.generated.deck_import_wizard.parser_result_page import Ui_SummaryPage
    from mtg_proxy_printer.ui.generated.deck_import_wizard.select_deck_parser_page import Ui_SelectDeckParserPage
except ModuleNotFoundError:







<







|



>







|


<







12
13
14
15
16
17
18

19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

41
42
43
44
45
46
47
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.



import itertools

import pathlib
import re
import typing
import urllib.error
import urllib.parse

from PyQt5.QtCore import pyqtSlot as Slot, pyqtSignal as Signal, pyqtProperty as Property, QStringListModel, Qt, \
    QSize, QUrl
from PyQt5.QtGui import QValidator, QIcon, QDesktopServices
from PyQt5.QtWidgets import QWizard, QFileDialog, QMessageBox, QWizardPage, QWidget, QRadioButton

from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.units_and_sizes import SectionProxy
import mtg_proxy_printer.settings
from mtg_proxy_printer.decklist_parser import re_parsers, common, csv_parsers
from mtg_proxy_printer.decklist_downloader import IsIdentifyingDeckUrlValidator, AVAILABLE_DOWNLOADERS, \
    get_downloader_class, ParserBase
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.card_list import CardListModel
from mtg_proxy_printer.natsort import NaturallySortedSortFilterProxyModel
from mtg_proxy_printer.ui.common import load_ui_from_file, format_size, WizardBase, markdown_to_html

from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList

try:
    from mtg_proxy_printer.ui.generated.deck_import_wizard.load_list_page import Ui_LoadListPage
    from mtg_proxy_printer.ui.generated.deck_import_wizard.parser_result_page import Ui_SummaryPage
    from mtg_proxy_printer.ui.generated.deck_import_wizard.select_deck_parser_page import Ui_SelectDeckParserPage
except ModuleNotFoundError:
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
        self.setField("selected_parser", parser)

    @selected_parser.getter
    def selected_parser(self) -> common.ParserBase:
        logger.debug(f"Reading selected parser {self._selected_parser.__class__.__name__}")
        return self._selected_parser

    def __init__(self, card_db: CardDatabase, image_db: ImageDatabase, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.ui = Ui_SelectDeckParserPage()
        self.ui.setupUi(self)
        self.card_db = card_db
        self.image_db = image_db
        self._selected_parser = None
        self.parser_creator: typing.Callable[[], None] = (lambda: None)
        group_names = ', '.join(sorted(re_parsers.GenericRegularExpressionDeckParser.SUPPORTED_GROUP_NAMES))
        custom_re_input = self.ui.custom_re_input
        custom_re_input.setToolTip(custom_re_input.toolTip().format(group_names=group_names))
        custom_re_input.setWhatsThis(markdown_to_html(custom_re_input.whatsThis()))
        custom_re_input.setValidator(IsDecklistParserRegularExpressionValidator(self))







|



|
|







310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
        self.setField("selected_parser", parser)

    @selected_parser.getter
    def selected_parser(self) -> common.ParserBase:
        logger.debug(f"Reading selected parser {self._selected_parser.__class__.__name__}")
        return self._selected_parser

    def __init__(self, document: Document, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.ui = Ui_SelectDeckParserPage()
        self.ui.setupUi(self)
        self.card_db = document.card_db
        self.image_db = document.image_db
        self._selected_parser = None
        self.parser_creator: typing.Callable[[], None] = (lambda: None)
        group_names = ', '.join(sorted(re_parsers.GenericRegularExpressionDeckParser.SUPPORTED_GROUP_NAMES))
        custom_re_input = self.ui.custom_re_input
        custom_re_input.setToolTip(custom_re_input.toolTip().format(group_names=group_names))
        custom_re_input.setWhatsThis(markdown_to_html(custom_re_input.whatsThis()))
        custom_re_input.setValidator(IsDecklistParserRegularExpressionValidator(self))
436
437
438
439
440
441
442
443








444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
        # here.
        self.selected_parser.incompatible_file_format.connect(self.wizard().on_incompatible_deck_file_selected)
        logger.info(f"Created parser: {self.selected_parser.__class__.__name__}")
        return self.isComplete()


class SummaryPage(QWizardPage):
    def __init__(self, card_db: CardDatabase, *args, **kwargs):








        super().__init__(*args, **kwargs)
        self.ui = Ui_SummaryPage()
        self.ui.setupUi(self)
        self.setCommitPage(True)
        self.card_list = CardListModel(card_db, self)
        self.card_list_sort_model = self._create_sort_model(self.card_list)
        self.card_list.oversized_card_count_changed.connect(self._update_accept_button_on_oversized_card_count_changed)
        self.ui.parsed_cards_table.setModel(self.card_list_sort_model)
        self.delegates = self._setup_parsed_cards_table()
        self.selected_cells_count = 0
        self.registerField("should_replace_document", self.ui.should_replace_document)
        self.ui.should_replace_document.toggled[bool].connect(
            self._update_accept_button_on_replace_document_option_toggled)
        logger.info(f"Created {self.__class__.__name__} instance.")

    def _create_sort_model(self, source_model: CardListModel) -> NaturallySortedSortFilterProxyModel:
        proxy_model = NaturallySortedSortFilterProxyModel(self)
        proxy_model.setSourceModel(source_model)
        proxy_model.setSortRole(Qt.ItemDataRole.EditRole)







|
>
>
>
>
>
>
>
>

|
|

|
<

|
<
<

|







435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455

456
457


458
459
460
461
462
463
464
465
466
        # here.
        self.selected_parser.incompatible_file_format.connect(self.wizard().on_incompatible_deck_file_selected)
        logger.info(f"Created parser: {self.selected_parser.__class__.__name__}")
        return self.isComplete()


class SummaryPage(QWizardPage):

    # Give the generic enum constants a semantic name
    BasicLandRemovalOption = WizardOption.HaveCustomButton1
    BasicLandRemovalButton = WizardButton.CustomButton1
    SelectedRemovalOption = WizardOption.HaveCustomButton2
    SelectedRemovalButton = WizardButton.CustomButton2

    def __init__(self, document: Document, *args, **kwargs):

        super().__init__(*args, **kwargs)
        self.ui = ui = Ui_SummaryPage()
        ui.setupUi(self)
        self.setCommitPage(True)
        self.card_list = CardListModel(document, self)

        self.card_list.oversized_card_count_changed.connect(self._update_accept_button_on_oversized_card_count_changed)
        ui.parsed_cards_table.setModel(self.card_list)


        self.registerField("should_replace_document", self.ui.should_replace_document)
        ui.should_replace_document.toggled[bool].connect(
            self._update_accept_button_on_replace_document_option_toggled)
        logger.info(f"Created {self.__class__.__name__} instance.")

    def _create_sort_model(self, source_model: CardListModel) -> NaturallySortedSortFilterProxyModel:
        proxy_model = NaturallySortedSortFilterProxyModel(self)
        proxy_model.setSourceModel(source_model)
        proxy_model.setSortRole(Qt.ItemDataRole.EditRole)
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
        if enabled:
            accept_button.setIcon(QIcon.fromTheme("document-replace"))
            accept_button.setToolTip(self.tr("Replace document content with the identified cards"))
        else:
            accept_button.setIcon(QIcon.fromTheme("dialog-ok"))
            accept_button.setToolTip(self.tr("Append identified cards to the document"))

    def _setup_parsed_cards_table(self) -> typing.Tuple[CardListComboBoxItemDelegate, SpinboxItemDelegate]:
        self.ui.parsed_cards_table.selectionModel().selectionChanged.connect(self.parsed_cards_table_selection_changed)
        delegate = CardListComboBoxItemDelegate(self.ui.parsed_cards_table)
        copies_delegate = SpinboxItemDelegate(self.ui.parsed_cards_table)
        self.ui.parsed_cards_table.setItemDelegateForColumn(CardListColumns.Copies, copies_delegate)
        self.ui.parsed_cards_table.setItemDelegateForColumn(CardListColumns.Set, delegate)
        self.ui.parsed_cards_table.setItemDelegateForColumn(CardListColumns.CollectorNumber, delegate)
        self.ui.parsed_cards_table.setItemDelegateForColumn(CardListColumns.Language, delegate)
        for column, scaling_factor in (  # These factors are empirically determined to give reasonable column sizes
                (CardListColumns.Copies, 0.9),
                (CardListColumns.CardName, 2),
                (CardListColumns.Set, 2.75),
                (CardListColumns.CollectorNumber, 0.95),
                (CardListColumns.Language, 0.9)):
            new_size = math.floor(self.ui.parsed_cards_table.columnWidth(column) * scaling_factor)
            self.ui.parsed_cards_table.setColumnWidth(column, new_size)
        return delegate, copies_delegate

    def initializePage(self) -> None:
        super().initializePage()
        self.selected_cells_count = 0
        parser: common.ParserBase = self.field("selected_parser")
        decklist_import_section = mtg_proxy_printer.settings.settings["decklist-import"]
        logger.debug(f"About to parse the deck list using parser {parser.__class__.__name__}")
        if self.field("translate-deck-list-enable"):
            language_override = self.field("translate-deck-list-target-language")
            logger.info(f"Language override enabled. Will translate deck list to language {language_override}")
        else:







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<


<







492
493
494
495
496
497
498


















499
500

501
502
503
504
505
506
507
        if enabled:
            accept_button.setIcon(QIcon.fromTheme("document-replace"))
            accept_button.setToolTip(self.tr("Replace document content with the identified cards"))
        else:
            accept_button.setIcon(QIcon.fromTheme("dialog-ok"))
            accept_button.setToolTip(self.tr("Append identified cards to the document"))



















    def initializePage(self) -> None:
        super().initializePage()

        parser: common.ParserBase = self.field("selected_parser")
        decklist_import_section = mtg_proxy_printer.settings.settings["decklist-import"]
        logger.debug(f"About to parse the deck list using parser {parser.__class__.__name__}")
        if self.field("translate-deck-list-enable"):
            language_override = self.field("translate-deck-list-target-language")
            logger.info(f"Language override enabled. Will translate deck list to language {language_override}")
        else:
534
535
536
537
538
539
540

541
542
543
544
545
546
547
548
549
550
551
552
553
554
555



556
557
558
559
560
561
562
563



564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595

596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633

634
635
636
637
638
639
640
641
642
            logger.info("Automatically remove basic lands")
            self._remove_basic_lands()
        logger.debug(f"Initialized {self.__class__.__name__}")

    def _initialize_custom_buttons(self, decklist_import_section: SectionProxy):
        wizard = self.wizard()
        wizard.customButtonClicked.connect(self.custom_button_clicked)

        have_basic_land_removal_button = not decklist_import_section.getboolean("automatically-remove-basic-lands")
        wizard.setOption(WizardOption.HaveCustomButton1, have_basic_land_removal_button)
        remove_basic_lands_button = wizard.button(WizardButton.CustomButton1)
        remove_basic_lands_button.setEnabled(self.card_list.has_basic_lands(
            decklist_import_section.getboolean("remove-basic-wastes"),
            decklist_import_section.getboolean("remove-snow-basics")))
        remove_basic_lands_button.setText(self.tr("Remove basic lands"))
        remove_basic_lands_button.setToolTip(self.tr("Remove all basic lands in the deck list above"))
        remove_basic_lands_button.setIcon(QIcon.fromTheme("edit-delete"))
        wizard.setOption(WizardOption.HaveCustomButton2, True)
        remove_selected_cards_button = wizard.button(WizardButton.CustomButton2)
        remove_selected_cards_button.setEnabled(False)
        remove_selected_cards_button.setText(self.tr("Remove selected"))
        remove_selected_cards_button.setToolTip(self.tr("Remove all selected cards in the deck list above"))
        remove_selected_cards_button.setIcon(QIcon.fromTheme("edit-delete"))




    def cleanupPage(self):
        self.card_list.clear()
        super().cleanupPage()
        wizard = self.wizard()
        wizard.customButtonClicked.disconnect(self.custom_button_clicked)
        wizard.setOption(WizardOption.HaveCustomButton1, False)
        wizard.setOption(WizardOption.HaveCustomButton2, False)



        logger.debug(f"Cleaned up {self.__class__.__name__}")

    @Slot()
    def isComplete(self) -> bool:
        return self.card_list.rowCount() > 0

    @Slot(QItemSelection, QItemSelection)
    def parsed_cards_table_selection_changed(self, selected: QItemSelection, deselected: QItemSelection):
        self.selected_cells_count += selected.count() - deselected.count()
        logger.debug(f"Selection changed: Currently selected cells: {self.selected_cells_count}")
        wizard = self.wizard()
        wizard.button(WizardButton.CustomButton2).setEnabled(self.selected_cells_count > 0)

    @Slot(int)
    def custom_button_clicked(self, button_id: int):
        button = WizardButton(button_id)
        self.wizard().button(button).setEnabled(False)
        if button == WizardButton.CustomButton1:
            logger.info("User requests to remove all basic lands")
            self._remove_basic_lands()
        elif button == WizardButton.CustomButton2:
            self._remove_selected_cards()
            self.selected_cells_count = 0

    def _remove_basic_lands(self):
        decklist_import_section = mtg_proxy_printer.settings.settings["decklist-import"]
        self.card_list.remove_all_basic_lands(
            decklist_import_section.getboolean("remove-basic-wastes"),
            decklist_import_section.getboolean("remove-snow-basics"))

    def _remove_selected_cards(self):
        logger.info("User removes the selected cards")

        selection_mapped_to_source = self.card_list_sort_model.mapSelectionToSource(
            self.ui.parsed_cards_table.selectionModel().selection())
        self.card_list.remove_multi_selection(selection_mapped_to_source)
        if not self.card_list.rowCount():
            # User deleted everything, so nothing left to complete the wizard. This’ll disable the Finish button.
            self.completeChanged.emit()


class DeckImportWizard(WizardBase):
    request_action = Signal(ActionImportDeckList)
    BUTTON_ICONS = {
        QWizard.WizardButton.FinishButton: "dialog-ok",
        QWizard.WizardButton.CancelButton: "dialog-cancel",
    }

    def __init__(self, card_db: CardDatabase, image_db: ImageDatabase,
                 language_model: QStringListModel, parent: QWidget = None, flags=Qt.WindowFlags()):
        super().__init__(QSize(1000, 600), parent, flags)
        self.card_db = card_db
        self.select_deck_parser_page = SelectDeckParserPage(card_db, image_db, self)
        self.load_list_page = LoadListPage(language_model, self)
        self.summary_page = SummaryPage(card_db, self)
        self.addPage(self.load_list_page)
        self.addPage(self.select_deck_parser_page)
        self.addPage(self.summary_page)
        self.setWindowIcon(QIcon.fromTheme("document-import"))
        self.setWindowTitle(self.tr("Import a deck list"))
        logger.info(f"Created {self.__class__.__name__} instance.")

    def accept(self):
        if not self._ask_about_oversized_cards():
            logger.info("Aborting accept(), because oversized cards are present "
                        "in the deck list and the user chose to go back.")
            return
        super().accept()
        logger.info("User finished the import wizard, performing the requested actions")
        if replace_document := self.field("should_replace_document"):
            logger.info("User chose to replace the current document content, clearing it")

        action = ActionImportDeckList(
            self.summary_page.card_list.as_cards(self.summary_page.card_list_sort_model.row_sort_order()),
            replace_document
        )
        logger.info(f"User loaded a deck list with {action.card_count()} cards, adding these to the document")
        self.request_action.emit(action)

    def _ask_about_oversized_cards(self) -> bool:
        oversized_count = self.summary_page.card_list.oversized_card_count







>
|
|
|






|
|




>
>
>






|
|
>
>
>






<
<
<
<
<
<
<




|


|

<









>
|














|
|

<
|

|
















>

|







519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561







562
563
564
565
566
567
568
569
570

571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598

599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
            logger.info("Automatically remove basic lands")
            self._remove_basic_lands()
        logger.debug(f"Initialized {self.__class__.__name__}")

    def _initialize_custom_buttons(self, decklist_import_section: SectionProxy):
        wizard = self.wizard()
        wizard.customButtonClicked.connect(self.custom_button_clicked)
        # When basic lands are stripped fully automatically, there is no need to have a non-functional button.
        should_offer_basic_land_removal = not decklist_import_section.getboolean("automatically-remove-basic-lands")
        wizard.setOption(self.BasicLandRemovalOption, should_offer_basic_land_removal)
        remove_basic_lands_button = wizard.button(self.BasicLandRemovalButton)
        remove_basic_lands_button.setEnabled(self.card_list.has_basic_lands(
            decklist_import_section.getboolean("remove-basic-wastes"),
            decklist_import_section.getboolean("remove-snow-basics")))
        remove_basic_lands_button.setText(self.tr("Remove basic lands"))
        remove_basic_lands_button.setToolTip(self.tr("Remove all basic lands in the deck list above"))
        remove_basic_lands_button.setIcon(QIcon.fromTheme("edit-delete"))
        wizard.setOption(self.SelectedRemovalOption, True)
        remove_selected_cards_button = wizard.button(self.SelectedRemovalButton)
        remove_selected_cards_button.setEnabled(False)
        remove_selected_cards_button.setText(self.tr("Remove selected"))
        remove_selected_cards_button.setToolTip(self.tr("Remove all selected cards in the deck list above"))
        remove_selected_cards_button.setIcon(QIcon.fromTheme("edit-delete"))
        self.ui.parsed_cards_table.changed_selection_is_empty.connect(
            remove_selected_cards_button.setDisabled
        )

    def cleanupPage(self):
        self.card_list.clear()
        super().cleanupPage()
        wizard = self.wizard()
        wizard.customButtonClicked.disconnect(self.custom_button_clicked)
        wizard.setOption(self.BasicLandRemovalOption, False)
        wizard.setOption(self.SelectedRemovalOption, False)
        self.ui.parsed_cards_table.changed_selection_is_empty.disconnect(
            wizard.button(self.SelectedRemovalButton).setDisabled
        )
        logger.debug(f"Cleaned up {self.__class__.__name__}")

    @Slot()
    def isComplete(self) -> bool:
        return self.card_list.rowCount() > 0








    @Slot(int)
    def custom_button_clicked(self, button_id: int):
        button = WizardButton(button_id)
        self.wizard().button(button).setEnabled(False)
        if button == self.BasicLandRemovalButton:
            logger.info("User requests to remove all basic lands")
            self._remove_basic_lands()
        elif button == self.SelectedRemovalButton:
            self._remove_selected_cards()


    def _remove_basic_lands(self):
        decklist_import_section = mtg_proxy_printer.settings.settings["decklist-import"]
        self.card_list.remove_all_basic_lands(
            decklist_import_section.getboolean("remove-basic-wastes"),
            decklist_import_section.getboolean("remove-snow-basics"))

    def _remove_selected_cards(self):
        logger.info("User removes the selected cards")
        sort_model = self.ui.parsed_cards_table.sort_model
        selection_mapped_to_source = sort_model.mapSelectionToSource(
            self.ui.parsed_cards_table.selectionModel().selection())
        self.card_list.remove_multi_selection(selection_mapped_to_source)
        if not self.card_list.rowCount():
            # User deleted everything, so nothing left to complete the wizard. This’ll disable the Finish button.
            self.completeChanged.emit()


class DeckImportWizard(WizardBase):
    request_action = Signal(ActionImportDeckList)
    BUTTON_ICONS = {
        QWizard.WizardButton.FinishButton: "dialog-ok",
        QWizard.WizardButton.CancelButton: "dialog-cancel",
    }

    def __init__(self, document: Document, language_model: QStringListModel,
                 parent: QWidget = None, flags=Qt.WindowFlags()):
        super().__init__(QSize(1000, 600), parent, flags)

        self.select_deck_parser_page = SelectDeckParserPage(document, self)
        self.load_list_page = LoadListPage(language_model, self)
        self.summary_page = SummaryPage(document, self)
        self.addPage(self.load_list_page)
        self.addPage(self.select_deck_parser_page)
        self.addPage(self.summary_page)
        self.setWindowIcon(QIcon.fromTheme("document-import"))
        self.setWindowTitle(self.tr("Import a deck list"))
        logger.info(f"Created {self.__class__.__name__} instance.")

    def accept(self):
        if not self._ask_about_oversized_cards():
            logger.info("Aborting accept(), because oversized cards are present "
                        "in the deck list and the user chose to go back.")
            return
        super().accept()
        logger.info("User finished the import wizard, performing the requested actions")
        if replace_document := self.field("should_replace_document"):
            logger.info("User chose to replace the current document content, clearing it")
        sort_order = self.summary_page.ui.parsed_cards_table.sort_model.row_sort_order()
        action = ActionImportDeckList(
            self.summary_page.card_list.as_cards(sort_order),
            replace_document
        )
        logger.info(f"User loaded a deck list with {action.card_count()} cards, adding these to the document")
        self.request_action.emit(action)

    def _ask_about_oversized_cards(self) -> bool:
        oversized_count = self.summary_page.card_list.oversized_card_count
Changes to mtg_proxy_printer/ui/dialogs.py.
20
21
22
23
24
25
26
27

28
29
30
31
32
33
34

from PyQt5.QtCore import QFile, pyqtSlot as Slot, QThreadPool, QObject, QEvent, Qt
from PyQt5.QtWidgets import QFileDialog, QWidget, QTextBrowser, QDialogButtonBox, QDialog
from PyQt5.QtGui import QIcon
from PyQt5.QtPrintSupport import QPrintPreviewDialog, QPrintDialog, QPrinter

import mtg_proxy_printer.app_dirs
from mtg_proxy_printer.model.carddb import Card, CardDatabase

import mtg_proxy_printer.model.document
import mtg_proxy_printer.model.imagedb
import mtg_proxy_printer.print
import mtg_proxy_printer.settings
import mtg_proxy_printer.ui.common
import mtg_proxy_printer.meta_data
from mtg_proxy_printer.units_and_sizes import DEFAULT_SAVE_SUFFIX, ConfigParser







|
>







20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

from PyQt5.QtCore import QFile, pyqtSlot as Slot, QThreadPool, QObject, QEvent, Qt
from PyQt5.QtWidgets import QFileDialog, QWidget, QTextBrowser, QDialogButtonBox, QDialog
from PyQt5.QtGui import QIcon
from PyQt5.QtPrintSupport import QPrintPreviewDialog, QPrintDialog, QPrinter

import mtg_proxy_printer.app_dirs
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.card import Card
import mtg_proxy_printer.model.document
import mtg_proxy_printer.model.imagedb
import mtg_proxy_printer.print
import mtg_proxy_printer.settings
import mtg_proxy_printer.ui.common
import mtg_proxy_printer.meta_data
from mtg_proxy_printer.units_and_sizes import DEFAULT_SAVE_SUFFIX, ConfigParser
Changes to mtg_proxy_printer/ui/item_delegates.py.
10
11
12
13
14
15
16

17
18
19
20
21
22
23
24







25
26
27
28
29

30
31
32
33
34
35
36














37

38
39
40
41
42
43
44
45
46
47
48
49
50
51
52


53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96











97



98


99




100













101














































































#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.

import typing


from PyQt5.QtCore import QModelIndex, Qt, QAbstractItemModel, QSortFilterProxyModel
from PyQt5.QtWidgets import QStyledItemDelegate, QWidget, QStyleOptionViewItem, QComboBox, QSpinBox

from mtg_proxy_printer.model.carddb import Card
from mtg_proxy_printer.model.card_list import CardListColumns
from mtg_proxy_printer.model.document import Document, PageColumns
from mtg_proxy_printer.logger import get_logger








logger = get_logger(__name__)
del get_logger
__all__ = [
    "ComboBoxItemDelegate",

    "DocumentComboBoxItemDelegate",
    "CardListComboBoxItemDelegate",
    "SpinboxItemDelegate",
]
ItemDataRole = Qt.ItemDataRole
















class SpinboxItemDelegate(QStyledItemDelegate):

    def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QSpinBox:
        editor = QSpinBox(parent)
        editor.setMinimum(1)
        editor.setMaximum(100)
        return editor


class ComboBoxItemDelegate(QStyledItemDelegate):
    """
    Editor widget allowing the user to switch a card printing by offering a choice among valid alternatives.
    """
    COLUMNS: typing.Union[PageColumns, CardListColumns] = None

    def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QComboBox:
        editor = QComboBox(parent)


        return editor

    def setEditorData(self, editor: QComboBox, index: QModelIndex) -> None:
        model: typing.Union[Document, QSortFilterProxyModel] = index.model()
        column = index.column()
        while hasattr(model, "sourceModel"):  # Resolve the source model to gain access to the card database.
            model = model.sourceModel()
        source_model: Document = model
        card: Card = index.data(ItemDataRole.UserRole)
        if hasattr(self.COLUMNS, "Copies") and column == self.COLUMNS.Copies:
            pass
        elif column == self.COLUMNS.Set:
            matching_sets = source_model.card_db.get_available_sets_for_card(card)
            current_set_code = card.set.code
            current_set_position = 0
            for position, set_data in enumerate(matching_sets):
                editor.addItem(set_data.data(ItemDataRole.DisplayRole), set_data.data(ItemDataRole.EditRole))
                if set_data.code == current_set_code:
                    current_set_position = position
            editor.setCurrentIndex(current_set_position)

        elif column == self.COLUMNS.CollectorNumber:
            matching_collector_numbers = source_model.card_db.get_available_collector_numbers_for_card_in_set(card)
            for collector_number in matching_collector_numbers:
                editor.addItem(collector_number, collector_number)  # Store the key in the UserData role
            if matching_collector_numbers:
                editor.setCurrentIndex(matching_collector_numbers.index(index.data(ItemDataRole.EditRole)))

        elif column == self.COLUMNS.Language:
            card = index.data(ItemDataRole.UserRole)
            matching_languages = source_model.card_db.get_available_languages_for_card(card)
            for language in matching_languages:
                editor.addItem(language, language)
            if matching_languages:
                editor.setCurrentIndex(matching_languages.index(index.data(ItemDataRole.EditRole)))

    def setModelData(self, editor: QComboBox, model: QAbstractItemModel, index: QModelIndex) -> None:
        new_value = editor.currentData(ItemDataRole.UserRole)
        previous_value = index.data(ItemDataRole.EditRole)
        if new_value != previous_value:
            logger.debug(f"Setting data for column {index.column()} to {new_value}")
            model.setData(index, new_value, ItemDataRole.EditRole)













class DocumentComboBoxItemDelegate(ComboBoxItemDelegate):



    COLUMNS = PageColumns







class CardListComboBoxItemDelegate(ComboBoxItemDelegate):













    COLUMNS = CardListColumns





















































































>


|

|
<
|

>
>
>
>
>
>
>




|
>
|
|
|




>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>







|
<
<
<
<
|
|

>
>


<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<








>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
|
>
>

>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
10
11
12
13
14
15
16
17
18
19
20
21
22

23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68




69
70
71
72
73
74
75


































76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.

import typing
from typing import Union

from PyQt5.QtCore import QModelIndex, Qt, QAbstractItemModel, QSortFilterProxyModel
from PyQt5.QtWidgets import QStyledItemDelegate, QWidget, QStyleOptionViewItem, QComboBox, QSpinBox, QLineEdit

from mtg_proxy_printer.model.card import MTGSet, Card, AnyCardType

from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.logger import get_logger

try:
    from mtg_proxy_printer.ui.generated.set_editor_widget import Ui_SetEditor
except ModuleNotFoundError:
    from mtg_proxy_printer.ui.common import load_ui_from_file
    Ui_SetEditor = load_ui_from_file("set_editor_widget")


logger = get_logger(__name__)
del get_logger
__all__ = [
    "CollectorNumberEditorDelegate",
    "BoundedCopiesSpinboxDelegate",
    "CardSideSelectionDelegate",
    "SetEditorDelegate",
    "LanguageEditorDelegate",
]
ItemDataRole = Qt.ItemDataRole


def get_document_from_index(index: QModelIndex) -> Document:
    """
    Returns the Document instance associated with the given index.
    Resolves any chain of layered sort/filter models, to grant access to non-Qt-API Document methods.
    """
    model: typing.Union[Document, QSortFilterProxyModel, None] = index.model()
    if model is None:
        raise RuntimeError("Invalid index without attached model passed")
    while hasattr(model, "sourceModel"):
        model = model.sourceModel()
    source_model: Document = model
    return source_model


class BoundedCopiesSpinboxDelegate(QStyledItemDelegate):
    """A QSpinBox delegate bounded to the inclusive range (1-100). Used for card copies."""
    def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QSpinBox:
        editor = QSpinBox(parent)
        editor.setMinimum(1)
        editor.setMaximum(100)
        return editor


class CardSideSelectionDelegate(QStyledItemDelegate):




    """A QComboBox delegate used to switch between Front and Back face of cards"""
    def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QSpinBox:
        editor = QComboBox(parent)
        editor.addItem(self.tr("Front"), True)
        editor.addItem(self.tr("Back"), False)
        return editor



































    def setModelData(self, editor: QComboBox, model: QAbstractItemModel, index: QModelIndex) -> None:
        new_value = editor.currentData(ItemDataRole.UserRole)
        previous_value = index.data(ItemDataRole.EditRole)
        if new_value != previous_value:
            logger.debug(f"Setting data for column {index.column()} to {new_value}")
            model.setData(index, new_value, ItemDataRole.EditRole)


class SetEditorDelegate(QStyledItemDelegate):
    """
    A set editor. For official cards, use a QComboBox with valid set choices for the given card.
    For custom cards, use the embedded editor widget to allow free-form text entry.
    """
    class CustomCardSetEditor(QWidget):
        """A widget holding two line edits, allowing the user to freely edit the set name & code of custom cards."""
        def __init__(self, parent: QWidget = None, flags=Qt.WindowFlags()):
            super().__init__(parent, flags)
            self.ui = ui = Ui_SetEditor()
            ui.setupUi(self)

        def set_data(self, mtg_set: MTGSet):
            self.ui.name_editor.setText(mtg_set.name)
            self.ui.code_edit.setText(mtg_set.code)

        def to_mtg_set(self):
            return MTGSet(self.ui.code_edit.text(), self.ui.name_editor.text())

    def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex):
        card: AnyCardType = index.data(ItemDataRole.UserRole)
        # Use a locked-down choice-based editor for official cards, and a free-form editor for custom cards
        return self.CustomCardSetEditor(parent) if card.is_custom_card else QComboBox(parent)

    def setEditorData(self, editor: Union[QComboBox, CustomCardSetEditor], index: QModelIndex):
        card: AnyCardType = index.data(ItemDataRole.UserRole)
        if card.is_custom_card:
            current_data: MTGSet = index.data(ItemDataRole.EditRole)
            editor.set_data(current_data)
        else:
            model = get_document_from_index(index)
            matching_sets = model.card_db.get_available_sets_for_card(card)
            current_set_code = card.set.code
            for position, set_data in enumerate(matching_sets):
                editor.addItem(set_data.data(ItemDataRole.DisplayRole), set_data)
                if set_data.code == current_set_code:
                    editor.setCurrentIndex(position)

    def setModelData(
            self, editor: Union[QComboBox, CustomCardSetEditor], model: QAbstractItemModel, index: QModelIndex) -> None:
        card: AnyCardType = index.data(ItemDataRole.UserRole)
        data = editor.to_mtg_set() if card.is_custom_card else editor.currentData(ItemDataRole.UserRole)
        model.setData(index, data, ItemDataRole.EditRole)

    @staticmethod
    def _is_official_card(editor: Union[QComboBox, CustomCardSetEditor]):
        return isinstance(editor, QComboBox)


class LanguageEditorDelegate(QStyledItemDelegate):
    """
    A language editor. For official cards, use a QComboBox with valid language choices for the given card.
    For custom cards, populate the combo box with all known languages and also enable the edit functionality
    to allow free-form text entry.
    """
    MAX_LENGTH = 5

    def createEditor(self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex) -> QComboBox:
        return QComboBox(parent)

    def setEditorData(self, editor: QComboBox, index: QModelIndex):
        model = get_document_from_index(index)
        card: Card = index.data(ItemDataRole.UserRole)
        current_language = card.language
        is_custom_card = card.is_custom_card
        editor.setEditable(is_custom_card)  # Allow custom languages for custom cards only
        if is_custom_card:
            editor.lineEdit().setMaxLength(self.MAX_LENGTH)
            languages = model.card_db.get_all_languages()
        else:
            languages = model.card_db.get_available_languages_for_card(card)
        for language in languages:
            editor.addItem(language, language)
        if current_language in languages:  # This is only false for custom cards and user-entered, unknown languages
            editor.setCurrentIndex(languages.index(index.data(ItemDataRole.EditRole)))

    def setModelData(self, editor: QComboBox, model: QAbstractItemModel, index: QModelIndex) -> None:
        new_value = editor.lineEdit().text() if editor.isEditable() else editor.currentData(ItemDataRole.UserRole)
        previous_value = index.data(ItemDataRole.EditRole)
        if new_value != previous_value:
            logger.debug(f"Setting data for column {index.column()} to {new_value}")
            model.setData(index, new_value, ItemDataRole.EditRole)


class CollectorNumberEditorDelegate(QStyledItemDelegate):
    """
    Editor for collector numbers. Allows free-form editing for custom cards,
    and uses a locked-down choice-based combo box for official cards
    """
    def createEditor(
            self, parent: QWidget, option: QStyleOptionViewItem, index: QModelIndex
    ) -> typing.Union[QLineEdit, QComboBox]:
        card: AnyCardType = index.data(ItemDataRole.UserRole)
        # Use a locked-down choice-based editor for official cards, and a free-form editor for custom cards
        return QLineEdit(parent) if card.is_custom_card else QComboBox(parent)

    def setEditorData(self, editor: typing.Union[QLineEdit, QComboBox], index: QModelIndex) -> None:
        model = get_document_from_index(index)
        card: Card = index.data(ItemDataRole.UserRole)
        if card.is_custom_card:
            editor.setText(card.collector_number)
        else:
            matching_collector_numbers = model.card_db.get_available_collector_numbers_for_card_in_set(card)
            for collector_number in matching_collector_numbers:
                editor.addItem(collector_number, collector_number)  # Store the key in the UserData role
            if matching_collector_numbers:
                editor.setCurrentIndex(matching_collector_numbers.index(index.data(ItemDataRole.EditRole)))

    def setModelData(
            self, editor: typing.Union[QLineEdit, QComboBox], model: QAbstractItemModel, index: QModelIndex) -> None:
        card: Card = index.data(ItemDataRole.UserRole)
        new_value = editor.text() if card.is_custom_card else editor.currentData(ItemDataRole.UserRole)
        previous_value = card.collector_number
        if new_value != previous_value:
            logger.debug(f"Setting collector number from {previous_value} to {new_value}")
            model.setData(index, new_value, ItemDataRole.EditRole)
Changes to mtg_proxy_printer/ui/main_window.py.
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

35
36
37
38
39
40
41
42
from PyQt5.QtCore import pyqtSlot as Slot, pyqtSignal as Signal, QStringListModel, QUrl, Qt, QSize
from PyQt5.QtGui import QCloseEvent, QKeySequence, QDesktopServices, QDragEnterEvent, QDropEvent, QPixmap
from PyQt5.QtWidgets import QApplication, QMessageBox, QAction, QWidget, QMainWindow, QDialog


from mtg_proxy_printer.missing_images_manager import MissingImagesManager
from mtg_proxy_printer.card_info_downloader import CardInfoDownloader
from mtg_proxy_printer.model.carddb import CardDatabase, Card, MTGSet
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.document_controller.compact_document import ActionCompactDocument
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage, ActionRemovePage
from mtg_proxy_printer.document_controller.shuffle_document import ActionShuffleDocument
from mtg_proxy_printer.document_controller.new_document import ActionNewDocument
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard

from mtg_proxy_printer.units_and_sizes import DEFAULT_SAVE_SUFFIX, CardSizes
import mtg_proxy_printer.settings
import mtg_proxy_printer.print
from mtg_proxy_printer.ui.dialogs import SavePDFDialog, SaveDocumentAsDialog, LoadDocumentDialog, \
    AboutDialog, PrintPreviewDialog, PrintDialog, DocumentSettingsDialog
from mtg_proxy_printer.ui.cache_cleanup_wizard import CacheCleanupWizard
from mtg_proxy_printer.ui.deck_import_wizard import DeckImportWizard
from mtg_proxy_printer.ui.progress_bar import ProgressBar







|







>
|







20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from PyQt5.QtCore import pyqtSlot as Slot, pyqtSignal as Signal, QStringListModel, QUrl, Qt, QSize
from PyQt5.QtGui import QCloseEvent, QKeySequence, QDesktopServices, QDragEnterEvent, QDropEvent, QPixmap
from PyQt5.QtWidgets import QApplication, QMessageBox, QAction, QWidget, QMainWindow, QDialog


from mtg_proxy_printer.missing_images_manager import MissingImagesManager
from mtg_proxy_printer.card_info_downloader import CardInfoDownloader
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.document_controller.compact_document import ActionCompactDocument
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage, ActionRemovePage
from mtg_proxy_printer.document_controller.shuffle_document import ActionShuffleDocument
from mtg_proxy_printer.document_controller.new_document import ActionNewDocument
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.ui.custom_card_import_dialog import CustomCardImportDialog
from mtg_proxy_printer.units_and_sizes import DEFAULT_SAVE_SUFFIX
import mtg_proxy_printer.settings
import mtg_proxy_printer.print
from mtg_proxy_printer.ui.dialogs import SavePDFDialog, SaveDocumentAsDialog, LoadDocumentDialog, \
    AboutDialog, PrintPreviewDialog, PrintDialog, DocumentSettingsDialog
from mtg_proxy_printer.ui.cache_cleanup_wizard import CacheCleanupWizard
from mtg_proxy_printer.ui.deck_import_wizard import DeckImportWizard
from mtg_proxy_printer.ui.progress_bar import ProgressBar
251
252
253
254
255
256
257
258
259
260
261








262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
        logger.info("User wants to clean up the local image cache")
        wizard = CacheCleanupWizard(self.card_database, self.image_db, self)
        wizard.show()

    @Slot()
    def on_action_import_deck_list_triggered(self):
        logger.info(f"User imports a deck list.")
        wizard = DeckImportWizard(self.card_database, self.image_db, self.language_model, parent=self)
        wizard.request_action.connect(self.image_db.fill_batch_document_action_images)
        wizard.show()









    @Slot()
    def on_action_print_triggered(self):
        logger.info(f"User prints the current document.")
        action_str = self.tr(
            "printing",
            "This is passed as the {action} when asking the user about compacting the document if that can save pages")
        if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel:
            return
        self.current_dialog = PrintDialog(self.document, self)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.missing_images_manager.obtain_missing_images(self.current_dialog.open)

    @Slot()
    def on_action_print_preview_triggered(self):
        logger.info(f"User views the print preview.")
        action_str = self.tr(
            "printing",
            "This is passed as the {action} when asking the user about compacting the document if that can save pages")
        if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel:
            return
        self.current_dialog = PrintPreviewDialog(self.document, self)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.missing_images_manager.obtain_missing_images(self.current_dialog.open)

    @Slot()
    def on_action_print_pdf_triggered(self):
        logger.info(f"User prints the current document to PDF.")
        action_str = self.tr(
            "exporting as a PDF",
            "This is passed as the {action} when asking the user about compacting the document if that can save pages")
        if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel:
            return
        self.current_dialog = SavePDFDialog(self, self.document)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.missing_images_manager.obtain_missing_images(self.current_dialog.open)

    @Slot()
    def on_action_add_empty_card_triggered(self):
        empty_card = self.document.get_empty_card_for_current_page()
        action = ActionAddCard(empty_card)
        self.document.apply(action)








|



>
>
>
>
>
>
>
>








|
|
|









|
|
|









|
|
|







252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
        logger.info("User wants to clean up the local image cache")
        wizard = CacheCleanupWizard(self.card_database, self.image_db, self)
        wizard.show()

    @Slot()
    def on_action_import_deck_list_triggered(self):
        logger.info(f"User imports a deck list.")
        wizard = DeckImportWizard(self.document, self.language_model, self)
        wizard.request_action.connect(self.image_db.fill_batch_document_action_images)
        wizard.show()

    @Slot()
    def on_action_add_custom_cards_triggered(self):
        logger.info(f"User adds custom cards.")
        self.current_dialog = dialog = CustomCardImportDialog(self.document, self)
        dialog.finished.connect(self.on_dialog_finished)
        dialog.request_action.connect(self.document.apply)
        dialog.show()

    @Slot()
    def on_action_print_triggered(self):
        logger.info(f"User prints the current document.")
        action_str = self.tr(
            "printing",
            "This is passed as the {action} when asking the user about compacting the document if that can save pages")
        if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel:
            return
        self.current_dialog = dialog = PrintDialog(self.document, self)
        dialog.finished.connect(self.on_dialog_finished)
        self.missing_images_manager.obtain_missing_images(dialog.open)

    @Slot()
    def on_action_print_preview_triggered(self):
        logger.info(f"User views the print preview.")
        action_str = self.tr(
            "printing",
            "This is passed as the {action} when asking the user about compacting the document if that can save pages")
        if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel:
            return
        self.current_dialog = dialog = PrintPreviewDialog(self.document, self)
        dialog.finished.connect(self.on_dialog_finished)
        self.missing_images_manager.obtain_missing_images(dialog.open)

    @Slot()
    def on_action_print_pdf_triggered(self):
        logger.info(f"User prints the current document to PDF.")
        action_str = self.tr(
            "exporting as a PDF",
            "This is passed as the {action} when asking the user about compacting the document if that can save pages")
        if self._ask_user_about_compacting_document(action_str) == StandardButton.Cancel:
            return
        self.current_dialog = dialog = SavePDFDialog(self, self.document)
        dialog.finished.connect(self.on_dialog_finished)
        self.missing_images_manager.obtain_missing_images(dialog.open)

    @Slot()
    def on_action_add_empty_card_triggered(self):
        empty_card = self.document.get_empty_card_for_current_page()
        action = ActionAddCard(empty_card)
        self.document.apply(action)

355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
            logger.debug("About to save the document")
            self.document.save_to_disk()
            logger.debug("Saved.")

    @Slot()
    def on_action_edit_document_settings_triggered(self):
        logger.info("User wants to edit the document settings. Showing the editor dialog")
        self.current_dialog = DocumentSettingsDialog(self.document, self)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.current_dialog.open()

    @Slot()
    def on_action_download_missing_card_images_triggered(self):
        logger.info("User wants to download missing card images")
        self.missing_images_manager.obtain_missing_images()

    @Slot()
    def on_action_save_as_triggered(self):
        self.current_dialog = SaveDocumentAsDialog(self.document, self)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.current_dialog.open()

    @Slot()
    def on_action_load_document_triggered(self):
        self.current_dialog = LoadDocumentDialog(self, self.document)
        self.current_dialog.accepted.connect(self.ui.central_widget.select_first_page)
        self.current_dialog.finished.connect(self.on_dialog_finished)
        self.current_dialog.open()

    def on_document_loading_failed(self, failed_path: pathlib.Path, reason: str):
        function_text = self.ui.action_import_deck_list.text()
        QMessageBox.critical(
            self, self.tr("Document loading failed"),
            self.tr('Loading file "{failed_path}" failed. The file was not recognized as a '
                    '{program_name} document. If you want to load a deck list, use the '







|
|
|








|
|
|



|
|
|
|







364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
            logger.debug("About to save the document")
            self.document.save_to_disk()
            logger.debug("Saved.")

    @Slot()
    def on_action_edit_document_settings_triggered(self):
        logger.info("User wants to edit the document settings. Showing the editor dialog")
        self.current_dialog = dialog = DocumentSettingsDialog(self.document, self)
        dialog.finished.connect(self.on_dialog_finished)
        dialog.open()

    @Slot()
    def on_action_download_missing_card_images_triggered(self):
        logger.info("User wants to download missing card images")
        self.missing_images_manager.obtain_missing_images()

    @Slot()
    def on_action_save_as_triggered(self):
        self.current_dialog = dialog = SaveDocumentAsDialog(self.document, self)
        dialog.finished.connect(self.on_dialog_finished)
        dialog.open()

    @Slot()
    def on_action_load_document_triggered(self):
        self.current_dialog = dialog = LoadDocumentDialog(self, self.document)
        dialog.accepted.connect(self.ui.central_widget.select_first_page)
        dialog.finished.connect(self.on_dialog_finished)
        dialog.open()

    def on_document_loading_failed(self, failed_path: pathlib.Path, reason: str):
        function_text = self.ui.action_import_deck_list.text()
        QMessageBox.critical(
            self, self.tr("Document loading failed"),
            self.tr('Loading file "{failed_path}" failed. The file was not recognized as a '
                    '{program_name} document. If you want to load a deck list, use the '
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502


503
504
505
506
507
508
509
            mtg_proxy_printer.settings.write_settings_to_file()
            logger.debug("Written settings to disk.")

    def dragEnterEvent(self, event: QDragEnterEvent) -> None:
        if self._to_save_file_path(event):
            logger.info("User drags a saved MTGProxyPrinter document onto the main window, accepting event")
            event.acceptProposedAction()
        elif images := self._to_pixmaps(event):
            logger.info(f"User drags {len(images)} images onto the main window, accepting event")
            event.acceptProposedAction()
        else:
            logger.debug("Rejecting drag&drop action for unknown or invalid data")

    def dropEvent(self, event: QDropEvent) -> None:
        if path := self._to_save_file_path(event):
            logger.info("User dropped save file onto the main window, loading the dropped document")
            self.document.loader.load_document(path)
        elif images := self._to_pixmaps(event):
            logger.info(f"User dropped {len(images)} images onto the main window, adding them as custom cards")
            for image in images:
                card = Card(
                    "Custom card", MTGSet("CUS", "Custom"), "", "", "", True, "", "", True,
                    CardSizes.REGULAR, 1, False, image)
                action = ActionAddCard(card)
                self.document.apply(action)



    @staticmethod
    def _to_save_file_path(event: typing.Union[QDragEnterEvent, QDropEvent]) -> typing.Optional[pathlib.Path]:
        """
        Returns a Path instance to a file, if the drag&drop event contains a reference to exactly 1 document save file,
        None otherwise.
        """







|
|








|
|
<
<
<
<
<
|
>
>







487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505





506
507
508
509
510
511
512
513
514
515
            mtg_proxy_printer.settings.write_settings_to_file()
            logger.debug("Written settings to disk.")

    def dragEnterEvent(self, event: QDragEnterEvent) -> None:
        if self._to_save_file_path(event):
            logger.info("User drags a saved MTGProxyPrinter document onto the main window, accepting event")
            event.acceptProposedAction()
        elif CustomCardImportDialog.dragdrop_acceptable(event):
            logger.info(f"User drags {len(event.mimeData().urls())} images onto the main window, accepting event")
            event.acceptProposedAction()
        else:
            logger.debug("Rejecting drag&drop action for unknown or invalid data")

    def dropEvent(self, event: QDropEvent) -> None:
        if path := self._to_save_file_path(event):
            logger.info("User dropped save file onto the main window, loading the dropped document")
            self.document.loader.load_document(path)
        elif CustomCardImportDialog.dragdrop_acceptable(event):
            self.current_dialog = dialog = CustomCardImportDialog(self.document, self)





            dialog.request_action.connect(self.document.apply)
            dialog.finished.connect(self.on_dialog_finished)
            dialog.show_from_drop_event(event)

    @staticmethod
    def _to_save_file_path(event: typing.Union[QDragEnterEvent, QDropEvent]) -> typing.Optional[pathlib.Path]:
        """
        Returns a Path instance to a file, if the drag&drop event contains a reference to exactly 1 document save file,
        None otherwise.
        """
Changes to mtg_proxy_printer/ui/page_card_table_view.py.
22
23
24
25
26
27
28
29

30

31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

48



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67




68




69
70
71
72
73
74
75
76
77
from PyQt5.QtCore import QPoint, Qt, pyqtSignal as Signal, pyqtSlot as Slot, QPersistentModelIndex
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QTableView, QWidget, QMenu, QAction, QInputDialog, QFileDialog

from mtg_proxy_printer.app_dirs import data_directories
from mtg_proxy_printer.document_controller import DocumentAction
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard, ActionRemoveCards
from mtg_proxy_printer.model.carddb import Card, CheckCard, CardDatabase, AnyCardType, CardList, AnyCardTypeForTypeCheck

from mtg_proxy_printer.model.document import PageColumns, Document

from mtg_proxy_printer.ui.item_delegates import DocumentComboBoxItemDelegate

from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger
ItemDataRole = Qt.ItemDataRole


class PageCardTableView(QTableView):

    request_action = Signal(DocumentAction)
    obtain_card_image = Signal(ActionAddCard)
    changed_selection_is_empty = Signal(bool)

    def __init__(self, parent: QWidget = None):
        super().__init__(parent)
        self.customContextMenuRequested.connect(self.page_table_context_menu_requested)

        self._combo_box_delegate = self._setup_combo_box_item_delegate()



        self.card_db: CardDatabase = None

    def set_data(self, document: Document, card_db: CardDatabase):
        self.card_db = card_db
        self.setModel(document)
        self.request_action.connect(document.apply)
        document.current_page_changed.connect(self.on_current_page_changed)
        # Has to be set up here, because setModel() implicitly creates the QItemSelectionModel
        self.selectionModel().selectionChanged.connect(self._on_selection_changed)

    @Slot()
    def _on_selection_changed(self):
        is_empty = self.selectionModel().selection().isEmpty()
        self.changed_selection_is_empty.emit(is_empty)


    def _setup_combo_box_item_delegate(self):
        combo_box_delegate = DocumentComboBoxItemDelegate(self)
        self.setItemDelegateForColumn(PageColumns.CollectorNumber, combo_box_delegate)




        self.setItemDelegateForColumn(PageColumns.Set, combo_box_delegate)




        self.setItemDelegateForColumn(PageColumns.Language, combo_box_delegate)
        return combo_box_delegate

    @Slot(QPoint)
    def page_table_context_menu_requested(self, pos: QPoint):
        if not (index := self.indexAt(pos)).isValid():
            logger.debug("Right clicked empty space in the page card table view, ignoring event")
            return
        logger.info(f"Page card table requests context menu at x={pos.x()}, y={pos.y()}, row={index.row()}")







|
>
|
>
|
















>
|
>
>
>















<

|

>
>
>
>
|
>
>
>
>
|
|







22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69

70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
from PyQt5.QtCore import QPoint, Qt, pyqtSignal as Signal, pyqtSlot as Slot, QPersistentModelIndex
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QTableView, QWidget, QMenu, QAction, QInputDialog, QFileDialog

from mtg_proxy_printer.app_dirs import data_directories
from mtg_proxy_printer.document_controller import DocumentAction
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard, ActionRemoveCards
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.card import Card, CheckCard, CardList, AnyCardType, AnyCardTypeForTypeCheck
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_page import PageColumns
from mtg_proxy_printer.ui.item_delegates import CollectorNumberEditorDelegate, SetEditorDelegate, LanguageEditorDelegate

from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger
ItemDataRole = Qt.ItemDataRole


class PageCardTableView(QTableView):

    request_action = Signal(DocumentAction)
    obtain_card_image = Signal(ActionAddCard)
    changed_selection_is_empty = Signal(bool)

    def __init__(self, parent: QWidget = None):
        super().__init__(parent)
        self.customContextMenuRequested.connect(self.page_table_context_menu_requested)
        self._column_delegates = (
            self._setup_combo_box_item_delegate(),
            self._setup_language_delegate(),
            self._setup_set_delegate(),
        )
        self.card_db: CardDatabase = None

    def set_data(self, document: Document, card_db: CardDatabase):
        self.card_db = card_db
        self.setModel(document)
        self.request_action.connect(document.apply)
        document.current_page_changed.connect(self.on_current_page_changed)
        # Has to be set up here, because setModel() implicitly creates the QItemSelectionModel
        self.selectionModel().selectionChanged.connect(self._on_selection_changed)

    @Slot()
    def _on_selection_changed(self):
        is_empty = self.selectionModel().selection().isEmpty()
        self.changed_selection_is_empty.emit(is_empty)


    def _setup_combo_box_item_delegate(self):
        combo_box_delegate = CollectorNumberEditorDelegate(self)
        self.setItemDelegateForColumn(PageColumns.CollectorNumber, combo_box_delegate)
        return combo_box_delegate

    def _setup_language_delegate(self):
        delegate = LanguageEditorDelegate(self)
        self.setItemDelegateForColumn(PageColumns.Language, delegate)
        return delegate

    def _setup_set_delegate(self):
        delegate = SetEditorDelegate(self)
        self.setItemDelegateForColumn(PageColumns.Set, delegate)
        return delegate

    @Slot(QPoint)
    def page_table_context_menu_requested(self, pos: QPoint):
        if not (index := self.indexAt(pos)).isValid():
            logger.debug("Right clicked empty space in the page card table view, ignoring event")
            return
        logger.info(f"Page card table requests context menu at x={pos.x()}, y={pos.y()}, row={index.row()}")
Changes to mtg_proxy_printer/ui/page_config_preview_area.py.
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard, ActionRemoveCards
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize
from mtg_proxy_printer.model.document_page import PageType
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.carddb import Card, MTGSet
from mtg_proxy_printer.ui.common import load_ui_from_file
from mtg_proxy_printer.logger import get_logger

try:
    from mtg_proxy_printer.ui.generated.page_config_preview_area import Ui_PageConfigPreviewArea
except ModuleNotFoundError:
    Ui_PageConfigPreviewArea = load_ui_from_file("page_config_preview_area")







|







23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard, ActionRemoveCards
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize
from mtg_proxy_printer.model.document_page import PageType
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.card import MTGSet, Card
from mtg_proxy_printer.ui.common import load_ui_from_file
from mtg_proxy_printer.logger import get_logger

try:
    from mtg_proxy_printer.ui.generated.page_config_preview_area import Ui_PageConfigPreviewArea
except ModuleNotFoundError:
    Ui_PageConfigPreviewArea = load_ui_from_file("page_config_preview_area")
Changes to mtg_proxy_printer/ui/page_scene.py.
21
22
23
24
25
26
27
28
29

30
31
32
33
34
35
36

from PyQt5.QtCore import Qt, QSizeF, QPointF, QRectF, pyqtSignal as Signal, QObject, pyqtSlot as Slot, \
    QPersistentModelIndex, QModelIndex, QRect, QPoint, QSize
from PyQt5.QtGui import QPen, QColorConstants, QBrush, QColor, QPalette, QFontMetrics, QPixmap, QTransform, QPolygonF
from PyQt5.QtWidgets import QGraphicsItemGroup, QGraphicsItem, QGraphicsPixmapItem, QGraphicsRectItem, \
    QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsScene, QGraphicsPolygonItem

from mtg_proxy_printer.model.carddb import Card, CardCorner
from mtg_proxy_printer.model.document import Document, PageColumns

from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.settings import settings
from mtg_proxy_printer.units_and_sizes import PageType, unit_registry, RESOLUTION, CardSizes, CardSize, QuantityT
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger








|
|
>







21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

from PyQt5.QtCore import Qt, QSizeF, QPointF, QRectF, pyqtSignal as Signal, QObject, pyqtSlot as Slot, \
    QPersistentModelIndex, QModelIndex, QRect, QPoint, QSize
from PyQt5.QtGui import QPen, QColorConstants, QBrush, QColor, QPalette, QFontMetrics, QPixmap, QTransform, QPolygonF
from PyQt5.QtWidgets import QGraphicsItemGroup, QGraphicsItem, QGraphicsPixmapItem, QGraphicsRectItem, \
    QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsScene, QGraphicsPolygonItem

from mtg_proxy_printer.model.card import CardCorner, Card
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_page import PageColumns
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.settings import settings
from mtg_proxy_printer.units_and_sizes import PageType, unit_registry, RESOLUTION, CardSizes, CardSize, QuantityT
from mtg_proxy_printer.logger import get_logger
logger = get_logger(__name__)
del get_logger

533
534
535
536
537
538
539
540






541
542
543
544
545
546
547
        if page.row() == self.selected_page.row():
            self.update_card_positions()
            if self.document.page_layout.draw_cut_markers:
                self.remove_cut_markers()
                self.draw_cut_markers()

    def on_data_changed(self, top_left: QModelIndex, bottom_right: QModelIndex, roles: typing.List[ItemDataRole]):
        if top_left.parent().row() == self.selected_page.row() and ItemDataRole.DisplayRole in roles:






            page_type: PageType = top_left.parent().data(ItemDataRole.UserRole)
            card_items = self.card_items
            for row in range(top_left.row(), bottom_right.row()+1):
                logger.debug(f"Card {row} on the current page was replaced, replacing image.")
                current_item = card_items[row]
                self.draw_card(row, page_type, current_item)
                self.removeItem(current_item)







|
>
>
>
>
>
>







534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
        if page.row() == self.selected_page.row():
            self.update_card_positions()
            if self.document.page_layout.draw_cut_markers:
                self.remove_cut_markers()
                self.draw_cut_markers()

    def on_data_changed(self, top_left: QModelIndex, bottom_right: QModelIndex, roles: typing.List[ItemDataRole]):
        if (top_left.parent().row() == self.selected_page.row()
                and ItemDataRole.DisplayRole in roles
                # Multiple columns changed means card replaced.
                # Editing custom cards only changes single columns.
                # Thes cases can be ignored, as the pixmap never changes
                and top_left.column() < bottom_right.column()
        ):
            page_type: PageType = top_left.parent().data(ItemDataRole.UserRole)
            card_items = self.card_items
            for row in range(top_left.row(), bottom_right.row()+1):
                logger.debug(f"Card {row} on the current page was replaced, replacing image.")
                current_item = card_items[row]
                self.draw_card(row, page_type, current_item)
                self.removeItem(current_item)
Changes to mtg_proxy_printer/units_and_sizes.py.
14
15
16
17
18
19
20

21
22
23
24
25
26
27
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


"""Contains some constants, type definitions and the unit parsing support code"""
import configparser
import enum
import re

import typing
try:
    from typing import NotRequired
except ImportError:  # Compatibility with Python < 3.11
    from typing_extensions import NotRequired

import pint







>







14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


"""Contains some constants, type definitions and the unit parsing support code"""
import configparser
import enum
import re
import sqlite3
import typing
try:
    from typing import NotRequired
except ImportError:  # Compatibility with Python < 3.11
    from typing_extensions import NotRequired

import pint
103
104
105
106
107
108
109



110
111
112
113
114
115
116
        return cls.OVERSIZED if page_type == PageType.OVERSIZED else cls.REGULAR

    @classmethod
    def from_bool(cls, value: bool) -> CardSize:
        return cls.OVERSIZED if value else cls.REGULAR





@enum.unique
class PageType(enum.Enum):
    """
    This enum can be used to indicate what kind of images are placed on a Page.
    A page that only contains regular-sized images is REGULAR, a page only containing oversized images is OVERSIZED.
    An empty page has an UNDETERMINED image size and can be used for both oversized or regular sized cards
    A page containing both is MIXED. This should never happen. A page being MIXED indicates a bug in the code.







>
>
>







104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
        return cls.OVERSIZED if page_type == PageType.OVERSIZED else cls.REGULAR

    @classmethod
    def from_bool(cls, value: bool) -> CardSize:
        return cls.OVERSIZED if value else cls.REGULAR


sqlite3.register_adapter(CardSize, lambda item: item.to_save_data())
sqlite3.register_adapter(CardSizes, lambda item: item.to_save_data())

@enum.unique
class PageType(enum.Enum):
    """
    This enum can be used to indicate what kind of images are placed on a Page.
    A page that only contains regular-sized images is REGULAR, a page only containing oversized images is OVERSIZED.
    An empty page has an UNDETERMINED image size and can be used for both oversized or regular sized cards
    A page containing both is MIXED. This should never happen. A page being MIXED indicates a bug in the code.
Changes to pyproject.toml.
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
Github = "https://github.com/luziferius/MTGProxyPrinter/"


[project.gui-scripts]
mtg-proxy-printer = "mtg_proxy_printer.__main__:main"

[tool.pytest.ini_options]
timeout = 10

[tool.setuptools]
exclude-package-data = {"mtg_proxy_printer" = ["resources", "resources.*"]}


[tool.setuptools.packages.find]
include = [







|







86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
Github = "https://github.com/luziferius/MTGProxyPrinter/"


[project.gui-scripts]
mtg-proxy-printer = "mtg_proxy_printer.__main__:main"

[tool.pytest.ini_options]
# timeout = 10

[tool.setuptools]
exclude-package-data = {"mtg_proxy_printer" = ["resources", "resources.*"]}


[tool.setuptools.packages.find]
include = [
Changes to scripts/update_translations.py.
21
22
23
24
25
26
27

28
29


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

47
48
49
50
51
52
53
54
"""

import argparse
import itertools
import pathlib
import re
import subprocess

from typing import Callable, NamedTuple




# Mapping between source locales, as provided by Crowdin, and the target, as expected/loaded by Qt.
# TODO: Investigate, how systems behave in locales requiring the country as disambiguation, like en or zh.
LOCALES = {
    "de-DE": "de",
#    "en-GB": "en_GB",
    "en-US": "en_US",
    "es-ES": "es",
    "fr-FR": "fr",
    "it-IT": "it",
    "ja-JP": "ja",
    "ko-KR": "ko",
    "pt-PT": "pt",
    "ru-RU": "ru",
    "zh-CN": "zh_CN",
    "zh-TW": "zh_TW",
}

TRANSLATIONS_DIR = pathlib.Path("mtg_proxy_printer/resources/translations/")
crowdin_yml_path = pathlib.Path(__file__).parent.parent/"crowdin.yml"
SOURCES_PATH = pathlib.Path(
    # Fetch the name of the sources .ts file from crowdin.yml.
    # (Since Python does not come with a YAML parser, use a simple RE for data extraction)
    re.search(
        r'"source":\s*"(?P<path>.+)",',
        crowdin_yml_path.read_text("utf-8")







>


>
>

















>
|







21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
"""

import argparse
import itertools
import pathlib
import re
import subprocess
import sys
from typing import Callable, NamedTuple

sys.path.insert(0, str(pathlib.Path(__file__).parent.parent.resolve()))
from mtg_proxy_printer.settings import VALID_LANGUAGES

# Mapping between source locales, as provided by Crowdin, and the target, as expected/loaded by Qt.
# TODO: Investigate, how systems behave in locales requiring the country as disambiguation, like en or zh.
LOCALES = {
    "de-DE": "de",
#    "en-GB": "en_GB",
    "en-US": "en_US",
    "es-ES": "es",
    "fr-FR": "fr",
    "it-IT": "it",
    "ja-JP": "ja",
    "ko-KR": "ko",
    "pt-PT": "pt",
    "ru-RU": "ru",
    "zh-CN": "zh_CN",
    "zh-TW": "zh_TW",
}

TRANSLATIONS_DIR = pathlib.Path(__file__, "..", "..", "mtg_proxy_printer/resources/translations/").resolve()
crowdin_yml_path = pathlib.Path(__file__).parent.parent/"crowdin.yml"
SOURCES_PATH = pathlib.Path(
    # Fetch the name of the sources .ts file from crowdin.yml.
    # (Since Python does not come with a YAML parser, use a simple RE for data extraction)
    re.search(
        r'"source":\s*"(?P<path>.+)",',
        crowdin_yml_path.read_text("utf-8")
122
123
124
125
126
127
128
129




130
131
132
133
134
135
136

def download_new_translations(args: Namespace):
    """Downloads translated .ts files from Crowdin via the API"""
    verify_crowdin_cli_present()
    subprocess.call([
        "crowdin", "download"
    ])






def get_lrelease():
    """
    Determine the lrelease binary name. Tries to find in on $PATH, or falls back to using the PySide2-supplied binary
    from the virtual environment.
    """
    try:







|
>
>
>
>







126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144

def download_new_translations(args: Namespace):
    """Downloads translated .ts files from Crowdin via the API"""
    verify_crowdin_cli_present()
    subprocess.call([
        "crowdin", "download"
    ])
    # Strip all translations that are not registered as valid locale setting
    for file in TRANSLATIONS_DIR.glob("*.ts"):  # type: pathlib.Path
        # Use get() to keep the mtgproxyprinter_sources.ts file by mapping it to the "System locale" value (empty str)
        if LOCALES.get(file.stem.split("_")[1], "") not in VALID_LANGUAGES:
            file.unlink()

def get_lrelease():
    """
    Determine the lrelease binary name. Tries to find in on $PATH, or falls back to using the PySide2-supplied binary
    from the virtual environment.
    """
    try:
Changes to tests/conftest.py.
21
22
23
24
25
26
27

28
29
30

31
32
33
34
35
36
37
38
39
40
41
42
43
44
import itertools
import sqlite3
import unittest.mock
from pathlib import Path

from PyQt5.QtGui import QColorConstants, QPixmap
import pytest


import mtg_proxy_printer.sqlite_helpers
import mtg_proxy_printer.settings

from mtg_proxy_printer.printing_filter_updater import PrintingFilterUpdater
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.units_and_sizes import CardSizes
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.imagedb_files import ImageKey
from tests.helpers import fill_card_database_with_json_cards


@pytest.fixture(params=[False, True])
def card_db(request) -> CardDatabase:
    section = mtg_proxy_printer.settings.settings["card-filter"]
    settings_to_use = {filter_name: "False" for filter_name in section.keys()}
    with unittest.mock.patch.dict(section, settings_to_use):







>



>






|







21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import itertools
import sqlite3
import unittest.mock
from pathlib import Path

from PyQt5.QtGui import QColorConstants, QPixmap
import pytest
from hamcrest import assert_that, is_

import mtg_proxy_printer.sqlite_helpers
import mtg_proxy_printer.settings
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.printing_filter_updater import PrintingFilterUpdater
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.units_and_sizes import CardSizes
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.imagedb_files import ImageKey
from tests.helpers import fill_card_database_with_json_cards, is_dataclass_equal_to


@pytest.fixture(params=[False, True])
def card_db(request) -> CardDatabase:
    section = mtg_proxy_printer.settings.settings["card-filter"]
    settings_to_use = {filter_name: "False" for filter_name in section.keys()}
    with unittest.mock.patch.dict(section, settings_to_use):
104
105
106
107
108
109
110








    mock_card_db = unittest.mock.NonCallableMagicMock()
    mock_card_db.db = mtg_proxy_printer.sqlite_helpers.create_in_memory_database(
        "carddb", check_same_thread=False)
    document = Document(mock_card_db, mock_imagedb)
    document.loader.db = mock_card_db.db
    yield document
    document.__dict__.clear()















>
>
>
>
>
>
>
>
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
    mock_card_db = unittest.mock.NonCallableMagicMock()
    mock_card_db.db = mtg_proxy_printer.sqlite_helpers.create_in_memory_database(
        "carddb", check_same_thread=False)
    document = Document(mock_card_db, mock_imagedb)
    document.loader.db = mock_card_db.db
    yield document
    document.__dict__.clear()


@pytest.fixture
def page_layout() -> PageLayoutSettings:
    layout = PageLayoutSettings.create_from_settings()
    defaults = PageLayoutSettings.create_from_settings(mtg_proxy_printer.settings.DEFAULT_SETTINGS)
    assert_that(layout, is_dataclass_equal_to(defaults))
    return layout
Changes to tests/decklist_parser/test_generic_re_parser.py.
12
13
14
15
16
17
18
19

20
21
22
23
24
25
26
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import unittest.mock

from mtg_proxy_printer.model.carddb import CardDatabase, Card

from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.decklist_parser.re_parsers import GenericRegularExpressionDeckParser

from tests.helpers import fill_card_database_with_json_cards

import pytest
from hamcrest import *







|
>







12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import unittest.mock

from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.card import Card
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.decklist_parser.re_parsers import GenericRegularExpressionDeckParser

from tests.helpers import fill_card_database_with_json_cards

import pytest
from hamcrest import *
Changes to tests/decklist_parser/test_scryfall_csv_parser.py.
16
17
18
19
20
21
22
23

24
25
26
27
28
29
30

import typing
import unittest.mock

import pytest
from hamcrest import *

from mtg_proxy_printer.model.carddb import CardDatabase, Card, CardIdentificationData

from mtg_proxy_printer.decklist_parser.csv_parsers import ScryfallCSVParser
from mtg_proxy_printer.decklist_downloader import DecklistDownloader

from tests.helpers import fill_card_database_with_json_cards, SHOULD_SKIP_NETWORK_TESTS

StringList = typing.List[str]
DECK_LIST_CSV_HEADER = "section,count,name,mana_cost,type,set,set_code,collector_number,lang,rarity," \







|
>







16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

import typing
import unittest.mock

import pytest
from hamcrest import *

from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData
from mtg_proxy_printer.model.card import Card
from mtg_proxy_printer.decklist_parser.csv_parsers import ScryfallCSVParser
from mtg_proxy_printer.decklist_downloader import DecklistDownloader

from tests.helpers import fill_card_database_with_json_cards, SHOULD_SKIP_NETWORK_TESTS

StringList = typing.List[str]
DECK_LIST_CSV_HEADER = "section,count,name,mana_cost,type,set,set_code,collector_number,lang,rarity," \
Changes to tests/decklist_parser/test_tappedout_csv_parser.py.
16
17
18
19
20
21
22
23

24
25
26
27
28
29
30

import typing
import unittest.mock

import pytest
from hamcrest import *

from mtg_proxy_printer.model.carddb import CardDatabase, Card, CardIdentificationData

from mtg_proxy_printer.decklist_parser.csv_parsers import TappedOutCSVParser
from mtg_proxy_printer.decklist_downloader import TappedOutDownloader

from tests.helpers import fill_card_database_with_json_cards, SHOULD_SKIP_NETWORK_TESTS

StringList = typing.List[str]
CSV_HEADER = "Board,Qty,Name,Printing,Foil,Alter,Signed,Condition,Language"







|
>







16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

import typing
import unittest.mock

import pytest
from hamcrest import *

from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData
from mtg_proxy_printer.model.card import Card
from mtg_proxy_printer.decklist_parser.csv_parsers import TappedOutCSVParser
from mtg_proxy_printer.decklist_downloader import TappedOutDownloader

from tests.helpers import fill_card_database_with_json_cards, SHOULD_SKIP_NETWORK_TESTS

StringList = typing.List[str]
CSV_HEADER = "Board,Qty,Name,Printing,Foil,Alter,Signed,Condition,Language"
Changes to tests/document_controller/helpers.py.
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import itertools

from PyQt5.QtGui import QPixmap

import hamcrest.core.base_matcher
from hamcrest import has_properties, same_instance, all_of, instance_of, assert_that, is_, equal_to, has_property

from mtg_proxy_printer.model.carddb import Card, MTGSet, AnyCardType
from mtg_proxy_printer.model.document_page import CardContainer, Page
from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize

__all__ = [
    "append_new_pages",
    "verify_page_index_cache_is_valid",
    "create_card",







|







20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import itertools

from PyQt5.QtGui import QPixmap

import hamcrest.core.base_matcher
from hamcrest import has_properties, same_instance, all_of, instance_of, assert_that, is_, equal_to, has_property

from mtg_proxy_printer.model.card import MTGSet, Card, AnyCardType
from mtg_proxy_printer.model.document_page import CardContainer, Page
from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize

__all__ = [
    "append_new_pages",
    "verify_page_index_cache_is_valid",
    "create_card",
Changes to tests/document_controller/test_action_add_card.py.
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

import copy
import unittest.mock

import pytest
from hamcrest import *

from mtg_proxy_printer.model.carddb import Card, MTGSet, CheckCard
from mtg_proxy_printer.model.document_page import PageType
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.document_controller import IllegalStateError
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.units_and_sizes import CardSizes








|







16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

import copy
import unittest.mock

import pytest
from hamcrest import *

from mtg_proxy_printer.model.card import MTGSet, Card, CheckCard
from mtg_proxy_printer.model.document_page import PageType
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.document_controller import IllegalStateError
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.units_and_sizes import CardSizes

Changes to tests/document_controller/test_action_new_page.py.
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29


from unittest.mock import MagicMock

from hamcrest import *
import pytest

from mtg_proxy_printer.model.carddb import Card
from mtg_proxy_printer.model.document_page import CardContainer, Page
from mtg_proxy_printer.document_controller import IllegalStateError
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.units_and_sizes import CardSizes

from .helpers import append_new_card_in_page, card_container_with, append_new_pages, verify_page_index_cache_is_valid, \
    create_card







|







15
16
17
18
19
20
21
22
23
24
25
26
27
28
29


from unittest.mock import MagicMock

from hamcrest import *
import pytest

from mtg_proxy_printer.model.card import Card
from mtg_proxy_printer.model.document_page import CardContainer, Page
from mtg_proxy_printer.document_controller import IllegalStateError
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.units_and_sizes import CardSizes

from .helpers import append_new_card_in_page, card_container_with, append_new_pages, verify_page_index_cache_is_valid, \
    create_card
Changes to tests/document_controller/test_action_save_document.py.
19
20
21
22
23
24
25

26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108



109
110
111
112
113
114
115

116



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
import textwrap

from hamcrest import *
from PyQt5.QtCore import QModelIndex, Qt
import pytest
from pytestqt.qtbot import QtBot


from mtg_proxy_printer.sqlite_helpers import open_database, create_in_memory_database
from mtg_proxy_printer.units_and_sizes import unit_registry, UnitT, CardSizes
from mtg_proxy_printer.model.carddb import CheckCard
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_loader import CardType
from mtg_proxy_printer.model.imagedb_files import ImageKey
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.document_controller.edit_document_settings import ActionEditDocumentSettings
from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument

from tests.test_document import document_custom_layout
from tests.helpers import close_to_, quantity_close_to

ItemDataRole = Qt.ItemDataRole
mm: UnitT = unit_registry.mm

def validate_qt_model_signal_parameter(
        expected_first: int, expected_last: int,
        parent: QModelIndex, first: int, last: int) -> bool:
    return not parent.isValid() and first == expected_first and last == expected_last


@pytest.mark.parametrize("source_version", [2, 3, 4, 5, 6])
def test_save_migration(tmp_path: Path, document: Document, source_version: int):
    """Tests migration of existing saves to the newest schema revision on save."""
    card = document.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True)
    capacity = document.page_layout.compute_page_card_capacity(card.requested_page_type())
    document.apply(ActionAddCard(card, capacity))
    action = ActionSaveDocument(_create_save_file(Path(tmp_path), source_version))
    action.apply(document)
    _validate_database_schema(action.file_path)
    _validate_saved_document_settings(document, action.file_path)


def test_create_save(tmp_path: Path, document_custom_layout: Document):
    """Tests that saving a new document uses the newest database schema version"""

    card = document_custom_layout.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True)
    capacity = document_custom_layout.page_layout.compute_page_card_capacity(card.requested_page_type())
    document_custom_layout.apply(ActionAddCard(card, capacity))
    save_file = tmp_path / "test.mtgproxies"
    action = ActionSaveDocument(save_file)
    action.apply(document_custom_layout)
    _validate_database_schema(save_file)
    _validate_saved_document_settings(document_custom_layout, save_file)


@pytest.mark.parametrize("is_front", [True, False])
def test_save_as_saves_regular_card(tmp_path: Path, document: Document, is_front: bool):
    card = document.card_db.get_card_with_scryfall_id("b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", is_front)
    document.apply(ActionAddCard(card))
    save_file = tmp_path/"test.mtgproxies"
    action = ActionSaveDocument(save_file)
    action.apply(document)
    with open_database(save_file, "document-v6", False) as con:
        content = con.execute("SELECT page, slot, scryfall_id, is_front, type FROM Card").fetchall()
    del con
    assert_that(
        content, contains_exactly(
            contains_exactly(1, 1, "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", is_front, CardType.REGULAR.value)
        )
    )


def test_save_as_saves_check_card(tmp_path: Path, document: Document):
    card = CheckCard(
        document.card_db.get_card_with_scryfall_id("b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", True),
        document.card_db.get_card_with_scryfall_id("b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", False),
    )
    document.apply(ActionAddCard(card))
    save_file = tmp_path / "test.mtgproxies"
    action = ActionSaveDocument(save_file)
    action.apply(document)
    with open_database(save_file, "document-v6") as con:
        content = con.execute("SELECT page, slot, scryfall_id, is_front, type FROM Card").fetchall()
    del con
    assert_that(
        content, contains_exactly(
            contains_exactly(1, 1, "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", True, CardType.CHECK_CARD.value)
        )
    )


def test_subsequent_save_updates_settings(tmp_path: Path, qtbot: QtBot, document_custom_layout: Document):



    """Tests that saving a new document uses the newest database schema version"""
    layout = copy.copy(document_custom_layout.page_layout)
    layout.page_height = 1000*mm
    card = document_custom_layout.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True)
    # Prevent network access when re-loading the document
    blank = document_custom_layout.image_db.get_blank(CardSizes.REGULAR)
    document_custom_layout.image_db.loaded_images[ImageKey(card.scryfall_id, card.is_front, card.highres_image)] = blank

    cards_per_page = document_custom_layout.page_layout.compute_page_card_capacity(card.requested_page_type())



    document_custom_layout.apply(ActionAddCard(card, cards_per_page))

    save_file = Path(tmp_path)/"test.mtgproxies"
    action = ActionSaveDocument(save_file)
    action.apply(document_custom_layout)
    _validate_database_schema(save_file)
    _validate_saved_document_settings(document_custom_layout, save_file)
    with qtbot.waitSignal(document_custom_layout.page_layout_changed):
        document_custom_layout.apply(ActionEditDocumentSettings(layout))
    action.apply(document_custom_layout)
    with qtbot.waitSignals([document_custom_layout.loading_state_changed]*2,
                           check_params_cbs=[lambda value: value, lambda value: not value]):
        document_custom_layout.loader.load_document(save_file)
    assert_that(
        document_custom_layout.page_layout.page_height,
        is_(quantity_close_to(1000*mm)))


def _create_save_file(temp_path: Path, source_version: int):
    """Creates an empty document save file at the given path and using the given schema version."""
    save_file_path = temp_path/"test.mtgproxies"
    open_database(save_file_path, f"document-v{source_version}").close()
    return save_file_path







>

|
|


<




|
|



















|




>

|





|









|


















|










>
>
>
|
|
|
<
<
|
<
>
|
>
>
>
|

<
<
<
<
<
|
|
|
<
<
<
<
|
<







19
20
21
22
23
24
25
26
27
28
29
30
31

32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115


116

117
118
119
120
121
122
123





124
125
126




127

128
129
130
131
132
133
134
import textwrap

from hamcrest import *
from PyQt5.QtCore import QModelIndex, Qt
import pytest
from pytestqt.qtbot import QtBot

from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.sqlite_helpers import open_database, create_in_memory_database
from mtg_proxy_printer.units_and_sizes import unit_registry, UnitT
from mtg_proxy_printer.model.card import CheckCard
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_loader import CardType

from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.document_controller.edit_document_settings import ActionEditDocumentSettings
from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument

from tests.model.test_document import document_custom_layout
from tests.helpers import quantity_close_to

ItemDataRole = Qt.ItemDataRole
mm: UnitT = unit_registry.mm

def validate_qt_model_signal_parameter(
        expected_first: int, expected_last: int,
        parent: QModelIndex, first: int, last: int) -> bool:
    return not parent.isValid() and first == expected_first and last == expected_last


@pytest.mark.parametrize("source_version", [2, 3, 4, 5, 6])
def test_save_migration(tmp_path: Path, document: Document, source_version: int):
    """Tests migration of existing saves to the newest schema revision on save."""
    card = document.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True)
    capacity = document.page_layout.compute_page_card_capacity(card.requested_page_type())
    document.apply(ActionAddCard(card, capacity))
    action = ActionSaveDocument(_create_save_file(Path(tmp_path), source_version))
    action.apply(document)
    _validate_database_schema(action.file_path)
    _validate_saved_document_settings(document.page_layout, action.file_path)


def test_create_save(tmp_path: Path, document_custom_layout: Document):
    """Tests that saving a new document uses the newest database schema version"""
    layout = document_custom_layout.page_layout
    card = document_custom_layout.card_db.get_card_with_scryfall_id("0000579f-7b35-4ed3-b44c-db2a538066fe", True)
    capacity = layout.compute_page_card_capacity(card.requested_page_type())
    document_custom_layout.apply(ActionAddCard(card, capacity))
    save_file = tmp_path / "test.mtgproxies"
    action = ActionSaveDocument(save_file)
    action.apply(document_custom_layout)
    _validate_database_schema(save_file)
    _validate_saved_document_settings(layout, save_file)


@pytest.mark.parametrize("is_front", [True, False])
def test_save_as_saves_regular_card(tmp_path: Path, document: Document, is_front: bool):
    card = document.card_db.get_card_with_scryfall_id("b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", is_front)
    document.apply(ActionAddCard(card))
    save_file = tmp_path/"test.mtgproxies"
    action = ActionSaveDocument(save_file)
    action.apply(document)
    with open_database(save_file, "document-v7") as con:
        content = con.execute("SELECT page, slot, scryfall_id, is_front, type FROM Card").fetchall()
    del con
    assert_that(
        content, contains_exactly(
            contains_exactly(1, 1, "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", is_front, CardType.REGULAR.value)
        )
    )


def test_save_as_saves_check_card(tmp_path: Path, document: Document):
    card = CheckCard(
        document.card_db.get_card_with_scryfall_id("b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", True),
        document.card_db.get_card_with_scryfall_id("b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", False),
    )
    document.apply(ActionAddCard(card))
    save_file = tmp_path / "test.mtgproxies"
    action = ActionSaveDocument(save_file)
    action.apply(document)
    with open_database(save_file, "document-v7") as con:
        content = con.execute("SELECT page, slot, scryfall_id, is_front, type FROM Card").fetchall()
    del con
    assert_that(
        content, contains_exactly(
            contains_exactly(1, 1, "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", True, CardType.CHECK_CARD.value)
        )
    )


def test_subsequent_save_updates_settings(tmp_path: Path, qtbot: QtBot, document_custom_layout: Document):
    save_file = tmp_path / "test.mtgproxies"
    save_action = ActionSaveDocument(save_file)
    save_action.apply(document_custom_layout)

    modified_layout = copy.copy(document_custom_layout.page_layout)
    modified_layout.page_height = modified_layout.page_width = 1000*mm


    modified_layout.margin_top = modified_layout.margin_bottom = 13*mm

    modified_layout.margin_left = modified_layout.margin_right= 14*mm
    modified_layout.column_spacing = modified_layout.row_spacing = 2*mm
    modified_layout.draw_page_numbers = not modified_layout.draw_page_numbers
    modified_layout.draw_cut_markers = not modified_layout.draw_cut_markers
    modified_layout.draw_sharp_corners = not modified_layout.draw_sharp_corners
    modified_layout.document_name = "New"






    with qtbot.waitSignal(document_custom_layout.page_layout_changed, timeout=100):
        document_custom_layout.apply(ActionEditDocumentSettings(modified_layout))
    save_action.apply(document_custom_layout)




    _validate_saved_document_settings(modified_layout, save_file)



def _create_save_file(temp_path: Path, source_version: int):
    """Creates an empty document save file at the given path and using the given schema version."""
    save_file_path = temp_path/"test.mtgproxies"
    open_database(save_file_path, f"document-v{source_version}").close()
    return save_file_path
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
            "Given save file inconsistent: Unexpected tables or views")
        assert_that(
            db_unsafe.execute(indices_query).fetchall(),
            contains_exactly(*db_known_good.execute(indices_query).fetchall()),
            "Given save file inconsistent: Unexpected indices")


def _validate_saved_document_settings(document: Document, save_file: Path):
    layout = document.page_layout
    with open_database(save_file, "document-v7") as save:
        assert_that(
            save.execute(textwrap.dedent("""
            SELECT sum(cnt) FROM (
              SELECT COUNT(1) AS cnt FROM DocumentSettings
              UNION ALL 
              SELECT COUNT(1) AS cnt FROM DocumentDimensions







|
<







179
180
181
182
183
184
185
186

187
188
189
190
191
192
193
            "Given save file inconsistent: Unexpected tables or views")
        assert_that(
            db_unsafe.execute(indices_query).fetchall(),
            contains_exactly(*db_known_good.execute(indices_query).fetchall()),
            "Given save file inconsistent: Unexpected indices")


def _validate_saved_document_settings(layout: PageLayoutSettings, save_file: Path):

    with open_database(save_file, "document-v7") as save:
        assert_that(
            save.execute(textwrap.dedent("""
            SELECT sum(cnt) FROM (
              SELECT COUNT(1) AS cnt FROM DocumentSettings
              UNION ALL 
              SELECT COUNT(1) AS cnt FROM DocumentDimensions
Changes to tests/document_controller/test_action_shuffle_document.py.
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import pytest
from hamcrest import *

from mtg_proxy_printer.units_and_sizes import PageType, CardSizes
from mtg_proxy_printer.model.carddb import CardList
from mtg_proxy_printer.model.document_page import Page
from mtg_proxy_printer.document_controller import IllegalStateError
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.document_controller.shuffle_document import ActionShuffleDocument

from .helpers import append_new_card_in_page








|







14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import pytest
from hamcrest import *

from mtg_proxy_printer.units_and_sizes import PageType, CardSizes
from mtg_proxy_printer.model.card import CardList
from mtg_proxy_printer.model.document_page import Page
from mtg_proxy_printer.document_controller import IllegalStateError
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.document_controller.shuffle_document import ActionShuffleDocument

from .helpers import append_new_card_in_page

Changes to tests/helpers.py.
14
15
16
17
18
19
20

21
22


23
24
25
26
27
28
29
30
31
32
33
34



35
36
37
38
39
40
41
42
43
44
45
46
47
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import dataclasses
import functools
import json
import os

import typing
from numbers import Real


from unittest.mock import patch, MagicMock

from hamcrest.core.base_matcher import BaseMatcher
from hamcrest import assert_that, is_, empty, contains_inanyorder, has_properties, equal_to, any_of, instance_of, \
    close_to, all_of, greater_than_or_equal_to, less_than_or_equal_to
from hamcrest.core.description import Description
from hamcrest.core.matcher import Matcher
from pytestqt.qtbot import QtBot

import mtg_proxy_printer.model
import mtg_proxy_printer.model.carddb
import mtg_proxy_printer.card_info_downloader



from mtg_proxy_printer.printing_filter_updater import PrintingFilterUpdater
from mtg_proxy_printer.units_and_sizes import CardDataType, StrDict, QuantityT
import mtg_proxy_printer.logger
import mtg_proxy_printer.settings
from mtg_proxy_printer.sqlite_helpers import read_resource_text



def _should_skip_network_tests() -> bool:
    result = os.getenv("MTGPROXYPRINTER_RUN_NETWORK_TESTS", "0")
    try:
        result = int(result)
    except ValueError:







>


>
>












>
>
>

|


|
<







14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

46
47
48
49
50
51
52
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import dataclasses
import functools
import json
import os
import sqlite3
import typing
from numbers import Real
from pathlib import Path
from typing import List, Tuple, Union, Literal
from unittest.mock import patch, MagicMock

from hamcrest.core.base_matcher import BaseMatcher
from hamcrest import assert_that, is_, empty, contains_inanyorder, has_properties, equal_to, any_of, instance_of, \
    close_to, all_of, greater_than_or_equal_to, less_than_or_equal_to
from hamcrest.core.description import Description
from hamcrest.core.matcher import Matcher
from pytestqt.qtbot import QtBot

import mtg_proxy_printer.model
import mtg_proxy_printer.model.carddb
import mtg_proxy_printer.card_info_downloader
from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument
from mtg_proxy_printer.model.document_loader import CardType
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.printing_filter_updater import PrintingFilterUpdater
from mtg_proxy_printer.units_and_sizes import CardDataType, StrDict, QuantityT, CardSize
import mtg_proxy_printer.logger
import mtg_proxy_printer.settings
from mtg_proxy_printer.sqlite_helpers import read_resource_text, open_database



def _should_skip_network_tests() -> bool:
    result = os.getenv("MTGPROXYPRINTER_RUN_NETWORK_TESTS", "0")
    try:
        result = int(result)
    except ValueError:
81
82
83
84
85
86
87

88
89
90
91
92
93
94
95
96
97
98
99

100
101
102
103
104
105
106
    dw = mtg_proxy_printer.card_info_downloader.DatabaseImportWorker(card_db)
    dw._db = card_db.db  # Explicitly share the in-memory database connection
    section = mtg_proxy_printer.settings.settings["card-filter"]
    with qtbot.assertNotEmitted(dw.other_error_occurred), qtbot.assertNotEmitted(dw.network_error_occurred):
        settings_to_use = update_database_printing_filters(card_db, filter_settings)
        with patch.dict(section, settings_to_use):
            dw.populate_database(data)


def update_database_printing_filters(
        card_db: mtg_proxy_printer.model.carddb.CardDatabase, filter_settings: StrDict) -> StrDict:
    section = mtg_proxy_printer.settings.settings["card-filter"]
    settings_to_use = {filter_name: "False" for filter_name in section.keys()}
    if filter_settings:
        settings_to_use.update(filter_settings)
    section = mtg_proxy_printer.settings.settings["card-filter"]
    with patch.dict(section, settings_to_use):
        updater = PrintingFilterUpdater(card_db, card_db.db, force_update_hidden_column=True)
        updater.run()
    return settings_to_use


@functools.lru_cache()
def load_json(name: str) -> CardDataType:
    data = read_resource_text("tests.json_samples", f"{name}.json")
    return json.loads(data)









>












>







86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
    dw = mtg_proxy_printer.card_info_downloader.DatabaseImportWorker(card_db)
    dw._db = card_db.db  # Explicitly share the in-memory database connection
    section = mtg_proxy_printer.settings.settings["card-filter"]
    with qtbot.assertNotEmitted(dw.other_error_occurred), qtbot.assertNotEmitted(dw.network_error_occurred):
        settings_to_use = update_database_printing_filters(card_db, filter_settings)
        with patch.dict(section, settings_to_use):
            dw.populate_database(data)


def update_database_printing_filters(
        card_db: mtg_proxy_printer.model.carddb.CardDatabase, filter_settings: StrDict) -> StrDict:
    section = mtg_proxy_printer.settings.settings["card-filter"]
    settings_to_use = {filter_name: "False" for filter_name in section.keys()}
    if filter_settings:
        settings_to_use.update(filter_settings)
    section = mtg_proxy_printer.settings.settings["card-filter"]
    with patch.dict(section, settings_to_use):
        updater = PrintingFilterUpdater(card_db, card_db.db, force_update_hidden_column=True)
        updater.run()
    return settings_to_use


@functools.lru_cache()
def load_json(name: str) -> CardDataType:
    data = read_resource_text("tests.json_samples", f"{name}.json")
    return json.loads(data)


125
126
127
128
129
130
131


















132
133
134
135
136
137
138
def fill_card_database_with_json_card(
        qtbot: QtBot,
        card_db: mtg_proxy_printer.model.carddb.CardDatabase,
        json_file_or_name: typing.Union[str, CardDataType],
        filter_settings: typing.Dict[str, str] = None) -> mtg_proxy_printer.model.carddb.CardDatabase:
    return fill_card_database_with_json_cards(qtbot, card_db, [json_file_or_name], filter_settings)




















def assert_relation_is_empty(card_db: mtg_proxy_printer.model.carddb.CardDatabase, name: str):
    assert_that(
        card_db.db.execute(f'SELECT * FROM "{name}"').fetchall(),
        is_(empty()), f"{name} contains unexpected data"
    )








>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
def fill_card_database_with_json_card(
        qtbot: QtBot,
        card_db: mtg_proxy_printer.model.carddb.CardDatabase,
        json_file_or_name: typing.Union[str, CardDataType],
        filter_settings: typing.Dict[str, str] = None) -> mtg_proxy_printer.model.carddb.CardDatabase:
    return fill_card_database_with_json_cards(qtbot, card_db, [json_file_or_name], filter_settings)


def create_save_database_with(
        path_or_connection: Union[Path, Literal[":memory:"], sqlite3.Connection],
        pages: List[Tuple[int, CardSize]],
        cards: List[Tuple[int, int, bool, str, CardType]],
        settings: PageLayoutSettings) -> sqlite3.Connection:
    save = path_or_connection if isinstance(path_or_connection, sqlite3.Connection) \
        else open_database(path_or_connection, "document-v7")
    ActionSaveDocument.save_settings(save, settings)
    save.executemany(
        "INSERT INTO Page (page, image_size) VALUES (?, ?)",
        pages
    )
    save.executemany(
        'INSERT INTO "Card" (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)',
        cards
    )
    return save

def assert_relation_is_empty(card_db: mtg_proxy_printer.model.carddb.CardDatabase, name: str):
    assert_that(
        card_db.db.execute(f'SELECT * FROM "{name}"').fetchall(),
        is_(empty()), f"{name} contains unexpected data"
    )

Added tests/model/__init__.py.
Added tests/model/test_card.py.






















































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.
import copy

import pytest
from PyQt5.QtCore import QBuffer, QIODevice
from PyQt5.QtGui import QPixmap, QColorConstants, QColor
from pytestqt.qtbot import QtBot

from mtg_proxy_printer.model.card import Card, MTGSet, CardCorner, CustomCard, CheckCard
from mtg_proxy_printer.units_and_sizes import CardSize, CardSizes, PageType, UUID

from hamcrest import *


# noinspection PyUnusedLocal
@pytest.fixture()
def card(qtbot: QtBot) -> Card:  # QPixmap() requires a QApplication to function
    size = CardSizes.REGULAR
    pixmap = QPixmap(size.as_qsize_px())
    pixmap.fill(QColorConstants.Red)
    return Card(
        "Name", MTGSet("CODE", "Set name"), "collector number", "en",
        "11112222-3333-4444-5555-666677778888", True,
        "aaaabbbb-cccc-dddd-eeee-ffff00001111", "/nonexistent", True,
        size, 1, False, pixmap
    )


# noinspection PyUnusedLocal
@pytest.fixture()
def oversized(qtbot: QtBot) -> Card:  # QPixmap() requires a QApplication to function
    size = CardSizes.OVERSIZED
    pixmap = QPixmap(size.as_qsize_px())
    pixmap.fill(QColorConstants.Red)
    return Card(
        "Name", MTGSet("CODE", "Set name"), "collector number", "en",
        "11112222-3333-4444-5555-666677778888", True,
        "aaaabbbb-cccc-dddd-eeee-ffff00001111", "/nonexistent", True,
        size, 1, False, pixmap
    )


def test_card_corner_color_is_cached(card: Card):
    assert_that(card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Red)))
    new_pixmap = card.image_file.copy()
    new_pixmap.fill(QColorConstants.Blue)
    card.image_file = new_pixmap  # Direct assignment does not clear the cache
    assert_that(card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Red)))


def test_card_set_image_file_resets_corner_color(card: Card):
    assert_that(card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Red)))
    new_pixmap = card.image_file.copy()
    new_pixmap.fill(QColorConstants.Blue)
    card.set_image_file(new_pixmap)
    assert_that(card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Blue)))


def test_card_set_code(card: Card):
    assert_that(card.set_code, is_("CODE"))


def test_card_is_custom_card(card: Card):
    assert_that(card.is_custom_card, is_(False))


def test_card_is_oversized(card: Card, oversized: Card):
    assert_that(card.is_oversized, is_(False))
    assert_that(oversized.is_oversized, is_(True))


def test_card_requested_page_type(card: Card, oversized: Card):
    assert_that(card.requested_page_type(), is_(PageType.REGULAR))
    assert_that(oversized.requested_page_type(), is_(PageType.OVERSIZED))


def test_card_display_string(card: Card):
    assert_that(card.display_string(), is_('"Name" [CODE:collector number]'))


def image_as_bytes(color: QColor, size: CardSize) -> bytes:
    pixmap = QPixmap(size.as_qsize_px())
    pixmap.fill(color)
    buffer = QBuffer()
    buffer.open(QIODevice.OpenModeFlag.WriteOnly)
    pixmap.save(buffer, "PNG", quality=100)
    return buffer.data().data()


# noinspection PyUnusedLocal
@pytest.fixture()
def custom_card(qtbot: QtBot) -> CustomCard:  # QPixmap() requires a QApplication to function
    size = CardSizes.REGULAR
    image = image_as_bytes(QColorConstants.Red, size)
    return CustomCard(
        "Name", MTGSet("CODE", "Set name"), "collector number", "en",
        True, "/nonexistent", True,
        size, 1, False, image
    )


# noinspection PyUnusedLocal
@pytest.fixture()
def custom_oversized(qtbot: QtBot) -> CustomCard:  # QPixmap() requires a QApplication to function
    size = CardSizes.OVERSIZED
    image = image_as_bytes(QColorConstants.Red, size)
    return CustomCard(
        "Name", MTGSet("CODE", "Set name"), "collector number", "en",
        True, "/nonexistent", True,
        size, 1, False, image
    )


def test_custom_card_corner_color(custom_card: CustomCard):
    assert_that(custom_card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Red)))


def test_custom_card_image_file(custom_card: CustomCard):
    image = custom_card.image_file.toImage()
    test_px = image.pixelColor(image.width()//2, image.height()//2)
    assert_that(test_px, is_(equal_to(QColorConstants.Red)))


def test_custom_card_scryfall_id_is_uuid(custom_card: CustomCard):
    assert_that(custom_card.scryfall_id, instance_of(UUID), "Scryfall ID not a UUID")


def test_custom_card_set_code(custom_card: CustomCard):
    assert_that(custom_card.set_code, is_("CODE"))


def test_custom_card_is_custom_card(custom_card: CustomCard):
    assert_that(custom_card.is_custom_card, is_(True))


def test_custom_card_is_oversized(custom_card: CustomCard, custom_oversized: CustomCard):
    assert_that(custom_card.is_oversized, is_(False))
    assert_that(custom_oversized.is_oversized, is_(True))


def test_custom_card_requested_page_type(custom_card: CustomCard, custom_oversized: CustomCard):
    assert_that(custom_card.requested_page_type(), is_(PageType.REGULAR))
    assert_that(custom_oversized.requested_page_type(), is_(PageType.OVERSIZED))


def test_custom_card_display_string(custom_card: CustomCard):
    assert_that(custom_card.display_string(), is_('"Name" [CODE:collector number]'))


def test_custom_card_oracle_id_is_empty(custom_card: CustomCard):
    assert_that(custom_card.oracle_id, is_(empty()))

@pytest.mark.parametrize("property_name", Card.__annotations__)
def test_custom_card_has_all_card_attributes(custom_card: CustomCard, property_name: str):
    assert_that(custom_card, has_property(property_name))

def _create_back(front: Card) -> Card:
    back = copy.copy(front)
    image = front.image_file.copy()
    image.fill(QColorConstants.Green)
    back.set_image_file(image)
    back.name = "Back"
    back.is_front = False
    back.face_number = front.face_number + 1
    return back

@pytest.fixture()
def check_card(card: Card) -> CheckCard:
    back = _create_back(card)
    return CheckCard(card, back)

@pytest.fixture()
def oversized_check_card(oversized: Card) -> CheckCard:
    back = _create_back(oversized)
    return CheckCard(oversized, back)


def test_check_card_corner_color(check_card: CheckCard):
    assert_that(check_card.corner_color(CardCorner.TOP_LEFT), is_(equal_to(QColorConstants.Red)))
    assert_that(check_card.corner_color(CardCorner.BOTTOM_LEFT), is_(equal_to(QColorConstants.Green)))


def test_check_card_image_file(check_card: CheckCard):
    image = check_card.image_file.toImage()
    test_px_top = image.pixelColor(image.width()//2, image.height()//3)
    assert_that(test_px_top, is_(equal_to(QColorConstants.Red)))
    test_px_bottom = image.pixelColor(image.width()//2, image.height()-image.height()//3)
    assert_that(test_px_bottom, is_(equal_to(QColorConstants.Green)))


def test_check_card_scryfall_id_is_uuid(check_card: CheckCard):
    assert_that(check_card.scryfall_id, is_(check_card.front.scryfall_id))


def test_check_card_set_code(check_card: CheckCard):
    assert_that(check_card.set_code, is_("CODE"))


def test_check_card_is_custom_card(check_card: CheckCard):
    assert_that(check_card.is_custom_card, is_(False))


def test_check_card_is_oversized(check_card: CheckCard, oversized_check_card: CheckCard):
    assert_that(check_card.is_oversized, is_(False))
    assert_that(oversized_check_card.is_oversized, is_(True))


def test_check_card_requested_page_type(check_card: CheckCard, oversized_check_card: CheckCard):
    assert_that(check_card.requested_page_type(), is_(PageType.REGULAR))
    assert_that(oversized_check_card.requested_page_type(), is_(PageType.OVERSIZED))


def test_check_card_display_string(check_card: CheckCard):
    assert_that(check_card.display_string(), is_('"Name // Back" [CODE:collector number]'))


def test_check_card_oracle_id_is_empty(check_card: CheckCard):
    assert_that(check_card.oracle_id, is_(check_card.front.oracle_id))

@pytest.mark.parametrize("property_name", Card.__annotations__)
def test_check_card_has_all_card_attributes(check_card: CheckCard, property_name: str):
    assert_that(check_card, has_property(property_name))
Added tests/model/test_card_list.py.














































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


from collections import Counter
import typing

from hamcrest import *
import pytest
from pytestqt.qtbot import QtBot
from PyQt5.QtCore import QItemSelectionModel

from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData
from mtg_proxy_printer.model.card_list import CardListModel, CardListModelRow, CardListColumns
from mtg_proxy_printer.model.document import Document

from tests.helpers import fill_card_database_with_json_cards

OVERSIZED_ID = "650722b4-d72b-4745-a1a5-00a34836282b"
REGULAR_ID = "0000579f-7b35-4ed3-b44c-db2a538066fe"
FOREST_ID = "7ef83f4c-d3ff-4905-a16d-f2bae673a5b2"
WASTES_ID = "9cc070d3-4b83-4684-9caf-063e5c473a77"
SNOW_FOREST_ID = "ca17acea-f079-4e53-8176-a2f5c5c408a1"


def _populate_card_db_and_create_model(qtbot, document: Document) -> CardListModel:
    fill_card_database_with_json_cards(
        qtbot, document.card_db,
        ["oversized_card", "regular_english_card", "english_basic_Forest", "english_basic_Wastes", "english_basic_Snow_Forest"])
    model = CardListModel(document)
    return model


@pytest.mark.parametrize("count", [1, 2, 10])
def test_add_oversized_card_updates_oversized_count(qtbot: QtBot, document: Document, count: int):
    model = _populate_card_db_and_create_model(qtbot, document)
    oversized = document.card_db.get_card_with_scryfall_id(OVERSIZED_ID, True)
    with qtbot.wait_signal(model.oversized_card_count_changed, check_params_cb=(lambda value: value == count)):
        model.add_cards(Counter({oversized: count}))
    assert_that(model.oversized_card_count, is_(equal_to(count)))


@pytest.mark.parametrize("count, expected", [
    (-1, 1), (0, 1), (1, 1), (99, 99), (100, 100), (101, 100),
])
def test_add_cards_with_invalid_count_clamped_to_valid_range(
        qtbot: QtBot, document: Document, count: int, expected: int):
    model = _populate_card_db_and_create_model(qtbot, document)
    card = document.card_db.get_card_with_scryfall_id(REGULAR_ID, True)
    model.add_cards(Counter({card: count}))
    assert_that(model.rowCount(), is_(1))
    index = model.index(0, CardListColumns.Copies)
    assert_that(model.data(index), is_(expected))


@pytest.mark.parametrize("new_count", [5, 15])
def test_update_oversized_card_count_updates_oversized_count(qtbot: QtBot, document: Document, new_count: int):
    model = _populate_card_db_and_create_model(qtbot, document)
    oversized = document.card_db.get_card_with_scryfall_id(OVERSIZED_ID, True)
    model.add_cards(Counter({oversized: 10}))
    assert_that(model.oversized_card_count, is_(equal_to(10)))

    index = model.index(0, CardListColumns.Copies)
    with qtbot.wait_signal(model.oversized_card_count_changed, check_params_cb=(lambda value: value == new_count)):
        model.setData(index, new_count)
    assert_that(model.oversized_card_count, is_(equal_to(new_count)))


def test_remove_oversized_card_updates_oversized_count(qtbot: QtBot, document: Document):
    model = _populate_card_db_and_create_model(qtbot, document)
    oversized = document.card_db.get_card_with_scryfall_id(OVERSIZED_ID, True)
    model.add_cards(Counter({oversized: 10}))
    assert_that(model.oversized_card_count, is_(equal_to(10)))

    with qtbot.wait_signal(model.oversized_card_count_changed, check_params_cb=(lambda value: value == 0)):
        model.remove_cards(0, 1)
    assert_that(model.oversized_card_count, is_(equal_to(0)))


def test_replace_oversized_with_regular_card_decrements_oversized_count(qtbot: QtBot, document: Document):
    model = _populate_card_db_and_create_model(qtbot, document)
    regular = document.card_db.get_card_with_scryfall_id(REGULAR_ID, True)
    oversized = document.card_db.get_card_with_scryfall_id(OVERSIZED_ID, True)
    regular_data = CardIdentificationData(
        regular.language, scryfall_id=regular.scryfall_id, is_front=regular.is_front)

    with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000, check_params_cb=(lambda value: value == 1)):
        model.add_cards(Counter({oversized: 1, regular: 1}))
    oversized_index = model.index(0, 0)
    regular_index = model.index(1, 0)
    assert_that(model.rows[0].card.is_oversized, is_(True))
    assert_that(model.rows[oversized_index.row()].card.is_oversized, is_(True))
    assert_that(model.rows[1].card.is_oversized, is_(False))
    assert_that(model.rows[regular_index.row()].card.is_oversized, is_(False))
    assert_that(model.oversized_card_count, is_(1))

    with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000):
        assert_that(model._request_replacement_card(oversized_index, regular_data), is_(True))
    assert_that(model.rows[0].card.is_oversized, is_(False))
    assert_that(model.rows[1].card.is_oversized, is_(False))
    assert_that(model.oversized_card_count, is_(0))


def test_replace_regular_with_oversized_card_increments_oversized_count(qtbot: QtBot, document: Document):
    model = _populate_card_db_and_create_model(qtbot, document)
    regular = document.card_db.get_card_with_scryfall_id(REGULAR_ID, True)
    oversized = document.card_db.get_card_with_scryfall_id(OVERSIZED_ID, True)
    oversized_data = CardIdentificationData(
        oversized.language, scryfall_id=oversized.scryfall_id, is_front=oversized.is_front)

    with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000, check_params_cb=(lambda value: value == 1)):
        model.add_cards(Counter({oversized: 1, regular: 1}))

    oversized_index = model.index(0, 0)
    regular_index = model.index(1, 0)
    assert_that(model.rows[0].card.is_oversized, is_(True))
    assert_that(model.rows[oversized_index.row()].card.is_oversized, is_(True))
    assert_that(model.rows[1].card.is_oversized, is_(False))
    assert_that(model.rows[regular_index.row()].card.is_oversized, is_(False))
    assert_that(model.oversized_card_count, is_(1))

    with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000):
        assert_that(model._request_replacement_card(regular_index, oversized_data), is_(True))
    assert_that(model.rows[0].card.is_oversized, is_(True))
    assert_that(model.rows[1].card.is_oversized, is_(True))
    assert_that(model.oversized_card_count, is_(2))


@pytest.mark.parametrize("ranges, merged", [
    ([], []),
    ([(2, 3)], [(2, 3)]),
    ([(0, 0), (0, 0)], [(0, 0)]),
    ([(0, 0), (0, 1)], [(0, 1)]),
    ([(0, 1), (2, 3)], [(0, 3)]),
    ([(0, 1), (3, 4)], [(0, 1), (3, 4)]),
])
def test__merge_ranges(ranges: typing.List[typing.Tuple[int, int]], merged: typing.List[typing.Tuple[int, int]]):
    assert_that(
        CardListModel._merge_ranges(ranges),
        contains_exactly(*merged),
        "Wrong merge result"
    )


def test_remove_multi_selection(qtbot: QtBot, document: Document):
    model = _populate_card_db_and_create_model(qtbot, document)
    regular = CardListModelRow(document.card_db.get_card_with_scryfall_id(REGULAR_ID, True), 1)
    oversized = CardListModelRow(document.card_db.get_card_with_scryfall_id(OVERSIZED_ID, True), 1)
    model.add_cards(Counter({
        oversized.card: 1,
        regular.card: 1,
    }))
    model.add_cards(Counter({
        oversized.card: 1,
    }))
    selection_model = QItemSelectionModel(model)
    selection_model.select(model.index(0, 0), QItemSelectionModel.Select)
    selection_model.select(model.index(2, 0), QItemSelectionModel.Select)
    assert_that(
        model.remove_multi_selection(selection_model.selection()),
        is_(equal_to(2))
    )
    assert_that(model.rows, contains_exactly(regular))
    assert_that(model.rowCount(), is_(equal_to(1)))


@pytest.mark.parametrize("include_wastes, include_snow_basics, present_cards, expected", [
    (False, False, [], False),
    (False, True, [], False),
    (True, False, [], False),
    (True, True, [], False),

    (False, False, [REGULAR_ID], False),
    (False, True, [REGULAR_ID], False),
    (True, False, [REGULAR_ID], False),
    (True, True, [REGULAR_ID], False),

    (False, False, [REGULAR_ID, OVERSIZED_ID], False),
    (False, True, [REGULAR_ID, OVERSIZED_ID], False),
    (True, False, [REGULAR_ID, OVERSIZED_ID], False),
    (True, True, [REGULAR_ID, OVERSIZED_ID], False),

    (False, False, [FOREST_ID], True),
    (False, True, [FOREST_ID], True),
    (True, False, [FOREST_ID], True),
    (True, True, [FOREST_ID], True),

    (False, False, [WASTES_ID], False),
    (False, True, [WASTES_ID], False),
    (True, False, [WASTES_ID], True),
    (True, True, [WASTES_ID], True),

    (False, False, [SNOW_FOREST_ID], False),
    (False, True, [SNOW_FOREST_ID], True),
    (True, False, [SNOW_FOREST_ID], False),
    (True, True, [SNOW_FOREST_ID], True),

    (False, False, [SNOW_FOREST_ID, WASTES_ID], False),
    (False, True, [SNOW_FOREST_ID, WASTES_ID], True),
    (True, False, [SNOW_FOREST_ID, WASTES_ID], True),
    (True, True, [SNOW_FOREST_ID, WASTES_ID], True),
])
def test_has_basic_lands(
        qtbot: QtBot, document: Document,
        include_wastes: bool, include_snow_basics: bool,
        present_cards: typing.List[str], expected: bool):
    model = _populate_card_db_and_create_model(qtbot, document)
    model.add_cards(Counter(
        {document.card_db.get_card_with_scryfall_id(scryfall_id, True): 1 for scryfall_id in present_cards}
    ))
    assert_that(
        model.has_basic_lands(include_wastes, include_snow_basics),
        is_(expected)
    )


@pytest.mark.parametrize("remove_wastes, remove_snow_basics, present_cards, expected_remaining", [
    (False, False, [], []),
    (False, True, [], []),
    (True, False, [], []),
    (True, True, [], []),
    
    (False, False, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]),
    (False, True, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]),
    (True, False, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]),
    (True, True, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]),

    (False, False, [WASTES_ID, SNOW_FOREST_ID], [WASTES_ID, SNOW_FOREST_ID]),
    (False, True, [WASTES_ID, SNOW_FOREST_ID], [WASTES_ID]),
    (True, False, [WASTES_ID, SNOW_FOREST_ID], [SNOW_FOREST_ID]),
    (True, True, [WASTES_ID, SNOW_FOREST_ID], []),

    (False, False, [FOREST_ID], []),
    (False, True, [FOREST_ID], []),
    (True, False, [FOREST_ID], []),
    (True, True, [FOREST_ID], []),
])
def test_remove_all_basic_lands(
        qtbot: QtBot, document: Document,
        remove_wastes: bool, remove_snow_basics: bool,
        present_cards: typing.List[str], expected_remaining: typing.List[str]):
    model = _populate_card_db_and_create_model(qtbot, document)
    model.add_cards(Counter(
        {document.card_db.get_card_with_scryfall_id(scryfall_id, True): 1 for scryfall_id in present_cards}
    ))
    model.remove_all_basic_lands(remove_wastes, remove_snow_basics)
    remaining = [row.card.scryfall_id for row in model.rows]
    assert_that(
        remaining,
        contains_exactly(*expected_remaining)
    )
Added tests/model/test_carddb.py.






















































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import datetime
import itertools
from pathlib import Path
import textwrap
import typing
import unittest.mock
from unittest.mock import MagicMock

from hamcrest import *
import pytest

import mtg_proxy_printer.settings
from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData, MINIMUM_REFRESH_DELAY
from mtg_proxy_printer.model.card import MTGSet, Card, CardList
from mtg_proxy_printer.model.imagedb_files import CacheContent
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.print_count_updater import PrintCountUpdater
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.units_and_sizes import UUID

from ..helpers import assert_model_is_empty, fill_card_database_with_json_card, \
    fill_card_database_with_json_cards, is_dataclass_equal_to, matches_type_annotation, update_database_printing_filters
from ..test_card_info_downloader import TestCaseData

StringList = typing.List[str]
OptString = typing.Optional[str]


def test_has_data_on_empty_database_returns_false(card_db: CardDatabase):
    assert_model_is_empty(card_db)
    assert_that(card_db.has_data(), is_(False))


def test_has_data_on_filled_database_returns_true(qtbot, card_db: CardDatabase):
    fill_card_database_with_json_card(qtbot, card_db, "regular_english_card")
    assert_that(card_db.has_data(), is_(True))


def test_get_all_languages_without_data(card_db: CardDatabase):
    assert_that(
        card_db.get_all_languages(),
        is_(empty())
    )


def test_get_all_languages_with_data(qtbot, card_db: CardDatabase):
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "german_Coercion_with_faulty_translation",
            "spanish_basic_Forest",
            "german_Duress",
        ],
    )
    assert_that(
        card_db.get_all_languages(),
        contains_exactly("de", "en", "es")
    )


@pytest.mark.parametrize("language, prefix, expected_names", [
    ("en", None, ["Forest", "Future Sight", "Duress", "Coercion"]),
    ("en", "Fu", ["Future Sight"]),
    ("en", "%or", ["Forest"]),
    ("en", "AAAAAAAA", []),
    ("en", "F%t", ["Forest", "Future Sight"]),
    ("de", None, ["Wald", "Zwang"]),  # noqa  # A German Forest and Duress
    ("es", None, ["Bosque"]),  # noqa  # A Spanish Forest
    ("Nonexisting language", None, []),
])
def test_get_card_names(qtbot, card_db: CardDatabase, language: str, prefix: OptString, expected_names: StringList):
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "english_basic_Forest",
            "english_basic_Forest_2",
            "english_card_Future_Sight_MH1",
            "english_card_Future_Sight_MTGO_promo",
            "german_Coercion_with_faulty_translation",
            "german_basic_Forest",
            "spanish_basic_Forest",
            "german_Duress",
        ],
    )
    assert_that(
        card_db.get_card_names(language, prefix),
        contains_inanyorder(*expected_names)
    )


@pytest.mark.parametrize("name, expected", [
    ("Forest", "en"),
    ("Future Sight", "en"),
    ("Wald", "de"),
    ("Bosque", "es"),
    ("Unknown", None),
    ("Mentor Corrosivo", "pt"),
    ("Mentor corrosivo", "es"),
])
def test_guess_language_from_name(qtbot, card_db: CardDatabase, name: str, expected: OptString):
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "english_basic_Forest",
            "english_basic_Forest_2",
            "english_card_Future_Sight_MH1",
            "english_card_Future_Sight_MTGO_promo",
            "german_Coercion_with_faulty_translation",
            "german_basic_Forest",
            "spanish_basic_Forest",
            "german_Duress",
            "korean_Forest_with_placeholder_name",
            "portuguese_Corrosive_Mentor",
            "spanish_Corrosive_Mentor",
        ],
    )
    assert_that(
        card_db.guess_language_from_name(name),
        is_(equal_to(expected))
    )


@pytest.mark.parametrize("language, expected", [
    ("en", True),
    ("de", True),
    ("es", True),
    ("", False),
    ("Unknown", False),
])
def test_is_known_language(qtbot, card_db: CardDatabase, language: str, expected: bool):
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "english_basic_Forest",
            "english_basic_Forest_2",
            "english_card_Future_Sight_MH1",
            "english_card_Future_Sight_MTGO_promo",
            "german_Coercion_with_faulty_translation",
            "german_basic_Forest",
            "spanish_basic_Forest",
            "german_Duress",
        ],
    )
    assert_that(
        card_db.is_known_language(language),
        is_(equal_to(expected))
    )


@pytest.fixture
def card_db_with_cards(qtbot, card_db: CardDatabase):
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "english_basic_Forest",
            "english_basic_Forest_2",
            "english_card_Future_Sight_MH1",
            "english_card_Future_Sight_MTGO_promo",
            "german_Coercion_with_faulty_translation",
            "german_basic_Forest",
            "spanish_basic_Forest",
            "german_Duress",
            "german_Duress_2",
            "english_Ironroot_Treefolk_1",
            "english_Ironroot_Treefolk_2",
            "english_Ironroot_Treefolk_3",
            "german_Ironroot_Treefolk_1",
            "german_Ironroot_Treefolk_2",
            "german_Ironroot_Treefolk_3",
            "oversized_card",
            "regular_english_card",
            "english_double_faced_card",
            "english_double_faced_art_series_card",
            "Flowerfoot_Swordmaster_card",
            "Flowerfoot_Swordmaster_token",
        ],
    )
    yield card_db
    card_db.__dict__.clear()


def generate_test_cases_for_test_translate_card_name():
    """Yields tuples with card data, target language and expected result."""
    # Same-language identity translation
    yield CardIdentificationData("en", "Forest"), "en", "Forest"
    yield CardIdentificationData("de", "Wald"), "de", "Wald"
    yield CardIdentificationData("es", "Bosque"), "es", "Bosque"
    # Guess source language
    yield CardIdentificationData(None, "Forest"), "en", "Forest"
    yield CardIdentificationData(None, "Wald"), "en", "Forest"
    yield CardIdentificationData(None, "Bosque"), "en", "Forest"
    yield CardIdentificationData(None, "Bosque"), "de", "Wald"
    yield CardIdentificationData(None, "Forest"), "de", "Wald"
    # translation with source language
    yield CardIdentificationData("de", "Wald"), "en", "Forest"
    yield CardIdentificationData("es", "Bosque"), "en", "Forest"
    # wrong source language. Returns no result
    yield CardIdentificationData("wrong_source", "Wald"), "en", None
    yield CardIdentificationData("wrong_source", "Forest"), "de", None
    yield CardIdentificationData("wrong_source", "Bosque"), "en", None
    yield CardIdentificationData("wrong_source", "Bosque"), "es", None
    # Card with name clash. Tests majority voting
    yield CardIdentificationData("de", "Zwang"), "en", "Duress"
    yield CardIdentificationData(None, "Zwang"), "en", "Duress"
    # Card with name clash. Tests using context information yields the expected name
    yield CardIdentificationData("de", "Zwang", scryfall_id="51c6ec30-afb2-41e6-895b-92e070aa86f3"), "en", "Duress"
    yield CardIdentificationData(None, "Zwang", scryfall_id="51c6ec30-afb2-41e6-895b-92e070aa86f3"), "en", "Duress"
    yield CardIdentificationData("de", "Zwang", scryfall_id="93054b80-fd1f-4200-8d33-2e826a181db0"), "en", "Coercion"
    yield CardIdentificationData(None, "Zwang", scryfall_id="93054b80-fd1f-4200-8d33-2e826a181db0"), "en", "Coercion"
    yield CardIdentificationData("de", "Zwang", "7ed"), "en", "Duress"
    yield CardIdentificationData(None, "Zwang", "7ed"), "en", "Duress"
    yield CardIdentificationData("de", "Zwang", "6ed"), "en", "Coercion"
    yield CardIdentificationData(None, "Zwang", "6ed"), "en", "Coercion"
    # Card with updated, localized name. Tests that all names can be a source name.
    yield CardIdentificationData("de", "Baumvolk der Eisenwurzler"), "en", "Ironroot Treefolk"
    yield CardIdentificationData(None, "Baumvolk der Eisenwurzler"), "en", "Ironroot Treefolk"
    yield CardIdentificationData("de", "Ehernen-Wald Baumvolk"), "en", "Ironroot Treefolk"
    yield CardIdentificationData(None, "Ehernen-Wald Baumvolk"), "en", "Ironroot Treefolk"
    yield CardIdentificationData("de", "Baumvolk des Ehernen-Waldes"), "en", "Ironroot Treefolk"
    yield CardIdentificationData(None, "Baumvolk des Ehernen-Waldes"), "en", "Ironroot Treefolk"
    # Card with updated, localized name. Tests returning the newest name without context information
    yield CardIdentificationData("en", "Ironroot Treefolk"), "de", "Baumvolk der Eisenwurzler"
    yield CardIdentificationData(None, "Ironroot Treefolk"), "de", "Baumvolk der Eisenwurzler"
    # Card with updated, localized name. Tests returning the correct name for the source set with context information
    yield CardIdentificationData("en", "Ironroot Treefolk", "5ed"), "de", "Baumvolk der Eisenwurzler"
    yield CardIdentificationData(None, "Ironroot Treefolk", "5ed"), "de", "Baumvolk der Eisenwurzler"
    yield CardIdentificationData("en", "Ironroot Treefolk", "4ed"), "de", "Ehernen-Wald Baumvolk"
    yield CardIdentificationData(None, "Ironroot Treefolk", "4ed"), "de", "Ehernen-Wald Baumvolk"
    yield CardIdentificationData("en", "Ironroot Treefolk", "3ed"), "de", "Baumvolk des Ehernen-Waldes"
    yield CardIdentificationData(None, "Ironroot Treefolk", "3ed"), "de", "Baumvolk des Ehernen-Waldes"
    yield CardIdentificationData("en", "Ironroot Treefolk", scryfall_id="6bdbba38-b4c9-4c14-b869-669b39390e4e"), "de", "Baumvolk der Eisenwurzler"
    yield CardIdentificationData(None, "Ironroot Treefolk", scryfall_id="6bdbba38-b4c9-4c14-b869-669b39390e4e"), "de", "Baumvolk der Eisenwurzler"
    yield CardIdentificationData("en", "Ironroot Treefolk", scryfall_id="c6c93c85-5263-4770-b937-704e57912478"), "de", "Ehernen-Wald Baumvolk"
    yield CardIdentificationData(None, "Ironroot Treefolk", scryfall_id="c6c93c85-5263-4770-b937-704e57912478"), "de", "Ehernen-Wald Baumvolk"
    yield CardIdentificationData("en", "Ironroot Treefolk", scryfall_id="6e6cfaae-ea9e-4c54-858e-381f8bf441a9"), "de", "Baumvolk des Ehernen-Waldes"
    yield CardIdentificationData(None, "Ironroot Treefolk", scryfall_id="6e6cfaae-ea9e-4c54-858e-381f8bf441a9"), "de", "Baumvolk des Ehernen-Waldes"
    # double-faced art series card. Same name on both sides
    yield CardIdentificationData("en", "Clearwater Pathway"), "en", "Clearwater Pathway"

    
@pytest.mark.parametrize("card_data, target_language, expected", generate_test_cases_for_test_translate_card_name())
def test_translate_card_name(
        card_db_with_cards: CardDatabase, card_data: CardIdentificationData, target_language: str, expected: OptString):
    assert_that(
        card_db_with_cards.translate_card_name(card_data, target_language),
        is_(equal_to(expected))
    )


@pytest.mark.parametrize("usage_count, expected", [
    (-1, []),
    (0, []),
    (1, [2]),
    (2, [1, 2]),
    (3, [0, 1, 2]),
    (100, [0, 1, 2]),
])
def test_cards_used_less_often_then(qtbot, card_db: CardDatabase, usage_count: int, expected: typing.List[int]):
    # Setup
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "english_basic_Forest",
            "english_basic_Forest_2",
            "english_card_Future_Sight_MH1",
            "english_card_Future_Sight_MTGO_promo",
            "german_Coercion_with_faulty_translation",
            "german_basic_Forest",
            "spanish_basic_Forest",
            "german_Duress",
        ],
    )
    document = Document(card_db, MagicMock())
    document.apply(ActionAddCard(
        _get_card_from_model(card_db, "e2ef9b74-481b-424b-8e33-f0b910f66370", True), 1)
    )
    PrintCountUpdater(document, card_db.db).run()
    document.apply(ActionAddCard(
        _get_card_from_model(card_db, "ffa13d4c-6c5e-44bd-859e-38e79d47a916", True), 1)
    )
    PrintCountUpdater(document, card_db.db).run()
    # Test
    assert_that(
        result := card_db.cards_used_less_often_then([
            ("e2ef9b74-481b-424b-8e33-f0b910f66370", True),
            ("ffa13d4c-6c5e-44bd-859e-38e79d47a916", True),
            ("cd4cf73d-a408-48f1-9931-54707553c5d5", True),
        ], usage_count),
        contains_exactly(*expected),
        f"Result: {result}"
    )


def _get_card_from_model(card_db: CardDatabase, scryfall_id: str, is_front: bool):
    card = card_db.get_card_with_scryfall_id(scryfall_id, is_front)
    assert_that(card, has_properties({
        "scryfall_id": equal_to(scryfall_id),
        "is_front": equal_to(is_front),
    }), "Wrong card returned")
    return card


@pytest.mark.parametrize("json_name, scryfall_id, expected", [
    ("regular_english_card", "0000579f-7b35-4ed3-b44c-db2a538066fe", False),
    ("oversized_card", "650722b4-d72b-4745-a1a5-00a34836282b", True)
])
def test_card_is_oversized(qtbot, card_db: CardDatabase, json_name: str, scryfall_id: str, expected: bool):
    """
    Tests that all methods creating Card instances correctly set is_oversized attribute.
    """
    fill_card_database_with_json_card(qtbot, card_db, json_name)
    assert_that(
        card_db.get_card_with_scryfall_id(scryfall_id, True),
        has_property("is_oversized", is_(expected))
    )


def generate_test_cases_for_test_get_cards_from_data():
    case = TestCaseData("regular_english_card")
    yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id), [case.as_card(),]
    yield CardIdentificationData(scryfall_id=case.scryfall_id), [case.as_card(),]

    case = TestCaseData("oversized_card")
    yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id), [case.as_card(),]
    yield CardIdentificationData(scryfall_id=case.scryfall_id), [case.as_card(),]

    # Tests effect of is_front on double-faced cards
    case = TestCaseData("english_double_faced_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id), [
        case.as_card(1),
        case.as_card(2),
    ]
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), [
        case.as_card(1),
    ]
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), [
        case.as_card(2),
    ]

    # Tests identification based on oracle_id alone. Also tests highres_image boolean
    forest_en_1 = TestCaseData("english_basic_Forest")
    forest_en_2 = TestCaseData("english_basic_Forest_2")
    forest_de = TestCaseData("german_basic_Forest")
    forest_es = TestCaseData("spanish_basic_Forest")
    yield CardIdentificationData(oracle_id="b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"), [
        forest_en_1.as_card(),
        forest_en_2.as_card(),
        forest_de.as_card(),
        forest_es.as_card(),
    ]
    # Tests other attribute combinations
    yield CardIdentificationData(name="Bosque"), [
        forest_es.as_card()
    ]
    yield CardIdentificationData(set_code="anb"), [
        forest_en_1.as_card()
    ]
    yield CardIdentificationData("de", set_code="znr"), [
       forest_de.as_card()
    ]
    yield CardIdentificationData(set_code="znr", collector_number="280"), [
        forest_en_2.as_card(),
        forest_es.as_card(),
    ]
    # Empty result set
    yield CardIdentificationData(scryfall_id="invalid"), []
    # Prefer cards to tokens with the same name
    yield CardIdentificationData(name="Flowerfoot Swordmaster"), [
        TestCaseData("Flowerfoot_Swordmaster_card").as_card(),
        TestCaseData("Flowerfoot_Swordmaster_token").as_card(),
    ]


@pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test_get_cards_from_data())
def test_get_cards_from_data(
        card_db_with_cards: CardDatabase,
        card_data: CardIdentificationData, expected: CardList):
    cards = card_db_with_cards.get_cards_from_data(card_data)
    for card in cards:
        assert_that(card, matches_type_annotation())
    assert_that(
        cards,
        contains_inanyorder(
            *map(is_dataclass_equal_to, expected)
        )
    )

@pytest.mark.parametrize("card_data, expected", [
            (CardIdentificationData(name="Flowerfoot Swordmaster"), [
        TestCaseData("Flowerfoot_Swordmaster_card").as_card(),
        TestCaseData("Flowerfoot_Swordmaster_token").as_card(),
    ])
])
def test_get_cards_from_data_always_prefers_card_over_token(
        card_db_with_cards: CardDatabase,
        card_data: CardIdentificationData, expected: CardList):
    cards = card_db_with_cards.get_cards_from_data(card_data)
    assert_that(
        cards,
        contains_exactly(
            *map(is_dataclass_equal_to, expected)
        )
    )

def generate_test_cases_for_test_get_card_with_scryfall_id() -> \
        typing.Generator[typing.Tuple[CardIdentificationData, typing.Optional[Card]], None, None]:
    # Regular card
    case = TestCaseData("regular_english_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card()
    # Back side of regular card returns None
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), None
    # Unknown scryfall_id returns None
    yield CardIdentificationData(scryfall_id="ueueueue-abcd-1234-5678-abcdefabcdef", is_front=True), None

    case = TestCaseData("oversized_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card()

    case = TestCaseData("german_basic_Forest")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card()

    case = TestCaseData("spanish_basic_Forest")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card()

    # Double-faced with high-res image
    case = TestCaseData("english_double_faced_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card(1)
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(2)

    # Art series card
    case = TestCaseData("english_double_faced_art_series_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card(1)
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(2)
    # Digital card
    case = TestCaseData("english_basic_Forest")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card()


@pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test_get_card_with_scryfall_id())
def test_get_card_with_scryfall_id(
        card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: typing.Optional[Card]):
    assert_that(
        card_db_with_cards.get_card_with_scryfall_id(card_data.scryfall_id, card_data.is_front),
        is_(any_of(
            all_of(
                none(),
                instance_of(type(expected))  # None if and only if expected is None
            ),
            all_of(
                is_(instance_of(Card)),
                matches_type_annotation(),
                has_properties({
                    # Verifies that the expected card matches the given card identification data.
                    # Not strictly required, but ensures that the test data is consistent
                    "scryfall_id": card_data.scryfall_id,
                    "is_front": card_data.is_front,
                }),
                is_dataclass_equal_to(expected),
            )))
    )


@pytest.mark.parametrize("language", ["en", None])
@pytest.mark.parametrize("card_count_data, expected_index, identification_data", [
    ([("7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", 2), ("e2ef9b74-481b-424b-8e33-f0b910f66370", 1)], 0, CardIdentificationData(name="Forest")),
    ([("7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", 1), ("e2ef9b74-481b-424b-8e33-f0b910f66370", 2)], 1, CardIdentificationData(name="Forest")),
])
def test_get_cards_from_data_order_by_print_count_enabled(
        qtbot, card_db: CardDatabase, language: OptString, card_count_data, expected_index: int, identification_data: CardIdentificationData):
    fill_card_database_with_json_cards(qtbot, card_db, ["english_basic_Forest", "english_basic_Forest_2"])
    card_db.db.executemany(
        "INSERT INTO LastImageUseTimestamps (scryfall_id, is_front, usage_count) VALUES (?, 1, ?)",
        card_count_data
    )
    identification_data.language = language
    cards = card_db.get_cards_from_data(identification_data, order_by_print_count=True)
    other_index = int(not expected_index)
    assert_that(
        cards,
        contains_exactly(
            has_property("scryfall_id", equal_to(
                card_count_data[expected_index][0]
            )),
            has_property("scryfall_id", equal_to(
                card_count_data[other_index][0]
            )),
        )
    )


def test_get_replacement_card(
        qtbot, card_db: CardDatabase):
    fill_card_database_with_json_cards(qtbot, card_db, ["english_basic_Forest", "german_basic_Forest"])
    card_db.db.executemany(
        textwrap.dedent("""\
            INSERT INTO RemovedPrintings (scryfall_id, language, oracle_id)
                VALUES (?, ?, ?)
            """), [
            ("english-id", "en", "b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"),
            ("german-id", "de", "b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc"),
            ("non-english-id", "invalid", "b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"),
        ])
    card_db.get_replacement_card_for_unknown_printing(CardIdentificationData(scryfall_id="english-id", language="en"))


def generate_test_cases_for_test__translate_card():
    # Same-language translation
    for case in (TestCaseData("regular_english_card"), TestCaseData("regular_english_card")):
        yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id, is_front=True), case.as_card()
    for case in (TestCaseData("english_double_faced_card"), TestCaseData("english_double_faced_art_series_card")):
        yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id, is_front=True), case.as_card(1)
        yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id, is_front=False), case.as_card(2)

    # Translate single-faced card
    forests = TestCaseData("english_basic_Forest_2"), TestCaseData("german_basic_Forest"), TestCaseData("spanish_basic_Forest")
    for source, target in itertools.product(forests, repeat=2):  # type: TestCaseData, TestCaseData
        yield CardIdentificationData(target.language, scryfall_id=source.scryfall_id, is_front=True), target.as_card()
    #
    treefolk = (
        (TestCaseData("german_Ironroot_Treefolk_1"), TestCaseData("english_Ironroot_Treefolk_1")),
        (TestCaseData("german_Ironroot_Treefolk_2"), TestCaseData("english_Ironroot_Treefolk_2")),
        (TestCaseData("german_Ironroot_Treefolk_3"), TestCaseData("english_Ironroot_Treefolk_3")),
    )
    for card_1, card_2 in treefolk:
        yield CardIdentificationData(card_2.language, scryfall_id=card_1.scryfall_id, is_front=True), card_2.as_card()
        yield CardIdentificationData(card_1.language, scryfall_id=card_2.scryfall_id, is_front=True), card_1.as_card()


@pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test__translate_card())
def test__translate_card(card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: Card):
    is_front = card_data.is_front is None or card_data.is_front
    to_translate = card_db_with_cards.get_card_with_scryfall_id(card_data.scryfall_id, is_front)
    # Use the private method to skip the internal shortcut in translate_card()
    # that skips requested same-language translations.
    assert_that(
        card_db_with_cards._translate_card(to_translate, expected.language), all_of(
            is_(Card),
            is_not(same_instance(to_translate)),  # No shortcut taken, is actually a new instance
            matches_type_annotation(),
            is_dataclass_equal_to(expected),
        )
    )


def generate_test_cases_for_test_get_opposing_face() -> \
        typing.Generator[typing.Tuple[CardIdentificationData, typing.Optional[Card]], None, None]:
    # Single-faced cards
    for case in (TestCaseData("regular_english_card"), TestCaseData("oversized_card")):
        # The back side of a regular card does not exist, Expect None
        yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), None
        # The other side of a non-existing back side of a regular card returns the existing front
        yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card()
    case = TestCaseData("split_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), None
    # FIXME: This returns None, but should return the first face of the front
    # yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(1)

    # Double-faced cards
    for case in (TestCaseData("english_double_faced_card"), TestCaseData("english_double_faced_art_series_card")):
        yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card(2)
        yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(1)


@pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test_get_opposing_face())
def test_get_opposing_face(
        card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: typing.Optional[Card]):
    result = card_db_with_cards.get_opposing_face(card_data)
    if expected is None:
        assert_that(result, is_(none()))
    else:
        assert_that(
            result,
            is_(all_of(
                is_(instance_of(Card)),
                matches_type_annotation(),
                has_properties({
                    # Verifies that the expected card matches the given card identification data.
                    # Not strictly required, but ensures that the test data is consistent
                    "scryfall_id": card_data.scryfall_id,
                    "is_front": not card_data.is_front,  # Negation here
                }),
                is_dataclass_equal_to(expected),
            ))
        )


def test_allow_updating_card_data_on_empty_database_returns_true(card_db: CardDatabase):
    assert_that(card_db.allow_updating_card_data(), is_(True))


def test_allow_updating_card_data_on_freshly_populated_database_returns_false(qtbot, card_db: CardDatabase):
    fill_card_database_with_json_card(qtbot, card_db, "regular_english_card")
    assert_that(card_db.allow_updating_card_data(), is_(False))


@pytest.mark.parametrize("delta_days", [-2, -1, 0, 1, 2])
def test_allow_updating_card_data_on_stale_populated_database_returns_true(
        qtbot, card_db: CardDatabase, delta_days: int):
    fill_card_database_with_json_card(qtbot, card_db, "regular_english_card")
    today = datetime.datetime.today()
    now = today + MINIMUM_REFRESH_DELAY + datetime.timedelta(delta_days)
    fromisoformat = datetime.datetime.fromisoformat
    with unittest.mock.patch("mtg_proxy_printer.model.carddb.datetime.datetime") as mock_date:
        mock_date.today.return_value = now
        mock_date.fromisoformat = fromisoformat
        assert_that(datetime.datetime.today(), is_not(today))
        assert_that(
            card_db.allow_updating_card_data(),
            is_(delta_days >= 0)
        )


def test_is_removed_printing_with_removed_printing_returns_true(qtbot, card_db: CardDatabase):
    fill_card_database_with_json_card(qtbot, card_db, "missing_image_double_faced_card")
    assert_that(
        card_db.is_removed_printing("b120e3c2-21b1-43e3-b685-9cf62bd7aa07"),
        is_(True)
    )


@pytest.mark.parametrize("filter_value", [True, False])
def test_is_removed_printing_with_included_printing_returns_false(qtbot, card_db: CardDatabase, filter_value: bool):
    fill_card_database_with_json_card(qtbot, card_db, "oversized_card", {"hide-oversized-cards": str(filter_value)})
    assert_that(
        card_db.is_removed_printing("650722b4-d72b-4745-a1a5-00a34836282b"),
        is_(filter_value)
    )


@pytest.mark.parametrize("order_printings", [True, False])
@pytest.mark.parametrize("cards_to_import, filter_name, card_data, expected_replacement", [
    (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", CardIdentificationData("en", scryfall_id="b120e3c2-21b1-43e3-b685-9cf62bd7aa07", is_front=True), "d9131fc3-018a-4975-8795-47be3956160d"),
    (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", CardIdentificationData(scryfall_id="b120e3c2-21b1-43e3-b685-9cf62bd7aa07", is_front=True), "d9131fc3-018a-4975-8795-47be3956160d"),
    (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", CardIdentificationData("de", scryfall_id="97b84e7d-258f-46dc-baef-4b1eb6f28d4d", is_front=True), "0600d6c2-0f72-4e79-a55d-1f06dffa48c2"),
    (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", CardIdentificationData(scryfall_id="97b84e7d-258f-46dc-baef-4b1eb6f28d4d", is_front=True), "0600d6c2-0f72-4e79-a55d-1f06dffa48c2"),
])
def test_get_replacement_card_for_unknown_printing(
        qtbot, card_db: CardDatabase, cards_to_import, filter_name: str, card_data: CardIdentificationData,
        expected_replacement: str, order_printings: bool):
    fill_card_database_with_json_cards(qtbot, card_db, cards_to_import, {filter_name: "True"})

    assert_that(
        card_db.get_replacement_card_for_unknown_printing(card_data, order_by_print_count=order_printings),
        all_of(
            not_(empty()),
            contains_exactly(
                has_property("scryfall_id", equal_to(expected_replacement)),
            )
        )
    )


@pytest.mark.parametrize("cards_to_import, filter_name, printing, expected", [
    (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", "b120e3c2-21b1-43e3-b685-9cf62bd7aa07", True),
    (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", "d9131fc3-018a-4975-8795-47be3956160d", False),
    (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", "97b84e7d-258f-46dc-baef-4b1eb6f28d4d", True),
    (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", "0600d6c2-0f72-4e79-a55d-1f06dffa48c2", False),
])
def test_is_removed_printing(
        qtbot, card_db: CardDatabase, cards_to_import, filter_name: str, printing: str, expected: bool):
    fill_card_database_with_json_cards(qtbot, card_db, cards_to_import, {filter_name: "True"})
    assert_that(
        card_db.is_removed_printing(printing),
        is_(expected)
    )


@pytest.mark.timeout(1)
@pytest.mark.parametrize("include_wastes, include_snow_basics, expected_oracle_ids", [
    (False, False, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"]),
    (True, False, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6", "05d24b0c-904a-46b6-b42a-96a4d91a0dd4"]),
    (False, True, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6", "5f0d3be8-e63e-4ade-ae58-6b0c14f2ce6d"]),
    (True, True, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6", "05d24b0c-904a-46b6-b42a-96a4d91a0dd4", "5f0d3be8-e63e-4ade-ae58-6b0c14f2ce6d"]),
])
def test_get_basic_land_oracle_ids(
        qtbot, card_db: CardDatabase,
        include_wastes: bool, include_snow_basics: bool, expected_oracle_ids: StringList):
    fill_card_database_with_json_cards(
        qtbot, card_db, ["english_basic_Forest", "english_basic_Wastes", "english_basic_Snow_Forest"])
    assert_that(
        card_db.get_basic_land_oracle_ids(include_wastes, include_snow_basics),
        contains_inanyorder(*expected_oracle_ids)
    )


@pytest.mark.parametrize("source_id, expected_cards_names", [
    ("2c6e5b25-b721-45ee-894a-697de1310b8c", ["Food"]),  # Bake into a Pie
    ("37e32ba6-108a-421f-9dad-3d03f7ebe239", []),  # Food token
    ("e4b7e3b5-2f3c-4eb7-abc9-322a049a9e1a", []),  # Food Token
    # Both printings of Asmoranomardicadaistinaculdacar
    ("d99a9a7d-d9ca-4c11-80ab-e39d5943a315", ["The Underworld Cookbook", "Food"]),
    ("2879f780-e17f-4e68-931e-6e45f9df28e1", ["The Underworld Cookbook", "Food"]),
    # The Underworld Cookbook
    ("4f24504e-b397-4b98-b8e8-8166457f7a2e", ["Asmoranomardicadaistinaculdacar", "Food"]),
    # Ring
    ("7215460e-8c06-47d0-94e5-d1832d0218af", []),  # The Ring itself
    ("e3bb16a8-b248-4ad5-ba45-1ed499ca1411", ["The Ring"]),  # Elrond
    ("fbc88c94-adf6-4699-a11e-24ebd16aac0c", ["The Ring"]),  # Samwise
    # Venture
    ("6f509dbe-6ec7-4438-ab36-e20be46c9922", []),  # Dungeon of the Mad Mage
    ("d4dbed36-190c-4748-b282-409a2fb5d134", ["Dungeon of the Mad Mage"]),  # Zombie Ogre
    ("b9b1e53f-1384-4860-9944-e68922afc65c", ["Dungeon of the Mad Mage"]),  # Bar the Gate
    # Initiative
    ("2c65185b-6cf0-451d-985e-56aa45d9a57d", []),  # The Undercity
    ("0c4f76ae-e93b-4ca1-ac62-753707f6319e", ["Undercity"]),  # Trailblazer's Torch
    ("0cbf06f5-d1c7-474c-8f09-72f5ad0c8120", ["Undercity"]),  # Explore the Underdark

])
def test_find_related_printings(qtbot, card_db: CardDatabase, source_id: str, expected_cards_names: StringList):
    fill_card_database_with_json_cards(
        qtbot, card_db, [
            "The_Underworld_Cookbook",
            "Food_Token",
            "Asmoranomardicadaistinaculdacar",
            "Bake_into_a_Pie",
            "Asmoranomardicadaistinaculdacar_2",
            "Food_Token_2",
            # The Ring emblem and "The Ring tempts you"
            "The_Ring",
            "Samwise_the_Stouthearted",
            "Elrond_Lord_of_Rivendell",
            # A Dungeon and "Venture into the dungeon"
            "Dungeon_of_the_Mad_Mage",
            "Bar_the_Gate",
            "Zombie_Ogre",
            # The "Undercity" dungeon and "Take the initiative."
            "Undercity",
            "Explore_the_Underdark",
            "Trailblazers_Torch",
        ])
    source_card = card_db.get_card_with_scryfall_id(source_id, True)
    assert_that(source_card, is_(not_none()), "Setup failed")
    related = card_db.find_related_cards(source_card)
    assert_that(
        related, contains_inanyorder(
            *[has_property("name", equal_to(expected)) for expected in expected_cards_names]
        ),
        f"Found cards do not match {expected_cards_names}"
    )


def test_get_all_cards_from_image_cache(qtbot, card_db):
    fill_card_database_with_json_cards(
        qtbot, card_db, ["regular_english_card", "oversized_card"], {"hide-oversized-cards": str(True)})
    cache_content = [
        CacheContent("650722b4-d72b-4745-a1a5-00a34836282b", True, True, Path()),  # Atraxa
        CacheContent("0000579f-7b35-4ed3-b44c-db2a538066fe", True, True, Path()),  # Fury Sliver
        CacheContent("abcdeabc-abcd-abcd-abcd-efghijklmnop", True, True, Path()),  # Non-existing
    ]
    assert_that(
        card_db.get_all_cards_from_image_cache(cache_content),
        contains_exactly(
            contains_exactly(contains_exactly(
                has_property("name", equal_to("Fury Sliver")),
                cache_content[1])),
            contains_exactly(contains_exactly(
                has_property("name", equal_to("Atraxa, Praetors' Voice")),
                cache_content[0])),
            contains_exactly(cache_content[-1]),
        )
    )


@pytest.mark.parametrize("json_name, scryfall_id, expected", [
    ("regular_english_card", "0000579f-7b35-4ed3-b44c-db2a538066fe", False),
    ("english_double_faced_card", "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", True),

])
def test_is_dfc(qtbot, card_db: CardDatabase, json_name: str, scryfall_id: str, expected: bool):
    fill_card_database_with_json_card(qtbot, card_db, json_name)
    assert_that(
        card_db.is_dfc(scryfall_id),
        is_(equal_to(expected))
    )


@pytest.mark.parametrize("card_data, filter_enabled, expected", [
    # Forests. All source languages return all available languages
    (CardIdentificationData(scryfall_id="7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", is_front=True), False, ["de", "en", "es"]),
    (CardIdentificationData(scryfall_id="ffa13d4c-6c5e-44bd-859e-38e79d47a916", is_front=True), False, ["de", "en", "es"]),
    (CardIdentificationData(scryfall_id="cd4cf73d-a408-48f1-9931-54707553c5d5", is_front=True), False, ["de", "en", "es"]),
    # The mis-translated German Coercion cannot be translated, as the English Coercion is not in the imported test data
    (CardIdentificationData(scryfall_id="93054b80-fd1f-4200-8d33-2e826a181db0", is_front=True), False, ["de"]),
    # English/German Duress can be translated
    (CardIdentificationData(scryfall_id="51c6ec30-afb2-41e6-895b-92e070aa86f3", is_front=True), False, ["de", "en"]),
    (CardIdentificationData(scryfall_id="15c8d82e-6e65-4d36-bf09-b24dde016581", is_front=True), False, ["de", "en"]),
    # English Back to Basics only finds English if card filters are active
    (CardIdentificationData(scryfall_id="0600d6c2-0f72-4e79-a55d-1f06dffa48c2", is_front=True), True, ["en"]),
    (CardIdentificationData(scryfall_id="0600d6c2-0f72-4e79-a55d-1f06dffa48c2", is_front=True), False, ["de", "en"]),
    # German, hidden printing of Back to Basics also round-trips the current language
    (CardIdentificationData(scryfall_id="97b84e7d-258f-46dc-baef-4b1eb6f28d4d", is_front=True), True, ["de", "en"]),
])
def test_get_available_languages_for_card(
        qtbot, card_db, card_data: CardIdentificationData, filter_enabled: bool, expected: StringList):
    fill_card_database_with_json_cards(qtbot, card_db, [
        "english_basic_Forest", "german_basic_Forest", "spanish_basic_Forest",
        "german_Coercion_with_faulty_translation", "german_Duress", "english_Duress",
        "english_Back_to_Basics", "german_Back_to_Basics",
    ])
    card = card_db.get_card_with_scryfall_id(card_data.scryfall_id, card_data.is_front)
    assert_that(card, is_(not_none()), "Setup failed, card not found")
    if filter_enabled:
        filters = {key: str(filter_enabled) for key in mtg_proxy_printer.settings.settings["card-filter"]}
        update_database_printing_filters(card_db, filters)
    assert_that(
        card_db.get_available_languages_for_card(card),
        all_of(has_length(len(expected)), contains_exactly(*expected))
    )


def test_get_card_from_data_prefers_highres_images_over_newer_lowres_printings(qtbot, card_db):
    fill_card_database_with_json_cards(
        qtbot, card_db, ["english_basic_Forest_2", "English_basic_Forest_newest_and_low_res"]
    )
    assert_that(
        card_db.get_cards_from_data(CardIdentificationData(name="Forest")),
        contains_exactly(
            has_properties(
                language="en",
                name="Forest",
                set=has_property("name", "Zendikar Rising"),
                scryfall_id="e2ef9b74-481b-424b-8e33-f0b910f66370",
                is_front=True,
                highres_image=True,
            ),
            has_properties(
                language="en",
                name="Forest",
                set=has_property("name", "Doctor Who"),
                scryfall_id="15b3f35e-451e-4de6-a4f7-249287566964",
                is_front=True,
                highres_image=False,
            ),
        )
    )


@pytest.mark.parametrize("jsons, scryfall_id, filter_enabled, expected", [
    # Result set with size > 1. Return sets in release order.
    # Also, these three cards have three different printed names
    (["german_Ironroot_Treefolk_1", "german_Ironroot_Treefolk_2", "german_Ironroot_Treefolk_3"],
     "2520cb2b-47f2-4fb3-a9e7-17ad135562c8", False,
     [MTGSet("3ed", "Revised Edition"), MTGSet("4ed", "Fourth Edition"), MTGSet("5ed", "Fifth Edition")]),
    # De-duplicate results
    (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"],
     "d99a9a7d-d9ca-4c11-80ab-e39d5943a315", False,
     [MTGSet("mh2", "Modern Horizons 2")]),
    # Only offer sets the card is available in the same language as the source
    (["english_Back_to_Basics", "german_Back_to_Basics"],
     "97b84e7d-258f-46dc-baef-4b1eb6f28d4d", False,
     [MTGSet("usg", "Urza's Saga")]),
    # 1/1 colorless Spirit token offers both TNEO and TC16
    (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"],
     "5009729f-6365-42ca-979f-d854a10e463b", False,
     [MTGSet("tc16", "Commander 2016 Tokens"), MTGSet("tneo", "Kamigawa: Neon Dynasty Tokens")]),
    (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"],
     "ca20548f-6324-4858-adbe-87303ff1ca52", False,
     [MTGSet("tc16", "Commander 2016 Tokens"), MTGSet("tneo", "Kamigawa: Neon Dynasty Tokens")]),
    # 4/5 green Spirit token from TNEO only offers TNEO
    (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"],
     "0f48aaab-dd6e-4bcc-a8fb-d31dd4a098ba", False,
     [MTGSet("tneo", "Kamigawa: Neon Dynasty Tokens")]),
    # The first of these has placeholder images, making it affected by a printing filter
    (["german_Duress", "german_Duress_2"],
     "920e8a8f-3cb4-4f33-8a71-f2524cf63aaf", True,  # ID of the second printing from MID
     [MTGSet("mid", "Innistrad: Midnight Hunt")]),
    # Data of hidden printings present in the document must round-trip.
    # Steps to reproduce: Disable a card filter, add a card affected by it, then re-enable it.
    (["german_Duress", "german_Duress_2"],
     "51c6ec30-afb2-41e6-895b-92e070aa86f3", True,  # ID of the first printing from 7th Edition
     [MTGSet("7ed", "Seventh Edition"), MTGSet("mid", "Innistrad: Midnight Hunt")]),
    (["german_Duress"],
     "51c6ec30-afb2-41e6-895b-92e070aa86f3", True,
     [MTGSet("7ed", "Seventh Edition")]),
    
])
def test_get_available_sets_for_card(
        qtbot, card_db,
        jsons: StringList, scryfall_id: UUID, filter_enabled: bool, expected: typing.List[MTGSet]):
    fill_card_database_with_json_cards(qtbot, card_db, jsons)
    card = card_db.get_card_with_scryfall_id(scryfall_id, True)
    if filter_enabled:
        filters = {key: str(filter_enabled) for key in mtg_proxy_printer.settings.settings["card-filter"]}
        update_database_printing_filters(card_db, filters)
    assert_that(card, is_(not_none()), "Test setup failed, card not found")
    fulfills_matcher = all_of(has_length(len(expected)), contains_exactly(*expected)) if expected else empty()
    assert_that(card_db.get_available_sets_for_card(card), fulfills_matcher)


@pytest.mark.parametrize("jsons, scryfall_id, filter_enabled, expected", [
    # Actual two variants in the same set (regular & extended art)
    (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"],
     "d99a9a7d-d9ca-4c11-80ab-e39d5943a315", False, ["186", "463"]),
    # With enabled filters, the extended art variant is unavailable, thus should not be suggested
    (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"],
     "d99a9a7d-d9ca-4c11-80ab-e39d5943a315", True, ["186"]),
    # The German, regular card should not find the collector number of the English extended art variant.
    (["Asmoranomardicadaistinaculdacar_German", "Asmoranomardicadaistinaculdacar_2"],
     "e710a21a-65eb-4106-a379-57a86fb9e6c6", False, ["186"]),
    # The 1/1 Spirit token in TNEO has number 2
    (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"],
     "ca20548f-6324-4858-adbe-87303ff1ca52", False, ["2"]),
    # 4/5 green Spirit token in TNEO  has number 11
    (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"],
     "0f48aaab-dd6e-4bcc-a8fb-d31dd4a098ba", False, ["11"]),
    # Data of hidden printings present in the document must round-trip.
    # Steps to reproduce: Disable a card filter, add a card affected by it, then re-enable it.
    (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"],
     "2879f780-e17f-4e68-931e-6e45f9df28e1", True, ["186", "463"]),
    (["german_Duress", "german_Duress_2"],
     "51c6ec30-afb2-41e6-895b-92e070aa86f3", True,
     ["131"]),
    (["german_Duress"],
     "51c6ec30-afb2-41e6-895b-92e070aa86f3", True,
     ["131"]),
])
def test_get_available_collector_numbers_for_card_in_set(
        qtbot, card_db,
        jsons: StringList, scryfall_id: UUID, filter_enabled: bool, expected: StringList):
    fill_card_database_with_json_cards(qtbot, card_db, jsons)
    card = card_db.get_card_with_scryfall_id(scryfall_id, True)
    assert_that(card, is_(not_none()), "Setup failed. Card not found")
    if filter_enabled:
        filters = {key: str(filter_enabled) for key in mtg_proxy_printer.settings.settings["card-filter"]}
        update_database_printing_filters(card_db, filters)

    fulfills_matcher = all_of(has_length(len(expected)), contains_exactly(*expected)) if expected else empty()
    assert_that(card_db.get_available_collector_numbers_for_card_in_set(card), fulfills_matcher)
Added tests/model/test_document.py.




































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import copy
import typing
import unittest.mock

from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap
from hamcrest import *

from hamcrest import contains_exactly
import pytest
from pytestqt.qtbot import QtBot

from mtg_proxy_printer.units_and_sizes import PageType, unit_registry, UnitT, CardSizes, CardSize
from mtg_proxy_printer.model.card import MTGSet, Card
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_page import PageColumns
from mtg_proxy_printer.model.page_layout import PageLayoutSettings

from mtg_proxy_printer.document_controller import DocumentAction
from mtg_proxy_printer.document_controller.new_document import ActionNewDocument
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.document_controller.edit_document_settings import ActionEditDocumentSettings

from tests.document_controller.helpers import append_new_card_in_page
from ..document_controller.helpers import insert_card_in_page, create_card

ItemDataRole = Qt.ItemDataRole
mm: UnitT = unit_registry.mm
REGULAR = CardSizes.REGULAR
OVERSIZED = CardSizes.OVERSIZED


class DummyAction(DocumentAction):
    """A dummy DocumentAction that does nothing. apply() and undo() are replaced with MagicMock instances."""
    apply: unittest.mock.MagicMock
    undo: unittest.mock.MagicMock
    COMPARISON_ATTRIBUTES = ["value"]

    def __init__(self, value: int = 0):
        super().__init__()
        self.value = value
        self.apply = unittest.mock.MagicMock(return_value=self)
        self.undo = unittest.mock.MagicMock(return_value=self)

    @property
    def as_str(self):
        return f"Value: {self.value}"


@pytest.mark.parametrize("first, second, matcher", [
    (1, 1, is_),
    (0, 1, is_not),
])
def test_dummy_action_eq(first: int, second: int, matcher):
    a_1 = DummyAction(first)
    a_2 = DummyAction(second)
    assert_that(a_1, is_(equal_to(a_1)))
    assert_that(a_2, is_(equal_to(a_2)))
    assert_that(a_1, matcher(equal_to(a_2)))


def assert_unused(action: DummyAction):
    action.apply.assert_not_called()
    action.undo.assert_not_called()


def assert_applied(action: DummyAction, document: Document):
    action.apply.assert_called_once_with(document)
    action.undo.assert_not_called()


def assert_undone(action: DummyAction, document: Document):
    action.apply.assert_not_called()
    action.undo.assert_called_once_with(document)


def test_apply_on_empty_undo_stack_empty_redo_stack(qtbot: QtBot, document_light: Document):
    action = DummyAction()
    with qtbot.wait_signals([document_light.undo_available_changed, document_light.action_applied], timeout=1000), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.apply(action)

    assert_that(document_light.redo_stack, is_(empty()))
    assert_that(document_light.undo_stack, contains_exactly(action))

    assert_applied(action, document_light)


def test_apply_on_empty_undo_stack_filled_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(redo_dummy := DummyAction(1))
    action = DummyAction(0)
    with qtbot.wait_signals([
                document_light.undo_available_changed,
                document_light.redo_available_changed,
                document_light.action_applied], timeout=1000), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.apply(action)

    assert_that(document_light.redo_stack, is_(empty()))
    assert_that(document_light.undo_stack, contains_exactly(action))

    assert_unused(redo_dummy)
    assert_applied(action, document_light)


def test_apply_on_filled_undo_stack_empty_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.undo_stack.append(previous_action := DummyAction())
    action = DummyAction()
    with qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone), \
            qtbot.wait_signal(document_light.action_applied, timeout=1000):
        document_light.apply(action)

    assert_that(document_light.redo_stack, is_(empty()))
    assert_that(document_light.undo_stack, contains_exactly(previous_action, action))

    assert_unused(previous_action)
    assert_applied(action, document_light)


def test_apply_on_filled_undo_stack_filled_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.undo_stack.append(previous_action := DummyAction())
    document_light.redo_stack.append(redo_dummy := DummyAction(1))
    action = DummyAction(0)
    expected_signals = [document_light.redo_available_changed, document_light.action_applied]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.apply(action)

    assert_that(document_light.redo_stack, is_(empty()))
    assert_that(document_light.undo_stack, contains_exactly(previous_action, action))

    assert_unused(redo_dummy)
    assert_unused(previous_action)
    assert_applied(action, document_light)


def test_apply_same_action_as_on_redo_stack_does_keep_remaining_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(should_stay := DummyAction(2))
    document_light.redo_stack.append(DummyAction(1))
    action = DummyAction(1)
    expected_signals = [document_light.undo_available_changed, document_light.action_applied]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.apply(action)
    assert_that(document_light.redo_stack, contains_exactly(should_stay))
    assert_that(document_light.undo_stack, contains_exactly(action))


def test_undo_on_empty_redo_stack_2_elements_on_undo_stack(qtbot: QtBot, document_light: Document):
    document_light.undo_stack.append(first := DummyAction())
    document_light.undo_stack.append(second := DummyAction())
    expected_signals = [document_light.redo_available_changed, document_light.action_undone,]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_applied):
        document_light.undo()

    assert_that(document_light.undo_stack, contains_exactly(first))
    assert_that(document_light.redo_stack, contains_exactly(second))

    assert_unused(first)
    assert_undone(second, document_light)


def test_undo_on_empty_redo_stack_1_element_on_undo_stack(qtbot: QtBot, document_light: Document):
    document_light.undo_stack.append(first := DummyAction())
    expected_signals = [
        document_light.redo_available_changed, document_light.undo_available_changed, document_light.action_undone,
    ]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.action_applied):
        document_light.undo()

    assert_that(document_light.undo_stack, is_(empty()))
    assert_that(document_light.redo_stack, contains_exactly(first))

    assert_undone(first, document_light)


def test_undo_on_filled_redo_stack_1_element_on_undo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(redo_dummy := DummyAction())
    document_light.undo_stack.append(first := DummyAction())
    expected_signals = [document_light.undo_available_changed, document_light.action_undone,]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_applied):
        document_light.undo()

    assert_that(document_light.undo_stack, is_(empty()))
    assert_that(document_light.redo_stack, contains_exactly(redo_dummy, first))

    assert_unused(redo_dummy)
    assert_undone(first, document_light)


def test_undo_on_filled_redo_stack_2_elements_on_undo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(redo_dummy := DummyAction())
    document_light.undo_stack.append(first := DummyAction())
    document_light.undo_stack.append(second := DummyAction())

    with qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_applied), \
            qtbot.wait_signal(document_light.action_undone, timeout=1000):
        document_light.undo()

    assert_that(document_light.undo_stack, contains_exactly(first))
    assert_that(document_light.redo_stack, contains_exactly(redo_dummy, second))

    assert_unused(redo_dummy)
    assert_unused(first)
    assert_undone(second, document_light)


def test_redo_on_empty_undo_stack_1_element_on_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(first := DummyAction())
    expected_signals = [
        document_light.undo_available_changed, document_light.redo_available_changed, document_light.action_applied
    ]
    with qtbot.wait_signals(
            expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.redo()

    assert_that(document_light.redo_stack, is_(empty()))
    assert_that(document_light.undo_stack, contains_exactly(first))

    assert_applied(first, document_light)


def test_redo_on_empty_undo_stack_2_elements_on_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(first := DummyAction())
    document_light.redo_stack.append(second := DummyAction())

    expected_signals = [document_light.undo_available_changed, document_light.action_applied]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.redo()

    assert_that(document_light.redo_stack, contains_exactly(first))
    assert_that(document_light.undo_stack, contains_exactly(second))

    assert_unused(first)
    assert_applied(second, document_light)


def test_redo_on_filled_undo_stack_1_element_on_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(first := DummyAction())
    document_light.undo_stack.append(undo_dummy := DummyAction())
    expected_signals = [document_light.redo_available_changed, document_light.action_applied]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.redo()

    assert_that(document_light.undo_stack, contains_exactly(undo_dummy, first))
    assert_that(document_light.redo_stack, is_(empty()))

    assert_unused(undo_dummy)
    assert_applied(first, document_light)


def test_redo_on_filled_undo_stack_2_elements_on_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(first := DummyAction())
    document_light.redo_stack.append(second := DummyAction())
    document_light.undo_stack.append(undo_dummy := DummyAction())

    with qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone), \
            qtbot.wait_signal(document_light.action_applied, timeout=1000):
        document_light.redo()

    assert_that(document_light.undo_stack, contains_exactly(undo_dummy, second))
    assert_that(document_light.redo_stack, contains_exactly(first))

    assert_unused(undo_dummy)
    assert_unused(first)
    assert_applied(second, document_light)


@pytest.mark.parametrize("additional_pages", range(3))
def test_rowCount_without_index_parameter_return_page_count(document_light, additional_pages: int):
    if additional_pages:
        document_light.apply(ActionNewPage(count=additional_pages))
    assert_that(document_light.pages, has_length(1+additional_pages), "Test setup failed")
    assert_that(document_light.rowCount(), is_(1+additional_pages), "Wrong rowCount() returned")


def test_rowCount_with_valid_index_returns_card_count_on_page_given_by_index(document_light):
    document_light.apply(ActionNewPage(count=3))
    for count in range(1, 4):
        document_light.set_currently_edited_page(document_light.pages[count])
        card = Card("", MTGSet("", ""), "", "", "", True, "", "", True, REGULAR, 0, None)
        document_light.apply(ActionAddCard(card, count=count))
        assert_that(document_light.currently_edited_page, has_length(count), "Test setup failed")
    for page in range(4):
        assert_that(
            document_light.rowCount(document_light.index(page, 0)),
            is_(equal_to(page)),
            f"Wrong rowCount() returned for page {page}"
        )


@pytest.mark.parametrize("page_type, parent_row, child_rows", [
    (PageType.REGULAR, 0, [0]),
    (PageType.OVERSIZED, 2, [0, 1]),
])
def test_get_card_indices_of_type(document_light, page_type: PageType, parent_row: int, child_rows: typing.List[int]):
    ActionNewPage(count=2).apply(document_light)
    append_new_card_in_page(document_light.pages[0], "Normal", REGULAR)
    append_new_card_in_page(document_light.pages[2], "Oversized", OVERSIZED)
    append_new_card_in_page(document_light.pages[2], "Oversized", OVERSIZED)
    indices = list(document_light.get_card_indices_of_type(page_type))
    assert_that(indices, has_length(len(child_rows)))
    for index, expected_row in zip(indices, child_rows):
        assert_that(index.row(), is_(expected_row))
        assert_that(index.parent().row(), is_(parent_row))
        card: Card = index.data(ItemDataRole.UserRole)
        assert_that(card.requested_page_type(), is_(page_type))


@pytest.fixture
def document_custom_layout(document: Document) -> Document:
    custom_layout = PageLayoutSettings(
        page_height=300*mm, page_width=200*mm,
        margin_top=20*mm, margin_bottom=19*mm, margin_left=18*mm, margin_right=17*mm,
        row_spacing=3*mm, column_spacing=2*mm, card_bleed=1*mm,
        draw_cut_markers=True, draw_sharp_corners=False,
    )
    document.apply(ActionEditDocumentSettings(custom_layout))
    yield document
    document.__dict__.clear()


def test_document_reset_clears_modified_page_layout(qtbot: QtBot, page_layout: PageLayoutSettings, document_custom_layout: Document):
    assert_that(
        document_custom_layout,
        has_property("page_layout", not_(equal_to(page_layout)))
    )
    assert_that(
        document_custom_layout.page_layout.compute_page_row_count(),
        is_not(equal_to(page_layout.compute_page_card_capacity())),
        "Test setup failed."
    )
    with qtbot.waitSignal(document_custom_layout.page_layout_changed, timeout=1000):
        document_custom_layout.apply(ActionNewDocument())

    assert_that(
        document_custom_layout,
        has_property("page_layout", equal_to(page_layout))
    )


def test_document_is_created_empty(document_light: Document):
    capacity = document_light.page_layout.compute_page_card_capacity()
    assert_that(capacity, is_(greater_than_or_equal_to(1)))
    assert_that(document_light.rowCount(), is_(equal_to(1)), "Expected creation of a single, empty page.")
    assert_that(
        document_light.pages,
        contains_exactly(empty()),
        "Expected creation of a single, empty page."
    )
    assert_that(
        document_light.rowCount(document_light.index(0, 0)), is_(equal_to(0)),
        "Expected empty page, but it is not empty")
    assert_that(
        document_light.pages[0].page_type(), is_(PageType.UNDETERMINED),
        "Empty page should have an undetermined page type"
    )




@pytest.mark.parametrize("size", [REGULAR, OVERSIZED])
def test_get_missing_image_cards(document_light: Document, size: CardSize):
    blank_image = document_light.image_db.get_blank(size)
    expected = create_card("Placeholder Image", size, "https://someurl", blank_image)
    # Create a new, distinct image by copying the blank image
    unexpected = create_card("Other Image", size, "", QPixmap(blank_image))
    document_light.apply(ActionAddCard(expected, 2))
    document_light.apply(ActionAddCard(unexpected, 2))
    assert_that(
        result := list(document_light.get_missing_image_cards()),
        has_length(2)
    )
    cards = [i.data(ItemDataRole.UserRole) for i in result]
    assert_that(cards, only_contains(expected))

@pytest.mark.parametrize("size", [REGULAR, OVERSIZED])
@pytest.mark.parametrize("result", [True, False])
def test_has_missing_images(document_light: Document, result: bool, size: CardSize):
    blank_image = document_light.image_db.get_blank(CardSizes.REGULAR)
    blank_image_card = create_card("Placeholder Image", size, "https://someurl", blank_image)
    # Create a new, distinct image by copying the blank image
    other_card = create_card("Other Image", size, "", QPixmap(blank_image))
    if result:
        document_light.apply(ActionAddCard(blank_image_card, 2))
    document_light.apply(ActionAddCard(other_card, 2))
    assert_that(
        document_light.has_missing_images(),
        is_(result)
    )


@pytest.mark.parametrize("pages_content, expected", [
    ([], 0),
    ([None, None], 1),
    ([create_card("Regular", REGULAR)], 0),
    ([create_card("Regular", REGULAR)]*2, 1),
    ([create_card("Regular", REGULAR), create_card("Oversized", OVERSIZED)], 0),
    ([create_card("Regular", REGULAR), create_card("Oversized", OVERSIZED)]*2, 2),
    ([create_card("Regular", REGULAR), create_card("Oversized", OVERSIZED), None]*2, 4),
])
def test_compute_pages_saved_by_compacting(
        document_light: Document, pages_content: typing.List[typing.Optional[Card]], expected: int):
    if len(pages_content) > 1:
        document_light.apply(ActionNewPage(count=len(pages_content)-1))
    for page, card in zip(document_light.pages, pages_content):
        if card is not None:
            insert_card_in_page(page, card)
    assert_that(
        document_light.compute_pages_saved_by_compacting(),
        is_(equal_to(expected))
    )


def test_update_page_layout_copies_the_passed_in_instance(document_light: Document):
    layout = copy.copy(document_light.page_layout)
    layout.row_spacing = 1*mm
    document_light.apply(ActionEditDocumentSettings(layout))
    layout.row_spacing = 2*mm
    assert_that(document_light.page_layout, has_property("row_spacing", equal_to(1*mm)))


@pytest.mark.parametrize("invalid_page_row", [2])
def test_document__data_page_logs_error_on_invalid_index(document_light, invalid_page_row: int):
    index = document_light.createIndex(invalid_page_row, 0, None)
    with unittest.mock.patch("mtg_proxy_printer.model.document.logger.error") as logger_mock:
        assert_that(document_light._data_page(index), is_(None))
        logger_mock.assert_called_once()


@pytest.mark.parametrize("invalid_card_row", [2])
def test_document__data_card_logs_error_on_invalid_index_row(document_light, invalid_card_row: int):
    append_new_card_in_page(document_light.pages[0], "Card")
    index = document_light.createIndex(invalid_card_row, 0, document_light.pages[0][0])
    with unittest.mock.patch("mtg_proxy_printer.model.document.logger.error") as logger_mock:
        assert_that(document_light._data_card(index), is_(None))
        logger_mock.assert_called_once()


@pytest.mark.parametrize("invalid_card_column", [len(PageColumns)])
def test_document__data_card_logs_error_on_invalid_index_column(document_light, invalid_card_column: int):
    append_new_card_in_page(document_light.pages[0], "Card")
    index = document_light.createIndex(0, invalid_card_column, document_light.pages[0][0])
    with unittest.mock.patch("mtg_proxy_printer.model.document.logger.error") as logger_mock:
        assert_that(document_light._data_card(index), is_(None))
        logger_mock.assert_called_once()
Added tests/model/test_document_loader.py.
























































































































































































































































































































































































































































































































































































































































































































































































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.

import contextlib
from itertools import chain, repeat, product
from pathlib import Path
import sqlite3
import unittest.mock
import textwrap


import pint
from pytestqt.qtbot import QtBot
import pytest
from hamcrest import *

import mtg_proxy_printer.model.document_loader
from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument
from tests.helpers import quantity_close_to
from mtg_proxy_printer.units_and_sizes import PageType, unit_registry, UnitT, CardSizes, QuantityT
from mtg_proxy_printer.model.card import CheckCard
import mtg_proxy_printer.sqlite_helpers
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.document_page import PageColumns
from mtg_proxy_printer.model.page_layout import PageLayoutSettings

from tests.helpers import create_save_database_with

CardType = mtg_proxy_printer.model.document_loader.CardType
mm: UnitT = unit_registry.mm

@pytest.fixture()
def page_layout() -> PageLayoutSettings:
    page_layout = mtg_proxy_printer.model.document_loader.PageLayoutSettings(
        page_height=300*mm, page_width=200*mm,
        margin_top=20*mm, margin_bottom=19*mm, margin_left=18*mm, margin_right=17*mm,
        row_spacing=3*mm, column_spacing=2*mm, card_bleed=1*mm,
        draw_cut_markers=True, draw_sharp_corners=False, draw_page_numbers=True
    )
    assert_that(
        page_layout.compute_page_card_capacity(PageType.OVERSIZED), is_(greater_than_or_equal_to(1)), "Setup failed"
    )
    return page_layout


@pytest.mark.parametrize("user_version", [-1, 0, 1, 8, 9])
def test_unknown_save_version_raises_exception(empty_save_database: sqlite3.Connection, user_version: int):
    empty_save_database.execute(f"PRAGMA user_version = {user_version};")
    assert_that(empty_save_database.execute("PRAGMA user_version").fetchone()[0], is_(user_version))
    worker = mtg_proxy_printer.model.document_loader.Worker
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock:
        mock.return_value = empty_save_database
        assert_that(
            calling(worker._open_validate_and_migrate_save_file).with_args(Path()),
            raises(AssertionError)
        )
        mock.assert_called_once()


def assert_document_is_empty(document: Document):
    assert_that(document.rowCount(), is_(equal_to(1)))
    page_index = document.index(0, 0)
    assert_that(page_index.isValid())
    assert_that(document.rowCount(page_index), is_(0))


@contextlib.contextmanager
def disabled_check_constraints(db: sqlite3.Connection):
    """
    Instruct SQLite3 to ignore the SQL CHECK constraints defined in the database schema for a limited timeframe.
    """
    db.execute("PRAGMA ignore_check_constraints = TRUE;")
    yield db
    db.execute("PRAGMA ignore_check_constraints = FALSE;")


def test_document_with_card_loads_correctly(
        qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection, page_layout: PageLayoutSettings):
    create_save_database_with(
        empty_save_database,
        [(1, CardSizes.REGULAR)],
        [(1, 1, True, "0000579f-7b35-4ed3-b44c-db2a538066fe", CardType.REGULAR)],
        page_layout
    )

    loader = document.loader
    save_path = Path("/tmp/invalid.mtgproxies")
    with unittest.mock.patch(
            "mtg_proxy_printer.model.document_loader.open_database",
            return_value=empty_save_database) as open_database, \
            qtbot.waitSignals(
                [loader.loading_state_changed]*2, check_params_cbs=[(lambda value: value), (lambda value: not value)]),\
            qtbot.waitSignals([loader.finished, loader.load_requested, document.page_layout_changed]):
        loader.load_document(save_path)
    open_database.assert_called_once()
    assert_that(document.rowCount(), is_(equal_to(1)))
    page_index = document.index(0, 0)
    assert_that(page_index.isValid())
    assert_that(document.rowCount(page_index), is_(1))
    assert_that(
        document.index(0, PageColumns.CardName, page_index).data(),
        is_("Fury Sliver")
    )
    assert_that(document.save_file_path, is_(equal_to(save_path)))
    assert_that(document.page_layout, is_(equal_to(page_layout)))


def test_empty_document_loads_correctly(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection, page_layout: PageLayoutSettings):
    create_save_database_with(empty_save_database, [], [], page_layout)
    loader = document.loader
    save_path = Path("/tmp/invalid.mtgproxies")
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignals([loader.loading_state_changed]*2,
                               check_params_cbs=[(lambda value: value), (lambda value: not value)]), \
                qtbot.waitSignals([loader.load_requested, document.page_layout_changed]), \
                qtbot.assert_not_emitted(loader.loading_file_failed):
            loader.load_document(save_path)
    mock.assert_called_once()
    assert_that(document.rowCount(), is_(equal_to(1)))
    page_index = document.index(0, 0)
    assert_that(page_index.isValid())
    assert_that(document.rowCount(page_index), is_(0))
    assert_that(document.save_file_path, is_(equal_to(save_path)))
    assert_that(document.page_layout, is_(equal_to(page_layout)))


def test_document_with_mixed_pages_distributes_cards_based_on_size(
        qtbot: QtBot, document: Document, page_layout: PageLayoutSettings,
        empty_save_database: sqlite3.Connection):
    create_save_database_with(
        empty_save_database,
        [(1, CardSizes.REGULAR)],
        [
            (1, 1, True, "0000579f-7b35-4ed3-b44c-db2a538066fe", CardType.REGULAR),
            (1, 2, True, "650722b4-d72b-4745-a1a5-00a34836282b", CardType.REGULAR),
        ],
        page_layout
    )
    loader = document.loader
    save_path = Path("/tmp/invalid.mtgproxies")
    with unittest.mock.patch(
            "mtg_proxy_printer.model.document_loader.open_database",
            return_value=empty_save_database) as open_database, \
        qtbot.waitSignals(
            [loader.loading_state_changed] * 2,
            check_params_cbs=[(lambda value: value), (lambda value: not value)]), \
        qtbot.waitSignals([loader.load_requested]):
            loader.load_document(save_path)
    open_database.assert_called_once()
    assert_that(document.rowCount(), is_(2))
    total_cards = 0
    for page in document.pages:
        assert_that(page.page_type(), is_in([PageType.OVERSIZED, PageType.REGULAR]))
        total_cards += len(page)
    assert_that(total_cards, is_(2))


@pytest.mark.parametrize("data", chain(
    # Syntactically invalid
    zip([-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR)),
    zip(repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR)),
    zip(repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR)),
    zip(repeat(1), repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(CardType.REGULAR)),
    zip([-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD)),
    zip(repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD)),
    zip(repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD)),
    zip(repeat(1), repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(CardType.CHECK_CARD)),
    # Semantically invalid, as type "d" it means generating a DFC check card for a single sided card.
    zip(repeat(1), repeat(1), repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), [-1, 1.3, -1000.2, "", b"binary", CardType.CHECK_CARD]),
    zip(repeat(1), repeat(1), repeat(0), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), [-1, 1.3, -1000.2, "", b"binary", CardType.CHECK_CARD]),
))
def test_invalid_data_in_card_columns_raises_exception(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection, data):
    # Replace the Card table with one that has no implicit type casting
    empty_save_database.execute("DROP TABLE Card")
    empty_save_database.execute("CREATE TABLE Card (page BLOB, slot BLOB, is_front BLOB, scryfall_id BLOB, type BLOB)")
    empty_save_database.execute('INSERT INTO Card (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)', data)
    assert_that(
        empty_save_database.execute("SELECT page, slot, is_front, scryfall_id, type FROM Card").fetchall(),
        contains_exactly(equal_to(data)),
        "Setup failed: Data mismatch"
    )
    loader = document.loader
    with unittest.mock.patch(
            "mtg_proxy_printer.model.document_loader.open_database",
            return_value=empty_save_database) as open_database, \
        qtbot.waitSignal(loader.loading_file_failed, raising=True), \
        qtbot.assertNotEmitted(loader.load_requested):
            loader.load_document(Path("/tmp/invalid.mtgproxies"))
    open_database.assert_called_once()
    assert_document_is_empty(document)
    assert_that(document.save_file_path, is_(none()))


def test_protects_against_infinite_save_data(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection):
    empty_save_database.execute("DROP TABLE Card")
    # LIMIT clause in the definition below is a safety measure.
    empty_save_database.execute(textwrap.dedent("""\
        CREATE VIEW Card (page, slot, scryfall_id, is_front) AS 
            WITH RECURSIVE card_gen (page, slot, scryfall_id, is_front) AS (
                SELECT 1, 1, '0000579f-7b35-4ed3-b44c-db2a538066fe', 1
                UNION ALL 
                SELECT 1, 1, '0000579f-7b35-4ed3-b44c-db2a538066fe', 1
                FROM card_gen
                LIMIT 100000
            )
        SELECT * FROM card_gen
        """))
    loader = document.loader
    with unittest.mock.patch(
            "mtg_proxy_printer.model.document_loader.open_database",
            return_value=empty_save_database) as open_database, \
        qtbot.waitSignal(loader.loading_file_failed, raising=True), \
        qtbot.assertNotEmitted(loader.load_requested):
            loader.load_document(Path("/tmp/invalid.mtgproxies"))
    open_database.assert_called_once()
    assert_document_is_empty(document)
    assert_that(document.save_file_path, is_(none()))


def generate_test_cases_for_test_protects_against_infinite_settings_data():
    # LIMIT clause in the definitions below are safety measures.
    yield 4, textwrap.dedent("""\
        CREATE VIEW DocumentSettings (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers) AS 
        WITH RECURSIVE settings_gen (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers
        ) AS (
            SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1
            UNION ALL 
            SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1
            FROM settings_gen
            LIMIT 100000
            )
        SELECT * FROM settings_gen
        """)
    yield 5, textwrap.dedent("""\
        CREATE VIEW DocumentSettings (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners) AS 
        WITH RECURSIVE settings_gen (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners
        ) AS (
            SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1
            UNION ALL 
            SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1
            FROM settings_gen
            LIMIT 100000
            )
        SELECT * FROM settings_gen
        """)
    yield 6, textwrap.dedent("""\
        CREATE VIEW DocumentSettings (key, value) AS 
        WITH RECURSIVE settings_gen (
          key, value
        ) AS (
            SELECT 'key', 'something'
            UNION ALL 
            SELECT 'key', 'something'
            FROM settings_gen
            LIMIT 100000
            )
        SELECT * FROM settings_gen
        """)


@pytest.mark.parametrize("user_version, script", generate_test_cases_for_test_protects_against_infinite_settings_data())
def test_protects_against_infinite_settings_data(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection, user_version: int, script: str):
    empty_save_database.execute(f"PRAGMA user_version = {user_version}")
    empty_save_database.execute("DROP TABLE DocumentSettings")
    empty_save_database.execute(script)
    loader = document.loader
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignal(loader.loading_file_failed, raising=True), \
                qtbot.assertNotEmitted(loader.load_requested):
            loader.load_document(Path("/tmp/invalid.mtgproxies"))
        mock.assert_called_once()
    assert_document_is_empty(document)
    assert_that(document.save_file_path, is_(none()))


def test_cancelling_loading_does_not_crash(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection):
    create_save_database_with(
        empty_save_database,
        [(1, CardSizes.REGULAR)],
        [
            (1, 1, True, "0000579f-7b35-4ed3-b44c-db2a538066fe", CardType.REGULAR),
            (1, 2, True, "650722b4-d72b-4745-a1a5-00a34836282b", CardType.REGULAR),
        ],
        document.page_layout
    )
    loader = document.loader
    loader.begin_loading_loop.connect(loader.cancel)
    with unittest.mock.patch(
            "mtg_proxy_printer.model.document_loader.open_database",
            return_value=empty_save_database) as open_database, \
        qtbot.wait_signals([
            loader.begin_loading_loop, loader.progress_loading_loop,
            loader.loading_state_changed, loader.finished], timeout=100):
        loader.load_document(Path("/tmp/invalid.mtgproxies"))
    open_database.assert_called_once()


def test_loads_check_card(
        qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection):
    create_save_database_with(
        empty_save_database,
        [(1, CardSizes.REGULAR)],
        [(1, 1, True, "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", CardType.CHECK_CARD)],
        document.page_layout
    )
    loader = document.loader
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as open_database:
        open_database.return_value = empty_save_database
        with qtbot.wait_signal(document.action_applied), \
                qtbot.assert_not_emitted(loader.loading_file_failed):
            loader.load_document(Path("/tmp/invalid.mtgproxies"))
    assert_that(
        document.pages, contains_exactly(
            contains_exactly(has_property("card", all_of(
                instance_of(CheckCard),
                has_properties({
                    "image_file": not_none(),
                    "name": "Growing Rites of Itlimoc // Itlimoc, Cradle of the Sun",
                    "is_front": True,
                    "is_dfc": False,
                })
            )))
        )
    )


@pytest.fixture(params=[
    (4, [1, 200, 150, 4, 5, 6, 7, 2, 3, 1]),
    (5, [1, 200, 150, 4, 5, 6, 7, 2, 3, 1, 0]),
    # Only old image spacing keys present
    (6, [("document_name", ""), ("draw_cut_markers", 1), ("draw_page_numbers", 0), ("draw_sharp_corners", 0),
         ("image_spacing_horizontal", 2), ("image_spacing_vertical", 3), ("margin_top", 4), ("margin_bottom", 5),
         ("margin_left", 6), ("margin_right", 7), ("page_height", 200), ("page_width", 150)]),
    # Old and new image spacing keys present. This should never exist in the wild. Ensure that the new keys are used.
    (6, [("document_name", ""), ("draw_cut_markers", 1), ("draw_page_numbers", 0), ("draw_sharp_corners", 0),
         ("image_spacing_horizontal", 8), ("image_spacing_vertical", 9), ("margin_top", 4), ("margin_bottom", 5),
         ("margin_left", 6), ("margin_right", 7), ("page_height", 200), ("page_width", 150),
         ("row_spacing", 2), ("column_spacing", 3)]),
    ])
def legacy_save_file(request, tmp_path: Path):
    save = tmp_path/"save.mtxproxies"
    save_version, settings = request.param  # type: int, list
    db = mtg_proxy_printer.sqlite_helpers.open_database(save, f"document-v{save_version}", False)
    db.execute("BEGIN IMMEDIATE TRANSACTION")
    if save_version < 6:
        db.execute(f"INSERT INTO DocumentSettings VALUES ({', '.join('?'*len(settings))})", settings)
    elif save_version == 6:
        db.executemany("INSERT INTO DocumentSettings (key, value) VALUES (?, ?)", settings)
    else:
        pass
    db.commit()
    db.close()
    return save


def test_load_settings_from_legacy_save_file_is_successful(
        qtbot: QtBot, legacy_save_file: Path, document: Document):
    loader = document.loader
    with qtbot.wait_signal(document.action_applied), \
            qtbot.assert_not_emitted(loader.loading_file_failed):
        loader.load_document(legacy_save_file)
    annotations = document.page_layout.__annotations__
    assert_that(
        document.page_layout,
        has_properties({
            item: instance_of(pint.Quantity if value is QuantityT else value)
            for item, value in annotations.items()
        })
    )
    assert_that(document.page_layout, has_properties({
        "document_name": "", "draw_cut_markers": True, "draw_page_numbers": False, "draw_sharp_corners": False,
        "row_spacing": quantity_close_to(2*mm), "column_spacing": quantity_close_to(3*mm),
        "margin_top": quantity_close_to(4*mm), "margin_bottom": quantity_close_to(5*mm),
        "margin_left": quantity_close_to(6*mm), "margin_right": quantity_close_to(7*mm),
        "page_height": quantity_close_to(200*mm), "page_width": quantity_close_to(150*mm)
    }))


@pytest.mark.parametrize("title", ["str", "", "1", "0x1", "1.0.0", "1..0", "01", "1.0"])
def test_load_correctly_sets_document_title(
        qtbot: QtBot, page_layout: PageLayoutSettings,
        empty_save_database: sqlite3.Connection, document: Document, title: str):
    loader = document.loader
    page_layout.document_name = title
    create_save_database_with(empty_save_database, [], [], page_layout)
    with unittest.mock.patch(
            "mtg_proxy_printer.model.document_loader.open_database",
            return_value=empty_save_database), \
            qtbot.wait_signal(document.action_applied, timeout=1000), \
            qtbot.assert_not_emitted(loader.loading_file_failed):
        loader.load_document(Path("/tmp/invalid.mtgproxies"))
    assert_that(document.page_layout, has_property("document_name", equal_to(title)))
Added tests/model/test_image_db.py.












































































































































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import io

import pytest
from PyQt5.QtCore import QBuffer, QIODevice
from PyQt5.QtGui import QPixmap
from hamcrest import *
from pytestqt.qtbot import QtBot

from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.imagedb_files import ImageKey

from tests.hasgetter import has_getter


def qpixmap_to_bytes_io(pixmap: QPixmap) -> io.BytesIO:
    buffer = QBuffer()
    buffer.open(QIODevice.OpenModeFlag.WriteOnly)
    pixmap.save(buffer, "PNG", quality=100)
    image = buffer.data().data()
    return io.BytesIO(image)


DOWNLOADER = "mtg_proxy_printer.model.imagedb.ImageDownloader"


def test_delete_disk_cache_entries_removes_empty_parent_directories(qtbot: QtBot, image_db: ImageDatabase):
    # Setup
    keys = [
        ImageKey("7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", True, True),
        ImageKey("7ef83f4c-abcd-abcd-9876-1234567890ab", True, True),  # Same prefix
    ]
    blank_image_file = qpixmap_to_bytes_io(image_db.get_blank())
    for key in keys:
        path = image_db.db_path / key.format_relative_path()
        path.parent.mkdir(exist_ok=True, parents=True)
        path.write_bytes(blank_image_file.read())
    image_db.images_on_disk.update(keys)

    # Test
    image_db.delete_disk_cache_entries([keys[0]])
    assert_that((image_db.db_path / keys[0].format_relative_path()).is_file(), is_(False))
    assert_that((image_db.db_path / keys[1].format_relative_path()).is_file(), is_(True))
    assert_that((image_db.db_path / keys[0].format_relative_path()).parent.is_dir(), is_(True))
    image_db.delete_disk_cache_entries([keys[1]])
    assert_that((image_db.db_path / keys[1].format_relative_path()).is_file(), is_(False))
    assert_that((image_db.db_path / keys[0].format_relative_path()).parent.is_dir(), is_(False))


@pytest.mark.parametrize("size", [CardSizes.REGULAR, CardSizes.OVERSIZED])
def test_get_blank(image_db: ImageDatabase, size: CardSize):
    image = image_db.get_blank(size)
    assert_that(image, is_(instance_of(QPixmap)))
    assert_that(image, has_getter("size", equal_to(size.as_qsize_px())))
Changes to tests/test_card_info_downloader.py.
20
21
22
23
24
25
26
27

28
29
30
31
32
33
34
import unittest.mock

from hamcrest import *
import pytest

import mtg_proxy_printer.card_info_downloader
from mtg_proxy_printer.card_info_downloader import SetWackinessScore
from mtg_proxy_printer.model.carddb import CardDatabase, Card, MTGSet

from mtg_proxy_printer.units_and_sizes import UUID, CardSizes

from .helpers import assert_model_is_empty, fill_card_database_with_json_card, load_json, assert_relation_is_empty, \
    fill_card_database_with_json_cards, CardDataType


class DatabasePrintingData(typing.NamedTuple):







|
>







20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import unittest.mock

from hamcrest import *
import pytest

import mtg_proxy_printer.card_info_downloader
from mtg_proxy_printer.card_info_downloader import SetWackinessScore
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.card import MTGSet, Card
from mtg_proxy_printer.units_and_sizes import UUID, CardSizes

from .helpers import assert_model_is_empty, fill_card_database_with_json_card, load_json, assert_relation_is_empty, \
    fill_card_database_with_json_cards, CardDataType


class DatabasePrintingData(typing.NamedTuple):
Deleted tests/test_card_list.py.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


from collections import Counter
import typing

from hamcrest import *
import pytest
from pytestqt.qtbot import QtBot
from PyQt5.QtCore import QItemSelectionModel

from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData
from mtg_proxy_printer.model.card_list import CardListModel, CardListModelRow, CardListColumns

from tests.helpers import fill_card_database_with_json_cards

OVERSIZED_ID = "650722b4-d72b-4745-a1a5-00a34836282b"
REGULAR_ID = "0000579f-7b35-4ed3-b44c-db2a538066fe"
FOREST_ID = "7ef83f4c-d3ff-4905-a16d-f2bae673a5b2"
WASTES_ID = "9cc070d3-4b83-4684-9caf-063e5c473a77"
SNOW_FOREST_ID = "ca17acea-f079-4e53-8176-a2f5c5c408a1"


def _populate_card_db_and_create_model(qtbot, card_db: CardDatabase) -> CardListModel:
    fill_card_database_with_json_cards(
        qtbot, card_db,
        ["oversized_card", "regular_english_card", "english_basic_Forest", "english_basic_Wastes", "english_basic_Snow_Forest"])
    model = CardListModel(card_db)
    return model


@pytest.mark.parametrize("count", [1, 2, 10])
def test_add_oversized_card_updates_oversized_count(qtbot: QtBot, card_db: CardDatabase, count: int):
    model = _populate_card_db_and_create_model(qtbot, card_db)
    oversized = card_db.get_card_with_scryfall_id(OVERSIZED_ID, True)
    with qtbot.wait_signal(model.oversized_card_count_changed, check_params_cb=(lambda value: value == count)):
        model.add_cards(Counter({oversized: count}))
    assert_that(model.oversized_card_count, is_(equal_to(count)))


@pytest.mark.parametrize("count, expected", [
    (-1, 1), (0, 1), (1, 1), (99, 99), (100, 100), (101, 100),
])
def test_add_cards_with_invalid_count_clamped_to_valid_range(
        qtbot: QtBot, card_db: CardDatabase, count: int, expected: int):
    model = _populate_card_db_and_create_model(qtbot, card_db)
    card = card_db.get_card_with_scryfall_id(REGULAR_ID, True)
    model.add_cards(Counter({card: count}))
    assert_that(model.rowCount(), is_(1))
    index = model.index(0, CardListColumns.Copies)
    assert_that(model.data(index), is_(expected))


@pytest.mark.parametrize("new_count", [5, 15])
def test_update_oversized_card_count_updates_oversized_count(qtbot: QtBot, card_db: CardDatabase, new_count: int):
    model = _populate_card_db_and_create_model(qtbot, card_db)
    oversized = card_db.get_card_with_scryfall_id(OVERSIZED_ID, True)
    model.add_cards(Counter({oversized: 10}))
    assert_that(model.oversized_card_count, is_(equal_to(10)))

    index = model.index(0, CardListColumns.Copies)
    with qtbot.wait_signal(model.oversized_card_count_changed, check_params_cb=(lambda value: value == new_count)):
        model.setData(index, new_count)
    assert_that(model.oversized_card_count, is_(equal_to(new_count)))


def test_remove_oversized_card_updates_oversized_count(qtbot: QtBot, card_db: CardDatabase):
    model = _populate_card_db_and_create_model(qtbot, card_db)
    oversized = card_db.get_card_with_scryfall_id(OVERSIZED_ID, True)
    model.add_cards(Counter({oversized: 10}))
    assert_that(model.oversized_card_count, is_(equal_to(10)))

    with qtbot.wait_signal(model.oversized_card_count_changed, check_params_cb=(lambda value: value == 0)):
        model.remove_cards(0, 1)
    assert_that(model.oversized_card_count, is_(equal_to(0)))


def test_replace_oversized_with_regular_card_decrements_oversized_count(qtbot: QtBot, card_db: CardDatabase):
    model = _populate_card_db_and_create_model(qtbot, card_db)
    regular = card_db.get_card_with_scryfall_id(REGULAR_ID, True)
    oversized = card_db.get_card_with_scryfall_id(OVERSIZED_ID, True)
    regular_data = CardIdentificationData(
        regular.language, scryfall_id=regular.scryfall_id, is_front=regular.is_front)

    with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000, check_params_cb=(lambda value: value == 1)):
        model.add_cards(Counter({oversized: 1, regular: 1}))
    oversized_index = model.index(0, 0)
    regular_index = model.index(1, 0)
    assert_that(model.rows[0].card.is_oversized, is_(True))
    assert_that(model.rows[oversized_index.row()].card.is_oversized, is_(True))
    assert_that(model.rows[1].card.is_oversized, is_(False))
    assert_that(model.rows[regular_index.row()].card.is_oversized, is_(False))
    assert_that(model.oversized_card_count, is_(1))

    with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000):
        assert_that(model._request_replacement_card(oversized_index, regular_data), is_(True))
    assert_that(model.rows[0].card.is_oversized, is_(False))
    assert_that(model.rows[1].card.is_oversized, is_(False))
    assert_that(model.oversized_card_count, is_(0))


def test_replace_regular_with_oversized_card_increments_oversized_count(qtbot: QtBot, card_db: CardDatabase):
    model = _populate_card_db_and_create_model(qtbot, card_db)
    regular = card_db.get_card_with_scryfall_id(REGULAR_ID, True)
    oversized = card_db.get_card_with_scryfall_id(OVERSIZED_ID, True)
    oversized_data = CardIdentificationData(
        oversized.language, scryfall_id=oversized.scryfall_id, is_front=oversized.is_front)

    with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000, check_params_cb=(lambda value: value == 1)):
        model.add_cards(Counter({oversized: 1, regular: 1}))

    oversized_index = model.index(0, 0)
    regular_index = model.index(1, 0)
    assert_that(model.rows[0].card.is_oversized, is_(True))
    assert_that(model.rows[oversized_index.row()].card.is_oversized, is_(True))
    assert_that(model.rows[1].card.is_oversized, is_(False))
    assert_that(model.rows[regular_index.row()].card.is_oversized, is_(False))
    assert_that(model.oversized_card_count, is_(1))

    with qtbot.wait_signal(model.oversized_card_count_changed, timeout=1000):
        assert_that(model._request_replacement_card(regular_index, oversized_data), is_(True))
    assert_that(model.rows[0].card.is_oversized, is_(True))
    assert_that(model.rows[1].card.is_oversized, is_(True))
    assert_that(model.oversized_card_count, is_(2))


@pytest.mark.parametrize("ranges, merged", [
    ([], []),
    ([(2, 3)], [(2, 3)]),
    ([(0, 0), (0, 0)], [(0, 0)]),
    ([(0, 0), (0, 1)], [(0, 1)]),
    ([(0, 1), (2, 3)], [(0, 3)]),
    ([(0, 1), (3, 4)], [(0, 1), (3, 4)]),
])
def test__merge_ranges(ranges: typing.List[typing.Tuple[int, int]], merged: typing.List[typing.Tuple[int, int]]):
    assert_that(
        CardListModel._merge_ranges(ranges),
        contains_exactly(*merged),
        "Wrong merge result"
    )


def test_remove_multi_selection(qtbot: QtBot, card_db: CardDatabase):
    model = _populate_card_db_and_create_model(qtbot, card_db)
    regular = CardListModelRow(card_db.get_card_with_scryfall_id(REGULAR_ID, True), 1)
    oversized = CardListModelRow(card_db.get_card_with_scryfall_id(OVERSIZED_ID, True), 1)
    model.add_cards(Counter({
        oversized.card: 1,
        regular.card: 1,
    }))
    model.add_cards(Counter({
        oversized.card: 1,
    }))
    selection_model = QItemSelectionModel(model)
    selection_model.select(model.index(0, 0), QItemSelectionModel.Select)
    selection_model.select(model.index(2, 0), QItemSelectionModel.Select)
    assert_that(
        model.remove_multi_selection(selection_model.selection()),
        is_(equal_to(2))
    )
    assert_that(model.rows, contains_exactly(regular))
    assert_that(model.rowCount(), is_(equal_to(1)))


@pytest.mark.parametrize("include_wastes, include_snow_basics, present_cards, expected", [
    (False, False, [], False),
    (False, True, [], False),
    (True, False, [], False),
    (True, True, [], False),

    (False, False, [REGULAR_ID], False),
    (False, True, [REGULAR_ID], False),
    (True, False, [REGULAR_ID], False),
    (True, True, [REGULAR_ID], False),

    (False, False, [REGULAR_ID, OVERSIZED_ID], False),
    (False, True, [REGULAR_ID, OVERSIZED_ID], False),
    (True, False, [REGULAR_ID, OVERSIZED_ID], False),
    (True, True, [REGULAR_ID, OVERSIZED_ID], False),

    (False, False, [FOREST_ID], True),
    (False, True, [FOREST_ID], True),
    (True, False, [FOREST_ID], True),
    (True, True, [FOREST_ID], True),

    (False, False, [WASTES_ID], False),
    (False, True, [WASTES_ID], False),
    (True, False, [WASTES_ID], True),
    (True, True, [WASTES_ID], True),

    (False, False, [SNOW_FOREST_ID], False),
    (False, True, [SNOW_FOREST_ID], True),
    (True, False, [SNOW_FOREST_ID], False),
    (True, True, [SNOW_FOREST_ID], True),

    (False, False, [SNOW_FOREST_ID, WASTES_ID], False),
    (False, True, [SNOW_FOREST_ID, WASTES_ID], True),
    (True, False, [SNOW_FOREST_ID, WASTES_ID], True),
    (True, True, [SNOW_FOREST_ID, WASTES_ID], True),
])
def test_has_basic_lands(
        qtbot: QtBot, card_db: CardDatabase,
        include_wastes: bool, include_snow_basics: bool,
        present_cards: typing.List[str], expected: bool):
    model = _populate_card_db_and_create_model(qtbot, card_db)
    model.add_cards(Counter(
        {card_db.get_card_with_scryfall_id(scryfall_id, True): 1 for scryfall_id in present_cards}
    ))
    assert_that(
        model.has_basic_lands(include_wastes, include_snow_basics),
        is_(expected)
    )


@pytest.mark.parametrize("remove_wastes, remove_snow_basics, present_cards, expected_remaining", [
    (False, False, [], []),
    (False, True, [], []),
    (True, False, [], []),
    (True, True, [], []),
    
    (False, False, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]),
    (False, True, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]),
    (True, False, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]),
    (True, True, [REGULAR_ID, OVERSIZED_ID], [REGULAR_ID, OVERSIZED_ID]),

    (False, False, [WASTES_ID, SNOW_FOREST_ID], [WASTES_ID, SNOW_FOREST_ID]),
    (False, True, [WASTES_ID, SNOW_FOREST_ID], [WASTES_ID]),
    (True, False, [WASTES_ID, SNOW_FOREST_ID], [SNOW_FOREST_ID]),
    (True, True, [WASTES_ID, SNOW_FOREST_ID], []),

    (False, False, [FOREST_ID], []),
    (False, True, [FOREST_ID], []),
    (True, False, [FOREST_ID], []),
    (True, True, [FOREST_ID], []),
])
def test_remove_all_basic_lands(
        qtbot: QtBot, card_db: CardDatabase,
        remove_wastes: bool, remove_snow_basics: bool,
        present_cards: typing.List[str], expected_remaining: typing.List[str]):
    model = _populate_card_db_and_create_model(qtbot, card_db)
    model.add_cards(Counter(
        {card_db.get_card_with_scryfall_id(scryfall_id, True): 1 for scryfall_id in present_cards}
    ))
    model.remove_all_basic_lands(remove_wastes, remove_snow_basics)
    remaining = [row.card.scryfall_id for row in model.rows]
    assert_that(
        remaining,
        contains_exactly(*expected_remaining)
    )
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































































































































































































































































































































































































































































































Deleted tests/test_carddb.py.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import datetime
import itertools
from pathlib import Path
import textwrap
import typing
import unittest.mock
from unittest.mock import MagicMock

from hamcrest import *
import pytest

import mtg_proxy_printer.settings
from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData, MINIMUM_REFRESH_DELAY, CardList, \
    Card, MTGSet
from mtg_proxy_printer.model.imagedb_files import CacheContent
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.print_count_updater import PrintCountUpdater
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.units_and_sizes import UUID

from .helpers import assert_model_is_empty, fill_card_database_with_json_card, \
    fill_card_database_with_json_cards, is_dataclass_equal_to, matches_type_annotation, update_database_printing_filters
from .test_card_info_downloader import TestCaseData

StringList = typing.List[str]
OptString = typing.Optional[str]


def test_has_data_on_empty_database_returns_false(card_db: CardDatabase):
    assert_model_is_empty(card_db)
    assert_that(card_db.has_data(), is_(False))


def test_has_data_on_filled_database_returns_true(qtbot, card_db: CardDatabase):
    fill_card_database_with_json_card(qtbot, card_db, "regular_english_card")
    assert_that(card_db.has_data(), is_(True))


def test_get_all_languages_without_data(card_db: CardDatabase):
    assert_that(
        card_db.get_all_languages(),
        is_(empty())
    )


def test_get_all_languages_with_data(qtbot, card_db: CardDatabase):
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "german_Coercion_with_faulty_translation",
            "spanish_basic_Forest",
            "german_Duress",
        ],
    )
    assert_that(
        card_db.get_all_languages(),
        contains_exactly("de", "en", "es")
    )


@pytest.mark.parametrize("language, prefix, expected_names", [
    ("en", None, ["Forest", "Future Sight", "Duress", "Coercion"]),
    ("en", "Fu", ["Future Sight"]),
    ("en", "%or", ["Forest"]),
    ("en", "AAAAAAAA", []),
    ("en", "F%t", ["Forest", "Future Sight"]),
    ("de", None, ["Wald", "Zwang"]),  # noqa  # A German Forest and Duress
    ("es", None, ["Bosque"]),  # noqa  # A Spanish Forest
    ("Nonexisting language", None, []),
])
def test_get_card_names(qtbot, card_db: CardDatabase, language: str, prefix: OptString, expected_names: StringList):
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "english_basic_Forest",
            "english_basic_Forest_2",
            "english_card_Future_Sight_MH1",
            "english_card_Future_Sight_MTGO_promo",
            "german_Coercion_with_faulty_translation",
            "german_basic_Forest",
            "spanish_basic_Forest",
            "german_Duress",
        ],
    )
    assert_that(
        card_db.get_card_names(language, prefix),
        contains_inanyorder(*expected_names)
    )


@pytest.mark.parametrize("name, expected", [
    ("Forest", "en"),
    ("Future Sight", "en"),
    ("Wald", "de"),
    ("Bosque", "es"),
    ("Unknown", None),
    ("Mentor Corrosivo", "pt"),
    ("Mentor corrosivo", "es"),
])
def test_guess_language_from_name(qtbot, card_db: CardDatabase, name: str, expected: OptString):
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "english_basic_Forest",
            "english_basic_Forest_2",
            "english_card_Future_Sight_MH1",
            "english_card_Future_Sight_MTGO_promo",
            "german_Coercion_with_faulty_translation",
            "german_basic_Forest",
            "spanish_basic_Forest",
            "german_Duress",
            "korean_Forest_with_placeholder_name",
            "portuguese_Corrosive_Mentor",
            "spanish_Corrosive_Mentor",
        ],
    )
    assert_that(
        card_db.guess_language_from_name(name),
        is_(equal_to(expected))
    )


@pytest.mark.parametrize("language, expected", [
    ("en", True),
    ("de", True),
    ("es", True),
    ("", False),
    ("Unknown", False),
])
def test_is_known_language(qtbot, card_db: CardDatabase, language: str, expected: bool):
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "english_basic_Forest",
            "english_basic_Forest_2",
            "english_card_Future_Sight_MH1",
            "english_card_Future_Sight_MTGO_promo",
            "german_Coercion_with_faulty_translation",
            "german_basic_Forest",
            "spanish_basic_Forest",
            "german_Duress",
        ],
    )
    assert_that(
        card_db.is_known_language(language),
        is_(equal_to(expected))
    )


@pytest.fixture
def card_db_with_cards(qtbot, card_db: CardDatabase):
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "english_basic_Forest",
            "english_basic_Forest_2",
            "english_card_Future_Sight_MH1",
            "english_card_Future_Sight_MTGO_promo",
            "german_Coercion_with_faulty_translation",
            "german_basic_Forest",
            "spanish_basic_Forest",
            "german_Duress",
            "german_Duress_2",
            "english_Ironroot_Treefolk_1",
            "english_Ironroot_Treefolk_2",
            "english_Ironroot_Treefolk_3",
            "german_Ironroot_Treefolk_1",
            "german_Ironroot_Treefolk_2",
            "german_Ironroot_Treefolk_3",
            "oversized_card",
            "regular_english_card",
            "english_double_faced_card",
            "english_double_faced_art_series_card",
            "Flowerfoot_Swordmaster_card",
            "Flowerfoot_Swordmaster_token",
        ],
    )
    yield card_db
    card_db.__dict__.clear()


def generate_test_cases_for_test_translate_card_name():
    """Yields tuples with card data, target language and expected result."""
    # Same-language identity translation
    yield CardIdentificationData("en", "Forest"), "en", "Forest"
    yield CardIdentificationData("de", "Wald"), "de", "Wald"
    yield CardIdentificationData("es", "Bosque"), "es", "Bosque"
    # Guess source language
    yield CardIdentificationData(None, "Forest"), "en", "Forest"
    yield CardIdentificationData(None, "Wald"), "en", "Forest"
    yield CardIdentificationData(None, "Bosque"), "en", "Forest"
    yield CardIdentificationData(None, "Bosque"), "de", "Wald"
    yield CardIdentificationData(None, "Forest"), "de", "Wald"
    # translation with source language
    yield CardIdentificationData("de", "Wald"), "en", "Forest"
    yield CardIdentificationData("es", "Bosque"), "en", "Forest"
    # wrong source language. Returns no result
    yield CardIdentificationData("wrong_source", "Wald"), "en", None
    yield CardIdentificationData("wrong_source", "Forest"), "de", None
    yield CardIdentificationData("wrong_source", "Bosque"), "en", None
    yield CardIdentificationData("wrong_source", "Bosque"), "es", None
    # Card with name clash. Tests majority voting
    yield CardIdentificationData("de", "Zwang"), "en", "Duress"
    yield CardIdentificationData(None, "Zwang"), "en", "Duress"
    # Card with name clash. Tests using context information yields the expected name
    yield CardIdentificationData("de", "Zwang", scryfall_id="51c6ec30-afb2-41e6-895b-92e070aa86f3"), "en", "Duress"
    yield CardIdentificationData(None, "Zwang", scryfall_id="51c6ec30-afb2-41e6-895b-92e070aa86f3"), "en", "Duress"
    yield CardIdentificationData("de", "Zwang", scryfall_id="93054b80-fd1f-4200-8d33-2e826a181db0"), "en", "Coercion"
    yield CardIdentificationData(None, "Zwang", scryfall_id="93054b80-fd1f-4200-8d33-2e826a181db0"), "en", "Coercion"
    yield CardIdentificationData("de", "Zwang", "7ed"), "en", "Duress"
    yield CardIdentificationData(None, "Zwang", "7ed"), "en", "Duress"
    yield CardIdentificationData("de", "Zwang", "6ed"), "en", "Coercion"
    yield CardIdentificationData(None, "Zwang", "6ed"), "en", "Coercion"
    # Card with updated, localized name. Tests that all names can be a source name.
    yield CardIdentificationData("de", "Baumvolk der Eisenwurzler"), "en", "Ironroot Treefolk"
    yield CardIdentificationData(None, "Baumvolk der Eisenwurzler"), "en", "Ironroot Treefolk"
    yield CardIdentificationData("de", "Ehernen-Wald Baumvolk"), "en", "Ironroot Treefolk"
    yield CardIdentificationData(None, "Ehernen-Wald Baumvolk"), "en", "Ironroot Treefolk"
    yield CardIdentificationData("de", "Baumvolk des Ehernen-Waldes"), "en", "Ironroot Treefolk"
    yield CardIdentificationData(None, "Baumvolk des Ehernen-Waldes"), "en", "Ironroot Treefolk"
    # Card with updated, localized name. Tests returning the newest name without context information
    yield CardIdentificationData("en", "Ironroot Treefolk"), "de", "Baumvolk der Eisenwurzler"
    yield CardIdentificationData(None, "Ironroot Treefolk"), "de", "Baumvolk der Eisenwurzler"
    # Card with updated, localized name. Tests returning the correct name for the source set with context information
    yield CardIdentificationData("en", "Ironroot Treefolk", "5ed"), "de", "Baumvolk der Eisenwurzler"
    yield CardIdentificationData(None, "Ironroot Treefolk", "5ed"), "de", "Baumvolk der Eisenwurzler"
    yield CardIdentificationData("en", "Ironroot Treefolk", "4ed"), "de", "Ehernen-Wald Baumvolk"
    yield CardIdentificationData(None, "Ironroot Treefolk", "4ed"), "de", "Ehernen-Wald Baumvolk"
    yield CardIdentificationData("en", "Ironroot Treefolk", "3ed"), "de", "Baumvolk des Ehernen-Waldes"
    yield CardIdentificationData(None, "Ironroot Treefolk", "3ed"), "de", "Baumvolk des Ehernen-Waldes"
    yield CardIdentificationData("en", "Ironroot Treefolk", scryfall_id="6bdbba38-b4c9-4c14-b869-669b39390e4e"), "de", "Baumvolk der Eisenwurzler"
    yield CardIdentificationData(None, "Ironroot Treefolk", scryfall_id="6bdbba38-b4c9-4c14-b869-669b39390e4e"), "de", "Baumvolk der Eisenwurzler"
    yield CardIdentificationData("en", "Ironroot Treefolk", scryfall_id="c6c93c85-5263-4770-b937-704e57912478"), "de", "Ehernen-Wald Baumvolk"
    yield CardIdentificationData(None, "Ironroot Treefolk", scryfall_id="c6c93c85-5263-4770-b937-704e57912478"), "de", "Ehernen-Wald Baumvolk"
    yield CardIdentificationData("en", "Ironroot Treefolk", scryfall_id="6e6cfaae-ea9e-4c54-858e-381f8bf441a9"), "de", "Baumvolk des Ehernen-Waldes"
    yield CardIdentificationData(None, "Ironroot Treefolk", scryfall_id="6e6cfaae-ea9e-4c54-858e-381f8bf441a9"), "de", "Baumvolk des Ehernen-Waldes"
    # double-faced art series card. Same name on both sides
    yield CardIdentificationData("en", "Clearwater Pathway"), "en", "Clearwater Pathway"

    
@pytest.mark.parametrize("card_data, target_language, expected", generate_test_cases_for_test_translate_card_name())
def test_translate_card_name(
        card_db_with_cards: CardDatabase, card_data: CardIdentificationData, target_language: str, expected: OptString):
    assert_that(
        card_db_with_cards.translate_card_name(card_data, target_language),
        is_(equal_to(expected))
    )


@pytest.mark.parametrize("usage_count, expected", [
    (-1, []),
    (0, []),
    (1, [2]),
    (2, [1, 2]),
    (3, [0, 1, 2]),
    (100, [0, 1, 2]),
])
def test_cards_used_less_often_then(qtbot, card_db: CardDatabase, usage_count: int, expected: typing.List[int]):
    # Setup
    fill_card_database_with_json_cards(
        qtbot, card_db,
        [
            "english_Coercion",
            "english_Duress",
            "english_basic_Forest",
            "english_basic_Forest_2",
            "english_card_Future_Sight_MH1",
            "english_card_Future_Sight_MTGO_promo",
            "german_Coercion_with_faulty_translation",
            "german_basic_Forest",
            "spanish_basic_Forest",
            "german_Duress",
        ],
    )
    document = Document(card_db, MagicMock())
    document.apply(ActionAddCard(
        _get_card_from_model(card_db, "e2ef9b74-481b-424b-8e33-f0b910f66370", True), 1)
    )
    PrintCountUpdater(document, card_db.db).run()
    document.apply(ActionAddCard(
        _get_card_from_model(card_db, "ffa13d4c-6c5e-44bd-859e-38e79d47a916", True), 1)
    )
    PrintCountUpdater(document, card_db.db).run()
    # Test
    assert_that(
        result := card_db.cards_used_less_often_then([
            ("e2ef9b74-481b-424b-8e33-f0b910f66370", True),
            ("ffa13d4c-6c5e-44bd-859e-38e79d47a916", True),
            ("cd4cf73d-a408-48f1-9931-54707553c5d5", True),
        ], usage_count),
        contains_exactly(*expected),
        f"Result: {result}"
    )


def _get_card_from_model(card_db: CardDatabase, scryfall_id: str, is_front: bool):
    card = card_db.get_card_with_scryfall_id(scryfall_id, is_front)
    assert_that(card, has_properties({
        "scryfall_id": equal_to(scryfall_id),
        "is_front": equal_to(is_front),
    }), "Wrong card returned")
    return card


@pytest.mark.parametrize("json_name, scryfall_id, expected", [
    ("regular_english_card", "0000579f-7b35-4ed3-b44c-db2a538066fe", False),
    ("oversized_card", "650722b4-d72b-4745-a1a5-00a34836282b", True)
])
def test_card_is_oversized(qtbot, card_db: CardDatabase, json_name: str, scryfall_id: str, expected: bool):
    """
    Tests that all methods creating Card instances correctly set is_oversized attribute.
    """
    fill_card_database_with_json_card(qtbot, card_db, json_name)
    assert_that(
        card_db.get_card_with_scryfall_id(scryfall_id, True),
        has_property("is_oversized", is_(expected))
    )


def generate_test_cases_for_test_get_cards_from_data():
    case = TestCaseData("regular_english_card")
    yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id), [case.as_card(),]
    yield CardIdentificationData(scryfall_id=case.scryfall_id), [case.as_card(),]

    case = TestCaseData("oversized_card")
    yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id), [case.as_card(),]
    yield CardIdentificationData(scryfall_id=case.scryfall_id), [case.as_card(),]

    # Tests effect of is_front on double-faced cards
    case = TestCaseData("english_double_faced_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id), [
        case.as_card(1),
        case.as_card(2),
    ]
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), [
        case.as_card(1),
    ]
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), [
        case.as_card(2),
    ]

    # Tests identification based on oracle_id alone. Also tests highres_image boolean
    forest_en_1 = TestCaseData("english_basic_Forest")
    forest_en_2 = TestCaseData("english_basic_Forest_2")
    forest_de = TestCaseData("german_basic_Forest")
    forest_es = TestCaseData("spanish_basic_Forest")
    yield CardIdentificationData(oracle_id="b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"), [
        forest_en_1.as_card(),
        forest_en_2.as_card(),
        forest_de.as_card(),
        forest_es.as_card(),
    ]
    # Tests other attribute combinations
    yield CardIdentificationData(name="Bosque"), [
        forest_es.as_card()
    ]
    yield CardIdentificationData(set_code="anb"), [
        forest_en_1.as_card()
    ]
    yield CardIdentificationData("de", set_code="znr"), [
       forest_de.as_card()
    ]
    yield CardIdentificationData(set_code="znr", collector_number="280"), [
        forest_en_2.as_card(),
        forest_es.as_card(),
    ]
    # Empty result set
    yield CardIdentificationData(scryfall_id="invalid"), []
    # Prefer cards to tokens with the same name
    yield CardIdentificationData(name="Flowerfoot Swordmaster"), [
        TestCaseData("Flowerfoot_Swordmaster_card").as_card(),
        TestCaseData("Flowerfoot_Swordmaster_token").as_card(),
    ]


@pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test_get_cards_from_data())
def test_get_cards_from_data(
        card_db_with_cards: CardDatabase,
        card_data: CardIdentificationData, expected: CardList):
    cards = card_db_with_cards.get_cards_from_data(card_data)
    for card in cards:
        assert_that(card, matches_type_annotation())
    assert_that(
        cards,
        contains_inanyorder(
            *map(is_dataclass_equal_to, expected)
        )
    )

@pytest.mark.parametrize("card_data, expected", [
            (CardIdentificationData(name="Flowerfoot Swordmaster"), [
        TestCaseData("Flowerfoot_Swordmaster_card").as_card(),
        TestCaseData("Flowerfoot_Swordmaster_token").as_card(),
    ])
])
def test_get_cards_from_data_always_prefers_card_over_token(
        card_db_with_cards: CardDatabase,
        card_data: CardIdentificationData, expected: CardList):
    cards = card_db_with_cards.get_cards_from_data(card_data)
    assert_that(
        cards,
        contains_exactly(
            *map(is_dataclass_equal_to, expected)
        )
    )

def generate_test_cases_for_test_get_card_with_scryfall_id() -> \
        typing.Generator[typing.Tuple[CardIdentificationData, typing.Optional[Card]], None, None]:
    # Regular card
    case = TestCaseData("regular_english_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card()
    # Back side of regular card returns None
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), None
    # Unknown scryfall_id returns None
    yield CardIdentificationData(scryfall_id="ueueueue-abcd-1234-5678-abcdefabcdef", is_front=True), None

    case = TestCaseData("oversized_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card()

    case = TestCaseData("german_basic_Forest")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card()

    case = TestCaseData("spanish_basic_Forest")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card()

    # Double-faced with high-res image
    case = TestCaseData("english_double_faced_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card(1)
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(2)

    # Art series card
    case = TestCaseData("english_double_faced_art_series_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card(1)
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(2)
    # Digital card
    case = TestCaseData("english_basic_Forest")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card()


@pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test_get_card_with_scryfall_id())
def test_get_card_with_scryfall_id(
        card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: typing.Optional[Card]):
    assert_that(
        card_db_with_cards.get_card_with_scryfall_id(card_data.scryfall_id, card_data.is_front),
        is_(any_of(
            all_of(
                none(),
                instance_of(type(expected))  # None if and only if expected is None
            ),
            all_of(
                is_(instance_of(Card)),
                matches_type_annotation(),
                has_properties({
                    # Verifies that the expected card matches the given card identification data.
                    # Not strictly required, but ensures that the test data is consistent
                    "scryfall_id": card_data.scryfall_id,
                    "is_front": card_data.is_front,
                }),
                is_dataclass_equal_to(expected),
            )))
    )


@pytest.mark.parametrize("language", ["en", None])
@pytest.mark.parametrize("card_count_data, expected_index, identification_data", [
    ([("7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", 2), ("e2ef9b74-481b-424b-8e33-f0b910f66370", 1)], 0, CardIdentificationData(name="Forest")),
    ([("7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", 1), ("e2ef9b74-481b-424b-8e33-f0b910f66370", 2)], 1, CardIdentificationData(name="Forest")),
])
def test_get_cards_from_data_order_by_print_count_enabled(
        qtbot, card_db: CardDatabase, language: OptString, card_count_data, expected_index: int, identification_data: CardIdentificationData):
    fill_card_database_with_json_cards(qtbot, card_db, ["english_basic_Forest", "english_basic_Forest_2"])
    card_db.db.executemany(
        "INSERT INTO LastImageUseTimestamps (scryfall_id, is_front, usage_count) VALUES (?, 1, ?)",
        card_count_data
    )
    identification_data.language = language
    cards = card_db.get_cards_from_data(identification_data, order_by_print_count=True)
    other_index = int(not expected_index)
    assert_that(
        cards,
        contains_exactly(
            has_property("scryfall_id", equal_to(
                card_count_data[expected_index][0]
            )),
            has_property("scryfall_id", equal_to(
                card_count_data[other_index][0]
            )),
        )
    )


def test_get_replacement_card(
        qtbot, card_db: CardDatabase):
    fill_card_database_with_json_cards(qtbot, card_db, ["english_basic_Forest", "german_basic_Forest"])
    card_db.db.executemany(
        textwrap.dedent("""\
            INSERT INTO RemovedPrintings (scryfall_id, language, oracle_id)
                VALUES (?, ?, ?)
            """), [
            ("english-id", "en", "b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"),
            ("german-id", "de", "b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc"),
            ("non-english-id", "invalid", "b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"),
        ])
    card_db.get_replacement_card_for_unknown_printing(CardIdentificationData(scryfall_id="english-id", language="en"))


def generate_test_cases_for_test__translate_card():
    # Same-language translation
    for case in (TestCaseData("regular_english_card"), TestCaseData("regular_english_card")):
        yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id, is_front=True), case.as_card()
    for case in (TestCaseData("english_double_faced_card"), TestCaseData("english_double_faced_art_series_card")):
        yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id, is_front=True), case.as_card(1)
        yield CardIdentificationData(case.language, scryfall_id=case.scryfall_id, is_front=False), case.as_card(2)

    # Translate single-faced card
    forests = TestCaseData("english_basic_Forest_2"), TestCaseData("german_basic_Forest"), TestCaseData("spanish_basic_Forest")
    for source, target in itertools.product(forests, repeat=2):  # type: TestCaseData, TestCaseData
        yield CardIdentificationData(target.language, scryfall_id=source.scryfall_id, is_front=True), target.as_card()
    #
    treefolk = (
        (TestCaseData("german_Ironroot_Treefolk_1"), TestCaseData("english_Ironroot_Treefolk_1")),
        (TestCaseData("german_Ironroot_Treefolk_2"), TestCaseData("english_Ironroot_Treefolk_2")),
        (TestCaseData("german_Ironroot_Treefolk_3"), TestCaseData("english_Ironroot_Treefolk_3")),
    )
    for card_1, card_2 in treefolk:
        yield CardIdentificationData(card_2.language, scryfall_id=card_1.scryfall_id, is_front=True), card_2.as_card()
        yield CardIdentificationData(card_1.language, scryfall_id=card_2.scryfall_id, is_front=True), card_1.as_card()


@pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test__translate_card())
def test__translate_card(card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: Card):
    is_front = card_data.is_front is None or card_data.is_front
    to_translate = card_db_with_cards.get_card_with_scryfall_id(card_data.scryfall_id, is_front)
    # Use the private method to skip the internal shortcut in translate_card()
    # that skips requested same-language translations.
    assert_that(
        card_db_with_cards._translate_card(to_translate, expected.language), all_of(
            is_(Card),
            is_not(same_instance(to_translate)),  # No shortcut taken, is actually a new instance
            matches_type_annotation(),
            is_dataclass_equal_to(expected),
        )
    )


def generate_test_cases_for_test_get_opposing_face() -> \
        typing.Generator[typing.Tuple[CardIdentificationData, typing.Optional[Card]], None, None]:
    # Single-faced cards
    for case in (TestCaseData("regular_english_card"), TestCaseData("oversized_card")):
        # The back side of a regular card does not exist, Expect None
        yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), None
        # The other side of a non-existing back side of a regular card returns the existing front
        yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card()
    case = TestCaseData("split_card")
    yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), None
    # FIXME: This returns None, but should return the first face of the front
    # yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(1)

    # Double-faced cards
    for case in (TestCaseData("english_double_faced_card"), TestCaseData("english_double_faced_art_series_card")):
        yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=True), case.as_card(2)
        yield CardIdentificationData(scryfall_id=case.scryfall_id, is_front=False), case.as_card(1)


@pytest.mark.parametrize("card_data, expected", generate_test_cases_for_test_get_opposing_face())
def test_get_opposing_face(
        card_db_with_cards: CardDatabase, card_data: CardIdentificationData, expected: typing.Optional[Card]):
    result = card_db_with_cards.get_opposing_face(card_data)
    if expected is None:
        assert_that(result, is_(none()))
    else:
        assert_that(
            result,
            is_(all_of(
                is_(instance_of(Card)),
                matches_type_annotation(),
                has_properties({
                    # Verifies that the expected card matches the given card identification data.
                    # Not strictly required, but ensures that the test data is consistent
                    "scryfall_id": card_data.scryfall_id,
                    "is_front": not card_data.is_front,  # Negation here
                }),
                is_dataclass_equal_to(expected),
            ))
        )


def test_allow_updating_card_data_on_empty_database_returns_true(card_db: CardDatabase):
    assert_that(card_db.allow_updating_card_data(), is_(True))


def test_allow_updating_card_data_on_freshly_populated_database_returns_false(qtbot, card_db: CardDatabase):
    fill_card_database_with_json_card(qtbot, card_db, "regular_english_card")
    assert_that(card_db.allow_updating_card_data(), is_(False))


@pytest.mark.parametrize("delta_days", [-2, -1, 0, 1, 2])
def test_allow_updating_card_data_on_stale_populated_database_returns_true(
        qtbot, card_db: CardDatabase, delta_days: int):
    fill_card_database_with_json_card(qtbot, card_db, "regular_english_card")
    today = datetime.datetime.today()
    now = today + MINIMUM_REFRESH_DELAY + datetime.timedelta(delta_days)
    fromisoformat = datetime.datetime.fromisoformat
    with unittest.mock.patch("mtg_proxy_printer.model.carddb.datetime.datetime") as mock_date:
        mock_date.today.return_value = now
        mock_date.fromisoformat = fromisoformat
        assert_that(datetime.datetime.today(), is_not(today))
        assert_that(
            card_db.allow_updating_card_data(),
            is_(delta_days >= 0)
        )


def test_is_removed_printing_with_removed_printing_returns_true(qtbot, card_db: CardDatabase):
    fill_card_database_with_json_card(qtbot, card_db, "missing_image_double_faced_card")
    assert_that(
        card_db.is_removed_printing("b120e3c2-21b1-43e3-b685-9cf62bd7aa07"),
        is_(True)
    )


@pytest.mark.parametrize("filter_value", [True, False])
def test_is_removed_printing_with_included_printing_returns_false(qtbot, card_db: CardDatabase, filter_value: bool):
    fill_card_database_with_json_card(qtbot, card_db, "oversized_card", {"hide-oversized-cards": str(filter_value)})
    assert_that(
        card_db.is_removed_printing("650722b4-d72b-4745-a1a5-00a34836282b"),
        is_(filter_value)
    )


@pytest.mark.parametrize("order_printings", [True, False])
@pytest.mark.parametrize("cards_to_import, filter_name, card_data, expected_replacement", [
    (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", CardIdentificationData("en", scryfall_id="b120e3c2-21b1-43e3-b685-9cf62bd7aa07", is_front=True), "d9131fc3-018a-4975-8795-47be3956160d"),
    (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", CardIdentificationData(scryfall_id="b120e3c2-21b1-43e3-b685-9cf62bd7aa07", is_front=True), "d9131fc3-018a-4975-8795-47be3956160d"),
    (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", CardIdentificationData("de", scryfall_id="97b84e7d-258f-46dc-baef-4b1eb6f28d4d", is_front=True), "0600d6c2-0f72-4e79-a55d-1f06dffa48c2"),
    (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", CardIdentificationData(scryfall_id="97b84e7d-258f-46dc-baef-4b1eb6f28d4d", is_front=True), "0600d6c2-0f72-4e79-a55d-1f06dffa48c2"),
])
def test_get_replacement_card_for_unknown_printing(
        qtbot, card_db: CardDatabase, cards_to_import, filter_name: str, card_data: CardIdentificationData,
        expected_replacement: str, order_printings: bool):
    fill_card_database_with_json_cards(qtbot, card_db, cards_to_import, {filter_name: "True"})

    assert_that(
        card_db.get_replacement_card_for_unknown_printing(card_data, order_by_print_count=order_printings),
        all_of(
            not_(empty()),
            contains_exactly(
                has_property("scryfall_id", equal_to(expected_replacement)),
            )
        )
    )


@pytest.mark.parametrize("cards_to_import, filter_name, printing, expected", [
    (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", "b120e3c2-21b1-43e3-b685-9cf62bd7aa07", True),
    (["missing_image_double_faced_card", "english_double_faced_card_2"], "any", "d9131fc3-018a-4975-8795-47be3956160d", False),
    (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", "97b84e7d-258f-46dc-baef-4b1eb6f28d4d", True),
    (["german_Back_to_Basics", "english_Back_to_Basics"], "hide-cards-without-images", "0600d6c2-0f72-4e79-a55d-1f06dffa48c2", False),
])
def test_is_removed_printing(
        qtbot, card_db: CardDatabase, cards_to_import, filter_name: str, printing: str, expected: bool):
    fill_card_database_with_json_cards(qtbot, card_db, cards_to_import, {filter_name: "True"})
    assert_that(
        card_db.is_removed_printing(printing),
        is_(expected)
    )


@pytest.mark.timeout(1)
@pytest.mark.parametrize("include_wastes, include_snow_basics, expected_oracle_ids", [
    (False, False, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6"]),
    (True, False, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6", "05d24b0c-904a-46b6-b42a-96a4d91a0dd4"]),
    (False, True, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6", "5f0d3be8-e63e-4ade-ae58-6b0c14f2ce6d"]),
    (True, True, ["b34bb2dc-c1af-4d77-b0b3-a0fb342a5fc6", "05d24b0c-904a-46b6-b42a-96a4d91a0dd4", "5f0d3be8-e63e-4ade-ae58-6b0c14f2ce6d"]),
])
def test_get_basic_land_oracle_ids(
        qtbot, card_db: CardDatabase,
        include_wastes: bool, include_snow_basics: bool, expected_oracle_ids: StringList):
    fill_card_database_with_json_cards(
        qtbot, card_db, ["english_basic_Forest", "english_basic_Wastes", "english_basic_Snow_Forest"])
    assert_that(
        card_db.get_basic_land_oracle_ids(include_wastes, include_snow_basics),
        contains_inanyorder(*expected_oracle_ids)
    )


@pytest.mark.parametrize("source_id, expected_cards_names", [
    ("2c6e5b25-b721-45ee-894a-697de1310b8c", ["Food"]),  # Bake into a Pie
    ("37e32ba6-108a-421f-9dad-3d03f7ebe239", []),  # Food token
    ("e4b7e3b5-2f3c-4eb7-abc9-322a049a9e1a", []),  # Food Token
    # Both printings of Asmoranomardicadaistinaculdacar
    ("d99a9a7d-d9ca-4c11-80ab-e39d5943a315", ["The Underworld Cookbook", "Food"]),
    ("2879f780-e17f-4e68-931e-6e45f9df28e1", ["The Underworld Cookbook", "Food"]),
    # The Underworld Cookbook
    ("4f24504e-b397-4b98-b8e8-8166457f7a2e", ["Asmoranomardicadaistinaculdacar", "Food"]),
    # Ring
    ("7215460e-8c06-47d0-94e5-d1832d0218af", []),  # The Ring itself
    ("e3bb16a8-b248-4ad5-ba45-1ed499ca1411", ["The Ring"]),  # Elrond
    ("fbc88c94-adf6-4699-a11e-24ebd16aac0c", ["The Ring"]),  # Samwise
    # Venture
    ("6f509dbe-6ec7-4438-ab36-e20be46c9922", []),  # Dungeon of the Mad Mage
    ("d4dbed36-190c-4748-b282-409a2fb5d134", ["Dungeon of the Mad Mage"]),  # Zombie Ogre
    ("b9b1e53f-1384-4860-9944-e68922afc65c", ["Dungeon of the Mad Mage"]),  # Bar the Gate
    # Initiative
    ("2c65185b-6cf0-451d-985e-56aa45d9a57d", []),  # The Undercity
    ("0c4f76ae-e93b-4ca1-ac62-753707f6319e", ["Undercity"]),  # Trailblazer's Torch
    ("0cbf06f5-d1c7-474c-8f09-72f5ad0c8120", ["Undercity"]),  # Explore the Underdark

])
def test_find_related_printings(qtbot, card_db: CardDatabase, source_id: str, expected_cards_names: StringList):
    fill_card_database_with_json_cards(
        qtbot, card_db, [
            "The_Underworld_Cookbook",
            "Food_Token",
            "Asmoranomardicadaistinaculdacar",
            "Bake_into_a_Pie",
            "Asmoranomardicadaistinaculdacar_2",
            "Food_Token_2",
            # The Ring emblem and "The Ring tempts you"
            "The_Ring",
            "Samwise_the_Stouthearted",
            "Elrond_Lord_of_Rivendell",
            # A Dungeon and "Venture into the dungeon"
            "Dungeon_of_the_Mad_Mage",
            "Bar_the_Gate",
            "Zombie_Ogre",
            # The "Undercity" dungeon and "Take the initiative."
            "Undercity",
            "Explore_the_Underdark",
            "Trailblazers_Torch",
        ])
    source_card = card_db.get_card_with_scryfall_id(source_id, True)
    assert_that(source_card, is_(not_none()), "Setup failed")
    related = card_db.find_related_cards(source_card)
    assert_that(
        related, contains_inanyorder(
            *[has_property("name", equal_to(expected)) for expected in expected_cards_names]
        ),
        f"Found cards do not match {expected_cards_names}"
    )


def test_get_all_cards_from_image_cache(qtbot, card_db):
    fill_card_database_with_json_cards(
        qtbot, card_db, ["regular_english_card", "oversized_card"], {"hide-oversized-cards": str(True)})
    cache_content = [
        CacheContent("650722b4-d72b-4745-a1a5-00a34836282b", True, True, Path()),  # Atraxa
        CacheContent("0000579f-7b35-4ed3-b44c-db2a538066fe", True, True, Path()),  # Fury Sliver
        CacheContent("abcdeabc-abcd-abcd-abcd-efghijklmnop", True, True, Path()),  # Non-existing
    ]
    assert_that(
        card_db.get_all_cards_from_image_cache(cache_content),
        contains_exactly(
            contains_exactly(contains_exactly(
                has_property("name", equal_to("Fury Sliver")),
                cache_content[1])),
            contains_exactly(contains_exactly(
                has_property("name", equal_to("Atraxa, Praetors' Voice")),
                cache_content[0])),
            contains_exactly(cache_content[-1]),
        )
    )


@pytest.mark.parametrize("json_name, scryfall_id, expected", [
    ("regular_english_card", "0000579f-7b35-4ed3-b44c-db2a538066fe", False),
    ("english_double_faced_card", "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", True),

])
def test_is_dfc(qtbot, card_db: CardDatabase, json_name: str, scryfall_id: str, expected: bool):
    fill_card_database_with_json_card(qtbot, card_db, json_name)
    assert_that(
        card_db.is_dfc(scryfall_id),
        is_(equal_to(expected))
    )


@pytest.mark.parametrize("card_data, filter_enabled, expected", [
    # Forests. All source languages return all available languages
    (CardIdentificationData(scryfall_id="7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", is_front=True), False, ["de", "en", "es"]),
    (CardIdentificationData(scryfall_id="ffa13d4c-6c5e-44bd-859e-38e79d47a916", is_front=True), False, ["de", "en", "es"]),
    (CardIdentificationData(scryfall_id="cd4cf73d-a408-48f1-9931-54707553c5d5", is_front=True), False, ["de", "en", "es"]),
    # The mis-translated German Coercion cannot be translated, as the English Coercion is not in the imported test data
    (CardIdentificationData(scryfall_id="93054b80-fd1f-4200-8d33-2e826a181db0", is_front=True), False, ["de"]),
    # English/German Duress can be translated
    (CardIdentificationData(scryfall_id="51c6ec30-afb2-41e6-895b-92e070aa86f3", is_front=True), False, ["de", "en"]),
    (CardIdentificationData(scryfall_id="15c8d82e-6e65-4d36-bf09-b24dde016581", is_front=True), False, ["de", "en"]),
    # English Back to Basics only finds English if card filters are active
    (CardIdentificationData(scryfall_id="0600d6c2-0f72-4e79-a55d-1f06dffa48c2", is_front=True), True, ["en"]),
    (CardIdentificationData(scryfall_id="0600d6c2-0f72-4e79-a55d-1f06dffa48c2", is_front=True), False, ["de", "en"]),
    # German, hidden printing of Back to Basics also round-trips the current language
    (CardIdentificationData(scryfall_id="97b84e7d-258f-46dc-baef-4b1eb6f28d4d", is_front=True), True, ["de", "en"]),
])
def test_get_available_languages_for_card(
        qtbot, card_db, card_data: CardIdentificationData, filter_enabled: bool, expected: StringList):
    fill_card_database_with_json_cards(qtbot, card_db, [
        "english_basic_Forest", "german_basic_Forest", "spanish_basic_Forest",
        "german_Coercion_with_faulty_translation", "german_Duress", "english_Duress",
        "english_Back_to_Basics", "german_Back_to_Basics",
    ])
    card = card_db.get_card_with_scryfall_id(card_data.scryfall_id, card_data.is_front)
    assert_that(card, is_(not_none()), "Setup failed, card not found")
    if filter_enabled:
        filters = {key: str(filter_enabled) for key in mtg_proxy_printer.settings.settings["card-filter"]}
        update_database_printing_filters(card_db, filters)
    assert_that(
        card_db.get_available_languages_for_card(card),
        all_of(has_length(len(expected)), contains_exactly(*expected))
    )


def test_get_card_from_data_prefers_highres_images_over_newer_lowres_printings(qtbot, card_db):
    fill_card_database_with_json_cards(
        qtbot, card_db, ["english_basic_Forest_2", "English_basic_Forest_newest_and_low_res"]
    )
    assert_that(
        card_db.get_cards_from_data(CardIdentificationData(name="Forest")),
        contains_exactly(
            has_properties(
                language="en",
                name="Forest",
                set=has_property("name", "Zendikar Rising"),
                scryfall_id="e2ef9b74-481b-424b-8e33-f0b910f66370",
                is_front=True,
                highres_image=True,
            ),
            has_properties(
                language="en",
                name="Forest",
                set=has_property("name", "Doctor Who"),
                scryfall_id="15b3f35e-451e-4de6-a4f7-249287566964",
                is_front=True,
                highres_image=False,
            ),
        )
    )


@pytest.mark.parametrize("jsons, scryfall_id, filter_enabled, expected", [
    # Result set with size > 1. Return sets in release order.
    # Also, these three cards have three different printed names
    (["german_Ironroot_Treefolk_1", "german_Ironroot_Treefolk_2", "german_Ironroot_Treefolk_3"],
     "2520cb2b-47f2-4fb3-a9e7-17ad135562c8", False,
     [MTGSet("3ed", "Revised Edition"), MTGSet("4ed", "Fourth Edition"), MTGSet("5ed", "Fifth Edition")]),
    # De-duplicate results
    (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"],
     "d99a9a7d-d9ca-4c11-80ab-e39d5943a315", False,
     [MTGSet("mh2", "Modern Horizons 2")]),
    # Only offer sets the card is available in the same language as the source
    (["english_Back_to_Basics", "german_Back_to_Basics"],
     "97b84e7d-258f-46dc-baef-4b1eb6f28d4d", False,
     [MTGSet("usg", "Urza's Saga")]),
    # 1/1 colorless Spirit token offers both TNEO and TC16
    (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"],
     "5009729f-6365-42ca-979f-d854a10e463b", False,
     [MTGSet("tc16", "Commander 2016 Tokens"), MTGSet("tneo", "Kamigawa: Neon Dynasty Tokens")]),
    (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"],
     "ca20548f-6324-4858-adbe-87303ff1ca52", False,
     [MTGSet("tc16", "Commander 2016 Tokens"), MTGSet("tneo", "Kamigawa: Neon Dynasty Tokens")]),
    # 4/5 green Spirit token from TNEO only offers TNEO
    (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"],
     "0f48aaab-dd6e-4bcc-a8fb-d31dd4a098ba", False,
     [MTGSet("tneo", "Kamigawa: Neon Dynasty Tokens")]),
    # The first of these has placeholder images, making it affected by a printing filter
    (["german_Duress", "german_Duress_2"],
     "920e8a8f-3cb4-4f33-8a71-f2524cf63aaf", True,  # ID of the second printing from MID
     [MTGSet("mid", "Innistrad: Midnight Hunt")]),
    # Data of hidden printings present in the document must round-trip.
    # Steps to reproduce: Disable a card filter, add a card affected by it, then re-enable it.
    (["german_Duress", "german_Duress_2"],
     "51c6ec30-afb2-41e6-895b-92e070aa86f3", True,  # ID of the first printing from 7th Edition
     [MTGSet("7ed", "Seventh Edition"), MTGSet("mid", "Innistrad: Midnight Hunt")]),
    (["german_Duress"],
     "51c6ec30-afb2-41e6-895b-92e070aa86f3", True,
     [MTGSet("7ed", "Seventh Edition")]),
    
])
def test_get_available_sets_for_card(
        qtbot, card_db,
        jsons: StringList, scryfall_id: UUID, filter_enabled: bool, expected: typing.List[MTGSet]):
    fill_card_database_with_json_cards(qtbot, card_db, jsons)
    card = card_db.get_card_with_scryfall_id(scryfall_id, True)
    if filter_enabled:
        filters = {key: str(filter_enabled) for key in mtg_proxy_printer.settings.settings["card-filter"]}
        update_database_printing_filters(card_db, filters)
    assert_that(card, is_(not_none()), "Test setup failed, card not found")
    fulfills_matcher = all_of(has_length(len(expected)), contains_exactly(*expected)) if expected else empty()
    assert_that(card_db.get_available_sets_for_card(card), fulfills_matcher)


@pytest.mark.parametrize("jsons, scryfall_id, filter_enabled, expected", [
    # Actual two variants in the same set (regular & extended art)
    (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"],
     "d99a9a7d-d9ca-4c11-80ab-e39d5943a315", False, ["186", "463"]),
    # With enabled filters, the extended art variant is unavailable, thus should not be suggested
    (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"],
     "d99a9a7d-d9ca-4c11-80ab-e39d5943a315", True, ["186"]),
    # The German, regular card should not find the collector number of the English extended art variant.
    (["Asmoranomardicadaistinaculdacar_German", "Asmoranomardicadaistinaculdacar_2"],
     "e710a21a-65eb-4106-a379-57a86fb9e6c6", False, ["186"]),
    # The 1/1 Spirit token in TNEO has number 2
    (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"],
     "ca20548f-6324-4858-adbe-87303ff1ca52", False, ["2"]),
    # 4/5 green Spirit token in TNEO  has number 11
    (["Spirit_1_1_TNEO", "Spirit_1_1_TC16", "Spirit_4_5_TNEO"],
     "0f48aaab-dd6e-4bcc-a8fb-d31dd4a098ba", False, ["11"]),
    # Data of hidden printings present in the document must round-trip.
    # Steps to reproduce: Disable a card filter, add a card affected by it, then re-enable it.
    (["Asmoranomardicadaistinaculdacar", "Asmoranomardicadaistinaculdacar_2"],
     "2879f780-e17f-4e68-931e-6e45f9df28e1", True, ["186", "463"]),
    (["german_Duress", "german_Duress_2"],
     "51c6ec30-afb2-41e6-895b-92e070aa86f3", True,
     ["131"]),
    (["german_Duress"],
     "51c6ec30-afb2-41e6-895b-92e070aa86f3", True,
     ["131"]),
])
def test_get_available_collector_numbers_for_card_in_set(
        qtbot, card_db,
        jsons: StringList, scryfall_id: UUID, filter_enabled: bool, expected: StringList):
    fill_card_database_with_json_cards(qtbot, card_db, jsons)
    card = card_db.get_card_with_scryfall_id(scryfall_id, True)
    assert_that(card, is_(not_none()), "Setup failed. Card not found")
    if filter_enabled:
        filters = {key: str(filter_enabled) for key in mtg_proxy_printer.settings.settings["card-filter"]}
        update_database_printing_filters(card_db, filters)

    fulfills_matcher = all_of(has_length(len(expected)), contains_exactly(*expected)) if expected else empty()
    assert_that(card_db.get_available_collector_numbers_for_card_in_set(card), fulfills_matcher)
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






















































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Changes to tests/test_check_card_rendering.py.
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import pytest
from hamcrest import *
from PyQt5.QtGui import QPixmap, QColorConstants

from mtg_proxy_printer.model.carddb import Card, CheckCard, MTGSet
from mtg_proxy_printer.units_and_sizes import CardSizes


@pytest.fixture
def blank_image(qtbot) -> QPixmap:
    pixmap = QPixmap(CardSizes.REGULAR.as_qsize_px())
    pixmap.fill(QColorConstants.White)







|







14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import pytest
from hamcrest import *
from PyQt5.QtGui import QPixmap, QColorConstants

from mtg_proxy_printer.model.card import MTGSet, Card, CheckCard
from mtg_proxy_printer.units_and_sizes import CardSizes


@pytest.fixture
def blank_image(qtbot) -> QPixmap:
    pixmap = QPixmap(CardSizes.REGULAR.as_qsize_px())
    pixmap.fill(QColorConstants.White)
Deleted tests/test_document.py.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import copy
import typing
import unittest.mock

from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap
from hamcrest import *

from hamcrest import contains_exactly
import pytest
from pytestqt.qtbot import QtBot

from mtg_proxy_printer.units_and_sizes import PageType, unit_registry, UnitT, CardSizes, CardSize
from mtg_proxy_printer.model.carddb import Card, MTGSet
from mtg_proxy_printer.model.document import Document, PageColumns
from mtg_proxy_printer.model.page_layout import PageLayoutSettings

from mtg_proxy_printer.document_controller import DocumentAction
from mtg_proxy_printer.document_controller.new_document import ActionNewDocument
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.document_controller.edit_document_settings import ActionEditDocumentSettings

from tests.document_controller.helpers import append_new_card_in_page
from .document_controller.helpers import insert_card_in_page, create_card

ItemDataRole = Qt.ItemDataRole
mm: UnitT = unit_registry.mm
REGULAR = CardSizes.REGULAR
OVERSIZED = CardSizes.OVERSIZED


class DummyAction(DocumentAction):
    """A dummy DocumentAction that does nothing. apply() and undo() are replaced with MagicMock instances."""
    apply: unittest.mock.MagicMock
    undo: unittest.mock.MagicMock
    COMPARISON_ATTRIBUTES = ["value"]

    def __init__(self, value: int = 0):
        super().__init__()
        self.value = value
        self.apply = unittest.mock.MagicMock(return_value=self)
        self.undo = unittest.mock.MagicMock(return_value=self)

    @property
    def as_str(self):
        return f"Value: {self.value}"


@pytest.mark.parametrize("first, second, matcher", [
    (1, 1, is_),
    (0, 1, is_not),
])
def test_dummy_action_eq(first: int, second: int, matcher):
    a_1 = DummyAction(first)
    a_2 = DummyAction(second)
    assert_that(a_1, is_(equal_to(a_1)))
    assert_that(a_2, is_(equal_to(a_2)))
    assert_that(a_1, matcher(equal_to(a_2)))


def assert_unused(action: DummyAction):
    action.apply.assert_not_called()
    action.undo.assert_not_called()


def assert_applied(action: DummyAction, document: Document):
    action.apply.assert_called_once_with(document)
    action.undo.assert_not_called()


def assert_undone(action: DummyAction, document: Document):
    action.apply.assert_not_called()
    action.undo.assert_called_once_with(document)


def test_apply_on_empty_undo_stack_empty_redo_stack(qtbot: QtBot, document_light: Document):
    action = DummyAction()
    with qtbot.wait_signals([document_light.undo_available_changed, document_light.action_applied], timeout=1000), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.apply(action)

    assert_that(document_light.redo_stack, is_(empty()))
    assert_that(document_light.undo_stack, contains_exactly(action))

    assert_applied(action, document_light)


def test_apply_on_empty_undo_stack_filled_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(redo_dummy := DummyAction(1))
    action = DummyAction(0)
    with qtbot.wait_signals([
                document_light.undo_available_changed,
                document_light.redo_available_changed,
                document_light.action_applied], timeout=1000), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.apply(action)

    assert_that(document_light.redo_stack, is_(empty()))
    assert_that(document_light.undo_stack, contains_exactly(action))

    assert_unused(redo_dummy)
    assert_applied(action, document_light)


def test_apply_on_filled_undo_stack_empty_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.undo_stack.append(previous_action := DummyAction())
    action = DummyAction()
    with qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone), \
            qtbot.wait_signal(document_light.action_applied, timeout=1000):
        document_light.apply(action)

    assert_that(document_light.redo_stack, is_(empty()))
    assert_that(document_light.undo_stack, contains_exactly(previous_action, action))

    assert_unused(previous_action)
    assert_applied(action, document_light)


def test_apply_on_filled_undo_stack_filled_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.undo_stack.append(previous_action := DummyAction())
    document_light.redo_stack.append(redo_dummy := DummyAction(1))
    action = DummyAction(0)
    expected_signals = [document_light.redo_available_changed, document_light.action_applied]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.apply(action)

    assert_that(document_light.redo_stack, is_(empty()))
    assert_that(document_light.undo_stack, contains_exactly(previous_action, action))

    assert_unused(redo_dummy)
    assert_unused(previous_action)
    assert_applied(action, document_light)


def test_apply_same_action_as_on_redo_stack_does_keep_remaining_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(should_stay := DummyAction(2))
    document_light.redo_stack.append(DummyAction(1))
    action = DummyAction(1)
    expected_signals = [document_light.undo_available_changed, document_light.action_applied]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.apply(action)
    assert_that(document_light.redo_stack, contains_exactly(should_stay))
    assert_that(document_light.undo_stack, contains_exactly(action))


def test_undo_on_empty_redo_stack_2_elements_on_undo_stack(qtbot: QtBot, document_light: Document):
    document_light.undo_stack.append(first := DummyAction())
    document_light.undo_stack.append(second := DummyAction())
    expected_signals = [document_light.redo_available_changed, document_light.action_undone,]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_applied):
        document_light.undo()

    assert_that(document_light.undo_stack, contains_exactly(first))
    assert_that(document_light.redo_stack, contains_exactly(second))

    assert_unused(first)
    assert_undone(second, document_light)


def test_undo_on_empty_redo_stack_1_element_on_undo_stack(qtbot: QtBot, document_light: Document):
    document_light.undo_stack.append(first := DummyAction())
    expected_signals = [
        document_light.redo_available_changed, document_light.undo_available_changed, document_light.action_undone,
    ]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.action_applied):
        document_light.undo()

    assert_that(document_light.undo_stack, is_(empty()))
    assert_that(document_light.redo_stack, contains_exactly(first))

    assert_undone(first, document_light)


def test_undo_on_filled_redo_stack_1_element_on_undo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(redo_dummy := DummyAction())
    document_light.undo_stack.append(first := DummyAction())
    expected_signals = [document_light.undo_available_changed, document_light.action_undone,]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_applied):
        document_light.undo()

    assert_that(document_light.undo_stack, is_(empty()))
    assert_that(document_light.redo_stack, contains_exactly(redo_dummy, first))

    assert_unused(redo_dummy)
    assert_undone(first, document_light)


def test_undo_on_filled_redo_stack_2_elements_on_undo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(redo_dummy := DummyAction())
    document_light.undo_stack.append(first := DummyAction())
    document_light.undo_stack.append(second := DummyAction())

    with qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_applied), \
            qtbot.wait_signal(document_light.action_undone, timeout=1000):
        document_light.undo()

    assert_that(document_light.undo_stack, contains_exactly(first))
    assert_that(document_light.redo_stack, contains_exactly(redo_dummy, second))

    assert_unused(redo_dummy)
    assert_unused(first)
    assert_undone(second, document_light)


def test_redo_on_empty_undo_stack_1_element_on_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(first := DummyAction())
    expected_signals = [
        document_light.undo_available_changed, document_light.redo_available_changed, document_light.action_applied
    ]
    with qtbot.wait_signals(
            expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.redo()

    assert_that(document_light.redo_stack, is_(empty()))
    assert_that(document_light.undo_stack, contains_exactly(first))

    assert_applied(first, document_light)


def test_redo_on_empty_undo_stack_2_elements_on_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(first := DummyAction())
    document_light.redo_stack.append(second := DummyAction())

    expected_signals = [document_light.undo_available_changed, document_light.action_applied]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.redo()

    assert_that(document_light.redo_stack, contains_exactly(first))
    assert_that(document_light.undo_stack, contains_exactly(second))

    assert_unused(first)
    assert_applied(second, document_light)


def test_redo_on_filled_undo_stack_1_element_on_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(first := DummyAction())
    document_light.undo_stack.append(undo_dummy := DummyAction())
    expected_signals = [document_light.redo_available_changed, document_light.action_applied]
    with qtbot.wait_signals(expected_signals, timeout=1000), \
            qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone):
        document_light.redo()

    assert_that(document_light.undo_stack, contains_exactly(undo_dummy, first))
    assert_that(document_light.redo_stack, is_(empty()))

    assert_unused(undo_dummy)
    assert_applied(first, document_light)


def test_redo_on_filled_undo_stack_2_elements_on_redo_stack(qtbot: QtBot, document_light: Document):
    document_light.redo_stack.append(first := DummyAction())
    document_light.redo_stack.append(second := DummyAction())
    document_light.undo_stack.append(undo_dummy := DummyAction())

    with qtbot.assert_not_emitted(document_light.undo_available_changed), \
            qtbot.assert_not_emitted(document_light.redo_available_changed), \
            qtbot.assert_not_emitted(document_light.action_undone), \
            qtbot.wait_signal(document_light.action_applied, timeout=1000):
        document_light.redo()

    assert_that(document_light.undo_stack, contains_exactly(undo_dummy, second))
    assert_that(document_light.redo_stack, contains_exactly(first))

    assert_unused(undo_dummy)
    assert_unused(first)
    assert_applied(second, document_light)


@pytest.mark.parametrize("additional_pages", range(3))
def test_rowCount_without_index_parameter_return_page_count(document_light, additional_pages: int):
    if additional_pages:
        document_light.apply(ActionNewPage(count=additional_pages))
    assert_that(document_light.pages, has_length(1+additional_pages), "Test setup failed")
    assert_that(document_light.rowCount(), is_(1+additional_pages), "Wrong rowCount() returned")


def test_rowCount_with_valid_index_returns_card_count_on_page_given_by_index(document_light):
    document_light.apply(ActionNewPage(count=3))
    for count in range(1, 4):
        document_light.set_currently_edited_page(document_light.pages[count])
        card = Card("", MTGSet("", ""), "", "", "", True, "", "", True, REGULAR, 0, None)
        document_light.apply(ActionAddCard(card, count=count))
        assert_that(document_light.currently_edited_page, has_length(count), "Test setup failed")
    for page in range(4):
        assert_that(
            document_light.rowCount(document_light.index(page, 0)),
            is_(equal_to(page)),
            f"Wrong rowCount() returned for page {page}"
        )


@pytest.mark.parametrize("page_type, parent_row, child_rows", [
    (PageType.REGULAR, 0, [0]),
    (PageType.OVERSIZED, 2, [0, 1]),
])
def test_get_card_indices_of_type(document_light, page_type: PageType, parent_row: int, child_rows: typing.List[int]):
    ActionNewPage(count=2).apply(document_light)
    append_new_card_in_page(document_light.pages[0], "Normal", REGULAR)
    append_new_card_in_page(document_light.pages[2], "Oversized", OVERSIZED)
    append_new_card_in_page(document_light.pages[2], "Oversized", OVERSIZED)
    indices = list(document_light.get_card_indices_of_type(page_type))
    assert_that(indices, has_length(len(child_rows)))
    for index, expected_row in zip(indices, child_rows):
        assert_that(index.row(), is_(expected_row))
        assert_that(index.parent().row(), is_(parent_row))
        card: Card = index.data(ItemDataRole.UserRole)
        assert_that(card.requested_page_type(), is_(page_type))


@pytest.fixture
def document_custom_layout(document: Document) -> Document:
    custom_layout = PageLayoutSettings(
        page_height=300*mm, page_width=200*mm,
        margin_top=20*mm, margin_bottom=19*mm, margin_left=18*mm, margin_right=17*mm,
        row_spacing=3*mm, column_spacing=2*mm, card_bleed=1*mm,
        draw_cut_markers=True, draw_sharp_corners=False,
    )
    document.apply(ActionEditDocumentSettings(custom_layout))
    yield document
    document.__dict__.clear()


def test_document_reset_clears_modified_page_layout(qtbot: QtBot, document_custom_layout: Document):
    default_layout = PageLayoutSettings.create_from_settings()
    assert_that(
        document_custom_layout,
        has_property("page_layout", not_(equal_to(default_layout)))
    )
    assert_that(
        document_custom_layout.page_layout.compute_page_row_count(),
        is_not(equal_to(default_layout.compute_page_card_capacity())),
        "Test setup failed."
    )
    with qtbot.waitSignal(document_custom_layout.page_layout_changed, timeout=1000):
        document_custom_layout.apply(ActionNewDocument())

    assert_that(
        document_custom_layout,
        has_property("page_layout", equal_to(default_layout))
    )


def test_document_is_created_empty(document_light: Document):
    capacity = document_light.page_layout.compute_page_card_capacity()
    assert_that(capacity, is_(greater_than_or_equal_to(1)))
    assert_that(document_light.rowCount(), is_(equal_to(1)), "Expected creation of a single, empty page.")
    assert_that(
        document_light.pages,
        contains_exactly(empty()),
        "Expected creation of a single, empty page."
    )
    assert_that(
        document_light.rowCount(document_light.index(0, 0)), is_(equal_to(0)),
        "Expected empty page, but it is not empty")
    assert_that(
        document_light.pages[0].page_type(), is_(PageType.UNDETERMINED),
        "Empty page should have an undetermined page type"
    )




@pytest.mark.parametrize("size", [REGULAR, OVERSIZED])
def test_get_missing_image_cards(document_light: Document, size: CardSize):
    blank_image = document_light.image_db.get_blank(size)
    expected = create_card("Placeholder Image", size, "https://someurl", blank_image)
    # Create a new, distinct image by copying the blank image
    unexpected = create_card("Other Image", size, "", QPixmap(blank_image))
    document_light.apply(ActionAddCard(expected, 2))
    document_light.apply(ActionAddCard(unexpected, 2))
    assert_that(
        result := list(document_light.get_missing_image_cards()),
        has_length(2)
    )
    cards = [i.data(ItemDataRole.UserRole) for i in result]
    assert_that(cards, only_contains(expected))

@pytest.mark.parametrize("size", [REGULAR, OVERSIZED])
@pytest.mark.parametrize("result", [True, False])
def test_has_missing_images(document_light: Document, result: bool, size: CardSize):
    blank_image = document_light.image_db.get_blank(CardSizes.REGULAR)
    blank_image_card = create_card("Placeholder Image", size, "https://someurl", blank_image)
    # Create a new, distinct image by copying the blank image
    other_card = create_card("Other Image", size, "", QPixmap(blank_image))
    if result:
        document_light.apply(ActionAddCard(blank_image_card, 2))
    document_light.apply(ActionAddCard(other_card, 2))
    assert_that(
        document_light.has_missing_images(),
        is_(result)
    )


@pytest.mark.parametrize("pages_content, expected", [
    ([], 0),
    ([None, None], 1),
    ([create_card("Regular", REGULAR)], 0),
    ([create_card("Regular", REGULAR)]*2, 1),
    ([create_card("Regular", REGULAR), create_card("Oversized", OVERSIZED)], 0),
    ([create_card("Regular", REGULAR), create_card("Oversized", OVERSIZED)]*2, 2),
    ([create_card("Regular", REGULAR), create_card("Oversized", OVERSIZED), None]*2, 4),
])
def test_compute_pages_saved_by_compacting(
        document_light: Document, pages_content: typing.List[typing.Optional[Card]], expected: int):
    if len(pages_content) > 1:
        document_light.apply(ActionNewPage(count=len(pages_content)-1))
    for page, card in zip(document_light.pages, pages_content):
        if card is not None:
            insert_card_in_page(page, card)
    assert_that(
        document_light.compute_pages_saved_by_compacting(),
        is_(equal_to(expected))
    )


def test_update_page_layout_copies_the_passed_in_instance(document_light: Document):
    layout = copy.copy(document_light.page_layout)
    layout.row_spacing = 1*mm
    document_light.apply(ActionEditDocumentSettings(layout))
    layout.row_spacing = 2*mm
    assert_that(document_light.page_layout, has_property("row_spacing", equal_to(1*mm)))


@pytest.mark.parametrize("invalid_page_row", [2])
def test_document__data_page_logs_error_on_invalid_index(document_light, invalid_page_row: int):
    index = document_light.createIndex(invalid_page_row, 0, None)
    with unittest.mock.patch("mtg_proxy_printer.model.document.logger.error") as logger_mock:
        assert_that(document_light._data_page(index), is_(None))
        logger_mock.assert_called_once()


@pytest.mark.parametrize("invalid_card_row", [2])
def test_document__data_card_logs_error_on_invalid_index_row(document_light, invalid_card_row: int):
    append_new_card_in_page(document_light.pages[0], "Card")
    index = document_light.createIndex(invalid_card_row, 0, document_light.pages[0][0])
    with unittest.mock.patch("mtg_proxy_printer.model.document.logger.error") as logger_mock:
        assert_that(document_light._data_card(index), is_(None))
        logger_mock.assert_called_once()


@pytest.mark.parametrize("invalid_card_column", [len(PageColumns)])
def test_document__data_card_logs_error_on_invalid_index_column(document_light, invalid_card_column: int):
    append_new_card_in_page(document_light.pages[0], "Card")
    index = document_light.createIndex(0, invalid_card_column, document_light.pages[0][0])
    with unittest.mock.patch("mtg_proxy_printer.model.document.logger.error") as logger_mock:
        assert_that(document_light._data_card(index), is_(None))
        logger_mock.assert_called_once()
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<




































































































































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted tests/test_document_loader.py.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import contextlib
from itertools import chain, repeat, product
from pathlib import Path
import sqlite3
import tempfile
import unittest.mock
import textwrap


import pint
from pytestqt.qtbot import QtBot
import pytest
from hamcrest import *

import mtg_proxy_printer.model.document_loader
from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument
from tests.helpers import quantity_close_to
from mtg_proxy_printer.units_and_sizes import PageType, unit_registry, UnitT, CardSizes, QuantityT
from mtg_proxy_printer.model.carddb import CheckCard
import mtg_proxy_printer.sqlite_helpers
from mtg_proxy_printer.model.document import PageColumns, Document
from mtg_proxy_printer.model.page_layout import PageLayoutSettings

CardType = mtg_proxy_printer.model.document_loader.CardType
mm: UnitT = unit_registry.mm


@pytest.mark.parametrize("user_version", [-1, 0, 1, 8, 9])
def test_unknown_save_version_raises_exception(empty_save_database: sqlite3.Connection, user_version: int):
    empty_save_database.execute(f"PRAGMA user_version = {user_version};")
    assert_that(empty_save_database.execute("PRAGMA user_version").fetchone()[0], is_(user_version))
    worker = mtg_proxy_printer.model.document_loader.Worker
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock:
        mock.return_value = empty_save_database
        assert_that(
            calling(worker._open_validate_and_migrate_save_file).with_args(Path()),
            raises(AssertionError)
        )
        mock.assert_called_once()


def assert_document_is_empty(document: Document):
    assert_that(document.rowCount(), is_(equal_to(1)))
    page_index = document.index(0, 0)
    assert_that(page_index.isValid())
    assert_that(document.rowCount(page_index), is_(0))


@contextlib.contextmanager
def disabled_check_constraints(db: sqlite3.Connection):
    """
    Instruct SQLite3 to ignore the SQL CHECK constraints defined in the database schema for a limited timeframe.
    """
    db.execute("PRAGMA ignore_check_constraints = TRUE;")
    yield db
    db.execute("PRAGMA ignore_check_constraints = FALSE;")


def test_document_with_card_loads_correctly(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection):
    empty_save_database.execute(
        "INSERT INTO Page (page, image_size) VALUES (?, ?)",
        (1, CardSizes.REGULAR.to_save_data())
    )
    empty_save_database.execute(
        'INSERT INTO "Card" (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)',
        (1, 1, 1, "0000579f-7b35-4ed3-b44c-db2a538066fe", "r")
    )
    page_layout = mtg_proxy_printer.model.document_loader.PageLayoutSettings(
        page_height=300*mm, page_width=200*mm,
        margin_top=20*mm, margin_bottom=19*mm, margin_left=18*mm, margin_right=17*mm,
        row_spacing=3*mm, column_spacing=2*mm, card_bleed=1*mm,
        draw_cut_markers=True, draw_sharp_corners=False,
    )
    assert_that(page_layout.compute_page_card_capacity(PageType.OVERSIZED), is_(greater_than_or_equal_to(1)))
    ActionSaveDocument.save_settings(empty_save_database, page_layout)
    loader = document.loader
    save_path = Path("/tmp/invalid.mtgproxies")
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignals([loader.loading_state_changed]*2,
                               check_params_cbs=[(lambda value: value), (lambda value: not value)]), \
                qtbot.waitSignals([loader.load_requested, document.page_layout_changed]):
            loader.load_document(save_path)
    mock.assert_called_once()
    assert_that(document.rowCount(), is_(equal_to(1)))
    page_index = document.index(0, 0)
    assert_that(page_index.isValid())
    assert_that(document.rowCount(page_index), is_(1))
    assert_that(
        document.index(0, PageColumns.CardName, page_index).data(),
        is_("Fury Sliver")
    )
    assert_that(document.save_file_path, is_(equal_to(save_path)))
    assert_that(document.page_layout, is_(equal_to(page_layout)))
    assert_that(
        document.page_layout.compute_page_card_capacity(PageType.REGULAR),
        is_(equal_to(page_layout.compute_page_card_capacity(PageType.REGULAR)))
    )
    assert_that(
        document.page_layout.compute_page_card_capacity(PageType.OVERSIZED),
        is_(equal_to(page_layout.compute_page_card_capacity(PageType.OVERSIZED)))
    )

def test_empty_document_loads_correctly(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection):
    page_layout = mtg_proxy_printer.model.document_loader.PageLayoutSettings(
        page_height=300*mm, page_width=200*mm,
        margin_top=20*mm, margin_bottom=19*mm, margin_left=18*mm, margin_right=17*mm,
        row_spacing=3*mm, column_spacing=2*mm, card_bleed=1*mm,
        draw_cut_markers=True, draw_sharp_corners=False,
    )
    assert_that(page_layout.compute_page_card_capacity(PageType.OVERSIZED), is_(greater_than_or_equal_to(1)))
    ActionSaveDocument.save_settings(empty_save_database, page_layout)
    loader = document.loader
    save_path = Path("/tmp/invalid.mtgproxies")
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignals([loader.loading_state_changed]*2,
                               check_params_cbs=[(lambda value: value), (lambda value: not value)]), \
                qtbot.waitSignals([loader.load_requested, document.page_layout_changed]), \
                qtbot.assert_not_emitted(loader.loading_file_failed):
            loader.load_document(save_path)
    mock.assert_called_once()
    assert_that(document.rowCount(), is_(equal_to(1)))
    page_index = document.index(0, 0)
    assert_that(page_index.isValid())
    assert_that(document.rowCount(page_index), is_(0))
    assert_that(document.save_file_path, is_(equal_to(save_path)))
    assert_that(document.page_layout, is_(equal_to(page_layout)))
    assert_that(
        document.page_layout.compute_page_card_capacity(PageType.REGULAR),
        is_(equal_to(page_layout.compute_page_card_capacity(PageType.REGULAR)))
    )
    assert_that(
        document.page_layout.compute_page_card_capacity(PageType.OVERSIZED),
        is_(equal_to(page_layout.compute_page_card_capacity(PageType.OVERSIZED)))
    )


def test_document_with_mixed_pages_distributes_cards_based_on_size(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection):
    empty_save_database.execute(
        "INSERT INTO Page (page, image_size) VALUES (?, ?)",
        (1, CardSizes.REGULAR.to_save_data())
    )
    empty_save_database.executemany(
        'INSERT INTO "Card" (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)', [
            (1, 1, 1, "0000579f-7b35-4ed3-b44c-db2a538066fe", "r"),
            (1, 2, 1, "650722b4-d72b-4745-a1a5-00a34836282b", "r"),
         ]
    )
    ActionSaveDocument.save_settings(empty_save_database, PageLayoutSettings.create_from_settings())
    loader = document.loader
    save_path = Path("/tmp/invalid.mtgproxies")
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignals([loader.loading_state_changed] * 2,
                               check_params_cbs=[(lambda value: value), (lambda value: not value)]), \
                qtbot.waitSignals([loader.load_requested]):
            loader.load_document(save_path)
        mock.assert_called_once()
    assert_that(document.rowCount(), is_(2))
    total_cards = 0
    for page in document.pages:
        assert_that(page.page_type(), is_in([PageType.OVERSIZED, PageType.REGULAR]))
        total_cards += len(page)
    assert_that(total_cards, is_(2))


@pytest.mark.parametrize("data", chain(
    # Syntactically invalid
    zip([-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR.value)),
    zip(repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR.value)),
    zip(repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.REGULAR.value)),
    zip(repeat(1), repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(CardType.REGULAR.value)),
    zip([-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD.value)),
    zip(repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD.value)),
    zip(repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), repeat(CardType.CHECK_CARD.value)),
    zip(repeat(1), repeat(1), repeat(1), [-1, 1.3, -1000.2, "", "ABC", b"binary"], repeat(CardType.CHECK_CARD.value)),
    # Semantically invalid, as type "d" it means generating a DFC check card for a single sided card.
    zip(repeat(1), repeat(1), repeat(1), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), [-1, 1.3, -1000.2, "", b"binary", CardType.CHECK_CARD.value]),
    zip(repeat(1), repeat(1), repeat(0), repeat("0000579f-7b35-4ed3-b44c-db2a538066fe"), [-1, 1.3, -1000.2, "", b"binary", CardType.CHECK_CARD.value]),
))
def test_invalid_data_in_card_columns_raises_exception(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection, data):
    # Replace the Card table with one that has no implicit type casting
    empty_save_database.execute("DROP TABLE Card")
    empty_save_database.execute("CREATE TABLE Card (page BLOB, slot BLOB, is_front BLOB, scryfall_id BLOB, type BLOB)")
    empty_save_database.execute('INSERT INTO Card (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)', data)
    assert_that(
        empty_save_database.execute("SELECT page, slot, is_front, scryfall_id, type FROM Card").fetchall(),
        contains_exactly(equal_to(data)),
        "Setup failed: Data mismatch"
    )
    loader = document.loader
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignal(loader.loading_file_failed, raising=True), \
                qtbot.assertNotEmitted(loader.load_requested):
            loader.load_document(Path("/tmp/invalid.mtgproxies"))
        mock.assert_called_once()
    assert_document_is_empty(document)
    assert_that(document.save_file_path, is_(none()))


def test_protects_against_infinite_save_data(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection):
    empty_save_database.execute("DROP TABLE Card")
    # LIMIT clause in the definition below is a safety measure.
    empty_save_database.execute(textwrap.dedent("""\
        CREATE VIEW Card (page, slot, scryfall_id, is_front) AS 
            WITH RECURSIVE card_gen (page, slot, scryfall_id, is_front) AS (
                SELECT 1, 1, '0000579f-7b35-4ed3-b44c-db2a538066fe', 1
                UNION ALL 
                SELECT 1, 1, '0000579f-7b35-4ed3-b44c-db2a538066fe', 1
                FROM card_gen
                LIMIT 100000
            )
        SELECT * FROM card_gen
        """))
    loader = document.loader
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignal(loader.loading_file_failed, raising=True), \
                qtbot.assertNotEmitted(loader.load_requested):
            loader.load_document(Path("/tmp/invalid.mtgproxies"))
        mock.assert_called_once()
    assert_document_is_empty(document)
    assert_that(document.save_file_path, is_(none()))


def generate_test_cases_for_test_protects_against_infinite_settings_data():
    # LIMIT clause in the definitions below are safety measures.
    yield 4, textwrap.dedent("""\
        CREATE VIEW DocumentSettings (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers) AS 
        WITH RECURSIVE settings_gen (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers
        ) AS (
            SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1
            UNION ALL 
            SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1
            FROM settings_gen
            LIMIT 100000
            )
        SELECT * FROM settings_gen
        """)
    yield 5, textwrap.dedent("""\
        CREATE VIEW DocumentSettings (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners) AS 
        WITH RECURSIVE settings_gen (
          rowid, page_height, page_width,
          margin_top, margin_bottom, margin_left, margin_right,
          image_spacing_horizontal, image_spacing_vertical, draw_cut_markers, draw_sharp_corners
        ) AS (
            SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1
            UNION ALL 
            SELECT 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1
            FROM settings_gen
            LIMIT 100000
            )
        SELECT * FROM settings_gen
        """)
    yield 6, textwrap.dedent("""\
        CREATE VIEW DocumentSettings (key, value) AS 
        WITH RECURSIVE settings_gen (
          key, value
        ) AS (
            SELECT 'key', 'something'
            UNION ALL 
            SELECT 'key', 'something'
            FROM settings_gen
            LIMIT 100000
            )
        SELECT * FROM settings_gen
        """)


@pytest.mark.parametrize("user_version, script", generate_test_cases_for_test_protects_against_infinite_settings_data())
def test_protects_against_infinite_settings_data(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection, user_version: int, script: str):
    empty_save_database.execute(f"PRAGMA user_version = {user_version}")
    empty_save_database.execute("DROP TABLE DocumentSettings")
    empty_save_database.execute(script)
    loader = document.loader
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as mock:
        mock.return_value = empty_save_database
        with qtbot.waitSignal(loader.loading_file_failed, raising=True), \
                qtbot.assertNotEmitted(loader.load_requested):
            loader.load_document(Path("/tmp/invalid.mtgproxies"))
        mock.assert_called_once()
    assert_document_is_empty(document)
    assert_that(document.save_file_path, is_(none()))


def test_cancelling_loading_does_not_crash(
        qtbot: QtBot, document: Document,
        empty_save_database: sqlite3.Connection):
    ActionSaveDocument.save_settings(empty_save_database, document.page_layout)
    empty_save_database.execute(
        "INSERT INTO Page (page, image_size) VALUES (?, ?)", (1, CardSizes.REGULAR.to_save_data())
    )
    empty_save_database.executemany(
        'INSERT INTO "Card" (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)', [
            (1, 1, 1, "0000579f-7b35-4ed3-b44c-db2a538066fe", "r"),
            (1, 2, 1, "650722b4-d72b-4745-a1a5-00a34836282b", "r"),
        ]
    )
    loader = document.loader
    loader.begin_loading_loop.connect(loader.cancel)
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as open_database:
        open_database.return_value = empty_save_database
        with qtbot.wait_signals(
                [loader.begin_loading_loop, loader.progress_loading_loop,
                 loader.loading_state_changed, loader.finished], timeout=100):
            loader.load_document(Path("/tmp/invalid.mtgproxies"))


def test_loads_check_card(
        qtbot: QtBot, document: Document, empty_save_database: sqlite3.Connection):
    ActionSaveDocument.save_settings(empty_save_database, document.page_layout)
    empty_save_database.execute(
        "INSERT INTO Page (page, image_size) VALUES (?, ?)", (1, CardSizes.REGULAR.to_save_data())
    )
    empty_save_database.executemany(
        'INSERT INTO "Card" (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)', [
            (1, 1, 1, "b3b87bfc-f97f-4734-94f6-e3e2f335fc4d", CardType.CHECK_CARD.value),
         ]
    )
    loader = document.loader
    with unittest.mock.patch("mtg_proxy_printer.model.document_loader.open_database") as open_database:
        open_database.return_value = empty_save_database
        with qtbot.wait_signal(document.action_applied), \
                qtbot.assert_not_emitted(loader.loading_file_failed):
            loader.load_document(Path("/tmp/invalid.mtgproxies"))
    assert_that(
        document.pages, contains_exactly(
            contains_exactly(has_property("card", all_of(
                instance_of(CheckCard),
                has_properties({
                    "image_file": not_none(),
                    "name": "Growing Rites of Itlimoc // Itlimoc, Cradle of the Sun",
                    "is_front": True,
                    "is_dfc": False,
                })
            )))
        )
    )


@pytest.fixture(params=product([
    (4, [1, 200, 150, 4, 5, 6, 7, 2, 3, 1]),
    (5, [1, 200, 150, 4, 5, 6, 7, 2, 3, 1, 0]),
    # Only old image spacing keys present
    (6, [("document_name", ""), ("draw_cut_markers", 1), ("draw_page_numbers", 0), ("draw_sharp_corners", 0),
         ("image_spacing_horizontal", 2), ("image_spacing_vertical", 3), ("margin_top", 4), ("margin_bottom", 5),
         ("margin_left", 6), ("margin_right", 7), ("page_height", 200), ("page_width", 150)]),
    # Old and new image spacing keys present. This should never exist in the wild. Ensure that the new keys are used.
    (6, [("document_name", ""), ("draw_cut_markers", 1), ("draw_page_numbers", 0), ("draw_sharp_corners", 0),
         ("image_spacing_horizontal", 8), ("image_spacing_vertical", 9), ("margin_top", 4), ("margin_bottom", 5),
         ("margin_left", 6), ("margin_right", 7), ("page_height", 200), ("page_width", 150),
         ("row_spacing", 2), ("column_spacing", 3)]),

    ], [True, False]))
def legacy_save_file(request):
    (save_version, settings), reverse_unordered = request.param  # type: (int, list), bool
    db = mtg_proxy_printer.sqlite_helpers.open_database(":memory:", f"document-v{save_version}", False)
    if save_version < 6:
        db.execute(f"INSERT INTO DocumentSettings VALUES ({', '.join('?'*len(settings))})", settings)
    elif save_version == 6:
        db.executemany("INSERT INTO DocumentSettings (key, value) VALUES (?, ?)", settings)
    else:
        pass
    if reverse_unordered:
        db.execute("PRAGMA reverse_unordered_selects = TRUE")
    yield db
    db.close()
    del db


def test_load_settings_from_legacy_save_file_is_successful(
        qtbot: QtBot, legacy_save_file: sqlite3.Connection, document_light: Document):
    loader = document_light.loader
    with unittest.mock.patch(
            "mtg_proxy_printer.model.document_loader.open_database",
            return_value=legacy_save_file), \
            qtbot.wait_signal(document_light.action_applied, timeout=2**30), \
            qtbot.assert_not_emitted(loader.loading_file_failed):
        loader.load_document(Path("/tmp/invalid.mtgproxies"))
    annotations = document_light.page_layout.__annotations__
    assert_that(
        document_light.page_layout,
        has_properties({
            item: instance_of(pint.Quantity if value is QuantityT else value)
            for item, value in annotations.items()
        })
    )
    assert_that(document_light.page_layout, has_properties({
        "document_name": "", "draw_cut_markers": True, "draw_page_numbers": False, "draw_sharp_corners": False,
        "row_spacing": quantity_close_to(2*mm), "column_spacing": quantity_close_to(3*mm),
        "margin_top": quantity_close_to(4*mm), "margin_bottom": quantity_close_to(5*mm),
        "margin_left": quantity_close_to(6*mm), "margin_right": quantity_close_to(7*mm),
        "page_height": quantity_close_to(200*mm), "page_width": quantity_close_to(150*mm)
    }))


@pytest.mark.parametrize("title", ["str", "", "1", "0x1", "1.0.0", "1..0", "01", "1.0"])
def test_load_correctly_sets_document_title(
        qtbot: QtBot, empty_save_database: sqlite3.Connection, document_light: Document, title: str):
    loader = document_light.loader
    annotations = document_light.page_layout.__annotations__
    ActionSaveDocument.save_settings(empty_save_database, document_light.page_layout)
    empty_save_database.execute(
        "UPDATE DocumentSettings SET value = ? WHERE key = ?",
        (title, "document_name"))

    with unittest.mock.patch(
            "mtg_proxy_printer.model.document_loader.open_database",
            return_value=empty_save_database), \
            qtbot.wait_signal(document_light.action_applied), \
            qtbot.assert_not_emitted(loader.loading_file_failed):
        loader.load_document(Path("/tmp/invalid.mtgproxies"))

    assert_that(
        document_light.page_layout,
        has_properties({
            item: instance_of(pint.Quantity if value is QuantityT else value)
            for item, value in annotations.items()
        })
    )
    assert_that(document_light.page_layout, has_property("document_name", equal_to(title)))
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
























































































































































































































































































































































































































































































































































































































































































































































































































































































































































Deleted tests/test_image_db.py.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#  Copyright © 2020-2025  Thomas Hess <thomas.hess@udo.edu>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import io

import pytest
from PyQt5.QtCore import QBuffer, QIODevice
from PyQt5.QtGui import QPixmap
from hamcrest import *
from pytestqt.qtbot import QtBot

from mtg_proxy_printer.units_and_sizes import CardSizes, CardSize
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.imagedb_files import ImageKey

from tests.hasgetter import has_getter


def qpixmap_to_bytes_io(pixmap: QPixmap) -> io.BytesIO:
    buffer = QBuffer()
    buffer.open(QIODevice.OpenModeFlag.WriteOnly)
    pixmap.save(buffer, "PNG", quality=100)
    image = buffer.data().data()
    return io.BytesIO(image)


DOWNLOADER = "mtg_proxy_printer.model.imagedb.ImageDownloader"


def test_delete_disk_cache_entries_removes_empty_parent_directories(qtbot: QtBot, image_db: ImageDatabase):
    # Setup
    keys = [
        ImageKey("7ef83f4c-d3ff-4905-a16d-f2bae673a5b2", True, True),
        ImageKey("7ef83f4c-abcd-abcd-9876-1234567890ab", True, True),  # Same prefix
    ]
    blank_image_file = qpixmap_to_bytes_io(image_db.get_blank())
    for key in keys:
        path = image_db.db_path / key.format_relative_path()
        path.parent.mkdir(exist_ok=True, parents=True)
        path.write_bytes(blank_image_file.read())
    image_db.images_on_disk.update(keys)

    # Test
    image_db.delete_disk_cache_entries([keys[0]])
    assert_that((image_db.db_path / keys[0].format_relative_path()).is_file(), is_(False))
    assert_that((image_db.db_path / keys[1].format_relative_path()).is_file(), is_(True))
    assert_that((image_db.db_path / keys[0].format_relative_path()).parent.is_dir(), is_(True))
    image_db.delete_disk_cache_entries([keys[1]])
    assert_that((image_db.db_path / keys[1].format_relative_path()).is_file(), is_(False))
    assert_that((image_db.db_path / keys[0].format_relative_path()).parent.is_dir(), is_(False))


@pytest.mark.parametrize("size", [CardSizes.REGULAR, CardSizes.OVERSIZED])
def test_get_blank(image_db: ImageDatabase, size: CardSize):
    image = image_db.get_blank(size)
    assert_that(image, is_(instance_of(QPixmap)))
    assert_that(image, has_getter("size", equal_to(size.as_qsize_px())))
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<












































































































































Changes to tests/test_page_layout_settings.py.
11
12
13
14
15
16
17

18
19
20
21
22
23
24
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.

import itertools
import unittest.mock


import pint

import mtg_proxy_printer.settings
import mtg_proxy_printer.model.document
import mtg_proxy_printer.model.document_loader
from mtg_proxy_printer.units_and_sizes import PageType, QuantityT, UnitT, unit_registry, StrDict







>







11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.

import itertools
import unittest.mock
from multiprocessing.context import assert_spawning

import pint

import mtg_proxy_printer.settings
import mtg_proxy_printer.model.document
import mtg_proxy_printer.model.document_loader
from mtg_proxy_printer.units_and_sizes import PageType, QuantityT, UnitT, unit_registry, StrDict
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

from tests.hasgetter import has_getters
from tests.helpers import quantity_close_to

mm: UnitT = unit_registry.mm


@pytest.fixture
def page_layout():
    layout = PageLayoutSettings.create_from_settings()
    return layout


@pytest.mark.parametrize("page_type, expected", [
    (PageType.OVERSIZED, 4),
    (PageType.REGULAR, 9),
    (PageType.MIXED, 9),
    (PageType.UNDETERMINED, 9),
])







<
<
<
<
<







33
34
35
36
37
38
39





40
41
42
43
44
45
46

from tests.hasgetter import has_getters
from tests.helpers import quantity_close_to

mm: UnitT = unit_registry.mm








@pytest.mark.parametrize("page_type, expected", [
    (PageType.OVERSIZED, 4),
    (PageType.REGULAR, 9),
    (PageType.MIXED, 9),
    (PageType.UNDETERMINED, 9),
])
82
83
84
85
86
87
88



89
90
91
92
93
94
95
    # because there is no spacing with one row
    (1000*mm, 1, PageType.REGULAR),
    (1000*mm, 1, PageType.UNDETERMINED),
    (1000*mm, 1, PageType.OVERSIZED),
])
def test_page_layout_compute_page_row_count(
        page_layout: PageLayoutSettings, page_type: PageType, row_spacing: QuantityT, expected: int):



    page_layout.row_spacing = row_spacing
    assert_that(page_layout.compute_page_row_count(page_type), is_(equal_to(expected)))


def test_page_layout_compute_compute_page_row_count_default_value(page_layout: PageLayoutSettings):
    assert_that(page_layout.compute_page_row_count(), is_(equal_to(3)))








>
>
>







78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
    # because there is no spacing with one row
    (1000*mm, 1, PageType.REGULAR),
    (1000*mm, 1, PageType.UNDETERMINED),
    (1000*mm, 1, PageType.OVERSIZED),
])
def test_page_layout_compute_page_row_count(
        page_layout: PageLayoutSettings, page_type: PageType, row_spacing: QuantityT, expected: int):
    assert_that(page_layout.page_height, quantity_close_to(297*mm), "Setup failed: Environment altered")
    assert_that(page_layout.margin_top, quantity_close_to(5*mm), "Setup failed: Environment altered")
    assert_that(page_layout.margin_bottom, quantity_close_to(5*mm), "Setup failed: Environment altered")
    page_layout.row_spacing = row_spacing
    assert_that(page_layout.compute_page_row_count(page_type), is_(equal_to(expected)))


def test_page_layout_compute_compute_page_row_count_default_value(page_layout: PageLayoutSettings):
    assert_that(page_layout.compute_page_row_count(), is_(equal_to(3)))

133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
    assert_that(calling(page_layout.__gt__).with_args(1), raises(TypeError))


def test_page_layout_lt_raises_type_error_on_incompatible_types(page_layout: PageLayoutSettings):
    assert_that(calling(page_layout.__lt__).with_args(1), raises(TypeError))


def test_page_layout_gt():
    layout = mtg_proxy_printer.model.document_loader.PageLayoutSettings()
    layout.page_height = 300*mm
    assert_that(layout.compute_page_card_capacity(PageType.REGULAR), is_(0))
    assert_that(layout, is_not(greater_than(layout)))


def test_page_layout_lt():
    layout = mtg_proxy_printer.model.document_loader.PageLayoutSettings.create_from_settings()
    assert_that(layout.compute_page_card_capacity(PageType.REGULAR), is_(9))
    assert_that(layout, is_not(less_than(layout)))


@pytest.mark.parametrize("values", [
    {
        "paper-height": "200 mm",
        "paper-width": "100 mm",
        "margin-top": "9 mm",







|
<
|
|
|


|
<
|
|







132
133
134
135
136
137
138
139

140
141
142
143
144
145

146
147
148
149
150
151
152
153
154
    assert_that(calling(page_layout.__gt__).with_args(1), raises(TypeError))


def test_page_layout_lt_raises_type_error_on_incompatible_types(page_layout: PageLayoutSettings):
    assert_that(calling(page_layout.__lt__).with_args(1), raises(TypeError))


def test_page_layout_gt(page_layout: PageLayoutSettings):

    page_layout.page_width = 10*mm
    assert_that(page_layout.compute_page_card_capacity(PageType.REGULAR), is_(0))
    assert_that(page_layout, is_not(greater_than(page_layout)))


def test_page_layout_lt(page_layout: PageLayoutSettings):

    assert_that(page_layout.compute_page_card_capacity(PageType.REGULAR), is_(9))
    assert_that(page_layout, is_not(less_than(page_layout)))


@pytest.mark.parametrize("values", [
    {
        "paper-height": "200 mm",
        "paper-width": "100 mm",
        "margin-top": "9 mm",
Changes to tests/test_save_file_migrations.py.
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
import pytest
from hamcrest import *

import mtg_proxy_printer.model.document_loader
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
import mtg_proxy_printer.model.document
import mtg_proxy_printer.sqlite_helpers
from mtg_proxy_printer.model.document_loader import SAVE_FILE_MAGIC_NUMBER, DocumentLoader
import mtg_proxy_printer.save_file_migrations
from mtg_proxy_printer.units_and_sizes import unit_registry, UnitT

from tests.helpers import quantity_close_to

mm: UnitT = unit_registry.mm

def validate_save_database_schema(db: sqlite3.Connection, schema_version: int):
    mtg_proxy_printer.sqlite_helpers.validate_database_schema(
        db, SAVE_FILE_MAGIC_NUMBER, f"document-v{schema_version}", "Invalid header"
    )
    user_version = db.execute("PRAGMA user_version").fetchone()[0]
    assert_that(user_version, is_(equal_to(schema_version)))


def create_save_db(schema_version: int) -> sqlite3.Connection:
    return mtg_proxy_printer.sqlite_helpers.open_database(":memory:", f"document-v{schema_version}")


@pytest.mark.parametrize("source_version", range(2, 7))
def test_single_migration_step_correctly_transforms_database_schema(source_version: int):
    settings = PageLayoutSettings.create_from_settings()
    target_version = source_version+1
    db = create_save_db(source_version)
    migration: Callable[[sqlite3.Connection, PageLayoutSettings], None] = getattr(
        mtg_proxy_printer.save_file_migrations, f"_migrate_{source_version}_to_{target_version}")
    migration(db, settings)
    validate_save_database_schema(db, target_version)


def test_migration_2_to_3_transforms_data():
    db = create_save_db(2)
    db.executemany("INSERT INTO CARD VALUES (?, ?, ?)", [(1, 1, 'abc'), (2, 1, 'xyz')])
    mtg_proxy_printer.save_file_migrations._migrate_2_to_3(db)
    assert_that(
        db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(),
        contains_exactly((1, 1, 1, 'abc'), (2, 1, 1, 'xyz'))
    )

def test_migration_3_to_4_transforms_data():
    db = create_save_db(3)
    settings = PageLayoutSettings.create_from_settings()
    cards = [(1, 1, 1, 'abc'), (2, 1, 1, 'xyz')]
    db.executemany("INSERT INTO CARD VALUES (?, ?, ?, ?)", cards)
    mtg_proxy_printer.save_file_migrations._migrate_3_to_4(db, settings)
    assert_that(
        db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(),
        contains_exactly(*cards)
    )
    assert_that(
        db.execute("SELECT * FROM DocumentSettings").fetchall(),
        contains_exactly(
        (1, settings.page_height.to("mm").magnitude, settings.page_width.to("mm").magnitude,
         settings.margin_top.to("mm").magnitude, settings.margin_bottom.to("mm").magnitude,
         settings.margin_left.to("mm").magnitude, settings.margin_right.to("mm").magnitude,
         settings.row_spacing.to("mm").magnitude, settings.column_spacing.to("mm").magnitude,
         int(settings.draw_cut_markers)
         )
    ))

def test_migration_4_to_5_transforms_data():
    db = create_save_db(4)
    settings = PageLayoutSettings.create_from_settings()
    settings.draw_sharp_corners = True
    cards = [(1, 1, 1, 'abc'), (2, 1, 1, 'xyz')]
    db.executemany("INSERT INTO CARD VALUES (?, ?, ?, ?)", cards)
    # Insert slightly altered data, then pass the unaltered PageLayoutSettings.
    # This verifies that the stored data is used and not replaced with the current default settings.
    db.execute(
        "INSERT INTO DocumentSettings VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (1, settings.page_height.to("mm").magnitude, settings.page_width.to("mm").magnitude,
         settings.margin_top.to("mm").magnitude+1, settings.margin_bottom.to("mm").magnitude-1,
         settings.margin_left.to("mm").magnitude, settings.margin_right.to("mm").magnitude,
         settings.row_spacing.to("mm").magnitude, settings.column_spacing.to("mm").magnitude,
         int(not settings.draw_cut_markers))
    )
    mtg_proxy_printer.save_file_migrations._migrate_4_to_5(db, settings)
    assert_that(
        db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(),
        contains_exactly(*cards)
    )
    assert_that(
        db.execute("SELECT * FROM DocumentSettings").fetchall(),
        contains_exactly(
        (1, settings.page_height.to("mm").magnitude, settings.page_width.to("mm").magnitude,
         settings.margin_top.to("mm").magnitude+1, settings.margin_bottom.to("mm").magnitude-1,
         settings.margin_left.to("mm").magnitude, settings.margin_right.to("mm").magnitude,
         settings.row_spacing.to("mm").magnitude, settings.column_spacing.to("mm").magnitude,
         int(not settings.draw_cut_markers), int(settings.draw_sharp_corners)
         )
    ))

def test_migration_5_to_6_transforms_data():
    db = create_save_db(5)
    settings = PageLayoutSettings.create_from_settings()
    settings.draw_sharp_corners = True
    db.executemany("INSERT INTO Card VALUES (?, ?, ?, ?)", [(1, 1, 1, 'abc'), (2, 1, 1, 'xyz')])
    # Insert slightly altered data, then pass the unaltered PageLayoutSettings.
    # This verifies that the stored data is used and not replaced with the current default settings.
    db.execute(
        "INSERT INTO DocumentSettings VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (1, settings.page_height.to("mm").magnitude, settings.page_width.to("mm").magnitude,
         settings.margin_top.to("mm").magnitude+1, settings.margin_bottom.to("mm").magnitude-1,
         settings.margin_left.to("mm").magnitude, settings.margin_right.to("mm").magnitude,
         settings.row_spacing.to("mm").magnitude, settings.column_spacing.to("mm").magnitude,
         int(not settings.draw_cut_markers), int(not settings.draw_sharp_corners))
    )
    mtg_proxy_printer.save_file_migrations._migrate_5_to_6(db, settings)
    assert_that(
        db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(),
        contains_exactly((1, 1, 1, 'abc', 'r'), (2, 1, 1, 'xyz', 'r'))
    )
    assert_that(
        db.execute("SELECT * FROM DocumentSettings").fetchall(),
        contains_inanyorder(
            # Migrated
            ("page_height", settings.page_height.to("mm").magnitude), ("page_width", settings.page_width.to("mm").magnitude),
            ("margin_top", settings.margin_top.to("mm").magnitude+1),("margin_bottom", settings.margin_bottom.to("mm").magnitude-1),
            ("margin_left", settings.margin_left.to("mm").magnitude),("margin_right", settings.margin_right.to("mm").magnitude),
            ("row_spacing", settings.row_spacing.to("mm").magnitude),("column_spacing", settings.column_spacing.to("mm").magnitude),
            ("draw_cut_markers", int(not settings.draw_cut_markers)),("draw_sharp_corners", int(not settings.draw_sharp_corners)),
            # New
            ("document_name", settings.document_name), ("card_bleed", settings.card_bleed.to("mm").magnitude),
            ("draw_page_numbers", int(settings.draw_page_numbers)),
    ))


def test_migration_6_to_7_transforms_data():
    db = create_save_db(6)
    settings = PageLayoutSettings.create_from_settings()
    settings.draw_sharp_corners = True
    uuid1 = "aaaabbbb-1111-2222-3333-55556666ffff"
    uuid2 = "ffffeeee-9999-8888-7777-ddddccccbbbb"
    db.executemany("INSERT INTO Card VALUES (?, ?, ?, ?, ?)", [(1, 1, 1, uuid1, "r"), (2, 1, 1, uuid2, "r")])
    # Insert slightly altered data, then pass the unaltered PageLayoutSettings.
    # This verifies that the stored data is used and not replaced with the current default settings.
    db.executemany(
        'INSERT INTO DocumentSettings ("key", value) VALUES (?, ?)',(
            ("page_height", settings.page_height.to("mm").magnitude), ("page_width", settings.page_width.to("mm").magnitude),
            ("margin_top", settings.margin_top.to("mm").magnitude+1),("margin_bottom", settings.margin_bottom.to("mm").magnitude-1),
            ("margin_left", settings.margin_left.to("mm").magnitude),("margin_right", settings.margin_right.to("mm").magnitude),
            ("row_spacing", settings.row_spacing.to("mm").magnitude),("column_spacing", settings.column_spacing.to("mm").magnitude),
            ("card_bleed", settings.card_bleed.to("mm").magnitude),
            ("document_name", settings.document_name),
            ("draw_cut_markers", int(not settings.draw_cut_markers)),("draw_sharp_corners", int(not settings.draw_sharp_corners)),
            ("draw_page_numbers", int(settings.draw_page_numbers)),
        )
    )
    mtg_proxy_printer.save_file_migrations._migrate_6_to_7(db, settings)
    assert_that(
        data := db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(),
        contains_exactly((1, 1, True, 'r', uuid1, None), (2, 1, True, 'r', uuid2, None)),
        f"Bad card data: {data}"
    )
    assert_that(
        data := db.execute("SELECT * FROM DocumentSettings").fetchall(),
        contains_inanyorder(
            contains_exactly("draw_cut_markers", str(not settings.draw_cut_markers)),
            contains_exactly("draw_sharp_corners", str(not settings.draw_sharp_corners)),
            contains_exactly("document_name", settings.document_name),
            contains_exactly("draw_page_numbers", str(settings.draw_page_numbers)),),
        f"Bad settings: {data}"
    )
    assert_that(
        data := db.execute("SELECT * FROM DocumentDimensions").fetchall(),
        contains_inanyorder(
            contains_exactly("page_height", quantity_close_to(settings.page_height)), contains_exactly("page_width", quantity_close_to(settings.page_width)),
            contains_exactly("margin_top", quantity_close_to(settings.margin_top+1*mm)), contains_exactly("margin_bottom", quantity_close_to(settings.margin_bottom-1*mm)),
            contains_exactly("margin_left", quantity_close_to(settings.margin_left)), contains_exactly("margin_right", quantity_close_to(settings.margin_right)),
            contains_exactly("row_spacing", quantity_close_to(settings.row_spacing)), contains_exactly("column_spacing", quantity_close_to(settings.column_spacing)),
            contains_exactly("card_bleed", quantity_close_to(settings.card_bleed)),
        ),
        f"Bad settings: {data}"
    )
    assert_that(db.execute("SELECT * FROM CustomCardData").fetchall(), is_(empty()))








|




















|
<




|












|

<


|







|
|
|
|
|



|

<
|






|
|
|
|
|

|







|
|
|
|
|



|

<
|





|
|
|
|
|

|








|
|
|
|
|

|
|



|

<
|


|




|
|
|
|
|
|
|
|


|








|
|
|
|





|
|
|
|
|





19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86

87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117

118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151

152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
import pytest
from hamcrest import *

import mtg_proxy_printer.model.document_loader
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
import mtg_proxy_printer.model.document
import mtg_proxy_printer.sqlite_helpers
from mtg_proxy_printer.model.document_loader import SAVE_FILE_MAGIC_NUMBER, DocumentLoader, CardType
import mtg_proxy_printer.save_file_migrations
from mtg_proxy_printer.units_and_sizes import unit_registry, UnitT

from tests.helpers import quantity_close_to

mm: UnitT = unit_registry.mm

def validate_save_database_schema(db: sqlite3.Connection, schema_version: int):
    mtg_proxy_printer.sqlite_helpers.validate_database_schema(
        db, SAVE_FILE_MAGIC_NUMBER, f"document-v{schema_version}", "Invalid header"
    )
    user_version = db.execute("PRAGMA user_version").fetchone()[0]
    assert_that(user_version, is_(equal_to(schema_version)))


def create_save_db(schema_version: int) -> sqlite3.Connection:
    return mtg_proxy_printer.sqlite_helpers.open_database(":memory:", f"document-v{schema_version}")


@pytest.mark.parametrize("source_version", range(2, 7))
def test_single_migration_step_correctly_transforms_database_schema(page_layout: PageLayoutSettings, source_version: int):

    target_version = source_version+1
    db = create_save_db(source_version)
    migration: Callable[[sqlite3.Connection, PageLayoutSettings], None] = getattr(
        mtg_proxy_printer.save_file_migrations, f"_migrate_{source_version}_to_{target_version}")
    migration(db, page_layout)
    validate_save_database_schema(db, target_version)


def test_migration_2_to_3_transforms_data():
    db = create_save_db(2)
    db.executemany("INSERT INTO CARD VALUES (?, ?, ?)", [(1, 1, 'abc'), (2, 1, 'xyz')])
    mtg_proxy_printer.save_file_migrations._migrate_2_to_3(db)
    assert_that(
        db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(),
        contains_exactly((1, 1, 1, 'abc'), (2, 1, 1, 'xyz'))
    )

def test_migration_3_to_4_transforms_data(page_layout: PageLayoutSettings):
    db = create_save_db(3)

    cards = [(1, 1, 1, 'abc'), (2, 1, 1, 'xyz')]
    db.executemany("INSERT INTO CARD VALUES (?, ?, ?, ?)", cards)
    mtg_proxy_printer.save_file_migrations._migrate_3_to_4(db, page_layout)
    assert_that(
        db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(),
        contains_exactly(*cards)
    )
    assert_that(
        db.execute("SELECT * FROM DocumentSettings").fetchall(),
        contains_exactly(
        (1, page_layout.page_height.to("mm").magnitude, page_layout.page_width.to("mm").magnitude,
         page_layout.margin_top.to("mm").magnitude, page_layout.margin_bottom.to("mm").magnitude,
         page_layout.margin_left.to("mm").magnitude, page_layout.margin_right.to("mm").magnitude,
         page_layout.row_spacing.to("mm").magnitude, page_layout.column_spacing.to("mm").magnitude,
         int(page_layout.draw_cut_markers)
         )
    ))

def test_migration_4_to_5_transforms_data(page_layout: PageLayoutSettings):
    db = create_save_db(4)

    page_layout.draw_sharp_corners = True
    cards = [(1, 1, 1, 'abc'), (2, 1, 1, 'xyz')]
    db.executemany("INSERT INTO CARD VALUES (?, ?, ?, ?)", cards)
    # Insert slightly altered data, then pass the unaltered PageLayoutSettings.
    # This verifies that the stored data is used and not replaced with the current default settings.
    db.execute(
        "INSERT INTO DocumentSettings VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (1, page_layout.page_height.to("mm").magnitude, page_layout.page_width.to("mm").magnitude,
         page_layout.margin_top.to("mm").magnitude+1, page_layout.margin_bottom.to("mm").magnitude-1,
         page_layout.margin_left.to("mm").magnitude, page_layout.margin_right.to("mm").magnitude,
         page_layout.row_spacing.to("mm").magnitude, page_layout.column_spacing.to("mm").magnitude,
         int(not page_layout.draw_cut_markers))
    )
    mtg_proxy_printer.save_file_migrations._migrate_4_to_5(db, page_layout)
    assert_that(
        db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(),
        contains_exactly(*cards)
    )
    assert_that(
        db.execute("SELECT * FROM DocumentSettings").fetchall(),
        contains_exactly(
        (1, page_layout.page_height.to("mm").magnitude, page_layout.page_width.to("mm").magnitude,
         page_layout.margin_top.to("mm").magnitude+1, page_layout.margin_bottom.to("mm").magnitude-1,
         page_layout.margin_left.to("mm").magnitude, page_layout.margin_right.to("mm").magnitude,
         page_layout.row_spacing.to("mm").magnitude, page_layout.column_spacing.to("mm").magnitude,
         int(not page_layout.draw_cut_markers), int(page_layout.draw_sharp_corners)
         )
    ))

def test_migration_5_to_6_transforms_data(page_layout: PageLayoutSettings):
    db = create_save_db(5)

    page_layout.draw_sharp_corners = True
    db.executemany("INSERT INTO Card VALUES (?, ?, ?, ?)", [(1, 1, 1, 'abc'), (2, 1, 1, 'xyz')])
    # Insert slightly altered data, then pass the unaltered PageLayoutSettings.
    # This verifies that the stored data is used and not replaced with the current default settings.
    db.execute(
        "INSERT INTO DocumentSettings VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (1, page_layout.page_height.to("mm").magnitude, page_layout.page_width.to("mm").magnitude,
         page_layout.margin_top.to("mm").magnitude+1, page_layout.margin_bottom.to("mm").magnitude-1,
         page_layout.margin_left.to("mm").magnitude, page_layout.margin_right.to("mm").magnitude,
         page_layout.row_spacing.to("mm").magnitude, page_layout.column_spacing.to("mm").magnitude,
         int(not page_layout.draw_cut_markers), int(not page_layout.draw_sharp_corners))
    )
    mtg_proxy_printer.save_file_migrations._migrate_5_to_6(db, page_layout)
    assert_that(
        db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(),
        contains_exactly((1, 1, 1, 'abc', 'r'), (2, 1, 1, 'xyz', 'r'))
    )
    assert_that(
        db.execute("SELECT * FROM DocumentSettings").fetchall(),
        contains_inanyorder(
            # Migrated
            ("page_height", page_layout.page_height.to("mm").magnitude), ("page_width", page_layout.page_width.to("mm").magnitude),
            ("margin_top", page_layout.margin_top.to("mm").magnitude+1),("margin_bottom", page_layout.margin_bottom.to("mm").magnitude-1),
            ("margin_left", page_layout.margin_left.to("mm").magnitude),("margin_right", page_layout.margin_right.to("mm").magnitude),
            ("row_spacing", page_layout.row_spacing.to("mm").magnitude),("column_spacing", page_layout.column_spacing.to("mm").magnitude),
            ("draw_cut_markers", int(not page_layout.draw_cut_markers)),("draw_sharp_corners", int(not page_layout.draw_sharp_corners)),
            # New
            ("document_name", page_layout.document_name), ("card_bleed", page_layout.card_bleed.to("mm").magnitude),
            ("draw_page_numbers", int(page_layout.draw_page_numbers)),
    ))


def test_migration_6_to_7_transforms_data(page_layout: PageLayoutSettings):
    db = create_save_db(6)

    page_layout.draw_sharp_corners = True
    uuid1 = "aaaabbbb-1111-2222-3333-55556666ffff"
    uuid2 = "ffffeeee-9999-8888-7777-ddddccccbbbb"
    db.executemany("INSERT INTO Card VALUES (?, ?, ?, ?, ?)", [(1, 1, 1, uuid1, CardType.REGULAR), (2, 1, 1, uuid2, CardType.REGULAR)])
    # Insert slightly altered data, then pass the unaltered PageLayoutSettings.
    # This verifies that the stored data is used and not replaced with the current default settings.
    db.executemany(
        'INSERT INTO DocumentSettings ("key", value) VALUES (?, ?)',(
            ("page_height", page_layout.page_height.to("mm").magnitude), ("page_width", page_layout.page_width.to("mm").magnitude),
            ("margin_top", page_layout.margin_top.to("mm").magnitude+1),("margin_bottom", page_layout.margin_bottom.to("mm").magnitude-1),
            ("margin_left", page_layout.margin_left.to("mm").magnitude),("margin_right", page_layout.margin_right.to("mm").magnitude),
            ("row_spacing", page_layout.row_spacing.to("mm").magnitude),("column_spacing", page_layout.column_spacing.to("mm").magnitude),
            ("card_bleed", page_layout.card_bleed.to("mm").magnitude),
            ("document_name", page_layout.document_name),
            ("draw_cut_markers", int(not page_layout.draw_cut_markers)),("draw_sharp_corners", int(not page_layout.draw_sharp_corners)),
            ("draw_page_numbers", int(page_layout.draw_page_numbers)),
        )
    )
    mtg_proxy_printer.save_file_migrations._migrate_6_to_7(db, page_layout)
    assert_that(
        data := db.execute("SELECT * FROM Card ORDER BY page ASC, slot ASC").fetchall(),
        contains_exactly((1, 1, True, 'r', uuid1, None), (2, 1, True, 'r', uuid2, None)),
        f"Bad card data: {data}"
    )
    assert_that(
        data := db.execute("SELECT * FROM DocumentSettings").fetchall(),
        contains_inanyorder(
            contains_exactly("draw_cut_markers", str(not page_layout.draw_cut_markers)),
            contains_exactly("draw_sharp_corners", str(not page_layout.draw_sharp_corners)),
            contains_exactly("document_name", page_layout.document_name),
            contains_exactly("draw_page_numbers", str(page_layout.draw_page_numbers)),),
        f"Bad settings: {data}"
    )
    assert_that(
        data := db.execute("SELECT * FROM DocumentDimensions").fetchall(),
        contains_inanyorder(
            contains_exactly("page_height", quantity_close_to(page_layout.page_height)), contains_exactly("page_width", quantity_close_to(page_layout.page_width)),
            contains_exactly("margin_top", quantity_close_to(page_layout.margin_top+1*mm)), contains_exactly("margin_bottom", quantity_close_to(page_layout.margin_bottom-1*mm)),
            contains_exactly("margin_left", quantity_close_to(page_layout.margin_left)), contains_exactly("margin_right", quantity_close_to(page_layout.margin_right)),
            contains_exactly("row_spacing", quantity_close_to(page_layout.row_spacing)), contains_exactly("column_spacing", quantity_close_to(page_layout.column_spacing)),
            contains_exactly("card_bleed", quantity_close_to(page_layout.card_bleed)),
        ),
        f"Bad settings: {data}"
    )
    assert_that(db.execute("SELECT * FROM CustomCardData").fetchall(), is_(empty()))

Changes to tests/test_units_and_sizes.py.
13
14
15
16
17
18
19

20
21

















22
23
24
25
26
27
28
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import pytest
from hamcrest import *


from mtg_proxy_printer.units_and_sizes import UUID, CardSizes, CardSize
from tests.hasgetter import has_getters


















@pytest.mark.parametrize("input_str", [
    "2c6e5b25-b721-45ee-894a-697de1310b8c",
    "1b9ec782-0ba1-41f1-bc39-d3302494ecb3",
])
def test_uuid_with_valid_inputs(input_str: str):
    assert_that(







>
|

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


import pytest
from hamcrest import *

from tests.helpers import quantity_close_to
from mtg_proxy_printer.units_and_sizes import UUID, CardSizes, CardSize, PageType, ConfigParser, unit_registry
from tests.hasgetter import has_getters


@pytest.fixture()
def config_parser():
    return ConfigParser({"Test": "1 mm"})


def test_ConfigParser_has_get_quantity(config_parser: ConfigParser):
    assert_that(config_parser, has_property("get_quantity"))
    assert_that(config_parser.get_quantity("DEFAULT", "Test"), quantity_close_to(1*unit_registry.mm))


def test_SectionProxy_has_get_quantity(config_parser: ConfigParser):
    proxy = config_parser["DEFAULT"]
    assert_that(proxy, has_property("get_quantity"))
    assert_that(proxy.get_quantity("Test"), quantity_close_to(1*unit_registry.mm))


@pytest.mark.parametrize("input_str", [
    "2c6e5b25-b721-45ee-894a-697de1310b8c",
    "1b9ec782-0ba1-41f1-bc39-d3302494ecb3",
])
def test_uuid_with_valid_inputs(input_str: str):
    assert_that(
42
43
44
45
46
47
48
49
50
51
52
53











54
55
56
57
58
59
60
    "2c6e5b25-b721-45eee-894a-697de1310b8c",
    "2c6e5b25-b721-45e-894a-697de1310b8c",
    "2c6e5b25-b721-45ee-89423-697de1310b8c",
    "2c6e5b25-b721-45ee-894-697de1310b8c",
    "2c6e5b25-b721-45ee-894a-4697de1310b8c",
    "2c6e5b25-b721-45ee-894a-97de1310b8c",
])
def test_uuid_with_invalid_input_raises_valueerror(input_str: str):
    assert_that(
        calling(UUID).with_args(input_str),
        raises(ValueError)
    )












@pytest.mark.parametrize("input_, expected", [(True, CardSizes.OVERSIZED), (False, CardSizes.REGULAR)])
def test_card_sizes_from_bool(input_: bool, expected: CardSize):
    assert_that(CardSizes.from_bool(input_), is_(expected))


@pytest.mark.parametrize("size, width, height", [







|




>
>
>
>
>
>
>
>
>
>
>







60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
    "2c6e5b25-b721-45eee-894a-697de1310b8c",
    "2c6e5b25-b721-45e-894a-697de1310b8c",
    "2c6e5b25-b721-45ee-89423-697de1310b8c",
    "2c6e5b25-b721-45ee-894-697de1310b8c",
    "2c6e5b25-b721-45ee-894a-4697de1310b8c",
    "2c6e5b25-b721-45ee-894a-97de1310b8c",
])
def test_uuid_with_invalid_input_raises_value_error(input_str: str):
    assert_that(
        calling(UUID).with_args(input_str),
        raises(ValueError)
    )


@pytest.mark.parametrize("input_, expected", [
    (PageType.OVERSIZED, CardSizes.OVERSIZED),
    (PageType.REGULAR, CardSizes.REGULAR),
    (PageType.UNDETERMINED, CardSizes.REGULAR),
    (PageType.MIXED, CardSizes.REGULAR),
])
def test_card_sizes_for_page_type(input_: PageType, expected: CardSize):
    assert_that(CardSizes.for_page_type(input_), is_(expected))


@pytest.mark.parametrize("input_, expected", [(True, CardSizes.OVERSIZED), (False, CardSizes.REGULAR)])
def test_card_sizes_from_bool(input_: bool, expected: CardSize):
    assert_that(CardSizes.from_bool(input_), is_(expected))


@pytest.mark.parametrize("size, width, height", [
Changes to tests/ui/test_deck_import_wizard.py.
25
26
27
28
29
30
31

32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

from PyQt5.QtCore import QStringListModel, Qt, QPoint, QObject
from PyQt5.QtWidgets import QCheckBox, QWizard, QTableView, QComboBox, QLineEdit
from PyQt5.QtTest import QTest

import mtg_proxy_printer.settings
from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData

from mtg_proxy_printer.ui.deck_import_wizard import DeckImportWizard
from mtg_proxy_printer.decklist_parser.re_parsers import MTGOnlineParser, MTGArenaParser, \
    GenericRegularExpressionDeckParser
from mtg_proxy_printer.model.card_list import CardListColumns, CardListModel, CardCounter
from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList

from tests.helpers import fill_card_database_with_json_cards

StringList = typing.List[str]
OptString = typing.Optional[str]
Key = Qt.Key
MouseButton = Qt.MouseButton
WizardButton = QWizard.WizardButton


def create_and_show_wizard(qtbot: QtBot, card_db: CardDatabase, cards: StringList) -> DeckImportWizard:

    fill_card_database_with_json_cards(qtbot, card_db, cards)
    language_model = QStringListModel(card_db.get_all_languages(), parent=None)
    wizard = DeckImportWizard(card_db, MagicMock(), language_model)
    qtbot.add_widget(wizard)
    with qtbot.wait_exposed(wizard):
        wizard.show()
    return wizard


def test_going_back_to_textual_deck_list_resets_parsed_cards_model(qtbot: QtBot, card_db: CardDatabase):
    wizard = create_and_show_wizard(qtbot, card_db, ["regular_english_card"])
    deck_list = "1 Fury Sliver"
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_online_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    list_model = wizard.summary_page.card_list
    _validate_model_content(list_model)







>















|
>


|






|
|







25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67

from PyQt5.QtCore import QStringListModel, Qt, QPoint, QObject
from PyQt5.QtWidgets import QCheckBox, QWizard, QTableView, QComboBox, QLineEdit
from PyQt5.QtTest import QTest

import mtg_proxy_printer.settings
from mtg_proxy_printer.model.carddb import CardDatabase, CardIdentificationData
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.ui.deck_import_wizard import DeckImportWizard
from mtg_proxy_printer.decklist_parser.re_parsers import MTGOnlineParser, MTGArenaParser, \
    GenericRegularExpressionDeckParser
from mtg_proxy_printer.model.card_list import CardListColumns, CardListModel, CardCounter
from mtg_proxy_printer.document_controller.import_deck_list import ActionImportDeckList

from tests.helpers import fill_card_database_with_json_cards

StringList = typing.List[str]
OptString = typing.Optional[str]
Key = Qt.Key
MouseButton = Qt.MouseButton
WizardButton = QWizard.WizardButton


def create_and_show_wizard(qtbot: QtBot, document: Document, cards: StringList) -> DeckImportWizard:
    card_db = document.card_db
    fill_card_database_with_json_cards(qtbot, card_db, cards)
    language_model = QStringListModel(card_db.get_all_languages(), parent=None)
    wizard = DeckImportWizard(document, language_model)
    qtbot.add_widget(wizard)
    with qtbot.wait_exposed(wizard):
        wizard.show()
    return wizard


def test_going_back_to_textual_deck_list_resets_parsed_cards_model(qtbot: QtBot, document: Document):
    wizard = create_and_show_wizard(qtbot, document, ["regular_english_card"])
    deck_list = "1 Fury Sliver"
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_online_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    list_model = wizard.summary_page.card_list
    _validate_model_content(list_model)
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
    ({"remove-snow-basics": "False", "remove-basic-wastes": "False"}, ["Snow-Covered Forest", "Wastes"]),
    ({"remove-snow-basics": "True", "remove-basic-wastes": "False"}, ["Wastes"]),
    ({"remove-snow-basics": "False", "remove-basic-wastes": "True"}, ["Snow-Covered Forest"]),
    ({"remove-snow-basics": "True", "remove-basic-wastes": "True"}, []),

])
def test_remove_basic_lands_button_works(
        qtbot: QtBot, card_db: CardDatabase,
        removal_settings: typing.Dict[str, str], expected_cards: StringList):
    deck_list = "\n".join(("1 Forest", "1 Snow-Covered Forest", "1 Wastes"))
    wizard = create_and_show_wizard(
        qtbot, card_db, ["english_basic_Forest", "english_basic_Snow_Forest", "english_basic_Wastes"]
    )
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    list_model = wizard.summary_page.card_list
    assert_that(list_model.rowCount(), is_(3))
    with unittest.mock.patch.dict(mtg_proxy_printer.settings.settings["decklist-import"], removal_settings):
        wizard.button(WizardButton.CustomButton1).click()
    assert_that(
        wizard.button(WizardButton.CustomButton1).isEnabled(),
        is_(False)
    )
    card_names_in_model: StringList = [
        list_model.data(list_model.index(row, CardListColumns.CardName))
        for row in range(list_model.rowCount())
    ]
    assert_that(card_names_in_model, contains_exactly(*expected_cards))


def test_remove_selected_cards_works(qtbot: QtBot, card_db: CardDatabase):
    deck_list = "\n".join(("1 Forest", "1 Snow-Covered Forest", "1 Wastes"))
    wizard = create_and_show_wizard(
        qtbot, card_db, ["english_basic_Forest", "english_basic_Snow_Forest", "english_basic_Wastes"]
    )
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    list_model = wizard.summary_page.card_list
    assert_that(list_model.rowCount(), is_(3))







|



|




















|


|







76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
    ({"remove-snow-basics": "False", "remove-basic-wastes": "False"}, ["Snow-Covered Forest", "Wastes"]),
    ({"remove-snow-basics": "True", "remove-basic-wastes": "False"}, ["Wastes"]),
    ({"remove-snow-basics": "False", "remove-basic-wastes": "True"}, ["Snow-Covered Forest"]),
    ({"remove-snow-basics": "True", "remove-basic-wastes": "True"}, []),

])
def test_remove_basic_lands_button_works(
        qtbot: QtBot, document: Document,
        removal_settings: typing.Dict[str, str], expected_cards: StringList):
    deck_list = "\n".join(("1 Forest", "1 Snow-Covered Forest", "1 Wastes"))
    wizard = create_and_show_wizard(
        qtbot, document, ["english_basic_Forest", "english_basic_Snow_Forest", "english_basic_Wastes"]
    )
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    list_model = wizard.summary_page.card_list
    assert_that(list_model.rowCount(), is_(3))
    with unittest.mock.patch.dict(mtg_proxy_printer.settings.settings["decklist-import"], removal_settings):
        wizard.button(WizardButton.CustomButton1).click()
    assert_that(
        wizard.button(WizardButton.CustomButton1).isEnabled(),
        is_(False)
    )
    card_names_in_model: StringList = [
        list_model.data(list_model.index(row, CardListColumns.CardName))
        for row in range(list_model.rowCount())
    ]
    assert_that(card_names_in_model, contains_exactly(*expected_cards))


def test_remove_selected_cards_works(qtbot: QtBot, document: Document):
    deck_list = "\n".join(("1 Forest", "1 Snow-Covered Forest", "1 Wastes"))
    wizard = create_and_show_wizard(
        qtbot, document, ["english_basic_Forest", "english_basic_Snow_Forest", "english_basic_Wastes"]
    )
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    list_model = wizard.summary_page.card_list
    assert_that(list_model.rowCount(), is_(3))
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
        super(CardListReceiver, self).__init__(parent)
        self.deck: CardCounter = Counter()

    def on_import_action_received(self, action: ActionImportDeckList):
        self.deck = action.cards


def test_selecting_different_printing_works(qtbot: QtBot, card_db: CardDatabase):
    wizard = create_and_show_wizard(qtbot, card_db, ["regular_english_card", "regular_english_card_reprint"])
    deck_list = "2 Fury Sliver (TSP) 157"
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    table_view: QTableView = wizard.summary_page.ui.parsed_cards_table
    cell_position = QPoint(







|
|







224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
        super(CardListReceiver, self).__init__(parent)
        self.deck: CardCounter = Counter()

    def on_import_action_received(self, action: ActionImportDeckList):
        self.deck = action.cards


def test_selecting_different_printing_works(qtbot: QtBot, document: Document):
    wizard = create_and_show_wizard(qtbot, document, ["regular_english_card", "regular_english_card_reprint"])
    deck_list = "2 Fury Sliver (TSP) 157"
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    table_view: QTableView = wizard.summary_page.ui.parsed_cards_table
    cell_position = QPoint(
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
                "https://cards.scryfall.io/png/front/a/8/"
                "a8a64329-09fc-4e0d-b7d1-378635f2801a.png?1619396979"),
            "image_file": is_(none()),
        }), 2)
    )


def test_complete_button_disabled_if_zero_cards_identified(qtbot: QtBot, card_db: CardDatabase):
    """
    If there are zero identified cards, the Finish button must be disabled, so that the wizard can’t be completed.
    """
    wizard = create_and_show_wizard(qtbot, card_db, ["regular_english_card"])
    deck_list = "Invalid deck list"
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    table_view: QTableView = wizard.summary_page.ui.parsed_cards_table
    assert_that(table_view.model().rowCount(), is_(0), "Setup failed: Parsed deck model must be empty!")
    assert_that(wizard.summary_page.isComplete(), is_(False))
    assert_that(wizard.button(WizardButton.FinishButton).isEnabled(), is_(False))


def test_complete_button_enabled_if_one_card_identified(qtbot: QtBot, card_db: CardDatabase):
    """
    If there is at least one identified card, the Finish button must be enabled.
    """
    wizard = create_and_show_wizard(qtbot, card_db, ["regular_english_card"])
    deck_list = "1 Fury Sliver (TSP) 157"
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    table_view: QTableView = wizard.summary_page.ui.parsed_cards_table
    assert_that(table_view.model().rowCount(), is_(1), "Setup failed: Parsed deck model must not be empty!")
    assert_that(wizard.summary_page.isComplete(), is_(True))
    assert_that(wizard.button(WizardButton.FinishButton).isEnabled(), is_(True))


def test_complete_state_updates_when_deck_list_updated_to_contain_cards(qtbot: QtBot, card_db: CardDatabase):
    """
    Test that going back and changing the deck list updates the isComplete()
    value of the SummaryPage and the Finish button enabled state.
    """
    wizard = create_and_show_wizard(qtbot, card_db, ["regular_english_card"])
    invalid_deck_list = "Invalid deck list"
    valid_deck_list = "1 Fury Sliver (TSP) 157"
    _input_deck_list(qtbot, wizard, invalid_deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    table_view: QTableView = wizard.summary_page.ui.parsed_cards_table







|



|











|



|











|




|







273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
                "https://cards.scryfall.io/png/front/a/8/"
                "a8a64329-09fc-4e0d-b7d1-378635f2801a.png?1619396979"),
            "image_file": is_(none()),
        }), 2)
    )


def test_complete_button_disabled_if_zero_cards_identified(qtbot: QtBot, document: Document):
    """
    If there are zero identified cards, the Finish button must be disabled, so that the wizard can’t be completed.
    """
    wizard = create_and_show_wizard(qtbot, document, ["regular_english_card"])
    deck_list = "Invalid deck list"
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    table_view: QTableView = wizard.summary_page.ui.parsed_cards_table
    assert_that(table_view.model().rowCount(), is_(0), "Setup failed: Parsed deck model must be empty!")
    assert_that(wizard.summary_page.isComplete(), is_(False))
    assert_that(wizard.button(WizardButton.FinishButton).isEnabled(), is_(False))


def test_complete_button_enabled_if_one_card_identified(qtbot: QtBot, document: Document):
    """
    If there is at least one identified card, the Finish button must be enabled.
    """
    wizard = create_and_show_wizard(qtbot, document, ["regular_english_card"])
    deck_list = "1 Fury Sliver (TSP) 157"
    _input_deck_list(qtbot, wizard, deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    table_view: QTableView = wizard.summary_page.ui.parsed_cards_table
    assert_that(table_view.model().rowCount(), is_(1), "Setup failed: Parsed deck model must not be empty!")
    assert_that(wizard.summary_page.isComplete(), is_(True))
    assert_that(wizard.button(WizardButton.FinishButton).isEnabled(), is_(True))


def test_complete_state_updates_when_deck_list_updated_to_contain_cards(qtbot: QtBot, document: Document):
    """
    Test that going back and changing the deck list updates the isComplete()
    value of the SummaryPage and the Finish button enabled state.
    """
    wizard = create_and_show_wizard(qtbot, document, ["regular_english_card"])
    invalid_deck_list = "Invalid deck list"
    valid_deck_list = "1 Fury Sliver (TSP) 157"
    _input_deck_list(qtbot, wizard, invalid_deck_list)
    _move_wizard_forward(qtbot, wizard)
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    table_view: QTableView = wizard.summary_page.ui.parsed_cards_table
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    assert_that(table_view.model().rowCount(), is_(0), "Setup failed: Parsed deck model must be empty!")
    assert_that(wizard.summary_page.isComplete(), is_(False))
    assert_that(wizard.button(WizardButton.FinishButton).isEnabled(), is_(False))


def test_custom_re_parser_works(qtbot: QtBot, card_db: CardDatabase):
    valid_re = r"(?P<name>.+)"
    deck_list = "Fury Sliver"
    wizard = create_and_show_wizard(qtbot, card_db, ["regular_english_card", "regular_english_card_reprint"])
    cards = card_db.get_cards_from_data(CardIdentificationData("en", "Fury Sliver"))
    wizard.select_deck_parser_page.image_db.filter_already_downloaded.return_value = cards
    _input_deck_list(qtbot, wizard, deck_list, enable_print_guessing=True)
    _move_wizard_forward(qtbot, wizard)
    _select_generic_re_parser(qtbot, wizard, valid_re, True)
    _move_wizard_forward(qtbot, wizard)
    table_view: QTableView = wizard.summary_page.ui.parsed_cards_table
    assert_that(table_view.model().rowCount(), is_(1), "Setup failed: Parsed deck model must not be empty!")
    assert_that(wizard.summary_page.isComplete(), is_(True))







|


|
<
<







345
346
347
348
349
350
351
352
353
354
355


356
357
358
359
360
361
362
    _select_magic_arena_parser(qtbot, wizard)
    _move_wizard_forward(qtbot, wizard)
    assert_that(table_view.model().rowCount(), is_(0), "Setup failed: Parsed deck model must be empty!")
    assert_that(wizard.summary_page.isComplete(), is_(False))
    assert_that(wizard.button(WizardButton.FinishButton).isEnabled(), is_(False))


def test_custom_re_parser_works(qtbot: QtBot, document: Document):
    valid_re = r"(?P<name>.+)"
    deck_list = "Fury Sliver"
    wizard = create_and_show_wizard(qtbot, document, ["regular_english_card", "regular_english_card_reprint"])


    _input_deck_list(qtbot, wizard, deck_list, enable_print_guessing=True)
    _move_wizard_forward(qtbot, wizard)
    _select_generic_re_parser(qtbot, wizard, valid_re, True)
    _move_wizard_forward(qtbot, wizard)
    table_view: QTableView = wizard.summary_page.ui.parsed_cards_table
    assert_that(table_view.model().rowCount(), is_(1), "Setup failed: Parsed deck model must not be empty!")
    assert_that(wizard.summary_page.isComplete(), is_(True))
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
        return " ".join(fr"(?P<{group_name}>.+)" for group_name in groups)

    for groups in flattened_powerset_without_empty(GenericRegularExpressionDeckParser.IDENTIFYING_GROUP_COMBINATIONS):
        yield generate_re(groups)


@pytest.mark.parametrize("valid_re", generate_test_cases_for_test_custom_re_parser_accepts_valid_re())
def test_custom_re_parser_accepts_valid_re(qtbot, card_db, valid_re: str):
    wizard = create_and_show_wizard(qtbot, card_db, ["regular_english_card", "regular_english_card_reprint"])
    deck_list = "Fury Sliver"
    _input_deck_list(qtbot, wizard, deck_list, enable_print_guessing=True)
    _move_wizard_forward(qtbot, wizard)
    _select_generic_re_parser(qtbot, wizard, valid_re, True)


@pytest.mark.parametrize("invalid_re", [
    "No group",
    r"(?P<count>.+)",
    r"(?P<collector_number>.+)",
    r"(?P<set_code>.+)",
    r"(?P<count>.+) (?P<collector_number>.+)",
    r"(?P<count>.+) (?P<set_code>.+)",
])
def test_custom_re_parser_declines_non_identifying_re(qtbot: QtBot, card_db: CardDatabase, invalid_re: str):
    wizard = create_and_show_wizard(qtbot, card_db, ["regular_english_card", "regular_english_card_reprint"])
    deck_list = "Fury Sliver"
    _input_deck_list(qtbot, wizard, deck_list, enable_print_guessing=True)
    _move_wizard_forward(qtbot, wizard)
    _select_generic_re_parser(qtbot, wizard, invalid_re, False)







|
|














|
|




375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
        return " ".join(fr"(?P<{group_name}>.+)" for group_name in groups)

    for groups in flattened_powerset_without_empty(GenericRegularExpressionDeckParser.IDENTIFYING_GROUP_COMBINATIONS):
        yield generate_re(groups)


@pytest.mark.parametrize("valid_re", generate_test_cases_for_test_custom_re_parser_accepts_valid_re())
def test_custom_re_parser_accepts_valid_re(qtbot: QtBot, document: Document, valid_re: str):
    wizard = create_and_show_wizard(qtbot, document, ["regular_english_card", "regular_english_card_reprint"])
    deck_list = "Fury Sliver"
    _input_deck_list(qtbot, wizard, deck_list, enable_print_guessing=True)
    _move_wizard_forward(qtbot, wizard)
    _select_generic_re_parser(qtbot, wizard, valid_re, True)


@pytest.mark.parametrize("invalid_re", [
    "No group",
    r"(?P<count>.+)",
    r"(?P<collector_number>.+)",
    r"(?P<set_code>.+)",
    r"(?P<count>.+) (?P<collector_number>.+)",
    r"(?P<count>.+) (?P<set_code>.+)",
])
def test_custom_re_parser_declines_non_identifying_re(qtbot: QtBot, document: Document, invalid_re: str):
    wizard = create_and_show_wizard(qtbot, document, ["regular_english_card", "regular_english_card_reprint"])
    deck_list = "Fury Sliver"
    _input_deck_list(qtbot, wizard, deck_list, enable_print_guessing=True)
    _move_wizard_forward(qtbot, wizard)
    _select_generic_re_parser(qtbot, wizard, invalid_re, False)
Changes to tests/ui/test_item_delegate.py.
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

40
41



42
43
44
45


46
47
48
49
50
51
52
53
54
55
56
57

58
59
60
61

62
63
64
65
66

67
68
69
70

71
72
73
74
75

76
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.


from collections import Counter
import itertools
from unittest.mock import NonCallableMagicMock

from PyQt5.QtWidgets import QComboBox
import pytest
from hamcrest import *


from mtg_proxy_printer.document_controller.card_actions import ActionAddCard
from mtg_proxy_printer.model.carddb import CardDatabase, Card, MTGSet
from mtg_proxy_printer.model.card_list import CardListModel, CardListColumns
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.natsort import NaturallySortedSortFilterProxyModel
from mtg_proxy_printer.units_and_sizes import CardSizes

from mtg_proxy_printer.ui.item_delegates import CardListComboBoxItemDelegate, DocumentComboBoxItemDelegate


@pytest.fixture(params=itertools.product(range(3), ["", "language"]))
def card_list_empty_carddb(card_db: CardDatabase, request):
    proxy_level, language = request.param  # type: int, str
    card = Card("", MTGSet("", ""), "", language, "", True, "", "", True, CardSizes.REGULAR, 1, False, None)

    source_model = CardListModel(card_db)
    source_model.add_cards(Counter({card: 1}))



    # Having this list keeps the references alive. Without keeping them here, access in test code raises
    #   RuntimeError: wrapped C/C++ object of type NaturallySortedSortFilterProxyModel has been deleted
    models = [source_model]
    for _ in range(proxy_level):


        proxy = NaturallySortedSortFilterProxyModel()
        proxy.setSourceModel(models[-1])
        models.append(proxy)
    yield models[-1]


@pytest.fixture(params=["", "language"])
def document_empty_carddb(card_db: CardDatabase, request):
    card = Card("", MTGSet("", ""), "", request.param, "", True, "", "", True, CardSizes.REGULAR, 1, False, None)
    source_model = Document(card_db, NonCallableMagicMock(spec=ImageDatabase))
    ActionAddCard(card).apply(source_model)
    yield source_model



@pytest.mark.parametrize("column", CardListModel.EDITABLE_COLUMNS-{CardListColumns.Copies})
def test_setEditorData_on_card_list_empty_model(qtbot, card_db: CardDatabase, card_list_empty_carddb, column):

    editor_widget = QComboBox()
    delegate = CardListComboBoxItemDelegate()
    index = card_list_empty_carddb.index(0, column)
    delegate.setEditorData(editor_widget, index)
    assert_that(editor_widget.model().rowCount(), is_(1))  # Data from the source card must round-trip



@pytest.mark.parametrize("column", Document.EDITABLE_COLUMNS)
def test_setEditorData_on_document_empty_model(qtbot, card_db: CardDatabase, document_empty_carddb, column):

    editor_widget = QComboBox()
    delegate = DocumentComboBoxItemDelegate()
    page_index = document_empty_carddb.index(0, 0)
    index = document_empty_carddb.index(0, column, page_index)
    delegate.setEditorData(editor_widget, index)

    assert_that(editor_widget.model().rowCount(), is_(1))  # Data from the source card must round-trip







|
<
<
<
<
|


>

<
<
<
|
<
<
|

|


|
<
<
<
>
|
|
>
>
>
|
|
<
<
>
>
<
<
<
<

|
<
<
<
<
<
<
>


|
|
>
|
<
<
|
|
>


|
|
>
|
<
<
<
|
>
|
9
10
11
12
13
14
15
16




17
18
19
20
21



22


23
24
25
26
27
28



29
30
31
32
33
34
35
36


37
38




39
40






41
42
43
44
45
46
47


48
49
50
51
52
53
54
55
56



57
58
59
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program. If not, see <http://www.gnu.org/licenses/>.

from PyQt5.QtCore import QModelIndex




from PyQt5.QtWidgets import QSpinBox, QWidget, QStyleOptionViewItem
import pytest
from hamcrest import *
from pytestqt.qtbot import QtBot




from mtg_proxy_printer.model.card import MTGSet


from mtg_proxy_printer.ui.item_delegates import BoundedCopiesSpinboxDelegate, SetEditorDelegate

from tests.hasgetter import has_getters


@pytest.fixture()



def bounded_copies_spinbox(qtbot: QtBot) -> QSpinBox:
    parent = QWidget()
    qtbot.add_widget(parent)
    delegate = BoundedCopiesSpinboxDelegate()
    editor = delegate.createEditor(parent, QStyleOptionViewItem(), QModelIndex())
    yield editor




def test_BoundedCopiesSpinboxDelegate_createEditor_returns_correct_type(bounded_copies_spinbox: QSpinBox):
    assert_that(bounded_copies_spinbox, is_(instance_of(QSpinBox)))





def test_BoundedCopiesSpinboxDelegate_createEditor_has_correct_limits(bounded_copies_spinbox: QSpinBox):






    assert_that(bounded_copies_spinbox, has_getters(minimum=1, maximum=100))


@pytest.mark.parametrize("mtg_set", [MTGSet("BAR", "bar"), MTGSet("FOO", "foo")])
def test_CustomCardSetEditor_set_data(qtbot: QtBot, mtg_set: MTGSet):
    editor = SetEditorDelegate.CustomCardSetEditor()
    qtbot.add_widget(editor)


    editor.set_data(mtg_set)
    assert_that(editor.ui.name_editor.text(), is_(equal_to(mtg_set.name)))
    assert_that(editor.ui.code_edit.text(), is_(equal_to(mtg_set.code)))


@pytest.mark.parametrize("mtg_set", [MTGSet("BAR", "bar"), MTGSet("FOO", "foo")])
def test_CustomCardSetEditor_to_mtg_set(qtbot: QtBot, mtg_set: MTGSet):
    editor = SetEditorDelegate.CustomCardSetEditor()
    qtbot.add_widget(editor)



    editor.ui.name_editor.setText(mtg_set.name)
    editor.ui.code_edit.setText(mtg_set.code)
    assert_that(editor.to_mtg_set(), is_(equal_to(mtg_set)))
Changes to tests/ui/test_main_window.py.
25
26
27
28
29
30
31

32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from pytestqt.qtbot import QtBot
from hamcrest import *
import pytest

import mtg_proxy_printer.http_file
import mtg_proxy_printer.downloader_base
from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument

from mtg_proxy_printer.sqlite_helpers import open_database
from mtg_proxy_printer.card_info_downloader import CardInfoDownloader
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.ui.main_window import MainWindow
from mtg_proxy_printer.ui.central_widget import Ui_ColumnarCentralWidget, Ui_GroupedCentralWidget, \
    Ui_TabbedCentralWidget
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.units_and_sizes import CardSizes
from mtg_proxy_printer.model.page_layout import PageLayoutSettings

from tests.helpers import fill_card_database_with_json_cards
from tests.document_controller.helpers import insert_card_in_page
StandardButton = QMessageBox.StandardButton


@pytest.fixture(params=[Ui_ColumnarCentralWidget, Ui_GroupedCentralWidget, Ui_TabbedCentralWidget])
def main_window(qtbot, card_db: CardDatabase, document: Document, request) -> typing.Generator[MainWindow, None, None]:
    fill_card_database_with_json_cards(qtbot, card_db, ["regular_english_card", "oversized_card"])







>












|







25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
from pytestqt.qtbot import QtBot
from hamcrest import *
import pytest

import mtg_proxy_printer.http_file
import mtg_proxy_printer.downloader_base
from mtg_proxy_printer.document_controller.save_document import ActionSaveDocument
from mtg_proxy_printer.model.document_loader import CardType
from mtg_proxy_printer.sqlite_helpers import open_database
from mtg_proxy_printer.card_info_downloader import CardInfoDownloader
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.imagedb import ImageDatabase
from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.ui.main_window import MainWindow
from mtg_proxy_printer.ui.central_widget import Ui_ColumnarCentralWidget, Ui_GroupedCentralWidget, \
    Ui_TabbedCentralWidget
from mtg_proxy_printer.document_controller.page_actions import ActionNewPage
from mtg_proxy_printer.units_and_sizes import CardSizes
from mtg_proxy_printer.model.page_layout import PageLayoutSettings

from tests.helpers import fill_card_database_with_json_cards, create_save_database_with
from tests.document_controller.helpers import insert_card_in_page
StandardButton = QMessageBox.StandardButton


@pytest.fixture(params=[Ui_ColumnarCentralWidget, Ui_GroupedCentralWidget, Ui_TabbedCentralWidget])
def main_window(qtbot, card_db: CardDatabase, document: Document, request) -> typing.Generator[MainWindow, None, None]:
    fill_card_database_with_json_cards(qtbot, card_db, ["regular_english_card", "oversized_card"])
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
        with qtbot.wait_exposed(main_window, timeout=1000):
            main_window.show()
        yield main_window
        main_window.hide()
        del cid
        main_window.__dict__.clear()


def test_main_window_hides_progress_bar_after_downloading_image_during_load(
        qtbot: QtBot, main_window: MainWindow):
    with unittest.mock.patch.object(  # Mock all HTTP-specific I/O calls
                mtg_proxy_printer.downloader_base.mtg_proxy_printer.http_file.MeteredSeekableHTTPFile,
                "_read_content_length") as cl_mock, \
            unittest.mock.patch.object(
                mtg_proxy_printer.downloader_base.mtg_proxy_printer.http_file.MeteredSeekableHTTPFile,
                "getcode", return_value=200), \
            unittest.mock.patch.object(
                mtg_proxy_printer.downloader_base.mtg_proxy_printer.http_file.MeteredSeekableHTTPFile,
                "content_encoding", return_value="identity"), \
            unittest.mock.patch.object(
                mtg_proxy_printer.downloader_base.mtg_proxy_printer.http_file.MeteredSeekableHTTPFile,
                "seekable", return_value=True), \
            unittest.mock.patch("mtg_proxy_printer.ui.main_window.QMessageBox.warning") as mb1, \
            unittest.mock.patch("mtg_proxy_printer.ui.main_window.QMessageBox.critical") as mb2:
        temp_path = main_window.image_db.db_path
        mock_image_path = _create_mock_image(main_window.image_db, temp_path)
        cl_mock.return_value = mock_image_path.stat().st_size
        main_window.card_database.db.execute("UPDATE CardFace SET png_image_uri = ?", (mock_image_path.as_uri(),))
        save_file_path = _create_save_file(temp_path)

        assert_that(main_window.progress_bars.ui.inner_progress_bar.isVisible(), is_(False))
        assert_that(main_window.progress_bars.ui.outer_progress_bar.isVisible(), is_(False))
        assert_that(main_window.progress_bars.ui.inner_progress_label.isVisible(), is_(False))
        assert_that(main_window.progress_bars.ui.outer_progress_label.isVisible(), is_(False))

        with qtbot.wait_signals(
                [(main_window.loading_state_changed, "loading_state_changed(True)"),
                 (main_window.loading_state_changed, "loading_state_changed(False)"),
                 (main_window.document.loader.finished, "finished"),], timeout=1000,
                check_params_cbs=[lambda value: value, lambda value: not value, lambda: True]):
            main_window.document.loader.load_document(save_file_path)
            
    assert_that(main_window.progress_bars.ui.inner_progress_bar.isVisible(), is_(False))
    assert_that(main_window.progress_bars.ui.outer_progress_bar.isVisible(), is_(False))
    assert_that(main_window.progress_bars.ui.inner_progress_label.isVisible(), is_(False))
    assert_that(main_window.progress_bars.ui.outer_progress_label.isVisible(), is_(False))
    mb1.assert_not_called()
    mb2.assert_not_called()


def _create_mock_image(image_db: ImageDatabase, temp_path: pathlib.Path) -> pathlib.Path:
    mock_image_path = temp_path / 'temp' / "0000579f-7b35-4ed3-b44c-db2a538066fe.png"
    mock_image_path.parent.mkdir(parents=True, exist_ok=False)
    image_db.get_blank().save(str(mock_image_path), "PNG", 100)
    assert_that(mock_image_path.is_file(), is_(True))
    return mock_image_path


def _create_save_file(temp_path: pathlib.Path):
    save_file_path = temp_path/"test.mtgproxies"
    with open_database(save_file_path, "document-v7") as save_file:
        ActionSaveDocument.save_settings(save_file, PageLayoutSettings.create_from_settings())
        save_file.execute(
            "INSERT INTO Page(page, image_size) VALUES (?, ?)",
            (1, CardSizes.REGULAR.to_save_data())
        )
        save_file.execute(
            "INSERT INTO Card (page, slot, is_front, scryfall_id, type) VALUES (?, ?, ?, ?, ?)",
            (1, 1, True, "0000579f-7b35-4ed3-b44c-db2a538066fe", "r")
        )
    return save_file_path


def test_declining_card_data_update_offer_results_in_no_action(qtbot: QtBot, main_window: MainWindow):
    ui = main_window.ui
    ui.action_download_card_data.setEnabled(False)
    with unittest.mock.patch.object(
            mtg_proxy_printer.ui.main_window.QMessageBox, "question", return_value=StandardButton.No), \
        unittest.mock.patch(







<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







66
67
68
69
70
71
72

































































73
74
75
76
77
78
79
        with qtbot.wait_exposed(main_window, timeout=1000):
            main_window.show()
        yield main_window
        main_window.hide()
        del cid
        main_window.__dict__.clear()



































































def test_declining_card_data_update_offer_results_in_no_action(qtbot: QtBot, main_window: MainWindow):
    ui = main_window.ui
    ui.action_download_card_data.setEnabled(False)
    with unittest.mock.patch.object(
            mtg_proxy_printer.ui.main_window.QMessageBox, "question", return_value=StandardButton.No), \
        unittest.mock.patch(
Changes to tests/ui/test_page_card_table_view.py.
18
19
20
21
22
23
24
25

26
27
28
29
30
31
32
from unittest.mock import NonCallableMagicMock, patch

import pytest
from pytestqt.qtbot import QtBot
from hamcrest import *

from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.carddb import Card, MTGSet, CheckCard, CardDatabase, AnyCardType

from mtg_proxy_printer.units_and_sizes import CardSizes
from mtg_proxy_printer.ui.page_card_table_view import PageCardTableView

# Import dynamically used by pytest. Without this, the main_window fixture won’t be found by pytest.
from .test_main_window import main_window  # noqa

@pytest.fixture()







|
>







18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from unittest.mock import NonCallableMagicMock, patch

import pytest
from pytestqt.qtbot import QtBot
from hamcrest import *

from mtg_proxy_printer.model.document import Document
from mtg_proxy_printer.model.carddb import CardDatabase
from mtg_proxy_printer.model.card import MTGSet, Card, CheckCard, AnyCardType
from mtg_proxy_printer.units_and_sizes import CardSizes
from mtg_proxy_printer.ui.page_card_table_view import PageCardTableView

# Import dynamically used by pytest. Without this, the main_window fixture won’t be found by pytest.
from .test_main_window import main_window  # noqa

@pytest.fixture()
Changes to tests/ui/test_page_config_container.py.
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.units_and_sizes import QuantityT, unit_registry
from mtg_proxy_printer.ui.page_config_container import PageConfigContainer

from tests.helpers import quantity_close_to


@pytest.fixture
def container(qtbot: QtBot):
    container = PageConfigContainer()
    container.ui.page_config_widget.load_from_page_layout(PageLayoutSettings.create_from_settings())
    qtbot.add_widget(container)
    return container


@pytest.mark.parametrize(
    "widget_name",
    ["draw_cut_markers", "draw_sharp_corners", "draw_page_numbers"])







|
|

|







23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from mtg_proxy_printer.model.page_layout import PageLayoutSettings
from mtg_proxy_printer.units_and_sizes import QuantityT, unit_registry
from mtg_proxy_printer.ui.page_config_container import PageConfigContainer

from tests.helpers import quantity_close_to


@pytest.fixture()
def container(qtbot: QtBot, page_layout: PageLayoutSettings):
    container = PageConfigContainer()
    container.ui.page_config_widget.load_from_page_layout(page_layout)
    qtbot.add_widget(container)
    return container


@pytest.mark.parametrize(
    "widget_name",
    ["draw_cut_markers", "draw_sharp_corners", "draw_page_numbers"])