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 | # Changelog # Next version (in development) | | > > > > > > > | > > > > > > | 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 | 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 | | | 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 | from abc import abstractmethod import typing from PyQt5.QtCore import QObject, pyqtSignal as Signal | | > | 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 | # noinspection PyUnresolvedReferences,PyUnboundLocalVariable profile except NameError: # If not defined, use this identity decorator as a replacement def profile(func): return func | | | 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 | import abc import collections import csv import typing from PyQt5.QtCore import QObject, QCoreApplication | | > | 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 | from collections import Counter import re import typing from PyQt5.QtCore import QObject, QCoreApplication from mtg_proxy_printer.decklist_parser.common import ParsedDeck, ParserBase | | > | 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 | @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. """ | | | 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 | import functools import itertools import math import typing | | > > | 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 | 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) | < < < < < < < < < < < < < < | 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 | 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 | | | 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 | 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 | | | 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 | import functools import typing from PyQt5.QtCore import Qt | > | | 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 | 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 | | | 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 | 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 | | | | | > | | | | | | > > > > > > | > < | | | < > | < < < < < < < < < < < < | < | | | < > | | > | > > > > > > > > > | | | < | | < < > | | < < < < < < < < > | | | | 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 | from secrets import token_bytes as randbytes import typing from PyQt5.QtCore import Qt, QModelIndex from ._interface import DocumentAction, IllegalStateError, Self | | | | | 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 | from collections import Counter import dataclasses import enum import itertools import typing | | > > > > > | > | > > > > > > > > > > > | > | | 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 | 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: | | > | | > | | | | > | > > | > > > > | > > | | > | < < < < | | | | | | | | | | | | | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | > > > > > > > | < | 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 | 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) | < < | 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 | # 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 | < | | | < < | > | < < < > > > < < < < < < < | < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < | | | | < < < < < < < < < | 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 | 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() | > | | | | 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 | 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")) | | | 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 | 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( | | | 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 | 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( | | | 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 | 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, | | | 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 | 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), ) | | | 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 | SELECT scryfall_id, is_front FROM Printing JOIN CardFace USING (printing_id) ) ''') cards = ImageDatabaseCards([], [], []) cards.unknown[:] = ( | | | | | 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 | query = cached_dedent(''' SELECT is_dfc -- is_dfc() FROM AllPrintings WHERE "scryfall_id" = ? ''') return bool(self._read_optional_scalar_from_db(query, (scryfall_id,))) | | | 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 | 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) | | | 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 | UNION ALL SELECT set_code, set_name, release_date FROM MTGSet WHERE set_code = ? ) ORDER BY release_date ASC """) | | | 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 | WHERE Printing.is_hidden IS FALSE AND FaceName.is_hidden IS FALSE AND oracle_id = ? AND set_code = ? AND language = ? ) """) | | | | | | | 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 | 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, | < | 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 | import pathlib import sys import typing from PyQt5.QtCore import QAbstractItemModel, QModelIndex, Qt, pyqtSlot as Slot, pyqtSignal as Signal, \ QPersistentModelIndex | > > | | > < < < < < < < < < < | 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 | 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) | | | 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 | 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) | | | > > | > | > | | < | | 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 | import collections import enum import functools import itertools import pathlib import sqlite3 import textwrap | > | | < | | | | 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 | class CardType(str, enum.Enum): REGULAR = "r" CHECK_CARD = "d" @classmethod def from_card(cls, card: AnyCardType) -> "CardType": | | | | 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 | 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) | | < | 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 | self.worker.load_document() finally: self.release_instance() def _create_worker(self): parent = self.parent worker = Worker(parent.document, self.path) | < < | 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 | """ loading_file_successful = Signal(pathlib.Path) def __init__(self, document: "Document", path: pathlib.Path): super().__init__(None) self.document = document self.save_path = path | | < | | | | | | | < < < | > | | | 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 | 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: | > | | < | | | | | | | > > | | > | | | | | > > > > < < | < < | 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 | 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 | < | | | < > | < > | < < | | < | 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 | 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)) | > | > > | | | | | | | | | | | | < < | 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 | # 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 | > | > > > > > > > > > | 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 | super().insert(__index, container) return container def append(self, __object: AnyCardType) -> CardContainer: container = CardContainer(self, __object) super().append(container) return container | < < < | 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 | 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 | | | 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 | 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) | | | | 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 | # along with this program. If not, see <http://www.gnu.org/licenses/>. import typing from PyQt5.QtCore import QAbstractListModel, Qt, QObject, QModelIndex | | < | 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 | <source>Third party licenses</source> <translation>Drittanbieter-Lizenzen</translation> </message> </context> <context> <name>ActionAddCard</name> <message numerus="yes"> | | > > > > > > > > > | 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 | <numerusform>Ersetze Dokument durch importierte Deckliste mit %n Karten</numerusform> </translation> </message> </context> <context> <name>ActionLoadDocument</name> <message numerus="yes"> | | | | 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 '{save_path}', containing %n page(s) {cards_total}</source> <comment>Undo/redo tooltip text.</comment> <translation> <numerusform>Lade Dokument von '{save_path}', mit %n Seite {cards_total}</numerusform> <numerusform>Lade Dokument von '{save_path}', 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 | <numerusform>Seiten {pages} hinzufügen</numerusform> </translation> </message> </context> <context> <name>ActionRemoveCards</name> <message numerus="yes"> | | | 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 | <numerusform>Seiten {formatted_pages} {formatted_card_count} entfernt</numerusform> </translation> </message> </context> <context> <name>ActionReplaceCard</name> <message> | | > > > > > > > > | | 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 '{save_file_path}'.</source> <translation>Dokument in '{save_file_path}' 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 | <source>Unknown images:</source> <translation>Unbekannte Bilder:</translation> </message> </context> <context> <name>CardListModel</name> <message> | | < < < < < | | | | | | | | < < < < < < < < < < < < | < < < < < < < < < < < < < < < < | | < < < < < | | < < | < | | | | | | | | | > > > > > > > > > > > > > > > > > > > > > > > | | | | 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 | <source>Open the Cutelog homepage</source> <translation>Öffne die Cutelog-Homepage</translation> </message> </context> <context> <name>DeckImportWizard</name> <message> | | | | | | | 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 | <source>Default settings for new documents</source> <translation>Standardeinstellungen für neue Dokumente</translation> </message> </context> <context> <name>Document</name> <message> | | | | | | | | | | | | | | | 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 | </message> <message> <location filename="../ui/settings_window/general_settings_page.ui" line="51"/> <source>Application language</source> <translation>Sprache der Anwendung</translation> </message> <message> | < < < < < < < < < < < < < < < < | 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 | <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> | > > > > > > > > > > > > > > > > | | | 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 | <source>Copies:</source> <translation>Kopien:</translation> </message> </context> <context> <name>ImageDownloader</name> <message> | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 '{card_name}'</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 | <source>Download deck list</source> <translation>Deckliste herunterladen</translation> </message> </context> <context> <name>LoadSaveDialog</name> <message> | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | < < < < < | | | | | | | | | > > > > > | > > > > > | 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&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 & 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 "{failed_path}" failed. The file was not recognized as a {program_name} document. If you want to load a deck list, use the "{function_text}" 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&le</source> <translation>&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>&Quit</source> <translation>&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>&Print</source> <translation>&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>&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>&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 | <source>Oversized</source> <translation>Übergroß</translation> </message> </context> <context> <name>PageConfigWidget</name> <message numerus="yes"> | | | | | 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 | Zoom in: {zoom_in_shortcuts} Zoom aus: {zoom_out_shortcuts}</translation> </message> </context> <context> <name>ParserBase</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 | <location filename="../ui/settings_window/settings_window.ui" line="17"/> <source>Settings</source> <translation>Einstellungen</translation> </message> </context> <context> <name>SummaryPage</name> | < < < < < < < < < < | | | | | | | > > > > > > > > > > | 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 | </message> <message> <location filename="../ui/central_widget/tabbed_vertical.ui" line="43"/> <source>Current page</source> <translation>Aktuelle Seite</translation> </message> <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 | <source>Copies:</source> <translation>Kopien:</translation> </message> </context> <context> <name>XMageParser</name> <message> | | | | 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 | <source>Third party licenses</source> <translation>Third Party licenses</translation> </message> </context> <context> <name>ActionAddCard</name> <message numerus="yes"> | | > > > > > > > > > | 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 | <numerusform>Replace document with imported deck list containing %n cards</numerusform> </translation> </message> </context> <context> <name>ActionLoadDocument</name> <message numerus="yes"> | | | | 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 '{save_path}', containing %n page(s) {cards_total}</source> <comment>Undo/redo tooltip text.</comment> <translation> <numerusform>Load document from '{save_path}', containing %n page {cards_total}</numerusform> <numerusform>Load document from '{save_path}', 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 | <numerusform>Add pages {pages}</numerusform> </translation> </message> </context> <context> <name>ActionRemoveCards</name> <message numerus="yes"> | | | 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 | <numerusform>Remove pages {formatted_pages} containing {formatted_card_count}</numerusform> </translation> </message> </context> <context> <name>ActionReplaceCard</name> <message> | | > > > > > > > > | | 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 '{save_file_path}'.</source> <translation>Save document to '{save_file_path}'.</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 | <source>Unknown images:</source> <translation>Unknown images:</translation> </message> </context> <context> <name>CardListModel</name> <message> | | < < < < < | | | | | | | | < < < | | | < | < < < < < < < < | | < < < < < < < | < | | | | < < < < < < | | < < < < < < < < < < | | | > > > > > > > > > > > > > > > > > > > > > > > | | | | 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 | <source>Open the Cutelog homepage</source> <translation>Open the Cutelog homepage</translation> </message> </context> <context> <name>DeckImportWizard</name> <message> | | | | | | | 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 | <source>Default settings for new documents</source> <translation>Default settings for new documents</translation> </message> </context> <context> <name>Document</name> <message> | | | | | | | | | | | | | | | 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 | </message> <message> <location filename="../ui/settings_window/general_settings_page.ui" line="51"/> <source>Application language</source> <translation>Application language</translation> </message> <message> | < < < < < < < < < < < < < < < < | 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 | <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> | > > > > > > > > > > > > > > > > | | | 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 | <source>Copies:</source> <translation>Copies:</translation> </message> </context> <context> <name>ImageDownloader</name> <message> | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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 '{card_name}'</source> <comment>Progress bar label text</comment> <translation>Downloading '{card_name}'</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 | <source>Download deck list</source> <translation>Download deck list</translation> </message> </context> <context> <name>LoadSaveDialog</name> <message> | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | < < < < < | | | | | | | | | > > > > > | > > > > > | 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&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&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 "{failed_path}" failed. The file was not recognized as a {program_name} document. If you want to load a deck list, use the "{function_text}" function instead. Reported failure reason: {reason}</source> <translation>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 "{function_text}" 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&le</source> <translation>Fi&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>&Quit</source> <translation>&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>&Print</source> <translation>&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>&Show print preview</source> <translation>&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>&Create PDF</source> <translation>&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 | <source>Oversized</source> <translation>Oversized</translation> </message> </context> <context> <name>PageConfigWidget</name> <message numerus="yes"> | | | | | 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 | Zoom in: {zoom_in_shortcuts} Zoom out: {zoom_out_shortcuts}</translation> </message> </context> <context> <name>ParserBase</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 | <location filename="../ui/settings_window/settings_window.ui" line="17"/> <source>Settings</source> <translation>Settings</translation> </message> </context> <context> <name>SummaryPage</name> | < < < < < < < < < < | | | | | | | > > > > > > > > > > | 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 | </message> <message> <location filename="../ui/central_widget/tabbed_vertical.ui" line="43"/> <source>Current page</source> <translation>Current page</translation> </message> <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 | <source>Copies:</source> <translation>Copies:</translation> </message> </context> <context> <name>XMageParser</name> <message> | | | | 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 | </property> <property name="lineWidth"> <number>0</number> </property> <property name="alternatingRowColors"> <bool>true</bool> </property> | < < < | 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 | </property> <property name="lineWidth"> <number>0</number> </property> <property name="alternatingRowColors"> <bool>true</bool> </property> | < < < | 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 | </property> <property name="lineWidth"> <number>0</number> </property> <property name="alternatingRowColors"> <bool>true</bool> </property> | < < < | 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 | <widget class="QLabel" name="parsed_cards_label"> <property name="text"> <string>These cards were successfully identified:</string> </property> </widget> </item> <item> | | | 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 | <widget class="CentralWidget" name="central_widget"/> <widget class="QMenuBar" name="menubar"> <property name="geometry"> <rect> <x>0</x> <y>0</y> <width>1050</width> | | > | 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&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 | </property> </action> <action name="action_import_deck_list"> <property name="icon"> <iconset theme="document-import"/> </property> <property name="text"> | | | 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 | 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, | < | 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 | import re import sqlite3 import sys import textwrap import typing from hamcrest import assert_that, contains_exactly | < | 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 | 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: | | | | | 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 | 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 | | | 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 | else: return None @property def current_set_name(self) -> Optional[str]: selected = self.ui.set_name_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 | # 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 | < | | | > | | 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 | "CacheCleanupWizard", ] INVALID_INDEX = QModelIndex() SelectRows = QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows ItemDataRole = Qt.ItemDataRole Orientation = Qt.Orientation | < < < < < < < < < < < < < < < < | 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 | 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: | | | 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 | 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: | | | 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 | def reject(self) -> None: super().reject() logger.info("User canceled the cache cleanup.") self._clear_tooltip_cache() @staticmethod def _clear_tooltip_cache(): | | | | 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 | # 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/>. | | | | | | > < > | | > > > > > > > > > > > > > > > > > > > > > > > | 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 | def __enter__(self): self.qt_object.blockSignals(True) def __exit__(self, exc_type, exc_val, exc_tb): self.qt_object.blockSignals(False) | < < < < < < < < < < < < < | 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 | 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""" | | | 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 | # # 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 | < | > | < | 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 | 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 | | | | | 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 | # 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): | | > > > > > > > > | | | < | < < | | 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 | 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")) | < < < < < < < < < < < < < < < < < < < | 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 | 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) | > | | | | | > > > | | > > > < < < < < < < | | < > | | | < | | > | | 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 | 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 | | > | 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 | # 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 | > | | < | > > > > > > > | > | | | > > > > > > > > > > > > > > | > | < < < < | | > > < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < > > > > > > > > > > > | > > > | > > > > > > | > > > > > > > > > > > > > | > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > | 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 | 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 | | > | | 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 | 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.") | | > > > > > > > > | | | | | | | | | | 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 | 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") | | | | | | | | | | | | 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 | 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() | | | | | < < < < < | > > | 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 | 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 | | > | > | > | > > > < | > > > > | > > > > | | | 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 | 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 | | | 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 | 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 | | | > | 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 | 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]): | | > > > > > > | 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 | Github = "https://github.com/luziferius/MTGProxyPrinter/" [project.gui-scripts] mtg-proxy-printer = "mtg_proxy_printer.__main__:main" [tool.pytest.ini_options] | | | 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 | """ 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", } | > > > > | | 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 | def download_new_translations(args: Namespace): """Downloads translated .ts files from Crowdin via the API""" verify_crowdin_cli_present() subprocess.call([ "crowdin", "download" ]) | | > > > > | 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 | 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 | > > | | 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 | # # 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 | | > | 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 | import typing import unittest.mock import pytest from hamcrest import * | | > | 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 | import typing import unittest.mock import pytest from hamcrest import * | | > | 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 | 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 | | | 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 | import copy import unittest.mock import pytest from hamcrest import * | | | 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 | from unittest.mock import MagicMock from hamcrest import * import pytest | | | 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 | 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 | > | | < | | | > | | | | > > > | | | < < | < > | > > > | < < < < < | | | < < < < | < | 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 | "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") | | < | 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 | # 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 | | | 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 | # 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 | > > > > > > | | < | 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 | import unittest.mock from hamcrest import * import pytest import mtg_proxy_printer.card_info_downloader from mtg_proxy_printer.card_info_downloader import SetWackinessScore | | > | 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.
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted tests/test_carddb.py.
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Changes to tests/test_check_card_rendering.py.
︙ | ︙ | |||
14 15 16 17 18 19 20 | # along with this program. If not, see <http://www.gnu.org/licenses/>. import pytest from hamcrest import * from PyQt5.QtGui import QPixmap, QColorConstants | | | 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.
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted tests/test_document_loader.py.
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
Deleted tests/test_image_db.py.
|
| < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < |
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 | from tests.hasgetter import has_getters from tests.helpers import quantity_close_to mm: UnitT = unit_registry.mm | < < < < < | 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 | 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)) | | < | | | | < | | | 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 | 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 | | | < | | < | | | | | | | < | | | | | | | | | | | | | < | | | | | | | | | | | | | | | < | | | | | | | | | | | | | | | | | | | | | 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 | # 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 * | > | > > > > > > > > > > > > > > > > > | 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 | "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", ]) | | > > > > > > > > > > > | 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 | 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 | > | > | | | | 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 | ({"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( | | | | | | 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 | super(CardListReceiver, self).__init__(parent) self.deck: CardCounter = Counter() def on_import_action_received(self, action: ActionImportDeckList): self.deck = action.cards | | | | 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 | "https://cards.scryfall.io/png/front/a/8/" "a8a64329-09fc-4e0d-b7d1-378635f2801a.png?1619396979"), "image_file": is_(none()), }), 2) ) | | | | | | | | 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 | _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)) | | | < < | 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 | 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()) | | | | | | 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 | # 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/>. | | < < < < | > < < < | < < | | | < < < > | | > > > | | < < > > < < < < | < < < < < < > | | > | < < | | > | | > | < < < | > | | 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 | 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 | > | | 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 | with qtbot.wait_exposed(main_window, timeout=1000): main_window.show() yield main_window main_window.hide() del cid main_window.__dict__.clear() | < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < | 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 | from unittest.mock import NonCallableMagicMock, patch import pytest from pytestqt.qtbot import QtBot from hamcrest import * from mtg_proxy_printer.model.document import Document | | > | 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 | 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 | | | | | 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"]) |
︙ | ︙ |