├── .dockerignore ├── .editorconfig ├── .env.example ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── composer.dockerfile ├── composer.json ├── composer.lock ├── composer.sh ├── config └── config.php ├── docker-compose.yml ├── docker └── alpine │ └── Dockerfile ├── entrypoint.sh ├── examples ├── formats │ ├── json.json │ ├── names.txt │ ├── omega.txt │ ├── ydk.txt │ └── ydke.txt └── imageify │ ├── LICENSE.txt │ ├── banner.jpg │ └── deck-list.jpeg ├── public ├── .htaccess ├── common │ ├── .htaccess │ ├── _base.php │ ├── format.php │ └── json.php ├── convert.php ├── detect.php ├── favicon.ico ├── imageify.php ├── index.php ├── parse.php ├── scripts │ ├── index.js │ └── lib.js ├── static │ ├── background-omega.png │ ├── background-simple.jpg │ ├── background-simple.png │ ├── background.jpg │ ├── background.png │ ├── unknown-fullsize.jpg │ └── unknown.jpg ├── styles │ └── index.css ├── vendor │ ├── autosize │ │ └── autosize.min.js │ └── cookie.js │ │ ├── LICENSE │ │ └── cookie.umd.min.js └── webhook │ └── update.php ├── scripts ├── install.sh ├── permissions.sh ├── populate-cache.php └── update-database.php └── src ├── Config.php ├── Format ├── Format.php ├── FormatDecodeTester.php ├── FormatDecoder.php ├── FormatEncoder.php ├── FormatException.php ├── NeedsRepository.php ├── decoders │ ├── JsonFormatDecoder.php │ ├── NameFormatDecoder.php │ ├── OmegaFormatDecoder.php │ ├── YdkFormatDecoder.php │ └── YdkeFormatDecoder.php ├── encoders │ ├── JsonFormatEncoder.php │ ├── NameFormatEncoder.php │ ├── OmegaFormatEncoder.php │ ├── YdkFormatEncoder.php │ └── YdkeFormatEncoder.php └── formats │ ├── NameFormat.php │ ├── OmegaFormat.php │ ├── YdkFormat.php │ └── YdkeFormat.php ├── Game ├── Card.php ├── CardList.php ├── Deck.php ├── DeckEntry.php ├── DeckList.php ├── DeckType.php ├── Repository │ ├── DataCard.php │ ├── NameMatchOptions.php │ ├── Repository.php │ └── SqliteRepository.php └── decks │ ├── ExtraDeck.php │ ├── MainDeck.php │ └── SideDeck.php ├── Http ├── Http.php ├── JsonErrorResponse.php ├── JsonResponse.php └── Response.php ├── Image ├── Image.php ├── ImageCache.php ├── ImageCacheEntry.php ├── ImageException.php ├── ImageKey.php ├── ImageType.php └── MemoryImage.php ├── Json ├── JsonException.php └── Serializable.php ├── Render ├── Cell.php ├── CellFactory.php ├── CellOverlap.php ├── CellView.php ├── Rectangle.php ├── Spacing.php ├── Table.php ├── TableLayout.php ├── TableRoot.php └── Vector.php ├── Utility ├── BitField.php ├── ListObject.php └── TypedListObject.php ├── definitions.php ├── factory.php └── scripts ├── db.php ├── helpers.php └── log.php /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | /vendor/ 3 | /data/ 4 | 5 | .env 6 | *.db 7 | *.cdb 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 4 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | 13 | [examples/**/*.*] 14 | trim_trailing_whitespace = false 15 | insert_final_newline = false 16 | 17 | [*.{css,js,json,yaml,yml,html}] 18 | indent_size = 2 19 | 20 | [config/config*.php] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=https://example.com/database/cards.cdb 2 | CARD_IMAGE_LOOKUP_JSON_URL=https://example.com/image_urls.json 3 | CARD_IMAGE_URL=https://example.com/images 4 | CARD_IMAGE_URL_EXT=jpg 5 | REQUEST_TOKEN=somerandomstring 6 | REQUEST_TOKEN_IN_UI=false 7 | WEBHOOK_UPDATE_TOKEN=somerandomstring 8 | PORT=8080 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /data/ 3 | 4 | .env* 5 | !.env.example 6 | 7 | .DS_Store 8 | *.sublime-* 9 | *.cdb 10 | *.db 11 | 12 | /config/config.dev.php 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.4-apache 2 | 3 | ARG BUILD_DEVELOPMENT 4 | ENV PHP_ENV=${BUILD_DEVELOPMENT:+development} 5 | ENV PHP_ENV=${PHP_ENV:-production} 6 | 7 | RUN DEBIAN_FRONTEND=noninteractive apt-get update \ 8 | && DEBIAN_FRONTEND=noninteractive apt-get install -y \ 9 | curl \ 10 | git \ 11 | zip unzip 12 | 13 | RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer 14 | 15 | ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ 16 | RUN chmod uga+x /usr/local/bin/install-php-extensions && sync 17 | 18 | RUN install-php-extensions \ 19 | opcache \ 20 | gd \ 21 | && a2enmod rewrite 22 | 23 | ENV APACHE_DOCUMENT_ROOT /var/www/public 24 | RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf 25 | RUN sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf 26 | 27 | RUN mv $PHP_INI_DIR/php.ini-$PHP_ENV $PHP_INI_DIR/php.ini 28 | 29 | ENV COMPOSER_ALLOW_SUPERUSER 1 30 | 31 | 32 | WORKDIR /var/www 33 | 34 | COPY composer.json . 35 | COPY composer.lock . 36 | 37 | RUN composer install --prefer-dist --no-dev --no-autoloader \ 38 | && rm -rf $(composer config --global home) 39 | 40 | COPY config /var/www/config 41 | COPY public /var/www/public 42 | COPY src /var/www/src 43 | 44 | RUN composer dump-autoload --no-dev --optimize 45 | 46 | 47 | ENV DATA_DIR /opt/data 48 | RUN mkdir -p ${DATA_DIR} && \ 49 | chown www-data:www-data ${DATA_DIR} && \ 50 | chmod g+s ${DATA_DIR} 51 | VOLUME ${DATA_DIR} 52 | 53 | COPY scripts /var/www/scripts 54 | COPY entrypoint.sh /usr/local/bin/ 55 | 56 | RUN chmod +x \ 57 | scripts/install.sh \ 58 | /usr/local/bin/entrypoint.sh 59 | 60 | RUN mkdir -p /opt/bin 61 | ENV PATH="/opt/bin:${PATH}" 62 | 63 | ENTRYPOINT ["entrypoint.sh"] 64 | CMD [] 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jonas van den Berg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## omega-api-decks 2 | 3 | ![Thumbnail showing a generated deck list image](examples/imageify/banner.jpg) 4 | 5 | This is a service for converting a deck list to any of the following things: 6 | 7 | - another format 8 | - an image of the deck and all its cards 9 | - a `JSON` object containing all information about the deck 10 | 11 | It can also be used to simply detect the format of your input. 12 | 13 | --- 14 | 15 | ### Configuration 16 | 17 | You can configure the behaviour of the API in [`config/config.php`](config/config.php). 18 | 19 | ### Environment Variables 20 | 21 | In order to run this application, you need to configure some environment variables: 22 | 23 | ```bash 24 | DATABASE_URL=https://example.com/database/cards.cdb 25 | CARD_IMAGE_LOOKUP_JSON_URL=https://example.com/image_urls.json 26 | CARD_IMAGE_URL=https://example.com/images 27 | CARD_IMAGE_URL_EXT=jpg 28 | REQUEST_TOKEN=somerandomstring 29 | REQUEST_TOKEN_IN_UI=true 30 | WEBHOOK_UPDATE_TOKEN=somerandomstring 31 | PORT=8080 32 | ``` 33 | 34 | An example can be found in the file `.env.example`. 35 | 36 | URLs for images are built like this: `{CARD_IMAGE_URL}/{passcode}.{CARD_IMAGE_URL_EXT}`. There is also a `CARD_IMAGE_LOOKUP_JSON_URL` which should give a JSON file that maps card IDs (`passcode`) to a valid image URL. At least one of these options must be given. If both are given, the results of `CARD_IMAGE_URL` are preferred. 37 | 38 | The `REQUEST_TOKEN` is optional and if present, requires a token in the URL path for authorized access to every API endpoint. Pass it as `?token=somerandomstring` with the value that is set in your env-file. Set `REQUEST_TOKEN_IN_UI` to `false`, `no` or `0` if you wish to hide the token in the web interface, which might be desirable if the token is used to make sure only authorized parties can use the API. By default, the token is visible through the UI, so users can convert deck codes with the web interface. At this time, the UI simply doesn't work if `REQUEST_TOKEN_IN_UI` is set to a falsey value (this might be updated in the future by implementing an option to disable the UI). 39 | 40 | The `WEBHOOK_UPDATE_TOKEN` exists to prevent unauthorized requests to the [`webhook/update`](public/webhook/update.php) endpoint. It must be set and is separate from the `REQUEST_TOKEN`. Pass it as `?token=somerandomstring` with the value that is set in your env-file. 41 | 42 | Make sure you generate a secure (random) token and rebuild the container whenever you change any environment variables. 43 | 44 | ### Production 45 | 46 | Make sure to create a `.env` file with above environment variables. 47 | 48 | Run the following commands and you're ready to go: 49 | 50 | ``` 51 | # docker compose up -d --build production 52 | # docker compose exec -u www-data production update-database 53 | # docker compose exec -u www-data production populate-cache 54 | ``` 55 | 56 | **NOTE**: It's important to run the above `exec` commands as the user `www-data`, otherwise created files will be owned by `root` and cannot be modified by the `httpd` instance when invoked through HTTP. 57 | 58 | [`update-database`](scripts/update-database.php) will automatically download and store the newest card database from your configured source (`DATABASE_URL`). This might take a bit depending of the size of the download and your bandwidth. After that you won't have to download it again. 59 | 60 | Populating the image cache will take a while, as all card images are downloaded and scaled down. In case an image is missing in the local cache during a request, it is downloaded within that request and persisted locally. It's thus not strictly necessary to pre-populate the cache, but doing this will reduce the number of cache misses during any request and the delay that this process would cause to a minimum. Once an image is cached it will be reused by all subsequent requests. The images are scaled down to normalize their resolution and only store as much data as is necessary. 61 | 62 | ### Development 63 | 64 | Make sure to create a `.env.dev` file with above environment variables. 65 | 66 | Run the following commands to set up your development environment: 67 | 68 | ``` 69 | # ./composer.sh install 70 | # docker compose up -d --build development 71 | # docker compose exec development update-database 72 | # scripts/permissions.sh 73 | ``` 74 | 75 | The last command fixes permissions of the `data`-folder in the root of the project, so that it can be written to by the development container. 76 | 77 | --- 78 | 79 | ### Supported Deck Formats 80 | 81 | |Format|Identifier| 82 | |:-|:-:| 83 | |[**`YDK`**](examples/formats/ydk.txt)|`ydk`| 84 | |[**`YDKE`**](examples/formats/ydke.txt)|`ydke`| 85 | |[**`Omega code`**](examples/formats/omega.txt)|`omega`| 86 | |[**`List of card names`**](examples/formats/names.txt)|`names`| 87 | |[**`JSON object`**](examples/formats/json.json)|`json`| 88 | 89 | Cards in the list of names are associated with a deck by the following rules: 90 | 91 | - The first 40 non-Extra Deck cards go to the Main Deck 92 | - The first 15 Extra Deck cards go to the Extra Deck 93 | - Up to 60 cards before the first Extra Deck card go to the Main Deck 94 | - The remaining cards are put into the Side Deck 95 | 96 | Alternatively, one can describe where cards belong by putting a line in front of them that contains the name of the respective deck (`main`, `side` or `extra`, case-insensitive), similar to how it works with the [`YDK`](examples/formats/ydk.txt) format. 97 | 98 | ### Common Query Parameters 99 | 100 | All endpoints have the following set of query parameters in common: 101 | 102 | **`?list=`** — A deck list in any format. This format may be any of the above and is detected on the fly. 103 | 104 | **`?=`** — `` may be any valid identifier and informs the service about the input format. This way the service does not have to guess based on the input. This is the recommended option in case the input format is known at the time of requesting this endpoint. 105 | 106 | `` resembles the deck list that is to be handled by the request. 107 | 108 | All JSON endpoints also have the `?pretty` query parameter which formats JSON nicely. 109 | 110 | `NOTE`: Query parameters must be URL encoded (e.g. with `encodeURIComponent()` in JavaScript). 111 | 112 | --- 113 | 114 | ### Endpoints 115 | 116 | ##### `/imageify` 117 | Generates an image of the deck list like you know it from YGOPro and friends. 118 | The optional query parameter `&quality=` configures the resulting image quality. Accepts values from 0 (worst) to 100 (best). 119 | 120 | #### JSON endpoints 121 | 122 | ##### `/detect` 123 | Parses input and returns its format. 124 | 125 | ##### `/parse` 126 | Parses input and outputs deck information in form of a `JSON` object. 127 | 128 | ##### `/convert` 129 | Converts a deck list from one format to all other formats. 130 | The optional query parameter `&to=` restricts the conversion to only one format. 131 | 132 | #### JSON structure 133 | 134 | The JSON for a successful response is structure in the following way: 135 | ```json 136 | { 137 | "success": true, 138 | "meta": { 139 | "format": "" 140 | }, 141 | "data": { 142 | 143 | } 144 | } 145 | ``` 146 | The `meta` object contains meta information about the request like the type of the input. 147 | The `data` object contains the generated data of the respective endpoint. 148 | 149 | An erroneous request returns JSON of this structure: 150 | ```json 151 | { 152 | "success": false, 153 | "meta": { 154 | "error": "" 155 | }, 156 | "data": {} 157 | } 158 | ``` 159 | The `error` field contains an error message describing why your request failed. 160 | 161 | --- 162 | 163 | ### Examples 164 | 165 | Using the [JSON input from the examples directory](examples/formats/json.json): 166 | 167 | `GET /convert?pretty&to=omega&list={"main":[27204311,2720...` 168 | 169 | Converts the deck list to an Omega code. This is the response: 170 | ```json 171 | { 172 | "success": true, 173 | "meta": { 174 | "format": "json" 175 | }, 176 | "data": { 177 | "formats": { 178 | "omega": "0+a6LjWfEYbv\/L\/MAMIXps0AY4kjoiww\/PbQdlYYFuz7zgDDKmaXWGB4zsmPjCC8uMSeGYRfys5kheHgpcuZQXj3GXs4XnDhIQscP7oGx\/ll7xlguPCSLrM1cx1L\/+bXjBYbk1k0uaWYg753MQcD8Ub3TWD8MGIuGIPsBNkBAA==" 179 | } 180 | } 181 | } 182 | ``` 183 | 184 | You can omit the `to` query parameter to get all formats: 185 | 186 | ```json 187 | { 188 | "success": true, 189 | "meta": { 190 | "format": "json" 191 | }, 192 | "data": { 193 | "formats": { 194 | "omega": "0+a6LjWfEYbv\/L\/MAMIXps0AY4kjoiww\/PbQdlYYFuz7zgDDKmaXWGB4zsmPjCC8uMSeGYRfys5kheHgpcuZQXj3GXs4XnDhIQscP7oGx\/ll7xlguPCSLrM1cx1L\/+bXjBYbk1k0uaWYg753MQcD8Ub3TWD8MGIuGIPsBNkBAA==", 195 | "ydke": "ydke:\/\/1xqfAdcanwHXGp8B3P\/TANz\/0wDQlpgA0JaYABjEFQQYxBUEGMQVBO3CtwXtwrcF7cK3BRGO9wARjvcAEY73ACQ20gQkNtIEJDbSBJzJ8QGcyfEBo3Q\/A6N0PwPpHZkF6R2ZBekdmQVTpacDU6WnA7vMPwO7zD8Du8w\/A6DQ4QSg0OEEoNDhBKDi1gSg4tYEoOLWBG927wBvdu8Ab3bvAA==!cdItAzsDfgSPs+sB!OLFjBCkLGgNS94oDU\/eKA7FHsgOxR7ID4VidA+FYnQOjdD8DU6WnAw==!", 196 | "ydk": "#main\n27204311\n27204311\n27204311\n13893596\n13893596\n10000080\n10000080\n68535320\n68535320\n68535320\n95929069\n95929069\n95929069\n16223761\n16223761\n16223761\n80885284\n80885284\n80885284\n32623004\n32623004\n54490275\n54490275\n93920745\n93920745\n93920745\n61318483\n61318483\n54512827\n54512827\n54512827\n81907872\n81907872\n81907872\n81191584\n81191584\n81191584\n15693423\n15693423\n15693423\n#extra\n53334641\n75367227\n32224143\n!side\n73642296\n52038441\n59438930\n59438931\n62015409\n62015409\n60643553\n60643553\n54490275\n61318483", 197 | "names": "3 Nibiru, the Primal Being\n2 Exodius the Ultimate Forbidden Lord\n2 The Winged Dragon of Ra - Sphere Mode\n3 Fire Hand\n3 Ice Hand\n3 Thunder Hand\n3 Ghostrick Jiangshi\n2 Nopenguin\n2 Ghostrick Yuki-onna\n3 Penguin Soldier\n2 Ghostrick Jackfrost\n3 Ghostrick Lantern\n3 Ghostrick Specter\n3 Recurring Nightmare\n3 Evenly Matched\n\n1 Ghostrick Angel of Mischief\n1 Ghostrick Alucard\n1 Ghostrick Socuteboss\n\n\n1 Ghost Belle & Haunted Mansion\n1 Ghost Mourner & Moonlit Chill\n1 Ghost Ogre & Snow Rabbit\n1 Ghost Ogre & Snow Rabbit\n2 Ghost Reaper & Winter Cherries\n2 Ghost Sister & Spooky Dogwood\n1 Ghostrick Yuki-onna\n1 Ghostrick Jackfrost", 198 | "json": "{\"main\":[27204311,27204311,27204311,13893596,13893596,10000080,10000080,68535320,68535320,68535320,95929069,95929069,95929069,16223761,16223761,16223761,80885284,80885284,80885284,32623004,32623004,54490275,54490275,93920745,93920745,93920745,61318483,61318483,54512827,54512827,54512827,81907872,81907872,81907872,81191584,81191584,81191584,15693423,15693423,15693423],\"extra\":[53334641,75367227,32224143],\"side\":[73642296,52038441,59438930,59438931,62015409,62015409,60643553,60643553,54490275,61318483]}" 199 | } 200 | } 201 | } 202 | ``` 203 | 204 | Using the `/imageify` endpoint, you might generate an image like this: 205 | 206 | ![Image of a Deck List](/examples/imageify/deck-list.jpeg) 207 | -------------------------------------------------------------------------------- /composer.dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.4-cli 2 | COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer 3 | RUN apt-get update && apt-get install -y --no-install-recommends git zip unzip 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "php": "^7.4.0", 4 | "monolog/monolog": "^2.1" 5 | }, 6 | "require-dev": {}, 7 | "autoload": { 8 | "classmap": [ 9 | "src/" 10 | ], 11 | "files": [ 12 | "src/definitions.php", 13 | "src/factory.php", 14 | "config/config.php", 15 | "src/scripts/db.php", 16 | "src/scripts/helpers.php", 17 | "src/scripts/log.php" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "88b50e7f0fba18a28f6072a71f0515f6", 8 | "packages": [ 9 | { 10 | "name": "monolog/monolog", 11 | "version": "2.9.1", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/Seldaek/monolog.git", 15 | "reference": "f259e2b15fb95494c83f52d3caad003bbf5ffaa1" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/Seldaek/monolog/zipball/f259e2b15fb95494c83f52d3caad003bbf5ffaa1", 20 | "reference": "f259e2b15fb95494c83f52d3caad003bbf5ffaa1", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "php": ">=7.2", 25 | "psr/log": "^1.0.1 || ^2.0 || ^3.0" 26 | }, 27 | "provide": { 28 | "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0" 29 | }, 30 | "require-dev": { 31 | "aws/aws-sdk-php": "^2.4.9 || ^3.0", 32 | "doctrine/couchdb": "~1.0@dev", 33 | "elasticsearch/elasticsearch": "^7 || ^8", 34 | "ext-json": "*", 35 | "graylog2/gelf-php": "^1.4.2 || ^2@dev", 36 | "guzzlehttp/guzzle": "^7.4", 37 | "guzzlehttp/psr7": "^2.2", 38 | "mongodb/mongodb": "^1.8", 39 | "php-amqplib/php-amqplib": "~2.4 || ^3", 40 | "phpspec/prophecy": "^1.15", 41 | "phpstan/phpstan": "^0.12.91", 42 | "phpunit/phpunit": "^8.5.14", 43 | "predis/predis": "^1.1 || ^2.0", 44 | "rollbar/rollbar": "^1.3 || ^2 || ^3", 45 | "ruflin/elastica": "^7", 46 | "swiftmailer/swiftmailer": "^5.3|^6.0", 47 | "symfony/mailer": "^5.4 || ^6", 48 | "symfony/mime": "^5.4 || ^6" 49 | }, 50 | "suggest": { 51 | "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", 52 | "doctrine/couchdb": "Allow sending log messages to a CouchDB server", 53 | "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", 54 | "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", 55 | "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", 56 | "ext-mbstring": "Allow to work properly with unicode symbols", 57 | "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", 58 | "ext-openssl": "Required to send log messages using SSL", 59 | "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", 60 | "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", 61 | "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", 62 | "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", 63 | "rollbar/rollbar": "Allow sending log messages to Rollbar", 64 | "ruflin/elastica": "Allow sending log messages to an Elastic Search server" 65 | }, 66 | "type": "library", 67 | "extra": { 68 | "branch-alias": { 69 | "dev-main": "2.x-dev" 70 | } 71 | }, 72 | "autoload": { 73 | "psr-4": { 74 | "Monolog\\": "src/Monolog" 75 | } 76 | }, 77 | "notification-url": "https://packagist.org/downloads/", 78 | "license": [ 79 | "MIT" 80 | ], 81 | "authors": [ 82 | { 83 | "name": "Jordi Boggiano", 84 | "email": "j.boggiano@seld.be", 85 | "homepage": "https://seld.be" 86 | } 87 | ], 88 | "description": "Sends your logs to files, sockets, inboxes, databases and various web services", 89 | "homepage": "https://github.com/Seldaek/monolog", 90 | "keywords": [ 91 | "log", 92 | "logging", 93 | "psr-3" 94 | ], 95 | "support": { 96 | "issues": "https://github.com/Seldaek/monolog/issues", 97 | "source": "https://github.com/Seldaek/monolog/tree/2.9.1" 98 | }, 99 | "funding": [ 100 | { 101 | "url": "https://github.com/Seldaek", 102 | "type": "github" 103 | }, 104 | { 105 | "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", 106 | "type": "tidelift" 107 | } 108 | ], 109 | "time": "2023-02-06T13:44:46+00:00" 110 | }, 111 | { 112 | "name": "psr/log", 113 | "version": "1.1.4", 114 | "source": { 115 | "type": "git", 116 | "url": "https://github.com/php-fig/log.git", 117 | "reference": "d49695b909c3b7628b6289db5479a1c204601f11" 118 | }, 119 | "dist": { 120 | "type": "zip", 121 | "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", 122 | "reference": "d49695b909c3b7628b6289db5479a1c204601f11", 123 | "shasum": "" 124 | }, 125 | "require": { 126 | "php": ">=5.3.0" 127 | }, 128 | "type": "library", 129 | "extra": { 130 | "branch-alias": { 131 | "dev-master": "1.1.x-dev" 132 | } 133 | }, 134 | "autoload": { 135 | "psr-4": { 136 | "Psr\\Log\\": "Psr/Log/" 137 | } 138 | }, 139 | "notification-url": "https://packagist.org/downloads/", 140 | "license": [ 141 | "MIT" 142 | ], 143 | "authors": [ 144 | { 145 | "name": "PHP-FIG", 146 | "homepage": "https://www.php-fig.org/" 147 | } 148 | ], 149 | "description": "Common interface for logging libraries", 150 | "homepage": "https://github.com/php-fig/log", 151 | "keywords": [ 152 | "log", 153 | "psr", 154 | "psr-3" 155 | ], 156 | "support": { 157 | "source": "https://github.com/php-fig/log/tree/1.1.4" 158 | }, 159 | "time": "2021-05-03T11:20:27+00:00" 160 | } 161 | ], 162 | "packages-dev": [], 163 | "aliases": [], 164 | "minimum-stability": "stable", 165 | "stability-flags": [], 166 | "prefer-stable": false, 167 | "prefer-lowest": false, 168 | "platform": { 169 | "php": "^7.4.0" 170 | }, 171 | "platform-dev": [], 172 | "plugin-api-version": "2.3.0" 173 | } 174 | -------------------------------------------------------------------------------- /composer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TEMP_CONTAINER_NAME=omega-api-decks-composer-update-b87b0498cb3bc649 4 | 5 | echo "Running command: composer $@" 6 | 7 | docker build -f composer.dockerfile -t "$TEMP_CONTAINER_NAME" . \ 8 | && docker container run --rm -v $(pwd):/app/ "$TEMP_CONTAINER_NAME" \ 9 | /bin/bash -c "cd app; composer $@" \ 10 | && docker image rm "$TEMP_CONTAINER_NAME" 11 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | [ 25 | 'encoders' => [ 26 | Format::OMEGA => Format\OmegaFormatEncoder::class, 27 | Format::YDKE => Format\YdkeFormatEncoder::class, 28 | Format::YDK => Format\YdkFormatEncoder::class, 29 | Format::NAMES => Format\NameFormatEncoder::class, 30 | Format::JSON => Format\JsonFormatEncoder::class 31 | ], 32 | 'decoders' => [ 33 | Format::YDK => Format\YdkFormatDecoder::class, 34 | Format::YDKE => Format\YdkeFormatDecoder::class, 35 | Format::OMEGA => Format\OmegaFormatDecoder::class, 36 | Format::NAMES => Format\NameFormatDecoder::class, 37 | Format::JSON => Format\JsonFormatDecoder::class 38 | ] 39 | ], 40 | 41 | 'images' => [ 42 | // the path to the background image for a deck list. 43 | // the dimensions of the tables which are defined later 44 | // in this file depend on the characteristics of this image. 45 | 'background' => STATIC_DIR . '/background-omega.png', 46 | 47 | // the placeholder image for a card that is not found in the database. 48 | 'placeholder' => STATIC_DIR . '/unknown.jpg', 49 | 50 | // if true, resized images are being resampled. 51 | 'resample_resized' => true 52 | ], 53 | 54 | 'output' => [ 55 | // the image type as which a generated deck list image is encoded. 56 | 'image_type' => ImageType::JPEG, 57 | 'content_disposition' => [ 58 | // the filename specified in the Content-Disposition header. 59 | // this is the name of the file one would download from their browser. 60 | 'filename' => 'deck-list' 61 | ] 62 | ], 63 | 64 | 'cache' => [ 65 | // the directory in which cached images are stored 66 | 'directory' => Config::get_env('DATA_DIR') . '/cache', 67 | 68 | // images are stored in subfolders which are named by the first N 69 | // characters of their filename. this clustering enables faster glob 70 | // pattern matching and possibly also shorter file lookup times. 71 | 'subfolder_length' => 2, 72 | 73 | // if true, card images are stored with their original dimensions. 74 | // doing this results in resizing each card image on every (!) request, 75 | // which will most certainly add up to a couple hundred milliseconds 76 | // of additional execution time. enabling this is not recommended. 77 | 'cache_original' => false 78 | ], 79 | 80 | 'repository' => [ 81 | // the name of the generated database file. 82 | 'path' => Config::get_env('DATA_DIR') . '/card.db', 83 | 'options' => new NameMatchOptions( 84 | 2 / 5, // maximum difference in length between input and a name (per letter) 85 | 1 / 5 // maximum amount of allowed errors per letter 86 | ), 87 | // the name of the lock file that is used for keeping updates atomic. 88 | 'update_lock_file' => Config::get_env('DATA_DIR') . '/update.lock~' 89 | ], 90 | 91 | 'image_urls' => [ 92 | // path to the JSON file containg ID -> URL key-value pairs. 93 | 'lookup_json_path' => Config::get_env('DATA_DIR') . '/imgurls.json', 94 | ], 95 | 96 | ]); 97 | 98 | 99 | $table_x = 25; // common x root component of every table 100 | $table_width = 902; // same width for each table 101 | $cell_spacing = new Spacing(10, 7); // spacing between cells 102 | 103 | 104 | // the dimensions of a table's cell 105 | Config::set('cell', new Rectangle(81, 118)); 106 | 107 | Config::set('tables', [ 108 | 109 | 'main' => [ 110 | 'root' => new Vector($table_x, 60), 111 | 'root_center_offset' => new Vector(0, 24 - $cell_spacing->vertical()), 112 | 'spacing' => $cell_spacing, 113 | 'width' => $table_width, 114 | 'height' => 526, 115 | 'layout' => [ 116 | 'primary' => TableLayout::LEFT_TO_RIGHT, 117 | 'secondary' => TableLayout::TOP_TO_BOTTOM 118 | ], 119 | 'overlap' => CellOverlap::VERTICAL 120 | ], 121 | 122 | 'extra' => [ 123 | 'root' => new Vector($table_x, 645), 124 | 'spacing' => $cell_spacing, 125 | 'width' => $table_width, 126 | 'height' => Config::get('cell')->height(), 127 | 'layout' => [ 128 | 'primary' => TableLayout::LEFT_TO_RIGHT, 129 | 'secondary' => TableLayout::TOP_TO_BOTTOM 130 | ], 131 | 'overlap' => CellOverlap::HORIZONTAL 132 | ], 133 | 134 | 'side' => [ 135 | 'root' => new Vector($table_x, 818), 136 | 'spacing' => $cell_spacing, 137 | 'width' => $table_width, 138 | 'height' => Config::get('cell')->height(), 139 | 'layout' => [ 140 | 'primary' => TableLayout::LEFT_TO_RIGHT, 141 | 'secondary' => TableLayout::TOP_TO_BOTTOM 142 | ], 143 | 'overlap' => CellOverlap::HORIZONTAL 144 | ] 145 | 146 | ]); 147 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | production: 5 | build: . 6 | ports: 7 | - ${PORT:-8000}:80 8 | env_file: .env 9 | volumes: 10 | - php-data:/opt/data 11 | 12 | development: 13 | build: 14 | context: . 15 | args: 16 | BUILD_DEVELOPMENT: 1 17 | ports: 18 | - ${PORT:-8080}:80 19 | env_file: .env.dev 20 | volumes: 21 | - ./src:/var/www/src 22 | - ./config:/var/www/config 23 | - ./public:/var/www/public 24 | - ./scripts:/var/www/scripts 25 | - ./vendor:/var/www/vendor 26 | - ./data:/opt/data 27 | 28 | volumes: 29 | php-data: 30 | -------------------------------------------------------------------------------- /docker/alpine/Dockerfile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungive/omega-api-decks/87e2d026ca4423b941e8bf4a852e3ed984ab0801/docker/alpine/Dockerfile -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | $(cd scripts; ./install.sh /opt/bin) 4 | 5 | if [ "$#" -gt "0" ]; then 6 | $@; exit 7 | fi 8 | 9 | set +x 10 | 11 | # TODO: if the database already exists, then run the update in the background 12 | # otherwise wait until the update is complete 13 | 14 | docker-php-entrypoint apache2-foreground 15 | -------------------------------------------------------------------------------- /examples/formats/json.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": [ 3 | 27204311, 4 | 27204311, 5 | 27204311, 6 | 13893596, 7 | 13893596, 8 | 10000080, 9 | 10000080, 10 | 68535320, 11 | 68535320, 12 | 68535320, 13 | 95929069, 14 | 95929069, 15 | 95929069, 16 | 16223761, 17 | 16223761, 18 | 16223761, 19 | 80885284, 20 | 80885284, 21 | 80885284, 22 | 32623004, 23 | 32623004, 24 | 54490275, 25 | 54490275, 26 | 93920745, 27 | 93920745, 28 | 93920745, 29 | 61318483, 30 | 61318483, 31 | 54512827, 32 | 54512827, 33 | 54512827, 34 | 81907872, 35 | 81907872, 36 | 81907872, 37 | 81191584, 38 | 81191584, 39 | 81191584, 40 | 15693423, 41 | 15693423, 42 | 15693423 43 | ], 44 | "extra": [ 45 | 53334641, 46 | 75367227, 47 | 32224143 48 | ], 49 | "side": [ 50 | 73642296, 51 | 52038441, 52 | 59438930, 53 | 59438931, 54 | 62015409, 55 | 62015409, 56 | 60643553, 57 | 60643553, 58 | 54490275, 59 | 61318483 60 | ] 61 | } -------------------------------------------------------------------------------- /examples/formats/names.txt: -------------------------------------------------------------------------------- 1 | 1 Dark Spirit of Malice 2 | 1 Dark Spirit of Banishment 3 | 1 Archfiend Empress 4 | 1 Curse Necrofear 5 | 1 Grinder Golem 6 | 1 Abominable Unchained Soul 7 | 1 Unchained Soul of Disaster 8 | 3 Unchained Twins – Sarama 9 | 3 Unchained Twins – Aruha 10 | 3 Graff, Malebranche of the Burning Abyss 11 | 1 Cir, Malebranche of the Burning Abyss 12 | 1 Scarm, Malebranche of the Burning Abyss 13 | 1 Barbar, Malebranche of the Burning Abyss 14 | 2 Tour Guide From the Underworld 15 | 1 Fiendish Rhino Warrior 16 | 17 | 1 Spirit Message “I” 18 | 1 Spirit Message “L” 19 | 1 Spirit Message “N” 20 | 1 Spirit Message “A” 21 | 3 Dark Spirit’s Mastery 22 | 2 Dark Sanctuary 23 | 3 Abomination’s Prison 24 | 1 Card Destruction 25 | 26 | 1 Destiny Board 27 | 3 Abominable Chamber of the Unchained 28 | 1 Call of the Archfiend 29 | 30 | 2 Unchained Soul of Rage 31 | 1 Unchained Abomination 32 | 2 Unchained Soul of Anguish 33 | 1 Cherubini, Ebon Angel of the Burning Abyss 34 | 2 Dante, Traveler of the Burning Abyss 35 | 1 Dante, Pilgrim of the Burning Abyss 36 | 1 Security Dragon 37 | 2 Linkuriboh 38 | 1 Akashic Magician 39 | 1 Mekk-Knight Crusadia Avramax 40 | 1 Knightmare Phoenix -------------------------------------------------------------------------------- /examples/formats/omega.txt: -------------------------------------------------------------------------------- 1 | 0+a6LjWfEYbv/L/MAMIXps0AY4kjoiww/PbQdlYYFuz7zgDDKmaXWGB4zsmPjCC8uMSeGYRfys5kheHgpcuZQXj3GXs4XnDhIQscP7oGx/ll7xlguPCSLrM1cx1L/+bXjBYbk1k0uaWYg753MQcD8Ub3TWD8MGIuGIPsBNkBAA== -------------------------------------------------------------------------------- /examples/formats/ydk.txt: -------------------------------------------------------------------------------- 1 | #created by Player 2 | #main 3 | 49036338 4 | 23401839 5 | 23401839 6 | 23401839 7 | 95492061 8 | 95492061 9 | 95492061 10 | 90307777 11 | 90307777 12 | 38814750 13 | 38814750 14 | 38814750 15 | 88240999 16 | 52068432 17 | 25857246 18 | 25857246 19 | 25857246 20 | 26674724 21 | 26674724 22 | 26674724 23 | 89463537 24 | 89463537 25 | 89463537 26 | 99185129 27 | 99185129 28 | 99185129 29 | 14532163 30 | 14532163 31 | 14532163 32 | 32807846 33 | 49238328 34 | 49238328 35 | 49238328 36 | 96729612 37 | 96729612 38 | 96729612 39 | 14735698 40 | 14735698 41 | 51124303 42 | 51124303 43 | 97211663 44 | 97211663 45 | #extra 46 | 23995346 47 | 90050480 48 | 12652643 49 | 74586817 50 | 79606837 51 | 79606837 52 | 56832966 53 | 17016362 54 | 84013238 55 | 46772449 56 | 581014 57 | 21044178 58 | 49202162 59 | 87054946 60 | 50277355 61 | !side 62 | 15661378 63 | 79606837 64 | 55285840 65 | 62517849 66 | 94770493 -------------------------------------------------------------------------------- /examples/formats/ydke.txt: -------------------------------------------------------------------------------- 1 | ydke://EUKKAwrmpwEK5qcBR5uPAEebjwBHm48AvadvAfx5vAKzoLECTkEDAE5BAwBOQQMAfjUBBUwyuADDhdcAnNXGA/ZJ0ACmm/QBPqRxAT6kcQE+pHEBVhgUAVYYFAFWGBQBZOgnA2ToJwNk6CcDIkiZACJImQAiSJkAdgljAnYJYwJ2CWMCVOZcAVTmXAF9e0AChKFCAYShQgGEoUIBPO4FAzzuBQM=!y7sdAIoTdQOKE3UDwLXNA9EgZgUNUFsFtWJvAqRbfAOkW3wDlk8AAoVAsQKA9rsBlI9dAQdR1QE5ySIF!URCDA1EQgwNREIMDI9adAiPWnQJvdu8Ab3bvANcanwHXGp8B1xqfASaQQgMmkEIDJpBCA0O+3QBDvt0A! -------------------------------------------------------------------------------- /examples/imageify/LICENSE.txt: -------------------------------------------------------------------------------- 1 | 0. Definitions. 2 | 3 | "I" refers to me, the owner of this repository and the code therein. Refer to 4 | the LICENSE file in the root of the project for more information. 5 | 6 | A "Card" refers to any playing card of the Yu-Gi-Oh!® Trading Card Game. 7 | 8 | The "Artwork" refers to the visual painting or illustration on a Card. For 9 | more information visit . 10 | 11 | An "Image" refers to the parsed content - or parts of it - of a file ending 12 | with a file extension like jpg, jpeg, png or anything alike. 13 | 14 | 1. Statement. 15 | 16 | Artworks of Cards depicted in any Image in the same folder as this file or 17 | any subfolders thereof are property of the following entity: 18 | 19 | © Studio Dice/SHUEISHA, TV TOKYO, KONAMI 20 | 21 | 22 | I hereby refrain from any copyright claims of those Artworks or anything 23 | related to it. I am in no way affiliated with the named entity and do not own 24 | or claim to own any of its property. -------------------------------------------------------------------------------- /examples/imageify/banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungive/omega-api-decks/87e2d026ca4423b941e8bf4a852e3ed984ab0801/examples/imageify/banner.jpg -------------------------------------------------------------------------------- /examples/imageify/deck-list.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungive/omega-api-decks/87e2d026ca4423b941e8bf4a852e3ed984ab0801/examples/imageify/deck-list.jpeg -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine on 2 | 3 | RewriteCond %{REQUEST_FILENAME} !-d 4 | RewriteCond %{REQUEST_FILENAME}.php -f 5 | RewriteRule ^(.*)$ $1.php 6 | 7 | RewriteCond %{THE_REQUEST} "^[^ ]* .*?\.php[? ].*$" 8 | RewriteRule .* - [L,R=404] 9 | -------------------------------------------------------------------------------- /public/common/.htaccess: -------------------------------------------------------------------------------- 1 | Deny from all 2 | -------------------------------------------------------------------------------- /public/common/_base.php: -------------------------------------------------------------------------------- 1 | decode($input); 70 | 71 | # TODO: put effort in something better than this... 72 | if ($decoder instanceof FormatDecodeTester) 73 | $format = $decoder->last_format_name(); 74 | } 75 | catch (FormatDecodeException $e) { 76 | $message = $e->getMessage(); 77 | $errors = $e->getErrors(); 78 | Http::fail("failed to decode deck list: $message", Http::BAD_REQUEST, 79 | [], [ 'format_errors' => $errors ]); 80 | } 81 | catch (\Exception $e) { 82 | $message = $e->getMessage(); 83 | Http::fail("an error occured while handling your request: $message"); 84 | } 85 | 86 | try { 87 | $decks->validate(true); 88 | } 89 | catch (\Exception $e) { 90 | Http::fail($e->getMessage(), Http::BAD_REQUEST); 91 | } 92 | 93 | return $decks; 94 | } 95 | -------------------------------------------------------------------------------- /public/common/json.php: -------------------------------------------------------------------------------- 1 | options(get_query_json_options()); 24 | 25 | if ($meta_format !== null) 26 | $response->meta('format', $meta_format); 27 | 28 | return $response; 29 | } 30 | -------------------------------------------------------------------------------- /public/convert.php: -------------------------------------------------------------------------------- 1 | $supported_encoders[$convert_to] ]; 20 | } 21 | 22 | foreach ($encoders as $name => $class) 23 | try { 24 | $encoders[$name] = Config\create_encoder_from_class($class); 25 | } 26 | catch (\Exception $e) { 27 | $message = $e->getMessage(); 28 | Http::fail("an error occured while handling your request: $message"); 29 | } 30 | 31 | $decks = Base\decode_query_deck($input_format); 32 | $response = Base\create_json_response($input_format); 33 | 34 | $formats = []; 35 | foreach ($encoders as $name => $encoder) 36 | try { 37 | $formats[$name] = $encoder->encode($decks); 38 | } 39 | catch (\Exception $e) { 40 | $message = $e->getMessage(); 41 | Http::fail("an error occured while handling your request: $message"); 42 | } 43 | 44 | $response->data('formats', $formats); 45 | 46 | Http::send($response); 47 | -------------------------------------------------------------------------------- /public/detect.php: -------------------------------------------------------------------------------- 1 | unique_card_codes() as $code) 25 | $cache->load($code); 26 | 27 | if ($cache_original) 28 | $cache->flush(); 29 | 30 | foreach ($decks->unique_card_codes() as $code) { 31 | $entry = $cache->get($code); 32 | if ($entry === null) 33 | Http::fail("an unexpected error occured whilst loading a card image"); 34 | 35 | $image = $entry->image(); 36 | $image->resize( 37 | $cell_dimensions->width(), 38 | $cell_dimensions->height(), 39 | $resample_resized 40 | ); 41 | } 42 | 43 | if (!$cache_original) 44 | $cache->flush(); 45 | 46 | 47 | $deck_image = Image::from_file(Config::get('images')['background']); 48 | if ($deck_image === null) 49 | Http::fail("failed to open deck background", Http::INTERNAL_SERVER_ERROR); 50 | 51 | // preserve alpha when working with PNG images. 52 | if ($deck_image->type() === ImageType::PNG) { 53 | imagealphablending($deck_image->handle(), false); 54 | imagesavealpha($deck_image->handle(), true); 55 | } 56 | 57 | 58 | $tables = [ 59 | Config\create_deck_table('main', $decks->main), 60 | Config\create_deck_table('extra', $decks->extra), 61 | Config\create_deck_table('side', $decks->side), 62 | ]; 63 | 64 | foreach ($tables as $table) 65 | foreach ($table->cells() as $cell) { 66 | 67 | $card = $cell->content(); 68 | $image = $cache->get($card->code)->image(); 69 | $position = $cell->position(); 70 | 71 | $dimensions = new Rectangle( 72 | $cell->visible_width(), 73 | $cell->visible_height() 74 | ); 75 | 76 | $cell_offset = new Vector( 77 | $cell->x_offset(), 78 | $cell->y_offset() 79 | ); 80 | 81 | $deck_image->insert( 82 | $image, 83 | $position->plus($cell_offset), 84 | $cell_offset, 85 | $dimensions, 86 | $dimensions 87 | ); 88 | } 89 | 90 | 91 | $image_type = Config::get('output')['image_type']; 92 | $filename = Config::get('output')['content_disposition']['filename']; 93 | 94 | $mime_type = ImageType::mime_type($image_type); 95 | $extension = ImageType::extension($image_type, false); 96 | 97 | header("Content-Type: $mime_type"); 98 | header("Content-Disposition: filename=$filename.$extension"); 99 | 100 | $quality_raw = Http::get_query_parameter('quality', false, -1); 101 | $quality = intval($quality_raw); 102 | 103 | if (!is_numeric($quality_raw)) Http::fail("quality must be numeric"); 104 | if ($quality < -1 || $quality > 100) 105 | Http::fail("quality must be between 0 and 100, inclusive"); 106 | 107 | switch ($image_type) { 108 | case ImageType::JPEG: break; 109 | case ImageType::PNG: 110 | $quality = intval(round($quality * 9 / 100)); 111 | break; 112 | default: 113 | Http::fail("the quality parameter is not applicable for $mime_type"); 114 | } 115 | 116 | $deck_image->echo($image_type, false, $quality); 117 | 118 | 119 | 120 | # TODO: cache generated images for a certain amount of time. 121 | # good opportunity to learn Redis e.g. 122 | 123 | 124 | 125 | # TODO: make configurable that you can iterate 126 | # backwards, so that cards can overlap the other way round 127 | 128 | // $cells = (function () use ($tables) { 129 | // $cells = []; 130 | // foreach ($tables as $table) 131 | // foreach ($table->cells() as $cell) 132 | // $cells[] = $cell; 133 | // 134 | // foreach(array_reverse($cells) as $cell) 135 | // yield $cell; 136 | // })(); 137 | 138 | # TODO: try drawing cards in random order 139 | 140 | // $cells = (function () use ($tables) { 141 | // $cells = []; 142 | // foreach ($tables as $table) 143 | // foreach ($table->cells() as $cell) 144 | // $cells[] = $cell; 145 | // 146 | // while (count($cells) > 0) { 147 | // $key = array_rand($cells); 148 | // get_logger('i')->info($key); 149 | // $cell = $cells[$key]; 150 | // unset($cells[$key]); 151 | // yield $cell; 152 | // } 153 | // })(); 154 | 155 | # TODO: print numbers with font 'Eurostile Regular'. 156 | # https://fonts.adobe.com/fonts/eurostile 157 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Decks 7 | 8 | 9 | 10 | 11 | 14 |
15 |
16 |
17 | 20 |
21 | 28 | 35 |
36 |
37 |
38 | 111 |
112 | 120 | 125 | 129 | 130 | 131 | 132 | 133 | -------------------------------------------------------------------------------- /public/parse.php: -------------------------------------------------------------------------------- 1 | data('decks', $decks); 15 | 16 | Http::send($response); 17 | -------------------------------------------------------------------------------- /public/scripts/index.js: -------------------------------------------------------------------------------- 1 | const TEXTAREA_HEIGHT = 'ta_height'; 2 | const TEXTAREA_VALUE = 'ta_value'; 3 | const NAVIGATION_SELECTED = 'nav_selected'; 4 | 5 | const elements = { 6 | deck: { 7 | image: document.querySelector('#deck-image'), 8 | link: document.querySelector('#deck-image-link') 9 | }, 10 | submit: document.querySelector('#submit'), 11 | output: document.querySelector('#output'), 12 | textarea: document.querySelector('#deck-list') 13 | }; 14 | 15 | if (cookie.get(TEXTAREA_HEIGHT)) 16 | elements.textarea.style.height = cookie.get(TEXTAREA_HEIGHT) + 'px'; 17 | 18 | if (cookie.get(TEXTAREA_VALUE)) { 19 | elements.textarea.value = atob(cookie.get(TEXTAREA_VALUE)); 20 | if (elements.textarea.value.length > 0) 21 | window.addEventListener('load', function () { 22 | computeOutputHeight(); 23 | submit(); 24 | }); 25 | } 26 | 27 | if (cookie.get(NAVIGATION_SELECTED)) { 28 | const labelId = cookie.get(NAVIGATION_SELECTED); 29 | const label = document.getElementById(labelId); 30 | window.addEventListener('load', function () { 31 | label.click(); 32 | }); 33 | } 34 | 35 | document.body.classList.remove('invisible'); 36 | 37 | // Store the height of the text area 38 | // in a cookie for consecutive page loads. 39 | onClientResize(elements.textarea, 40 | function (element) { 41 | cookie.set(TEXTAREA_HEIGHT, element.offsetHeight, { sameSite: 'Strict' }); 42 | } 43 | ); 44 | 45 | // Focus a textarea immediately when clicking on its label. 46 | for (label of document.querySelectorAll('label')) { 47 | const selector = 'label ~ textarea#' + label.htmlFor; 48 | const textarea = label.parentElement.querySelector(selector); 49 | 50 | if (!textarea) 51 | continue; 52 | 53 | label.addEventListener('mousedown', function (event) { 54 | setTimeout(function () { 55 | textarea.focus(); 56 | }, 0); 57 | }); 58 | } 59 | 60 | const allTabElements = document.querySelectorAll('#navigation > label'); 61 | let lastSelectedTab = document.querySelector('#navigation > label'); 62 | 63 | function selectTab (element) { 64 | const input = document.querySelector('#' + element.htmlFor); 65 | input.checked = true; 66 | 67 | const output = document.querySelector('#output'); 68 | const previousHeight = output.style.height; 69 | const scrollOffset = document.documentElement.scrollTop; 70 | 71 | // Memorize the height so we don't jump to the top 72 | output.style.height = output.clientHeight + 'px'; 73 | 74 | for (other of allTabElements) 75 | if (other !== element) 76 | other.classList.remove('selected'); 77 | element.classList.add('selected'); 78 | 79 | cookie.set(NAVIGATION_SELECTED, element.id, { sameSite: 'Strict' }); 80 | setTimeout(function () { 81 | computeOutputHeight(); 82 | 83 | // Reset everything 84 | output.style.height = previousHeight; 85 | document.documentElement.scrollTop = scrollOffset; 86 | }, 0); 87 | } 88 | 89 | // Add CSS class for selected navigation element. 90 | for (element of allTabElements) 91 | element.addEventListener('click', function (event) { 92 | selectTab(this); 93 | lastSelectedTab = this; 94 | }); 95 | 96 | for (element of document.querySelectorAll('#output-convert textarea')) 97 | (function () { 98 | element.addEventListener('click', function (event) { 99 | this.select(); 100 | }); 101 | })(); 102 | 103 | // Handle auto-submission when: 104 | // 105 | // 1. Pasting input 106 | // 2. Pressing the Enter key (with or without CTRL-modifier) 107 | // 3. Navigating away from a line after changing it 108 | // 109 | (function () { 110 | let isTextChanged; 111 | let lastKeyCode; 112 | let keyCode; 113 | 114 | const _submit = function () { 115 | isTextChanged = false; 116 | submit(); 117 | }; 118 | 119 | const _submitChanged = function () { 120 | if (isTextChanged) 121 | return _submit(); 122 | }; 123 | 124 | elements.textarea.addEventListener('mousedown', _submitChanged); 125 | elements.textarea.addEventListener('focusout', _submitChanged); 126 | 127 | elements.textarea.addEventListener('keydown', 128 | function (event) { 129 | keyCode = event.keyCode; 130 | 131 | if (event.key === 'Enter' && event.ctrlKey) 132 | return _submit(); 133 | 134 | if (isTextChanged) 135 | if (event.key === 'ArrowUp' || event.key === 'ArrowDown') 136 | return _submit(); 137 | } 138 | ); 139 | 140 | elements.textarea.addEventListener('keyup', function (event) { 141 | if (event.keyCode === 86 /* V */ && event.ctrlKey) 142 | return _submit(); 143 | }); 144 | 145 | elements.textarea.addEventListener('input', 146 | (function () { 147 | const handle = function (callback) { 148 | return function (event) { 149 | callback.call(this, event); 150 | lastKeyCode = keyCode; 151 | }; 152 | }; 153 | 154 | return handle(function (event) { 155 | if (this.value.trim().length === 0) 156 | cookie.remove(TEXTAREA_VALUE); 157 | 158 | if (lastKeyCode === 13 /* Enter */) return; 159 | 160 | if (keyCode === 13 /* Enter */) 161 | return _submit(); 162 | 163 | if (event.inputType === 'insertFromPaste') { 164 | console.log(elements.textarea.value 165 | .replace(/\\n/g, '\n').replace(/\\"/g, '\"') 166 | .replace(/\\\\/g, '\\').replace(/\\\//g, '/')); 167 | elements.textarea.value = elements.textarea.value 168 | .replace(/\\n/g, '\n').replace(/\\"/g, '\"') 169 | .replace(/\\\\/g, '\\').replace(/\\\//g, '/'); 170 | return _submit(); 171 | } 172 | 173 | isTextChanged = true; 174 | }); 175 | })() 176 | ); 177 | 178 | elements.submit.addEventListener('mouseup', function () { this.blur(); }); 179 | elements.submit.addEventListener('mousedown', _submitChanged); 180 | })(); 181 | 182 | function computeOutputHeight () { 183 | document 184 | .querySelectorAll('#output textarea') 185 | .forEach(function (element) { 186 | const style = window.getComputedStyle(element); 187 | const pad = parseFloat(style.paddingBottom); 188 | 189 | element.style.height = 0; 190 | element.style.height = (element.scrollHeight + pad) + 'px'; 191 | }); 192 | } 193 | 194 | window.addEventListener('resize', computeOutputHeight); 195 | 196 | // Factory for creating a new endpoint 197 | const endpoint = function (endpoint, list, query) { 198 | query = query || {}; 199 | query.list = list; 200 | return new Endpoint(endpoint, query); 201 | }; 202 | 203 | // Shortcuts for specific endpoints 204 | endpoint.convert = endpoint.bind(endpoint, 'convert'); 205 | endpoint.imageify = endpoint.bind(endpoint, 'imageify'); 206 | 207 | function submit () { 208 | console.debug('submit'); 209 | 210 | let list = decodeURIComponent(elements.textarea.value); 211 | 212 | const endsLineFeed = list.endsWith('\n'); 213 | list = list.trim() + (endsLineFeed ? '\n' : ''); 214 | 215 | cookie.set(TEXTAREA_VALUE, btoa(list), { sameSite: 'Strict' }); 216 | 217 | list = list.trim(); 218 | 219 | const promises = [ 220 | endpoint 221 | .imageify(list, { quality: 10 }) 222 | .fetch() 223 | .then(function (res) { 224 | res.blob().then(handleImage); 225 | elements.deck.link.href = endpoint.imageify(list).url(); 226 | }), 227 | endpoint 228 | .convert(list) 229 | .fetch() 230 | .then(function (res) { 231 | res.json().then(function (json) { 232 | handleConversion(json); 233 | }); 234 | }) 235 | ]; 236 | 237 | return Promise 238 | .all(promises) 239 | .then(function (data) { 240 | elements.output.classList.remove('hidden'); 241 | computeOutputHeight(); 242 | }) 243 | .catch(function (error) { 244 | elements.output.classList.remove('hidden'); 245 | handleError(error); 246 | }); 247 | } 248 | 249 | function hideError () { 250 | const element = document.querySelector('#navigation > #label-error'); 251 | const error = document.querySelector('#output-error'); 252 | const separator = error.parentElement.querySelector('span.separator'); 253 | const content = error.parentElement.querySelector('span.content'); 254 | 255 | error.value = ''; 256 | content.innerText = ''; 257 | separator.classList.add('hidden'); 258 | element.classList.add('hidden'); 259 | selectTab(lastSelectedTab); 260 | console.log(lastSelectedTab); 261 | } 262 | 263 | function showError () { 264 | const element = document.querySelector('#navigation > #label-error'); 265 | const separator = document.querySelector('#output span.separator'); 266 | 267 | separator.classList.remove('hidden'); 268 | element.classList.remove('hidden'); 269 | selectTab(element); 270 | } 271 | 272 | function handleError (data) { 273 | const error = document.querySelector('#output-error'); 274 | const content = error.parentElement.querySelector('span.content'); 275 | 276 | error.value = JSON.stringify(data, undefined, 2); 277 | 278 | const parts = data.meta.error.split(':'); 279 | const message = parts[parts.length - 1].trim(); 280 | content.innerText = message; 281 | 282 | computeOutputHeight(); 283 | showError(); 284 | } 285 | 286 | function handleImage (blob) { 287 | createObjectURL(blob) 288 | .then(function (url) { 289 | elements.deck.image.src = url; 290 | }); 291 | } 292 | 293 | function handleConversion ({ meta, data }) { 294 | hideError(); 295 | 296 | const omega = document.querySelector('#output-omega'); 297 | const ydke = document.querySelector('#output-ydke'); 298 | const ydk = document.querySelector('#output-ydk'); 299 | const names = document.querySelector('#output-names'); 300 | const json = document.querySelector('#output-json'); 301 | 302 | omega.value = data.formats.omega; 303 | ydke.value = data.formats.ydke; 304 | ydk.value = data.formats.ydk; 305 | names.value = data.formats.names; 306 | json.value = JSON.stringify(JSON.parse(data.formats.json), undefined, 2); 307 | 308 | computeOutputHeight(); 309 | } 310 | -------------------------------------------------------------------------------- /public/scripts/lib.js: -------------------------------------------------------------------------------- 1 | function fixedEncodeURIComponent (str) { 2 | return encodeURIComponent(str).replace(/[!'()*]/g, function (c) { 3 | return '%' + c.charCodeAt(0).toString(16); 4 | }); 5 | } 6 | 7 | function createObjectURL (blob) { 8 | return new Promise(function (resolve, reject) { 9 | const reader = new FileReader(); 10 | reader.readAsDataURL(blob); 11 | reader.onloadend = function () { 12 | resolve(reader.result); 13 | }; 14 | reader.onerror = reject; 15 | }); 16 | } 17 | 18 | function flattenQuery (query) { 19 | const items = []; 20 | 21 | for (const key in query) { 22 | if (!query.hasOwnProperty(key)) 23 | continue; 24 | const name = fixedEncodeURIComponent(key); 25 | const value = fixedEncodeURIComponent(query[key]); 26 | items.push([name, value].join('=')); 27 | } 28 | 29 | return items.join('&'); 30 | } 31 | 32 | function onTextAreaResize (element, callback) { 33 | let width = element.clientWidth; 34 | let height = element.clientHeight; 35 | 36 | window.addEventListener('mouseup', function () { 37 | if (element.clientWidth != width || element.clientHeight != height) 38 | return callback(element); 39 | 40 | width = element.clientWidth; 41 | height = element.clientHeight; 42 | }); 43 | } 44 | 45 | function onClientResize (element, callback) { 46 | if (element.tagName === 'TEXTAREA') 47 | return onTextAreaResize(element, callback); 48 | throw 'Not implemented'; 49 | } 50 | 51 | function getRequestToken() { 52 | return document.getElementById('data-request-token').getAttribute('data-request-token'); 53 | } 54 | 55 | function Endpoint (name, query) { 56 | this.name = fixedEncodeURIComponent(name); 57 | this.query = query; 58 | query['token'] = getRequestToken(); 59 | } 60 | 61 | Endpoint.prototype.url = function () { 62 | const name = fixedEncodeURIComponent(this.name); 63 | const queryString = flattenQuery(this.query); 64 | return [name, queryString].join('?'); 65 | }; 66 | 67 | Endpoint.prototype.fetch = function () { 68 | const url = this.url(); 69 | return new Promise(function (resolve, reject) { 70 | fetch(url) 71 | .then(function (res) { 72 | if (res.status !== 200) 73 | return res.json().then(reject); 74 | resolve(res); 75 | }) 76 | .catch(reject); 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /public/static/background-omega.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungive/omega-api-decks/87e2d026ca4423b941e8bf4a852e3ed984ab0801/public/static/background-omega.png -------------------------------------------------------------------------------- /public/static/background-simple.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungive/omega-api-decks/87e2d026ca4423b941e8bf4a852e3ed984ab0801/public/static/background-simple.jpg -------------------------------------------------------------------------------- /public/static/background-simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungive/omega-api-decks/87e2d026ca4423b941e8bf4a852e3ed984ab0801/public/static/background-simple.png -------------------------------------------------------------------------------- /public/static/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungive/omega-api-decks/87e2d026ca4423b941e8bf4a852e3ed984ab0801/public/static/background.jpg -------------------------------------------------------------------------------- /public/static/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungive/omega-api-decks/87e2d026ca4423b941e8bf4a852e3ed984ab0801/public/static/background.png -------------------------------------------------------------------------------- /public/static/unknown-fullsize.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungive/omega-api-decks/87e2d026ca4423b941e8bf4a852e3ed984ab0801/public/static/unknown-fullsize.jpg -------------------------------------------------------------------------------- /public/static/unknown.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungive/omega-api-decks/87e2d026ca4423b941e8bf4a852e3ed984ab0801/public/static/unknown.jpg -------------------------------------------------------------------------------- /public/styles/index.css: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | overflow: hidden; 4 | height: 100%; 5 | } 6 | 7 | footer { 8 | position: absolute; 9 | bottom: 0; 10 | left: 0; 11 | right: 0; 12 | text-align: center; 13 | display: block; 14 | background-color: white; 15 | box-shadow: 0px 0 1px rgba(0, 0, 0, 0.6); 16 | font-family: Arial, Helvetica, sans-serif; 17 | font-size: 0.68em; 18 | padding-top: 4px; 19 | padding-bottom: 6px; 20 | } 21 | 22 | .noselect { 23 | -webkit-touch-callout: none; 24 | -webkit-user-select: none; 25 | -khtml-user-select: none; 26 | -moz-user-select: none; 27 | -ms-user-select: none; 28 | user-select: none; 29 | } 30 | 31 | html, 32 | body { 33 | height: 100vh; 34 | padding: 0; 35 | margin: 0; 36 | } 37 | 38 | div { 39 | margin: 0; 40 | padding: 0; 41 | } 42 | 43 | body { 44 | overflow-y: scroll; 45 | } 46 | 47 | .invisible { 48 | visibility: hidden; 49 | } 50 | 51 | .hidden { 52 | display: none; 53 | } 54 | 55 | #main { 56 | width: 500px; 57 | padding: 4em 0; 58 | margin: auto; 59 | letter-spacing: 0.8px; 60 | } 61 | 62 | #main * { 63 | font-size: 0.9rem; 64 | font-family: serif; 65 | } 66 | 67 | #main textarea { 68 | font-family: monospace; 69 | font-size: 1rem; 70 | } 71 | 72 | #main .item { 73 | margin-top: 0.8em; 74 | } 75 | 76 | #main .item.indent { 77 | padding-left: 1em; 78 | } 79 | 80 | .border { 81 | border-radius: 5px; 82 | border: 1px solid #ccc; 83 | -webkit-box-shadow: 1px 1px 1px #999; 84 | -moz-box-shadow: 1px 1px 1px #999; 85 | box-shadow: 1px 1px 1px #999; 86 | -webkit-box-sizing: border-box; 87 | -moz-box-sizing: border-box; 88 | box-sizing: border-box; 89 | } 90 | 91 | textarea { 92 | line-height: 1.3; 93 | padding: 0.5em; 94 | padding: 0.5em 0.5em 0.3em 1em; 95 | width: 100%; 96 | } 97 | 98 | textarea:focus { 99 | outline: none; 100 | border-radius: 5px; 101 | border-color: #222; 102 | border-width: 2px; 103 | padding: calc(0.5em - 1px) calc(0.5em - 1px) calc(0.2em - 1px) calc(1em - 1px); 104 | } 105 | 106 | #output textarea { 107 | max-height: calc(20rem + 0.8em); 108 | word-break: break-all; 109 | height: auto; 110 | } 111 | 112 | #output textarea:focus { 113 | padding: 0.5em 0.5em 0.3em 1em; 114 | border-width: 1px; 115 | } 116 | 117 | #text-box { 118 | position: relative; 119 | float: left; 120 | width: 100%; 121 | } 122 | 123 | #submit { 124 | position: absolute; 125 | right: 1.4em; 126 | bottom: 1.4em; 127 | border-radius: 4px; 128 | background-color: #fdfdfd; 129 | padding: 4px 12px; 130 | } 131 | 132 | #submit:focus { 133 | outline: none; 134 | border-color: #222; 135 | border-width: 2px; 136 | right: calc(1.4em - 1px); 137 | bottom: calc(1.4em - 1px); 138 | } 139 | 140 | #output { 141 | margin: 1em 0.5em; 142 | margin-right: 0.4em; 143 | } 144 | 145 | #output .tab > div:first-of-type { 146 | margin-top: 1.2em; 147 | } 148 | 149 | #output textarea { 150 | resize: none; 151 | border-radius: 0px 0px 5px 5px; 152 | } 153 | 154 | #input textarea { 155 | min-height: 128px; 156 | max-height: 400px; 157 | word-break: break-all; 158 | resize: vertical; 159 | } 160 | 161 | label { 162 | display: block; 163 | -webkit-user-select: none; 164 | -moz-user-select: none; 165 | -ms-user-select: none; 166 | user-select: none; 167 | } 168 | 169 | #navigation { 170 | margin-top: 1.5em; 171 | -webkit-user-select: none; 172 | -moz-user-select: none; 173 | -ms-user-select: none; 174 | user-select: none; 175 | } 176 | 177 | #navigation > label { 178 | display: inline-block; 179 | margin-right: 8px; 180 | margin-top: 2em; 181 | padding: 3px 12px; 182 | border-radius: 4px; 183 | } 184 | 185 | #navigation > label.selected { 186 | text-decoration: underline; 187 | -webkit-box-shadow: 1px 1px 1px #555; 188 | -moz-box-shadow: 1px 1px 1px #555; 189 | box-shadow: 1px 1px 1px #555; 190 | } 191 | 192 | #navigation > label:hover { 193 | cursor: pointer; 194 | border-color: #999; 195 | -webkit-box-shadow: 1px 1px 1px #777; 196 | -moz-box-shadow: 1px 1px 1px #777; 197 | box-shadow: 1px 1px 1px #777; 198 | } 199 | 200 | input[type='radio'] ~ .bind-radio { 201 | display: none; 202 | } 203 | 204 | input[type='radio']:checked ~ .bind-radio { 205 | display: inherit; 206 | } 207 | 208 | #output section { 209 | margin-top: 0.5em; 210 | } 211 | 212 | #output .tab label { 213 | color: #494949; 214 | background-color: #f8f8f8; 215 | font-family: monospace !important; 216 | font-weight: bold; 217 | border-bottom-width: 0; 218 | border-bottom-left-radius: 0; 219 | border-bottom-right-radius: 0; 220 | padding: 0.5em 0; 221 | padding-left: 1.2em; 222 | } 223 | 224 | #output .tab label.error { 225 | color: #e00; 226 | } 227 | 228 | #output .tab label.error > span { 229 | color: #444 !important; 230 | font-family: monospace; 231 | font-size: 0.7rem; 232 | position: relative; 233 | color: initial; 234 | bottom: 1px; 235 | } 236 | 237 | #output .tab label.error > span.separator.hidden ~ span.content { 238 | display: none; 239 | } 240 | 241 | textarea#output-error { 242 | word-break: normal; 243 | } 244 | 245 | img#deck-image { 246 | width: 100%; 247 | border-radius: 5px; 248 | } 249 | 250 | @media only screen and (max-width: 600px) { 251 | div#main { 252 | width: calc(100% - 100px); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /public/vendor/autosize/autosize.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | autosize 4.0.2 3 | license: MIT 4 | http://www.jacklmoore.com/autosize 5 | */ 6 | !function(e,t){if("function"==typeof define&&define.amd)define(["module","exports"],t);else if("undefined"!=typeof exports)t(module,exports);else{var n={exports:{}};t(n,n.exports),e.autosize=n.exports}}(this,function(e,t){"use strict";var n,o,p="function"==typeof Map?new Map:(n=[],o=[],{has:function(e){return-1info("received request: " . basename(__FILE__)); 15 | 16 | 17 | $token_param = 'token'; 18 | if (!isset($_GET[$token_param])) { 19 | $log->alert("aborting: security token not set"); 20 | Http::close(Http::NOT_FOUND); 21 | } 22 | 23 | $expected_token = getenv('WEBHOOK_UPDATE_TOKEN'); 24 | if ($expected_token !== false && !hash_equals($_GET[$token_param], $expected_token)) { 25 | $log->critical("aborting: invalid token: " . $_GET[$token_param]); 26 | Http::close(Http::NOT_FOUND); 27 | } 28 | 29 | $database_url = getenv('DATABASE_URL'); 30 | if ($database_url === false) { 31 | $log->error("aborting: database url is not defined"); 32 | Http::close(Http::INTERNAL_SERVER_ERROR); 33 | } 34 | 35 | 36 | // close the connection 37 | http_response_code(Http::OK); 38 | header("Connection: close"); 39 | // https://github.com/docker-library/php/issues/1113 40 | header("Content-Encoding: none"); 41 | header("Content-Length: " . 0); 42 | ob_end_flush(); 43 | flush(); 44 | 45 | 46 | $update_lock = new FileLock(Config::get('repository')['update_lock_file']); 47 | if ($update_lock->is_locked()) { 48 | $log->warning("aborting: an update seems to be in progress already"); 49 | exit(1); 50 | } 51 | 52 | $update_lock->lock(); 53 | Db\update_database($database_url); 54 | $update_lock->unlock(); 55 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BIN="$1" 4 | 5 | function create_script() { 6 | cat << EOF 7 | #!/bin/bash 8 | cd "$(pwd)" 9 | php "$script" "\$@" 10 | EOF 11 | } 12 | 13 | for script in *.php; do 14 | NAME="${script%.*}" 15 | FILE="$BIN/$NAME" 16 | create_script "$script" > $FILE 17 | chmod +x "$FILE" 18 | done 19 | -------------------------------------------------------------------------------- /scripts/permissions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ $EUID != 0 ]; then 4 | sudo "$0" "$@" 5 | exit $? 6 | fi 7 | 8 | USER=${1:-$SUDO_USER} 9 | 10 | WWW_UID=$(docker compose exec development id www-data -u | tr -d '\r') 11 | WWW_GID=$(docker compose exec development id www-data -g | tr -d '\r') 12 | echo "www-data uid:gid: $WWW_UID:$WWW_GID" 13 | 14 | set -x 15 | 16 | mkdir -p data 17 | chown -R $WWW_UID:$WWW_GID data 18 | chmod g+s data 19 | -------------------------------------------------------------------------------- /scripts/populate-cache.php: -------------------------------------------------------------------------------- 1 | loader(function (ImageKey $key, int $type): ?Image { 18 | return Config\image_loader($key, $type, false); 19 | }); 20 | 21 | 22 | // $db = new \PDO('sqlite:' . Config::get('repository')['path']); 23 | $db = Config\create_repository_pdo(); 24 | 25 | $cards = $db->query(" SELECT id FROM card ORDER BY CAST(id AS TEXT) "); 26 | $count = intval($db->query(" SELECT COUNT(id) FROM card ")->fetchColumn()); 27 | 28 | $failed = []; 29 | 30 | $total = 0; 31 | $loaded = 0; 32 | $last_percent = 0.0; 33 | 34 | $log->info("loading cards..."); 35 | 36 | $cell_dimensions = Config::get('cell'); 37 | $cache_original = Config::get('cache')['cache_original']; 38 | $resample_resized = Config::get('images')['resample_resized']; 39 | 40 | foreach ($cards as $row) { 41 | $code = $row['id']; 42 | $total ++; 43 | 44 | $entry = $cache->load_with_loader($code); 45 | 46 | if ($entry === null) { 47 | $failed[] = $code; 48 | continue; 49 | } 50 | 51 | $loaded ++; 52 | 53 | $image = $entry->image(); 54 | 55 | // resize if we're not caching originals 56 | if (!$cache_original) 57 | $image->resize( 58 | $cell_dimensions->width(), 59 | $cell_dimensions->height(), 60 | $resample_resized 61 | ); 62 | 63 | // flush and clear the buffer frequently so that 64 | // we don't fill up our memory unnecessarily. 65 | if (count($cache) >= FLUSH_FREQUENCY) { 66 | $amount = $cache->flush(); 67 | $cache->clear_buffer(); 68 | } 69 | 70 | $percent = 100.0 * $total / $count; 71 | if ($percent > $last_percent + PROGRESS_STEP) { 72 | $percent = intval($percent / PROGRESS_STEP) * PROGRESS_STEP; 73 | $last_percent = $percent; 74 | 75 | $log->info("progress: $percent% ($total of $count)"); 76 | } 77 | } 78 | 79 | $cache->flush(); // don't forget to flush the remaining cards! 80 | 81 | $log->info("$loaded of $count cards loaded"); 82 | 83 | if ($loaded < $total) { 84 | $missing = $total - $loaded; 85 | $failed = implode(", ", $failed); 86 | $log->info("$missing cards failed to load: $failed"); 87 | } 88 | 89 | $log->info("done."); 90 | -------------------------------------------------------------------------------- /scripts/update-database.php: -------------------------------------------------------------------------------- 1 | error("aborting: database url is not defined"); 14 | exit(1); 15 | } 16 | 17 | 18 | $update_lock = new FileLock(Config::get('repository')['update_lock_file']); 19 | if ($update_lock->is_locked()) { 20 | $log->warning("aborting: an update seems to be in progress already"); 21 | exit(1); 22 | } 23 | 24 | $update_lock->lock(); 25 | if (count($argv) == 1) { 26 | Db\update_database($database_url); 27 | Db\update_image_urls(); 28 | } 29 | else if (in_array("--database", $argv)) { 30 | Db\update_database($database_url); 31 | } 32 | else if (in_array("--image-urls", $argv)) { 33 | Db\update_image_urls(); 34 | } 35 | $update_lock->unlock(); 36 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | $value) 24 | self::set($key, $value); 25 | } 26 | 27 | public static function get_env(string $name): string 28 | { 29 | if (($value = getenv($name)) !== false) 30 | return $value; 31 | 32 | if (isset(self::$env_defaults[$name])) 33 | return self::$env_defaults[$name]; 34 | 35 | throw new \Exception("environment variable $name is not set"); 36 | } 37 | 38 | public static function put_env(string $name, string $value): void 39 | { 40 | if (putenv("$name=$value") === false) 41 | throw new \Exception("failed to set environment variable $name"); 42 | } 43 | 44 | public static function require_env(string $name, ?string $default = null): void 45 | { 46 | if (getenv($name) === false && $default === null) 47 | throw new \Exception("required environment variable $name is not set"); 48 | 49 | self::$env_defaults[$name] = $default; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Format/Format.php: -------------------------------------------------------------------------------- 1 | decoders[$name] = $decoder; 15 | } 16 | 17 | # TODO: put effort in something better than this... 18 | private ?string $last_format_name = null; 19 | 20 | function decode(string $input): DeckList 21 | { 22 | if (count($this->decoders) === 0) 23 | throw new FormatDecodeException( 24 | "cannot decode without any decoders"); 25 | 26 | $errors = []; 27 | 28 | foreach ($this->decoders as $name => $decoder) 29 | try { 30 | $list = $decoder->decode($input); 31 | $this->last_format_name = $name; 32 | $exception = null; 33 | break; 34 | } 35 | catch (FormatDecodeException $e) { 36 | $errors[$name] = $e->getMessage(); 37 | $exception = $e; 38 | } 39 | 40 | if ($exception !== null) 41 | throw new FormatDecodeException( 42 | "could not determine format from input", $errors); 43 | 44 | return $list; 45 | } 46 | 47 | function last_format_name(): ?string { return $this->last_format_name; } 48 | } 49 | -------------------------------------------------------------------------------- /src/Format/FormatDecoder.php: -------------------------------------------------------------------------------- 1 | errors = $errors; 15 | } 16 | 17 | public function getErrors(): array 18 | { 19 | return $this->errors; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Format/NeedsRepository.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Format/decoders/JsonFormatDecoder.php: -------------------------------------------------------------------------------- 1 | getMessage(); 18 | throw new FormatDecodeException(lcfirst($message)); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Format/decoders/NameFormatDecoder.php: -------------------------------------------------------------------------------- 1 | decks())) as $name) 30 | $deck_names[] = strtolower($name); 31 | $deck_names = implode('|', $deck_names); 32 | 33 | // a card at index n is the first card of the last consecutive 34 | // block of cards that is preceded by n lines of whitespace. 35 | // all cards after the greatest number of empty lines will be 36 | // considered side deck cards (assuming there are no extra deck cards). 37 | $potential_first_side_cards = []; 38 | $separator_height = 0; 39 | 40 | $separator_count = 0; 41 | 42 | $entries = explode("\n", trim($input)); 43 | foreach ($entries as $index => $entry) { 44 | $line = $index + 1; 45 | $entry = trim($entry); 46 | 47 | if (strlen($entry) === 0) { 48 | $separator_height++; 49 | continue; 50 | } 51 | 52 | $matches = []; 53 | $result = preg_match(self::LINE_CARD_REGEX, $entry, $matches); 54 | 55 | if (!$result) { 56 | $result = preg_match("/($deck_names)/", strtolower($entry), $matches); 57 | if (!$result) 58 | continue; 59 | 60 | $deck_name = $matches[1]; 61 | $deck = $deck_list->$deck_name; 62 | 63 | if ($current_deck === null) { 64 | // in case there are cards before the first 65 | // separator we add all of them to the Main Deck. 66 | foreach ($cards as $card) 67 | $deck_list->main->add($card); 68 | $cards = []; 69 | } 70 | 71 | $current_deck = $deck; 72 | continue; 73 | } 74 | 75 | $card_count = intval($matches[1]); 76 | $card_name = $matches[2]; 77 | 78 | $card = $this->repository->get_card_by_name($card_name); 79 | 80 | if ($current_deck != null) { 81 | $current_deck->add($card, $card_count); 82 | continue; 83 | } 84 | 85 | // before we save the "first side card", we need to make sure 86 | // that up until this point the main deck has enough cards, 87 | // otherwise it wouldn't make sense to potentially put the 88 | // remaining cards into the side deck. 89 | $main_deck_satisfied = count($cards) >= MainDeck::MIN_SIZE; 90 | 91 | for ($i = 0; $i < $card_count; $i++) 92 | $cards[] = $card; 93 | 94 | if ($separator_height > 0) { 95 | if ($main_deck_satisfied) 96 | $potential_first_side_cards[$separator_height] = $card; 97 | $separator_height = 0; 98 | $separator_count++; 99 | } 100 | } 101 | 102 | // separators were used, so we're done here. 103 | if ($current_deck !== null) 104 | return $deck_list; 105 | 106 | $extra_deck = $deck_list->extra; 107 | $side_deck = $deck_list->side; 108 | $main_deck = $deck_list->main; 109 | 110 | // will hold the first card before the first extra 111 | // deck card, or null if there are no extra deck cards. 112 | $card_before_extra_deck = null; 113 | 114 | $move_card = function (int $key, array &$src, Deck $dest) { 115 | if (count($dest) >= $dest::MAX_SIZE) 116 | return false; 117 | 118 | $dest->add($src[$key]); 119 | unset($src[$key]); 120 | 121 | return true; 122 | }; 123 | 124 | // the first 15 extra deck cards go into the extra deck. 125 | foreach ($cards as $key => $card) { 126 | 127 | if ($card->deck_type !== DeckType::EXTRA) { 128 | if (count($extra_deck) === 0) 129 | $card_before_extra_deck = $card; 130 | continue; 131 | } 132 | 133 | // move the cards of this entry to the extra deck. 134 | if ($move_card($key, $cards, $extra_deck)) 135 | continue; 136 | 137 | // if not all cards could be added (because the extra deck 138 | // is full), then add the remaining cards to the side deck. 139 | if (!$move_card($key, $cards, $side_deck)) 140 | throw new FormatDecodeException(implode(" ", [ 141 | "too many Extra Deck cards, no more than", 142 | ExtraDeck::MAX_SIZE + SideDeck::MAX_SIZE, 143 | "allowed" 144 | ])); 145 | } 146 | 147 | $consecutive_spaces = 0; // largest number of consecutive spaces. 148 | if (count($potential_first_side_cards) > 0) 149 | $consecutive_spaces = max(array_keys($potential_first_side_cards)); 150 | 151 | // allow a single space with only one separating line. 152 | if ($separator_count > 1) 153 | if ($consecutive_spaces < self::SIDE_PRECEDING_EMPTY_LINES) 154 | $consecutive_spaces = 0; 155 | 156 | if (count($extra_deck) === 0 && $consecutive_spaces > 0) { 157 | // there are no extra deck cards, so in order to separate 158 | // main deck cards from side deck cards, we split at the last 159 | // separator entry that is found in our card list. 160 | 161 | $first_side_card = $potential_first_side_cards[$consecutive_spaces]; 162 | 163 | $found_first = false; 164 | foreach ($cards as $key => $card) { 165 | if (!$found_first && $card !== $first_side_card) 166 | continue; 167 | 168 | $found_first = true; 169 | $move_card($key, $cards, $side_deck); 170 | } 171 | } 172 | else { 173 | // we're iterating in reverse, so we can't immediately add 174 | // side deck cards. store them and reverse them again. 175 | $side_count = count($side_deck); 176 | $side_keys = []; 177 | 178 | // the cards from the end of the list until the position at which 179 | // the first extra deck card was encountered belong to the side deck. 180 | foreach (array_reverse($cards, true) as $key => $card) { 181 | if ($card === $card_before_extra_deck) 182 | break; // done 183 | if ($side_count < $side_deck::MAX_SIZE) { 184 | $side_keys[] = $key; 185 | $side_count ++; 186 | continue; 187 | } 188 | // there are cards that don't fit into the side deck anymore. 189 | // those will be added to the main deck instead, even though 190 | // they appeared behind the first extra deck card. 191 | break; 192 | } 193 | 194 | foreach (array_reverse($side_keys) as $key) 195 | if (!$move_card($key, $cards, $side_deck)) 196 | throw new FormatDecodeException( 197 | "this shouldn't happen, please file an issue"); 198 | } 199 | 200 | // the remaining cards go to the main deck. 201 | foreach ($cards as $key => $card) 202 | if (!$move_card($key, $cards, $main_deck)) 203 | break; 204 | 205 | // if there are still cards remaining, try to put them into the side deck 206 | foreach($cards as $key => $card) 207 | if (!$move_card($key, $cards, $side_deck)) 208 | throw new FormatDecodeException( 209 | "too many cards, Main and Side Deck have reached their limit"); 210 | 211 | return $deck_list; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/Format/decoders/OmegaFormatDecoder.php: -------------------------------------------------------------------------------- 1 | unpack('C', $raw); 29 | 30 | if ($main_and_extra_count > MainDeck::MAX_SIZE + ExtraDeck::MAX_SIZE) 31 | throw new FormatDecodeException("Main or Extra Deck is too large"); 32 | 33 | # the second byte represents the size of the side deck. 34 | $side_count = $this->unpack('C', $raw); 35 | 36 | if ($side_count > SideDeck::MAX_SIZE) 37 | throw new FormatDecodeException("Side Deck is too large"); 38 | 39 | $deck_list = new DeckList(); 40 | 41 | for ($i = 0; $i < $main_and_extra_count; $i++) { 42 | $code = $this->unpack_code($raw); 43 | $card = $this->repository->get_card_by_code($code); 44 | $deck_list->get($card->deck_type)->add($card); 45 | } 46 | 47 | for ($i = 0; $i < $side_count; $i++) 48 | $deck_list->side->add(new Card($this->unpack_code($raw))); 49 | 50 | return $deck_list; 51 | } 52 | 53 | private function unpack_code(&$data) 54 | { 55 | return $this->unpack('V', $data); 56 | } 57 | 58 | private function unpack(string $format, string &$data) 59 | { 60 | if (!($unpacked = unpack($format, $data))) 61 | throw new FormatDecodeException("unexpected end of input"); 62 | 63 | $count = 1; 64 | switch ($format[0]) { 65 | case 'C': $count = 1; break; 66 | case 'V': $count = 4; break; 67 | default: 68 | assert(false, "unhandled format string"); # FIXME 69 | } 70 | 71 | $data = substr($data, $count); 72 | return $unpacked[1]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Format/decoders/YdkFormatDecoder.php: -------------------------------------------------------------------------------- 1 | $line) { 18 | $l = $index + 1; 19 | $line = trim($line); 20 | 21 | if (strlen($line) === 0) 22 | continue; // ignore empty lines 23 | 24 | if (preg_match(YdkFormat::COMMENT_REGEX, $line, $matches)) { 25 | switch ($content = strtolower($matches[1])) { 26 | case 'main': case 'extra': case 'side': 27 | $deck = $deck_list->$content; // current deck 28 | } 29 | continue; 30 | } 31 | 32 | if (preg_match(YdkFormat::CARD_CODE_REGEX, $line, $matches)) { 33 | $code = intval($matches[1]); 34 | if ($deck === null) 35 | throw new FormatDecodeException( 36 | "unable to associate code $code with any deck on line $l"); 37 | 38 | $type = $deck::TYPE; 39 | if ($type === DeckType::SIDE) 40 | $type = DeckType::UNKNOWN; 41 | 42 | $deck->add(new Card($code, $type)); 43 | continue; 44 | } 45 | 46 | throw new FormatDecodeException( 47 | "unable to match line $l of input, expected a comment or a card code"); 48 | } 49 | 50 | return $deck_list; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Format/decoders/YdkeFormatDecoder.php: -------------------------------------------------------------------------------- 1 | decode_codes($parts[0]) as $code) 35 | $deck_list->main->add(new Card($code, DeckType::MAIN)); 36 | 37 | foreach ($this->decode_codes($parts[1]) as $code) 38 | $deck_list->extra->add(new Card($code, DeckType::EXTRA)); 39 | 40 | foreach ($this->decode_codes($parts[2]) as $code) 41 | $deck_list->side->add(new Card($code)); 42 | 43 | return $deck_list; 44 | } 45 | 46 | private function decode_codes(string $encoded): array 47 | { 48 | if (strlen($encoded) == 0) 49 | return []; 50 | 51 | if (!($raw = base64_decode($encoded, true))) 52 | throw new FormatDecodeException("malformed base64"); 53 | return unpack("V*", $raw); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Format/encoders/JsonFormatEncoder.php: -------------------------------------------------------------------------------- 1 | to_json(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Format/encoders/NameFormatEncoder.php: -------------------------------------------------------------------------------- 1 | encode_deck($list->main); 16 | $extra = $this->encode_deck($list->extra); 17 | $side = $this->encode_deck($list->side); 18 | 19 | // extra line break before the side deck. 20 | $side = self::EOL . $side; 21 | 22 | $separator = self::EOL . self::EOL; 23 | return trim(implode($separator, [ &$main, &$extra, &$side ])); 24 | } 25 | 26 | public function encode_deck(Deck $deck): string 27 | { 28 | $encoded = ""; 29 | 30 | foreach ($deck->entries() as $entry) { 31 | $card = $entry->card(); 32 | $name = $this->repository->get_name_by_code($card->code()); 33 | 34 | $encoded .= count($entry) . ' '; 35 | $encoded .= $name . self::EOL; 36 | } 37 | 38 | return trim($encoded); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Format/encoders/OmegaFormatEncoder.php: -------------------------------------------------------------------------------- 1 | main) + count($list->extra)); 14 | $raw .= pack('C', count($list->side)); 15 | $raw .= pack('V*', ...$list->main->card_codes()); 16 | $raw .= pack('V*', ...$list->extra->card_codes()); 17 | $raw .= pack('V*', ...$list->side->card_codes()); 18 | 19 | $deflated = gzdeflate($raw); 20 | $encoded = base64_encode($deflated); 21 | 22 | return $encoded; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Format/encoders/YdkFormatEncoder.php: -------------------------------------------------------------------------------- 1 | main->card_codes())) . self::EOL; 18 | 19 | $encoded .= '#extra' . self::EOL; 20 | $encoded .= implode(self::EOL, iterator_to_array($list->extra->card_codes())) . self::EOL; 21 | 22 | $encoded .= '!side' . self::EOL; 23 | $encoded .= implode(self::EOL, iterator_to_array($list->side->card_codes())); 24 | 25 | return $encoded; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Format/encoders/YdkeFormatEncoder.php: -------------------------------------------------------------------------------- 1 | encode_deck($list->main), 17 | $this->encode_deck($list->extra), 18 | $this->encode_deck($list->side) 19 | ]; 20 | 21 | $encoded .= implode(YdkeFormat::SEPARATOR, $parts); 22 | $encoded .= YdkeFormat::SUFFIX; 23 | 24 | return $encoded; 25 | } 26 | 27 | private function encode_deck(Deck $deck): string 28 | { 29 | $raw = ""; 30 | foreach ($deck->cards() as $card) 31 | $raw .= pack('V', $card->code); 32 | return base64_encode($raw); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Format/formats/NameFormat.php: -------------------------------------------------------------------------------- 1 | code = $code; 14 | $this->deck_type = $deck_type; 15 | } 16 | 17 | public function code(): int { return $this->code; } 18 | public function deck_type(): int { return $this->deck_type; } 19 | } 20 | -------------------------------------------------------------------------------- /src/Game/CardList.php: -------------------------------------------------------------------------------- 1 | get_code(); 16 | } 17 | 18 | # public function card_names(): array { return $this->column('name'); } 19 | 20 | // public static function from_codes(array $codes, int $deck_type): CardList 21 | // { 22 | // # TODO 23 | // # return CardList::create(Card::with_code, $codes, $deck_type); 24 | 25 | // return new CardList(array_map( 26 | // function ($code) use ($deck_type) { 27 | // return Card::with_code($code, $deck_type); 28 | // }, $codes 29 | // )); 30 | // } 31 | 32 | // public static function from_names(array $names, int $deck_type): CardList 33 | // { 34 | // return new CardList(array_map( 35 | // function ($name) use ($deck_type) { 36 | // return Card::with_name($name, $deck_type); 37 | // }, $names 38 | // )); 39 | // } 40 | 41 | // # TODO: callable? 42 | // public static function create(callable $card_factory, iterable $items, ...$args) : CardList 43 | // { 44 | // return new CardList(array_map( 45 | // function ($item) use ($card_factory, $items, $args) { 46 | // return $card_factory($item, ...$args); 47 | // }, $items 48 | // )); 49 | // } 50 | } 51 | -------------------------------------------------------------------------------- /src/Game/Deck.php: -------------------------------------------------------------------------------- 1 | add($card); 21 | } 22 | 23 | public function set(Card $card, int $count): DeckEntry 24 | { 25 | if (isset($this->entries[$card->code])) { 26 | $this->subtract_count($this->entries[$card->code]->count); 27 | return $this->entries[$card->code]; 28 | } 29 | 30 | return $this->add_entry(new DeckEntry($card, $count)); 31 | } 32 | 33 | public function add(Card $card, int $count = 1): DeckEntry 34 | { 35 | if ($entry = $this->get_or_null($card)) { 36 | $entry->add_count($count); 37 | $this->add_count($count); 38 | return $entry; 39 | } 40 | 41 | return $this->set($card, $count); 42 | } 43 | 44 | public function remove(Card $card, $count = DeckEntry::MAX_COUNT): void 45 | { 46 | if (!$this->has($card)) 47 | return; 48 | 49 | $entry = $this->get($card); 50 | 51 | if (count($entry) <= $count) { 52 | $this->remove_entry($entry); 53 | return; 54 | } 55 | 56 | $entry->subtract_count($count); 57 | $this->subtract_count($count); 58 | } 59 | 60 | public function remove_one(Card $card): void 61 | { 62 | $this->remove($card, 1); 63 | } 64 | 65 | public function has(Card $card): bool 66 | { 67 | return $this->get_entry($card) !== null; 68 | } 69 | 70 | public function get(Card $card): DeckEntry 71 | { 72 | return $this->entries[$card->code]; 73 | } 74 | 75 | public function get_or_null(Card $card): ?DeckEntry 76 | { 77 | return $this->has($card) ? $this->get($card) : null; 78 | } 79 | 80 | public function count(): int { return $this->count; } 81 | 82 | 83 | public function entries(): \Generator 84 | { 85 | foreach ($this->entries as $entry) 86 | yield $entry; 87 | } 88 | 89 | public function cards(): \Generator 90 | { 91 | foreach ($this->entries as $entry) 92 | for ($i = 0; $i < count($entry); $i++) 93 | yield $entry->card; 94 | } 95 | 96 | public function unique_cards(): \Generator 97 | { 98 | foreach ($this->entries as $entry) 99 | yield $entry->card; 100 | } 101 | 102 | public function card_codes(): \Generator 103 | { 104 | foreach ($this->cards() as $card) 105 | yield $card->code; 106 | } 107 | 108 | public function unique_card_codes(): \Generator 109 | { 110 | foreach ($this->unique_cards() as $card) 111 | yield $card->code; 112 | } 113 | 114 | public function has_entry(DeckEntry $entry): bool 115 | { 116 | if (isset($this->entries[$entry->card->code])) 117 | return $this->entries[$entry->card->code] === $entry; 118 | return false; 119 | } 120 | 121 | public function get_entry(Card $card): ?DeckEntry 122 | { 123 | if (isset($this->entries[$card->code])) 124 | return $this->entries[$card->code]; 125 | 126 | return null; 127 | } 128 | 129 | public function add_entry(DeckEntry $entry): DeckEntry 130 | { 131 | if ($this->has_entry($entry)) 132 | return $entry; 133 | 134 | $this->add_count(count($entry)); 135 | return $this->entries[$entry->card->code] = $entry; 136 | } 137 | 138 | public function remove_entry(DeckEntry $entry): void 139 | { 140 | if (!$this->has_entry($entry)) 141 | return; 142 | 143 | $this->subtract_count(count($entry)); 144 | unset($this->entries[$entry->card->code]); 145 | } 146 | 147 | public function validate(bool $allow_too_little = false): void 148 | { 149 | if (!$allow_too_little && $this->count < $this::MIN_SIZE) 150 | throw new DeckLimitException( 151 | "the " . $this->get_deck_name() . " cannot have less than " . 152 | $this::MIN_SIZE . " cards"); 153 | 154 | if ($this->count > $this::MAX_SIZE) 155 | throw new DeckLimitException( 156 | "the " . $this->get_deck_name() . " cannot have more than " . 157 | $this::MAX_SIZE . " cards"); 158 | } 159 | 160 | public function get_deck_name(): string 161 | { 162 | $class = get_called_class(); 163 | $name = substr($class, strrpos($class, '\\') + 1); 164 | return trim(preg_replace("/([A-Z])/", ' $1', $name)); 165 | } 166 | 167 | private function add_count(int $diff): void 168 | { 169 | $this->count += $diff; 170 | } 171 | 172 | private function subtract_count(int $diff): void 173 | { 174 | $this->add_count((-1) * $diff); 175 | } 176 | } 177 | 178 | class DeckLimitException extends \Exception {} 179 | -------------------------------------------------------------------------------- /src/Game/DeckEntry.php: -------------------------------------------------------------------------------- 1 | card = $card; 17 | $this->set_count($count); 18 | } 19 | 20 | public function card(): Card { return $this->card; } 21 | public function count(): int { return $this->count; } 22 | 23 | public function set_count(int $count): void 24 | { 25 | assert($count >= 1, "an entry cannot have less than one card"); 26 | 27 | if ($count > self::MAX_COUNT) 28 | throw new CopyLimitExceededException( 29 | "maximum number of copies exceeded"); 30 | 31 | $this->count = $count; 32 | } 33 | 34 | public function add_count(int $count): void 35 | { 36 | $this->set_count($this->count + $count); 37 | } 38 | 39 | public function subtract_count(int $count): void 40 | { 41 | $this->add_count((-1) * $count); 42 | } 43 | 44 | public function increment(): void { $this->add_count(1); } 45 | public function decrement(): void { $this->subtract_count(1); } 46 | } 47 | 48 | 49 | class CopyLimitExceededException extends \Exception {} 50 | -------------------------------------------------------------------------------- /src/Game/DeckList.php: -------------------------------------------------------------------------------- 1 | main = new MainDeck(); 19 | $this->extra = new ExtraDeck(); 20 | $this->side = new SideDeck(); 21 | } 22 | 23 | public function get(int $deck_type): Deck 24 | { 25 | switch ($deck_type) { 26 | case DeckType::MAIN: return $this->main; 27 | case DeckType::EXTRA: return $this->extra; 28 | case DeckType::SIDE: return $this->side; 29 | case DeckType::UNKNOWN: return null; 30 | } 31 | 32 | throw new \InvalidArgumentException("deck type does not exist"); 33 | } 34 | 35 | public function decks(): \Generator 36 | { 37 | return $this->reflect_decks(); 38 | } 39 | 40 | public function cards(): \Generator 41 | { 42 | foreach ($this->decks() as $deck) 43 | foreach ($deck->cards() as $card) 44 | yield $card; 45 | } 46 | 47 | public function unique_cards(): \Generator 48 | { 49 | $encountered = []; 50 | 51 | foreach ($this->cards() as $card) { 52 | $code = $card->code(); 53 | if (isset($encountered[$code])) 54 | continue; 55 | $encountered[$code] = true; 56 | yield $card; 57 | } 58 | } 59 | 60 | public function card_codes(): \Generator 61 | { 62 | foreach ($this->cards() as $card) 63 | yield $card->code(); 64 | } 65 | 66 | public function unique_card_codes(): \Generator 67 | { 68 | foreach ($this->unique_cards() as $card) 69 | yield $card->code(); 70 | } 71 | 72 | public function count() 73 | { 74 | $sum = 0; 75 | foreach ($this->decks() as $deck) 76 | $sum += count($deck); 77 | return $sum; 78 | } 79 | 80 | public function validate(bool $allow_too_little = false): void 81 | { 82 | foreach ($this->decks() as $deck) 83 | $deck->validate($allow_too_little); 84 | } 85 | 86 | public function json_serialize() 87 | { 88 | $decks = []; 89 | 90 | foreach ($this->reflect_decks() as $name => $deck) 91 | $decks[$name] = iterator_to_array($deck->card_codes()); 92 | 93 | return $decks; 94 | } 95 | 96 | public static function json_deserialize($decks): self 97 | { 98 | $list = new DeckList(); 99 | 100 | foreach ($list->reflect_decks() as $name => $deck) { 101 | if (!isset($decks[$name])) 102 | throw new JsonDeserializeException("deck '$name' does not exist"); 103 | 104 | foreach ($decks[$name] as $code) 105 | $deck->add(new Card($code)); 106 | } 107 | 108 | return $list; 109 | } 110 | 111 | private function reflect_decks(): \Generator 112 | { 113 | $reflection = new \ReflectionClass(self::class); 114 | $properties = $reflection->getProperties(); 115 | 116 | foreach ($properties as $property) { 117 | if (($type = $property->getType()) === null) 118 | continue; 119 | 120 | $name = $property->getName(); 121 | $class = $type->getName(); 122 | if ($class === Deck::class || is_subclass_of($class, Deck::class)) 123 | yield $name => $this->$name; 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Game/DeckType.php: -------------------------------------------------------------------------------- 1 | code = $code; 14 | $this->deck_type = $deck_type; 15 | } 16 | } 17 | 18 | 19 | use \Utility\TypedListObject; 20 | 21 | class DataCardList extends TypedListObject 22 | { 23 | protected function allowed($value): bool 24 | { 25 | return $value instanceof DataCard; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Game/Repository/NameMatchOptions.php: -------------------------------------------------------------------------------- 1 | = 0); 15 | assert($max_length_diff_per_letter <= 1); 16 | 17 | assert($max_errors_per_letter >= 0); 18 | assert($max_errors_per_letter <= 1); 19 | 20 | $this->max_length_diff_per_letter = $max_length_diff_per_letter; 21 | $this->max_errors_per_letter = $max_errors_per_letter; 22 | } 23 | 24 | public function max_length_diff_per_letter() { return $this->max_length_diff_per_letter; } 25 | public function max_errors_per_letter() { return $this->max_errors_per_letter; } 26 | } 27 | -------------------------------------------------------------------------------- /src/Game/Repository/Repository.php: -------------------------------------------------------------------------------- 1 | options = $options; 15 | } 16 | 17 | public abstract function get_card_by_code(int $code): Card; 18 | public abstract function get_card_by_name(string $name): Card; 19 | public abstract function get_name_by_code(int $code): string; 20 | } 21 | -------------------------------------------------------------------------------- /src/Game/Repository/SqliteRepository.php: -------------------------------------------------------------------------------- 1 | db = new \PDO("sqlite:$filename"); 21 | $this->db->sqliteCreateFunction('levenshtein', 'levenshtein', 2); 22 | } 23 | 24 | public function get_card_by_code(int $code): Card 25 | { 26 | $stmt = $this->db->prepare(<<bindValue(':code', $code); 40 | 41 | $stmt->execute(); 42 | $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); 43 | 44 | if (count($rows) === 0) 45 | throw new \Exception("card not found: $code"); 46 | 47 | return $this->row_to_card($rows[0]); 48 | } 49 | 50 | public function get_card_by_name(string $name): Card 51 | { 52 | $stmt = $this->db->prepare(<<bindValue(':query', $sanitized_name); 76 | $stmt->bindValue(':cluster', $name_cluster); 77 | $stmt->bindValue(':max_length_diff_per_letter', $this->options->max_length_diff_per_letter()); 78 | $stmt->bindValue(':max_errors_per_letter', $this->options->max_errors_per_letter()); 79 | 80 | $stmt->execute(); 81 | $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); 82 | 83 | if (count($rows) === 0) 84 | throw new \Exception("card not found: $name"); 85 | 86 | return $this->row_to_card($rows[0]); 87 | } 88 | 89 | public function get_name_by_code(int $code): string 90 | { 91 | $stmt = $this->db->prepare(<<bindValue(':code', $code); 103 | 104 | $stmt->execute(); 105 | $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); 106 | 107 | if (count($rows) === 0) 108 | throw new \Exception("card code not found: $code"); 109 | 110 | return $rows[0]['name']; 111 | } 112 | 113 | private function row_to_card(array $row): Card 114 | { 115 | $id = intval($row['id']); 116 | 117 | switch ($row['type']) { 118 | case 'MAIN': $type = DeckType::MAIN; break; 119 | case 'EXTRA': $type = DeckType::EXTRA; break; 120 | default: assert(false, "unknown card type: " . $row['type']); 121 | } 122 | 123 | return new Card($id, $type); 124 | } 125 | 126 | // public function get_cards_by_code(int ...$codes): CardList 127 | // { 128 | // $db = new \PDO('sqlite:' . DB_FILE); 129 | 130 | // $code_values = implode(',', $codes); 131 | // $stmt = $db->prepare(<<execute(); 141 | // $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); 142 | 143 | // $cards = new CardList(); 144 | // $codes = array_flip($codes); 145 | 146 | // foreach ($rows as $row) { 147 | // $card = $this->row_to_card($row); 148 | // $offset = $codes[$card->code]; 149 | // unset($codes[$card->code]); 150 | // $cards[$offset] = $card; 151 | // } 152 | 153 | // if (count($codes) > 0) 154 | // throw new \Exception("card not found: " . array_key_first($codes)); 155 | 156 | // return $cards; 157 | // } 158 | 159 | // public function get_cards_by_name(string ...$names): CardList 160 | // { 161 | // $cards = new CardList(); 162 | 163 | // if (count($names) !== 0) 164 | // foreach ($names as $name) 165 | // $cards[] = $this->get_card_by_name($name); 166 | 167 | // return $cards; 168 | // } 169 | } 170 | -------------------------------------------------------------------------------- /src/Game/decks/ExtraDeck.php: -------------------------------------------------------------------------------- 1 | = 0, "query parameter count cannot be less than 0"); 46 | 47 | // if (count($_GET) === $count) 48 | // return; 49 | 50 | // self::fail("too many query parameters, $count expected", self::BAD_REQUEST); 51 | // } 52 | 53 | // public static function allow_query_parameters(string ...$names): void 54 | // { 55 | // $allowed = []; 56 | // foreach ($names as $name) 57 | // $allowed[$name] = true; 58 | 59 | // $disallowed_name = null; 60 | 61 | // foreach (array_keys($_GET) as $name) 62 | // if (!isset($allowed[$name])) { 63 | // $disallowed_name = $name; 64 | // break; 65 | // } 66 | 67 | // if ($disallowed_name === null) 68 | // return; 69 | 70 | // self::fail("unrecognized query parameter '$disallowed_name'", self::BAD_REQUEST); 71 | // } 72 | 73 | // public static function get_query_parameter_names(): \Generator 74 | // { 75 | // foreach (array_keys($_GET) as $name) 76 | // yield $name; 77 | // } 78 | 79 | // public static function get_first_query_parameter_name(): string 80 | // { 81 | // if (($name = self::get_query_parameter_names()->current()) !== null) 82 | // return $name; 83 | 84 | // throw new \Exception("there are no query parameters"); 85 | // } 86 | 87 | public static function get_query_parameter(string $name, bool $required = true, $default = null): ?string 88 | { 89 | if (!isset($_GET[$name])) { 90 | if (!$required) return $default; 91 | 92 | $message = "query parameter '$name' is required"; 93 | self::fail($message, self::BAD_REQUEST); 94 | } 95 | 96 | return $_GET[$name]; 97 | } 98 | 99 | public static function response_code(?int $response_code = null): int 100 | { 101 | return http_response_code($response_code); 102 | } 103 | 104 | public static function header(string $name, $value, bool $replace = true, 105 | int $http_response_code = null): void 106 | { 107 | header("$name: $value", $replace, $http_response_code); 108 | } 109 | 110 | public static function send(JsonResponse $response, int $code = self::OK): void 111 | { 112 | if ($response instanceof JsonErrorResponse) 113 | get_logger('http')->alert("failed with: " . $response->get_error()); 114 | 115 | if (Http::get_query_parameter('pretty', false) !== null) 116 | $response->options(JSON_PRETTY_PRINT); 117 | 118 | self::header('Content-Type', $response::mime_type()); 119 | echo $response->to_json(); 120 | self::close($code); 121 | } 122 | 123 | public static function fail(string $message, 124 | int $code = self::INTERNAL_SERVER_ERROR, 125 | array $extra_meta = [], 126 | array $extra_data = []): void 127 | { 128 | $response = new JsonErrorResponse($message); 129 | 130 | foreach ($extra_meta as $key => $value) $response->meta($key, $value); 131 | foreach ($extra_data as $key => $value) $response->data($key, $value); 132 | 133 | self::send($response, $code); 134 | } 135 | 136 | public static function close(?int $code = null) 137 | { 138 | if ($code !== null) 139 | self::response_code($code); 140 | exit; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Http/JsonErrorResponse.php: -------------------------------------------------------------------------------- 1 | success(false); 13 | $this->error($message); 14 | } 15 | 16 | public function success(bool $success): void 17 | { 18 | if ($success) 19 | throw new \InvalidArgumentException( 20 | "an error response cannot be successful"); 21 | 22 | parent::success($success); 23 | } 24 | 25 | public function error(?string $message): void 26 | { 27 | $this->meta(self::ERROR_KEY, $message); 28 | } 29 | 30 | public function get_error(): ?string 31 | { 32 | return $this->get_meta(self::ERROR_KEY); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Http/JsonResponse.php: -------------------------------------------------------------------------------- 1 | success = $success; } 29 | 30 | public function meta(string $key, $value): void { $this->meta[$key] = $value; } 31 | public function data(string $key, $value): void { $this->data[$key] = $value; } 32 | 33 | public function get_meta(string $key) { return $this->get('meta', $key); } 34 | public function get_data(string $key) { return $this->get('data', $key); } 35 | 36 | private function get(string $member, string $key) 37 | { 38 | if (!isset($this->$member[$key])) 39 | throw new \InvalidArgumentException("key '$key' in $member is not set"); 40 | 41 | return $this->$member[$key]; 42 | } 43 | 44 | public function options(int $options): void { $this->options = $options; } 45 | 46 | public function to_json(int $options = 0, int $depth = 1 << 9): string 47 | { 48 | $options |= $this->options; 49 | return parent::to_json($options, $depth); 50 | } 51 | 52 | public function json_serialize() 53 | { 54 | $meta = []; 55 | $data = []; 56 | 57 | $objects = [ 58 | 'meta' => &$meta, 59 | 'data' => &$data 60 | ]; 61 | 62 | foreach ($objects as $name => &$object) 63 | foreach ($this->$name as $key => $value) { 64 | if ($value instanceof \Json\Serializable) 65 | $value = $value->json_serialize(); 66 | $object[$key] = $value; 67 | } 68 | 69 | return [ 70 | 'success' => $this->success, 71 | 'meta' => (object)$meta, 72 | 'data' => (object)$data 73 | ]; 74 | } 75 | 76 | public static function json_deserialize($value): self 77 | { 78 | throw new \Exception("deserializing a response object is not supported"); 79 | } 80 | 81 | public static function mime_type(): string 82 | { 83 | return 'application/json'; 84 | } 85 | 86 | public function __toString() 87 | { 88 | return $this->to_json(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Http/Response.php: -------------------------------------------------------------------------------- 1 | handle = $handle; 18 | $this->type = $type; 19 | } 20 | 21 | public function __destruct() 22 | { 23 | if ($this->handle !== null) 24 | imagedestroy($this->handle); 25 | } 26 | 27 | public function handle($handle = false) 28 | { 29 | if ($handle !== false) 30 | $this->handle = $handle; 31 | 32 | return $this->handle; 33 | } 34 | 35 | public function type(int $type = ImageType::AUTO): int 36 | { 37 | if ($type !== ImageType::AUTO) 38 | $this->type = $type; 39 | 40 | return $this->type; 41 | } 42 | 43 | public function move_from(Image $image): void 44 | { 45 | $this->handle = $image->handle; 46 | $this->type = $image->type; 47 | 48 | $image->handle = null; 49 | $image->type = ImageType::NONE; 50 | } 51 | 52 | public function width(): int { return imagesx($this->handle); } 53 | public function height(): int { return imagesy($this->handle); } 54 | 55 | public function resize(int $width, int $height, bool $resample = false): bool 56 | { 57 | assert($width > 0 || $height > 0, "one side must be larger than 0"); 58 | 59 | if ($this->width() === $width && $this->height() < $height) return true; 60 | if ($this->height() === $height && $this->width() < $width) return true; 61 | 62 | $w = $this->width(); 63 | $h = $this->height(); 64 | $r = $w / $h; 65 | 66 | if ($width === 0) { 67 | $factor = $height / $h; 68 | $width = intval($w * $factor); 69 | } 70 | else if ($height === 0) { 71 | $factor = $width / $w; 72 | $height = intval($h * $factor); 73 | } 74 | else if ($width / $height > $r) 75 | $width = intval($height * $r); 76 | else 77 | $height = intval($width / $r); 78 | 79 | $source = $this->handle; 80 | $destination = imagecreatetruecolor($width, $height); 81 | 82 | $resize = $resample ? 'imagecopyresampled' : 'imagecopyresized'; 83 | $result = $resize($destination, $source, 0, 0, 0, 0, $width, $height, $w, $h); 84 | 85 | if (!$result) 86 | return false; 87 | 88 | $this->handle = $destination; 89 | return true; 90 | } 91 | 92 | public function insert( 93 | Image $other, 94 | Vector $to_position, Vector $from_position, 95 | Rectangle $to_dimensions, Rectangle $from_dimensions, 96 | bool $resample = false 97 | ): bool 98 | { 99 | $resize = $resample ? 'imagecopyresampled' : 'imagecopyresized'; 100 | return $resize( 101 | $this->handle(), $other->handle(), 102 | $to_position->x(), $to_position->y(), 103 | $from_position->x(), $from_position->y(), 104 | $to_dimensions->width(), $to_dimensions->height(), 105 | $from_dimensions->width(), $to_dimensions->height() 106 | ); 107 | } 108 | 109 | public function write(int $type = ImageType::AUTO, $to = null, ...$args): bool 110 | { 111 | if ($type === ImageType::AUTO) 112 | $type = $this->type; 113 | 114 | switch ($type) { 115 | case ImageType::GIF: $result = imagegif ($this->handle, $to, ...$args); break; 116 | case ImageType::JPEG: $result = imagejpeg($this->handle, $to, ...$args); break; 117 | case ImageType::PNG: $result = imagepng ($this->handle, $to, ...$args); break; 118 | case ImageType::BMP: $result = imagebmp ($this->handle, $to, ...$args); break; 119 | case ImageType::WBMP: $result = imagewbmp($this->handle, $to, ...$args); break; 120 | case ImageType::XBM: $result = imagexbm ($this->handle, $to, ...$args); break; 121 | case ImageType::WEBP: $result = imagewebp($this->handle, $to, ...$args); break; 122 | default: throw new ImageException("unsupported image type"); 123 | } 124 | 125 | return $result; 126 | } 127 | 128 | public function echo(int $type = ImageType::AUTO, 129 | bool $send_headers = true, ...$args): bool 130 | { 131 | if ($type === ImageType::AUTO) 132 | $type = $this->type; 133 | 134 | if ($send_headers) { 135 | $mime_type = ImageType::mime_type($type); 136 | header("Content-Type: $mime_type"); 137 | } 138 | 139 | return $this->write($type, null, ...$args); 140 | } 141 | 142 | public static function from_image(self $image): self 143 | { 144 | return new self($image->handle(), $image->type()); 145 | } 146 | 147 | public static function from_file(string $name, 148 | int $type = ImageType::AUTO): ?Image 149 | { 150 | if ($type === ImageType::AUTO) 151 | $type = ImageType::from_filename($name); 152 | 153 | if (!is_readable($name)) 154 | throw new ImageException("cannot read file"); 155 | 156 | switch ($type) { 157 | case ImageType::GIF: $handle = imagecreatefromgif($name); break; 158 | case ImageType::JPEG: $handle = imagecreatefromjpeg($name); break; 159 | case ImageType::PNG: $handle = imagecreatefrompng($name); break; 160 | case ImageType::BMP: $handle = imagecreatefrombmp($name); break; 161 | case ImageType::WBMP: $handle = imagecreatefromwbmp($name); break; 162 | case ImageType::XBM: $handle = imagecreatefromxbm($name); break; 163 | case ImageType::WEBP: $handle = imagecreatefromwebp($name); break; 164 | default: throw new ImageException("unsupported image type"); 165 | } 166 | 167 | return $handle !== false ? new Image($handle, $type) : null; 168 | } 169 | 170 | public static function from_url(string $url, int $type = ImageType::AUTO, 171 | int $timeout_ms = 0): ?Image 172 | { 173 | if ($type === ImageType::AUTO) 174 | $type = ImageType::from_filename($url); 175 | 176 | $ch = curl_init(); 177 | curl_setopt($ch, CURLOPT_URL, $url); 178 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 179 | curl_setopt($ch, CURLOPT_TIMEOUT_MS, $timeout_ms); 180 | $data = curl_exec($ch); 181 | 182 | $content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); 183 | $is_valid_type = $content_type === false; 184 | 185 | if (!$is_valid_type) 186 | foreach (explode(';', $content_type) as $part) 187 | if (trim($part) === ImageType::mime_type($type)) { 188 | $is_valid_type = true; 189 | break; 190 | } 191 | 192 | if (!$is_valid_type) 193 | throw new ImageException( 194 | "url returned unexpected content: $content_type"); 195 | 196 | $errno = curl_errno($ch); 197 | $error = curl_error($ch); 198 | 199 | curl_close($ch); 200 | 201 | if ($errno !== 0) 202 | throw new ImageException("could not open image: $error"); 203 | 204 | return Image::from_data($data, $type); 205 | } 206 | 207 | public static function from_data(string $data, 208 | int $type = ImageType::NONE): ?Image 209 | { 210 | $handle = imagecreatefromstring($data); 211 | return $handle !== false ? new Image($handle, $type) : null; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/Image/ImageCache.php: -------------------------------------------------------------------------------- 1 | directory = $directory; 24 | $this->subfolder_length = $subfolder_length; 25 | } 26 | 27 | public function directory(): string { return $this->directory; } 28 | public function subfolder_length(): int { return $this->subfolder_length; } 29 | 30 | public function loader(\Closure $loader): void { $this->loader_ = $loader; } 31 | 32 | public function type(int $type = ImageType::NONE, ?string $custom_extension = null): int 33 | { 34 | if ($type !== ImageType::NONE) { 35 | 36 | if ($type !== $this->type) { 37 | assert($custom_extension === null || strlen($custom_extension) > 0, 38 | "custom extension is empty"); 39 | $this->custom_extension = $custom_extension; 40 | } 41 | 42 | $this->type = $type; 43 | } 44 | 45 | return $this->type; 46 | } 47 | 48 | public function extension(): string 49 | { 50 | if ($this->type === ImageType::NONE) 51 | throw new ImageException("cannot determine extension without a type"); 52 | 53 | if ($this->type === ImageType::AUTO) 54 | throw new ImageException( 55 | "cannot determine extension with potentially multiple types"); 56 | 57 | if ($this->custom_extension !== null) 58 | return $this->custom_extension; 59 | 60 | return ImageType::extension($this->type); 61 | } 62 | 63 | public function load_with_loader($key, int $type = ImageType::AUTO): ?ImageCacheEntry 64 | { 65 | if ($this->loader_ === null) 66 | throw new ImageException("cannot load without a loader"); 67 | 68 | $image_key = new ImageKey($key); 69 | 70 | if ($type === ImageType::AUTO) 71 | $type = $this->type; 72 | 73 | $image = ($this->loader_)($image_key, $type); 74 | if ($image === null) 75 | return null; 76 | 77 | if (! $image instanceof Image) 78 | throw new ImageException("the loader is expected to return an Image"); 79 | 80 | // make sure the image has the required type. 81 | if ($type !== ImageType::AUTO) 82 | $image->type($type); 83 | 84 | $entry = new ImageCacheEntry($this, $image, $image_key); 85 | $this->loaded[$image_key->value()] = $entry; 86 | 87 | return $entry; 88 | } 89 | 90 | public function read_from_disk($key, int $type = ImageType::AUTO): ?ImageCacheEntry 91 | { 92 | $image = new Image(); 93 | $image_key = new ImageKey($key); 94 | $entry = new ImageCacheEntry($this, $image, $image_key); 95 | 96 | $directory = $entry->directory(); 97 | $filename = $entry->filename(); 98 | $extension = null; 99 | 100 | if ($type === ImageType::AUTO) { 101 | 102 | $files = glob("$directory/$filename.?*", GLOB_NOSORT); 103 | if (count($files) === 0) 104 | return null; 105 | 106 | $is_valid = false; 107 | foreach ($files as $file) { 108 | $extension = pathinfo($file, PATHINFO_EXTENSION); 109 | if (ImageType::is_format($extension)) { 110 | $is_valid = true; 111 | break; 112 | } 113 | } 114 | 115 | if (!$is_valid) 116 | return null; 117 | } 118 | else 119 | $extension = ImageType::extension($type); 120 | 121 | $cache_image = Image::from_file("$directory/$filename.$extension"); 122 | if ($cache_image === null) 123 | return null; 124 | 125 | $image->move_from($cache_image); 126 | $this->cached[$image_key->value()] = $entry; 127 | 128 | return $entry; 129 | } 130 | 131 | /** 132 | * looks for the specified key in this object's internal memory, 133 | * in the cache directory specified during construction time or 134 | * it attempts to load it with this object's loader function. 135 | */ 136 | public function load($key): ?ImageCacheEntry 137 | { 138 | $entry = $this->read($key); 139 | if ($entry !== null) 140 | return $entry; 141 | 142 | return $this->load_with_loader($key); 143 | } 144 | 145 | /** 146 | * looks for the specified key in this object's internal memory 147 | * or in the cache directory specified during construction time. 148 | */ 149 | public function read($key): ?ImageCacheEntry 150 | { 151 | $entry = $this->get($key); 152 | if ($entry !== null) 153 | return $entry; 154 | 155 | return $this->read_from_disk($key); 156 | } 157 | 158 | /** 159 | * looks for the specified key in this object's internal memory. 160 | */ 161 | public function get($key): ?ImageCacheEntry 162 | { 163 | $cached = $this->get_from_cached($key); 164 | return $cached ?: $this->get_from_loaded($key); 165 | } 166 | 167 | public function get_from_cached($key): ?ImageCacheEntry 168 | { 169 | return isset($this->cached[$key]) ? $this->cached[$key] : null; 170 | } 171 | 172 | public function get_from_loaded($key): ?ImageCacheEntry 173 | { 174 | return isset($this->loaded[$key]) ? $this->loaded[$key] : null; 175 | } 176 | 177 | public function loaded(): \Generator 178 | { 179 | foreach ($this->loaded as $entry) 180 | yield $entry->key()->value() => $entry; 181 | } 182 | 183 | public function cached(): \Generator 184 | { 185 | foreach ($this->cached as $entry) 186 | yield $entry->key()->value() => $entry; 187 | } 188 | 189 | public function flush(int $type = ImageType::AUTO, ...$args): int 190 | { 191 | $count = 0; 192 | 193 | foreach ($this->loaded() as $key => $entry) { 194 | $entry->flush($type, ...$args); 195 | 196 | unset($this->loaded[$key]); 197 | $this->cached[$key] = $entry; 198 | 199 | $count ++; 200 | } 201 | 202 | return $count; 203 | } 204 | 205 | public function clear_buffer() 206 | { 207 | $this->cached = []; 208 | $this->loaded = []; 209 | } 210 | 211 | public function count_cached(): int { return count($this->cached); } 212 | public function count_loaded(): int { return count($this->loaded); } 213 | 214 | public function count(): int 215 | { 216 | return $this->count_cached() + $this->count_loaded(); 217 | } 218 | 219 | // public function load_or_null($key, int $type = ImageType::AUTO): ?ImageCacheEntry 220 | // { 221 | // try { 222 | // $entry = $this->read_from_disk($key, $type); 223 | // } 224 | // catch (ImageException $e) { 225 | // $entry = null; 226 | // } 227 | 228 | // if ($entry === null) 229 | // $entry = $this->load_with_loader($key, $type); 230 | 231 | // return $entry; 232 | // } 233 | 234 | // public function load($key, int $type = ImageType::AUTO): ImageCacheEntry 235 | // { 236 | // $entry = $this->load_or_null($key, $type); 237 | // if ($entry === null) 238 | // throw new ImageException("failed to load image"); 239 | 240 | // return $entry; 241 | // } 242 | 243 | // public function get_or_null($key): ?ImageCacheEntry 244 | // { 245 | // $cached = $this->get_from_cached($key); 246 | // return $cached ?: $this->get_from_loaded($key); 247 | // } 248 | 249 | // public function get($key): ImageCacheEntry 250 | // { 251 | // $entry = $this->get_or_null($key); 252 | // if ($entry === null) 253 | // throw new ImageException("this key does not exist"); 254 | 255 | // return $entry; 256 | // } 257 | 258 | // public function get_or_load($key, int $type = ImageType::AUTO): ImageCacheEntry 259 | // { 260 | // $entry = $this->get_or_null($key); 261 | // if ($entry !== null) 262 | // return $entry; 263 | 264 | // return $this->load($key, $type); 265 | // } 266 | } 267 | -------------------------------------------------------------------------------- /src/Image/ImageCacheEntry.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 17 | $this->key = $key; 18 | 19 | $this->original = $image; 20 | $this->image = $image; 21 | } 22 | 23 | public function cache() { return $this->cache; } 24 | public function image() { return $this->image; } 25 | public function key() { return $this->key; } 26 | 27 | public function filename(): string 28 | { 29 | return $this->key->filename(); 30 | } 31 | 32 | public function subfolder(): ?string 33 | { 34 | $length = $this->cache->subfolder_length(); 35 | if ($length <= 0) 36 | return null; 37 | 38 | $folder = substr($this->key->string(), 0, $length); 39 | $folder = str_pad($folder, $length, ImageCache::SUBFOLDER_PAD_CHAR); 40 | 41 | return $folder; 42 | } 43 | 44 | public function directory(): string 45 | { 46 | $directory = $this->cache->directory(); 47 | $subfolder = $this->subfolder(); 48 | 49 | if ($subfolder !== null) 50 | $directory .= "/$subfolder"; 51 | 52 | return $directory; 53 | } 54 | 55 | public function flush(int $type = ImageType::AUTO, ...$args): bool 56 | { 57 | if ($type === ImageType::AUTO) 58 | $type = $this->image->type(); 59 | 60 | $directory = $this->directory(); 61 | $filename = $this->key->filename(); 62 | $extension = ImageType::extension($type, false); 63 | 64 | if (!file_exists($directory)) 65 | if (!mkdir($directory, 0775, true)) 66 | throw new ImageException("could not create directory/subfolder"); 67 | 68 | $path = "$directory/$filename.$extension"; 69 | return $this->image->write($type, $path, ...$args); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Image/ImageException.php: -------------------------------------------------------------------------------- 1 | value = $value; 16 | } 17 | 18 | public function value() { return $this->value; } 19 | public function string() { return strval($this->value); } 20 | 21 | public function sanitized(&$removed_count = null): string 22 | { 23 | $original = $this->string(); 24 | 25 | $key = $original; 26 | $key = str_replace(self::NAME_HASH_SEPARATOR, '', $key, $c1); 27 | $key = preg_replace("/[^aA-zZ0-9\-_]/", '', $key, -1, $c2); 28 | $key = $key === '' ? self::NO_VALUE_PLACEHOLDER : $key; 29 | 30 | $removed_count = $c1 + $c2 - ($key === self::NO_VALUE_PLACEHOLDER); 31 | 32 | return $key; 33 | } 34 | 35 | public function hash($length = 8): string 36 | { 37 | return bin2hex(substr(md5($this->value, true), 0, $length)); 38 | } 39 | 40 | public function filename(): string 41 | { 42 | $name = $this->sanitized($missing_chars); 43 | 44 | if ($missing_chars > 0) { 45 | $name .= self::NAME_HASH_SEPARATOR; 46 | $name .= $this->hash(); 47 | } 48 | 49 | return $name; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Image/ImageType.php: -------------------------------------------------------------------------------- 1 | 0; 22 | } 23 | 24 | public static function from_format(string $format): int 25 | { 26 | switch (strtoupper($format)) { 27 | case 'GIF': return self::GIF; 28 | case 'JPG': 29 | case 'JPEG': return self::JPEG; 30 | case 'PNG': return self::PNG; 31 | case 'BMP': return self::BMP; 32 | case 'WBMP': return self::WBMP; 33 | case 'XBM': return self::XBM; 34 | case 'WEBP': return self::WEBP; 35 | } 36 | 37 | throw new ImageException("unsupported image format"); 38 | } 39 | 40 | public static function is_format(string $format): bool 41 | { 42 | try { 43 | self::from_format($format); 44 | } 45 | catch (ImageException $e) { 46 | return false; 47 | } 48 | 49 | return true; 50 | } 51 | 52 | public static function from_filename(string $filename): int 53 | { 54 | $extension = pathinfo($filename, PATHINFO_EXTENSION); 55 | return ImageType::from_format($extension); 56 | } 57 | 58 | public static function extension(int $type, bool $include_dot = true): ?string 59 | { 60 | return image_type_to_extension($type, $include_dot) ?: null; 61 | } 62 | 63 | public static function mime_type(int $type): ?string 64 | { 65 | return image_type_to_mime_type($type) ?: null; 66 | } 67 | 68 | public static function types(): \Generator 69 | { 70 | $reflection = new \ReflectionClass(__CLASS__); 71 | foreach($reflection->getConstants() as $format => $type) { 72 | if ($type <= 0) 73 | continue; 74 | 75 | yield $format => $type; 76 | } 77 | } 78 | 79 | public static function extensions(bool $include_dot = true): \Generator 80 | { 81 | foreach(self::types() as $type) 82 | yield $type => self::extension($type, $include_dot); 83 | } 84 | 85 | public static function mime_types(): \Generator 86 | { 87 | foreach(self::types() as $type) 88 | yield $type => self::mime_type($type); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Image/MemoryImage.php: -------------------------------------------------------------------------------- 1 | move_from($image); 43 | return $result; 44 | } 45 | 46 | public static function move_from_image_or_null(?Image $image): ?MemoryImage 47 | { 48 | return $image !== null ? self::move_from_image($image) : null; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Json/JsonException.php: -------------------------------------------------------------------------------- 1 | json_serialize(); 26 | } 27 | 28 | public function to_json(int $options = 0, int $depth = 1 << 9): string 29 | { 30 | $result = $this->json_serialize(); 31 | return json_encode($result, $options, $depth); 32 | } 33 | 34 | public static function from_json(string $json, int $depth = 1 << 9, 35 | int $options = 0) 36 | { 37 | $options |= JSON_THROW_ON_ERROR; 38 | $value = json_decode($json, true, $depth, $options); 39 | $class = get_called_class(); 40 | 41 | // $reflection = new \ReflectionClass($class); 42 | 43 | // $constructor = $reflection->getConstructor(); 44 | // $with_constructor = $constructor === null 45 | // || $constructor->getNumberOfParameters() === 0; 46 | 47 | // $instance = $with_constructor 48 | // ? $reflection->newInstance() 49 | // : $reflection->newInstanceWithoutConstructor(); 50 | 51 | // $instance = $reflection->newInstanceWithoutConstructor(); 52 | 53 | return $class::json_deserialize($value); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Render/Cell.php: -------------------------------------------------------------------------------- 1 | content = $content; 15 | } 16 | 17 | public function content() { return $this->content; } 18 | } 19 | -------------------------------------------------------------------------------- /src/Render/CellFactory.php: -------------------------------------------------------------------------------- 1 | width, $this->height, $content); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Render/CellOverlap.php: -------------------------------------------------------------------------------- 1 | horizontal = $horizontal; 19 | $this->vertical = $vertical; 20 | } 21 | 22 | public function horizontal(): int { return $this->horizontal; } 23 | public function vertical(): int { return $this->vertical; } 24 | } 25 | -------------------------------------------------------------------------------- /src/Render/CellView.php: -------------------------------------------------------------------------------- 1 | visible_width = $visible_width; 26 | $this->visible_height = $visible_height; 27 | 28 | $this->x_offset = $x_offset; 29 | $this->y_offset = $y_offset; 30 | 31 | $this->position = $position; 32 | } 33 | 34 | public function visible_width(): int { return $this->visible_width; } 35 | public function visible_height(): int { return $this->visible_height; } 36 | 37 | public function x_offset(): int { return $this->x_offset; } 38 | public function y_offset(): int { return $this->y_offset; } 39 | 40 | public function position(): Vector { return $this->position; } 41 | 42 | public function x(): int { return $this->position->x(); } 43 | public function y(): int { return $this->position->y(); } 44 | } 45 | -------------------------------------------------------------------------------- /src/Render/Rectangle.php: -------------------------------------------------------------------------------- 1 | width = $width; 14 | $this->height = $height; 15 | } 16 | 17 | public function width(): int { return $this->width; } 18 | public function height(): int { return $this->height; } 19 | 20 | public function plus(Rectangle $other): Rectangle 21 | { 22 | return new Rectangle( 23 | $this->width + $other->width, 24 | $this->height + $other->height 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Render/Spacing.php: -------------------------------------------------------------------------------- 1 | horizontal = $horizontal; 14 | $this->vertical = $vertical; 15 | } 16 | 17 | public function horizontal(): float { return $this->horizontal; } 18 | public function vertical(): float { return $this->vertical; } 19 | } 20 | -------------------------------------------------------------------------------- /src/Render/Table.php: -------------------------------------------------------------------------------- 1 | width() === 0) 30 | throw new \Exception("a table's cell cannot have a width of 0"); 31 | 32 | if ($cell_factory->height() === 0) 33 | throw new \Exception("a table's cell cannot have a height of 0"); 34 | 35 | $this->root = new Vector(0, 0); 36 | 37 | $this->cell_factory = $cell_factory; 38 | 39 | $this->min_spacing = new Spacing(0, 0); 40 | $this->max_spacing = new Spacing(0, 0); 41 | 42 | $this->rows = $this->calculate_rows(); 43 | $this->columns = $this->calculate_columns(); 44 | } 45 | 46 | public function count(): int { return count($this->cells); } 47 | 48 | public function primary_layout(): int { return $this->primary_layout; } 49 | public function secondary_layout(): int { return $this->secondary_layout; } 50 | 51 | public function cell_factory(): CellFactory { return $this->cell_factory; } 52 | 53 | public function get_root(): Vector 54 | { 55 | return new Vector($this->root->x(), $this->root->y()); 56 | } 57 | 58 | public function root(int $x, int $y): void 59 | { 60 | $this->root = new Vector($x, $y); 61 | } 62 | 63 | public function min_spacing(float $horizontal, ?float $vertical = null): void 64 | { 65 | if ($vertical === null) 66 | $vertical = $horizontal; 67 | 68 | $horizontal = max(0, $horizontal); 69 | $vertical = max(0, $vertical); 70 | 71 | $this->min_spacing = new Spacing($horizontal, $vertical); 72 | $this->max_spacing = new Spacing( 73 | max($this->max_spacing->horizontal(), $horizontal), 74 | max($this->max_spacing->vertical(), $vertical) 75 | ); 76 | 77 | $this->rows = $this->calculate_rows(); 78 | $this->columns = $this->calculate_columns(); 79 | } 80 | 81 | public function max_spacing(float $horizontal, ?float $vertical = null): void 82 | { 83 | if ($vertical === null) 84 | $vertical = $horizontal; 85 | 86 | $horizontal = max(0, $horizontal); 87 | $vertical = max(0, $vertical); 88 | 89 | $this->max_spacing = new Spacing($horizontal, $vertical); 90 | $this->min_spacing = new Spacing( 91 | min($this->min_spacing->horizontal(), $horizontal), 92 | min($this->min_spacing->vertical(), $vertical) 93 | ); 94 | 95 | $this->rows = $this->calculate_rows(); 96 | $this->columns = $this->calculate_columns(); 97 | } 98 | 99 | public function spacing(float $horizontal, ?float $vertical = null): void 100 | { 101 | if ($vertical === null) 102 | $vertical = $horizontal; 103 | 104 | $horizontal = max(0, $horizontal); 105 | $vertical = max(0, $vertical); 106 | 107 | $spacing = new Spacing($horizontal, $vertical); 108 | $this->min_spacing = $spacing; 109 | $this->max_spacing = $spacing; 110 | 111 | $this->rows = $this->calculate_rows(); 112 | $this->columns = $this->calculate_columns(); 113 | } 114 | 115 | public function dynamic_spacing(bool $dynamic_spacing): void 116 | { 117 | $this->max_spacing = $dynamic_spacing 118 | ? new Spacing(PHP_INT_MAX, PHP_INT_MAX) 119 | : $this->min_spacing; 120 | } 121 | 122 | public function layout(int $primary, int $secondary): void 123 | { 124 | if ($primary === TableLayout::opposite($secondary)) 125 | throw new \Exception("cannot have two opposing layouts"); 126 | 127 | $this->primary_layout = $primary; 128 | $this->secondary_layout = $secondary; 129 | } 130 | 131 | public function overlap(?int $overlap = null): int 132 | { 133 | if ($overlap !== null) 134 | $this->overlap = $overlap; 135 | 136 | return $this->overlap; 137 | } 138 | 139 | public function rows(): int 140 | { 141 | if ($this->overlap === CellOverlap::VERTICAL) { 142 | $rows = intval(ceil(count($this->cells) / $this->columns())); 143 | return max($rows, $this->rows); 144 | } 145 | 146 | return $this->rows; 147 | } 148 | 149 | public function columns(): int 150 | { 151 | if ($this->overlap === CellOverlap::HORIZONTAL) { 152 | $cols = intval(ceil(count($this->cells) / $this->rows())); 153 | return max($cols, $this->columns); 154 | } 155 | 156 | return $this->columns; 157 | } 158 | 159 | public function push($item): Cell 160 | { 161 | if ($this->cell_factory === null) 162 | throw new \Exception("cannot push an item without a cell factory"); 163 | 164 | if (count($this->cells) > $this->capacity()) 165 | throw new \Exception("capacity of table reached"); 166 | 167 | $cell = $this->cell_factory->create($item); 168 | $this->cells[] = $cell; 169 | 170 | return $cell; 171 | } 172 | 173 | public function cells(): \Generator 174 | { 175 | $count = count($this->cells); # in for loop expression? 176 | for ($index = 0; $index < $count; $index ++) 177 | yield $this->create_cell_view($index); 178 | } 179 | 180 | public function capacity(): int 181 | { 182 | // the capacity is unlimited when cells can overlap. 183 | # TODO: actually, if it were unlimited then at some point cells 184 | # would overlap so much, that some wouldn't be visible anymore. 185 | if ($this->overlap !== CellOverlap::NONE) 186 | return PHP_INT_MAX; 187 | 188 | return $this->rows() * $this->columns(); 189 | } 190 | 191 | protected function calculate_rows(): int 192 | { 193 | $cell_height = $this->cell_factory->height; 194 | $cell_height += $this->min_spacing->vertical(); 195 | 196 | $total_height = max(0, $this->height - $this->cell_factory->height); 197 | $rows = intval(floor($total_height / $cell_height)) + 1; 198 | 199 | return $rows; 200 | } 201 | 202 | protected function calculate_columns(): int 203 | { 204 | $cell_width = $this->cell_factory->width; 205 | $cell_width += $this->min_spacing->horizontal(); 206 | 207 | $total_width = max(0, $this->width - $this->cell_factory->width); 208 | $columns = intval(floor($total_width / $cell_width)) + 1; 209 | 210 | return $columns; 211 | } 212 | 213 | protected function create_cell_view(int $index): CellView 214 | { 215 | $primary = $this->primary_layout; 216 | $secondary = $this->secondary_layout; 217 | 218 | $rows = $this->rows(); 219 | $cols = $this->columns(); 220 | 221 | $row = $col = 0; 222 | 223 | if (TableLayout::is_horizontal($primary)) { 224 | $row = intval($index / $cols); 225 | $col = $index % $cols; 226 | 227 | if ($primary === TableLayout::RIGHT_TO_LEFT) 228 | $col = $cols - $col - 1; 229 | 230 | if ($secondary === TableLayout::BOTTOM_TO_TOP) 231 | $row = $rows - $row - 1; 232 | } 233 | 234 | if (TableLayout::is_vertical($primary)) { 235 | $col = intval($index / $rows); 236 | $row = $index % $rows; 237 | 238 | if ($primary === TableLayout::BOTTOM_TO_TOP) 239 | $row = $rows - $row - 1; 240 | 241 | if ($secondary === TableLayout::RIGHT_TO_LEFT) 242 | $col = $cols - $col - 1; 243 | } 244 | 245 | $cell_width = $this->cell_factory->width(); 246 | $cell_height = $this->cell_factory->height(); 247 | 248 | $cell_count = count($this->cells); 249 | 250 | $row_overlap = $col_overlap = 0; 251 | 252 | if ($this->overlap === CellOverlap::HORIZONTAL) 253 | $col_overlap = $this->calculate_overlap( 254 | $this->width, $cell_width, $cols); 255 | 256 | if ($this->overlap === CellOverlap::VERTICAL) 257 | $row_overlap = $this->calculate_overlap( 258 | $this->height, $cell_height, $rows); 259 | 260 | assert( # TODO: handle this differently 261 | $col_overlap < $cell_width && 262 | $row_overlap < $cell_height, 263 | "overlapping an entire cell" 264 | ); 265 | 266 | $spacing = $this->calculate_spacing(); 267 | 268 | $col_spacing = intval($col * $spacing->horizontal()); 269 | $row_spacing = intval($row * $spacing->vertical()); 270 | 271 | $is_row_cut_off = $is_col_cut_off = false; 272 | $x_offset = $y_offset = 0; 273 | 274 | if (TableLayout::is_horizontal($primary)) { 275 | $is_row_cut_off = $index + $cols < $cell_count; 276 | $is_col_cut_off = $index + 1 < $cell_count && $index % $cols !== $cols - 1; 277 | } 278 | 279 | if (TableLayout::is_vertical($primary)) { 280 | $is_col_cut_off = $index + $rows < $cell_count; 281 | $is_row_cut_off = $index + 1 < $cell_count && $index % $rows !== $rows - 1; 282 | } 283 | 284 | if ($is_row_cut_off) 285 | if ($primary === TableLayout::BOTTOM_TO_TOP || 286 | $secondary === TableLayout::BOTTOM_TO_TOP) 287 | $y_offset = $row_overlap; 288 | 289 | if ($is_col_cut_off) 290 | if ($primary === TableLayout::RIGHT_TO_LEFT || 291 | $secondary === TableLayout::RIGHT_TO_LEFT) 292 | $x_offset = $col_overlap; 293 | 294 | $visible_width = $cell_width - $is_col_cut_off * $col_overlap; 295 | $visible_height = $cell_height - $is_row_cut_off * $row_overlap; 296 | 297 | $x = $this->root->x(); 298 | $y = $this->root->y(); 299 | 300 | return new CellView( 301 | $cell_width, $cell_height, 302 | $visible_width, $visible_height, 303 | $x_offset, $y_offset, 304 | new Vector( 305 | $x + $col * $cell_width - $col * $col_overlap + $col_spacing, 306 | $y + $row * $cell_height - $row * $row_overlap + $row_spacing 307 | ), 308 | $this->cells[$index]->content() 309 | ); 310 | } 311 | 312 | protected function calculate_spacing(): Spacing 313 | { 314 | $rows = $this->rows(); 315 | $cols = $this->columns(); 316 | 317 | $total_width = $this->cell_factory->width() * $cols; 318 | $total_height = $this->cell_factory->height() * $rows; 319 | 320 | $unused_width = max(0, $this->width - $total_width); 321 | $unused_height = max(0, $this->height - $total_height); 322 | 323 | $horizontal = $cols > 1 ? $unused_width / ($cols - 1) : 0; 324 | $vertical = $rows > 1 ? $unused_height / ($rows - 1) : 0; 325 | 326 | $horizontal = min($horizontal, $this->max_spacing->horizontal()); 327 | $vertical = min($vertical, $this->max_spacing->vertical()); 328 | 329 | if ($this->overlap !== CellOverlap::HORIZONTAL) 330 | $horizontal = max($horizontal, $this->min_spacing->horizontal()); 331 | 332 | if ($this->overlap !== CellOverlap::VERTICAL) 333 | $vertical = max($vertical, $this->min_spacing->vertical()); 334 | 335 | return new Spacing($horizontal, $vertical); 336 | } 337 | 338 | protected function calculate_overlap 339 | (int $total_length, int $part_length, int $count): float 340 | { 341 | if ($count <= 1) 342 | return 0; 343 | 344 | $visible = ($total_length - $part_length) / ($count - 1); 345 | $hidden = max(0.0, $part_length - $visible); 346 | 347 | return $hidden; 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/Render/TableLayout.php: -------------------------------------------------------------------------------- 1 | x = $x; 14 | $this->y = $y; 15 | } 16 | 17 | public function x(): int { return $this->x; } 18 | public function y(): int { return $this->y; } 19 | 20 | public function plus(Vector $other): Vector 21 | { 22 | return new Vector( 23 | $this->x + $other->x, 24 | $this->y + $other->y 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Utility/BitField.php: -------------------------------------------------------------------------------- 1 | bits = $bits; } 11 | 12 | public function get (): int { return $this->bits; } 13 | public function clear (): void { $this->bits = 0; } 14 | 15 | public function set (int $bits): void { $this->bits = $bits; } 16 | public function add (int $bits): void { $this->bits |= $bits; } 17 | public function remove (int $bits): void { $this->bits &= ~$bits; } 18 | 19 | public function has($bits): bool { return ($this->bits & $bits) === $bits; } 20 | public function any(): bool { return $this->bits !== 0; } 21 | 22 | public function count(): int 23 | { 24 | return substr_count(decbin($this->bits), '1'); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Utility/ListObject.php: -------------------------------------------------------------------------------- 1 | array(), $property); 16 | } 17 | 18 | public function append_all($values): void 19 | { 20 | $this->exchangeArray(array_merge($this->array(), (array)$values)); 21 | } 22 | 23 | public function merge($other): ListObject 24 | { 25 | $result = clone $this; 26 | $result->append_all($other); 27 | return $result; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Utility/TypedListObject.php: -------------------------------------------------------------------------------- 1 | append_all($v); 12 | } 13 | 14 | protected abstract function allowed($value): bool; 15 | 16 | public function offsetSet($offset, $value): void 17 | { 18 | if (!$this->allowed($value)) 19 | throw new \TypeError("array element has the wrong type"); 20 | 21 | parent::offsetSet($offset, $value); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/definitions.php: -------------------------------------------------------------------------------- 1 | create_decoder($format_name); 88 | } 89 | 90 | function create_all_decoders(): \Generator 91 | { 92 | foreach (Config::get('formats')['decoders'] as $format_name => $class) 93 | yield $format_name => create_decoder_from_class($class); 94 | } 95 | 96 | function create_decode_tester(string ...$format_names): FormatDecodeTester 97 | { 98 | $tester = new FormatDecodeTester(); 99 | 100 | $decoders = count($format_names) > 0 101 | ? create_decoders(...$format_names) 102 | : create_all_decoders(); 103 | 104 | foreach ($decoders as $format_name => $decoder) 105 | $tester->register($format_name, $decoder); 106 | 107 | return $tester; 108 | } 109 | 110 | function get_image_urls(ImageKey $key): array 111 | { 112 | $lookup_json_path = Config::get('image_urls')['lookup_json_path']; 113 | $contents = file_get_contents($lookup_json_path); 114 | $lookup_table = json_decode($contents, true); 115 | 116 | $name = $key->value(); 117 | if (!array_key_exists("$name", $lookup_table)) { 118 | return null; 119 | } 120 | 121 | return $lookup_table["$name"]; 122 | } 123 | 124 | function image_loader(ImageKey $key, int $type, bool $allow_placeholder = true): ?Image 125 | { 126 | $urls = get_image_urls($key); 127 | $image = null; 128 | 129 | foreach ($urls as $url) { 130 | try { 131 | $image = Image::from_url($url, $type); 132 | break; 133 | } 134 | catch (\Exception $e) { 135 | $image = null; 136 | } 137 | } 138 | 139 | if ($allow_placeholder && $image === null) { 140 | $placeholder = Config::get('images')['placeholder']; 141 | $image = MemoryImage::from_file($placeholder); 142 | if ($image === null) 143 | throw new \Exception("failed to read image placeholder"); 144 | } 145 | 146 | return $image; 147 | } 148 | 149 | function create_image_cache(): ImageCache 150 | { 151 | $cache = new ImageCache( 152 | Config::get('cache')['directory'], 153 | Config::get('cache')['subfolder_length'] 154 | ); 155 | 156 | $loader = \Closure::fromCallable(__NAMESPACE__ . '\\image_loader'); 157 | 158 | $cache->loader($loader); 159 | $cache->type(\Image\ImageType::AUTO); 160 | 161 | return $cache; 162 | } 163 | 164 | function create_table(string $name, bool $is_center_deck): Table 165 | { 166 | $tables = Config::get('tables'); 167 | 168 | if (!isset($tables[$name])) 169 | throw new \Exception("table $name is not defined in configuration"); 170 | 171 | $cell_dimensions = Config::get('cell'); 172 | 173 | $cell_factory = new CellFactory( 174 | $cell_dimensions->width(), 175 | $cell_dimensions->height() 176 | ); 177 | 178 | $T = $tables[$name]; 179 | 180 | $table = new Table($T['width'], $T['height'], $cell_factory); 181 | 182 | $table->layout($T['layout']['primary'], $T['layout']['secondary']); 183 | $table->overlap($T['overlap']); 184 | 185 | $root_x = $T['root']->x(); 186 | $root_y = $T['root']->y(); 187 | if ($is_center_deck) { 188 | $root_x += $T['root_center_offset']->x(); 189 | $root_y += $T['root_center_offset']->y(); 190 | } 191 | $table->root($root_x, $root_y); 192 | $table->spacing($T['spacing']->horizontal(), $T['spacing']->vertical()); 193 | 194 | return $table; 195 | } 196 | 197 | function create_deck_table(string $name, Deck $deck): Table 198 | { 199 | $is_center_deck = $deck instanceof MainDeck && $deck->count() <= MainDeck::MIN_SIZE; 200 | $table = create_table($name, $is_center_deck); 201 | 202 | foreach ($deck->cards() as $card) 203 | $table->push($card); 204 | 205 | return $table; 206 | } 207 | -------------------------------------------------------------------------------- /src/scripts/db.php: -------------------------------------------------------------------------------- 1 | prepare("$base_query $placeholders;"); 32 | 33 | foreach ($chunks as $chunk) { 34 | $values = array_merge(...$chunk); 35 | $retval = $statement->execute($values); 36 | if (!$retval) 37 | return false; 38 | } 39 | 40 | // last chunk 41 | if (count($last_chunk) > 0) { 42 | $chunk_size = count($last_chunk[0]); 43 | $chunks = array_splice($last_chunk, 0); 44 | $last_chunk = []; 45 | goto insert; 46 | } 47 | 48 | return true; 49 | } 50 | 51 | function sanitize_name(string $name): string 52 | { 53 | $name = strtolower($name); 54 | $name = preg_replace("/(\s*[^a-z0-9]\s*)+/", " ", $name); 55 | $name = trim($name); 56 | return $name; 57 | } 58 | 59 | /** 60 | * returns the first two identifying characters of a name. 61 | * @param string $s the name in its sanitized form 62 | */ 63 | function name_cluster(string $s, string $placeholder = '_'): string 64 | { 65 | switch (strlen($s)) { 66 | default: break; 67 | case 1: return $s[0] . $placeholder; 68 | case 0: return $placeholder . $placeholder; 69 | } 70 | 71 | $c1 = $s[0]; 72 | $c2 = ctype_space($s[1]) ? $s[2] : $s[1]; 73 | return $c1 . $c2; 74 | } 75 | 76 | function download_file_with_info($log, $source_path, $source_url): bool 77 | { 78 | $download_success = download_file($source_path, $source_url, $status_code); 79 | 80 | if (!$download_success) { 81 | $message = "download failed"; 82 | if ($status_code) 83 | $message .= " (HTTP status code $status_code)"; 84 | 85 | $log->error($message); 86 | return false; 87 | } 88 | 89 | return true; 90 | } 91 | 92 | function update_database(string $source_url): bool 93 | { 94 | $log = get_logger('database'); 95 | 96 | $log->info("downloading database from remote..."); 97 | 98 | $source_path = temp_filename(); 99 | if (!download_file_with_info($log, $source_path, $source_url)) { 100 | return false; 101 | } 102 | 103 | $src_db = new \PDO("sqlite:$source_path"); 104 | $src_db->exec("CREATE INDEX idx_texts_name ON texts(name);"); 105 | $select_stmt = $src_db->prepare(<<execute(); 132 | $rows = $select_stmt->fetchAll(\PDO::FETCH_ASSOC); 133 | 134 | $src_db = null; // close connection 135 | unlink($source_path); // not needed anymore 136 | 137 | 138 | $write_path = temp_filename(); 139 | $dest_db = new \PDO("sqlite:$write_path"); 140 | $dest_db->exec(<<exec(<<exec("CREATE INDEX idx_card_cluster ON card(cluster);"); 162 | $dest_db->exec("CREATE INDEX idx_card_name ON card(sanitized_name);"); 163 | $dest_db->exec("CREATE INDEX idx_card_type ON card(type);"); 164 | $dest_db->exec("CREATE INDEX idx_card_match_name ON card(match_name);"); 165 | 166 | # TODO: fetch source rows in a chunked manner. 167 | 168 | $cards = []; 169 | foreach ($rows as $row) { 170 | $name = $row['name']; 171 | $sanitized_name = sanitize_name($name); 172 | $name_cluster = name_cluster($sanitized_name); 173 | 174 | $cards[] = [ 175 | intval($row['id']), 176 | intval($row['alias']), 177 | $name_cluster, 178 | $sanitized_name, 179 | $name, 180 | $row['type'], 181 | intval($row['match_name']) 182 | ]; 183 | } 184 | 185 | $dest_db->beginTransaction(); 186 | $columns = [ 'id', 'alias', 'cluster', 'sanitized_name', 'name', 'type', 'match_name' ]; 187 | $retval = insert_chunked($dest_db, 'card', $columns, $cards, 256); 188 | 189 | if (!$retval) { 190 | $log->error('failed to insert rows'); 191 | return false; 192 | } 193 | 194 | $dest_db->commit(); 195 | 196 | $dest_path = Config::get('repository')['path']; 197 | rename($write_path, $dest_path); 198 | chmod($dest_path, 0664); // anyone may read 199 | 200 | $log->info("SUCCESS - database update completed"); 201 | 202 | return true; 203 | } 204 | 205 | function update_image_urls(): bool 206 | { 207 | $log = get_logger('database'); 208 | 209 | $log->info("updating image urls..."); 210 | 211 | $lookup_json_url = getenv('CARD_IMAGE_LOOKUP_JSON_URL'); 212 | $url_prefix = getenv('CARD_IMAGE_URL'); 213 | $url_postfix = getenv('CARD_IMAGE_URL_EXT'); 214 | 215 | $dest_path = Config::get('image_urls')['lookup_json_path']; 216 | 217 | if (file_exists($dest_path)) { 218 | unlink($dest_path); 219 | } 220 | 221 | $has_image_source = false; 222 | 223 | $json_urls = null; 224 | 225 | if ($lookup_json_url !== false) { 226 | $has_image_source = true; 227 | 228 | $write_path = temp_filename(); 229 | if (!download_file_with_info($log, $write_path, $lookup_json_url)) { 230 | return false; 231 | } 232 | $contents = file_get_contents($write_path); 233 | $json_urls = json_decode($contents, true); 234 | unlink($write_path); 235 | } 236 | 237 | $lookup_table = []; 238 | 239 | if ($url_prefix !== false && $url_postfix !== false) { 240 | $has_image_source = true; 241 | 242 | $url = rtrim($url_prefix, "/"); 243 | $extension = ltrim($url_postfix, "."); 244 | 245 | $db = Config\create_repository_pdo(); 246 | $cards = $db->query(<< c2.id 250 | GROUP BY c1.id 251 | ORDER BY CAST(c1.id AS TEXT) 252 | 253 | SQL); 254 | 255 | foreach ($cards as $row) { 256 | $code = $row['id']; 257 | $alias = $row['alias']; 258 | $same_name_ids = $row['same_name_ids']; 259 | 260 | $result = ["$url/$code.$extension"]; 261 | $ids = [intval($code)]; 262 | 263 | if (!empty($alias) && intval($alias) !== 0) { 264 | $result[] = "$url/$alias.$extension"; 265 | $ids[] = intval($alias); 266 | } 267 | if ($same_name_ids !== null) { 268 | foreach (explode(',', $same_name_ids) as $id) { 269 | $result[] = "$url/$id.$extension"; 270 | $ids[] = intval($id); 271 | } 272 | } 273 | if ($json_urls != null) { 274 | foreach ($ids as $id) { 275 | if (array_key_exists($id, $json_urls)) { 276 | $result[] = $json_urls[$id]; 277 | } 278 | } 279 | } 280 | 281 | $lookup_table["$code"] = array_unique($result); 282 | } 283 | } 284 | else if ($json_urls !== null) { 285 | foreach ($json_urls as $key => $value) { 286 | if (!array_key_exists($key, $lookup_table)) { 287 | $lookup_table["$key"] = []; 288 | } 289 | $lookup_table["$key"][] = $value; 290 | } 291 | } 292 | 293 | 294 | 295 | if (!$has_image_source) { 296 | $log->error('missing card image source in config'); 297 | return false; 298 | } 299 | 300 | $data = json_encode($lookup_table); 301 | file_put_contents($dest_path, $data); 302 | chmod($dest_path, 0664); // anyone may read 303 | 304 | $log->info("SUCCESS - image url update completed"); 305 | 306 | return true; 307 | } 308 | -------------------------------------------------------------------------------- /src/scripts/helpers.php: -------------------------------------------------------------------------------- 1 | warning('cURL: ' . curl_error($ch)); 57 | else { 58 | $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); 59 | $is_error = $code != 0 && $code !== Http::OK; 60 | if ($code !== 0) $status_code = $code; 61 | } 62 | 63 | curl_close($ch); 64 | fclose($fp); 65 | 66 | if ($is_error) { 67 | unlink($dest_file); 68 | return false; 69 | } 70 | 71 | return true; 72 | } 73 | 74 | class FileLock 75 | { 76 | private string $filename; 77 | private bool $is_locked = false; 78 | 79 | private $file_handle = null; 80 | 81 | public function __construct(string $filename) 82 | { 83 | $this->filename = $filename; 84 | } 85 | 86 | public function lock(): bool 87 | { 88 | $this->file_handle = fopen($this->filename, 'a+'); 89 | chmod($this->filename, 0660); 90 | 91 | if ($success = $this->file_handle !== false) { 92 | flock($this->file_handle, LOCK_EX); 93 | $this->is_locked = true; 94 | } 95 | 96 | return $success; 97 | } 98 | 99 | public function unlock() 100 | { 101 | if (!$this->is_locked) 102 | return; 103 | 104 | flock($this->file_handle, LOCK_UN); 105 | $this->is_locked = false; 106 | 107 | fclose($this->file_handle); 108 | $this->file_handle = null; 109 | 110 | if (file_exists($this->filename)) 111 | unlink($this->filename); 112 | } 113 | 114 | public function is_locked() 115 | { 116 | if ($this->is_locked) return true; 117 | // if ($this->file_handle === null) 118 | // return false; 119 | 120 | $fp = fopen($this->filename, 'a+'); 121 | flock($fp, LOCK_EX | LOCK_NB, $wouldblock); 122 | flock($fp, LOCK_UN); 123 | fclose($fp); 124 | 125 | return $wouldblock === 1; 126 | } 127 | } 128 | 129 | function continue_in_background(bool $ignore_user_abort = true, 130 | int $time_limit = 0): void 131 | { 132 | ignore_user_abort($ignore_user_abort); 133 | set_time_limit($time_limit); 134 | 135 | header("Connection: close"); 136 | ob_end_flush(); 137 | flush(); 138 | } 139 | -------------------------------------------------------------------------------- /src/scripts/log.php: -------------------------------------------------------------------------------- 1 | setFormatter($formatter); 15 | 16 | $logger->pushHandler($handler); 17 | return $logger; 18 | } 19 | --------------------------------------------------------------------------------