feat: Add card type summary

This commit is contained in:
Samir L. Boulema 2026-05-11 09:11:34 +02:00
parent d061ff7b73
commit b5d2ff4986
23 changed files with 914 additions and 751 deletions

View file

@ -10,7 +10,7 @@ permissions:
contents: read
env:
version: '1.8.${{ github.run_number }}'
version: '1.9.${{ github.run_number }}'
nodeVersion: '22'
jobs:

View file

@ -32,6 +32,9 @@ const buildOptions = {
'@lezer/highlight',
'@lezer/lr',
...builtinModules],
loader: {
".svg": "text",
},
format: 'cjs',
target: 'es2018',
logLevel: "info",

View file

@ -1,7 +1,7 @@
{
"id": "mtg-deck",
"name": "MTG Deck",
"version": "1.8.57",
"version": "1.9.58",
"minAppVersion": "0.15.0",
"description": "Display your MTG decks and card lists in your notes.",
"author": "Samir Boulema",

View file

@ -1,6 +1,6 @@
{
"name": "mtg-deck",
"version": "1.8.57",
"version": "1.9.58",
"description": "Display your MTG decks and card lists in your notes.",
"main": "main.js",
"scripts": {

View file

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
<title>artifact</title>
<path fill="#444" d="M7.992 1.938h16.015c0.41 0.128 0.83 0.344 0.968 0.784 0.134 0.709-0.138 1.412-0.067 2.123 0.065 0.616-0.192 1.222-0.067 1.834 0.242 0.355 0.684 0.517 0.895 0.896 0.081 0.129 0.142 0.271 0.202 0.412v0.842c-0.141 0.346-0.342 0.674-0.634 0.913-0.261 0.233-0.621 0.407-0.728 0.766-0.581 2.304-1.286 4.598-2.381 6.715-0.255 0.534-0.77 0.864-1.211 1.23-0.641 0.526-1.397 0.881-2.142 1.231-0.457 0.212-0.993 0.349-1.319 0.758-0.058 0.308 0 0.621 0.045 0.926 0.086 1.014 0.179 2.028 0.252 3.044 0.102 0.546-0.009 1.125 0.219 1.647 0.352 0.851 1.152 1.388 1.933 1.809 0.726 0.327 1.499 0.625 2.309 0.599 0.611-0.026 1.244 0.074 1.787 0.368 0.322 0.16 0.375 0.636 0.113 0.877-0.269 0.209-0.617 0.263-0.938 0.35h-14.489c-0.32-0.086-0.667-0.142-0.935-0.35-0.241-0.225-0.22-0.653 0.055-0.841 0.636-0.386 1.403-0.427 2.128-0.407 1.109-0.103 2.175-0.573 3.052-1.254 0.427-0.332 0.783-0.774 0.957-1.29 0.169-0.488 0.071-1.010 0.171-1.508 0.095-1.181 0.173-2.363 0.315-3.54 0.008-0.207 0.043-0.458-0.16-0.587-0.517-0.406-1.18-0.55-1.747-0.873-0.847-0.384-1.604-0.934-2.292-1.554-0.404-0.38-0.622-0.901-0.864-1.387-0.879-1.91-1.519-3.922-2.014-5.962-0.115-0.407-0.548-0.577-0.823-0.856-0.263-0.214-0.43-0.513-0.534-0.832v-0.836c0.102-0.515 0.548-0.787 0.915-1.103 0.319-0.229 0.206-0.662 0.188-0.996-0.14-0.618-0.028-1.252-0.096-1.875-0.070-0.426-0.122-0.865-0.047-1.294 0.167-0.407 0.565-0.654 0.968-0.782z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
<title>creature</title>
<path fill="#444" d="M23.537 1.823h1.456c-1.451 1.276-3.218 1.65-4.807 2.514-2.125 0.933-3.74 2.641-5.553 4.033-1.708 1.36-3.619 2.498-5.034 4.193-1.405 1.669-2.95 3.304-3.794 5.349 0.354 0.79 0.656 1.602 0.918 2.428 4.9-4.47 9.863-9.134 16.063-11.746 2.998-1.198 5.922-2.81 9.198-3.090-0.637 1.096-1.663 1.529-2.596 2.017-3.944 1.889-7.748 4.199-10.819 7.35-2.918 3.243-6.531 5.757-9.589 8.86-1.265 1.281-2.24 2.835-3.613 4.008-0.883 0.768-2.115-0.36-1.896-1.354 0.134-1.555 0.841-3.361-0.242-4.741-0.93-0.749-2.287-0.962-3.103-1.752 0 0-0.086-0.361-0.105-0.63s0.105-0.759 0.105-0.759c0.509-2.321 2.373-4.024 3.877-5.782 1.65-1.861 3.387-3.705 5.502-5.046 1.494-0.79 3.17-1.185 4.639-2.033 2.966-1.638 5.906-3.166 9.395-3.817zM21.912 18.504c-2.536 1.297-4.897 2.905-7.124 4.674-1.634 1.265-3.944 2.163-4.419 4.406-0.37 1.045 0.236 2.039 1.102 2.593h1.195c1.552-0.959 2.364-2.66 3.571-3.97 1.816-2.013 3.708-4.005 5.989-5.499 1.647-1.105 3.278-2.236 4.944-3.316 1.564-1.026 3.352-1.689 4.811-3.428-3.648 0.739-6.901 2.67-10.071 4.54z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
<title>enchantment</title>
<path fill="#444" d="M15.839 2.855h0.294c0.115 0.318 0.203 0.647 0.245 0.985 0.414 3.991 0.839 7.985 1.253 11.976 0.072 0.448 0.075 0.966 0.396 1.32 0.377 0.288 0.786-0.094 1.018-0.384 2.057-2.781 4.163-5.529 6.231-8.304 0.219-0.325 0.523-0.575 0.874-0.745-0.018 0.409-0.083 0.82-0.248 1.197-1.232 2.994-2.422 6.007-3.649 9.003-0.136 0.409-0.35 0.85-0.206 1.287 0.209 0.419 0.767 0.222 1.117 0.12 2.356-0.946 4.708-1.909 7.061-2.865 0.563-0.254 1.141-0.486 1.76-0.561v0.283c-0.376 0.462-0.857 0.815-1.309 1.196-1.905 1.621-3.796 3.258-5.715 4.863-0.312 0.277-0.646 0.639-0.582 1.092 0.113 0.379 0.52 0.54 0.862 0.654 2.257 0.524 4.493 1.145 6.744 1.701v0.293c-2.732 0.601-5.436 1.34-8.194 1.817-0.488 0.054-1.028 0.24-1.485-0.029-0.206-0.321-0.061-0.711-0.034-1.060 0.213-1.255 0.054-2.57-0.492-3.721-0.842-1.809-2.562-3.197-4.527-3.577-1.766-0.363-3.678 0.066-5.104 1.173-1.88 1.386-2.893 3.881-2.425 6.181 0.048 0.353 0.177 0.786-0.136 1.060-0.296 0.131-0.623 0.085-0.934 0.043-2.711-0.39-5.339-1.191-8.020-1.738-0.208-0.053-0.414-0.115-0.62-0.173v-0.203c0.246-0.117 0.503-0.213 0.77-0.273 1.933-0.42 3.822-1.026 5.758-1.439 0.412-0.109 0.918-0.262 1.063-0.715 0.050-0.451-0.286-0.81-0.595-1.090-1.68-1.407-3.338-2.842-5.005-4.263-0.655-0.598-1.399-1.101-1.992-1.763v-0.328c0.334 0.074 0.675 0.133 0.994 0.262 2.473 1.034 4.968 2.014 7.444 3.042 0.451 0.176 1.391 0.585 1.512-0.157 0.030-0.623-0.28-1.191-0.499-1.757-0.985-2.427-1.965-4.856-2.951-7.281-0.281-0.735-0.694-1.448-0.684-2.259 0.36 0.155 0.66 0.406 0.879 0.729 2.046 2.757 4.134 5.481 6.18 8.239 0.249 0.376 0.649 0.609 1.108 0.534 0.177-0.444 0.313-0.906 0.352-1.386 0.353-3.429 0.719-6.856 1.081-10.283 0.109-0.902 0.118-1.825 0.408-2.697z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
<title>instant</title>
<path fill="#444" d="M13.701 2.542h11.528c0.286 0.096 0.558 0.283 0.628 0.595-0.053 0.387-0.288 0.713-0.566 0.974-0.488 0.611-1.080 1.127-1.625 1.685-0.336 0.304-0.577 0.695-0.915 0.998-0.544 0.559-1.14 1.071-1.621 1.688-0.459 0.473-0.933 0.93-1.399 1.396-0.275 0.266-0.481 0.595-0.767 0.851-0.557 0.571-1.166 1.095-1.658 1.725-0.527 0.551-1.091 1.066-1.612 1.622-0.335 0.446-0.756 0.813-1.145 1.21-0.307 0.31-0.666 0.604-0.825 1.024-0.071 0.29 0.311 0.431 0.543 0.378 2.471-0.492 4.942-0.988 7.414-1.482 0.346-0.065 0.775-0.007 0.975 0.317 0.406 0.644 0.477 1.436 0.849 2.094 0.2 0.36 0.643 0.469 1.024 0.491 0.56-0.039 1.117-0.179 1.682-0.115 0.146 0.030 0.257 0.139 0.379 0.218v0.396c-0.24 0.355-0.563 0.64-0.863 0.943-0.809 0.809-1.616 1.619-2.424 2.43-0.257 0.26-0.558 0.469-0.814 0.73-1.027 1.032-2.057 2.061-3.084 3.093-0.463 0.521-1.038 0.921-1.511 1.43-0.916 0.922-1.838 1.839-2.753 2.763-0.25 0.257-0.531 0.472-0.844 0.632l-0.32 0.080c0.116-0.691 0.641-1.197 0.968-1.79 1.713-2.678 3.402-5.372 5.113-8.051 0.133-0.218 0.242-0.474 0.201-0.734-0.082-0.376-0.518-0.538-0.863-0.518-3.796 0.585-7.585 1.199-11.38 1.787-0.684 0.089-1.372 0.278-2.064 0.163-0.235-0.065-0.424-0.228-0.542-0.438v-0.677c0.371-0.862 0.835-1.679 1.286-2.5 1.252-2.25 2.519-4.491 3.785-6.733 0.208-0.368 0.456-0.727 0.552-1.146 0.109-0.273-0.152-0.557-0.416-0.599-0.385-0.116-0.912-0.001-1.141-0.416-0.22-0.598 0.196-1.169 0.502-1.651 0.8-1.331 1.602-2.661 2.42-3.982 0.294-0.459 0.79-0.735 1.302-0.883z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
<title>land</title>
<path fill="#444" d="M10.756 3.523l0.002-0.001c0.825-0.316 1.306 0.346 1.5 0.658 1.075 1.515 1.514 3.352 2.294 5.017 1.055 2.212 2.206 4.463 4.041 6.138 0.241 0.259 0.719 0.494 0.928 0.249 0.768-0.9 1.389-1.911 1.948-2.952 0.361-0.718 1.161-1.177 1.962-1.151 0.634-0.018 1.206 0.389 1.559 0.891 0.809 1.231 1.198 2.665 1.739 4.022 0.666 1.43 1.391 2.874 2.519 4.003 0.669 0.624 1.395 1.184 2.020 1.855 0.343 0.304 0.536 0.728 0.686 1.151l0.015 0.043c0.149 1.177-0.513 1.735-0.892 2.004-0.739 0.589-1.621 0.96-2.509 1.258-2.994 1.063-6.163 1.622-9.327 1.758-5.152 0.221-7.177 0.004-7.702-0.086-1.923-0.172-3.833-0.493-5.711-0.942-1.543-0.425-3.119-0.855-4.494-1.702-0.536-0.319-1.558-0.709-1.275-2.319l0.016-0.021c0.289-1.060 1.099-1.865 1.837-2.644 0.545-0.742 1.307-1.284 1.852-2.025 1.294-1.664 2.244-3.568 2.989-5.531 0.682-1.721 1.081-3.538 1.687-5.286 0.303-1.127 0.753-2.212 1.298-3.243 0.243-0.455 0.579-0.862 1.018-1.141z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
<title>planeswalker</title>
<path fill="#444" d="M15.996 0c0.041 0.076 0.047 0.163 0.063 0.246 0.066 0.441 0.091 0.886 0.125 1.33 0.076 1.183 0.137 2.367 0.211 3.55 0.125 1.934 0.268 3.87 0.548 5.79 0.137 0.906 0.3 1.812 0.577 2.686 0.367 0.027 0.734 0.054 1.1 0.094 0.181-0.535 0.27-1.095 0.363-1.651 0.083-0.519 0.153-1.041 0.217-1.563 0.249-2.039 0.423-4.086 0.611-6.131 0.043-0.421 0.071-0.845 0.14-1.263 0.035 0.090 0.043 0.188 0.058 0.283 0.041 0.286 0.071 0.574 0.102 0.861 0.197 1.912 0.37 3.827 0.616 5.734 0.113 0.875 0.238 1.749 0.405 2.616 0.112 0.566 0.232 1.134 0.434 1.677 0.34 0.104 0.678 0.218 1.005 0.358 0.103-0.235 0.18-0.48 0.252-0.726 0.183-0.638 0.31-1.291 0.42-1.944 0.239-1.41 0.379-2.834 0.566-4.252 0.049-0.323 0.082-0.652 0.184-0.964 0.050 0.134 0.065 0.277 0.088 0.417 0.090 0.64 0.152 1.284 0.216 1.927 0.065 0.699 0.135 1.397 0.187 2.096 0.002-0.008 0.006-0.024 0.008-0.032-0.001 0.098 0.009 0.196 0.019 0.294 0.143 1.685 0.281 3.371 0.406 5.058-0.959 0.363-1.866 0.861-2.695 1.463-1.11 0.806-2.075 1.803-2.876 2.916-0.835 1.16-1.495 2.443-1.969 3.791-0.596 1.696-0.933 3.474-1.114 5.26-0.059 0.548-0.095 1.099-0.151 1.647-0.020 0.146-0.024 0.301-0.094 0.434h-0.035c-0.065-0.099-0.069-0.22-0.087-0.333-0.043-0.364-0.067-0.73-0.1-1.094-0.084-0.983-0.204-1.965-0.388-2.935-0.302-1.587-0.757-3.154-1.468-4.608-0.813-1.668-1.933-3.197-3.336-4.417-1.040-0.905-2.235-1.635-3.525-2.124 0.178-2.348 0.366-4.695 0.585-7.040 0.074-0.762 0.145-1.524 0.249-2.282 0.022-0.138 0.039-0.278 0.080-0.412 0.005 0.001 0.016 0.003 0.022 0.004 0.085 0.274 0.118 0.561 0.161 0.844 0.149 1.096 0.261 2.197 0.417 3.293 0.125 0.878 0.265 1.755 0.48 2.616 0.096 0.374 0.201 0.749 0.354 1.105 0.327-0.141 0.666-0.253 1.006-0.359 0.127-0.338 0.218-0.688 0.3-1.039 0.124-0.545 0.222-1.095 0.31-1.647 0.356-2.277 0.564-4.574 0.796-6.867 0.042-0.412 0.083-0.825 0.137-1.236 0.020-0.13 0.031-0.263 0.073-0.389 0.046 0.287 0.071 0.577 0.1 0.866 0.183 1.947 0.345 3.896 0.567 5.839 0.107 0.923 0.221 1.847 0.385 2.762 0.074 0.386 0.147 0.775 0.279 1.147 0.366-0.033 0.732-0.066 1.099-0.091 0.274-0.862 0.435-1.755 0.572-2.648 0.149-1 0.256-2.005 0.345-3.012 0.152-1.717 0.248-3.437 0.346-5.157 0.048-0.762 0.084-1.525 0.165-2.284 0.021-0.169 0.039-0.34 0.089-0.503z"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,5 @@
<!-- Generated by IcoMoon.io -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
<title>sorcery</title>
<path fill="#444" d="M12.781 1.938h0.013c0.58 0.136 1.216 0.232 1.689 0.624 0.163 0.113 0.166 0.317 0.17 0.496-0.593 0.041-1.178-0.157-1.771-0.066-0.921 0.117-1.898 0.407-2.517 1.14-0.556 0.667-0.596 1.605-0.437 2.422 0.152 0.83 0.798 1.453 1.465 1.912 1.048 0.667 2.248 1.066 3.251 1.808 1.058 0.741 1.792 1.85 2.855 2.585 0.371 0.256 0.803 0.458 1.266 0.434 0.707 0.051 1.404-0.406 1.681-1.049 0.368-1.21-0.184-2.666-1.31-3.272-0.762-0.427-1.624-0.616-2.427-0.944-0.216-0.082-0.433-0.222-0.477-0.466 0.040-0.158 0.141-0.318 0.317-0.341 1.29-0.192 2.594 0.14 3.769 0.666 1.745 0.907 3.329 2.162 4.493 3.756 1.124 1.493 1.752 3.299 2.093 5.121 0.059 0.428 0.125 0.855 0.204 1.28v2.759c-0.198 1.282-0.44 2.579-0.998 3.762-0.819 1.822-2.18 3.429-3.925 4.421-1.036 0.609-1.406 1.149-5.86 1.072v0c-0.507-0.148-0.662-0.32-0.75-0.652 0.138-0.466 0.672-0.597 1.087-0.698 0.453-0.118 1.032-0.153 1.272-0.624-0.127-0.544-0.669-0.83-1.15-1.009-0.727-0.268-1.525-0.239-2.282-0.146-1.097 0.197-2.099 0.832-3.238 0.802-1.393-0.023-2.711-0.632-3.821-1.438-0.803-0.575-1.424-1.37-1.902-2.227-0.336-0.601-1.042-1.925-0.652-3.675v-0.015c0.211-1.099 0.622-2.228 1.493-2.976 0.572-0.437 1.244-0.812 1.977-0.845 0.274 0.008 0.619 0.034 0.777 0.293-0.012 0.106-0.067 0.183-0.16 0.232-0.356 0.233-0.796 0.378-1.038 0.752-0.454 0.618-0.496 1.432-0.385 2.165 0.181 0.906 0.824 1.676 1.615 2.129 0.631 0.362 1.312 0.646 2.026 0.788 0.642 0.131 1.297 0.168 1.949 0.197 0.648 0.028 1.193-0.067 1.762-0.062 0.788 0.007 1.596 0.066 2.333 0.382 0.988 0.431 1.891 1.134 2.408 2.095 0.197 0.374 0.245 0.805 0.444 1.177 0.441-0.083 0.638-0.537 0.772-0.916 0.324-0.878 0.046-1.839-0.329-2.653-0.593-1.227-1.761-2.093-3.046-2.485-1.219-0.392-2.546-0.21-3.752-0.651-0.561-0.197-0.993-0.631-1.364-1.078-0.23-0.276-0.354-0.657-0.224-1.006 0.549-0.137 1.061 0.289 1.613 0.212 0.469-0.106 0.795-0.539 0.889-0.993 0.203-0.697 0.029-1.47-0.424-2.032-0.607-0.755-1.495-1.215-2.389-1.544-1.001-0.384-2.093-0.546-3.022-1.106-1.011-0.633-1.883-1.557-2.273-2.701-0.381-0.993-0.378-2.075-0.33-3.122 0.098-0.99 0.532-1.923 1.118-2.716 0.382-0.531 0.933-0.9 1.478-1.244 0.648-0.411 2.128-0.996 3.972-0.73z"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

49
src/card-grid.ts Normal file
View file

@ -0,0 +1,49 @@
import { nameToId } from "./collection";
import { CardData } from "./scryfall";
import { Line } from "./types";
export const buildCardGrid = (
container: HTMLElement,
lines: Line[],
cardDataByCardId: Record<string, CardData>
): HTMLElement => {
const cardGrid = container.createDiv({ cls: "decklist__card-grid" });
cardGrid.style.display = "none";
lines
.filter((line) => line.lineType === "card" && line.cardName)
.forEach((line) => {
const cardId = nameToId(line.cardName!);
const cardInfo = cardDataByCardId[cardId];
const imgUri =
cardInfo?.image_uris?.normal ??
cardInfo?.card_faces?.[0]?.image_uris?.normal;
if (!imgUri) return;
const cardEl = cardGrid.createDiv({ cls: "decklist__card-grid__item" });
cardEl.createEl("img", {
attr: { src: imgUri },
cls: "decklist__card-grid__image",
});
cardEl.createSpan({
cls: "decklist__card-grid__count",
text: `${line.cardCount}x`,
});
});
return cardGrid;
};
export const setupGridToggle = (
toggleBtn: HTMLElement,
cardGrid: HTMLElement,
sectionList: HTMLElement
): void => {
toggleBtn.addEventListener("click", () => {
const isGridVisible = cardGrid.style.display !== "none";
cardGrid.style.display = isGridVisible ? "none" : "flex";
sectionList.style.display = isGridVisible ? "" : "none";
toggleBtn.textContent = isGridVisible ? "Visual View" : "List View";
});
};

75
src/card-preview.ts Normal file
View file

@ -0,0 +1,75 @@
import { nameToId } from "./collection";
import { CardData } from "./scryfall";
export const setupCardPreview = (
lineEl: HTMLElement,
cardName: string,
cardDataByCardId: Record<string, CardData>,
imgElContainer: HTMLElement,
imgEl: HTMLImageElement,
sectionList: HTMLElement
): void => {
const cardId = nameToId(cardName);
const cardInfo = cardDataByCardId[cardId];
const isDoubleFaced =
(cardInfo?.card_faces?.length ?? 0) > 1 && !cardInfo?.image_uris;
let faceIndex = 0;
const getImgUri = (
info: CardData,
index: number
): string | undefined => {
if (info.image_uris) {
return info.image_uris.large;
}
return info.card_faces?.[index]?.image_uris?.large;
};
lineEl.addEventListener("mouseenter", () => {
const cardInfo = cardDataByCardId[cardId];
if (!cardInfo) return;
faceIndex = 0;
imgElContainer.style.display = "block";
imgElContainer.style.top = `${lineEl.offsetTop}px`;
imgElContainer.style.right = "50px";
imgElContainer.style.left = "auto";
const imgUri = getImgUri(cardInfo, faceIndex);
if (imgUri) {
imgEl.src = imgUri;
}
if (isDoubleFaced) {
imgEl.style.cursor = "pointer";
imgEl.onclick = () => {
faceIndex = faceIndex === 0 ? 1 : 0;
const uri = getImgUri(cardInfo, faceIndex);
if (uri) {
imgEl.src = uri;
}
};
} else {
imgEl.style.cursor = "default";
imgEl.onclick = null;
}
});
lineEl.addEventListener("mouseleave", (e) => {
if (!imgElContainer.contains(e.relatedTarget as Node)) {
imgElContainer.style.display = "none";
imgEl.src = "";
imgEl.onclick = null;
}
});
imgElContainer.addEventListener("mouseleave", (e) => {
if (!sectionList.contains(e.relatedTarget as Node)) {
imgElContainer.style.display = "none";
imgEl.src = "";
imgEl.onclick = null;
}
});
};

94
src/parser.ts Normal file
View file

@ -0,0 +1,94 @@
import { CardCounts, nameToId } from "./collection";
import { CardIdentifier } from "./scryfall";
import { COMMENT_DELIMITER, Line } from "./types";
const lineMatchRE = /^(\d+)\s+(.*?)(\s+\(([A-Za-z0-9]{3})\)\s+0*(\d+))?$/;
const blankLineRE = /^\s+$/;
const headingMatchRE = new RegExp("^[^[0-9|" + COMMENT_DELIMITER + "]");
export const parseLines = (
rawLines: string[],
cardCounts: CardCounts
): Line[] => {
// This means global counts are not available because they are missing or no collection files are present
const shouldSkipGlobalCounts = !Object.keys(cardCounts).length;
return rawLines.map((line) => {
// Handle blank lines
if (!line.length || line.match(blankLineRE)) {
return { lineType: "blank" };
}
// Handle headings
if (line.match(headingMatchRE)) {
return { lineType: "section", text: line };
}
// Handle comment lines
if (line.startsWith(COMMENT_DELIMITER + " ")) {
return { lineType: "comment", comments: [line] };
}
let lineWithoutComments: string = line;
const comments: string[] = [];
// Handle inline comments
if (line.includes(COMMENT_DELIMITER)) {
const lineAndComments = line.split(COMMENT_DELIMITER);
lineAndComments.slice(1).forEach((comment) => comments.push(comment));
lineWithoutComments = lineAndComments[0];
}
const lineParts = lineWithoutComments.match(lineMatchRE);
if (lineParts == null) {
return { lineType: "error", errors: [`invalid line: ${line}`] };
}
const cardCount: number = parseInt(lineParts[1] || "0");
const cardName: string = lineParts[2];
const cardId: string = nameToId(cardName);
const cardSet: string = lineParts[4];
const cardNumber: string = lineParts[5];
const errors: string[] = [];
let globalCount = null;
if (!shouldSkipGlobalCounts) {
globalCount = cardCounts[cardId] || 0;
}
if (cardName.length === 0) {
errors.push(`Unable to parse card name from: ${line}`);
}
return {
lineType: "card",
cardCount,
globalCount,
cardName,
cardSet,
cardNumber,
comments,
errors,
};
});
};
export const buildDistinctCardList = (lines: Line[]): CardIdentifier[] => {
return Array.from(
new Set(
lines.flatMap((line): CardIdentifier[] => {
if (line.lineType !== "card") {
return [];
} else if (line.cardSet === undefined) {
return [{ name: nameToId(line.cardName) }];
} else if (line.cardNumber !== undefined) {
return [{ set: line.cardSet, collector_number: line.cardNumber }];
} else {
return [];
}
})
)
);
};

32
src/pricing.ts Normal file
View file

@ -0,0 +1,32 @@
import { nameToId } from "./collection";
import { CardData } from "./scryfall";
import { ObsidianPluginMtgSettings } from "./settings";
export const currencyMapping: Record<string, string> = {
usd: "$",
eur: "€",
tix: "Tx",
};
export const getCardPrice = (
cardName: string,
cardDataById: Record<string, CardData>,
settings: ObsidianPluginMtgSettings
): string | null => {
const cardId = nameToId(cardName);
const cardData = cardDataById[cardId];
const preferredCurrency = settings.decklist.preferredCurrency;
const showCardPrices = settings.decklist.showCardPrices;
if (!cardData || !showCardPrices) {
return null;
}
if (preferredCurrency === "eur") {
return cardData.prices?.eur || null;
} else if (preferredCurrency === "tix") {
return cardData.prices?.tix || null;
} else {
return cardData.prices?.usd || null;
}
};

View file

@ -1,239 +1,12 @@
import { CardCounts, nameToId, UNKNOWN_CARD } from "./collection";
import {
CardIdentifier,
CardData,
getMultipleCardData,
MAX_SCRYFALL_BATCH_SIZE,
ScryfallResponse,
} from "./scryfall";
import { CardIdentifier, CardData, fetchCardDataFromScryfall } from "./scryfall";
import { ObsidianPluginMtgSettings } from "./settings";
import { DEFAULT_SECTION_NAME, SKIP_SECTION_NAMES, Line } from "./types";
import { parseLines, buildDistinctCardList } from "./parser";
import { getCardPrice, currencyMapping } from "./pricing";
import { renderSection, renderSectionSkeleton } from "./section-renderer";
const DEFAULT_SECTION_NAME = "Deck:";
const COMMENT_DELIMITER = "#";
const SKIP_SECTION_NAMES = ["About", "Name"];
interface Line {
lineType: "card" | "section" | "error" | "blank" | "comment";
cardCount?: number;
globalCount?: number | null;
cardName?: string;
cardSet?: string;
cardNumber?: string;
comments?: string[];
errors?: string[];
text?: string;
}
const lineMatchRE = /^(\d+)\s+(.*?)(\s+\(([A-Za-z0-9]{3})\)\s+0*(\d+))?$/;
const blankLineRE = /^\s+$/;
const headingMatchRE = new RegExp("^[^[0-9|" + COMMENT_DELIMITER + "]");
const currencyMapping = {
usd: "$",
eur: "€",
tix: "Tx",
};
const cardTypeOrder = [
"Planeswalker",
"Creature",
"Sorcery",
"Instant",
"Artifact",
"Enchantment",
"Land",
];
const getTypeOrder = (line: Line, cardDataByCardId: Record<string, CardData>): number => {
if (line.lineType !== "card" || !line.cardName) {
return 999;
}
const cardId = nameToId(line.cardName);
const cardInfo = cardDataByCardId[cardId];
if (!cardInfo?.type_line) {
return 999;
}
const index = cardTypeOrder.findIndex(type => cardInfo.type_line!.includes(type));
return index === -1 ? 999 : index;
};
export const getCardPrice = (
cardName: string,
cardDataById: Record<string, CardData>,
settings: ObsidianPluginMtgSettings
) => {
const cardId = nameToId(cardName);
const cardData = cardDataById[cardId];
const preferredCurrency = settings.decklist.preferredCurrency;
const showCardPrices = settings.decklist.showCardPrices;
if (!cardData || !showCardPrices) {
return null;
} else {
if (preferredCurrency === "eur") {
return cardData.prices?.eur || null;
} else if (preferredCurrency === "tix") {
return cardData.prices?.tix || null;
} else {
return cardData.prices?.usd || null;
}
}
};
export const parseLines = (
rawLines: string[],
cardCounts: CardCounts
): Line[] => {
// This means global counts are not available because they are missing or no collection files are present
const shouldSkipGlobalCounts = !Object.keys(cardCounts).length;
// count, collection_count, card name, comment
return rawLines.map((line) => {
// Handle blank lines
if (!line.length || line.match(blankLineRE)) {
return {
lineType: "blank",
};
}
// Handle headings
if (line.match(headingMatchRE)) {
return {
lineType: "section",
text: line,
};
}
// Handle comment lines
if (line.startsWith(COMMENT_DELIMITER + " ")) {
return {
lineType: "comment",
comments: [line],
};
}
let lineWithoutComments: string = line;
const comments: string[] = [];
// Handle comments
if (line.includes(COMMENT_DELIMITER)) {
const lineAndComments = line.split(COMMENT_DELIMITER);
lineAndComments
.slice(1)
.forEach((comment) => comments.push(comment));
lineWithoutComments = lineAndComments[0];
}
// Handle card lines
const lineParts = lineWithoutComments.match(lineMatchRE);
// Handle invalid line
if (lineParts == null) {
return {
lineType: "error",
errors: [`invalid line: ${line}`],
};
} else {
const cardCount: number = parseInt(lineParts[1] || "0");
const cardName: string = lineParts[2];
const cardId: string = nameToId(cardName);
const cardSet: string = lineParts[4];
const cardNumber: string = lineParts[5];
const errors: string[] = [];
let globalCount = null;
if (!shouldSkipGlobalCounts) {
globalCount = cardCounts[cardId] || 0;
}
if (cardName.length === 0) {
errors.push(`Unable to parse card name from: ${line}`);
}
return {
lineType: "card",
cardCount,
globalCount,
cardName,
cardSet,
cardNumber,
comments,
errors,
};
}
});
};
export const buildDistinctCardList = (lines: Line[]): CardIdentifier[] => {
return Array.from(
new Set(
lines.flatMap((line): CardIdentifier[] => {
if (line.lineType !== "card") {
return [];
} else if (line.cardSet === undefined) {
return [
{
name: nameToId(line.cardName),
},
];
} else if (line.cardNumber !== undefined) {
return [
{
set: line.cardSet,
collector_number: line.cardNumber,
},
];
} else {
// cardSet is defined but cardNumber is
// undefined. Should never happen.
return [];
}
})
)
);
};
export const fetchCardDataFromScryfall = async (
distinctCards: CardIdentifier[]
): Promise<Record<string, CardData>> => {
// Fetch in batches of 75, since that's the limit of Scryfall batch sizes
const batches: CardIdentifier[][] = [];
let currentBatch: CardIdentifier[] = [];
batches.push(currentBatch);
distinctCards.forEach((identifier: CardIdentifier, idx: number) => {
if (currentBatch.length === MAX_SCRYFALL_BATCH_SIZE) {
batches.push(currentBatch);
// Make new batch
currentBatch = [];
}
currentBatch.push(identifier);
});
// Add remaining cards
batches.push(currentBatch);
const cardDataInBatches: ScryfallResponse[] = await Promise.all(
batches.map((batch) => getMultipleCardData(batch))
);
const cardDataByCardId: Record<string, CardData> = {};
const cards: CardData[] = [];
cardDataInBatches.forEach((batch) => {
batch.data.forEach((card: CardData) => {
cards.push(card);
if (card.name) {
const cardId = nameToId(card.name);
cardDataByCardId[cardId] = card;
}
});
});
return cardDataByCardId;
};
export { getCardPrice, fetchCardDataFromScryfall };
export const renderDecklist = async (
root: Element,
@ -242,498 +15,82 @@ export const renderDecklist = async (
settings: ObsidianPluginMtgSettings,
dataFetcher = fetchCardDataFromScryfall
): Promise<Element> => {
const containerEl = root.createDiv({ cls: "decklist" });
const lines: string[] = source.split("\n");
const parsedLines: Line[] = parseLines(lines, cardCounts);
const linesBySection: Record<string, Line[]> = {};
let currentSection = DEFAULT_SECTION_NAME;
const sections: string[] = [];
parsedLines.forEach((line, idx) => {
if (idx == 0 && line.lineType !== "section") {
currentSection = `${currentSection}`;
sections.push(`${currentSection}`);
}
if (line.lineType === "section") {
currentSection = line.text || DEFAULT_SECTION_NAME;
sections.push(`${currentSection}`);
} else {
if (!linesBySection[currentSection]) {
linesBySection[currentSection] = [];
}
linesBySection[currentSection].push(line);
}
});
// Create list of distinct cards
const distinctCards: CardIdentifier[] = buildDistinctCardList(parsedLines);
let cardDataByCardId: Record<string, CardData> = {};
// Try to fetch data from Scryfall
try {
cardDataByCardId = await dataFetcher(distinctCards);
} catch (err) {
console.error("Error fetching card data: ", err);
}
// Determines whether any card info was found for the cards on the list
const hasCardInfo = Object.keys(cardDataByCardId).length > 0;
// Make elements from parsedLines
const sectionContainers: Element[] = [];
// Footer section
const footer = containerEl.createDiv({ cls: "footer" });
const sectionTotalCounts: Record<string, number> = sections.reduce(
(acc, curr) => ({ ...acc, [curr]: 0 }),
{}
);
const sectionTotalCost: Record<string, number> = sections.reduce(
(acc, curr) => ({ ...acc, [curr]: 0.0 }),
{}
);
const missingCardCounts: CardCounts = {};
sections
.filter(section => !SKIP_SECTION_NAMES.includes(section))
.forEach((section: string) => {
// Put the entire deck in containing div for styling
const sectionContainer = containerEl.createDiv({ cls: "decklist__section-container" });
const sectionHeadingContainer = sectionContainer.createDiv({ cls: "decklist__section-heading-container" });
const toggleViewBtn = sectionHeadingContainer.createEl("button", {
text: "Visual View",
cls: "decklist__toggle-view"
});
const imgElContainer = sectionContainer.createDiv({ cls: "card-image-container" });
const imgEl = imgElContainer.createEl("img", { cls: "card-image" });
// Create a heading
const sectionHeadingEl = sectionContainer.createEl("h3", { cls: "decklist__section-heading" });
// Treat "Name" section as a special case since it's not really a section but more of a metadata field for the deck,
// so we just show it as a heading without creating a table for it
if (section.startsWith("Name")) {
sectionHeadingEl.textContent = section.replace(/^Name\s+/, "");
sectionContainers.push(sectionContainer);
return;
}
// Create conttained for the visual deck view
const cardGrid = sectionContainer.createDiv({ cls: "decklist__card-grid" });
cardGrid.style.display = "none";
// Toggle between table and grid
toggleViewBtn.addEventListener("click", () => {
const isGridVisible = cardGrid.style.display !== "none";
cardGrid.style.display = isGridVisible ? "none" : "flex";
sectionList.style.display = isGridVisible ? "" : "none";
toggleViewBtn.textContent = isGridVisible ? "Visual View" : "List View";
});
// Create container for the table rows
const sectionList = sectionContainer.createEl("table");
const sectionListHead = sectionList.createEl("thead");
const sectionListHeadRow = sectionListHead.createEl("tr");
sectionListHeadRow.createEl("th", { text: "Count" });
sectionListHeadRow.createEl("th", { text: "Name", cls: "max" });
if (settings.decklist.showManaCosts) {
sectionListHeadRow.createEl("th", { text: "Cost" });
}
if (settings.decklist.showCardPrices) {
sectionListHeadRow.createEl("th", { text: "Price" });
}
const sectionListBody = sectionList.createEl("tbody");
const sectionMissingCardCounts: CardCounts = {};
// Sort lines by card type, preserving relative order of non-card lines
const sortedLines = [...linesBySection[section]].sort((a, b) => {
const typeOrder = getTypeOrder(a, cardDataByCardId) - getTypeOrder(b, cardDataByCardId);
if (typeOrder !== 0) {
return typeOrder;
}
return (a.cardName ?? "").localeCompare(b.cardName ?? "");
});
// Populate grid with distinct cards
sortedLines
.filter(line => line.lineType === "card" && line.cardName)
.forEach(line => {
const cardId = nameToId(line.cardName!);
const cardInfo = cardDataByCardId[cardId];
const imgUri = cardInfo?.image_uris?.normal
?? cardInfo?.card_faces?.[0]?.image_uris?.normal;
if (!imgUri) return;
const cardEl = cardGrid.createDiv({ cls: "decklist__card-grid__item" });
cardEl.createEl("img", {
attr: { src: imgUri },
cls: "decklist__card-grid__image"
});
cardEl.createSpan({
cls: "decklist__card-grid__count",
text: `${line.cardCount}x`
});
});
let previousTypeOrder = -1;
// Create line item elements
sortedLines.forEach((line: Line) => {
const lineEl = sectionListBody.createEl("tr");
if (line.lineType === "card") {
const cardId = nameToId(line.cardName);
const cardInfo = cardDataByCardId[cardId];
const currentTypeOrder = getTypeOrder(line, cardDataByCardId);
if (currentTypeOrder !== previousTypeOrder) {
lineEl.classList.add("type-separator");
previousTypeOrder = currentTypeOrder;
}
// Card count cell
const cardCountCell = lineEl.createEl("td");
if (settings.decklist.showManaCosts) {
cardCountCell.createSpan({ cls: `card-rarity ${cardInfo?.rarity}` });
}
const cardCountEl = cardCountCell.createSpan({ cls: "count" });
// Card name cell
const cardNameCell = lineEl.createEl("td");
const cardNameEl = cardNameCell.createSpan({ cls: "card-name" });
const cardCommentsEl = cardNameCell.createSpan({
cls: "comment",
text: line.comments?.join("#") || "",
});
// Card cost cell (if enabled in settings)
if (settings.decklist.showManaCosts) {
const cardCostCell = lineEl.createEl("td");
const cardCostEl = cardCostCell.createSpan({ cls: "card-cost" });
if (line.cardName) {
const cardManaCost = cardInfo?.mana_cost
?? cardInfo?.card_faces?.[0]?.mana_cost;
if (cardManaCost) {
cardManaCost
.replace(/\//g, "")
.split("{")
.slice(1)
.forEach(part => {
cardCostEl.createEl("img", {
attr: {
src: `https://svgs.scryfall.io/card-symbols/${part.slice(0, -1)}.svg`,
width: 18,
height: 18,
}
});
});
}
}
}
let cardPrice;
let cardPriceEl;
if (settings.decklist.showCardPrices) {
const cardPriceCell = lineEl.createEl("td");
cardPriceEl = cardPriceCell.createSpan({ cls: "card-price" });
if (line.cardName) {
cardPrice = getCardPrice(
line.cardName,
cardDataByCardId,
settings
);
}
}
// Add hyperlink when possible
if (line.cardName) {
const cardId = nameToId(line.cardName);
const cardInfo = cardDataByCardId[cardId];
if (
settings.decklist.showCardNamesAsHyperlinks &&
cardInfo &&
cardInfo.scryfall_uri
) {
const cardLinkEl = cardNameEl.createEl("a");
cardLinkEl.href = cardInfo.scryfall_uri;
cardLinkEl.textContent = `${cardInfo.name}`;
} else {
cardNameEl.textContent = `${(cardInfo && cardInfo.name) ||
line.cardName ||
UNKNOWN_CARD
}`;
}
}
if (line.errors && line.errors.length) {
cardNameEl.createSpan({
cls: "error",
text: line.errors?.join(",") || "",
});
}
const lineCardCount = line.cardCount || 0;
const lineGlobalCount =
line.globalCount === null ? -1 : line.globalCount || 0;
// Show missing card counts
if (lineGlobalCount !== -1 && lineCardCount > lineGlobalCount) {
const counts = cardCountEl.createSpan({ cls: "count" });
// Card error element
counts.createSpan({
cls: "error",
text: `${lineGlobalCount}`,
});
// Card counts row element
counts.createSpan({
text: ` / ${lineCardCount}`,
});
lineEl.classList.add("insufficient-count");
const cardId = nameToId(line.cardName);
missingCardCounts[cardId] =
(missingCardCounts[cardId] || 0) +
(lineCardCount - lineGlobalCount);
sectionMissingCardCounts[cardId] =
(sectionMissingCardCounts[cardId] || 0) +
(lineCardCount - lineGlobalCount);
if (cardPrice) {
cardPriceEl!.classList.add("insufficient-count");
const totalPrice: number =
lineCardCount * parseFloat(cardPrice);
const amountOwned: number =
lineGlobalCount * parseFloat(cardPrice);
cardPriceEl!.createSpan({
cls: "error",
text: `${currencyMapping[
settings.decklist.preferredCurrency
]
}${amountOwned.toFixed(2)}`,
});
cardPriceEl!.createSpan({
text: ` / ${currencyMapping[
settings.decklist.preferredCurrency
]
}${totalPrice.toFixed(2)}`,
});
// Add cost to total
sectionTotalCost[section] =
sectionTotalCost[section] + (totalPrice || 0.0);
}
} else {
cardCountEl.textContent = `${lineCardCount}`;
if (cardPrice) {
const totalPrice: number =
lineCardCount * parseFloat(cardPrice);
const displayPrice = `${currencyMapping[settings.decklist.preferredCurrency]
}${totalPrice.toFixed(2)}`;
cardPriceEl!.textContent = displayPrice;
// Add cost to total
sectionTotalCost[section] =
sectionTotalCost[section] + (totalPrice || 0.0);
}
}
sectionTotalCounts[section] =
sectionTotalCounts[section] + (line.cardCount || 0);
// Show card preview on hover
if (settings.decklist.showCardPreviews && line.cardName) {
const cardId = nameToId(line.cardName);
const cardInfo = cardDataByCardId[cardId];
const isDoubleFaced = (cardInfo?.card_faces?.length ?? 0) > 1
&& !cardInfo?.image_uris;
let faceIndex = 0;
lineEl.addEventListener("mouseenter", () => {
const cardId = nameToId(line.cardName);
const cardInfo = cardDataByCardId[cardId];
if (!cardInfo) return;
faceIndex = 0;
const getImgUri = (index: number): string | undefined => {
if (cardInfo.image_uris) {
return cardInfo.image_uris.large;
}
return cardInfo.card_faces?.[index]?.image_uris?.large;
};
imgElContainer.style.display = "block";
imgElContainer.style.top = `${lineEl.offsetTop}px`;
imgElContainer.style.right = "50px";
imgElContainer.style.left = "auto";
const imgUri = getImgUri(faceIndex);
if (imgUri) {
imgEl.src = imgUri;
}
if (isDoubleFaced) {
imgEl.style.cursor = "pointer";
imgEl.onclick = () => {
faceIndex = faceIndex === 0 ? 1 : 0;
const uri = getImgUri(faceIndex);
if (uri) {
imgEl.src = uri;
}
};
} else {
imgEl.style.cursor = "default";
imgEl.onclick = null;
}
});
lineEl.addEventListener("mouseleave", (e) => {
// Only hide if we're not moving onto the image container
if (!imgElContainer.contains(e.relatedTarget as Node)) {
imgElContainer.style.display = "none";
imgEl.src = "";
imgEl.onclick = null;
}
});
imgElContainer.addEventListener("mouseleave", (e) => {
// Only hide if we're not moving back onto a table row
if (!sectionList.contains(e.relatedTarget as Node)) {
imgElContainer.style.display = "none";
imgEl.src = "";
imgEl.onclick = null;
}
});
}
} else if (line.lineType === "comment") {
// Comments
lineEl.createSpan({
cls: "comment",
text: line.comments?.join(" ") || "",
});
}
});
const sectionListFoot = sectionList.createEl("tfoot", { cls: "decklist__section-totals" });
const sectionListFootRow = sectionListFoot.createEl("tr");
const totalCardsEl = sectionListFootRow.createEl("td", { cls: "decklist__section-totals__count fit" });
sectionListFootRow.createEl("td", { text: "Cards" });
sectionListFootRow.createEl("td");
let totalCostEl;
if (hasCardInfo && settings.decklist.showCardPrices) {
totalCostEl = sectionListFootRow.createEl("td", { cls: "decklist__section-totals__cost fit" });
}
sectionHeadingEl.textContent = `${section}`;
const sectionMissingCardIds = Object.keys(sectionMissingCardCounts);
// When there are missing cards, show fraction
if (sectionMissingCardIds.length) {
// Counts
const totalMissingCountInSection = Object.values(
sectionMissingCardCounts
).reduce((acc, v) => acc + v, 0);
const totalCardsOwned =
sectionTotalCounts[section] - totalMissingCountInSection;
// Errors
totalCardsEl.createSpan({
cls: "error",
text: `${totalCardsOwned}`,
});
// Counts
totalCardsEl.createSpan({
cls: "insufficient-count",
text: ` / ${sectionTotalCounts[section]}`,
});
const totalMissingCostInSection = Object.keys(
sectionMissingCardCounts
).reduce((acc, cardId) => {
const countNeeded = sectionMissingCardCounts[cardId];
const cardPrice: number = parseFloat(
getCardPrice(cardId, cardDataByCardId, settings) || "0.00"
);
return acc + cardPrice * countNeeded;
}, 0.0);
// Value
if (hasCardInfo && settings.decklist.showCardPrices) {
const totalValueOwned =
sectionTotalCost[section] - totalMissingCostInSection;
totalCostEl!.createSpan({
cls: "error",
text: `${currencyMapping[settings.decklist.preferredCurrency]
}${totalValueOwned.toFixed(2)}`,
});
// Total value needed
totalCostEl!.createSpan({
cls: "insufficient-count",
text: ` / ${currencyMapping[settings.decklist.preferredCurrency]
}${sectionTotalCost[section].toFixed(2)}`,
});
}
} else {
totalCardsEl.textContent = `${sectionTotalCounts[section]}`;
if (settings.decklist.showCardPrices) {
totalCostEl!.textContent = `${currencyMapping[settings.decklist.preferredCurrency]
}${sectionTotalCost[section].toFixed(2)}`;
}
}
sectionContainers.push(sectionContainer);
});
sectionContainers.forEach((sectionContainer) =>
containerEl.appendChild(sectionContainer)
);
const containerEl = root.createDiv({ cls: "decklist" });
const lines = source.split("\n");
const parsedLines = parseLines(lines, cardCounts);
const linesBySection: Record<string, Line[]> = {};
let currentSection = DEFAULT_SECTION_NAME;
const sections: string[] = [];
parsedLines.forEach((line, idx) => {
if (idx === 0 && line.lineType !== "section") {
sections.push(currentSection);
}
if (line.lineType === "section") {
currentSection = line.text || DEFAULT_SECTION_NAME;
sections.push(currentSection);
} else {
if (!linesBySection[currentSection]) {
linesBySection[currentSection] = [];
}
linesBySection[currentSection].push(line);
}
});
const filteredSections = sections.filter(s => !SKIP_SECTION_NAMES.includes(s));
// Pass 1: render skeletons immediately
const sectionContainers = filteredSections.map(section =>
renderSectionSkeleton(containerEl, section, linesBySection[section] ?? [], settings)
);
sectionContainers.forEach(el => containerEl.appendChild(el));
const footer = containerEl.createDiv({ cls: "footer" });
containerEl.appendChild(footer);
// Pass 2: fetch and enrich
const distinctCards = buildDistinctCardList(parsedLines);
let cardDataByCardId: Record<string, CardData> = {};
try {
cardDataByCardId = await dataFetcher(distinctCards);
} catch (err) {
console.error("Error fetching card data: ", err);
}
const hasCardInfo = Object.keys(cardDataByCardId).length > 0;
const sectionTotalCounts: Record<string, number> = filteredSections.reduce((acc, s) => ({ ...acc, [s]: 0 }), {});
const sectionTotalCost: Record<string, number> = filteredSections.reduce((acc, s) => ({ ...acc, [s]: 0.0 }), {});
const missingCardCounts: CardCounts = {};
// Replace skeletons with fully rendered sections
filteredSections.forEach((section, i) => {
const enriched = renderSection(containerEl, {
section,
lines: linesBySection[section] ?? [],
cardDataByCardId,
hasCardInfo,
settings,
missingCardCounts,
sectionTotalCounts,
sectionTotalCost,
});
sectionContainers[i].replaceWith(enriched);
});
// Buylist
const buylistCardIds = Object.keys(missingCardCounts);
const buylistCardCounts = Object.values(missingCardCounts).reduce(
(acc, val) => acc + val,
0
);
// Only show the buylist element when there are missing cards
if (buylistCardIds.length && settings.decklist.showBuylist) {
// Build buylist
const buylist = footer.createDiv({ cls: "buylist-container" });
const buylistHeader = buylist.createEl("h3", { cls: "decklist__section-heading" });
const buylistHeader = buylist.createEl("h3", {
cls: "decklist__section-heading",
});
buylistHeader.textContent = "Buylist: ";
let totalCostOfBuylist = 0.0;
@ -741,54 +98,38 @@ export const renderDecklist = async (
buylistCardIds.forEach((cardId) => {
const cardInfo = cardDataByCardId[cardId];
let buylistLine = "";
const countNeeded = missingCardCounts[cardId];
// Add count
buylistLine += `${countNeeded}` + " ";
let buylistLine = `${countNeeded} `;
if (cardInfo) {
const cardName = cardInfo.name || "";
buylistLine += `${cardName}`;
// Retrieve price
const cardPrice: number = parseFloat(
buylistLine += cardName;
const cardPrice = parseFloat(
getCardPrice(cardName, cardDataByCardId, settings) || "0.00"
);
totalCostOfBuylist =
totalCostOfBuylist + cardPrice * countNeeded;
buylistLines += buylistLine + "\n";
totalCostOfBuylist += cardPrice * countNeeded;
} else {
// Card name might be unknown
buylistLines += buylistLine + `${cardId || UNKNOWN_CARD}\n`;
buylistLine += cardId || UNKNOWN_CARD;
}
buylistLines += buylistLine + "\n";
});
const buylistPre = buylist.createEl("pre", { cls: "buylist-container" });
buylistPre.textContent = buylistLines;
buylist.createEl("pre", { cls: "buylist-container" }).textContent =
buylistLines;
buylist.createEl("hr");
const buylistLineEl = buylist.createDiv({ cls: "buylist-line" });
buylistLineEl.createSpan({
cls: "decklist__section-totals__count",
text: `${buylistCardCounts} `,
});
buylistLineEl.createSpan({
cls: "card-name",
text: "cards",
});
buylistLineEl.createSpan({ cls: "card-name", text: "cards" });
if (hasCardInfo && settings.decklist.showCardPrices) {
buylistLineEl.createSpan({
cls: "decklist__section-totals",
text: `${currencyMapping[settings.decklist.preferredCurrency]
}${totalCostOfBuylist.toFixed(2)}`,
text: `${currencyMapping[settings.decklist.preferredCurrency]}${totalCostOfBuylist.toFixed(2)}`,
});
}
}
@ -796,4 +137,4 @@ export const renderDecklist = async (
containerEl.appendChild(footer);
return containerEl;
};
};

View file

@ -1,3 +1,4 @@
import { nameToId } from "./collection";
import { promiseWrappedRequest } from "./http";
export type CardIdentifier =
@ -187,4 +188,39 @@ export const getMultipleCardData = async (
};
return request(params);
};
export const fetchCardDataFromScryfall = async (
distinctCards: CardIdentifier[]
): Promise<Record<string, CardData>> => {
const batches: CardIdentifier[][] = [];
let currentBatch: CardIdentifier[] = [];
batches.push(currentBatch);
distinctCards.forEach((identifier: CardIdentifier) => {
if (currentBatch.length === MAX_SCRYFALL_BATCH_SIZE) {
batches.push(currentBatch);
currentBatch = [];
}
currentBatch.push(identifier);
});
batches.push(currentBatch);
const cardDataInBatches: ScryfallResponse[] = await Promise.all(
batches.map((batch) => getMultipleCardData(batch))
);
const cardDataByCardId: Record<string, CardData> = {};
cardDataInBatches.forEach((batch) => {
batch.data.forEach((card: CardData) => {
if (card.name) {
const cardId = nameToId(card.name);
cardDataByCardId[cardId] = card;
}
});
});
return cardDataByCardId;
};

380
src/section-renderer.ts Normal file
View file

@ -0,0 +1,380 @@
import { CardCounts, nameToId, UNKNOWN_CARD } from "./collection";
import { CardData } from "./scryfall";
import { ObsidianPluginMtgSettings } from "./settings";
import { Line } from "./types";
import { currencyMapping, getCardPrice } from "./pricing";
import { cardTypeIcons, cardTypeOrder, getTypeCounts, getTypeOrder, sortLines } from "./sorting";
import { setupCardPreview } from "./card-preview";
import { buildCardGrid, setupGridToggle } from "./card-grid";
import { sanitizeHTMLToDom } from "obsidian";
export interface SectionRenderContext {
section: string;
lines: Line[];
cardDataByCardId: Record<string, CardData>;
hasCardInfo: boolean;
settings: ObsidianPluginMtgSettings;
missingCardCounts: CardCounts;
sectionTotalCounts: Record<string, number>;
sectionTotalCost: Record<string, number>;
}
export const renderSection = (
containerEl: HTMLElement,
ctx: SectionRenderContext
): HTMLElement => {
const {
section,
lines,
cardDataByCardId,
hasCardInfo,
settings,
missingCardCounts,
sectionTotalCounts,
sectionTotalCost,
} = ctx;
const sectionContainer = containerEl.createDiv({
cls: "decklist__section-container",
});
// Heading container with toggle button
const sectionHeadingContainer = sectionContainer.createDiv({
cls: "decklist__section-heading-container",
});
const sectionHeadingEl = sectionHeadingContainer.createEl("h3", {
cls: "decklist__section-heading",
});
const toggleViewBtn = sectionHeadingContainer.createEl("button", {
text: "Visual View",
cls: "decklist__toggle-view",
});
const hasCards = lines.some(line => line.lineType === "card" && line.cardName);
toggleViewBtn.style.display = hasCards ? "" : "none";
// Card preview image container
const imgElContainer = sectionContainer.createDiv({
cls: "card-image-container",
});
const imgEl = imgElContainer.createEl("img", { cls: "card-image" });
// Handle "Name ..." metadata sections
if (section.startsWith("Name")) {
sectionHeadingEl.textContent = section.replace(/^Name\s+/, "");
return sectionContainer;
}
sectionHeadingEl.textContent = section;
// Table
const sectionList = sectionContainer.createEl("table");
const sectionListHead = sectionList.createEl("thead");
const sectionListHeadRow = sectionListHead.createEl("tr");
sectionListHeadRow.createEl("th", { text: "Count" });
sectionListHeadRow.createEl("th", { text: "Name", cls: "max" });
if (settings.decklist.showManaCosts) {
sectionListHeadRow.createEl("th", { text: "Cost" });
}
if (settings.decklist.showCardPrices) {
sectionListHeadRow.createEl("th", { text: "Price" });
}
const sectionListBody = sectionList.createEl("tbody");
const sectionMissingCardCounts: CardCounts = {};
// Card grid (visual view)
const cardGrid = buildCardGrid(sectionContainer, sortLines(lines, cardDataByCardId), cardDataByCardId);
setupGridToggle(toggleViewBtn, cardGrid, sectionList);
// Sort and render lines
const sortedLines = sortLines(lines, cardDataByCardId);
let previousTypeOrder = -1;
sortedLines.forEach((line: Line) => {
const lineEl = sectionListBody.createEl("tr");
if (line.lineType === "card") {
const cardId = nameToId(line.cardName!);
const cardInfo = cardDataByCardId[cardId];
const currentTypeOrder = getTypeOrder(line, cardDataByCardId);
if (currentTypeOrder !== previousTypeOrder) {
lineEl.classList.add("type-separator");
previousTypeOrder = currentTypeOrder;
}
// Count cell
const cardCountCell = lineEl.createEl("td");
cardCountCell.createSpan({ cls: `card-rarity ${cardInfo?.rarity}` });
const cardCountEl = cardCountCell.createSpan({ cls: "count" });
// Name cell
const cardNameCell = lineEl.createEl("td");
const cardNameEl = cardNameCell.createSpan({ cls: "card-name" });
const cardCommentsEl = cardNameCell.createSpan({
cls: "comment",
text: line.comments?.join("#") || "",
});
// Mana cost cell
if (settings.decklist.showManaCosts) {
const cardCostCell = lineEl.createEl("td");
const cardCostEl = cardCostCell.createSpan({ cls: "card-cost" });
const cardManaCost =
cardInfo?.mana_cost ?? cardInfo?.card_faces?.[0]?.mana_cost;
if (cardManaCost) {
cardManaCost
.replace(/\//g, "")
.split("{")
.slice(1)
.forEach((part) => {
cardCostEl.createEl("img", {
attr: {
src: `https://svgs.scryfall.io/card-symbols/${part.slice(0, -1)}.svg`,
width: 18,
height: 18,
},
});
});
}
}
// Price cell
let cardPrice: string | null = null;
let cardPriceEl: HTMLElement | undefined;
if (settings.decklist.showCardPrices) {
const cardPriceCell = lineEl.createEl("td");
cardPriceEl = cardPriceCell.createSpan({ cls: "card-price" });
if (line.cardName) {
cardPrice = getCardPrice(line.cardName, cardDataByCardId, settings);
}
}
// Card name / hyperlink
if (line.cardName) {
if (settings.decklist.showCardNamesAsHyperlinks && cardInfo?.scryfall_uri) {
const cardLinkEl = cardNameEl.createEl("a");
cardLinkEl.href = cardInfo.scryfall_uri;
cardLinkEl.textContent = cardInfo.name ?? line.cardName;
} else {
cardNameEl.textContent =
cardInfo?.name ?? line.cardName ?? UNKNOWN_CARD;
}
}
if (line.errors?.length) {
cardNameEl.createSpan({
cls: "error",
text: line.errors.join(","),
});
}
const lineCardCount = line.cardCount || 0;
const lineGlobalCount =
line.globalCount === null ? -1 : line.globalCount || 0;
if (lineGlobalCount !== -1 && lineCardCount > lineGlobalCount) {
// Insufficient count display
const counts = cardCountEl.createSpan({ cls: "count" });
counts.createSpan({ cls: "error", text: `${lineGlobalCount}` });
counts.createSpan({ text: ` / ${lineCardCount}` });
lineEl.classList.add("insufficient-count");
missingCardCounts[cardId] =
(missingCardCounts[cardId] || 0) + (lineCardCount - lineGlobalCount);
sectionMissingCardCounts[cardId] =
(sectionMissingCardCounts[cardId] || 0) +
(lineCardCount - lineGlobalCount);
if (cardPrice && cardPriceEl) {
cardPriceEl.classList.add("insufficient-count");
const totalPrice = lineCardCount * parseFloat(cardPrice);
const amountOwned = lineGlobalCount * parseFloat(cardPrice);
const currency = currencyMapping[settings.decklist.preferredCurrency];
cardPriceEl.createSpan({
cls: "error",
text: `${currency}${amountOwned.toFixed(2)}`,
});
cardPriceEl.createSpan({
text: ` / ${currency}${totalPrice.toFixed(2)}`,
});
sectionTotalCost[section] += totalPrice || 0;
}
} else {
cardCountEl.textContent = `${lineCardCount}`;
if (cardPrice && cardPriceEl) {
const totalPrice = lineCardCount * parseFloat(cardPrice);
cardPriceEl.textContent = `${currencyMapping[settings.decklist.preferredCurrency]}${totalPrice.toFixed(2)}`;
sectionTotalCost[section] += totalPrice || 0;
}
}
sectionTotalCounts[section] += line.cardCount || 0;
// Card preview on hover
if (settings.decklist.showCardPreviews && line.cardName) {
setupCardPreview(
lineEl,
line.cardName,
cardDataByCardId,
imgElContainer,
imgEl,
sectionList
);
}
} else if (line.lineType === "comment") {
lineEl.createSpan({
cls: "comment",
text: line.comments?.join(" ") || "",
});
}
});
// Footer
const sectionListFoot = sectionList.createEl("tfoot", {
cls: "decklist__section-totals",
});
const sectionListFootRow = sectionListFoot.createEl("tr");
const totalCardsEl = sectionListFootRow.createEl("td", {
cls: "decklist__section-totals__count",
});
const totalTypeCardsEl = sectionListFootRow.createEl("td");
const typeCounts = getTypeCounts(lines, cardDataByCardId);
cardTypeOrder
.filter(type => typeCounts[type] > 0)
.forEach(type => {
const item = totalTypeCardsEl.createSpan({ cls: "type-summary__item" });
const svgEl = sanitizeHTMLToDom(cardTypeIcons[type]);
item.appendChild(svgEl);
item.createSpan({ text: ` ${typeCounts[type]}` });
});
sectionListFootRow.createEl("td");
let totalCostEl: HTMLElement | undefined;
if (hasCardInfo && settings.decklist.showCardPrices) {
totalCostEl = sectionListFootRow.createEl("td", {
cls: "decklist__section-totals__cost",
});
}
const sectionMissingCardIds = Object.keys(sectionMissingCardCounts);
if (sectionMissingCardIds.length) {
const totalMissingCount = Object.values(sectionMissingCardCounts).reduce(
(acc, v) => acc + v,
0
);
const totalCardsOwned = sectionTotalCounts[section] - totalMissingCount;
totalCardsEl.createSpan({ cls: "error", text: `${totalCardsOwned}` });
totalCardsEl.createSpan({
cls: "insufficient-count",
text: ` / ${sectionTotalCounts[section]}`,
});
if (hasCardInfo && settings.decklist.showCardPrices && totalCostEl) {
const totalMissingCost = sectionMissingCardIds.reduce((acc, cardId) => {
const countNeeded = sectionMissingCardCounts[cardId];
const price = parseFloat(
getCardPrice(cardId, cardDataByCardId, settings) || "0.00"
);
return acc + price * countNeeded;
}, 0);
const totalValueOwned = sectionTotalCost[section] - totalMissingCost;
const currency = currencyMapping[settings.decklist.preferredCurrency];
totalCostEl.createSpan({
cls: "error",
text: `${currency}${totalValueOwned.toFixed(2)}`,
});
totalCostEl.createSpan({
cls: "insufficient-count",
text: ` / ${currency}${sectionTotalCost[section].toFixed(2)}`,
});
}
} else {
totalCardsEl.textContent = `${sectionTotalCounts[section]}`;
if (settings.decklist.showCardPrices && totalCostEl) {
totalCostEl.textContent = `${currencyMapping[settings.decklist.preferredCurrency]}${sectionTotalCost[section].toFixed(2)}`;
}
}
return sectionContainer;
};
export const renderSectionSkeleton = (
containerEl: HTMLElement,
section: string,
lines: Line[],
settings: ObsidianPluginMtgSettings
): HTMLElement => {
const sectionContainer = containerEl.createDiv({ cls: "decklist__section-container" });
const sectionHeadingContainer = sectionContainer.createDiv({ cls: "decklist__section-heading-container" });
const sectionHeadingEl = sectionHeadingContainer.createEl("h3", { cls: "decklist__section-heading" });
sectionHeadingEl.textContent = section.startsWith("Name")
? section.replace(/^Name\s+/, "")
: section;
if (section.startsWith("Name")) {
return sectionContainer;
}
const sectionList = sectionContainer.createEl("table");
const sectionListHead = sectionList.createEl("thead");
const sectionListHeadRow = sectionListHead.createEl("tr");
sectionListHeadRow.createEl("th", { text: "Count" });
sectionListHeadRow.createEl("th", { text: "Name", cls: "max" });
if (settings.decklist.showManaCosts) {
sectionListHeadRow.createEl("th", { text: "Cost" });
}
if (settings.decklist.showCardPrices) {
sectionListHeadRow.createEl("th", { text: "Price" });
}
const sectionListBody = sectionList.createEl("tbody");
lines.forEach((line: Line) => {
const lineEl = sectionListBody.createEl("tr");
if (line.lineType === "card") {
lineEl.setAttribute("data-card-name", line.cardName ?? "");
const cardCountCell = lineEl.createEl("td");
cardCountCell.createSpan({ cls: "card-rarity" });
cardCountCell.createSpan({ cls: "count", text: `${line.cardCount}` });
const cardNameCell = lineEl.createEl("td");
cardNameCell.createSpan({ cls: "card-name", text: line.cardName ?? "" });
cardNameCell.createSpan({ cls: "comment", text: line.comments?.join("#") || "" });
if (settings.decklist.showManaCosts) {
lineEl.createEl("td").createSpan({ cls: "card-cost" });
}
if (settings.decklist.showCardPrices) {
lineEl.createEl("td").createSpan({ cls: "card-price" });
}
} else if (line.lineType === "comment") {
lineEl.createSpan({ cls: "comment", text: line.comments?.join(" ") || "" });
}
});
sectionList.createEl("tfoot", { cls: "decklist__section-totals" })
.createEl("tr")
.createEl("td", { attr: { colspan: "4" } });
return sectionContainer;
};

83
src/sorting.ts Normal file
View file

@ -0,0 +1,83 @@
import { nameToId } from "./collection";
import { CardData } from "./scryfall";
import { Line } from "./types";
import creatureSvg from "./assets/card-types/creature.svg";
import planeswalkerSvg from "./assets/card-types/planeswalker.svg";
import sorcerySvg from "./assets/card-types/sorcery.svg";
import instantSvg from "./assets/card-types/instant.svg";
import artifactSvg from "./assets/card-types/artifact.svg";
import enchantmentSvg from "./assets/card-types/enchantment.svg";
import landSvg from "./assets/card-types/land.svg";
export const cardTypeOrder = [
"Planeswalker",
"Creature",
"Sorcery",
"Instant",
"Artifact",
"Enchantment",
"Land",
];
export const cardTypeIcons: Record<string, string> = {
"Planeswalker": planeswalkerSvg,
"Creature": creatureSvg,
"Sorcery": sorcerySvg,
"Instant": instantSvg,
"Artifact": artifactSvg,
"Enchantment": enchantmentSvg,
"Land": landSvg,
};
export const getTypeOrder = (
line: Line,
cardDataByCardId: Record<string, CardData>
): number => {
if (line.lineType !== "card" || !line.cardName) {
return 999;
}
const cardId = nameToId(line.cardName);
const cardInfo = cardDataByCardId[cardId];
if (!cardInfo?.type_line) {
return 999;
}
const index = cardTypeOrder.findIndex((type) =>
cardInfo.type_line!.includes(type)
);
return index === -1 ? 999 : index;
};
export const getTypeCounts = (
lines: Line[],
cardDataByCardId: Record<string, CardData>
): Record<string, number> => {
return lines
.filter(line => line.lineType === "card" && line.cardName)
.reduce((acc, line) => {
const typeOrder = getTypeOrder(line, cardDataByCardId);
const typeName = cardTypeOrder[typeOrder] ?? "Other";
acc[typeName] = (acc[typeName] ?? 0) + (line.cardCount ?? 0);
return acc;
}, {} as Record<string, number>);
};
export const sortLines = (
lines: Line[],
cardDataByCardId: Record<string, CardData>
): Line[] => {
return [...lines].sort((a, b) => {
const typeOrder =
getTypeOrder(a, cardDataByCardId) - getTypeOrder(b, cardDataByCardId);
if (typeOrder !== 0) {
return typeOrder;
}
return (a.cardName ?? "").localeCompare(b.cardName ?? "");
});
};

15
src/types.ts Normal file
View file

@ -0,0 +1,15 @@
export const DEFAULT_SECTION_NAME = "Deck:";
export const COMMENT_DELIMITER = "#";
export const SKIP_SECTION_NAMES = ["About", "Name"];
export interface Line {
lineType: "card" | "section" | "error" | "blank" | "comment";
cardCount?: number;
globalCount?: number | null;
cardName?: string;
cardSet?: string;
cardNumber?: string;
comments?: string[];
errors?: string[];
text?: string;
}

View file

@ -28,8 +28,23 @@
border-top: 1px solid var(--background-modifier-border);
}
.decklist__section-totals .card-name {
flex: 4;
.type-summary {
display: flex;
align-items: center;
gap: 8px;
}
.type-summary__item {
display: inline-flex;
align-items: center;
gap: 3px;
white-space: nowrap;
margin-right: 5px;
}
.type-summary__item svg {
vertical-align: middle;
position: relative;
}
/* Card */

4
svg.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.svg" {
const content: string;
export default content;
}

View file

@ -7,5 +7,6 @@
"1.5.52": "0.15.0",
"1.6.54": "0.15.0",
"1.7.56": "0.15.0",
"1.8.57": "0.15.0"
"1.8.57": "0.15.0",
"1.9.58": "0.15.0"
}