diff --git a/.dockerignore b/.dockerignore
index 4ff75da..983e069 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,2 +1,4 @@
*.local
-/data
\ No newline at end of file
+/data
+/tests
+/unwind-ui
diff --git a/Procfile b/Procfile
new file mode 100644
index 0000000..62ce09e
--- /dev/null
+++ b/Procfile
@@ -0,0 +1,2 @@
+server: ./run dev-server
+ui: ./run dev-ui
diff --git a/poetry.lock b/poetry.lock
index 3891013..d34b73d 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -11,7 +11,7 @@ typing_extensions = ">=3.7.2"
[[package]]
name = "asgiref"
-version = "3.3.4"
+version = "3.4.1"
description = "ASGI specs, helper code, and adapters"
category = "main"
optional = false
@@ -59,19 +59,22 @@ lxml = ["lxml"]
[[package]]
name = "certifi"
-version = "2020.12.5"
+version = "2021.5.30"
description = "Python package for providing Mozilla's CA Bundle."
category = "main"
optional = false
python-versions = "*"
[[package]]
-name = "chardet"
-version = "4.0.0"
-description = "Universal encoding detector for Python 2 and 3"
+name = "charset-normalizer"
+version = "2.0.4"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=3.5.0"
+
+[package.extras]
+unicode_backport = ["unicodedata2"]
[[package]]
name = "click"
@@ -138,11 +141,11 @@ lxml = ["lxml"]
[[package]]
name = "idna"
-version = "2.10"
+version = "3.2"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.5"
[[package]]
name = "iniconfig"
@@ -154,11 +157,11 @@ python-versions = "*"
[[package]]
name = "packaging"
-version = "20.9"
+version = "21.0"
description = "Core utilities for Python packages"
category = "main"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+python-versions = ">=3.6"
[package.dependencies]
pyparsing = ">=2.0.2"
@@ -213,21 +216,21 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm
[[package]]
name = "requests"
-version = "2.25.1"
+version = "2.26.0"
description = "Python HTTP for Humans."
category = "main"
optional = false
-python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[package.dependencies]
certifi = ">=2017.4.17"
-chardet = ">=3.0.2,<5"
-idna = ">=2.5,<3"
+charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
+idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
urllib3 = ">=1.21.1,<1.27"
[package.extras]
-security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
+use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]]
name = "six"
@@ -302,16 +305,16 @@ python-versions = "*"
[[package]]
name = "urllib3"
-version = "1.26.4"
+version = "1.26.6"
description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras]
+brotli = ["brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
-brotli = ["brotlipy (>=0.6.0)"]
[[package]]
name = "uvicorn"
@@ -348,8 +351,8 @@ aiosqlite = [
{file = "aiosqlite-0.17.0.tar.gz", hash = "sha256:f0e6acc24bc4864149267ac82fb46dfb3be4455f99fe21df82609cc6e6baee51"},
]
asgiref = [
- {file = "asgiref-3.3.4-py3-none-any.whl", hash = "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee"},
- {file = "asgiref-3.3.4.tar.gz", hash = "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"},
+ {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"},
+ {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"},
]
atomicwrites = [
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
@@ -365,12 +368,12 @@ beautifulsoup4 = [
{file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"},
]
certifi = [
- {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"},
- {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"},
+ {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
+ {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
]
-chardet = [
- {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
- {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
+charset-normalizer = [
+ {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"},
+ {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"},
]
click = [
{file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
@@ -393,16 +396,16 @@ html5lib = [
{file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"},
]
idna = [
- {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"},
- {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"},
+ {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
+ {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
]
iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
packaging = [
- {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"},
- {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"},
+ {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
+ {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
]
pluggy = [
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
@@ -421,8 +424,8 @@ pytest = [
{file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"},
]
requests = [
- {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"},
- {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"},
+ {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
+ {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
]
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
@@ -486,8 +489,8 @@ ulid-py = [
{file = "ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987"},
]
urllib3 = [
- {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"},
- {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"},
+ {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"},
+ {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"},
]
uvicorn = [
{file = "uvicorn-0.14.0-py3-none-any.whl", hash = "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae"},
diff --git a/scripts/build b/scripts/build
index 3be3536..a8be114 100755
--- a/scripts/build
+++ b/scripts/build
@@ -4,4 +4,7 @@ cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x
-exec poetry export -o requirements.txt
+poetry export -o requirements.txt
+
+cd unwind-ui
+npm run build
diff --git a/scripts/dev b/scripts/dev
index 7d3e2ef..dee1b94 100755
--- a/scripts/dev
+++ b/scripts/dev
@@ -4,4 +4,4 @@ cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x
-exec uvicorn unwind:create_app --factory --reload
+exec honcho start
diff --git a/scripts/dev-server b/scripts/dev-server
new file mode 100755
index 0000000..7d3e2ef
--- /dev/null
+++ b/scripts/dev-server
@@ -0,0 +1,7 @@
+#!/bin/sh -eu
+
+cd "$RUN_DIR"
+
+[ -z "${DEBUG:-}" ] || set -x
+
+exec uvicorn unwind:create_app --factory --reload
diff --git a/scripts/dev-ui b/scripts/dev-ui
new file mode 100755
index 0000000..696e6a6
--- /dev/null
+++ b/scripts/dev-ui
@@ -0,0 +1,7 @@
+#!/bin/sh -eu
+
+cd "$RUN_DIR"/unwind-ui
+
+[ -z "${DEBUG:-}" ] || set -x
+
+exec npm run dev
diff --git a/scripts/lint b/scripts/lint
index 09b4184..0df720e 100755
--- a/scripts/lint
+++ b/scripts/lint
@@ -1,8 +1,6 @@
#!/bin/sh -eu
-cd "$RUN_DIR"
-
[ -z "${DEBUG:-}" ] || set -x
-isort --profile black unwind
-black unwind
+"$RUN_BIN" lint-js "$@"
+"$RUN_BIN" lint-py "$@"
diff --git a/scripts/lint-js b/scripts/lint-js
new file mode 100755
index 0000000..ad68556
--- /dev/null
+++ b/scripts/lint-js
@@ -0,0 +1,21 @@
+#!/bin/sh -eu
+
+cd "$RUN_DIR"
+
+if [ "${1:-}" = '--fix' ]; then
+ mode=fix
+else
+ mode=check
+fi
+
+if [ "$mode" = 'fix' ]; then
+ prettier_opts=--write
+else
+ prettier_opts=--check
+fi
+
+[ -z "${DEBUG:-}" ] || set -x
+
+cd unwind-ui
+npm run lint ||:
+npx prettier $prettier_opts 'vite.config.ts' 'src/**/*.{js,ts,vue}'
diff --git a/scripts/lint-py b/scripts/lint-py
new file mode 100755
index 0000000..09b4184
--- /dev/null
+++ b/scripts/lint-py
@@ -0,0 +1,8 @@
+#!/bin/sh -eu
+
+cd "$RUN_DIR"
+
+[ -z "${DEBUG:-}" ] || set -x
+
+isort --profile black unwind
+black unwind
diff --git a/unwind-ui/.gitignore b/unwind-ui/.gitignore
new file mode 100644
index 0000000..f06235c
--- /dev/null
+++ b/unwind-ui/.gitignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/unwind-ui/index.html b/unwind-ui/index.html
new file mode 100644
index 0000000..9302117
--- /dev/null
+++ b/unwind-ui/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ ๐งถ Unwind
+
+
+
+
+
+
diff --git a/unwind-ui/package-lock.json b/unwind-ui/package-lock.json
new file mode 100644
index 0000000..88ddcd2
--- /dev/null
+++ b/unwind-ui/package-lock.json
@@ -0,0 +1,1698 @@
+{
+ "name": "unwind-ui",
+ "version": "0.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "version": "0.0.0",
+ "dependencies": {
+ "bulma": "^0.9.3",
+ "vue": "^3.0.5"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^1.2.5",
+ "@vue/compiler-sfc": "^3.0.5",
+ "typescript": "^4.3.2",
+ "vite": "^2.4.2",
+ "vue-tsc": "^0.0.24"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.14.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz",
+ "integrity": "sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.15.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.15.0.tgz",
+ "integrity": "sha512-0v7oNOjr6YT9Z2RAOTv4T9aP+ubfx4Q/OhVtAet7PFDt0t9Oy6Jn+/rfC6b8HJ5zEqrQCiMxJfgtHpmIminmJQ==",
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.15.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.15.0.tgz",
+ "integrity": "sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ==",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.14.9",
+ "to-fast-properties": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "0.0.48",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.48.tgz",
+ "integrity": "sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==",
+ "dev": true
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-1.3.0.tgz",
+ "integrity": "sha512-wJvuJdTBjvucUX0vK4fuy60t+A9bJSZxc59vp1Y+8kiOd0NU5kFt4lay72gMWPeR+lSUjrTmGUq8Uzb99Jbw3A==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "@vue/compiler-sfc": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.1.5.tgz",
+ "integrity": "sha512-TXBhFinoBaXKDykJzY26UEuQU1K07FOp/0Ie+OXySqqk0bS0ZO7Xvl7UmiTUPYcLrWbxWBR7Bs/y55AI0MNc2Q==",
+ "dependencies": {
+ "@babel/parser": "^7.12.0",
+ "@babel/types": "^7.12.0",
+ "@vue/shared": "3.1.5",
+ "estree-walker": "^2.0.1",
+ "source-map": "^0.6.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.1.5.tgz",
+ "integrity": "sha512-ZsL3jqJ52OjGU/YiT/9XiuZAmWClKInZM2aFJh9gnsAPqOrj2JIELMbkIFpVKR/CrVO/f2VxfPiiQdQTr65jcQ==",
+ "dependencies": {
+ "@vue/compiler-core": "3.1.5",
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.1.5.tgz",
+ "integrity": "sha512-mtMY6xMvZeSRx9MTa1+NgJWndrkzVTdJ1pQAmAKQuxyb5LsHVvrgP7kcQFvxPHVpLVTORbTJWHaiqoKrJvi1iA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/parser": "^7.13.9",
+ "@babel/types": "^7.13.0",
+ "@types/estree": "^0.0.48",
+ "@vue/compiler-core": "3.1.5",
+ "@vue/compiler-dom": "3.1.5",
+ "@vue/compiler-ssr": "3.1.5",
+ "@vue/shared": "3.1.5",
+ "consolidate": "^0.16.0",
+ "estree-walker": "^2.0.1",
+ "hash-sum": "^2.0.0",
+ "lru-cache": "^5.1.1",
+ "magic-string": "^0.25.7",
+ "merge-source-map": "^1.1.0",
+ "postcss": "^8.1.10",
+ "postcss-modules": "^4.0.0",
+ "postcss-selector-parser": "^6.0.4",
+ "source-map": "^0.6.1"
+ },
+ "peerDependencies": {
+ "vue": "3.1.5"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.1.5.tgz",
+ "integrity": "sha512-CU5N7Di/a4lyJ18LGJxJYZS2a8PlLdWpWHX9p/XcsjT2TngMpj3QvHVRkuik2u8QrIDZ8OpYmTyj1WDNsOV+Dg==",
+ "dev": true,
+ "dependencies": {
+ "@vue/compiler-dom": "3.1.5",
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
+ "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
+ "dependencies": {
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.1.5.tgz",
+ "integrity": "sha512-YQbG5cBktN1RowQDKA22itmvQ+b40f0WgQ6CXK4VYoYICAiAfu6Cc14777ve8zp1rJRGtk5oIeS149TOculrTg==",
+ "dependencies": {
+ "@vue/reactivity": "3.1.5",
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.1.5.tgz",
+ "integrity": "sha512-tNcf3JhVR0RfW0kw1p8xZgv30nvX8Y9rsz7eiQ0dHe273sfoCngAG0y4GvMaY4Xd8FsjUwFedd4suQ8Lu8meXg==",
+ "dependencies": {
+ "@vue/runtime-core": "3.1.5",
+ "@vue/shared": "3.1.5",
+ "csstype": "^2.6.8"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
+ "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "node_modules/big-integer": {
+ "version": "1.6.48",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz",
+ "integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/big.js": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
+ "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/binary": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
+ "integrity": "sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=",
+ "dev": true,
+ "dependencies": {
+ "buffers": "~0.1.1",
+ "chainsaw": "~0.1.0"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/bluebird": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+ "dev": true
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/buffer-indexof-polyfill": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
+ "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/buffers": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
+ "integrity": "sha1-skV5w77U1tOWru5tmorn9Ugqt7s=",
+ "dev": true,
+ "engines": {
+ "node": ">=0.2.0"
+ }
+ },
+ "node_modules/bulma": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.3.tgz",
+ "integrity": "sha512-0d7GNW1PY4ud8TWxdNcP6Cc8Bu7MxcntD/RRLGWuiw/s0a9P+XlH/6QoOIrmbj6o8WWJzJYhytiu9nFjTszk1g=="
+ },
+ "node_modules/chainsaw": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
+ "integrity": "sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=",
+ "dev": true,
+ "dependencies": {
+ "traverse": ">=0.3.0 <0.4"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/colorette": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
+ "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==",
+ "dev": true
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+ "dev": true
+ },
+ "node_modules/consolidate": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.16.0.tgz",
+ "integrity": "sha512-Nhl1wzCslqXYTJVDyJCu3ODohy9OfBMB5uD2BiBTzd7w+QY0lBzafkR8y8755yMYHAaMD4NuzbAw03/xzfw+eQ==",
+ "dev": true,
+ "dependencies": {
+ "bluebird": "^3.7.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
+ "dev": true
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "2.6.17",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.17.tgz",
+ "integrity": "sha512-u1wmTI1jJGzCJzWndZo8mk4wnPTZd1eOIYTYvuEyOQGfmDl3TrabCCfKnOC86FZwW/9djqTl933UF/cS425i9A=="
+ },
+ "node_modules/duplexer2": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
+ "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=",
+ "dev": true,
+ "dependencies": {
+ "readable-stream": "^2.0.2"
+ }
+ },
+ "node_modules/emojis-list": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
+ "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.12.18",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.18.tgz",
+ "integrity": "sha512-arWhBQSy+oiBAp8VRRCFvAU+3jyf0gGacABLO3haMHboXCDjzq4WUqyQklst2XRuFS8MXgap+9uvODqj9Iygpg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+ "dev": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/fstream": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
+ "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.1.2",
+ "inherits": "~2.0.0",
+ "mkdirp": ">=0.5 0",
+ "rimraf": "2"
+ },
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+ "dev": true
+ },
+ "node_modules/generic-names": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-2.0.1.tgz",
+ "integrity": "sha512-kPCHWa1m9wGG/OwQpeweTwM/PYiQLrUIxXbt/P4Nic3LbGjCP0YwrALHW1uNLKZ0LIMg+RF+XRlj2ekT9ZlZAQ==",
+ "dev": true,
+ "dependencies": {
+ "loader-utils": "^1.1.0"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.1.7",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+ "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
+ "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==",
+ "dev": true
+ },
+ "node_modules/has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/hash-sum": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz",
+ "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
+ "dev": true
+ },
+ "node_modules/icss-replace-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz",
+ "integrity": "sha1-Bupvg2ead0njhs/h/oEq5dsiPe0=",
+ "dev": true
+ },
+ "node_modules/icss-utils": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
+ "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
+ "dev": true,
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "dev": true,
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "node_modules/is-core-module": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.5.0.tgz",
+ "integrity": "sha512-TXCMSDsEHMEEZ6eCA8rwRDbLu55MRGmrctljsBX/2v1d9/GzqHOxW5c5oPSgrUt2vBFXebu9rGqckXGPWOlYpg==",
+ "dev": true,
+ "dependencies": {
+ "has": "^1.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
+ "dev": true
+ },
+ "node_modules/json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.0"
+ },
+ "bin": {
+ "json5": "lib/cli.js"
+ }
+ },
+ "node_modules/listenercount": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz",
+ "integrity": "sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc=",
+ "dev": true
+ },
+ "node_modules/loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "dependencies": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/lodash.camelcase": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+ "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=",
+ "dev": true
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.25.7",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
+ "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==",
+ "dev": true,
+ "dependencies": {
+ "sourcemap-codec": "^1.4.4"
+ }
+ },
+ "node_modules/merge-source-map": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz",
+ "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==",
+ "dev": true,
+ "dependencies": {
+ "source-map": "^0.6.1"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+ "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+ "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+ "dev": true
+ },
+ "node_modules/mkdirp": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
+ "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.5"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.1.23",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
+ "integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
+ "dev": true,
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+ "dev": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
+ "node_modules/postcss": {
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.3.6.tgz",
+ "integrity": "sha512-wG1cc/JhRgdqB6WHEuyLTedf3KIRuD0hG6ldkFEZNCjRxiC+3i6kkWUUbiJQayP28iwG35cEmAbe98585BYV0A==",
+ "dev": true,
+ "dependencies": {
+ "colorette": "^1.2.2",
+ "nanoid": "^3.1.23",
+ "source-map-js": "^0.6.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ }
+ },
+ "node_modules/postcss-modules": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/postcss-modules/-/postcss-modules-4.2.2.tgz",
+ "integrity": "sha512-/H08MGEmaalv/OU8j6bUKi/kZr2kqGF6huAW8m9UAgOLWtpFdhA14+gPBoymtqyv+D4MLsmqaF2zvIegdCxJXg==",
+ "dev": true,
+ "dependencies": {
+ "generic-names": "^2.0.1",
+ "icss-replace-symbols": "^1.1.0",
+ "lodash.camelcase": "^4.3.0",
+ "postcss-modules-extract-imports": "^3.0.0",
+ "postcss-modules-local-by-default": "^4.0.0",
+ "postcss-modules-scope": "^3.0.0",
+ "postcss-modules-values": "^4.0.0",
+ "string-hash": "^1.1.1"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-modules-extract-imports": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
+ "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
+ "dev": true,
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/postcss-modules-local-by-default": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz",
+ "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^5.0.0",
+ "postcss-selector-parser": "^6.0.2",
+ "postcss-value-parser": "^4.1.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/postcss-modules-scope": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz",
+ "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==",
+ "dev": true,
+ "dependencies": {
+ "postcss-selector-parser": "^6.0.4"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/postcss-modules-values": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
+ "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
+ "dev": true,
+ "dependencies": {
+ "icss-utils": "^5.0.0"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >= 14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.0.6",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz",
+ "integrity": "sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg==",
+ "dev": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
+ "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==",
+ "dev": true
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "dev": true
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+ "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
+ "dev": true,
+ "dependencies": {
+ "is-core-module": "^2.2.0",
+ "path-parse": "^1.0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "2.56.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.56.0.tgz",
+ "integrity": "sha512-weEafgbjbHCnrtJPNyCrhYnjP62AkF04P0BcV/1mofy1+gytWln4VVB1OK462cq2EAyWzRDpTMheSP/o+quoiA==",
+ "dev": true,
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=",
+ "dev": true
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-0.6.2.tgz",
+ "integrity": "sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/sourcemap-codec": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+ "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
+ "dev": true
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string-hash": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz",
+ "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=",
+ "dev": true
+ },
+ "node_modules/to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/traverse": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
+ "integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
+ "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=4.2.0"
+ }
+ },
+ "node_modules/unzipper": {
+ "version": "0.10.11",
+ "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.11.tgz",
+ "integrity": "sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw==",
+ "dev": true,
+ "dependencies": {
+ "big-integer": "^1.6.17",
+ "binary": "~0.3.0",
+ "bluebird": "~3.4.1",
+ "buffer-indexof-polyfill": "~1.0.0",
+ "duplexer2": "~0.1.4",
+ "fstream": "^1.0.12",
+ "graceful-fs": "^4.2.2",
+ "listenercount": "~1.0.1",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "~1.0.4"
+ }
+ },
+ "node_modules/unzipper/node_modules/bluebird": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
+ "integrity": "sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM=",
+ "dev": true
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
+ "dev": true
+ },
+ "node_modules/vite": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-2.4.4.tgz",
+ "integrity": "sha512-m1wK6pFJKmaYA6AeZIUXyiAgUAAJzVXhIMYCdZUpCaFMGps0v0IlNJtbmPvkUhVEyautalajmnW5X6NboUPsnw==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.12.8",
+ "postcss": "^8.3.6",
+ "resolve": "^1.20.0",
+ "rollup": "^2.38.5"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.1.5.tgz",
+ "integrity": "sha512-Ho7HNb1nfDoO+HVb6qYZgeaobt1XbY6KXFe4HGs1b9X6RhkWG/113n4/SrtM1LUclM6OrP/Se5aPHHvAPG1iVQ==",
+ "dependencies": {
+ "@vue/compiler-dom": "3.1.5",
+ "@vue/runtime-dom": "3.1.5",
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "0.0.24",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-0.0.24.tgz",
+ "integrity": "sha512-Qx0V7jkWMtvddtaWa1SA8YKkBCRmjq9zZUB2UIMZiso6JSH538oHD2VumSzkoDnAfFbY3t0/j1mB2abpA0bGWA==",
+ "dev": true,
+ "dependencies": {
+ "unzipper": "0.10.11"
+ },
+ "bin": {
+ "vue-tsc": "vue-tsc.js"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+ "dev": true
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ },
+ "dependencies": {
+ "@babel/helper-validator-identifier": {
+ "version": "7.14.9",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz",
+ "integrity": "sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g=="
+ },
+ "@babel/parser": {
+ "version": "7.15.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.15.0.tgz",
+ "integrity": "sha512-0v7oNOjr6YT9Z2RAOTv4T9aP+ubfx4Q/OhVtAet7PFDt0t9Oy6Jn+/rfC6b8HJ5zEqrQCiMxJfgtHpmIminmJQ=="
+ },
+ "@babel/types": {
+ "version": "7.15.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.15.0.tgz",
+ "integrity": "sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ==",
+ "requires": {
+ "@babel/helper-validator-identifier": "^7.14.9",
+ "to-fast-properties": "^2.0.0"
+ }
+ },
+ "@types/estree": {
+ "version": "0.0.48",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.48.tgz",
+ "integrity": "sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==",
+ "dev": true
+ },
+ "@vitejs/plugin-vue": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-1.3.0.tgz",
+ "integrity": "sha512-wJvuJdTBjvucUX0vK4fuy60t+A9bJSZxc59vp1Y+8kiOd0NU5kFt4lay72gMWPeR+lSUjrTmGUq8Uzb99Jbw3A==",
+ "dev": true,
+ "requires": {}
+ },
+ "@vue/compiler-core": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.1.5.tgz",
+ "integrity": "sha512-TXBhFinoBaXKDykJzY26UEuQU1K07FOp/0Ie+OXySqqk0bS0ZO7Xvl7UmiTUPYcLrWbxWBR7Bs/y55AI0MNc2Q==",
+ "requires": {
+ "@babel/parser": "^7.12.0",
+ "@babel/types": "^7.12.0",
+ "@vue/shared": "3.1.5",
+ "estree-walker": "^2.0.1",
+ "source-map": "^0.6.1"
+ }
+ },
+ "@vue/compiler-dom": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.1.5.tgz",
+ "integrity": "sha512-ZsL3jqJ52OjGU/YiT/9XiuZAmWClKInZM2aFJh9gnsAPqOrj2JIELMbkIFpVKR/CrVO/f2VxfPiiQdQTr65jcQ==",
+ "requires": {
+ "@vue/compiler-core": "3.1.5",
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "@vue/compiler-sfc": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.1.5.tgz",
+ "integrity": "sha512-mtMY6xMvZeSRx9MTa1+NgJWndrkzVTdJ1pQAmAKQuxyb5LsHVvrgP7kcQFvxPHVpLVTORbTJWHaiqoKrJvi1iA==",
+ "dev": true,
+ "requires": {
+ "@babel/parser": "^7.13.9",
+ "@babel/types": "^7.13.0",
+ "@types/estree": "^0.0.48",
+ "@vue/compiler-core": "3.1.5",
+ "@vue/compiler-dom": "3.1.5",
+ "@vue/compiler-ssr": "3.1.5",
+ "@vue/shared": "3.1.5",
+ "consolidate": "^0.16.0",
+ "estree-walker": "^2.0.1",
+ "hash-sum": "^2.0.0",
+ "lru-cache": "^5.1.1",
+ "magic-string": "^0.25.7",
+ "merge-source-map": "^1.1.0",
+ "postcss": "^8.1.10",
+ "postcss-modules": "^4.0.0",
+ "postcss-selector-parser": "^6.0.4",
+ "source-map": "^0.6.1"
+ }
+ },
+ "@vue/compiler-ssr": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.1.5.tgz",
+ "integrity": "sha512-CU5N7Di/a4lyJ18LGJxJYZS2a8PlLdWpWHX9p/XcsjT2TngMpj3QvHVRkuik2u8QrIDZ8OpYmTyj1WDNsOV+Dg==",
+ "dev": true,
+ "requires": {
+ "@vue/compiler-dom": "3.1.5",
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "@vue/reactivity": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
+ "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
+ "requires": {
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "@vue/runtime-core": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.1.5.tgz",
+ "integrity": "sha512-YQbG5cBktN1RowQDKA22itmvQ+b40f0WgQ6CXK4VYoYICAiAfu6Cc14777ve8zp1rJRGtk5oIeS149TOculrTg==",
+ "requires": {
+ "@vue/reactivity": "3.1.5",
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "@vue/runtime-dom": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.1.5.tgz",
+ "integrity": "sha512-tNcf3JhVR0RfW0kw1p8xZgv30nvX8Y9rsz7eiQ0dHe273sfoCngAG0y4GvMaY4Xd8FsjUwFedd4suQ8Lu8meXg==",
+ "requires": {
+ "@vue/runtime-core": "3.1.5",
+ "@vue/shared": "3.1.5",
+ "csstype": "^2.6.8"
+ }
+ },
+ "@vue/shared": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
+ "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="
+ },
+ "balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "big-integer": {
+ "version": "1.6.48",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz",
+ "integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w==",
+ "dev": true
+ },
+ "big.js": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
+ "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
+ "dev": true
+ },
+ "binary": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
+ "integrity": "sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=",
+ "dev": true,
+ "requires": {
+ "buffers": "~0.1.1",
+ "chainsaw": "~0.1.0"
+ }
+ },
+ "bluebird": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+ "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+ "dev": true
+ },
+ "brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "buffer-indexof-polyfill": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
+ "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==",
+ "dev": true
+ },
+ "buffers": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
+ "integrity": "sha1-skV5w77U1tOWru5tmorn9Ugqt7s=",
+ "dev": true
+ },
+ "bulma": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/bulma/-/bulma-0.9.3.tgz",
+ "integrity": "sha512-0d7GNW1PY4ud8TWxdNcP6Cc8Bu7MxcntD/RRLGWuiw/s0a9P+XlH/6QoOIrmbj6o8WWJzJYhytiu9nFjTszk1g=="
+ },
+ "chainsaw": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
+ "integrity": "sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=",
+ "dev": true,
+ "requires": {
+ "traverse": ">=0.3.0 <0.4"
+ }
+ },
+ "colorette": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz",
+ "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==",
+ "dev": true
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+ "dev": true
+ },
+ "consolidate": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/consolidate/-/consolidate-0.16.0.tgz",
+ "integrity": "sha512-Nhl1wzCslqXYTJVDyJCu3ODohy9OfBMB5uD2BiBTzd7w+QY0lBzafkR8y8755yMYHAaMD4NuzbAw03/xzfw+eQ==",
+ "dev": true,
+ "requires": {
+ "bluebird": "^3.7.2"
+ }
+ },
+ "core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
+ "dev": true
+ },
+ "cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true
+ },
+ "csstype": {
+ "version": "2.6.17",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.17.tgz",
+ "integrity": "sha512-u1wmTI1jJGzCJzWndZo8mk4wnPTZd1eOIYTYvuEyOQGfmDl3TrabCCfKnOC86FZwW/9djqTl933UF/cS425i9A=="
+ },
+ "duplexer2": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
+ "integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=",
+ "dev": true,
+ "requires": {
+ "readable-stream": "^2.0.2"
+ }
+ },
+ "emojis-list": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
+ "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
+ "dev": true
+ },
+ "esbuild": {
+ "version": "0.12.18",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.18.tgz",
+ "integrity": "sha512-arWhBQSy+oiBAp8VRRCFvAU+3jyf0gGacABLO3haMHboXCDjzq4WUqyQklst2XRuFS8MXgap+9uvODqj9Iygpg==",
+ "dev": true
+ },
+ "estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+ "dev": true
+ },
+ "fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "optional": true
+ },
+ "fstream": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz",
+ "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "inherits": "~2.0.0",
+ "mkdirp": ">=0.5 0",
+ "rimraf": "2"
+ }
+ },
+ "function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==",
+ "dev": true
+ },
+ "generic-names": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-2.0.1.tgz",
+ "integrity": "sha512-kPCHWa1m9wGG/OwQpeweTwM/PYiQLrUIxXbt/P4Nic3LbGjCP0YwrALHW1uNLKZ0LIMg+RF+XRlj2ekT9ZlZAQ==",
+ "dev": true,
+ "requires": {
+ "loader-utils": "^1.1.0"
+ }
+ },
+ "glob": {
+ "version": "7.1.7",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+ "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+ "dev": true,
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "graceful-fs": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz",
+ "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==",
+ "dev": true
+ },
+ "has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1"
+ }
+ },
+ "hash-sum": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz",
+ "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
+ "dev": true
+ },
+ "icss-replace-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz",
+ "integrity": "sha1-Bupvg2ead0njhs/h/oEq5dsiPe0=",
+ "dev": true
+ },
+ "icss-utils": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
+ "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
+ "dev": true,
+ "requires": {}
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "dev": true,
+ "requires": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true
+ },
+ "is-core-module": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.5.0.tgz",
+ "integrity": "sha512-TXCMSDsEHMEEZ6eCA8rwRDbLu55MRGmrctljsBX/2v1d9/GzqHOxW5c5oPSgrUt2vBFXebu9rGqckXGPWOlYpg==",
+ "dev": true,
+ "requires": {
+ "has": "^1.0.3"
+ }
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
+ "dev": true
+ },
+ "json5": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+ "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ },
+ "listenercount": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz",
+ "integrity": "sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc=",
+ "dev": true
+ },
+ "loader-utils": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.0.tgz",
+ "integrity": "sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==",
+ "dev": true,
+ "requires": {
+ "big.js": "^5.2.2",
+ "emojis-list": "^3.0.0",
+ "json5": "^1.0.1"
+ }
+ },
+ "lodash.camelcase": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
+ "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=",
+ "dev": true
+ },
+ "lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "requires": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "magic-string": {
+ "version": "0.25.7",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
+ "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==",
+ "dev": true,
+ "requires": {
+ "sourcemap-codec": "^1.4.4"
+ }
+ },
+ "merge-source-map": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz",
+ "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==",
+ "dev": true,
+ "requires": {
+ "source-map": "^0.6.1"
+ }
+ },
+ "minimatch": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+ "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "minimist": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+ "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+ "dev": true
+ },
+ "mkdirp": {
+ "version": "0.5.5",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
+ "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.5"
+ }
+ },
+ "nanoid": {
+ "version": "3.1.23",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
+ "integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
+ "dev": true
+ },
+ "once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+ "dev": true,
+ "requires": {
+ "wrappy": "1"
+ }
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+ "dev": true
+ },
+ "path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true
+ },
+ "postcss": {
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.3.6.tgz",
+ "integrity": "sha512-wG1cc/JhRgdqB6WHEuyLTedf3KIRuD0hG6ldkFEZNCjRxiC+3i6kkWUUbiJQayP28iwG35cEmAbe98585BYV0A==",
+ "dev": true,
+ "requires": {
+ "colorette": "^1.2.2",
+ "nanoid": "^3.1.23",
+ "source-map-js": "^0.6.2"
+ }
+ },
+ "postcss-modules": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/postcss-modules/-/postcss-modules-4.2.2.tgz",
+ "integrity": "sha512-/H08MGEmaalv/OU8j6bUKi/kZr2kqGF6huAW8m9UAgOLWtpFdhA14+gPBoymtqyv+D4MLsmqaF2zvIegdCxJXg==",
+ "dev": true,
+ "requires": {
+ "generic-names": "^2.0.1",
+ "icss-replace-symbols": "^1.1.0",
+ "lodash.camelcase": "^4.3.0",
+ "postcss-modules-extract-imports": "^3.0.0",
+ "postcss-modules-local-by-default": "^4.0.0",
+ "postcss-modules-scope": "^3.0.0",
+ "postcss-modules-values": "^4.0.0",
+ "string-hash": "^1.1.1"
+ }
+ },
+ "postcss-modules-extract-imports": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
+ "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
+ "dev": true,
+ "requires": {}
+ },
+ "postcss-modules-local-by-default": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz",
+ "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^5.0.0",
+ "postcss-selector-parser": "^6.0.2",
+ "postcss-value-parser": "^4.1.0"
+ }
+ },
+ "postcss-modules-scope": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz",
+ "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==",
+ "dev": true,
+ "requires": {
+ "postcss-selector-parser": "^6.0.4"
+ }
+ },
+ "postcss-modules-values": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
+ "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
+ "dev": true,
+ "requires": {
+ "icss-utils": "^5.0.0"
+ }
+ },
+ "postcss-selector-parser": {
+ "version": "6.0.6",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz",
+ "integrity": "sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg==",
+ "dev": true,
+ "requires": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "postcss-value-parser": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
+ "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==",
+ "dev": true
+ },
+ "process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "dev": true
+ },
+ "readable-stream": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+ "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+ "dev": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "resolve": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+ "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
+ "dev": true,
+ "requires": {
+ "is-core-module": "^2.2.0",
+ "path-parse": "^1.0.6"
+ }
+ },
+ "rimraf": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
+ "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "rollup": {
+ "version": "2.56.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.56.0.tgz",
+ "integrity": "sha512-weEafgbjbHCnrtJPNyCrhYnjP62AkF04P0BcV/1mofy1+gytWln4VVB1OK462cq2EAyWzRDpTMheSP/o+quoiA==",
+ "dev": true,
+ "requires": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "dev": true
+ },
+ "setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=",
+ "dev": true
+ },
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+ },
+ "source-map-js": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-0.6.2.tgz",
+ "integrity": "sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug==",
+ "dev": true
+ },
+ "sourcemap-codec": {
+ "version": "1.4.8",
+ "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+ "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
+ "dev": true
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "dev": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "string-hash": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz",
+ "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs=",
+ "dev": true
+ },
+ "to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4="
+ },
+ "traverse": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz",
+ "integrity": "sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=",
+ "dev": true
+ },
+ "typescript": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz",
+ "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==",
+ "dev": true
+ },
+ "unzipper": {
+ "version": "0.10.11",
+ "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.11.tgz",
+ "integrity": "sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw==",
+ "dev": true,
+ "requires": {
+ "big-integer": "^1.6.17",
+ "binary": "~0.3.0",
+ "bluebird": "~3.4.1",
+ "buffer-indexof-polyfill": "~1.0.0",
+ "duplexer2": "~0.1.4",
+ "fstream": "^1.0.12",
+ "graceful-fs": "^4.2.2",
+ "listenercount": "~1.0.1",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "~1.0.4"
+ },
+ "dependencies": {
+ "bluebird": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
+ "integrity": "sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM=",
+ "dev": true
+ }
+ }
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
+ "dev": true
+ },
+ "vite": {
+ "version": "2.4.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-2.4.4.tgz",
+ "integrity": "sha512-m1wK6pFJKmaYA6AeZIUXyiAgUAAJzVXhIMYCdZUpCaFMGps0v0IlNJtbmPvkUhVEyautalajmnW5X6NboUPsnw==",
+ "dev": true,
+ "requires": {
+ "esbuild": "^0.12.8",
+ "fsevents": "~2.3.2",
+ "postcss": "^8.3.6",
+ "resolve": "^1.20.0",
+ "rollup": "^2.38.5"
+ }
+ },
+ "vue": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.1.5.tgz",
+ "integrity": "sha512-Ho7HNb1nfDoO+HVb6qYZgeaobt1XbY6KXFe4HGs1b9X6RhkWG/113n4/SrtM1LUclM6OrP/Se5aPHHvAPG1iVQ==",
+ "requires": {
+ "@vue/compiler-dom": "3.1.5",
+ "@vue/runtime-dom": "3.1.5",
+ "@vue/shared": "3.1.5"
+ }
+ },
+ "vue-tsc": {
+ "version": "0.0.24",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-0.0.24.tgz",
+ "integrity": "sha512-Qx0V7jkWMtvddtaWa1SA8YKkBCRmjq9zZUB2UIMZiso6JSH538oHD2VumSzkoDnAfFbY3t0/j1mB2abpA0bGWA==",
+ "dev": true,
+ "requires": {
+ "unzipper": "0.10.11"
+ }
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+ "dev": true
+ },
+ "yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ }
+ }
+}
diff --git a/unwind-ui/package.json b/unwind-ui/package.json
new file mode 100644
index 0000000..78b0c2a
--- /dev/null
+++ b/unwind-ui/package.json
@@ -0,0 +1,26 @@
+{
+ "version": "0.0.0",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "lint": "vue-tsc --noEmit",
+ "serve": "vite preview"
+ },
+ "dependencies": {
+ "bulma": "^0.9.3",
+ "vue": "^3.0.5"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^1.2.5",
+ "@vue/compiler-sfc": "^3.0.5",
+ "typescript": "^4.3.2",
+ "vite": "^2.4.2",
+ "vue-tsc": "^0.0.24"
+ },
+ "prettier": {
+ "arrowParens": "always",
+ "printWidth": 88,
+ "trailingComma": "all",
+ "semi": false
+ }
+}
diff --git a/unwind-ui/src/App.vue b/unwind-ui/src/App.vue
new file mode 100644
index 0000000..d797fdc
--- /dev/null
+++ b/unwind-ui/src/App.vue
@@ -0,0 +1,259 @@
+
+
+
+
(credentials = login)"
+ />
+
+
+
+
+
+
+
+ ๐ฅ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unwind-ui/src/components/MovieList.vue b/unwind-ui/src/components/MovieList.vue
new file mode 100644
index 0000000..fe61e67
--- /dev/null
+++ b/unwind-ui/src/components/MovieList.vue
@@ -0,0 +1,138 @@
+
+
+
+
+ | title |
+ year |
+ type |
+ rating |
+ runtime |
+
+
+
+
+ |
+
+ {{ item.original_title }}
+
+ ({{ item.title }})
+ |
+
+
+ {{ item.title }}
+
+ |
+
+ {{ item.release_year }}
+ |
+
+ {{ item.media_type }}
+ |
+
+ {{ imdb_rating(item.imdb_score) }}
+ {{ avg_imdb_rating(item.user_scores) }}
+ |
+
+ {{ duration(item.runtime) }}
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/unwind-ui/src/components/UserLogin.vue b/unwind-ui/src/components/UserLogin.vue
new file mode 100644
index 0000000..578f978
--- /dev/null
+++ b/unwind-ui/src/components/UserLogin.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+ ๐ค
+
+
+
+
+
+ ๐
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unwind-ui/src/config.ts b/unwind-ui/src/config.ts
new file mode 100644
index 0000000..c6f6b3a
--- /dev/null
+++ b/unwind-ui/src/config.ts
@@ -0,0 +1,3 @@
+export default {
+ api_url: `${process.env.API_URL}v1/`,
+}
diff --git a/unwind-ui/src/main.ts b/unwind-ui/src/main.ts
new file mode 100644
index 0000000..e88f45c
--- /dev/null
+++ b/unwind-ui/src/main.ts
@@ -0,0 +1,12 @@
+import { createApp } from "vue"
+import App from "./App.vue"
+
+import MovieList from "./components/MovieList.vue"
+import UserLogin from "./components/UserLogin.vue"
+
+import "bulma/css/bulma.css"
+
+const app = createApp(App)
+app.component("movie-list", MovieList)
+app.component("user-login", UserLogin)
+app.mount("#app")
diff --git a/unwind-ui/src/shims-vue.d.ts b/unwind-ui/src/shims-vue.d.ts
new file mode 100644
index 0000000..089a40f
--- /dev/null
+++ b/unwind-ui/src/shims-vue.d.ts
@@ -0,0 +1,6 @@
+declare module "*.vue" {
+ import { DefineComponent } from "vue"
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
+ const component: DefineComponent<{}, {}, any>
+ export default component
+}
diff --git a/unwind-ui/src/utils.ts b/unwind-ui/src/utils.ts
new file mode 100644
index 0000000..15aa8d4
--- /dev/null
+++ b/unwind-ui/src/utils.ts
@@ -0,0 +1,18 @@
+export function is_object(x) {
+ return x !== null && typeof x === "object" && !Array.isArray(x)
+}
+
+export function sum(nums) {
+ return nums.reduce((s, n) => s + n, 0)
+}
+
+export function mean(nums) {
+ return sum(nums) / nums.length
+}
+
+export function pstdev(nums, mu = null) {
+ if (mu === null) {
+ mu = mean(nums)
+ }
+ return Math.sqrt(mean(nums.map((n) => (n - mu) ** 2)))
+}
diff --git a/unwind-ui/src/vite-env.d.ts b/unwind-ui/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/unwind-ui/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/unwind-ui/tsconfig.json b/unwind-ui/tsconfig.json
new file mode 100644
index 0000000..f7825db
--- /dev/null
+++ b/unwind-ui/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "module": "esnext",
+ "moduleResolution": "node",
+ "strict": false,
+ "jsx": "preserve",
+ "sourceMap": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "lib": ["esnext", "dom"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
+}
diff --git a/unwind-ui/vite.config.ts b/unwind-ui/vite.config.ts
new file mode 100644
index 0000000..84d4068
--- /dev/null
+++ b/unwind-ui/vite.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from "vite"
+import vue from "@vitejs/plugin-vue"
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ base: process.env.BASE_URL || "/",
+ define: {
+ "process.env.API_URL": JSON.stringify(
+ process.env.API_URL || "http://localhost:8000/api/",
+ ),
+ },
+ plugins: [vue()],
+})
diff --git a/unwind/config.py b/unwind/config.py
index 26ad3ec..6382cf5 100644
--- a/unwind/config.py
+++ b/unwind/config.py
@@ -16,4 +16,6 @@ config_path = os.getenv("UNWIND_CONFIG", datadir / "config.toml")
_config = toml.load(config_path)
-api_credentials = _config["api"]["credentials"]
+api_base = _config["api"].get("base", "/api/")
+api_cors = _config["api"].get("cors", "*")
+api_credentials = _config["api"].get("credentials", {})
diff --git a/unwind/db.py b/unwind/db.py
index 258d64f..21e9728 100644
--- a/unwind/db.py
+++ b/unwind/db.py
@@ -1,4 +1,3 @@
-import json
import logging
import re
from pathlib import Path
@@ -19,8 +18,10 @@ from .models import (
optional_fields,
utcnow,
)
+from .types import ULID
log = logging.getLogger(__name__)
+T = TypeVar("T")
_shared_connection: Optional[Database] = None
@@ -256,6 +257,12 @@ async def update(item):
await shared_connection().execute(query=query, values=values)
+async def remove(item):
+ values = asplain(item, fields_={"id"})
+ query = f"DELETE FROM {item._table} WHERE id=:id"
+ await shared_connection().execute(query=query, values=values)
+
+
async def add_or_update_user(user: User):
db_user = await get(User, imdb_id=user.imdb_id)
if not db_user:
@@ -453,6 +460,135 @@ async def find_ratings(
return tuple(dict(r) for r in rows)
+def sql_fields(tp: Type):
+ return (f"{tp._table}.{f.name}" for f in fields(tp))
+
+
+def sql_fieldmap(tp: Type):
+ """-> {alias: (table, field_name)}"""
+ return {f"{tp._table}_{f.name}": (tp._table, f.name) for f in fields(tp)}
+
+
+def mux(*tps: Type):
+ return ", ".join(
+ f"{t}.{n} AS {k}" for tp in tps for k, (t, n) in sql_fieldmap(tp).items()
+ )
+
+
+def demux(tp: Type[ModelType], row) -> ModelType:
+ return fromplain(tp, {n: row[k] for k, (_, n) in sql_fieldmap(tp).items()})
+
+
+def sql_in(column: str, values: list[T]) -> tuple[str, dict[str, T]]:
+ c = column.replace(".", "___")
+ value_map = {f"{c}_{i}": v for i, v in enumerate(values, start=1)}
+ placeholders = ",".join(":" + k for k in value_map)
+ return f"{column} IN ({placeholders})", value_map
+
+
+async def ratings_for_movies(
+ movie_ids: Iterable[ULID], user_ids: Iterable[ULID] = []
+) -> Iterable[Rating]:
+ values: dict[str, str] = {}
+ conditions: list[str] = []
+
+ q, vm = sql_in("movie_id", [str(m) for m in movie_ids])
+ conditions.append(q)
+ values.update(vm)
+
+ if user_ids:
+ q, vm = sql_in("user_id", [str(m) for m in user_ids])
+ conditions.append(q)
+ values.update(vm)
+
+ query = f"""
+ SELECT {','.join(sql_fields(Rating))}
+ FROM {Rating._table}
+ WHERE {' AND '.join(f'({c})' for c in conditions) if conditions else '1=1'}
+ """
+
+ rows = await shared_connection().fetch_all(query, values)
+
+ return (fromplain(Rating, row) for row in rows)
+
+
+async def find_movies(
+ *,
+ title: str = None,
+ media_type: str = None,
+ exact: bool = False,
+ ignore_tv_episodes: bool = False,
+ yearcomp: tuple[Literal["<", "=", ">"], int] = None,
+ limit_rows: int = 10,
+ skip_rows: int = 0,
+ include_unrated: bool = False,
+ user_ids: list[ULID] = [],
+) -> Iterable[tuple[Movie, list[Rating]]]:
+ values: dict[str, Union[int, str]] = {
+ "limit_rows": limit_rows,
+ "skip_rows": skip_rows,
+ }
+
+ conditions = []
+ if title:
+ values["escape"] = "#"
+ escaped_title = sql_escape(title, char=values["escape"])
+ values["pattern"] = (
+ "_".join(escaped_title.split())
+ if exact
+ else "%" + "%".join(escaped_title.split()) + "%"
+ )
+ conditions.append(
+ f"""
+ (
+ {Movie._table}.title LIKE :pattern ESCAPE :escape
+ OR {Movie._table}.original_title LIKE :pattern ESCAPE :escape
+ )
+ """
+ )
+
+ if yearcomp:
+ op, year = yearcomp
+ assert op in "<=>"
+ values["year"] = year
+ conditions.append(f"{Movie._table}.release_year{op}:year")
+
+ if media_type:
+ values["media_type"] = media_type
+ conditions.append(f"{Movie._table}.media_type=:media_type")
+
+ if ignore_tv_episodes:
+ conditions.append(f"{Movie._table}.media_type!='TV Episode'")
+
+ if not include_unrated:
+ conditions.append(f"{Movie._table}.score NOTNULL")
+
+ query = f"""
+ SELECT {','.join(sql_fields(Movie))}
+ FROM {Movie._table}
+ WHERE {(' AND '.join(conditions)) if conditions else '1=1'}
+ ORDER BY
+ length({Movie._table}.title) ASC,
+ {Movie._table}.imdb_score DESC,
+ {Movie._table}.release_year DESC
+ LIMIT :skip_rows, :limit_rows
+ """
+ rows = await shared_connection().fetch_all(bindparams(query, values))
+
+ movies = [fromplain(Movie, row) for row in rows]
+
+ if not user_ids:
+ return ((m, []) for m in movies)
+
+ ratings = await ratings_for_movies((m.id for m in movies), user_ids)
+
+ aggreg: dict[ULID, tuple[Movie, list[Rating]]] = {m.id: (m, []) for m in movies}
+ for rating in ratings:
+ aggreg[rating.movie_id][1].append(rating)
+
+ return aggreg.values()
+
+
def bindparams(query: str, values: dict):
"""Bind values to a query.
diff --git a/unwind/imdb_import.py b/unwind/imdb_import.py
index 1d514f3..e29a981 100644
--- a/unwind/imdb_import.py
+++ b/unwind/imdb_import.py
@@ -4,7 +4,7 @@ import logging
from dataclasses import dataclass, fields
from datetime import datetime, timezone
from pathlib import Path
-from typing import Optional, cast
+from typing import Generator, Literal, Optional, Type, TypeVar, overload
from . import config, db, request
from .db import add_or_update_many_movies
@@ -13,6 +13,7 @@ from .models import Movie
log = logging.getLogger(__name__)
+T = TypeVar("T")
# See
# - https://www.imdb.com/interfaces/
@@ -120,6 +121,20 @@ def count_lines(path) -> int:
return i
+@overload
+def read_imdb_tsv(
+ path, row_type, *, unpack: Literal[False]
+) -> Generator[list[str], None, None]:
+ ...
+
+
+@overload
+def read_imdb_tsv(
+ path, row_type: Type[T], *, unpack: Literal[True] = True
+) -> Generator[T, None, None]:
+ ...
+
+
def read_imdb_tsv(path, row_type, *, unpack=True):
with gzip.open(path, "rt", newline="") as f:
rows = csv.reader(f, delimiter="\t", quoting=csv.QUOTE_NONE)
@@ -158,7 +173,6 @@ def read_ratings(path):
def read_ratings_as_mapping(path):
"""Optimized function to quickly load all ratings."""
rows = read_imdb_tsv(path, RatingRow, unpack=False)
- rows = cast(list[list[str]], rows)
return {r[0]: (round(100 * (float(r[1]) - 1) / 9), int(r[2])) for r in rows}
diff --git a/unwind/models.py b/unwind/models.py
index 7c2862c..5944363 100644
--- a/unwind/models.py
+++ b/unwind/models.py
@@ -6,6 +6,7 @@ from typing import (
Annotated,
Any,
ClassVar,
+ Literal,
Optional,
Type,
TypeVar,
@@ -16,6 +17,8 @@ from typing import (
from .types import ULID
+T = TypeVar("T")
+
def annotations(tp: Type) -> Optional[tuple]:
return tp.__metadata__ if hasattr(tp, "__metadata__") else None
@@ -69,12 +72,15 @@ def optional_fields(o):
yield f
-def asplain(o) -> dict[str, Any]:
+def asplain(o, *, fields_: set = None) -> dict[str, Any]:
validate(o)
d = {}
for f in fields(o):
+ if fields_ is not None and f.name not in fields_:
+ continue
+
target = f.type
# XXX this doesn't properly support any kind of nested types
if (otype := optional_type(f.type)) is not None:
@@ -99,7 +105,7 @@ def asplain(o) -> dict[str, Any]:
return d
-def fromplain(cls, d: dict[str, Any]):
+def fromplain(cls: Type[T], d: dict[str, Any]) -> T:
dd = {}
for f in fields(cls):
@@ -229,7 +235,6 @@ class Movie:
self._is_lazy = False
-T = TypeVar("T")
_RelationSentinel = object()
"""Mark a model field as containing external data.
@@ -271,6 +276,13 @@ class Rating:
)
+Access = Literal[
+ "r", # read
+ "i", # index
+ "w", # write
+]
+
+
@dataclass
class User:
_table: ClassVar[str] = "users"
@@ -278,6 +290,21 @@ class User:
id: ULID = field(default_factory=ULID)
imdb_id: str = None
name: str = None # canonical user name
+ secret: str = None
+ groups: list[dict[str, str]] = field(default_factory=list)
+
+ def has_access(self, group_id: Union[ULID, str], access: Access = "r"):
+ group_id = group_id if isinstance(group_id, str) else str(group_id)
+ return any(g["id"] == group_id and access == g["access"] for g in self.groups)
+
+ def set_access(self, group_id: Union[ULID, str], access: Access):
+ group_id = group_id if isinstance(group_id, str) else str(group_id)
+ for g in self.groups:
+ if g["id"] == group_id:
+ g["access"] = access
+ break
+ else:
+ self.groups.append({"id": group_id, "access": access})
@dataclass
@@ -286,5 +313,4 @@ class Group:
id: ULID = field(default_factory=ULID)
name: str = None
- secret: str = None
users: list[dict[str, str]] = field(default_factory=list)
diff --git a/unwind/sql/20210801-201151--add-user-secret.sql b/unwind/sql/20210801-201151--add-user-secret.sql
new file mode 100644
index 0000000..3294a56
--- /dev/null
+++ b/unwind/sql/20210801-201151--add-user-secret.sql
@@ -0,0 +1,22 @@
+-- add secret to users
+
+CREATE TABLE _migrate_users (
+ id TEXT PRIMARY KEY NOT NULL,
+ imdb_id TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ secret TEXT NOT NULL
+);;
+
+INSERT INTO _migrate_users
+SELECT
+ id,
+ imdb_id,
+ name,
+ '' AS secret
+FROM users
+WHERE true;;
+
+DROP TABLE users;;
+
+ALTER TABLE _migrate_users
+RENAME TO users;;
diff --git a/unwind/sql/20210802-212312--add-group-admins.sql b/unwind/sql/20210802-212312--add-group-admins.sql
new file mode 100644
index 0000000..13f3105
--- /dev/null
+++ b/unwind/sql/20210802-212312--add-group-admins.sql
@@ -0,0 +1,45 @@
+-- add group admins
+
+--- remove secrets from groups
+CREATE TABLE _migrate_groups (
+ id TEXT PRIMARY KEY NOT NULL,
+ name TEXT NOT NULL,
+ users TEXT NOT NULL -- JSON array
+);;
+
+INSERT INTO _migrate_groups
+SELECT
+ id,
+ name,
+ users
+FROM groups
+WHERE true;;
+
+DROP TABLE groups;;
+
+ALTER TABLE _migrate_groups
+RENAME TO groups;;
+
+--- add group access to users
+CREATE TABLE _migrate_users (
+ id TEXT PRIMARY KEY NOT NULL,
+ imdb_id TEXT NOT NULL UNIQUE,
+ name TEXT NOT NULL,
+ secret TEXT NOT NULL,
+ groups TEXT NOT NULL -- JSON array
+);;
+
+INSERT INTO _migrate_users
+SELECT
+ id,
+ imdb_id,
+ name,
+ secret,
+ '[]' AS groups
+FROM users
+WHERE true;;
+
+DROP TABLE users;;
+
+ALTER TABLE _migrate_users
+RENAME TO users;;
diff --git a/unwind/web.py b/unwind/web.py
index f2bb103..b8306ef 100644
--- a/unwind/web.py
+++ b/unwind/web.py
@@ -2,7 +2,7 @@ import asyncio
import logging
import secrets
from json.decoder import JSONDecodeError
-from typing import Literal, Optional
+from typing import Literal, Optional, overload
from starlette.applications import Starlette
from starlette.authentication import (
@@ -17,22 +17,29 @@ from starlette.background import BackgroundTask
from starlette.exceptions import HTTPException
from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
+from starlette.middleware.cors import CORSMiddleware
+from starlette.middleware.gzip import GZipMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from . import config, db, imdb, imdb_import
-from .db import close_connection_pool, find_ratings, open_connection_pool
+from .db import close_connection_pool, find_movies, find_ratings, open_connection_pool
from .middleware.responsetime import ResponseTimeMiddleware
from .models import Group, Movie, User, asplain
from .types import ULID
-from .utils import b64encode, phc_compare, phc_scrypt
+from .utils import b64decode, b64encode, phc_compare, phc_scrypt
log = logging.getLogger(__name__)
+# XXX we probably don't need a group secret anymore, if group access is managed
+# on a user level; a group secret would be a separate user with full group
+# access
-class BearingUser(BaseUser):
- def __init__(self, token):
- self.token = token
+
+class AuthedUser(BaseUser):
+ def __init__(self, user_id: str, secret: str):
+ self.user_id = user_id
+ self.secret = secret
class BearerAuthBackend(AuthenticationBackend):
@@ -43,25 +50,33 @@ class BearerAuthBackend(AuthenticationBackend):
if "Authorization" not in request.headers:
return
+ # XXX should we remove the auth header after reading, for security reasons?
+
auth = request.headers["Authorization"]
try:
- scheme, token = auth.split()
+ scheme, credentials = auth.split()
except ValueError:
raise AuthenticationError("Invalid auth credentials")
- if scheme.lower() != "bearer":
- return
-
roles = []
- is_admin = token in self.admin_tokens
-
- if is_admin:
- user = SimpleUser(self.admin_tokens[token])
+ if scheme.lower() == "bearer":
+ is_admin = credentials in self.admin_tokens
+ if not is_admin:
+ return
+ name = self.admin_tokens[credentials]
+ user = SimpleUser(name)
roles.append("admin")
+ elif scheme.lower() == "basic":
+ try:
+ name, secret = b64decode(credentials).decode().split(":")
+ except:
+ raise AuthenticationError("Invalid auth credentials")
+ user = AuthedUser(name, secret)
+
else:
- user = BearingUser(token)
+ return
return AuthCredentials(["authenticated", *roles]), user
@@ -101,11 +116,63 @@ def as_int(x, *, max: int = None, min: Optional[int] = 1, default: int = None):
def as_ulid(s: str) -> ULID:
try:
+ if not s:
+ raise ValueError("Invalid ULID.")
+
return ULID(s)
+
except ValueError:
raise HTTPException(422, "Not a valid ULID.")
+@overload
+async def json_from_body(request) -> dict:
+ ...
+
+
+@overload
+async def json_from_body(request, keys: list[str]) -> list:
+ ...
+
+
+async def json_from_body(request, keys: list[str] = None):
+ if not await request.body():
+ data = {}
+
+ else:
+ try:
+ data = await request.json()
+ except JSONDecodeError:
+ raise HTTPException(422, "Invalid JSON content.")
+
+ if not keys:
+ return data
+
+ try:
+ return [data[k] for k in keys]
+ except KeyError as err:
+ raise HTTPException(422, f"Missing data for key: {err.args[0]}")
+
+
+def is_admin(request):
+ return "admin" in request.auth.scopes
+
+
+async def auth_user(request) -> Optional[User]:
+ if not isinstance(request.user, AuthedUser):
+ return
+
+ user = await db.get(User, id=request.user.user_id)
+ if not user:
+ return
+
+ is_authed = phc_compare(secret=request.user.secret, phc_string=user.secret)
+ if not is_authed:
+ return
+
+ return user
+
+
_routes = []
@@ -123,6 +190,7 @@ route.registered = _routes
@route("/groups/{group_id}/ratings")
async def get_ratings_for_group(request):
+
group_id = as_ulid(request.path_params["group_id"])
group = await db.get(Group, id=str(group_id))
@@ -178,24 +246,77 @@ def not_found(reason: str = "Not Found"):
return JSONResponse({"error": reason}, status_code=404)
+def not_implemented():
+ raise HTTPException(404, "Not yet implemented.")
+
+
@route("/movies")
-@requires(["private"])
-async def get_movies(request):
- imdb_id = request.query_params.get("imdb_id")
+@requires(["authenticated"])
+async def list_movies(request):
- movie = await db.get(Movie, imdb_id=imdb_id)
+ params = request.query_params
+
+ user = await auth_user(request)
+
+ user_ids = set()
+
+ if group_id := params.get("group_id"):
+ group_id = as_ulid(group_id)
+
+ group = await db.get(Group, id=str(group_id))
+ if not group:
+ return not_found("Group not found.")
+
+ is_allowed = is_admin(request) or user and user.has_access(group_id)
+ if not is_allowed:
+ return forbidden("No access to group.")
+
+ user_ids |= {ULID(u["id"]) for u in group.users}
+
+ if user_id := params.get("user_id"):
+ user_id = as_ulid(user_id)
+
+ # Currently a user may only directly access their own ratings.
+ is_allowed = is_admin(request) or user and user.id == user_id
+ if not is_allowed:
+ return forbidden("No access to user.")
+
+ user_ids |= {user_id}
+
+ if imdb_id := params.get("imdb_id"):
+ # XXX missing support for user_ids and user_scores
+ movies = [await db.get(Movie, imdb_id=imdb_id)]
+ resp = [asplain(m) for m in movies]
+
+ else:
+ per_page = as_int(params.get("per_page"), max=1000, default=5)
+ page = as_int(params.get("page"), min=1, default=1)
+ movieratings = await find_movies(
+ title=params.get("title"),
+ media_type=params.get("media_type"),
+ exact=truthy(params.get("exact")),
+ ignore_tv_episodes=truthy(params.get("ignore_tv_episodes")),
+ include_unrated=truthy(params.get("include_unrated")),
+ yearcomp=yearcomp(params["year"]) if "year" in params else None,
+ limit_rows=per_page,
+ skip_rows=(page - 1) * per_page,
+ user_ids=list(user_ids),
+ )
+
+ resp = []
+ for movie, ratings in movieratings:
+ mov = asplain(movie)
+ mov["user_scores"] = [rating.score for rating in ratings]
+ resp.append(mov)
- resp = [asplain(movie)] if movie else []
return JSONResponse(resp)
@route("/movies", methods=["POST"])
@requires(["authenticated", "admin"])
async def add_movie(request):
- pass
-
-import_lock = asyncio.Lock()
+ not_implemented()
@route("/movies/_reload_imdb", methods=["GET"])
@@ -212,6 +333,8 @@ async def progress_for_load_imdb_movies(request):
status = None
if error:
status = "Error during import."
+ elif percent == 0.0 and progress.stopped:
+ status = "Import skipped."
elif percent < 100:
status = "Import is running."
else:
@@ -228,10 +351,14 @@ async def progress_for_load_imdb_movies(request):
return JSONResponse(resp)
+_import_lock = asyncio.Lock()
+
+
@route("/movies/_reload_imdb", methods=["POST"])
@requires(["authenticated", "admin"])
async def load_imdb_movies(request):
- async with import_lock:
+
+ async with _import_lock:
progress = await db.get_import_progress()
if progress and not progress.stopped:
return JSONResponse(
@@ -250,26 +377,166 @@ async def load_imdb_movies(request):
@route("/users")
@requires(["authenticated", "admin"])
async def list_users(request):
+
users = await db.get_all(User)
+
return JSONResponse([asplain(u) for u in users])
@route("/users", methods=["POST"])
@requires(["authenticated", "admin"])
async def add_user(request):
- pass
+
+ name, imdb_id = await json_from_body(request, ["name", "imdb_id"])
+
+ # XXX restrict name
+ # XXX check if imdb_id is well-formed
+
+ secret = secrets.token_bytes()
+
+ user = User(name=name, imdb_id=imdb_id, secret=phc_scrypt(secret))
+ await db.add(user)
+
+ return JSONResponse(
+ {
+ "secret": b64encode(secret),
+ "user": asplain(user),
+ }
+ )
+
+
+@route("/users/{user_id}")
+@requires(["authenticated"])
+async def show_user(request):
+
+ user_id = as_ulid(request.path_params["user_id"])
+
+ if is_admin(request):
+ user = await db.get(User, id=str(user_id))
+
+ else:
+ user = await auth_user(request)
+
+ if not user:
+ return not_found()
+
+ is_allowed = user.id == user_id
+ if not is_allowed:
+ return forbidden()
+
+ # Redact `secret`
+ resp = asplain(user)
+ resp["secret"] = None
+
+ # Fix `groups`
+ resp["groups"] = user.groups
+
+ return JSONResponse(resp)
+
+
+@route("/users/{user_id}", methods=["DELETE"])
+@requires(["authenticated", "admin"])
+async def remove_user(request):
+
+ user_id = as_ulid(request.path_params["user_id"])
+
+ user = await db.get(User, id=str(user_id))
+ if not user:
+ return not_found()
+
+ async with db.shared_connection().transaction():
+ # XXX remove user refs from groups and ratings
+
+ await db.remove(user)
+
+ return JSONResponse(asplain(user))
+
+
+@route("/users/{user_id}", methods=["PATCH"])
+@requires(["authenticated"])
+async def modify_user(request):
+
+ user_id = as_ulid(request.path_params["user_id"])
+
+ if is_admin(request):
+ user = await db.get(User, id=str(user_id))
+
+ else:
+ user = await auth_user(request)
+
+ if not user:
+ return not_found()
+
+ is_allowed = user.id == user_id
+ if not is_allowed:
+ return forbidden()
+
+ data = await json_from_body(request)
+
+ if "name" in data:
+ if not is_admin(request):
+ return forbidden("Changing user name is not allowed.")
+
+ # XXX restrict name
+ user.name = data["name"]
+
+ if "imdb_id" in data:
+ if not is_admin(request):
+ return forbidden("Changing IMDb ID is not allowed.")
+
+ # XXX check if imdb_id is well-formed
+ user.imdb_id = data["imdb_id"]
+
+ if "secret" in data:
+ try:
+ secret = b64decode(data["secret"])
+ except:
+ raise HTTPException(422, f"Invalid secret.")
+
+ user.secret = phc_scrypt(secret)
+
+ await db.update(user)
+
+ return JSONResponse(asplain(user))
+
+
+@route("/users/{user_id}/groups", methods=["POST"])
+@requires(["authenticated", "admin"])
+async def add_group_to_user(request):
+
+ user_id = as_ulid(request.path_params["user_id"])
+
+ user = await db.get(User, id=str(user_id))
+ if not user:
+ return not_found("User not found")
+
+ (group_id, access) = await json_from_body(request, ["group", "access"])
+
+ group = await db.get(Group, id=str(group_id))
+ if not group:
+ return not_found("Group not found")
+
+ if access not in set("riw"):
+ raise HTTPException(422, f"Invalid access level.")
+
+ user.set_access(group_id, access)
+ await db.update(user)
+
+ return JSONResponse(asplain(user))
@route("/users/{user_id}/ratings")
@requires(["private"])
async def ratings_for_user(request):
- request.path_params["user_id"]
+
+ not_implemented()
@route("/users/{user_id}/ratings", methods=["PUT"])
@requires("authenticated")
async def set_rating_for_user(request):
- request.path_params["user_id"]
+
+ not_implemented()
@route("/users/_reload_ratings", methods=["POST"])
@@ -284,69 +551,49 @@ async def load_imdb_user_ratings(request):
@route("/groups")
@requires(["authenticated", "admin"])
async def list_groups(request):
+
groups = await db.get_all(Group)
+
return JSONResponse([asplain(g) for g in groups])
@route("/groups", methods=["POST"])
@requires(["authenticated", "admin"])
async def add_group(request):
- if not await request.body():
- data = {}
- else:
- try:
- data = await request.json()
- except JSONDecodeError:
- raise HTTPException(422, "Invalid JSON content.")
- try:
- name = data["name"]
- except KeyError as err:
- raise HTTPException(422, f"Missing data for key: {err.args[0]}")
+ (name,) = await json_from_body(request, ["name"])
# XXX restrict name
- secret = secrets.token_bytes()
-
- group = Group(name=name, secret=phc_scrypt(secret))
+ group = Group(name=name)
await db.add(group)
- return JSONResponse(
- {
- "secret": b64encode(secret),
- "group": asplain(group),
- }
- )
+ return JSONResponse(asplain(group))
@route("/groups/{group_id}/users", methods=["POST"])
@requires(["authenticated"])
async def add_user_to_group(request):
+
group_id = as_ulid(request.path_params["group_id"])
group = await db.get(Group, id=str(group_id))
if not group:
return not_found()
- is_allowed = "admin" in request.auth.scopes or phc_compare(
- secret=request.user.token, phc_string=group.secret
- )
+ is_allowed = is_admin(request)
+
+ if not is_allowed:
+ user = await auth_user(request)
+ if not user:
+ return not_found("User not found.")
+
+ is_allowed = user.has_access(group_id, "w")
+
if not is_allowed:
return forbidden()
- if not await request.body():
- data = {}
- else:
- try:
- data = await request.json()
- except JSONDecodeError:
- raise HTTPException(422, "Invalid JSON content.")
-
- try:
- name = data["name"]
- user_id = data["id"]
- except KeyError as err:
- raise HTTPException(422, f"Missing data for key: {err.args[0]}")
+ name, user_id = await json_from_body(request, ["name", "id"])
# XXX check if user exists
# XXX restrict name
@@ -382,7 +629,7 @@ def create_app():
on_startup=[open_connection_pool],
on_shutdown=[close_connection_pool],
routes=[
- Mount("/api/v1", routes=route.registered),
+ Mount(f"{config.api_base}v1", routes=route.registered),
],
middleware=[
Middleware(ResponseTimeMiddleware, header_name="Unwind-Elapsed"),
@@ -391,6 +638,14 @@ def create_app():
backend=BearerAuthBackend(config.api_credentials),
on_error=auth_error,
),
+ Middleware(
+ CORSMiddleware,
+ allow_origins=[config.api_cors],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ ),
+ Middleware(GZipMiddleware),
],
exception_handlers={HTTPException: http_exception},
)