feat: Add card type summary
2
.github/workflows/workflow.yml
vendored
|
|
@ -10,7 +10,7 @@ permissions:
|
|||
contents: read
|
||||
|
||||
env:
|
||||
version: '1.8.${{ github.run_number }}'
|
||||
version: '1.9.${{ github.run_number }}'
|
||||
nodeVersion: '22'
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ const buildOptions = {
|
|||
'@lezer/highlight',
|
||||
'@lezer/lr',
|
||||
...builtinModules],
|
||||
loader: {
|
||||
".svg": "text",
|
||||
},
|
||||
format: 'cjs',
|
||||
target: 'es2018',
|
||||
logLevel: "info",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
5
src/assets/card-types/artifact.svg
Normal 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 |
5
src/assets/card-types/creature.svg
Normal 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 |
5
src/assets/card-types/enchantment.svg
Normal 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 |
5
src/assets/card-types/instant.svg
Normal 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 |
5
src/assets/card-types/land.svg
Normal 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 |
5
src/assets/card-types/planeswalker.svg
Normal 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 |
5
src/assets/card-types/sorcery.svg
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
831
src/renderer.ts
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
19
styles.css
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
declare module "*.svg" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||