summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorWolfgang Müller2024-03-05 18:08:09 +0100
committerWolfgang Müller2024-03-05 19:25:59 +0100
commitd1d654ebac2d51e3841675faeb56480e440f622f (patch)
tree56ef123c1a15a10dfd90836e4038e27efde950c6
downloadhircine-d1d654ebac2d51e3841675faeb56480e440f622f.tar.gz
Initial commit0.1.0
-rw-r--r--.gitignore5
-rw-r--r--LICENSE15
-rw-r--r--Makefile17
-rw-r--r--docs/_examples/example_scraper.json8
-rw-r--r--docs/_examples/example_scraper.py37
-rw-r--r--docs/_examples/example_transformer.py21
-rw-r--r--docs/_images/archive.jpgbin0 -> 221038 bytes
-rw-r--r--docs/_images/comic-edit.jpgbin0 -> 244959 bytes
-rw-r--r--docs/_images/comics.jpgbin0 -> 248591 bytes
-rw-r--r--docs/_images/filtering.jpgbin0 -> 243578 bytes
-rw-r--r--docs/_images/scraper.jpgbin0 -> 533309 bytes
-rw-r--r--docs/_static/favicon.svg25
-rw-r--r--docs/_static/logo.webpbin0 -> 89322 bytes
-rw-r--r--docs/about.rst12
-rw-r--r--docs/advanced/api.rst14
-rw-r--r--docs/advanced/hashing.rst15
-rw-r--r--docs/advanced/image-processing.rst19
-rw-r--r--docs/advanced/import-process.rst53
-rw-r--r--docs/advanced/index.rst13
-rw-r--r--docs/changelog.rst7
-rw-r--r--docs/conf.py31
-rw-r--r--docs/glossary.rst13
-rw-r--r--docs/index.rst38
-rw-r--r--docs/overview.rst124
-rw-r--r--docs/plugins/builtin.rst16
-rw-r--r--docs/plugins/index.rst17
-rw-r--r--docs/plugins/writing/index.rst20
-rw-r--r--docs/plugins/writing/reference.rst51
-rw-r--r--docs/plugins/writing/scrapers.rst48
-rw-r--r--docs/plugins/writing/transformers.rst31
-rw-r--r--docs/setup.rst113
-rw-r--r--docs/usage/admin.rst58
-rw-r--r--docs/usage/configuration.rst41
-rw-r--r--docs/usage/filtering.rst45
-rw-r--r--docs/usage/getting-started.rst121
-rw-r--r--docs/usage/index.rst18
-rw-r--r--docs/usage/namespaces.rst43
-rw-r--r--docs/usage/reading.rst50
-rw-r--r--docs/usage/scraping.rst90
-rw-r--r--docs/usage/shortcuts.rst114
-rw-r--r--frontend/.eslintignore16
-rw-r--r--frontend/.eslintrc.cjs49
-rw-r--r--frontend/.gitignore13
-rw-r--r--frontend/.npmrc1
-rw-r--r--frontend/.prettierignore15
-rw-r--r--frontend/.prettierrc7
-rw-r--r--frontend/.vscode/settings.json1
-rw-r--r--frontend/codegen.ts20
-rw-r--r--frontend/operations.graphql696
-rw-r--r--frontend/package-lock.json12892
-rw-r--r--frontend/package.json57
-rw-r--r--frontend/postcss.config.cjs6
-rw-r--r--frontend/src/app.css180
-rw-r--r--frontend/src/app.d.ts12
-rw-r--r--frontend/src/app.html13
-rw-r--r--frontend/src/gql/Mutations.ts244
-rw-r--r--frontend/src/gql/Queries.ts243
-rw-r--r--frontend/src/gql/Utils.ts74
-rw-r--r--frontend/src/gql/graphql.ts1764
-rw-r--r--frontend/src/lib/Actions.ts109
-rw-r--r--frontend/src/lib/Enums.ts325
-rw-r--r--frontend/src/lib/Filter.ts365
-rw-r--r--frontend/src/lib/Meta.ts1
-rw-r--r--frontend/src/lib/Navigation.ts114
-rw-r--r--frontend/src/lib/Pagination.ts31
-rw-r--r--frontend/src/lib/Reader.ts62
-rw-r--r--frontend/src/lib/Scraper.ts156
-rw-r--r--frontend/src/lib/Selection.ts141
-rw-r--r--frontend/src/lib/Shortcuts.ts153
-rw-r--r--frontend/src/lib/Sort.ts42
-rw-r--r--frontend/src/lib/Tabs.ts18
-rw-r--r--frontend/src/lib/Toasts.ts19
-rw-r--r--frontend/src/lib/Transitions.ts10
-rw-r--r--frontend/src/lib/Update.ts97
-rw-r--r--frontend/src/lib/Utils.ts108
-rw-r--r--frontend/src/lib/assets/logo.webpbin0 -> 89322 bytes
-rw-r--r--frontend/src/lib/components/AddButton.svelte7
-rw-r--r--frontend/src/lib/components/Badge.svelte15
-rw-r--r--frontend/src/lib/components/BookmarkButton.svelte9
-rw-r--r--frontend/src/lib/components/Card.svelte106
-rw-r--r--frontend/src/lib/components/Cardlet.svelte37
-rw-r--r--frontend/src/lib/components/DeleteButton.svelte15
-rw-r--r--frontend/src/lib/components/Dialog.svelte36
-rw-r--r--frontend/src/lib/components/Dropdown.svelte18
-rw-r--r--frontend/src/lib/components/Empty.svelte10
-rw-r--r--frontend/src/lib/components/Expander.svelte17
-rw-r--r--frontend/src/lib/components/Guard.svelte13
-rw-r--r--frontend/src/lib/components/Head.svelte12
-rw-r--r--frontend/src/lib/components/Labelled.svelte10
-rw-r--r--frontend/src/lib/components/LabelledBlock.svelte18
-rw-r--r--frontend/src/lib/components/OrganizedButton.svelte9
-rw-r--r--frontend/src/lib/components/RefreshButton.svelte3
-rw-r--r--frontend/src/lib/components/RemovePageButton.svelte13
-rw-r--r--frontend/src/lib/components/Select.svelte55
-rw-r--r--frontend/src/lib/components/Spinner.svelte36
-rw-r--r--frontend/src/lib/components/SubmitButton.svelte7
-rw-r--r--frontend/src/lib/components/Titlebar.svelte32
-rw-r--r--frontend/src/lib/containers/Cardlets.svelte11
-rw-r--r--frontend/src/lib/containers/Cards.svelte8
-rw-r--r--frontend/src/lib/containers/Carousel.svelte15
-rw-r--r--frontend/src/lib/containers/Column.svelte3
-rw-r--r--frontend/src/lib/containers/Grid.svelte23
-rw-r--r--frontend/src/lib/dialogs/AddArtist.svelte30
-rw-r--r--frontend/src/lib/dialogs/AddCharacter.svelte30
-rw-r--r--frontend/src/lib/dialogs/AddCircle.svelte30
-rw-r--r--frontend/src/lib/dialogs/AddNamespace.svelte30
-rw-r--r--frontend/src/lib/dialogs/AddTag.svelte30
-rw-r--r--frontend/src/lib/dialogs/AddWorld.svelte30
-rw-r--r--frontend/src/lib/dialogs/ConfirmDeletion.svelte51
-rw-r--r--frontend/src/lib/dialogs/EditArtist.svelte46
-rw-r--r--frontend/src/lib/dialogs/EditCharacter.svelte46
-rw-r--r--frontend/src/lib/dialogs/EditCircle.svelte46
-rw-r--r--frontend/src/lib/dialogs/EditNamespace.svelte46
-rw-r--r--frontend/src/lib/dialogs/EditTag.svelte44
-rw-r--r--frontend/src/lib/dialogs/EditWorld.svelte46
-rw-r--r--frontend/src/lib/dialogs/UpdateComics.svelte96
-rw-r--r--frontend/src/lib/dialogs/UpdateTags.svelte45
-rw-r--r--frontend/src/lib/dialogs/components/UpdateModeSelector.svelte24
-rw-r--r--frontend/src/lib/filter/ComicFilterForm.svelte48
-rw-r--r--frontend/src/lib/filter/TagFilterForm.svelte31
-rw-r--r--frontend/src/lib/filter/components/ComicFilterGroup.svelte27
-rw-r--r--frontend/src/lib/filter/components/Filter.svelte77
-rw-r--r--frontend/src/lib/filter/components/FilterForm.svelte47
-rw-r--r--frontend/src/lib/filter/components/TagFilterGroup.svelte14
-rw-r--r--frontend/src/lib/forms/ArtistForm.svelte25
-rw-r--r--frontend/src/lib/forms/CharacterForm.svelte25
-rw-r--r--frontend/src/lib/forms/CircleForm.svelte25
-rw-r--r--frontend/src/lib/forms/ComicForm.svelte100
-rw-r--r--frontend/src/lib/forms/NamespaceForm.svelte28
-rw-r--r--frontend/src/lib/forms/TagForm.svelte42
-rw-r--r--frontend/src/lib/forms/WorldForm.svelte25
-rw-r--r--frontend/src/lib/gallery/Gallery.svelte42
-rw-r--r--frontend/src/lib/gallery/GalleryPage.svelte93
-rw-r--r--frontend/src/lib/icons/Bookmark.svelte10
-rw-r--r--frontend/src/lib/icons/Female.svelte1
-rw-r--r--frontend/src/lib/icons/Location.svelte1
-rw-r--r--frontend/src/lib/icons/Male.svelte1
-rw-r--r--frontend/src/lib/icons/Organized.svelte21
-rw-r--r--frontend/src/lib/icons/Star.svelte25
-rw-r--r--frontend/src/lib/icons/Transgender.svelte1
-rw-r--r--frontend/src/lib/navigation/Link.svelte20
-rw-r--r--frontend/src/lib/navigation/Navigation.svelte5
-rw-r--r--frontend/src/lib/pagination/Pagination.svelte45
-rw-r--r--frontend/src/lib/pagination/Target.svelte21
-rw-r--r--frontend/src/lib/pills/AssociationPill.svelte30
-rw-r--r--frontend/src/lib/pills/ComicPills.svelte37
-rw-r--r--frontend/src/lib/pills/Pill.svelte40
-rw-r--r--frontend/src/lib/pills/TagPill.svelte40
-rw-r--r--frontend/src/lib/reader/PageView.svelte67
-rw-r--r--frontend/src/lib/reader/Reader.svelte39
-rw-r--r--frontend/src/lib/reader/ReaderPage.svelte24
-rw-r--r--frontend/src/lib/reader/components/CloseReaderButton.svelte19
-rw-r--r--frontend/src/lib/reader/components/ReaderMenuButton.svelte16
-rw-r--r--frontend/src/lib/scraper/ComicScrapeForm.svelte138
-rw-r--r--frontend/src/lib/scraper/components/SelectorButton.svelte22
-rw-r--r--frontend/src/lib/scraper/components/SelectorGroup.svelte35
-rw-r--r--frontend/src/lib/scraper/components/SelectorItem.svelte24
-rw-r--r--frontend/src/lib/selection/Selectable.svelte24
-rw-r--r--frontend/src/lib/selection/SelectionOverlay.svelte34
-rw-r--r--frontend/src/lib/tabs/AddOverlay.svelte36
-rw-r--r--frontend/src/lib/tabs/ArchiveDelete.svelte42
-rw-r--r--frontend/src/lib/tabs/ArchiveDetails.svelte50
-rw-r--r--frontend/src/lib/tabs/ArchiveEdit.svelte68
-rw-r--r--frontend/src/lib/tabs/ComicDelete.svelte34
-rw-r--r--frontend/src/lib/tabs/ComicDetails.svelte121
-rw-r--r--frontend/src/lib/tabs/DetailsHeader.svelte11
-rw-r--r--frontend/src/lib/tabs/DetailsSection.svelte10
-rw-r--r--frontend/src/lib/tabs/Tab.svelte14
-rw-r--r--frontend/src/lib/tabs/Tabs.svelte40
-rw-r--r--frontend/src/lib/toolbar/DeleteSelection.svelte26
-rw-r--r--frontend/src/lib/toolbar/EditSelection.svelte29
-rw-r--r--frontend/src/lib/toolbar/FilterBookmarked.svelte24
-rw-r--r--frontend/src/lib/toolbar/FilterFavourites.svelte24
-rw-r--r--frontend/src/lib/toolbar/FilterOrganized.svelte30
-rw-r--r--frontend/src/lib/toolbar/MarkBookmark.svelte27
-rw-r--r--frontend/src/lib/toolbar/MarkFavourite.svelte27
-rw-r--r--frontend/src/lib/toolbar/MarkOrganized.svelte27
-rw-r--r--frontend/src/lib/toolbar/MarkSelection.svelte24
-rw-r--r--frontend/src/lib/toolbar/Search.svelte21
-rw-r--r--frontend/src/lib/toolbar/SelectItems.svelte19
-rw-r--r--frontend/src/lib/toolbar/SelectSort.svelte61
-rw-r--r--frontend/src/lib/toolbar/SelectionControls.svelte57
-rw-r--r--frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte40
-rw-r--r--frontend/src/lib/toolbar/Toolbar.svelte42
-rw-r--r--frontend/src/routes/+layout.svelte95
-rw-r--r--frontend/src/routes/+layout.ts1
-rw-r--r--frontend/src/routes/+page.svelte66
-rw-r--r--frontend/src/routes/archives/+page.svelte119
-rw-r--r--frontend/src/routes/archives/+page.ts12
-rw-r--r--frontend/src/routes/archives/[id]/+page.svelte99
-rw-r--r--frontend/src/routes/archives/[id]/+page.ts5
-rw-r--r--frontend/src/routes/artists/+page.svelte101
-rw-r--r--frontend/src/routes/artists/+page.ts12
-rw-r--r--frontend/src/routes/characters/+page.svelte101
-rw-r--r--frontend/src/routes/characters/+page.ts12
-rw-r--r--frontend/src/routes/circles/+page.svelte101
-rw-r--r--frontend/src/routes/circles/+page.ts12
-rw-r--r--frontend/src/routes/comics/+page.svelte116
-rw-r--r--frontend/src/routes/comics/+page.ts12
-rw-r--r--frontend/src/routes/comics/[id]/+page.svelte176
-rw-r--r--frontend/src/routes/comics/[id]/+page.ts5
-rw-r--r--frontend/src/routes/namespaces/+page.svelte101
-rw-r--r--frontend/src/routes/namespaces/+page.ts12
-rw-r--r--frontend/src/routes/tags/+page.svelte109
-rw-r--r--frontend/src/routes/tags/+page.ts12
-rw-r--r--frontend/src/routes/worlds/+page.svelte102
-rw-r--r--frontend/src/routes/worlds/+page.ts12
-rw-r--r--frontend/static/favicon.svg25
-rw-r--r--frontend/svelte.config.js44
-rw-r--r--frontend/tailwind.config.cjs7
-rw-r--r--frontend/tests/Reader.test.ts45
-rw-r--r--frontend/tests/Selection.test.ts183
-rw-r--r--frontend/tsconfig.json19
-rw-r--r--frontend/vite.config.ts11
-rw-r--r--poetry.lock1641
-rw-r--r--pyproject.toml61
-rw-r--r--scripts/dev.sh8
-rw-r--r--scripts/lint.sh4
-rw-r--r--src/hircine/__init__.py1
-rw-r--r--src/hircine/api/__init__.py28
-rw-r--r--src/hircine/api/filters.py347
-rw-r--r--src/hircine/api/inputs.py578
-rw-r--r--src/hircine/api/mutation/__init__.py69
-rw-r--r--src/hircine/api/mutation/resolvers.py217
-rw-r--r--src/hircine/api/query/__init__.py54
-rw-r--r--src/hircine/api/query/resolvers.py146
-rw-r--r--src/hircine/api/responses.py219
-rw-r--r--src/hircine/api/sort.py94
-rw-r--r--src/hircine/api/types.py337
-rw-r--r--src/hircine/app.py79
-rw-r--r--src/hircine/cli.py128
-rw-r--r--src/hircine/config.py38
-rw-r--r--src/hircine/db/__init__.py99
-rw-r--r--src/hircine/db/models.py379
-rw-r--r--src/hircine/db/ops.py200
-rw-r--r--src/hircine/enums.py244
-rw-r--r--src/hircine/migrations/alembic.ini37
-rw-r--r--src/hircine/migrations/env.py96
-rw-r--r--src/hircine/migrations/script.py.mako26
-rw-r--r--src/hircine/plugins/__init__.py49
-rw-r--r--src/hircine/plugins/scrapers/__init__.py0
-rw-r--r--src/hircine/plugins/scrapers/anchira.py101
-rw-r--r--src/hircine/plugins/scrapers/ehentai_api.py75
-rw-r--r--src/hircine/plugins/scrapers/gallery_dl.py54
-rw-r--r--src/hircine/plugins/scrapers/handlers/__init__.py0
-rw-r--r--src/hircine/plugins/scrapers/handlers/dynastyscans.py41
-rw-r--r--src/hircine/plugins/scrapers/handlers/e621.py81
-rw-r--r--src/hircine/plugins/scrapers/handlers/exhentai.py139
-rw-r--r--src/hircine/plugins/scrapers/handlers/mangadex.py54
-rw-r--r--src/hircine/scanner.py320
-rw-r--r--src/hircine/scraper/__init__.py108
-rw-r--r--src/hircine/scraper/types.py246
-rw-r--r--src/hircine/scraper/utils.py62
-rw-r--r--src/hircine/thumbnailer.py75
-rw-r--r--tests/api/test_archive.py388
-rw-r--r--tests/api/test_artist.py278
-rw-r--r--tests/api/test_character.py285
-rw-r--r--tests/api/test_circle.py278
-rw-r--r--tests/api/test_collection.py0
-rw-r--r--tests/api/test_comic.py1505
-rw-r--r--tests/api/test_comic_tag.py134
-rw-r--r--tests/api/test_db.py324
-rw-r--r--tests/api/test_filter.py521
-rw-r--r--tests/api/test_image.py16
-rw-r--r--tests/api/test_namespace.py291
-rw-r--r--tests/api/test_page.py39
-rw-r--r--tests/api/test_pagination.py61
-rw-r--r--tests/api/test_scraper_api.py395
-rw-r--r--tests/api/test_sort.py137
-rw-r--r--tests/api/test_tag.py441
-rw-r--r--tests/api/test_world.py278
-rw-r--r--tests/config/data/config.toml3
-rw-r--r--tests/conftest.py594
-rw-r--r--tests/plugins/test_plugins.py9
-rw-r--r--tests/scanner/data/contents/archive.zipbin0 -> 1284 bytes
-rw-r--r--tests/scanner/test_scanner.py311
-rw-r--r--tests/scrapers/test_scraper.py55
-rw-r--r--tests/scrapers/test_scraper_utils.py28
-rw-r--r--tests/scrapers/test_types.py131
-rw-r--r--tests/thumbnailer/data/example_palette.pngbin0 -> 703 bytes
-rw-r--r--tests/thumbnailer/data/example_rgb.pngbin0 -> 14362 bytes
-rw-r--r--tests/thumbnailer/test_thumbnailer.py74
282 files changed, 37736 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9e64c72
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/.coverage*
+/.doctrees
+/dist
+/src/hircine/static
+/instance
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b0f9809
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2022-2024 Wolfgang Müller
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..73b7aae
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,17 @@
+wheel: frontend docs
+ poetry build
+
+frontend:
+ npm -C frontend run build
+
+docs:
+ rm -rf src/hircine/static/help
+ poetry run sphinx-build -Wb html -j auto -d .doctrees/ docs/ src/hircine/static/help
+
+test:
+ poetry run pytest -qs -W ignore::DeprecationWarning
+
+coverage:
+ poetry run pytest -s --cov=hircine --cov-report=html
+
+.PHONY: wheel frontend docs test
diff --git a/docs/_examples/example_scraper.json b/docs/_examples/example_scraper.json
new file mode 100644
index 0000000..9efe126
--- /dev/null
+++ b/docs/_examples/example_scraper.json
@@ -0,0 +1,8 @@
+{
+ "title": "This is a Title",
+ "tags": {
+ "artists": ["Alan Smithee", "Noah Ward"],
+ "characters": ["A", "B", "C"],
+ "misc": ["horror", "sci-fi"]
+ }
+}
diff --git a/docs/_examples/example_scraper.py b/docs/_examples/example_scraper.py
new file mode 100644
index 0000000..d00c292
--- /dev/null
+++ b/docs/_examples/example_scraper.py
@@ -0,0 +1,37 @@
+import json
+
+from hircine.scraper import Scraper
+from hircine.scraper.types import Artist, Character, Tag, Title
+from hircine.scraper.utils import open_archive_file, parse_dict
+
+
+class MyScraper(Scraper):
+ name = "Example scraper"
+ source = "example"
+
+ def __init__(self, comic):
+ super().__init__(comic)
+
+ self.data = self.load()
+
+ if self.data:
+ self.is_available = True
+
+ def load(self):
+ try:
+ with open_archive_file(self.comic.archive, "metadata.json") as jif:
+ return json.load(jif)
+ except Exception:
+ return {}
+
+ def scrape(self):
+ parsers = {
+ "title": Title,
+ "tags": {
+ "artists": Artist,
+ "misc": Tag.from_string,
+ "characters": Character,
+ },
+ }
+
+ yield from parse_dict(parsers, self.data)
diff --git a/docs/_examples/example_transformer.py b/docs/_examples/example_transformer.py
new file mode 100644
index 0000000..6e443ae
--- /dev/null
+++ b/docs/_examples/example_transformer.py
@@ -0,0 +1,21 @@
+from hircine.plugins import transformer
+from hircine.scraper.types import Artist, Tag
+
+
+@transformer
+def transform(generator, info):
+ for item in generator:
+ # Ignore the "Drama" tag when scraping from mangadex
+ if info.source == "mangadex":
+ match item:
+ case Tag(tag="Drama"):
+ continue
+
+ # convert all Artist names to lowercase
+ match item:
+ case Artist(name):
+ yield Artist(name.lower())
+ continue
+
+ # other items are not modified
+ yield item
diff --git a/docs/_images/archive.jpg b/docs/_images/archive.jpg
new file mode 100644
index 0000000..3ea2310
--- /dev/null
+++ b/docs/_images/archive.jpg
Binary files differ
diff --git a/docs/_images/comic-edit.jpg b/docs/_images/comic-edit.jpg
new file mode 100644
index 0000000..cef6455
--- /dev/null
+++ b/docs/_images/comic-edit.jpg
Binary files differ
diff --git a/docs/_images/comics.jpg b/docs/_images/comics.jpg
new file mode 100644
index 0000000..5dd9c04
--- /dev/null
+++ b/docs/_images/comics.jpg
Binary files differ
diff --git a/docs/_images/filtering.jpg b/docs/_images/filtering.jpg
new file mode 100644
index 0000000..a61204d
--- /dev/null
+++ b/docs/_images/filtering.jpg
Binary files differ
diff --git a/docs/_images/scraper.jpg b/docs/_images/scraper.jpg
new file mode 100644
index 0000000..1da82a8
--- /dev/null
+++ b/docs/_images/scraper.jpg
Binary files differ
diff --git a/docs/_static/favicon.svg b/docs/_static/favicon.svg
new file mode 100644
index 0000000..6c7be45
--- /dev/null
+++ b/docs/_static/favicon.svg
@@ -0,0 +1,25 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64">
+ <defs>
+ <linearGradient id="b">
+ <stop offset=".261" stop-color="#d9825f"/>
+ <stop offset="1" stop-color="#f1d6b0"/>
+ </linearGradient>
+ <linearGradient id="a">
+ <stop offset="0" stop-color="#b46643"/>
+ <stop offset=".521" stop-color="#400d02"/>
+ </linearGradient>
+ <linearGradient xlink:href="#a" id="d" x1="6.023" x2="57.975" y1="31.667" y2="31.667" gradientUnits="userSpaceOnUse"/>
+ <linearGradient xlink:href="#a" id="e" x1="5.523" x2="58.48" y1="31.67" y2="31.67" gradientUnits="userSpaceOnUse"/>
+ <radialGradient xlink:href="#b" id="c" cx="35.966" cy="29.958" r="17.403" fx="35.966" fy="29.958" gradientTransform="matrix(.98534 0 0 .67716 .782 9.643)" gradientUnits="userSpaceOnUse"/>
+ <filter id="f" width="1.316" height="1.453" x="-.167" y="-.24" color-interpolation-filters="sRGB">
+ <feFlood flood-color="#b46643" flood-opacity=".498" result="flood"/>
+ <feGaussianBlur in="SourceGraphic" result="blur" stdDeviation="3"/>
+ <feOffset dx="-1" dy="-1" in="blur" result="offset"/>
+ <feComposite in="flood" in2="offset" operator="in" result="comp1"/>
+ <feComposite in="SourceGraphic" in2="comp1" result="comp2"/>
+ </filter>
+ </defs>
+ <path fill="url(#c)" d="M51.66 18.146c-2.207 0-23.763 6.417-28.092 8.362-3.382 1.52-4.51 3.09-4.495 6.252.016 3.116-3.929 3.866-.79 5.738 3.693 2.203 18.797 3.788 23.607 1.634 1.805-.809 4.193-2.037 5.309-2.733 2.642-1.647 5.336-8.207 5.926-14.434.452-4.77.436-4.82-1.464-4.82z" transform="translate(-5.778 -5.386) scale(1.1806)"/>
+ <path fill="url(#d)" stroke="url(#e)" d="M53.375 13.541c-1.724.004-23.937 8.24-28.492 10.564-3.433 1.753-4.371 2.931-7.09 8.909-1.287 2.828-3.65 6.58-5.252 8.334-4.408 4.826-6.518 7.542-6.518 8.392 0 .422 2.505-1.723 5.565-4.767 6.321-6.289 5.42-6.1 15.758-3.297 16.264 4.408 28.65-4.69 30.476-22.383.33-3.2.208-3.676-1.197-4.66-.86-.603-2.323-1.094-3.25-1.092Zm-1.467 4.662c1.626 0 1.639.047 1.252 4.656-.504 6.018-2.808 12.358-5.068 13.95-.954.672-2.997 1.859-4.541 2.64-4.114 2.082-12.428 2.03-15.586-.1-2.686-1.809-3.918-4.011-3.932-7.023-.013-3.056.952-4.574 3.846-6.043 3.703-1.88 22.14-8.08 24.03-8.08zM21.014 32.086l.42 2.055c.23 1.13.795 2.506 1.253 3.058.72.867.459 1.004-1.906 1.004h-2.74l1.486-3.058z" filter="url(#f)" transform="translate(-5.778 -5.386) scale(1.1806)"/>
+ <path fill="#360c03" d="M30.712 34.768c-1.46-1.157-2.165-2.45-2.165-3.973 0-2.867.69-3.258 6.233-3.543a20.459 20.459 0 0 0 8.539-2.328c5.15-2.693 9.132-2.536 9.447.372.574 5.307-6.13 10.22-14.774 10.828-4.394.308-5.421.117-7.28-1.356z"/>
+</svg>
diff --git a/docs/_static/logo.webp b/docs/_static/logo.webp
new file mode 100644
index 0000000..e41cbb0
--- /dev/null
+++ b/docs/_static/logo.webp
Binary files differ
diff --git a/docs/about.rst b/docs/about.rst
new file mode 100644
index 0000000..0ae7bbc
--- /dev/null
+++ b/docs/about.rst
@@ -0,0 +1,12 @@
+About
+=====
+
+**hircine** was designed and written by `Wolfgang Müller
+<https://oriole.systems>`_.
+
+Special thanks
+--------------
+
+- `Nate <https://www.deviantart.com/hasiruh>`_ for designing the lovely logo.
+- `nortti <https://ahti.space/~nortti>`_ for invaluable feedback, testing, and
+ patiently supporting my obsession with this project.
diff --git a/docs/advanced/api.rst b/docs/advanced/api.rst
new file mode 100644
index 0000000..61f6d01
--- /dev/null
+++ b/docs/advanced/api.rst
@@ -0,0 +1,14 @@
+GraphQL API & Versioning
+========================
+
+**hircine** exposes the `GraphQL <https://graphql.org>`_ endpoint on `/graphql
+</graphql>`_. When accessing this documentation on a running instance, clicking
+that link will open an interactive GraphQL IDE with a built-in documentation
+explorer.
+
+Versioning
+----------
+
+**hircine** uses `Semantic Versioning <https://semver.org>`_. The *public API*
+consists of both the frontend (command-line interface and web application) and
+the backend (GraphQL API and plugin infrastructure).
diff --git a/docs/advanced/hashing.rst b/docs/advanced/hashing.rst
new file mode 100644
index 0000000..90da3db
--- /dev/null
+++ b/docs/advanced/hashing.rst
@@ -0,0 +1,15 @@
+Hashing
+=======
+
+**hircine** uses the `BLAKE3 cryptographic hash function
+<https://github.com/BLAKE3-team/BLAKE3>`_ to compute hashes of archives and all
+its contained files.
+
+Whilst the latter files are hashed directly (i.e. their data is passed directly
+to the hash function), the *ZIP* archives are not. Instead, **hircine**
+calculates the hash of an archive by concatenating the hashes of *all* files
+within it in archive order.
+
+This means that changes to the archive files themselves will invalidate an
+archive's hash, but changes to *ZIP* compression levels or other basic metadata
+will not.
diff --git a/docs/advanced/image-processing.rst b/docs/advanced/image-processing.rst
new file mode 100644
index 0000000..dba71d0
--- /dev/null
+++ b/docs/advanced/image-processing.rst
@@ -0,0 +1,19 @@
+Image processing
+================
+
+Images are processed by the `Python Imaging Library (Pillow)
+<https://pillow.readthedocs.io/en/stable/index.html>`_ which supports a `wide
+variety
+<https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html>`_ of
+image formats. Processed images are stored in the :ref:`overview-object-store`
+using the `webp <https://developers.google.com/speed/webp>`_ format. Images are
+resampled using a `Lanczos filter
+<https://pillow.readthedocs.io/en/stable/handbook/concepts.html#PIL.Image.Resampling.LANCZOS>`_.
+
+Scaling
+-------
+
+By default, images are scaled to fit within the bounds of ``4200x2000`` pixels
+for display in the reader, and ``1680x800`` pixels for use as thumbnails. These
+values are optimized for larger displays and may be :ref:`changed in the
+configuration file <cfg-scale>` if you do not require such high resolutions.
diff --git a/docs/advanced/import-process.rst b/docs/advanced/import-process.rst
new file mode 100644
index 0000000..b33a927
--- /dev/null
+++ b/docs/advanced/import-process.rst
@@ -0,0 +1,53 @@
+Import process
+==============
+
+When importing a new archive, **hircine** will do the following:
+
+1. Calculate the hash of the archive its contents. See :doc:`/advanced/hashing`.
+2. Process each image for display in the application. See :doc:`/advanced/image-processing`.
+3. Collate all images in the archive in "natural" sort order. See `natsort
+ <https://github.com/SethMMorton/natsort?tab=readme-ov-file#quick-description>`_.
+4. Add the images and archive to the database.
+
+Status display
+--------------
+
+For each new or updated archive, **hircine** will report its status on the
+command line:
+
++---------+--------------------------------------------------------------------+
+| Symbol | Meaning |
++=========+====================================================================+
+| ``[+]`` | This is a new archive. |
++---------+--------------------------------------------------------------------+
+| ``[*]`` | This archive was updated (i.e. its modified time has changed). |
++---------+--------------------------------------------------------------------+
+| ``[>]`` | This archive has been renamed. |
++---------+--------------------------------------------------------------------+
+| ``[I]`` | This archive was ignored as it is a duplicate. |
++---------+--------------------------------------------------------------------+
+| ``[!]`` | This archive conflicts with another archive. |
++---------+--------------------------------------------------------------------+
+| ``[?]`` | This archive is referenced in the database but could not be found. |
++---------+--------------------------------------------------------------------+
+| ``[~]`` | The images from this archive were reprocessed. |
++---------+--------------------------------------------------------------------+
+
+
+
+Duplicates
+----------
+
+**hircine** will not add duplicate archives to its database. If two or more
+archives have the same content (i.e. their hashes match), a warning will be
+issued.
+
+Conflicts
+---------
+
+A conflict occurs when an archive hash in the database no longer matches the
+hash of the archive file on disk. **hircine** will take no further action other
+than printing an error message including the path of the archive and both
+hashes; it is up to the user to reconcile conflicts. An easy (but destructive)
+solution is to delete the affected archive in the web application and
+reimport it.
diff --git a/docs/advanced/index.rst b/docs/advanced/index.rst
new file mode 100644
index 0000000..3300030
--- /dev/null
+++ b/docs/advanced/index.rst
@@ -0,0 +1,13 @@
+Advanced topics
+===============
+
+This section describes advanced topics that are not crucial for usage of
+**hircine**, but may nevertheless be of interest.
+
+.. toctree::
+ :maxdepth: 1
+
+ import-process
+ hashing
+ image-processing
+ api
diff --git a/docs/changelog.rst b/docs/changelog.rst
new file mode 100644
index 0000000..a072d67
--- /dev/null
+++ b/docs/changelog.rst
@@ -0,0 +1,7 @@
+Changelog
+=========
+
+0.1.0 "Satanic Satyr"
+---------------------
+
+- Initial release.
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..53eac6b
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,31 @@
+import importlib.metadata
+
+_META = importlib.metadata.metadata("hircine")
+
+author = _META["Author"]
+project = _META["Name"]
+release = _META["Version"]
+version = release
+copyright = "2022-2024, Wolfgang Müller"
+
+extensions = [
+ "sphinx.ext.autodoc",
+ "sphinx.ext.intersphinx",
+]
+templates_path = ["_templates"]
+exclude_patterns = []
+
+html_theme = "furo"
+html_static_path = ["_static"]
+html_favicon = "_static/favicon.svg"
+html_logo = "_static/logo.webp"
+html_show_copyright = False
+html_copy_source = False
+
+autodoc_typehints_format = "short"
+autosectionlabel_prefix_document = True
+
+intersphinx_mapping = {
+ "python": ("https://docs.python.org/3", None),
+ "packaging": ("https://packaging.python.org/en/latest", None),
+}
diff --git a/docs/glossary.rst b/docs/glossary.rst
new file mode 100644
index 0000000..303ed6e
--- /dev/null
+++ b/docs/glossary.rst
@@ -0,0 +1,13 @@
+Glossary
+========
+
+.. glossary::
+ :sorted:
+
+ qualified tag
+ A specific pairing of a namespace and a tag. See :ref:`overview-tags`
+ for more.
+
+ object store
+ The content-addressable filesystem for processed image files. See
+ :ref:`overview-object-store` for more.
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..ac62829
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,38 @@
+Intro
+=====
+
+**hircine** [#f1]_ is a web-based comic organizer written in `Python
+<https://www.python.org>`_ and `SvelteKit <https://kit.svelte.dev>`_.
+
+.. image:: /_images/comics.jpg
+ :align: center
+ :alt: An overview of comics
+
+It imports image files from *ZIP* archives, supports a wide range of metadata
+including custom tags and namespaces, comes with a powerful filtering system,
+and keeps its own object store of processed and deduplicated image files for
+easy and fast access via the browser. This is done without ever modifying any
+source archives so you can keep your collection in pristine condition.
+
+**hircine** contains a simple reader interface that supports per-comic page
+layouts and includes an extensible scraper framework that allows easy importing
+of metadata from local files or online sources.
+
+.. toctree::
+ :maxdepth: 2
+
+ overview
+ setup
+ usage/index
+ advanced/index
+ plugins/index
+ glossary
+ changelog
+ about
+ git repository <https://git.oriole.systems/hircine/>
+
+|
+
+.. rubric:: Footnotes
+
+.. [#f1] Oxford English Dictionary, 2nd Edition: (ˈhɜːsaɪn) [ad. L. hircīnus (hirquīnus) of a goat; having a goatish smell.]
diff --git a/docs/overview.rst b/docs/overview.rst
new file mode 100644
index 0000000..8eb327f
--- /dev/null
+++ b/docs/overview.rst
@@ -0,0 +1,124 @@
+Overview
+========
+
+Before setting up **hircine** it is important to understand its choice of
+technologies as well as its design goals and core concepts. This will allow you
+to make an informed decision on whether or not it is the right solution for
+you.
+
+.. _overview-technologies:
+
+Technologies
+------------
+
+**hircine** consists of two core parts: a `Python <https://www.python.org>`_
+backend that exposes a `GraphQL <https://graphql.org>`_ API and a `SvelteKit
+<https://kit.svelte.dev>`_ frontend written in `TypeScript
+<https://www.typescriptlang.org>`_ that communicates with it. Data is stored in
+an `SQLite <https://www.sqlite.org/index.html>`_ database.
+
+Image processing is done using `Pillow
+<https://pillow.readthedocs.io/en/stable>`_. hircine uses the `BLAKE3 Python
+bindings <https://pypi.org/project/blake3>`_ to build hashes used for file
+identification and deduplication.
+
+The web application is designed to be the canonical user interface, but any
+program may freely use the :doc:`provided API </advanced/api>`.
+
+.. _overview-goals:
+
+Design goals
+------------
+
+**hircine** is designed to organize a large personal collection of comics and
+make it easily queryable. It provides a set of concepts and tools that allows
+categorization and classification of comics and comes with a powerful filtering
+system.
+
+Whilst **hircine** does have basic support for categories of metadata such as
+artists or characters, it is mostly concerned with classifying the *content* of
+a comic through user-defined namespaces and tags. The primary goal is to find
+something you are in the mood to read, and not to provide a full archival
+system where you keep track of the minute details of a comic's publication or
+creation.
+
+As such, it is designed to tackle large and diverse collections of comics,
+manga, or doujin where the story, art, and characters are the primary appeal.
+
+.. _overview-concepts:
+
+Core concepts
+-------------
+
+Archives
+^^^^^^^^
+
+**hircine** reads image files from *ZIP* archives. Loose image files are
+**not** supported. Usually an archive contains a single comic (or chapter), but
+it may also contain a whole volume - once imported, an archive can be split
+into multiple comics in the web application.
+
+Comics
+^^^^^^
+
+A comic is a logical grouping of pages (image files) that can be annotated with
+metadata. Most of the functionality in the web application pertains to
+organizing, querying, and reading comics.
+
+Comics are created by collating a sequence of pages from a single archive. This
+sequence is exclusive, meaning that a page may only ever be allocated to a
+single comic. Not all pages of an archive have to be used.
+
+Metadata
+^^^^^^^^
+
+A fair chunk of comic metadata is self-explanatory. For example, a comic may be
+annotated with the date of its publication, the language it is written in, or
+what kind of censorship is in use. There also exist a number of well-defined
+categories of metadata that are managed by the user:
+
++------------+-------------------------------------------------------------------------+
+| Category | Description |
++============+=========================================================================+
+| Artists | A person involved in the creating of a comic. |
++------------+-------------------------------------------------------------------------+
+| Circles | A group of people involved in the publishing or translation of a comic. |
++------------+-------------------------------------------------------------------------+
+| Characters | A fictional character portrayed in a comic. |
++------------+-------------------------------------------------------------------------+
+| Worlds | A fictional world portrayed in a comic. |
++------------+-------------------------------------------------------------------------+
+
+.. _overview-tags:
+
+Namespaces & Tags
+^^^^^^^^^^^^^^^^^
+
+Alongside those well-defined categories, **hircine** supports user-defined
+namespaces and tags. The latter is a familiar concept: a tag is a simple piece
+of information that is attached to an object of interest.
+
+Namespaces enhance that concept by introducing a context in which the tag is
+placed. Let's say we want to keep track of the gender of a story's love
+interest. We may solve this using tags alone, like ``male love interest`` or
+``female love interest``, but this is quite unwieldy. Namespaces instead allow
+us to create a ``male`` and ``female`` namespace and a single tag ``love
+interest`` which can then be combined with either namespace.
+
+**hircine** requires the use of namespaces when tagging comics. A tag cannot be
+applied to a comic unless it is paired with a namespace. Such a pairing is
+called a *qualified tag*. When filtering, either the namespace or tag is
+optional: you may decide to exclude comics with any ``love interest``, or
+filter for comics that only have tags in the ``female`` namespace.
+
+.. _overview-object-store:
+
+Object store
+^^^^^^^^^^^^
+
+**hircine** keeps all processed images files in a a `content-addressable
+filesystem <https://en.wikipedia.org/wiki/Content-addressable_storage>`_ called
+the object store. The purpose of the store is twofold: Firstly, if multiple
+archives contain the same image, it only needs to be stored once in the object
+store. Secondly, the object store allows the application to used without having
+to serve potentially large image files.
diff --git a/docs/plugins/builtin.rst b/docs/plugins/builtin.rst
new file mode 100644
index 0000000..61d531f
--- /dev/null
+++ b/docs/plugins/builtin.rst
@@ -0,0 +1,16 @@
+Built-in plugins
+================
+
+**hircine** comes with a number of plugins already built-in. This page serves
+as a quick reference for users and developers alike.
+
+.. _builtin-scrapers:
+
+Scrapers
+--------
+
+.. autoclass:: hircine.plugins.scrapers.gallery_dl.GalleryDLScraper()
+
+.. autoclass:: hircine.plugins.scrapers.ehentai_api.EHentaiAPIScraper()
+
+.. autoclass:: hircine.plugins.scrapers.anchira.AnchiraYamlScraper()
diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst
new file mode 100644
index 0000000..2263fa2
--- /dev/null
+++ b/docs/plugins/index.rst
@@ -0,0 +1,17 @@
+Plugins
+=======
+
+Plugins are `Python <https://www.python.org>`_ programs that use **hircine**'s
+plugin architecture to customize or enhance the behaviour of certain parts of
+the application.
+
+There are two types of plugins. **Scrapers** read and report metadata from
+arbitrary sources and **Transformers** may modify that metadata freely before
+it is shown in the :ref:`scraper-interface`. As such, transformers cater to the
+needs of a specific user (e.g. to ignore certain pieces of metadata).
+
+.. toctree::
+ :maxdepth: 1
+
+ builtin
+ writing/index
diff --git a/docs/plugins/writing/index.rst b/docs/plugins/writing/index.rst
new file mode 100644
index 0000000..42afebd
--- /dev/null
+++ b/docs/plugins/writing/index.rst
@@ -0,0 +1,20 @@
+Writing plugins
+===============
+
+Before writing plugins, please familiarize yourself with the basics of the
+:ref:`Python programming language <python:tutorial-index>`. It is also
+recommended to read the :doc:`packaging:overview`. **hircine** discovers
+plugins via :ref:`package metadata <packaging:plugin-entry-points>`, so it is
+also useful to have a basic understanding of the :ref:`packaging:entry-points`.
+
+The plugin examples on the following pages are a good place to start once you
+are ready. You may also have a look at the `source code
+<https://git.oriole.systems/hircine/tree/src/hircine/plugins/scrapers>`_ for
+the built-in scrapers.
+
+.. toctree::
+ :maxdepth: 1
+
+ scrapers
+ transformers
+ reference
diff --git a/docs/plugins/writing/reference.rst b/docs/plugins/writing/reference.rst
new file mode 100644
index 0000000..1be281e
--- /dev/null
+++ b/docs/plugins/writing/reference.rst
@@ -0,0 +1,51 @@
+Plugin API Reference
+====================
+
+.. _scraped-data:
+
+Scraped Data
+------------
+
+.. automodule:: hircine.scraper.types
+ :members:
+
+API Data
+--------
+
+.. autoclass:: hircine.api.types.FullComic
+ :members:
+ :inherited-members:
+ :undoc-members:
+ :exclude-members: cover
+
+.. autoclass:: hircine.api.types.Archive
+ :members:
+ :undoc-members:
+ :exclude-members: cover
+
+Enums
+-----
+
+.. autoclass:: hircine.enums.Category()
+ :members:
+ :undoc-members:
+
+.. autoclass:: hircine.enums.Censorship()
+ :members:
+ :undoc-members:
+
+.. autoclass:: hircine.enums.Direction()
+ :members:
+ :undoc-members:
+
+.. autoclass:: hircine.enums.Language()
+ :members:
+ :undoc-members:
+
+.. autoclass:: hircine.enums.Layout()
+ :members:
+ :undoc-members:
+
+.. autoclass:: hircine.enums.Rating()
+ :members:
+ :undoc-members:
diff --git a/docs/plugins/writing/scrapers.rst b/docs/plugins/writing/scrapers.rst
new file mode 100644
index 0000000..258d3a8
--- /dev/null
+++ b/docs/plugins/writing/scrapers.rst
@@ -0,0 +1,48 @@
+Scrapers
+========
+
+A scraper extends the abstract :class:`~hircine.scraper.Scraper` class and
+implements its :meth:`~hircine.scraper.Scraper.scrape` method. The latter is a
+generator function yielding :ref:`scraped-data`.
+
+.. autoclass:: hircine.scraper.Scraper
+ :members:
+ :special-members: __init__
+
+Exceptions
+----------
+
+A scraper may raise two kinds of exceptions:
+
+.. autoexception:: hircine.scraper.ScrapeWarning
+
+.. autoexception:: hircine.scraper.ScrapeError
+
+Utility functions
+-----------------
+
+.. automodule:: hircine.scraper.utils
+ :members:
+
+Registering a scraper
+---------------------
+
+To register your class as a scraper, place it into the ``hircine.scraper``
+:ref:`entry point group <packaging:entry-points>`. For example, put the
+following in a ``pyproject.toml`` file:
+
+.. code-block:: toml
+
+ [project.entry-points.'hircine.scraper']
+ my_scraper = 'myscraper.MyScraper'
+
+Example
+-------
+
+.. literalinclude:: /_examples/example_scraper.py
+ :language: python
+
+The scraper above will scrape a JSON file with the following structure:
+
+.. literalinclude:: /_examples/example_scraper.json
+ :language: json
diff --git a/docs/plugins/writing/transformers.rst b/docs/plugins/writing/transformers.rst
new file mode 100644
index 0000000..045058d
--- /dev/null
+++ b/docs/plugins/writing/transformers.rst
@@ -0,0 +1,31 @@
+Transformers
+============
+
+**hircine** supports modification of scraper results by the use of
+transformers. Transformers are functions that hook into the scraping process
+and may freely modify any :ref:`scraped-data` before it is shown to the user.
+
+A transformer is specified by decorating a generator function with the
+:func:`~hircine.plugins.transformer` decorator.
+
+.. autodecorator:: hircine.plugins.transformer
+
+.. autoclass:: hircine.scraper.ScraperInfo
+
+Registering transformers
+------------------------
+
+To register transformers, place them into a module in the
+``hircine.transformer`` :ref:`entry point group <packaging:entry-points>`. For
+example, put the following in a ``pyproject.toml`` file:
+
+.. code-block:: toml
+
+ [project.entry-points.'hircine.transformer']
+ my_transformers = 'mytransformers.transformers'
+
+Example
+-------
+
+.. literalinclude:: /_examples/example_transformer.py
+ :language: python
diff --git a/docs/setup.rst b/docs/setup.rst
new file mode 100644
index 0000000..ad2123e
--- /dev/null
+++ b/docs/setup.rst
@@ -0,0 +1,113 @@
+Setup
+=====
+
+Requirements
+------------
+
+- `Python 3.12 <https://www.python.org>`_ or newer. It is likely that your
+ system already comes with this. Otherwise, refer to the `Python Beginners
+ Guide <https://wiki.python.org/moin/BeginnersGuide/Download>`_.
+
+- A modern browser. **hircine** is built to target `ES2022
+ <https://262.ecma-international.org/13.0>`_ and should run on all common
+ browsers at the time of writing. See `this support table
+ <https://caniuse.com/?feats=mdn-javascript_builtins_array_at,mdn-javascript_builtins_regexp_hasindices,mdn-javascript_builtins_object_hasown,mdn-javascript_builtins_error_cause,mdn-javascript_operators_await_top_level,mdn-javascript_classes_private_class_fields,mdn-javascript_classes_private_class_methods,mdn-javascript_classes_static_class_fields,mdn-javascript_classes_static_initialization_blocks>`_
+ for a detailed breakdown. The web interface was successfully tested on the
+ following systems and browsers:
+
+ - Linux 6.7.6: Firefox 123.0
+ - Windows 10 Pro 22H2: Edge 122.0.2365.59, Firefox 123.0
+ - Windows 11 Pro 23H2: Edge 122.0.2365.59, Firefox 123.0
+
+**hircine** is designed to be hosted on Linux systems but may be set up for
+Windows as well. Keep in mind that some utilities (e.g. ``gunicorn``) are not
+available for Windows.
+
+Installation
+------------
+
+**hircine** should be installed in a :ref:`virtual environment
+<packaging:creating and using virtual environments>`:
+
+.. code-block:: console
+
+ $ python -m venv <VENVDIR>
+ $ source <VENVDIR>/bin/activate
+
+.. note::
+
+ ``VENVDIR`` should only ever contain program files and should not be the
+ directory you choose for the database in the next step.
+
+ For example, ``~/.local/share/hircine`` is a sensible setting for
+ ``VENVDIR``.
+
+Once the environment is set up, download the `latest wheel
+<https://hircine.oriole.systems/dist/hircine-0.1.0-py3-none-any.whl>`_ and
+install it using `pip <https://pip.pypa.io/en/stable/>`_:
+
+.. code-block:: console
+
+ (.venv) $ python -m pip install <WHEEL>
+
+Now the ``hircine`` command is available from within your shell:
+
+.. code-block:: console
+
+ (.venv) $ hircine version
+ hircine 0.1.0 "Satanic Satyr"
+
+.. important::
+
+ Outside of this document it is assumed that the virtual environment is
+ activated and that the ``hircine`` command is present.
+
+Initializing the database
+-------------------------
+
+Next, navigate to where you want to store the database and initialize it:
+
+.. code-block:: console
+
+ (.venv) $ cd <DIR>
+ (.venv) $ hircine init
+
+This will create the following structure:
+
++------------+------------------------------------------------------------------------+
+| Item | Description |
++============+========================================================================+
+| hircine.db | the SQLite database |
++------------+------------------------------------------------------------------------+
+| content/ | the directory containing your archives (may be nested arbitrarily) |
++------------+------------------------------------------------------------------------+
+| objects/ | the :term:`object store` for processed images |
++------------+------------------------------------------------------------------------+
+| backups/ | backups of the SQLite database |
++------------+------------------------------------------------------------------------+
+
+.. tip::
+
+ By default, the command-line interface and the web application will always
+ look for the database in the current directory. Whilst this behaviour cannot
+ be changed when launching the web application, you may direct the
+ command-line program to a different directory using ``-C <DIR>``.
+
+ If ``-C <DIR>`` is given on the command line, it must appear before any
+ sub-command (``import``, etc.)
+
+Starting the web application
+----------------------------
+
+To serve the web application, you need a compatible ASGI server. We recommend
+`gunicorn <https://gunicorn.org>`_. The endpoint for the web application is the
+``app()`` factory in ``hircine.app``:
+
+.. code-block:: console
+
+ (.venv) $ python -m pip install gunicorn
+ (.venv) $ gunicorn -k uvicorn.workers.UvicornWorker --bind localhost:8000 "hircine.app:app()"
+
+Now you can point your browser to http://localhost:8000 to open the web
+application. To stop it, simply terminate ``gunicorn`` or the ASGI server of
+your choice.
diff --git a/docs/usage/admin.rst b/docs/usage/admin.rst
new file mode 100644
index 0000000..5fe2e90
--- /dev/null
+++ b/docs/usage/admin.rst
@@ -0,0 +1,58 @@
+Administrative tasks
+====================
+
+Administrative tasks are handled by the command-line interface. To get a quick
+overview of available commands, run:
+
+.. code-block:: console
+
+ $ hircine -h
+
+Updating the application
+------------------------
+
+To update **hircine**, download the newest wheel and install it in the virtual
+environment:
+
+.. code-block:: console
+
+ $ source <VENVDIR>/bin/activate
+ (.venv) $ python -m pip install <WHEEL>
+
+Running database migrations
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+After the update it might be required to bring the database up-to-date. To do
+this, run:
+
+.. code-block:: console
+
+ $ hircine migrate
+
+A backup will be created automatically.
+
+Backing up the database
+-----------------------
+
+To save a current backup of the database into the ``backup/`` directory, run:
+
+.. code-block:: console
+
+ $ hircine backup
+
+To restore a previously saved backup, stop **hircine** and simply replace the
+``hircine.db`` file with the backup.
+
+Optimizing the database
+-----------------------
+
+To optimize the SQLite database, run:
+
+.. code-block:: console
+
+ $ hircine vacuum
+
+This is generally only recommended after deleting a large amount of data. Refer
+to the `SQLite documentation
+<https://www.sqlite.org/lang_vacuum.html#description>`_ for details on this
+process.
diff --git a/docs/usage/configuration.rst b/docs/usage/configuration.rst
new file mode 100644
index 0000000..e0f3669
--- /dev/null
+++ b/docs/usage/configuration.rst
@@ -0,0 +1,41 @@
+Configuration
+=============
+
+**hircine** looks for an optional configuration file named ``hircine.ini`` in
+its regular directory structure. Refer to Python's :mod:`configparser` module
+for details on its format.
+
+Sections
+--------
+
+.. _cfg-scale:
+
+import.scale
+^^^^^^^^^^^^
+
+This section is split into two subsections, ``full`` and ``thumb``. The former
+controls scaling for images displayed in the reader and the latter controls
+scaling for thumbnails. See :doc:`/advanced/image-processing`.
+
+The ``width`` and ``height`` settings in each subsection control the maximum
+pixel dimensions for the processed image. They are given as integers and must
+be greater than zero. The defaults are as follows:
+
+.. code-block:: ini
+
+ [import.scale.full]
+ width = 4200
+ height = 2000
+
+ [import.scale.thumb]
+ width = 1680
+ height = 800
+
+.. important::
+
+ Changes to these settings will only apply to newly processed image files. If
+ you want to reprocess your entire collection, run:
+
+ .. code-block:: console
+
+ $ hircine import -r
diff --git a/docs/usage/filtering.rst b/docs/usage/filtering.rst
new file mode 100644
index 0000000..05066fb
--- /dev/null
+++ b/docs/usage/filtering.rst
@@ -0,0 +1,45 @@
+Filtering
+=========
+
+A filter is a combination of predicates that have to be satisfied. **hircine**
+provides a filter interface that allows using almost any type of metadata as a
+predicate and can therefore build complex and expressive queries.
+
+.. image:: /_images/filtering.jpg
+ :align: center
+ :alt: Filtering comics
+
+If given multiple types of predicates, all of them have to match. If one type
+of predicate contains multiple selections, the selected mode determines how
+they are combined:
+
++----------------------+--------------------------------------------------+
+| Mode | Behaviour |
++======================+==================================================+
+| ∀ ("for all") | matches if all given entities match |
++----------------------+--------------------------------------------------+
+| ∃ ("there exists") | matches if any of the given entities match |
++----------------------+--------------------------------------------------+
+| = ("exactly") | matches if entities are present exactly as given |
++----------------------+--------------------------------------------------+
+
+For example, in the picture above, a comic only matches if *all* of the
+following is true:
+
+- It is tagged as ``female:idol`` or ``:tights``.
+- It is tagged with the artist ``40hara``.
+- It is rated as *Questionable*.
+- It is uncensored.
+- It is in any language except Japanese.
+
+Matching empty sets
+-------------------
+
+Each type of predicate may also match on the ∅ empty set. If enabled, the
+predicate only matches if the object does not contain any entities of that
+type.
+
+.. important::
+
+ If matching on the empty set, make sure there are no selections present in
+ the corresponding dropdown menu, as otherwise the filter will never match.
diff --git a/docs/usage/getting-started.rst b/docs/usage/getting-started.rst
new file mode 100644
index 0000000..60a167e
--- /dev/null
+++ b/docs/usage/getting-started.rst
@@ -0,0 +1,121 @@
+Getting started
+===============
+
+
+Importing archives
+------------------
+
+Place your archives in the ``content/`` directory and import them using the
+command-line interface:
+
+.. code-block:: console
+
+ $ hircine import
+
+As **hircine** can identify an archive by its contents, subsequent import jobs
+won't import the same archive again. Archives may also be renamed or moved
+freely within the ``content/`` directory; the next import job will recognize
+these changes automatically. Symbolic links will **not** be followed.
+
+.. note::
+
+ For a more technical breakdown see :doc:`/advanced/import-process`.
+
+Adding comics
+-------------
+
+Once the import job has finished, navigate to the archive tab in the web
+application and hit the refresh button [#f1]_ to load the newly added archives.
+Next, navigate to the archive that contains the comic you want to add. You'll
+be presented with an tabbed pane on the left and all the pages in the archive
+on the right.
+
+.. image:: /_images/archive.jpg
+ :align: center
+ :alt: The archive view
+
+By default, the pane shows the *Details* tab. Here you can see basic
+information on the archive and, once added, a list of comics from this archive.
+Clicking on a page will open the reader interface.
+
+.. note::
+
+ Once a comic has been added, you may specify its reading direction and page
+ layout. However, in archive view, the reader always defaults to
+ left-to-right single-page. For more information on the reader interface, see
+ :doc:`/usage/reading`.
+
+To add a new comic, navigate to the *Edit* tab and click the button to enter
+selection mode. Now, instead of opening the reader interface, clicking on a
+page adds it to the current selection.
+
+.. tip::
+
+ When in selection mode you may use Ctrl+Click to access the reader interface
+ instead.
+
+Select all pages that you want to add to a new comic and click the *Add* button
+that has appeared. The newly added comic will appear below and the selection
+mode exits. You may now add further comics by the same process, or add pages to
+an already existing comic by clicking on the *Add* button that appears over
+each comic.
+
+.. tip::
+
+ Once all relevant pages from an archive have been allocated, you may mark
+ the archive as "organized". This will automatically happen once the last
+ page has been added to a comic.
+
+Next, click on the newly added comic.
+
+Editing comics
+--------------
+
+Comics use the same layout as archives - a tabbed pane on the left and comic
+pages on the right. Just like before, clicking a page will open the reader.
+Navigate to the *Edit* tab to start annotating the comic with metadata.
+
+.. image:: /_images/comic-edit.jpg
+ :align: center
+ :alt: Editing a comic
+
+The top section of the edit form controls basic information about a comic,
+whilst the bottom section contains dropdown fields for the user-managed
+metadata categories. The picture above shows two categories that already have a
+selection, but for you these lists will be empty.
+
+Adding metadata
+---------------
+
+To add new metadata entities, navigate to the respective tab in the web
+application and hit the *Add* button in the top right corner. Alternatively,
+new entities may be added *at any time* using :ref:`shortcuts
+<shortcut-metadata>`.
+
+Let's add a new character. Hit ``nh`` on your keyboard, type in the character's
+name in the modal that appears and confirm by clicking on *Save* or hitting the
+``Enter`` key. The character is now available in the *Characters* dropdown.
+Select it there and save your changes - the comic is now tagged with this
+character.
+
+Removing comic pages
+--------------------
+
+Comic pages may be removed in the *Edit* tab by entering selection mode,
+selecting the pages that should be removed, and clicking the *Remove selected
+pages* button or hitting ``Delete``. Removed pages will be available again for
+allocation in the archive.
+
+Setting the cover
+-----------------
+
+The cover of a comic or an archive may be set at any time outside of selection
+mode by control clicking a page.
+
+|
+
+.. rubric:: Footnotes
+
+.. [#f1] This is the only time you need to refresh something manually in the
+ web application. Care has been taken that all other elements update
+ automatically.
diff --git a/docs/usage/index.rst b/docs/usage/index.rst
new file mode 100644
index 0000000..a685ad8
--- /dev/null
+++ b/docs/usage/index.rst
@@ -0,0 +1,18 @@
+Usage
+=====
+
+**hircine** is mainly controlled via the web interface. The command-line
+interface handles various administrative tasks and, crucially, is used to
+import your archives into the application.
+
+.. toctree::
+ :maxdepth: 2
+
+ getting-started
+ namespaces
+ reading
+ filtering
+ scraping
+ shortcuts
+ admin
+ configuration
diff --git a/docs/usage/namespaces.rst b/docs/usage/namespaces.rst
new file mode 100644
index 0000000..4eacb14
--- /dev/null
+++ b/docs/usage/namespaces.rst
@@ -0,0 +1,43 @@
+Namespaces & Tags
+=================
+
+As :ref:`mentioned earlier <overview-tags>`, the use of namespaces is required
+when tagging comics. The user must also choose which namespaces a tag is
+applicable to. That means that in order for the :term:`qualified tag`
+``female:love interest`` to appear as a valid selection, the following must be
+true:
+
+1. The namespace ``female`` must exist.
+2. The tag ``love interest`` must exist.
+3. The tag ``love interest`` must specify ``female`` as a valid namespace.
+
+.. note::
+
+ Qualified tags that are subsequently rendered invalid will **not**
+ automatically be removed from comics. Whilst the qualified tag can no longer
+ be selected in the editing or filtering interface, it can still be removed
+ from the comic manually.
+
+Namespace sorting
+-----------------
+
+Namespaces may be configured with a "sort name". This name will be used when
+sorting lists of namespaces or qualified tags. If no such name is given, the
+namespace name is used for sorting instead.
+
+Tag descriptions
+----------------
+
+Tags may be annotated with a custom description that further explains how the
+tag should be used. This description will be displayed as a tooltip when
+hovering over a qualified tag.
+
+Qualified tag display
+---------------------
+
+When selecting from a list, qualified tags are displayed by combining the
+namespace and tag with a colon, like ``female:love interest``. In all other
+contexts, qualified tags are rendered as small pills that contain the tag name
+only. A small number of namespaces have special handling, however: *female*,
+*male*, *trans*, *mixed*, and *location*. These are displayed with a specific
+colour and icon.
diff --git a/docs/usage/reading.rst b/docs/usage/reading.rst
new file mode 100644
index 0000000..f48e4c6
--- /dev/null
+++ b/docs/usage/reading.rst
@@ -0,0 +1,50 @@
+Reading
+=======
+
+The reader interface may be accessed by clicking on any comic page. The reader
+overlay will then open with the selected page in view. There are two metadata
+settings that affect the reader, **Direction** and **Layout**. The former
+adjusts reading direction and the latter determines how many pages are rendered
+at once.
+
+Navigation
+----------
+
+The reader is controlled with the mouse or the keyboard. When displaying a
+single page, clicking on the left side of the image will advance left, whilst
+clicking on the right side of the image will advance right. Similarly, when
+displaying two pages, clicking on the left image will advance left while
+clicking on the right image will advance right.
+
+Additionally, the following keyboard shortcuts are available:
+
++-------------+----------------------------------------------------+
+| Key | Action |
++=============+====================================================+
+| Left Arrow | Advance left. |
++-------------+----------------------------------------------------+
+| Right Arrow | Advance right. |
++-------------+----------------------------------------------------+
+| Down Arrow | Advance to the next page in reading direction. |
++-------------+ |
+| Page Down | |
++-------------+ |
+| Space | |
++-------------+----------------------------------------------------+
+| Up Arrow | Advance to the previous page in reading direction. |
++-------------+ |
+| Page Up | |
++-------------+ |
+| Backspace | |
++-------------+----------------------------------------------------+
+
+Editing when reading
+--------------------
+
+A comic may be edited in the reader interface by opening the edit menu in the
+top left corner or hitting ``z``. Changes to **Direction** and **Layout** will
+be visible in the reader right away, but will only persist if saved. This makes
+it easy to preview changes.
+
+Pending changes will not be lost when closing the reader interface - they can
+still be modified and saved in the normal *Edit* tab.
diff --git a/docs/usage/scraping.rst b/docs/usage/scraping.rst
new file mode 100644
index 0000000..37bae98
--- /dev/null
+++ b/docs/usage/scraping.rst
@@ -0,0 +1,90 @@
+Scraping
+========
+
+**hircine** comes with a generic scraper interface that allows scraping comic
+metadata from virtually any source. A number of scrapers for common file
+formats and websites are :ref:`included <builtin-scrapers>` in the base
+installation. Refer to :doc:`/plugins/index` if you want to write your own.
+
+
+Scraper sources
+---------------
+
+Usually, a scraper will access a location on the web or a local file on your
+disk. The former may be an online API, whilst the latter may be a `JSON
+<https://www.json.org/json-en.html>`_ file like `gallery-dl
+<https://github.com/mikf/gallery-dl>`_'s ``info.json``.
+
+For local files, two locations are considered. The comic's archive may contain
+this file, or it may be stored as sidecar file alongside the archive in the
+``content/`` directory.
+
+.. _sidecar-files:
+
+Archive & sidecar files
+^^^^^^^^^^^^^^^^^^^^^^^
+
+Sidecar files need to be prefixed with the full name of the archive. For
+example, if a scraper accesses a file named ``info.json`` for an archive
+``Hoshiiro GirlDrop Comic Anthology.zip``, the following locations will be
+considered:
+
++----------+-------------------------------------------------------------+
+| Location | Name |
++==========+=============================================================+
+| Archive | ``info.json`` |
++----------+-------------------------------------------------------------+
+| Sidecar | ``content/Hoshiiro GirlDrop Comic Anthology.zip.info.json`` |
++----------+-------------------------------------------------------------+
+
+.. note::
+
+ If a file exists in both locations, the sidecar file is preferred.
+
+.. _scraper-interface:
+
+Scraper interface
+-----------------
+
+If a comic has scrapers available, they will be shown in the *Scrape* tab.
+Selecting the desired scraper and clicking on the *Scrape* button will start
+the scraping process.
+
+.. image:: /_images/scraper.jpg
+ :align: center
+ :alt: Scraping a comic.
+
+Once the scraper has returned results, they are shown in the pane below. Only
+results that differ from existing comic metadata will be displayed.
+
+Metadata that should not be kept may be deselected. For groups with a larger
+set of entries, the selection may be inverted to quickly deselect the whole
+group, or to only select a few entries. Pressing the *Merge* button will update
+the comic with the selected metadata.
+
+Options
+^^^^^^^
+
+By default, **hircine** does not automatically create missing metadata entries.
+This can be controlled using the *Create missing items* option.
+
+.. note::
+
+ Scrapers always return :term:`qualified tags <qualified tag>` (the namespace
+ is set to ``none`` if it could not be determined). When requested to create
+ a missing qualified tag, the namespace and tag will be created (if needed),
+ and the tag will be marked as applicable to the namespace.
+
+ A qualified tag is considered to be missing if any of the following apply:
+
+ 1. The namespace does not exist.
+ 2. The tag does not exist.
+ 3. The tag is not applicable to the namespace.
+
+
+Modifying scraper results
+-------------------------
+
+**hircine** allows modifying results that are returned by a scraper without
+having to change the scraper logic. Refer to the documentation on
+:doc:`/plugins/index` for more.
diff --git a/docs/usage/shortcuts.rst b/docs/usage/shortcuts.rst
new file mode 100644
index 0000000..b3f88bb
--- /dev/null
+++ b/docs/usage/shortcuts.rst
@@ -0,0 +1,114 @@
+Shortcuts
+=========
+
+**hircine** supports a number of shortcuts that are meant to streamline a few
+common actions. Shortcuts may be made up of multiple keys. In that case, type
+them in order.
+
+.. _shortcut-navigation:
+
+Navigation
+----------
+
+.. list-table::
+ :align: left
+ :header-rows: 1
+
+ * - Shortcut
+ - Navigates to
+ * - ``go``
+ - Home
+ * - ``gc``
+ - Comics
+ * - ``gn``
+ - Namespaces
+ * - ``gt``
+ - Tags
+ * - ``gh``
+ - Characters
+ * - ``gw``
+ - Worlds
+ * - ``ga``
+ - Artists
+ * - ``gi``
+ - Circles
+ * - ``gz``
+ - Archives
+ * - ``?``
+ - Help
+
+.. _shortcut-metadata:
+
+Adding metadata
+---------------
+
+.. list-table::
+ :align: left
+ :header-rows: 1
+
+ * - Shortcut
+ - Action
+ * - ``na``
+ - Add a new artist.
+ * - ``ni``
+ - Add a new circle.
+ * - ``nh``
+ - Add a new character.
+ * - ``nw``
+ - Add a new world.
+ * - ``nn``
+ - Add a new namespace.
+ * - ``nt``
+ - Add a new tag.
+
+.. _shortcut-reader:
+
+Reader
+------
+
+.. list-table::
+ :align: left
+ :header-rows: 1
+
+ * - Shortcut
+ - Action
+ * - ``z``
+ - Open edit menu.
+ * - ``Escape``
+ - Close reader.
+
+.. _shortcut-filtering:
+
+Filtering
+---------
+
+.. list-table::
+ :align: left
+ :header-rows: 1
+
+ * - Shortcut
+ - Action
+ * - ``F``
+ - Focus search.
+ * - ``f``
+ - Toggle favourites.
+ * - ``b``
+ - Toggle bookmarked.
+ * - ``o``
+ - Toggle organized.
+
+.. _shortcut-misc:
+
+Miscellaneous
+-------------
+
+.. list-table::
+ :align: left
+ :header-rows: 1
+
+ * - Shortcut
+ - Action
+ * - ``s``
+ - Toggle selection mode if available.
+ * - ``Delete``
+ - Delete selected items.
diff --git a/frontend/.eslintignore b/frontend/.eslintignore
new file mode 100644
index 0000000..fb4d584
--- /dev/null
+++ b/frontend/.eslintignore
@@ -0,0 +1,16 @@
+.DS_Store
+node_modules
+/build
+/.svelte-kit
+/package
+.env
+.env.*
+!.env.example
+/coverage
+
+# Ignore files for PNPM, NPM and YARN
+pnpm-lock.yaml
+package-lock.json
+yarn.lock
+
+/src/gql \ No newline at end of file
diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs
new file mode 100644
index 0000000..c204ebe
--- /dev/null
+++ b/frontend/.eslintrc.cjs
@@ -0,0 +1,49 @@
+module.exports = {
+ root: true,
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended-type-checked',
+ 'plugin:@typescript-eslint/stylistic-type-checked',
+ 'plugin:svelte/recommended',
+ 'prettier'
+ ],
+ parser: '@typescript-eslint/parser',
+ plugins: ['@typescript-eslint'],
+ ignorePatterns: ['*.cjs'],
+ parserOptions: {
+ sourceType: 'module',
+ ecmaVersion: 2022,
+ extraFileExtensions: ['.svelte'],
+ project: true,
+ tsconfigRootDir: __dirname
+ },
+ env: {
+ browser: true,
+ es2022: true,
+ node: true
+ },
+ overrides: [
+ {
+ files: ['*.svelte'],
+ parser: 'svelte-eslint-parser',
+ parserOptions: {
+ parser: '@typescript-eslint/parser'
+ },
+ rules: {
+ '@typescript-eslint/no-unsafe-argument': 'off',
+ '@typescript-eslint/no-unsafe-assignment': 'off',
+ '@typescript-eslint/no-unsafe-call': 'off',
+ '@typescript-eslint/no-unsafe-enum-comparison': 'off',
+ '@typescript-eslint/no-unsafe-member-access': 'off'
+ }
+ },
+ {
+ files: ['codegen.ts', 'svelte.config.js'],
+ extends: ['plugin:@typescript-eslint/disable-type-checked']
+ }
+ ],
+ rules: {
+ 'no-console': 'warn',
+ eqeqeq: 'error'
+ }
+};
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..99658b4
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,13 @@
+/objects
+/coverage
+
+.DS_Store
+node_modules
+/build
+/.svelte-kit
+/package
+.env
+.env.*
+!.env.example
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
diff --git a/frontend/.npmrc b/frontend/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/frontend/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/frontend/.prettierignore b/frontend/.prettierignore
new file mode 100644
index 0000000..b5ba723
--- /dev/null
+++ b/frontend/.prettierignore
@@ -0,0 +1,15 @@
+.DS_Store
+node_modules
+/build
+/.svelte-kit
+/package
+.env
+.env.*
+!.env.example
+
+# Ignore files for PNPM, NPM and YARN
+pnpm-lock.yaml
+package-lock.json
+yarn.lock
+
+/src/gql/graphql.ts \ No newline at end of file
diff --git a/frontend/.prettierrc b/frontend/.prettierrc
new file mode 100644
index 0000000..ee7cc95
--- /dev/null
+++ b/frontend/.prettierrc
@@ -0,0 +1,7 @@
+{
+ "useTabs": true,
+ "singleQuote": true,
+ "trailingComma": "none",
+ "printWidth": 100,
+ "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"]
+}
diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/frontend/.vscode/settings.json
@@ -0,0 +1 @@
+{}
diff --git a/frontend/codegen.ts b/frontend/codegen.ts
new file mode 100644
index 0000000..42bc8be
--- /dev/null
+++ b/frontend/codegen.ts
@@ -0,0 +1,20 @@
+import type { CodegenConfig } from '@graphql-codegen/cli';
+
+const config: CodegenConfig = {
+ schema: 'http://[::]:8000/graphql',
+ documents: 'operations.graphql',
+ generates: {
+ './src/gql/graphql.ts': {
+ plugins: ['typescript', 'typescript-operations', 'typed-document-node']
+ }
+ },
+ config: {
+ useTypeImports: true,
+ documentMode: 'documentNode',
+ scalars: {
+ Date: 'string',
+ DateTime: 'string'
+ }
+ }
+};
+export default config;
diff --git a/frontend/operations.graphql b/frontend/operations.graphql
new file mode 100644
index 0000000..5c41080
--- /dev/null
+++ b/frontend/operations.graphql
@@ -0,0 +1,696 @@
+fragment Image on Image {
+ hash
+ width
+ height
+}
+
+fragment Page on Page {
+ id
+ path
+ image {
+ id
+ hash
+ aspectRatio
+ width
+ height
+ }
+ comicId
+}
+
+fragment Comic on Comic {
+ id
+ title
+ originalTitle
+ favourite
+ cover {
+ ...Image
+ }
+ tags {
+ name
+ description
+ }
+ artists {
+ name
+ }
+ characters {
+ name
+ }
+ worlds {
+ name
+ }
+ circles {
+ name
+ }
+}
+
+fragment FullArchive on FullArchive {
+ id
+ name
+ path
+ size
+ createdAt
+ mtime
+ organized
+ pageCount
+ pages {
+ ...Page
+ }
+ comics {
+ ...Comic
+ }
+}
+
+fragment Archive on Archive {
+ id
+ name
+ size
+ pageCount
+ cover {
+ ...Image
+ }
+}
+
+fragment FullComic on FullComic {
+ id
+ title
+ originalTitle
+ url
+ language
+ direction
+ date
+ layout
+ rating
+ category
+ censorship
+ favourite
+ createdAt
+ updatedAt
+ organized
+ bookmarked
+ pages {
+ ...Page
+ }
+ archive {
+ id
+ }
+ tags {
+ id
+ name
+ description
+ }
+ artists {
+ id
+ name
+ }
+ characters {
+ id
+ name
+ }
+ worlds {
+ id
+ name
+ }
+ circles {
+ id
+ name
+ }
+}
+
+fragment ComicScraper on ComicScraper {
+ id
+ name
+}
+
+fragment ScrapeComicResult on ScrapeComicResult {
+ data {
+ artists
+ category
+ censorship
+ characters
+ circles
+ date
+ direction
+ language
+ layout
+ originalTitle
+ url
+ rating
+ tags
+ title
+ worlds
+ }
+ warnings
+}
+
+query comics($pagination: Pagination!, $filter: ComicFilterInput, $sort: ComicSortInput) {
+ comics(pagination: $pagination, filter: $filter, sort: $sort) {
+ edges {
+ ...Comic
+ }
+ count
+ }
+}
+
+query archives($pagination: Pagination!, $filter: ArchiveFilterInput, $sort: ArchiveSortInput) {
+ archives(pagination: $pagination, filter: $filter, sort: $sort) {
+ edges {
+ ...Archive
+ }
+ count
+ }
+}
+
+query archive($id: Int!) {
+ archive(id: $id) {
+ ... on FullArchive {
+ ...FullArchive
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+query comic($id: Int!) {
+ comic(id: $id) {
+ ... on FullComic {
+ ...FullComic
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+query tag($id: Int!) {
+ tag(id: $id) {
+ ... on FullTag {
+ id
+ name
+ description
+ namespaces {
+ id
+ name
+ }
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+query tags($pagination: Pagination!, $filter: TagFilterInput, $sort: TagSortInput) {
+ tags(pagination: $pagination, filter: $filter, sort: $sort) {
+ edges {
+ id
+ name
+ description
+ }
+ count
+ }
+}
+
+query namespace($id: Int!) {
+ namespace(id: $id) {
+ ... on Namespace {
+ id
+ name
+ sortName
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+query namespaces(
+ $pagination: Pagination!
+ $filter: NamespaceFilterInput
+ $sort: NamespaceSortInput
+) {
+ namespaces(pagination: $pagination, filter: $filter, sort: $sort) {
+ count
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query comicTagList($forFilter: Boolean = false) {
+ comicTags(forFilter: $forFilter) {
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query artistList {
+ artists {
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query characterList {
+ characters {
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query circleList {
+ circles {
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query worldList {
+ worlds {
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query namespaceList {
+ namespaces {
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query artists($pagination: Pagination!, $filter: ArtistFilterInput, $sort: ArtistSortInput) {
+ artists(pagination: $pagination, filter: $filter, sort: $sort) {
+ count
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query artist($id: Int!) {
+ artist(id: $id) {
+ ... on Artist {
+ id
+ name
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+query characters(
+ $pagination: Pagination!
+ $filter: CharacterFilterInput
+ $sort: CharacterSortInput
+) {
+ characters(pagination: $pagination, filter: $filter, sort: $sort) {
+ count
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query character($id: Int!) {
+ character(id: $id) {
+ ... on Character {
+ id
+ name
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+query circles($pagination: Pagination!, $filter: CircleFilterInput, $sort: CircleSortInput) {
+ circles(pagination: $pagination, filter: $filter, sort: $sort) {
+ count
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query circle($id: Int!) {
+ circle(id: $id) {
+ ... on Circle {
+ id
+ name
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+query worlds($pagination: Pagination!, $filter: WorldFilterInput, $sort: WorldSortInput) {
+ worlds(pagination: $pagination, filter: $filter, sort: $sort) {
+ count
+ edges {
+ id
+ name
+ }
+ }
+}
+
+query world($id: Int!) {
+ world(id: $id) {
+ ... on World {
+ id
+ name
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+query comicScrapers($id: Int!) {
+ comicScrapers(id: $id) {
+ id
+ name
+ }
+}
+
+query scrapeComic($id: Int!, $scraper: String!) {
+ scrapeComic(id: $id, scraper: $scraper) {
+ ... on ScrapeComicResult {
+ ...ScrapeComicResult
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+query frontpage {
+ recent: comics(pagination: { items: 6 }, sort: { on: CREATED_AT, direction: DESCENDING }) {
+ edges {
+ ...Comic
+ }
+ count
+ }
+ favourites: comics(
+ pagination: { items: 6 }
+ filter: { include: { favourite: true } }
+ sort: { on: RANDOM }
+ ) {
+ edges {
+ ...Comic
+ }
+ count
+ }
+ bookmarked: comics(
+ pagination: { items: 6 }
+ filter: { include: { bookmarked: true } }
+ sort: { on: RANDOM }
+ ) {
+ edges {
+ ...Comic
+ }
+ count
+ }
+}
+
+mutation addComic($input: AddComicInput!) {
+ addComic(input: $input) {
+ ... on AddComicSuccess {
+ message
+ archivePagesRemaining
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation updateArchives($ids: [Int!]!, $input: UpdateArchiveInput!) {
+ updateArchives(ids: $ids, input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation updateComics($ids: [Int!]!, $input: UpdateComicInput!) {
+ updateComics(ids: $ids, input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation upsertComics($ids: [Int!]!, $input: UpsertComicInput!) {
+ upsertComics(ids: $ids, input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation deleteArchives($ids: [Int!]!) {
+ deleteArchives(ids: $ids) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation deleteComics($ids: [Int!]!) {
+ deleteComics(ids: $ids) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation addTag($input: AddTagInput!) {
+ addTag(input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation updateTags($ids: [Int!]!, $input: UpdateTagInput!) {
+ updateTags(ids: $ids, input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation deleteTags($ids: [Int!]!) {
+ deleteTags(ids: $ids) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation addNamespace($input: AddNamespaceInput!) {
+ addNamespace(input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation updateNamespaces($ids: [Int!]!, $input: UpdateNamespaceInput!) {
+ updateNamespaces(ids: $ids, input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation deleteNamespaces($ids: [Int!]!) {
+ deleteNamespaces(ids: $ids) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation addArtist($input: AddArtistInput!) {
+ addArtist(input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation updateArtists($ids: [Int!]!, $input: UpdateArtistInput!) {
+ updateArtists(ids: $ids, input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation deleteArtists($ids: [Int!]!) {
+ deleteArtists(ids: $ids) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation addCharacter($input: AddCharacterInput!) {
+ addCharacter(input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation updateCharacters($ids: [Int!]!, $input: UpdateCharacterInput!) {
+ updateCharacters(ids: $ids, input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation deleteCharacters($ids: [Int!]!) {
+ deleteCharacters(ids: $ids) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation addCircle($input: AddCircleInput!) {
+ addCircle(input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation updateCircles($ids: [Int!]!, $input: UpdateCircleInput!) {
+ updateCircles(ids: $ids, input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation deleteCircles($ids: [Int!]!) {
+ deleteCircles(ids: $ids) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation addWorld($input: AddWorldInput!) {
+ addWorld(input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation updateWorlds($ids: [Int!]!, $input: UpdateWorldInput!) {
+ updateWorlds(ids: $ids, input: $input) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
+
+mutation deleteWorlds($ids: [Int!]!) {
+ deleteWorlds(ids: $ids) {
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ }
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..a092acc
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,12892 @@
+{
+ "name": "hircine",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "hircine",
+ "version": "0.1.0",
+ "license": "ISC",
+ "dependencies": {
+ "@jsonurl/jsonurl": "^1.1.7",
+ "@urql/svelte": "^4.1.0",
+ "filesize": "^10.1.0",
+ "graphql": "npm:graphql-web-lite@^16.6.0",
+ "svelecte": "^3.17.3",
+ "svelte-modals": "^1.3.0"
+ },
+ "devDependencies": {
+ "@graphql-codegen/cli": "^5.0.2",
+ "@graphql-codegen/typed-document-node": "^5.0.6",
+ "@graphql-codegen/typescript-operations": "^4.2.0",
+ "@iconify-json/material-symbols": "^1.1.74",
+ "@iconify/tailwind": "^0.1.4",
+ "@sveltejs/adapter-static": "^3.0.1",
+ "@sveltejs/kit": "^2.5.2",
+ "@sveltejs/vite-plugin-svelte": "^3.0.2",
+ "@typescript-eslint/eslint-plugin": "^7.1.0",
+ "@typescript-eslint/parser": "^7.1.0",
+ "@zerodevx/svelte-toast": "^0.9.5",
+ "autoprefixer": "^10.4.18",
+ "date-fns": "^3.3.1",
+ "eslint": "^8.57.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-svelte": "^2.35.1",
+ "fast-deep-equal": "^3.1.3",
+ "npm-check-updates": "^16.14.15",
+ "postcss": "^8.4.35",
+ "prettier": "^3.2.5",
+ "prettier-plugin-svelte": "^3.2.2",
+ "prettier-plugin-tailwindcss": "^0.5.11",
+ "svelte": "^4.2.12",
+ "svelte-check": "^3.6.6",
+ "tailwindcss": "^3.4.1",
+ "tslib": "^2.6.2",
+ "typescript": "^5.3.3",
+ "vite": "^5.1.4",
+ "vitest": "^1.3.1"
+ }
+ },
+ "node_modules/@0no-co/graphql.web": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.0.4.tgz",
+ "integrity": "sha512-W3ezhHGfO0MS1PtGloaTpg0PbaT8aZSmmaerL7idtU5F7oCI+uu25k+MsMS31BVFlp4aMkHSrNRxiD72IlK8TA==",
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0"
+ },
+ "peerDependenciesMeta": {
+ "graphql": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@aashutoshrathi/word-wrap": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
+ "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@ardatan/relay-compiler": {
+ "version": "12.0.0",
+ "resolved": "https://registry.npmjs.org/@ardatan/relay-compiler/-/relay-compiler-12.0.0.tgz",
+ "integrity": "sha512-9anThAaj1dQr6IGmzBMcfzOQKTa5artjuPmw8NYK/fiGEMjADbSguBY2FMDykt+QhilR3wc9VA/3yVju7JHg7Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.14.0",
+ "@babel/generator": "^7.14.0",
+ "@babel/parser": "^7.14.0",
+ "@babel/runtime": "^7.0.0",
+ "@babel/traverse": "^7.14.0",
+ "@babel/types": "^7.0.0",
+ "babel-preset-fbjs": "^3.4.0",
+ "chalk": "^4.0.0",
+ "fb-watchman": "^2.0.0",
+ "fbjs": "^3.0.0",
+ "glob": "^7.1.1",
+ "immutable": "~3.7.6",
+ "invariant": "^2.2.4",
+ "nullthrows": "^1.1.1",
+ "relay-runtime": "12.0.0",
+ "signedsource": "^1.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "relay-compiler": "bin/relay-compiler"
+ },
+ "peerDependencies": {
+ "graphql": "*"
+ }
+ },
+ "node_modules/@ardatan/relay-compiler/node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@ardatan/relay-compiler/node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/@ardatan/relay-compiler/node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@ardatan/relay-compiler/node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@ardatan/relay-compiler/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@ardatan/relay-compiler/node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@ardatan/relay-compiler/node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "dev": true
+ },
+ "node_modules/@ardatan/relay-compiler/node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "dev": true,
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@ardatan/relay-compiler/node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "dev": true,
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@ardatan/sync-fetch": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@ardatan/sync-fetch/-/sync-fetch-0.0.1.tgz",
+ "integrity": "sha512-xhlTqH0m31mnsG0tIP4ETgfSB6gXDaYYsUWTrlUV93fFQPI9dd8hE0Ot6MHLCtqgB32hwJAC3YZMWlXZw7AleA==",
+ "dev": true,
+ "dependencies": {
+ "node-fetch": "^2.6.1"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.23.5",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz",
+ "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/highlight": "^7.23.4",
+ "chalk": "^2.4.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/code-frame/node_modules/ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/code-frame/node_modules/chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/code-frame/node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "1.1.3"
+ }
+ },
+ "node_modules/@babel/code-frame/node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "dev": true
+ },
+ "node_modules/@babel/code-frame/node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/@babel/code-frame/node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/code-frame/node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.23.5",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz",
+ "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.24.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
+ "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
+ "dev": true,
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.0",
+ "@babel/code-frame": "^7.23.5",
+ "@babel/generator": "^7.23.6",
+ "@babel/helper-compilation-targets": "^7.23.6",
+ "@babel/helper-module-transforms": "^7.23.3",
+ "@babel/helpers": "^7.24.0",
+ "@babel/parser": "^7.24.0",
+ "@babel/template": "^7.24.0",
+ "@babel/traverse": "^7.24.0",
+ "@babel/types": "^7.24.0",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.23.6",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz",
+ "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.23.6",
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "@jridgewell/trace-mapping": "^0.3.17",
+ "jsesc": "^2.5.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz",
+ "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.23.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz",
+ "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.23.5",
+ "@babel/helper-validator-option": "^7.23.5",
+ "browserslist": "^4.22.2",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.24.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.0.tgz",
+ "integrity": "sha512-QAH+vfvts51BCsNZ2PhY6HAggnlS6omLLFTsIpeqZk/MmJ6cW7tgz5yRv0fMJThcr6FmbMrENh1RgrWPTYA76g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.22.5",
+ "@babel/helper-environment-visitor": "^7.22.20",
+ "@babel/helper-function-name": "^7.23.0",
+ "@babel/helper-member-expression-to-functions": "^7.23.0",
+ "@babel/helper-optimise-call-expression": "^7.22.5",
+ "@babel/helper-replace-supers": "^7.22.20",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5",
+ "@babel/helper-split-export-declaration": "^7.22.6",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-environment-visitor": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
+ "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-function-name": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
+ "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.22.15",
+ "@babel/types": "^7.23.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-hoist-variables": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
+ "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz",
+ "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.23.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.22.15",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz",
+ "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.15"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz",
+ "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-environment-visitor": "^7.22.20",
+ "@babel/helper-module-imports": "^7.22.15",
+ "@babel/helper-simple-access": "^7.22.5",
+ "@babel/helper-split-export-declaration": "^7.22.6",
+ "@babel/helper-validator-identifier": "^7.22.20"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz",
+ "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.24.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz",
+ "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz",
+ "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-environment-visitor": "^7.22.20",
+ "@babel/helper-member-expression-to-functions": "^7.22.15",
+ "@babel/helper-optimise-call-expression": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-simple-access": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz",
+ "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz",
+ "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-split-export-declaration": {
+ "version": "7.22.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz",
+ "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==",
+ "dev": true,
+ "dependencies": {
+ "@babel/types": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz",
+ "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+ "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.23.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz",
+ "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.24.0",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.0.tgz",
+ "integrity": "sha512-ulDZdc0Aj5uLc5nETsa7EPx2L7rM0YJM8r7ck7U73AXi7qOV44IHHRAYZHY6iU1rr3C5N4NtTmMRUJP6kwCWeA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/template": "^7.24.0",
+ "@babel/traverse": "^7.24.0",
+ "@babel/types": "^7.24.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/highlight": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz",
+ "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "chalk": "^2.4.2",
+ "js-tokens": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/highlight/node_modules/ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/highlight/node_modules/chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/highlight/node_modules/color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "1.1.3"
+ }
+ },
+ "node_modules/@babel/highlight/node_modules/color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+ "dev": true
+ },
+ "node_modules/@babel/highlight/node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/@babel/highlight/node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/highlight/node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.24.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz",
+ "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==",
+ "dev": true,
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-class-properties": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz",
+ "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==",
+ "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead.",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-object-rest-spread": {
+ "version": "7.20.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz",
+ "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==",
+ "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead.",
+ "dev": true,
+ "dependencies": {
+ "@babel/compat-data": "^7.20.5",
+ "@babel/helper-compilation-targets": "^7.20.7",
+ "@babel/helper-plugin-utils": "^7.20.2",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-transform-parameters": "^7.20.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-class-properties": {
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.12.13"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-flow": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.23.3.tgz",
+ "integrity": "sha512-YZiAIpkJAwQXBJLIQbRFayR5c+gJ35Vcz3bg954k7cd73zqjvhacJuL9RbrzPz8qPmZdgqP6EUKwy0PCNhaaPA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-assertions": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz",
+ "integrity": "sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz",
+ "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-object-rest-spread": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-arrow-functions": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz",
+ "integrity": "sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoped-functions": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz",
+ "integrity": "sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoping": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz",
+ "integrity": "sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-classes": {
+ "version": "7.23.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.8.tgz",
+ "integrity": "sha512-yAYslGsY1bX6Knmg46RjiCiNSwJKv2IUC8qOdYKqMMr0491SXFhcHqOdRDeCRohOOIzwN/90C6mQ9qAKgrP7dg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.22.5",
+ "@babel/helper-compilation-targets": "^7.23.6",
+ "@babel/helper-environment-visitor": "^7.22.20",
+ "@babel/helper-function-name": "^7.23.0",
+ "@babel/helper-plugin-utils": "^7.22.5",
+ "@babel/helper-replace-supers": "^7.22.20",
+ "@babel/helper-split-export-declaration": "^7.22.6",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-computed-properties": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz",
+ "integrity": "sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5",
+ "@babel/template": "^7.22.15"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-destructuring": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz",
+ "integrity": "sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-flow-strip-types": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.23.3.tgz",
+ "integrity": "sha512-26/pQTf9nQSNVJCrLB1IkHUKyPxR+lMrH2QDPG89+Znu9rAMbtrybdbWeE9bb7gzjmE5iXHEY+e0HUwM6Co93Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5",
+ "@babel/plugin-syntax-flow": "^7.23.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-for-of": {
+ "version": "7.23.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.6.tgz",
+ "integrity": "sha512-aYH4ytZ0qSuBbpfhuofbg/e96oQ7U2w1Aw/UQmKT+1l39uEhUPoFS3fHevDc1G0OvewyDudfMKY1OulczHzWIw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-function-name": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz",
+ "integrity": "sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.22.15",
+ "@babel/helper-function-name": "^7.23.0",
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-literals": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz",
+ "integrity": "sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-member-expression-literals": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz",
+ "integrity": "sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz",
+ "integrity": "sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.23.3",
+ "@babel/helper-plugin-utils": "^7.22.5",
+ "@babel/helper-simple-access": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-super": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz",
+ "integrity": "sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5",
+ "@babel/helper-replace-supers": "^7.22.20"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-parameters": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz",
+ "integrity": "sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-property-literals": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz",
+ "integrity": "sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-display-name": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.23.3.tgz",
+ "integrity": "sha512-GnvhtVfA2OAtzdX58FJxU19rhoGeQzyVndw3GgtdECQvQFXPEZIOVULHVZGAYmOgmqjXpVpfocAbSjh99V/Fqw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx": {
+ "version": "7.23.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz",
+ "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.22.5",
+ "@babel/helper-module-imports": "^7.22.15",
+ "@babel/helper-plugin-utils": "^7.22.5",
+ "@babel/plugin-syntax-jsx": "^7.23.3",
+ "@babel/types": "^7.23.4"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-shorthand-properties": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz",
+ "integrity": "sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-spread": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz",
+ "integrity": "sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-template-literals": {
+ "version": "7.23.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz",
+ "integrity": "sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.22.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.24.0",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz",
+ "integrity": "sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==",
+ "dev": true,
+ "dependencies": {
+ "regenerator-runtime": "^0.14.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.24.0",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz",
+ "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.23.5",
+ "@babel/parser": "^7.24.0",
+ "@babel/types": "^7.24.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.24.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.0.tgz",
+ "integrity": "sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.23.5",
+ "@babel/generator": "^7.23.6",
+ "@babel/helper-environment-visitor": "^7.22.20",
+ "@babel/helper-function-name": "^7.23.0",
+ "@babel/helper-hoist-variables": "^7.22.5",
+ "@babel/helper-split-export-declaration": "^7.22.6",
+ "@babel/parser": "^7.24.0",
+ "@babel/types": "^7.24.0",
+ "debug": "^4.3.1",
+ "globals": "^11.1.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.24.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz",
+ "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.23.4",
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "to-fast-properties": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@colors/colors": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
+ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.90"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
+ "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
+ "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
+ "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
+ "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
+ "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
+ "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
+ "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
+ "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
+ "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
+ "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
+ "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
+ "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
+ "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
+ "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
+ "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
+ "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
+ "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
+ "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
+ "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
+ "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
+ "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
+ "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
+ "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
+ "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==",
+ "dev": true,
+ "dependencies": {
+ "eslint-visitor-keys": "^3.3.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.10.0",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz",
+ "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==",
+ "dev": true,
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
+ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
+ "dev": true,
+ "dependencies": {
+ "ajv": "^6.12.4",
+ "debug": "^4.3.2",
+ "espree": "^9.6.0",
+ "globals": "^13.19.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.0",
+ "minimatch": "^3.1.2",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/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/@eslint/eslintrc/node_modules/globals": {
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "dev": true,
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "8.57.0",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
+ "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@gar/promisify": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
+ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
+ "dev": true
+ },
+ "node_modules/@graphql-codegen/add": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-5.0.2.tgz",
+ "integrity": "sha512-ouBkSvMFUhda5VoKumo/ZvsZM9P5ZTyDsI8LW18VxSNWOjrTeLXBWHG8Gfaai0HwhflPtCYVABbriEcOmrRShQ==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-codegen/plugin-helpers": "^5.0.3",
+ "tslib": "~2.6.0"
+ },
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@graphql-codegen/cli": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-5.0.2.tgz",
+ "integrity": "sha512-MBIaFqDiLKuO4ojN6xxG9/xL9wmfD3ZjZ7RsPjwQnSHBCUXnEkdKvX+JVpx87Pq29Ycn8wTJUguXnTZ7Di0Mlw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/generator": "^7.18.13",
+ "@babel/template": "^7.18.10",
+ "@babel/types": "^7.18.13",
+ "@graphql-codegen/client-preset": "^4.2.2",
+ "@graphql-codegen/core": "^4.0.2",
+ "@graphql-codegen/plugin-helpers": "^5.0.3",
+ "@graphql-tools/apollo-engine-loader": "^8.0.0",
+ "@graphql-tools/code-file-loader": "^8.0.0",
+ "@graphql-tools/git-loader": "^8.0.0",
+ "@graphql-tools/github-loader": "^8.0.0",
+ "@graphql-tools/graphql-file-loader": "^8.0.0",
+ "@graphql-tools/json-file-loader": "^8.0.0",
+ "@graphql-tools/load": "^8.0.0",
+ "@graphql-tools/prisma-loader": "^8.0.0",
+ "@graphql-tools/url-loader": "^8.0.0",
+ "@graphql-tools/utils": "^10.0.0",
+ "@whatwg-node/fetch": "^0.8.0",
+ "chalk": "^4.1.0",
+ "cosmiconfig": "^8.1.3",
+ "debounce": "^1.2.0",
+ "detect-indent": "^6.0.0",
+ "graphql-config": "^5.0.2",
+ "inquirer": "^8.0.0",
+ "is-glob": "^4.0.1",
+ "jiti": "^1.17.1",
+ "json-to-pretty-yaml": "^1.2.2",
+ "listr2": "^4.0.5",
+ "log-symbols": "^4.0.0",
+ "micromatch": "^4.0.5",
+ "shell-quote": "^1.7.3",
+ "string-env-interpolation": "^1.0.1",
+ "ts-log": "^2.2.3",
+ "tslib": "^2.4.0",
+ "yaml": "^2.3.1",
+ "yargs": "^17.0.0"
+ },
+ "bin": {
+ "gql-gen": "cjs/bin.js",
+ "graphql-code-generator": "cjs/bin.js",
+ "graphql-codegen": "cjs/bin.js",
+ "graphql-codegen-esm": "esm/bin.js"
+ },
+ "peerDependencies": {
+ "@parcel/watcher": "^2.1.0",
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@parcel/watcher": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@graphql-codegen/client-preset": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/client-preset/-/client-preset-4.2.4.tgz",
+ "integrity": "sha512-k1c8v2YxJhhITGQGxViG9asLAoop9m7X9duU7Zztqjc98ooxsUzXICfvAWsH3mLAUibXAx4Ax6BPzKsTtQmBPg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.20.2",
+ "@babel/template": "^7.20.7",
+ "@graphql-codegen/add": "^5.0.2",
+ "@graphql-codegen/gql-tag-operations": "4.0.6",
+ "@graphql-codegen/plugin-helpers": "^5.0.3",
+ "@graphql-codegen/typed-document-node": "^5.0.6",
+ "@graphql-codegen/typescript": "^4.0.6",
+ "@graphql-codegen/typescript-operations": "^4.2.0",
+ "@graphql-codegen/visitor-plugin-common": "^5.1.0",
+ "@graphql-tools/documents": "^1.0.0",
+ "@graphql-tools/utils": "^10.0.0",
+ "@graphql-typed-document-node/core": "3.2.0",
+ "tslib": "~2.6.0"
+ },
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@graphql-codegen/core": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/core/-/core-4.0.2.tgz",
+ "integrity": "sha512-IZbpkhwVqgizcjNiaVzNAzm/xbWT6YnGgeOLwVjm4KbJn3V2jchVtuzHH09G5/WkkLSk2wgbXNdwjM41JxO6Eg==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-codegen/plugin-helpers": "^5.0.3",
+ "@graphql-tools/schema": "^10.0.0",
+ "@graphql-tools/utils": "^10.0.0",
+ "tslib": "~2.6.0"
+ },
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@graphql-codegen/gql-tag-operations": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/gql-tag-operations/-/gql-tag-operations-4.0.6.tgz",
+ "integrity": "sha512-y6iXEDpDNjwNxJw3WZqX1/Znj0QHW7+y8O+t2V8qvbTT+3kb2lr9ntc8By7vCr6ctw9tXI4XKaJgpTstJDOwFA==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-codegen/plugin-helpers": "^5.0.3",
+ "@graphql-codegen/visitor-plugin-common": "5.1.0",
+ "@graphql-tools/utils": "^10.0.0",
+ "auto-bind": "~4.0.0",
+ "tslib": "~2.6.0"
+ },
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@graphql-codegen/plugin-helpers": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/plugin-helpers/-/plugin-helpers-5.0.3.tgz",
+ "integrity": "sha512-yZ1rpULIWKBZqCDlvGIJRSyj1B2utkEdGmXZTBT/GVayP4hyRYlkd36AJV/LfEsVD8dnsKL5rLz2VTYmRNlJ5Q==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/utils": "^10.0.0",
+ "change-case-all": "1.0.15",
+ "common-tags": "1.8.2",
+ "import-from": "4.0.0",
+ "lodash": "~4.17.0",
+ "tslib": "~2.6.0"
+ },
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@graphql-codegen/schema-ast": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/schema-ast/-/schema-ast-4.0.2.tgz",
+ "integrity": "sha512-5mVAOQQK3Oz7EtMl/l3vOQdc2aYClUzVDHHkMvZlunc+KlGgl81j8TLa+X7ANIllqU4fUEsQU3lJmk4hXP6K7Q==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-codegen/plugin-helpers": "^5.0.3",
+ "@graphql-tools/utils": "^10.0.0",
+ "tslib": "~2.6.0"
+ },
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@graphql-codegen/typed-document-node": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/typed-document-node/-/typed-document-node-5.0.6.tgz",
+ "integrity": "sha512-US0J95hOE2/W/h42w4oiY+DFKG7IetEN1mQMgXXeat1w6FAR5PlIz4JrRrEkiVfVetZ1g7K78SOwBD8/IJnDiA==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-codegen/plugin-helpers": "^5.0.3",
+ "@graphql-codegen/visitor-plugin-common": "5.1.0",
+ "auto-bind": "~4.0.0",
+ "change-case-all": "1.0.15",
+ "tslib": "~2.6.0"
+ },
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@graphql-codegen/typescript": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript/-/typescript-4.0.6.tgz",
+ "integrity": "sha512-IBG4N+Blv7KAL27bseruIoLTjORFCT3r+QYyMC3g11uY3/9TPpaUyjSdF70yBe5GIQ6dAgDU+ENUC1v7EPi0rw==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-codegen/plugin-helpers": "^5.0.3",
+ "@graphql-codegen/schema-ast": "^4.0.2",
+ "@graphql-codegen/visitor-plugin-common": "5.1.0",
+ "auto-bind": "~4.0.0",
+ "tslib": "~2.6.0"
+ },
+ "peerDependencies": {
+ "graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@graphql-codegen/typescript-operations": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/typescript-operations/-/typescript-operations-4.2.0.tgz",
+ "integrity": "sha512-lmuwYb03XC7LNRS8oo9M4/vlOrq/wOKmTLBHlltK2YJ1BO/4K/Q9Jdv/jDmJpNydHVR1fmeF4wAfsIp1f9JibA==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-codegen/plugin-helpers": "^5.0.3",
+ "@graphql-codegen/typescript": "^4.0.6",
+ "@graphql-codegen/visitor-plugin-common": "5.1.0",
+ "auto-bind": "~4.0.0",
+ "tslib": "~2.6.0"
+ },
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@graphql-codegen/visitor-plugin-common": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@graphql-codegen/visitor-plugin-common/-/visitor-plugin-common-5.1.0.tgz",
+ "integrity": "sha512-eamQxtA9bjJqI2lU5eYoA1GbdMIRT2X8m8vhWYsVQVWD3qM7sx/IqJU0kx0J3Vd4/CSd36BzL6RKwksibytDIg==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-codegen/plugin-helpers": "^5.0.3",
+ "@graphql-tools/optimize": "^2.0.0",
+ "@graphql-tools/relay-operation-optimizer": "^7.0.0",
+ "@graphql-tools/utils": "^10.0.0",
+ "auto-bind": "~4.0.0",
+ "change-case-all": "1.0.15",
+ "dependency-graph": "^0.11.0",
+ "graphql-tag": "^2.11.0",
+ "parse-filepath": "^1.0.2",
+ "tslib": "~2.6.0"
+ },
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/apollo-engine-loader": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/apollo-engine-loader/-/apollo-engine-loader-8.0.1.tgz",
+ "integrity": "sha512-NaPeVjtrfbPXcl+MLQCJLWtqe2/E4bbAqcauEOQ+3sizw1Fc2CNmhHRF8a6W4D0ekvTRRXAMptXYgA2uConbrA==",
+ "dev": true,
+ "dependencies": {
+ "@ardatan/sync-fetch": "^0.0.1",
+ "@graphql-tools/utils": "^10.0.13",
+ "@whatwg-node/fetch": "^0.9.0",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/events": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.1.tgz",
+ "integrity": "sha512-AyQEn5hIPV7Ze+xFoXVU3QTHXVbWPrzaOkxtENMPMuNL6VVHrp4hHfDt9nrQpjO7BgvuM95dMtkycX5M/DZR3w==",
+ "dev": true,
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/fetch": {
+ "version": "0.9.17",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.9.17.tgz",
+ "integrity": "sha512-TDYP3CpCrxwxpiNY0UMNf096H5Ihf67BK1iKGegQl5u9SlpEDYrvnV71gWBGJm+Xm31qOy8ATgma9rm8Pe7/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@whatwg-node/node-fetch": "^0.5.7",
+ "urlpattern-polyfill": "^10.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/apollo-engine-loader/node_modules/@whatwg-node/node-fetch": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.5.7.tgz",
+ "integrity": "sha512-YZA+N3JcW1eh2QRi7o/ij+M07M0dqID73ltgsOEMRyEc2UYVDbyomaih+CWCEZqBIDHw4KMDveXvv4SBZ4TLIw==",
+ "dev": true,
+ "dependencies": {
+ "@kamilkisiela/fast-url-parser": "^1.1.4",
+ "@whatwg-node/events": "^0.1.0",
+ "busboy": "^1.6.0",
+ "fast-querystring": "^1.1.1",
+ "tslib": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/apollo-engine-loader/node_modules/urlpattern-polyfill": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz",
+ "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==",
+ "dev": true
+ },
+ "node_modules/@graphql-tools/batch-execute": {
+ "version": "9.0.4",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-9.0.4.tgz",
+ "integrity": "sha512-kkebDLXgDrep5Y0gK1RN3DMUlLqNhg60OAz0lTCqrYeja6DshxLtLkj+zV4mVbBA4mQOEoBmw6g1LZs3dA84/w==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/utils": "^10.0.13",
+ "dataloader": "^2.2.2",
+ "tslib": "^2.4.0",
+ "value-or-promise": "^1.0.12"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/code-file-loader": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/code-file-loader/-/code-file-loader-8.1.1.tgz",
+ "integrity": "sha512-q4KN25EPSUztc8rA8YUU3ufh721Yk12xXDbtUA+YstczWS7a1RJlghYMFEfR1HsHSYbF7cUqkbnTKSGM3o52bQ==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/graphql-tag-pluck": "8.3.0",
+ "@graphql-tools/utils": "^10.0.13",
+ "globby": "^11.0.3",
+ "tslib": "^2.4.0",
+ "unixify": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/delegate": {
+ "version": "10.0.4",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/delegate/-/delegate-10.0.4.tgz",
+ "integrity": "sha512-WswZRbQZMh/ebhc8zSomK9DIh6Pd5KbuiMsyiKkKz37TWTrlCOe+4C/fyrBFez30ksq6oFyCeSKMwfrCbeGo0Q==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/batch-execute": "^9.0.4",
+ "@graphql-tools/executor": "^1.2.1",
+ "@graphql-tools/schema": "^10.0.3",
+ "@graphql-tools/utils": "^10.0.13",
+ "dataloader": "^2.2.2",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/documents": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/documents/-/documents-1.0.0.tgz",
+ "integrity": "sha512-rHGjX1vg/nZ2DKqRGfDPNC55CWZBMldEVcH+91BThRa6JeT80NqXknffLLEZLRUxyikCfkwMsk6xR3UNMqG0Rg==",
+ "dev": true,
+ "dependencies": {
+ "lodash.sortby": "^4.7.0",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/executor": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-1.2.1.tgz",
+ "integrity": "sha512-BP5UI1etbNOXmTSt7q4NL1+zsURFgh2pG+Hyt9K/xO0LlsfbSx59L5dHLerqZP7Js0xI6GYqrUQ4m29rUwUHJg==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/utils": "^10.0.13",
+ "@graphql-typed-document-node/core": "3.2.0",
+ "@repeaterjs/repeater": "^3.0.4",
+ "tslib": "^2.4.0",
+ "value-or-promise": "^1.0.12"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/executor-graphql-ws": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/executor-graphql-ws/-/executor-graphql-ws-1.1.2.tgz",
+ "integrity": "sha512-+9ZK0rychTH1LUv4iZqJ4ESbmULJMTsv3XlFooPUngpxZkk00q6LqHKJRrsLErmQrVaC7cwQCaRBJa0teK17Lg==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/utils": "^10.0.13",
+ "@types/ws": "^8.0.0",
+ "graphql-ws": "^5.14.0",
+ "isomorphic-ws": "^5.0.0",
+ "tslib": "^2.4.0",
+ "ws": "^8.13.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/executor-http": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/executor-http/-/executor-http-1.0.9.tgz",
+ "integrity": "sha512-+NXaZd2MWbbrWHqU4EhXcrDbogeiCDmEbrAN+rMn4Nu2okDjn2MTFDbTIab87oEubQCH4Te1wDkWPKrzXup7+Q==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/utils": "^10.0.13",
+ "@repeaterjs/repeater": "^3.0.4",
+ "@whatwg-node/fetch": "^0.9.0",
+ "extract-files": "^11.0.0",
+ "meros": "^1.2.1",
+ "tslib": "^2.4.0",
+ "value-or-promise": "^1.0.12"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/executor-http/node_modules/@whatwg-node/events": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.1.tgz",
+ "integrity": "sha512-AyQEn5hIPV7Ze+xFoXVU3QTHXVbWPrzaOkxtENMPMuNL6VVHrp4hHfDt9nrQpjO7BgvuM95dMtkycX5M/DZR3w==",
+ "dev": true,
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/executor-http/node_modules/@whatwg-node/fetch": {
+ "version": "0.9.17",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.9.17.tgz",
+ "integrity": "sha512-TDYP3CpCrxwxpiNY0UMNf096H5Ihf67BK1iKGegQl5u9SlpEDYrvnV71gWBGJm+Xm31qOy8ATgma9rm8Pe7/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@whatwg-node/node-fetch": "^0.5.7",
+ "urlpattern-polyfill": "^10.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/executor-http/node_modules/@whatwg-node/node-fetch": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.5.7.tgz",
+ "integrity": "sha512-YZA+N3JcW1eh2QRi7o/ij+M07M0dqID73ltgsOEMRyEc2UYVDbyomaih+CWCEZqBIDHw4KMDveXvv4SBZ4TLIw==",
+ "dev": true,
+ "dependencies": {
+ "@kamilkisiela/fast-url-parser": "^1.1.4",
+ "@whatwg-node/events": "^0.1.0",
+ "busboy": "^1.6.0",
+ "fast-querystring": "^1.1.1",
+ "tslib": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/executor-http/node_modules/urlpattern-polyfill": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz",
+ "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==",
+ "dev": true
+ },
+ "node_modules/@graphql-tools/executor-legacy-ws": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/executor-legacy-ws/-/executor-legacy-ws-1.0.6.tgz",
+ "integrity": "sha512-lDSxz9VyyquOrvSuCCnld3256Hmd+QI2lkmkEv7d4mdzkxkK4ddAWW1geQiWrQvWmdsmcnGGlZ7gDGbhEExwqg==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/utils": "^10.0.13",
+ "@types/ws": "^8.0.0",
+ "isomorphic-ws": "^5.0.0",
+ "tslib": "^2.4.0",
+ "ws": "^8.15.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/git-loader": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/git-loader/-/git-loader-8.0.5.tgz",
+ "integrity": "sha512-P97/1mhruDiA6D5WUmx3n/aeGPLWj2+4dpzDOxFGGU+z9NcI/JdygMkeFpGZNHeJfw+kHfxgPcMPnxHcyhAoVA==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/graphql-tag-pluck": "8.3.0",
+ "@graphql-tools/utils": "^10.0.13",
+ "is-glob": "4.0.3",
+ "micromatch": "^4.0.4",
+ "tslib": "^2.4.0",
+ "unixify": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/github-loader": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/github-loader/-/github-loader-8.0.1.tgz",
+ "integrity": "sha512-W4dFLQJ5GtKGltvh/u1apWRFKBQOsDzFxO9cJkOYZj1VzHCpRF43uLST4VbCfWve+AwBqOuKr7YgkHoxpRMkcg==",
+ "dev": true,
+ "dependencies": {
+ "@ardatan/sync-fetch": "^0.0.1",
+ "@graphql-tools/executor-http": "^1.0.9",
+ "@graphql-tools/graphql-tag-pluck": "^8.0.0",
+ "@graphql-tools/utils": "^10.0.13",
+ "@whatwg-node/fetch": "^0.9.0",
+ "tslib": "^2.4.0",
+ "value-or-promise": "^1.0.12"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/github-loader/node_modules/@whatwg-node/events": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.1.tgz",
+ "integrity": "sha512-AyQEn5hIPV7Ze+xFoXVU3QTHXVbWPrzaOkxtENMPMuNL6VVHrp4hHfDt9nrQpjO7BgvuM95dMtkycX5M/DZR3w==",
+ "dev": true,
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/github-loader/node_modules/@whatwg-node/fetch": {
+ "version": "0.9.17",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.9.17.tgz",
+ "integrity": "sha512-TDYP3CpCrxwxpiNY0UMNf096H5Ihf67BK1iKGegQl5u9SlpEDYrvnV71gWBGJm+Xm31qOy8ATgma9rm8Pe7/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@whatwg-node/node-fetch": "^0.5.7",
+ "urlpattern-polyfill": "^10.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/github-loader/node_modules/@whatwg-node/node-fetch": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.5.7.tgz",
+ "integrity": "sha512-YZA+N3JcW1eh2QRi7o/ij+M07M0dqID73ltgsOEMRyEc2UYVDbyomaih+CWCEZqBIDHw4KMDveXvv4SBZ4TLIw==",
+ "dev": true,
+ "dependencies": {
+ "@kamilkisiela/fast-url-parser": "^1.1.4",
+ "@whatwg-node/events": "^0.1.0",
+ "busboy": "^1.6.0",
+ "fast-querystring": "^1.1.1",
+ "tslib": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/github-loader/node_modules/urlpattern-polyfill": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz",
+ "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==",
+ "dev": true
+ },
+ "node_modules/@graphql-tools/graphql-file-loader": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-file-loader/-/graphql-file-loader-8.0.1.tgz",
+ "integrity": "sha512-7gswMqWBabTSmqbaNyWSmRRpStWlcCkBc73E6NZNlh4YNuiyKOwbvSkOUYFOqFMfEL+cFsXgAvr87Vz4XrYSbA==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/import": "7.0.1",
+ "@graphql-tools/utils": "^10.0.13",
+ "globby": "^11.0.3",
+ "tslib": "^2.4.0",
+ "unixify": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/graphql-tag-pluck": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/graphql-tag-pluck/-/graphql-tag-pluck-8.3.0.tgz",
+ "integrity": "sha512-gNqukC+s7iHC7vQZmx1SEJQmLnOguBq+aqE2zV2+o1hxkExvKqyFli1SY/9gmukFIKpKutCIj+8yLOM+jARutw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.22.9",
+ "@babel/parser": "^7.16.8",
+ "@babel/plugin-syntax-import-assertions": "^7.20.0",
+ "@babel/traverse": "^7.16.8",
+ "@babel/types": "^7.16.8",
+ "@graphql-tools/utils": "^10.0.13",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/import": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/import/-/import-7.0.1.tgz",
+ "integrity": "sha512-935uAjAS8UAeXThqHfYVr4HEAp6nHJ2sximZKO1RzUTq5WoALMAhhGARl0+ecm6X+cqNUwIChJbjtaa6P/ML0w==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/utils": "^10.0.13",
+ "resolve-from": "5.0.0",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/json-file-loader": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/json-file-loader/-/json-file-loader-8.0.1.tgz",
+ "integrity": "sha512-lAy2VqxDAHjVyqeJonCP6TUemrpYdDuKt25a10X6zY2Yn3iFYGnuIDQ64cv3ytyGY6KPyPB+Kp+ZfOkNDG3FQA==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/utils": "^10.0.13",
+ "globby": "^11.0.3",
+ "tslib": "^2.4.0",
+ "unixify": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/load": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/load/-/load-8.0.2.tgz",
+ "integrity": "sha512-S+E/cmyVmJ3CuCNfDuNF2EyovTwdWfQScXv/2gmvJOti2rGD8jTt9GYVzXaxhblLivQR9sBUCNZu/w7j7aXUCA==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/schema": "^10.0.3",
+ "@graphql-tools/utils": "^10.0.13",
+ "p-limit": "3.1.0",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/merge": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.0.3.tgz",
+ "integrity": "sha512-FeKv9lKLMwqDu0pQjPpF59GY3HReUkWXKsMIuMuJQOKh9BETu7zPEFUELvcw8w+lwZkl4ileJsHXC9+AnsT2Lw==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/utils": "^10.0.13",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/optimize": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/optimize/-/optimize-2.0.0.tgz",
+ "integrity": "sha512-nhdT+CRGDZ+bk68ic+Jw1OZ99YCDIKYA5AlVAnBHJvMawSx9YQqQAIj4refNc1/LRieGiuWvhbG3jvPVYho0Dg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/prisma-loader": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/prisma-loader/-/prisma-loader-8.0.3.tgz",
+ "integrity": "sha512-oZhxnMr3Jw2WAW1h9FIhF27xWzIB7bXWM8olz4W12oII4NiZl7VRkFw9IT50zME2Bqi9LGh9pkmMWkjvbOpl+Q==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/url-loader": "^8.0.2",
+ "@graphql-tools/utils": "^10.0.13",
+ "@types/js-yaml": "^4.0.0",
+ "@types/json-stable-stringify": "^1.0.32",
+ "@whatwg-node/fetch": "^0.9.0",
+ "chalk": "^4.1.0",
+ "debug": "^4.3.1",
+ "dotenv": "^16.0.0",
+ "graphql-request": "^6.0.0",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.0",
+ "jose": "^5.0.0",
+ "js-yaml": "^4.0.0",
+ "json-stable-stringify": "^1.0.1",
+ "lodash": "^4.17.20",
+ "scuid": "^1.1.0",
+ "tslib": "^2.4.0",
+ "yaml-ast-parser": "^0.0.43"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/prisma-loader/node_modules/@whatwg-node/events": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.1.tgz",
+ "integrity": "sha512-AyQEn5hIPV7Ze+xFoXVU3QTHXVbWPrzaOkxtENMPMuNL6VVHrp4hHfDt9nrQpjO7BgvuM95dMtkycX5M/DZR3w==",
+ "dev": true,
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/prisma-loader/node_modules/@whatwg-node/fetch": {
+ "version": "0.9.17",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.9.17.tgz",
+ "integrity": "sha512-TDYP3CpCrxwxpiNY0UMNf096H5Ihf67BK1iKGegQl5u9SlpEDYrvnV71gWBGJm+Xm31qOy8ATgma9rm8Pe7/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@whatwg-node/node-fetch": "^0.5.7",
+ "urlpattern-polyfill": "^10.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/prisma-loader/node_modules/@whatwg-node/node-fetch": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.5.7.tgz",
+ "integrity": "sha512-YZA+N3JcW1eh2QRi7o/ij+M07M0dqID73ltgsOEMRyEc2UYVDbyomaih+CWCEZqBIDHw4KMDveXvv4SBZ4TLIw==",
+ "dev": true,
+ "dependencies": {
+ "@kamilkisiela/fast-url-parser": "^1.1.4",
+ "@whatwg-node/events": "^0.1.0",
+ "busboy": "^1.6.0",
+ "fast-querystring": "^1.1.1",
+ "tslib": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/prisma-loader/node_modules/urlpattern-polyfill": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz",
+ "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==",
+ "dev": true
+ },
+ "node_modules/@graphql-tools/relay-operation-optimizer": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/relay-operation-optimizer/-/relay-operation-optimizer-7.0.1.tgz",
+ "integrity": "sha512-y0ZrQ/iyqWZlsS/xrJfSir3TbVYJTYmMOu4TaSz6F4FRDTQ3ie43BlKkhf04rC28pnUOS4BO9pDcAo1D30l5+A==",
+ "dev": true,
+ "dependencies": {
+ "@ardatan/relay-compiler": "12.0.0",
+ "@graphql-tools/utils": "^10.0.13",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/schema": {
+ "version": "10.0.3",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.0.3.tgz",
+ "integrity": "sha512-p28Oh9EcOna6i0yLaCFOnkcBDQECVf3SCexT6ktb86QNj9idnkhI+tCxnwZDh58Qvjd2nURdkbevvoZkvxzCog==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/merge": "^9.0.3",
+ "@graphql-tools/utils": "^10.0.13",
+ "tslib": "^2.4.0",
+ "value-or-promise": "^1.0.12"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/url-loader": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/url-loader/-/url-loader-8.0.2.tgz",
+ "integrity": "sha512-1dKp2K8UuFn7DFo1qX5c1cyazQv2h2ICwA9esHblEqCYrgf69Nk8N7SODmsfWg94OEaI74IqMoM12t7eIGwFzQ==",
+ "dev": true,
+ "dependencies": {
+ "@ardatan/sync-fetch": "^0.0.1",
+ "@graphql-tools/delegate": "^10.0.4",
+ "@graphql-tools/executor-graphql-ws": "^1.1.2",
+ "@graphql-tools/executor-http": "^1.0.9",
+ "@graphql-tools/executor-legacy-ws": "^1.0.6",
+ "@graphql-tools/utils": "^10.0.13",
+ "@graphql-tools/wrap": "^10.0.2",
+ "@types/ws": "^8.0.0",
+ "@whatwg-node/fetch": "^0.9.0",
+ "isomorphic-ws": "^5.0.0",
+ "tslib": "^2.4.0",
+ "value-or-promise": "^1.0.11",
+ "ws": "^8.12.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/url-loader/node_modules/@whatwg-node/events": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.1.tgz",
+ "integrity": "sha512-AyQEn5hIPV7Ze+xFoXVU3QTHXVbWPrzaOkxtENMPMuNL6VVHrp4hHfDt9nrQpjO7BgvuM95dMtkycX5M/DZR3w==",
+ "dev": true,
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/url-loader/node_modules/@whatwg-node/fetch": {
+ "version": "0.9.17",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.9.17.tgz",
+ "integrity": "sha512-TDYP3CpCrxwxpiNY0UMNf096H5Ihf67BK1iKGegQl5u9SlpEDYrvnV71gWBGJm+Xm31qOy8ATgma9rm8Pe7/5Q==",
+ "dev": true,
+ "dependencies": {
+ "@whatwg-node/node-fetch": "^0.5.7",
+ "urlpattern-polyfill": "^10.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/url-loader/node_modules/@whatwg-node/node-fetch": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.5.7.tgz",
+ "integrity": "sha512-YZA+N3JcW1eh2QRi7o/ij+M07M0dqID73ltgsOEMRyEc2UYVDbyomaih+CWCEZqBIDHw4KMDveXvv4SBZ4TLIw==",
+ "dev": true,
+ "dependencies": {
+ "@kamilkisiela/fast-url-parser": "^1.1.4",
+ "@whatwg-node/events": "^0.1.0",
+ "busboy": "^1.6.0",
+ "fast-querystring": "^1.1.1",
+ "tslib": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/url-loader/node_modules/urlpattern-polyfill": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz",
+ "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==",
+ "dev": true
+ },
+ "node_modules/@graphql-tools/utils": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-10.1.0.tgz",
+ "integrity": "sha512-wLPqhgeZ9BZJPRoaQbsDN/CtJDPd/L4qmmtPkjI3NuYJ39x+Eqz1Sh34EAGMuDh+xlOHqBwHczkZUpoK9tvzjw==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-typed-document-node/core": "^3.1.1",
+ "cross-inspect": "1.0.0",
+ "dset": "^3.1.2",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/wrap": {
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/wrap/-/wrap-10.0.2.tgz",
+ "integrity": "sha512-nb/YjBcyF02KBCy3hiyw0nBKIC+qkiDY/tGMCcIe4pM6BPEcnreaPhXA28Rdge7lKtySF4Mhbc86XafFH5bIkQ==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/delegate": "^10.0.4",
+ "@graphql-tools/schema": "^10.0.3",
+ "@graphql-tools/utils": "^10.0.13",
+ "tslib": "^2.4.0",
+ "value-or-promise": "^1.0.12"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-typed-document-node/core": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
+ "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
+ "dev": true,
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array": {
+ "version": "0.11.14",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
+ "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
+ "dev": true,
+ "dependencies": {
+ "@humanwhocodes/object-schema": "^2.0.2",
+ "debug": "^4.3.1",
+ "minimatch": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=10.10.0"
+ }
+ },
+ "node_modules/@humanwhocodes/config-array/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/@humanwhocodes/config-array/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/object-schema": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz",
+ "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==",
+ "dev": true
+ },
+ "node_modules/@iconify-json/material-symbols": {
+ "version": "1.1.74",
+ "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.1.74.tgz",
+ "integrity": "sha512-cuQKvpGWrMNJq0i3ynO+V6yus0Smiupw92GW8Gq/4MHfYfbl1MrbVmafKQAd8RJVqiEXq4/F084OEcigf77UqQ==",
+ "dev": true,
+ "dependencies": {
+ "@iconify/types": "*"
+ }
+ },
+ "node_modules/@iconify/tailwind": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/@iconify/tailwind/-/tailwind-0.1.4.tgz",
+ "integrity": "sha512-U7RzcU2fkwOfMDsGQ3mtpLIaApSnqb+vgcJJknPPbg8/NF5s7tI1o5otEMfcpnLGk4PbYB8bxmKTz7IJVUlU2Q==",
+ "dev": true,
+ "dependencies": {
+ "@iconify/types": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyberalien"
+ }
+ },
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "dev": true
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+ "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+ "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true
+ },
+ "node_modules/@isaacs/cliui/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
+ "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
+ "dependencies": {
+ "@jridgewell/set-array": "^1.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/set-array": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
+ "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.4.15",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.25",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
+ "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@jsonurl/jsonurl": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@jsonurl/jsonurl/-/jsonurl-1.1.7.tgz",
+ "integrity": "sha512-H3aKlfSjVoUhhNRO+2HDtVNq8UT0TV8kyAgJ29bETLdjU6Xz3s1PMzbRkfcAdwuwzHjiisabHpJoP8p+hju6bA=="
+ },
+ "node_modules/@kamilkisiela/fast-url-parser": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@kamilkisiela/fast-url-parser/-/fast-url-parser-1.1.4.tgz",
+ "integrity": "sha512-gbkePEBupNydxCelHCESvFSFM8XPh1Zs/OAVRW/rKpEqPAl5PbOM90Si8mv9bvnR53uPD2s/FiRxdvSejpRJew==",
+ "dev": true
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@npmcli/fs": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz",
+ "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@npmcli/fs/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@npmcli/fs/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@npmcli/fs/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/@npmcli/git": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-4.1.0.tgz",
+ "integrity": "sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ==",
+ "dev": true,
+ "dependencies": {
+ "@npmcli/promise-spawn": "^6.0.0",
+ "lru-cache": "^7.4.4",
+ "npm-pick-manifest": "^8.0.0",
+ "proc-log": "^3.0.0",
+ "promise-inflight": "^1.0.1",
+ "promise-retry": "^2.0.1",
+ "semver": "^7.3.5",
+ "which": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@npmcli/git/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@npmcli/git/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@npmcli/git/node_modules/semver/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@npmcli/git/node_modules/which": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz",
+ "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@npmcli/git/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/@npmcli/installed-package-contents": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz",
+ "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==",
+ "dev": true,
+ "dependencies": {
+ "npm-bundled": "^3.0.0",
+ "npm-normalize-package-bin": "^3.0.0"
+ },
+ "bin": {
+ "installed-package-contents": "lib/index.js"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@npmcli/move-file": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz",
+ "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==",
+ "deprecated": "This functionality has been moved to @npmcli/fs",
+ "dev": true,
+ "dependencies": {
+ "mkdirp": "^1.0.4",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@npmcli/move-file/node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "dev": true,
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@npmcli/node-gyp": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz",
+ "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@npmcli/promise-spawn": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-6.0.2.tgz",
+ "integrity": "sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==",
+ "dev": true,
+ "dependencies": {
+ "which": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@npmcli/promise-spawn/node_modules/which": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz",
+ "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@npmcli/run-script": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-6.0.2.tgz",
+ "integrity": "sha512-NCcr1uQo1k5U+SYlnIrbAh3cxy+OQT1VtqiAbxdymSlptbzBb62AjH2xXgjNCoP073hoa1CfCAcwoZ8k96C4nA==",
+ "dev": true,
+ "dependencies": {
+ "@npmcli/node-gyp": "^3.0.0",
+ "@npmcli/promise-spawn": "^6.0.0",
+ "node-gyp": "^9.0.0",
+ "read-package-json-fast": "^3.0.0",
+ "which": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@npmcli/run-script/node_modules/which": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz",
+ "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/which.js"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@peculiar/asn1-schema": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz",
+ "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==",
+ "dev": true,
+ "dependencies": {
+ "asn1js": "^3.0.5",
+ "pvtsutils": "^1.3.5",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@peculiar/json-schema": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz",
+ "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/@peculiar/webcrypto": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.4.5.tgz",
+ "integrity": "sha512-oDk93QCDGdxFRM8382Zdminzs44dg3M2+E5Np+JWkpqLDyJC9DviMh8F8mEJkYuUcUOGA5jHO5AJJ10MFWdbZw==",
+ "dev": true,
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.3.8",
+ "@peculiar/json-schema": "^1.1.12",
+ "pvtsutils": "^1.3.5",
+ "tslib": "^2.6.2",
+ "webcrypto-core": "^1.7.8"
+ },
+ "engines": {
+ "node": ">=10.12.0"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@pnpm/config.env-replace": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
+ "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.22.0"
+ }
+ },
+ "node_modules/@pnpm/network.ca-file": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz",
+ "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "4.2.10"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ }
+ },
+ "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": {
+ "version": "4.2.10",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz",
+ "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==",
+ "dev": true
+ },
+ "node_modules/@pnpm/npm-conf": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.2.2.tgz",
+ "integrity": "sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==",
+ "dev": true,
+ "dependencies": {
+ "@pnpm/config.env-replace": "^1.1.0",
+ "@pnpm/network.ca-file": "^1.0.1",
+ "config-chain": "^1.1.11"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.24",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz",
+ "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==",
+ "dev": true
+ },
+ "node_modules/@repeaterjs/repeater": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.5.tgz",
+ "integrity": "sha512-l3YHBLAol6d/IKnB9LhpD0cEZWAoe3eFKUyTYWmFmCO2Q/WOckxLQAUyMZWwZV2M/m3+4vgRoaolFqaII82/TA==",
+ "dev": true
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz",
+ "integrity": "sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz",
+ "integrity": "sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz",
+ "integrity": "sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz",
+ "integrity": "sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz",
+ "integrity": "sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz",
+ "integrity": "sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz",
+ "integrity": "sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz",
+ "integrity": "sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz",
+ "integrity": "sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz",
+ "integrity": "sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz",
+ "integrity": "sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz",
+ "integrity": "sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz",
+ "integrity": "sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sigstore/bundle": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-1.1.0.tgz",
+ "integrity": "sha512-PFutXEy0SmQxYI4texPw3dd2KewuNqv7OuK1ZFtY2fM754yhvG2KdgwIhRnoEE2uHdtdGNQ8s0lb94dW9sELog==",
+ "dev": true,
+ "dependencies": {
+ "@sigstore/protobuf-specs": "^0.2.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@sigstore/protobuf-specs": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz",
+ "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@sigstore/sign": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-1.0.0.tgz",
+ "integrity": "sha512-INxFVNQteLtcfGmcoldzV6Je0sbbfh9I16DM4yJPw3j5+TFP8X6uIiA18mvpEa9yyeycAKgPmOA3X9hVdVTPUA==",
+ "dev": true,
+ "dependencies": {
+ "@sigstore/bundle": "^1.1.0",
+ "@sigstore/protobuf-specs": "^0.2.0",
+ "make-fetch-happen": "^11.0.1"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@sigstore/tuf": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-1.0.3.tgz",
+ "integrity": "sha512-2bRovzs0nJZFlCN3rXirE4gwxCn97JNjMmwpecqlbgV9WcxX7WRuIrgzx/X7Ib7MYRbyUTpBYE0s2x6AmZXnlg==",
+ "dev": true,
+ "dependencies": {
+ "@sigstore/protobuf-specs": "^0.2.0",
+ "tuf-js": "^1.1.7"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.8",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
+ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
+ "dev": true
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz",
+ "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@sveltejs/adapter-static": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.1.tgz",
+ "integrity": "sha512-6lMvf7xYEJ+oGeR5L8DFJJrowkefTK6ZgA4JiMqoClMkKq0s6yvsd3FZfCFvX1fQ0tpCD7fkuRVHsnUVgsHyNg==",
+ "dev": true,
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.0.0"
+ }
+ },
+ "node_modules/@sveltejs/kit": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.2.tgz",
+ "integrity": "sha512-1Pm2lsBYURQsjnLyZa+jw75eVD4gYHxGRwPyFe4DAmB3FjTVR8vRNWGeuDLGFcKMh/B1ij6FTUrc9GrerogCng==",
+ "dev": true,
+ "hasInstallScript": true,
+ "dependencies": {
+ "@types/cookie": "^0.6.0",
+ "cookie": "^0.6.0",
+ "devalue": "^4.3.2",
+ "esm-env": "^1.0.0",
+ "import-meta-resolve": "^4.0.0",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.5",
+ "mrmime": "^2.0.0",
+ "sade": "^1.8.1",
+ "set-cookie-parser": "^2.6.0",
+ "sirv": "^2.0.4",
+ "tiny-glob": "^0.2.9"
+ },
+ "bin": {
+ "svelte-kit": "svelte-kit.js"
+ },
+ "engines": {
+ "node": ">=18.13"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^3.0.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "vite": "^5.0.3"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.2.tgz",
+ "integrity": "sha512-MpmF/cju2HqUls50WyTHQBZUV3ovV/Uk8k66AN2gwHogNAG8wnW8xtZDhzNBsFJJuvmq1qnzA5kE7YfMJNFv2Q==",
+ "dev": true,
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^2.0.0",
+ "debug": "^4.3.4",
+ "deepmerge": "^4.3.1",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.5",
+ "svelte-hmr": "^0.15.3",
+ "vitefu": "^0.2.5"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.0.0.tgz",
+ "integrity": "sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^3.0.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "vite": "^5.0.0"
+ }
+ },
+ "node_modules/@szmarczak/http-timer": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
+ "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
+ "dev": true,
+ "dependencies": {
+ "defer-to-connect": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=14.16"
+ }
+ },
+ "node_modules/@tootallnate/once": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
+ "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tufjs/canonical-json": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-1.0.0.tgz",
+ "integrity": "sha512-QTnf++uxunWvG2z3UFNzAoQPHxnSXOwtaI3iJ+AohhV+5vONuArPjJE7aPXPVXfXJsqrVbZBu9b81AJoSd09IQ==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@tufjs/models": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-1.0.4.tgz",
+ "integrity": "sha512-qaGV9ltJP0EO25YfFUPhxRVK0evXFIAGicsVXuRim4Ed9cjPxYhNnNJ49SFmbeLgtxpslIkX317IgpfcHPVj/A==",
+ "dev": true,
+ "dependencies": {
+ "@tufjs/canonical-json": "1.0.0",
+ "minimatch": "^9.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "dev": true
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
+ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw=="
+ },
+ "node_modules/@types/http-cache-semantics": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
+ "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
+ "dev": true
+ },
+ "node_modules/@types/js-yaml": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
+ "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
+ "dev": true
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true
+ },
+ "node_modules/@types/json-stable-stringify": {
+ "version": "1.0.36",
+ "resolved": "https://registry.npmjs.org/@types/json-stable-stringify/-/json-stable-stringify-1.0.36.tgz",
+ "integrity": "sha512-b7bq23s4fgBB76n34m2b3RBf6M369B0Z9uRR8aHTMd8kZISRkmDEpPD8hhpYvDFzr3bJCPES96cm3Q6qRNDbQw==",
+ "dev": true
+ },
+ "node_modules/@types/node": {
+ "version": "20.11.24",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.24.tgz",
+ "integrity": "sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==",
+ "dev": true,
+ "dependencies": {
+ "undici-types": "~5.26.4"
+ }
+ },
+ "node_modules/@types/pug": {
+ "version": "2.0.10",
+ "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz",
+ "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==",
+ "dev": true
+ },
+ "node_modules/@types/semver": {
+ "version": "7.5.8",
+ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
+ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
+ "dev": true
+ },
+ "node_modules/@types/ws": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
+ "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.0.tgz",
+ "integrity": "sha512-j6vT/kCulhG5wBmGtstKeiVr1rdXE4nk+DT1k6trYkwlrvW9eOF5ZbgKnd/YR6PcM4uTEXa0h6Fcvf6X7Dxl0w==",
+ "dev": true,
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.5.1",
+ "@typescript-eslint/scope-manager": "7.1.0",
+ "@typescript-eslint/type-utils": "7.1.0",
+ "@typescript-eslint/utils": "7.1.0",
+ "@typescript-eslint/visitor-keys": "7.1.0",
+ "debug": "^4.3.4",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.4",
+ "natural-compare": "^1.4.0",
+ "semver": "^7.5.4",
+ "ts-api-utils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^7.0.0",
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.0.tgz",
+ "integrity": "sha512-V1EknKUubZ1gWFjiOZhDSNToOjs63/9O0puCgGS8aDOgpZY326fzFu15QAUjwaXzRZjf/qdsdBrckYdv9YxB8w==",
+ "dev": true,
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "7.1.0",
+ "@typescript-eslint/types": "7.1.0",
+ "@typescript-eslint/typescript-estree": "7.1.0",
+ "@typescript-eslint/visitor-keys": "7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.0.tgz",
+ "integrity": "sha512-6TmN4OJiohHfoOdGZ3huuLhpiUgOGTpgXNUPJgeZOZR3DnIpdSgtt83RS35OYNNXxM4TScVlpVKC9jyQSETR1A==",
+ "dev": true,
+ "dependencies": {
+ "@typescript-eslint/types": "7.1.0",
+ "@typescript-eslint/visitor-keys": "7.1.0"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.0.tgz",
+ "integrity": "sha512-UZIhv8G+5b5skkcuhgvxYWHjk7FW7/JP5lPASMEUoliAPwIH/rxoUSQPia2cuOj9AmDZmwUl1usKm85t5VUMew==",
+ "dev": true,
+ "dependencies": {
+ "@typescript-eslint/typescript-estree": "7.1.0",
+ "@typescript-eslint/utils": "7.1.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.0.tgz",
+ "integrity": "sha512-qTWjWieJ1tRJkxgZYXx6WUYtWlBc48YRxgY2JN1aGeVpkhmnopq+SUC8UEVGNXIvWH7XyuTjwALfG6bFEgCkQA==",
+ "dev": true,
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.0.tgz",
+ "integrity": "sha512-k7MyrbD6E463CBbSpcOnwa8oXRdHzH1WiVzOipK3L5KSML92ZKgUBrTlehdi7PEIMT8k0bQixHUGXggPAlKnOQ==",
+ "dev": true,
+ "dependencies": {
+ "@typescript-eslint/types": "7.1.0",
+ "@typescript-eslint/visitor-keys": "7.1.0",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "9.0.3",
+ "semver": "^7.5.4",
+ "ts-api-utils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.0.tgz",
+ "integrity": "sha512-WUFba6PZC5OCGEmbweGpnNJytJiLG7ZvDBJJoUcX4qZYf1mGZ97mO2Mps6O2efxJcJdRNpqweCistDbZMwIVHw==",
+ "dev": true,
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@types/json-schema": "^7.0.12",
+ "@types/semver": "^7.5.0",
+ "@typescript-eslint/scope-manager": "7.1.0",
+ "@typescript-eslint/types": "7.1.0",
+ "@typescript-eslint/typescript-estree": "7.1.0",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.0.tgz",
+ "integrity": "sha512-FhUqNWluiGNzlvnDZiXad4mZRhtghdoKW6e98GoEOYSu5cND+E39rG5KwJMUzeENwm1ztYBRqof8wMLP+wNPIA==",
+ "dev": true,
+ "dependencies": {
+ "@typescript-eslint/types": "7.1.0",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
+ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==",
+ "dev": true
+ },
+ "node_modules/@urql/core": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@urql/core/-/core-4.3.0.tgz",
+ "integrity": "sha512-wT+FeL8DG4x5o6RfHEnONNFVDM3616ouzATMYUClB6CB+iIu2mwfBKd7xSUxYOZmwtxna5/hDRQdMl3nbQZlnw==",
+ "dependencies": {
+ "@0no-co/graphql.web": "^1.0.1",
+ "wonka": "^6.3.2"
+ }
+ },
+ "node_modules/@urql/svelte": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@urql/svelte/-/svelte-4.1.0.tgz",
+ "integrity": "sha512-Ov3EclCjaXPPTjKNTcIDlAG3qY/jhLjl/J9yyz9FeLUQ9S2jEgsvlzNXibrY27f4ihD4gH36CNGuj1XOi5hEEQ==",
+ "dependencies": {
+ "@urql/core": "^4.3.0",
+ "wonka": "^6.3.2"
+ },
+ "peerDependencies": {
+ "svelte": "^3.0.0 || ^4.0.0"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz",
+ "integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/spy": "1.3.1",
+ "@vitest/utils": "1.3.1",
+ "chai": "^4.3.10"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz",
+ "integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/utils": "1.3.1",
+ "p-limit": "^5.0.0",
+ "pathe": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner/node_modules/p-limit": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
+ "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
+ "dev": true,
+ "dependencies": {
+ "yocto-queue": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@vitest/runner/node_modules/yocto-queue": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz",
+ "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz",
+ "integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==",
+ "dev": true,
+ "dependencies": {
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz",
+ "integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==",
+ "dev": true,
+ "dependencies": {
+ "tinyspy": "^2.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz",
+ "integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==",
+ "dev": true,
+ "dependencies": {
+ "diff-sequences": "^29.6.3",
+ "estree-walker": "^3.0.3",
+ "loupe": "^2.3.7",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@whatwg-node/events": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.0.3.tgz",
+ "integrity": "sha512-IqnKIDWfXBJkvy/k6tzskWTc2NK3LcqHlb+KHGCrjOCH4jfQckRX0NAiIcC/vIqQkzLYw2r2CTSwAxcrtcD6lA==",
+ "dev": true
+ },
+ "node_modules/@whatwg-node/fetch": {
+ "version": "0.8.8",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.8.8.tgz",
+ "integrity": "sha512-CdcjGC2vdKhc13KKxgsc6/616BQ7ooDIgPeTuAiE8qfCnS0mGzcfCOoZXypQSz73nxI+GWc7ZReIAVhxoE1KCg==",
+ "dev": true,
+ "dependencies": {
+ "@peculiar/webcrypto": "^1.4.0",
+ "@whatwg-node/node-fetch": "^0.3.6",
+ "busboy": "^1.6.0",
+ "urlpattern-polyfill": "^8.0.0",
+ "web-streams-polyfill": "^3.2.1"
+ }
+ },
+ "node_modules/@whatwg-node/node-fetch": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.3.6.tgz",
+ "integrity": "sha512-w9wKgDO4C95qnXZRwZTfCmLWqyRnooGjcIwG0wADWjw9/HN0p7dtvtgSvItZtUyNteEvgTrd8QojNEqV6DAGTA==",
+ "dev": true,
+ "dependencies": {
+ "@whatwg-node/events": "^0.0.3",
+ "busboy": "^1.6.0",
+ "fast-querystring": "^1.1.1",
+ "fast-url-parser": "^1.1.3",
+ "tslib": "^2.3.1"
+ }
+ },
+ "node_modules/@zerodevx/svelte-toast": {
+ "version": "0.9.5",
+ "resolved": "https://registry.npmjs.org/@zerodevx/svelte-toast/-/svelte-toast-0.9.5.tgz",
+ "integrity": "sha512-JLeB/oRdJfT+dz9A5bgd3Z7TuQnBQbeUtXrGIrNWMGqWbabpepBF2KxtWVhL2qtxpRqhae2f6NAOzH7xs4jUSw==",
+ "dev": true,
+ "peerDependencies": {
+ "svelte": "^3.57.0 || ^4.0.0"
+ }
+ },
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "dev": true
+ },
+ "node_modules/acorn": {
+ "version": "8.11.3",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
+ "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
+ "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
+ "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/agentkeepalive": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz",
+ "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==",
+ "dev": true,
+ "dependencies": {
+ "humanize-ms": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/aggregate-error": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+ "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+ "dev": true,
+ "dependencies": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-align": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
+ "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.1.0"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
+ "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
+ "dev": true,
+ "dependencies": {
+ "type-fest": "^0.21.3"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/aproba": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
+ "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
+ "dev": true
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz",
+ "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==",
+ "dev": true,
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/array-union": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/asap": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
+ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
+ "dev": true
+ },
+ "node_modules/asn1js": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz",
+ "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==",
+ "dev": true,
+ "dependencies": {
+ "pvtsutils": "^1.3.2",
+ "pvutils": "^1.1.3",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/astral-regex": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/auto-bind": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-4.0.0.tgz",
+ "integrity": "sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.18",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.18.tgz",
+ "integrity": "sha512-1DKbDfsr6KUElM6wg+0zRNkB/Q7WcKYAaK+pzXn+Xqmszm/5Xa9coeNdtP88Vi+dPzZnMjhge8GIV49ZQkDa+g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "browserslist": "^4.23.0",
+ "caniuse-lite": "^1.0.30001591",
+ "fraction.js": "^4.3.7",
+ "normalize-range": "^0.1.2",
+ "picocolors": "^1.0.0",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz",
+ "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/babel-plugin-syntax-trailing-function-commas": {
+ "version": "7.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-7.0.0-beta.0.tgz",
+ "integrity": "sha512-Xj9XuRuz3nTSbaTXWv3itLOcxyF4oPD8douBBmj7U9BBC6nEBYfyOJYQMf/8PJAFotC62UY5dFfIGEPr7WswzQ==",
+ "dev": true
+ },
+ "node_modules/babel-preset-fbjs": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/babel-preset-fbjs/-/babel-preset-fbjs-3.4.0.tgz",
+ "integrity": "sha512-9ywCsCvo1ojrw0b+XYk7aFvTH6D9064t0RIL1rtMf3nsa02Xw41MS7sZw216Im35xj/UY0PDBQsa1brUDDF1Ow==",
+ "dev": true,
+ "dependencies": {
+ "@babel/plugin-proposal-class-properties": "^7.0.0",
+ "@babel/plugin-proposal-object-rest-spread": "^7.0.0",
+ "@babel/plugin-syntax-class-properties": "^7.0.0",
+ "@babel/plugin-syntax-flow": "^7.0.0",
+ "@babel/plugin-syntax-jsx": "^7.0.0",
+ "@babel/plugin-syntax-object-rest-spread": "^7.0.0",
+ "@babel/plugin-transform-arrow-functions": "^7.0.0",
+ "@babel/plugin-transform-block-scoped-functions": "^7.0.0",
+ "@babel/plugin-transform-block-scoping": "^7.0.0",
+ "@babel/plugin-transform-classes": "^7.0.0",
+ "@babel/plugin-transform-computed-properties": "^7.0.0",
+ "@babel/plugin-transform-destructuring": "^7.0.0",
+ "@babel/plugin-transform-flow-strip-types": "^7.0.0",
+ "@babel/plugin-transform-for-of": "^7.0.0",
+ "@babel/plugin-transform-function-name": "^7.0.0",
+ "@babel/plugin-transform-literals": "^7.0.0",
+ "@babel/plugin-transform-member-expression-literals": "^7.0.0",
+ "@babel/plugin-transform-modules-commonjs": "^7.0.0",
+ "@babel/plugin-transform-object-super": "^7.0.0",
+ "@babel/plugin-transform-parameters": "^7.0.0",
+ "@babel/plugin-transform-property-literals": "^7.0.0",
+ "@babel/plugin-transform-react-display-name": "^7.0.0",
+ "@babel/plugin-transform-react-jsx": "^7.0.0",
+ "@babel/plugin-transform-shorthand-properties": "^7.0.0",
+ "@babel/plugin-transform-spread": "^7.0.0",
+ "@babel/plugin-transform-template-literals": "^7.0.0",
+ "babel-plugin-syntax-trailing-function-commas": "^7.0.0-beta.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "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/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+ "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "dev": true,
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/boxen": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz",
+ "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==",
+ "dev": true,
+ "dependencies": {
+ "ansi-align": "^3.0.1",
+ "camelcase": "^7.0.1",
+ "chalk": "^5.2.0",
+ "cli-boxes": "^3.0.0",
+ "string-width": "^5.1.2",
+ "type-fest": "^2.13.0",
+ "widest-line": "^4.0.1",
+ "wrap-ansi": "^8.1.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/ansi-regex": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+ "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/ansi-styles": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
+ "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/chalk": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+ "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+ "dev": true,
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true
+ },
+ "node_modules/boxen/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/boxen/node_modules/type-fest": {
+ "version": "2.19.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
+ "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/boxen/node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+ "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+ "dev": true,
+ "dependencies": {
+ "fill-range": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.23.0",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz",
+ "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001587",
+ "electron-to-chromium": "^1.4.668",
+ "node-releases": "^2.0.14",
+ "update-browserslist-db": "^1.0.13"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/bser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
+ "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
+ "dev": true,
+ "dependencies": {
+ "node-int64": "^0.4.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true
+ },
+ "node_modules/builtins": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.0.1.tgz",
+ "integrity": "sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^7.0.0"
+ }
+ },
+ "node_modules/builtins/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/builtins/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/builtins/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dev": true,
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cacache": {
+ "version": "17.1.4",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-17.1.4.tgz",
+ "integrity": "sha512-/aJwG2l3ZMJ1xNAnqbMpA40of9dj/pIH3QfiuQSqjfPJF747VR0J/bHn+/KdNnHKc6XQcWt/AfRSBft82W1d2A==",
+ "dev": true,
+ "dependencies": {
+ "@npmcli/fs": "^3.1.0",
+ "fs-minipass": "^3.0.0",
+ "glob": "^10.2.2",
+ "lru-cache": "^7.7.1",
+ "minipass": "^7.0.3",
+ "minipass-collect": "^1.0.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "p-map": "^4.0.0",
+ "ssri": "^10.0.0",
+ "tar": "^6.1.11",
+ "unique-filename": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/cacache/node_modules/glob": {
+ "version": "10.3.10",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+ "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+ "dev": true,
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^2.3.5",
+ "minimatch": "^9.0.1",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+ "path-scurry": "^1.10.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/cacache/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cacache/node_modules/minipass": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
+ "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/cacheable-lookup": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
+ "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.16"
+ }
+ },
+ "node_modules/cacheable-request": {
+ "version": "10.2.14",
+ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz",
+ "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/http-cache-semantics": "^4.0.2",
+ "get-stream": "^6.0.1",
+ "http-cache-semantics": "^4.1.1",
+ "keyv": "^4.5.3",
+ "mimic-response": "^4.0.0",
+ "normalize-url": "^8.0.0",
+ "responselike": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
+ "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==",
+ "dev": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camel-case": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
+ "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
+ "dev": true,
+ "dependencies": {
+ "pascal-case": "^3.1.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz",
+ "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001593",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001593.tgz",
+ "integrity": "sha512-UWM1zlo3cZfkpBysd7AS+z+v007q9G1+fLTUU42rQnY6t2axoogPW/xol6T7juU5EUoOhML4WgBIdG+9yYqAjQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ]
+ },
+ "node_modules/capital-case": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz",
+ "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==",
+ "dev": true,
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3",
+ "upper-case-first": "^2.0.2"
+ }
+ },
+ "node_modules/chai": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz",
+ "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==",
+ "dev": true,
+ "dependencies": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.3",
+ "deep-eql": "^4.1.3",
+ "get-func-name": "^2.0.2",
+ "loupe": "^2.3.6",
+ "pathval": "^1.1.1",
+ "type-detect": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/change-case": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz",
+ "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==",
+ "dev": true,
+ "dependencies": {
+ "camel-case": "^4.1.2",
+ "capital-case": "^1.0.4",
+ "constant-case": "^3.0.4",
+ "dot-case": "^3.0.4",
+ "header-case": "^2.0.4",
+ "no-case": "^3.0.4",
+ "param-case": "^3.0.4",
+ "pascal-case": "^3.1.2",
+ "path-case": "^3.0.4",
+ "sentence-case": "^3.0.4",
+ "snake-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/change-case-all": {
+ "version": "1.0.15",
+ "resolved": "https://registry.npmjs.org/change-case-all/-/change-case-all-1.0.15.tgz",
+ "integrity": "sha512-3+GIFhk3sNuvFAJKU46o26OdzudQlPNBCu1ZQi3cMeMHhty1bhDxu2WrEilVNYaGvqUtR1VSigFcJOiS13dRhQ==",
+ "dev": true,
+ "dependencies": {
+ "change-case": "^4.1.2",
+ "is-lower-case": "^2.0.2",
+ "is-upper-case": "^2.0.2",
+ "lower-case": "^2.0.2",
+ "lower-case-first": "^2.0.2",
+ "sponge-case": "^1.0.1",
+ "swap-case": "^2.0.2",
+ "title-case": "^3.0.3",
+ "upper-case": "^2.0.2",
+ "upper-case-first": "^2.0.2"
+ }
+ },
+ "node_modules/chardet": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
+ "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
+ "dev": true
+ },
+ "node_modules/check-error": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+ "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+ "dev": true,
+ "dependencies": {
+ "get-func-name": "^2.0.2"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ci-info": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
+ "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/clean-stack": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cli-boxes": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
+ "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-cursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
+ "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
+ "dev": true,
+ "dependencies": {
+ "restore-cursor": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cli-spinners": {
+ "version": "2.9.2",
+ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
+ "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-table3": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz",
+ "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.2.0"
+ },
+ "engines": {
+ "node": "10.* || >= 12.*"
+ },
+ "optionalDependencies": {
+ "@colors/colors": "1.5.0"
+ }
+ },
+ "node_modules/cli-truncate": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
+ "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
+ "dev": true,
+ "dependencies": {
+ "slice-ansi": "^3.0.0",
+ "string-width": "^4.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cli-width": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz",
+ "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/clone": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
+ "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/code-red": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
+ "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15",
+ "@types/estree": "^1.0.1",
+ "acorn": "^8.10.0",
+ "estree-walker": "^3.0.3",
+ "periscopic": "^3.1.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "dev": true,
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/colorette": {
+ "version": "2.0.20",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
+ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
+ "dev": true
+ },
+ "node_modules/commander": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/common-tags": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
+ "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true
+ },
+ "node_modules/config-chain": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
+ "dev": true,
+ "dependencies": {
+ "ini": "^1.3.4",
+ "proto-list": "~1.2.1"
+ }
+ },
+ "node_modules/config-chain/node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true
+ },
+ "node_modules/configstore": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz",
+ "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==",
+ "dev": true,
+ "dependencies": {
+ "dot-prop": "^6.0.1",
+ "graceful-fs": "^4.2.6",
+ "unique-string": "^3.0.0",
+ "write-file-atomic": "^3.0.3",
+ "xdg-basedir": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/yeoman/configstore?sponsor=1"
+ }
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "dev": true
+ },
+ "node_modules/constant-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz",
+ "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==",
+ "dev": true,
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3",
+ "upper-case": "^2.0.2"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true
+ },
+ "node_modules/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cosmiconfig": {
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
+ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+ "dev": true,
+ "dependencies": {
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0",
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cross-fetch": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz",
+ "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==",
+ "dev": true,
+ "dependencies": {
+ "node-fetch": "^2.6.12"
+ }
+ },
+ "node_modules/cross-inspect": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.0.tgz",
+ "integrity": "sha512-4PFfn4b5ZN6FMNGSZlyb7wUhuN8wvj8t/VQHZdM4JsDcruGJ8L2kf9zao98QIrBPFCpdk27qst/AGTl7pL3ypQ==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/crypto-random-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
+ "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
+ "dev": true,
+ "dependencies": {
+ "type-fest": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/crypto-random-string/node_modules/type-fest": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
+ "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
+ "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
+ "dependencies": {
+ "mdn-data": "2.0.30",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "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/dataloader": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.2.tgz",
+ "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==",
+ "dev": true
+ },
+ "node_modules/date-fns": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz",
+ "integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/debounce": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
+ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
+ "dev": true
+ },
+ "node_modules/debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "2.1.2"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "dev": true,
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/decompress-response/node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
+ "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==",
+ "dev": true,
+ "dependencies": {
+ "type-detect": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/defaults": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz",
+ "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
+ "dev": true,
+ "dependencies": {
+ "clone": "^1.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/defer-to-connect": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+ "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "dev": true
+ },
+ "node_modules/dependency-graph": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz",
+ "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-indent": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz",
+ "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz",
+ "integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==",
+ "dev": true
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true
+ },
+ "node_modules/diff-sequences": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
+ "dev": true,
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/dir-glob": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+ "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+ "dev": true,
+ "dependencies": {
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/dot-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
+ "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
+ "dev": true,
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/dot-prop": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
+ "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==",
+ "dev": true,
+ "dependencies": {
+ "is-obj": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.4.5",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
+ "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dset": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.3.tgz",
+ "integrity": "sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.4.690",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.690.tgz",
+ "integrity": "sha512-+2OAGjUx68xElQhydpcbqH50hE8Vs2K6TkAeLhICYfndb67CVH0UsZaijmRUE3rHlIxU1u0jxwhgVe6fK3YANA==",
+ "dev": true
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "node_modules/encoding": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
+ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "iconv-lite": "^0.6.2"
+ }
+ },
+ "node_modules/encoding/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/err-code": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz",
+ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==",
+ "dev": true
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "dev": true,
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
+ "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
+ "dev": true,
+ "dependencies": {
+ "get-intrinsic": "^1.2.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es6-promise": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz",
+ "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==",
+ "dev": true
+ },
+ "node_modules/esbuild": {
+ "version": "0.19.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
+ "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.19.12",
+ "@esbuild/android-arm": "0.19.12",
+ "@esbuild/android-arm64": "0.19.12",
+ "@esbuild/android-x64": "0.19.12",
+ "@esbuild/darwin-arm64": "0.19.12",
+ "@esbuild/darwin-x64": "0.19.12",
+ "@esbuild/freebsd-arm64": "0.19.12",
+ "@esbuild/freebsd-x64": "0.19.12",
+ "@esbuild/linux-arm": "0.19.12",
+ "@esbuild/linux-arm64": "0.19.12",
+ "@esbuild/linux-ia32": "0.19.12",
+ "@esbuild/linux-loong64": "0.19.12",
+ "@esbuild/linux-mips64el": "0.19.12",
+ "@esbuild/linux-ppc64": "0.19.12",
+ "@esbuild/linux-riscv64": "0.19.12",
+ "@esbuild/linux-s390x": "0.19.12",
+ "@esbuild/linux-x64": "0.19.12",
+ "@esbuild/netbsd-x64": "0.19.12",
+ "@esbuild/openbsd-x64": "0.19.12",
+ "@esbuild/sunos-x64": "0.19.12",
+ "@esbuild/win32-arm64": "0.19.12",
+ "@esbuild/win32-ia32": "0.19.12",
+ "@esbuild/win32-x64": "0.19.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
+ "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-goat": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz",
+ "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "8.57.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
+ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
+ "dev": true,
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@eslint-community/regexpp": "^4.6.1",
+ "@eslint/eslintrc": "^2.1.4",
+ "@eslint/js": "8.57.0",
+ "@humanwhocodes/config-array": "^0.11.14",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@nodelib/fs.walk": "^1.2.8",
+ "@ungap/structured-clone": "^1.2.0",
+ "ajv": "^6.12.4",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.3.2",
+ "doctrine": "^3.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^7.2.2",
+ "eslint-visitor-keys": "^3.4.3",
+ "espree": "^9.6.1",
+ "esquery": "^1.4.2",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "globals": "^13.19.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.2",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3",
+ "strip-ansi": "^6.0.1",
+ "text-table": "^0.2.0"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-compat-utils": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.1.2.tgz",
+ "integrity": "sha512-Jia4JDldWnFNIru1Ehx1H5s9/yxiRHY/TimCuUc0jNexew3cF1gI6CYZil1ociakfWO3rRqFjl1mskBblB3RYg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "eslint": ">=6.0.0"
+ }
+ },
+ "node_modules/eslint-config-prettier": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
+ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
+ "dev": true,
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-svelte": {
+ "version": "2.35.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.35.1.tgz",
+ "integrity": "sha512-IF8TpLnROSGy98Z3NrsKXWDSCbNY2ReHDcrYTuXZMbfX7VmESISR78TWgO9zdg4Dht1X8coub5jKwHzP0ExRug==",
+ "dev": true,
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.2.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14",
+ "debug": "^4.3.1",
+ "eslint-compat-utils": "^0.1.2",
+ "esutils": "^2.0.3",
+ "known-css-properties": "^0.29.0",
+ "postcss": "^8.4.5",
+ "postcss-load-config": "^3.1.4",
+ "postcss-safe-parser": "^6.0.0",
+ "postcss-selector-parser": "^6.0.11",
+ "semver": "^7.5.3",
+ "svelte-eslint-parser": ">=0.33.0 <1.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ota-meshi"
+ },
+ "peerDependencies": {
+ "eslint": "^7.0.0 || ^8.0.0-0",
+ "svelte": "^3.37.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "svelte": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-svelte/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/eslint-plugin-svelte/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/eslint-plugin-svelte/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/eslint-scope": {
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
+ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==",
+ "dev": true,
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/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/eslint/node_modules/globals": {
+ "version": "13.24.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
+ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
+ "dev": true,
+ "dependencies": {
+ "type-fest": "^0.20.2"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/eslint/node_modules/type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz",
+ "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==",
+ "dev": true
+ },
+ "node_modules/espree": {
+ "version": "9.6.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
+ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.9.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^3.4.1"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz",
+ "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/execa": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+ "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^8.0.1",
+ "human-signals": "^5.0.0",
+ "is-stream": "^3.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^5.1.0",
+ "onetime": "^6.0.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.17"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/execa/node_modules/get-stream": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+ "dev": true,
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/execa/node_modules/mimic-fn": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
+ "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/execa/node_modules/onetime": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
+ "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
+ "dev": true,
+ "dependencies": {
+ "mimic-fn": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/execa/node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/exponential-backoff": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz",
+ "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==",
+ "dev": true
+ },
+ "node_modules/external-editor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
+ "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==",
+ "dev": true,
+ "dependencies": {
+ "chardet": "^0.7.0",
+ "iconv-lite": "^0.4.24",
+ "tmp": "^0.0.33"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/extract-files": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/extract-files/-/extract-files-11.0.0.tgz",
+ "integrity": "sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jaydenseric"
+ }
+ },
+ "node_modules/fast-decode-uri-component": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
+ "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
+ "dev": true
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+ "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+ "dev": true,
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true
+ },
+ "node_modules/fast-memoize": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz",
+ "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==",
+ "dev": true
+ },
+ "node_modules/fast-querystring": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
+ "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
+ "dev": true,
+ "dependencies": {
+ "fast-decode-uri-component": "^1.0.1"
+ }
+ },
+ "node_modules/fast-url-parser": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz",
+ "integrity": "sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^1.3.2"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.17.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
+ "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
+ "dev": true,
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fb-watchman": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
+ "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==",
+ "dev": true,
+ "dependencies": {
+ "bser": "2.1.1"
+ }
+ },
+ "node_modules/fbjs": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
+ "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
+ "dev": true,
+ "dependencies": {
+ "cross-fetch": "^3.1.5",
+ "fbjs-css-vars": "^1.0.0",
+ "loose-envify": "^1.0.0",
+ "object-assign": "^4.1.0",
+ "promise": "^7.1.1",
+ "setimmediate": "^1.0.5",
+ "ua-parser-js": "^1.0.35"
+ }
+ },
+ "node_modules/fbjs-css-vars": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
+ "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
+ "dev": true
+ },
+ "node_modules/figures": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
+ "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
+ "dev": true,
+ "dependencies": {
+ "escape-string-regexp": "^1.0.5"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/figures/node_modules/escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "dependencies": {
+ "flat-cache": "^3.0.4"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/filesize": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.0.tgz",
+ "integrity": "sha512-GTLKYyBSDz3nPhlLVPjPWZCnhkd9TrrRArNcy8Z+J2cqScB7h2McAzR6NBX6nYOoWafql0roY8hrocxnZBv9CQ==",
+ "engines": {
+ "node": ">= 10.4.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+ "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
+ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==",
+ "dev": true,
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.3",
+ "rimraf": "^3.0.2"
+ },
+ "engines": {
+ "node": "^10.12.0 || >=12.0.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
+ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
+ "dev": true
+ },
+ "node_modules/foreground-child": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
+ "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.0",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/foreground-child/node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/form-data-encoder": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz",
+ "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 14.17"
+ }
+ },
+ "node_modules/fp-and-or": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/fp-and-or/-/fp-and-or-0.1.4.tgz",
+ "integrity": "sha512-+yRYRhpnFPWXSly/6V4Lw9IfOV26uu30kynGJ03PW+MnjOEQe45RZ141QcS0aJehYBYA50GfCDnsRbFJdhssRw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
+ "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "patreon",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fs-minipass": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz",
+ "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/fs-minipass/node_modules/minipass": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
+ "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gauge": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz",
+ "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==",
+ "dev": true,
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.3",
+ "console-control-strings": "^1.1.0",
+ "has-unicode": "^2.0.1",
+ "signal-exit": "^3.0.7",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.5"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-func-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
+ "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
+ "dev": true,
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "has-proto": "^1.0.1",
+ "has-symbols": "^1.0.3",
+ "hasown": "^2.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-stdin": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz",
+ "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/glob/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/glob/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/global-dirs": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz",
+ "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==",
+ "dev": true,
+ "dependencies": {
+ "ini": "2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/global-dirs/node_modules/ini": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz",
+ "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/globalyzer": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
+ "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==",
+ "dev": true
+ },
+ "node_modules/globby": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+ "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+ "dev": true,
+ "dependencies": {
+ "array-union": "^2.1.0",
+ "dir-glob": "^3.0.1",
+ "fast-glob": "^3.2.9",
+ "ignore": "^5.2.0",
+ "merge2": "^1.4.1",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globrex": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
+ "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
+ "dev": true
+ },
+ "node_modules/gopd": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
+ "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
+ "dev": true,
+ "dependencies": {
+ "get-intrinsic": "^1.1.3"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/got": {
+ "version": "12.6.1",
+ "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz",
+ "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==",
+ "dev": true,
+ "dependencies": {
+ "@sindresorhus/is": "^5.2.0",
+ "@szmarczak/http-timer": "^5.0.1",
+ "cacheable-lookup": "^7.0.0",
+ "cacheable-request": "^10.2.8",
+ "decompress-response": "^6.0.0",
+ "form-data-encoder": "^2.1.2",
+ "get-stream": "^6.0.1",
+ "http2-wrapper": "^2.1.10",
+ "lowercase-keys": "^3.0.0",
+ "p-cancelable": "^3.0.0",
+ "responselike": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/got?sponsor=1"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true
+ },
+ "node_modules/graphemer": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
+ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
+ "dev": true
+ },
+ "node_modules/graphql": {
+ "version": "16.8.1",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz",
+ "integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==",
+ "engines": {
+ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
+ }
+ },
+ "node_modules/graphql-config": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/graphql-config/-/graphql-config-5.0.3.tgz",
+ "integrity": "sha512-BNGZaoxIBkv9yy6Y7omvsaBUHOzfFcII3UN++tpH8MGOKFPFkCPZuwx09ggANMt8FgyWP1Od8SWPmrUEZca4NQ==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-tools/graphql-file-loader": "^8.0.0",
+ "@graphql-tools/json-file-loader": "^8.0.0",
+ "@graphql-tools/load": "^8.0.0",
+ "@graphql-tools/merge": "^9.0.0",
+ "@graphql-tools/url-loader": "^8.0.0",
+ "@graphql-tools/utils": "^10.0.0",
+ "cosmiconfig": "^8.1.0",
+ "jiti": "^1.18.2",
+ "minimatch": "^4.2.3",
+ "string-env-interpolation": "^1.0.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "peerDependencies": {
+ "cosmiconfig-toml-loader": "^1.0.0",
+ "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ },
+ "peerDependenciesMeta": {
+ "cosmiconfig-toml-loader": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/graphql-config/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/graphql-config/node_modules/minimatch": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.3.tgz",
+ "integrity": "sha512-lIUdtK5hdofgCTu3aT0sOaHsYR37viUuIc0rwnnDXImbwFRcumyLMeZaM0t0I/fgxS6s6JMfu0rLD1Wz9pv1ng==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/graphql-request": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-6.1.0.tgz",
+ "integrity": "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw==",
+ "dev": true,
+ "dependencies": {
+ "@graphql-typed-document-node/core": "^3.2.0",
+ "cross-fetch": "^3.1.5"
+ },
+ "peerDependencies": {
+ "graphql": "14 - 16"
+ }
+ },
+ "node_modules/graphql-tag": {
+ "version": "2.12.6",
+ "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz",
+ "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
+ }
+ },
+ "node_modules/graphql-ws": {
+ "version": "5.15.0",
+ "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.15.0.tgz",
+ "integrity": "sha512-xWGAtm3fig9TIhSaNsg0FaDZ8Pyn/3re3RFlP4rhQcmjRDIPpk1EhRuNB+YSJtLzttyuToaDiNhwT1OMoGnJnw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "graphql": ">=0.11 <=16"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
+ "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "dev": true
+ },
+ "node_modules/has-yarn": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-3.0.0.tgz",
+ "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz",
+ "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==",
+ "dev": true,
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/header-case": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz",
+ "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==",
+ "dev": true,
+ "dependencies": {
+ "capital-case": "^1.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/hosted-git-info": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-5.2.1.tgz",
+ "integrity": "sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^7.5.1"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/hosted-git-info/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
+ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==",
+ "dev": true
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/http2-wrapper": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
+ "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
+ "dev": true,
+ "dependencies": {
+ "quick-lru": "^5.1.1",
+ "resolve-alpn": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz",
+ "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==",
+ "dev": true,
+ "dependencies": {
+ "agent-base": "^7.0.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
+ "node_modules/humanize-ms": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+ "dev": true,
+ "dependencies": {
+ "ms": "^2.0.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "dev": true,
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/ignore": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
+ "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/ignore-walk": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.4.tgz",
+ "integrity": "sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw==",
+ "dev": true,
+ "dependencies": {
+ "minimatch": "^9.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/immutable": {
+ "version": "3.7.6",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz",
+ "integrity": "sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "dev": true,
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/import-fresh/node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/import-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/import-from/-/import-from-4.0.0.tgz",
+ "integrity": "sha512-P9J71vT5nLlDeV8FHs5nNxaLbrpfAV5cF5srvbZfpwpcJoM/xZR3hiv+q+SAnuSmuGbXMWud063iIMx/V/EWZQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/import-lazy": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
+ "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/import-meta-resolve": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz",
+ "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==",
+ "dev": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/infer-owner": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz",
+ "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==",
+ "dev": true
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "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/ini": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz",
+ "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/inquirer": {
+ "version": "8.2.6",
+ "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz",
+ "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==",
+ "dev": true,
+ "dependencies": {
+ "ansi-escapes": "^4.2.1",
+ "chalk": "^4.1.1",
+ "cli-cursor": "^3.1.0",
+ "cli-width": "^3.0.0",
+ "external-editor": "^3.0.3",
+ "figures": "^3.0.0",
+ "lodash": "^4.17.21",
+ "mute-stream": "0.0.8",
+ "ora": "^5.4.1",
+ "run-async": "^2.4.0",
+ "rxjs": "^7.5.5",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0",
+ "through": "^2.3.6",
+ "wrap-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/invariant": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+ "dev": true,
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
+ "node_modules/ip-address": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
+ "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
+ "dev": true,
+ "dependencies": {
+ "jsbn": "1.1.0",
+ "sprintf-js": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/is-absolute": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz",
+ "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==",
+ "dev": true,
+ "dependencies": {
+ "is-relative": "^1.0.0",
+ "is-windows": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-ci": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
+ "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==",
+ "dev": true,
+ "dependencies": {
+ "ci-info": "^3.2.0"
+ },
+ "bin": {
+ "is-ci": "bin.js"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
+ "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
+ "dev": true,
+ "dependencies": {
+ "hasown": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-installed-globally": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz",
+ "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==",
+ "dev": true,
+ "dependencies": {
+ "global-dirs": "^3.0.0",
+ "is-path-inside": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-interactive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz",
+ "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-lambda": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz",
+ "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==",
+ "dev": true
+ },
+ "node_modules/is-lower-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz",
+ "integrity": "sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/is-npm": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz",
+ "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-obj": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
+ "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
+ "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/is-relative": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz",
+ "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==",
+ "dev": true,
+ "dependencies": {
+ "is-unc-path": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-typedarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
+ "dev": true
+ },
+ "node_modules/is-unc-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz",
+ "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==",
+ "dev": true,
+ "dependencies": {
+ "unc-path-regex": "^0.1.2"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-unicode-supported": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-upper-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-upper-case/-/is-upper-case-2.0.2.tgz",
+ "integrity": "sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/is-windows": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+ "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-yarn-global": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.4.1.tgz",
+ "integrity": "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true
+ },
+ "node_modules/isomorphic-ws": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
+ "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
+ "dev": true,
+ "peerDependencies": {
+ "ws": "*"
+ }
+ },
+ "node_modules/jackspeak": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
+ "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
+ "dev": true,
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz",
+ "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==",
+ "dev": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/jju": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz",
+ "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==",
+ "dev": true
+ },
+ "node_modules/jose": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.2.2.tgz",
+ "integrity": "sha512-/WByRr4jDcsKlvMd1dRJnPfS1GVO3WuKyaurJ/vvXcOaUQO8rnNObCQMlv/5uCceVQIq5Q4WLF44ohsdiTohdg==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsbn": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+ "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
+ "dev": true
+ },
+ "node_modules/jsesc": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+ "dev": true,
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true
+ },
+ "node_modules/json-parse-helpfulerror": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/json-parse-helpfulerror/-/json-parse-helpfulerror-1.0.3.tgz",
+ "integrity": "sha512-XgP0FGR77+QhUxjXkwOMkC94k3WtqEBfcnjWqhRd82qTat4SWKRE+9kUnynz/shm3I4ea2+qISvTIeGTNU7kJg==",
+ "dev": true,
+ "dependencies": {
+ "jju": "^1.1.0"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "node_modules/json-stable-stringify": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.1.1.tgz",
+ "integrity": "sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg==",
+ "dev": true,
+ "dependencies": {
+ "call-bind": "^1.0.5",
+ "isarray": "^2.0.5",
+ "jsonify": "^0.0.1",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true
+ },
+ "node_modules/json-to-pretty-yaml": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/json-to-pretty-yaml/-/json-to-pretty-yaml-1.2.2.tgz",
+ "integrity": "sha512-rvm6hunfCcqegwYaG5T4yKJWxc9FXFgBVrcTZ4XfSVRwa5HA/Xs+vB/Eo9treYYHCeNM0nrSUr82V/M31Urc7A==",
+ "dev": true,
+ "dependencies": {
+ "remedial": "^1.0.7",
+ "remove-trailing-spaces": "^1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.2.0"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsonc-parser": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz",
+ "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==",
+ "dev": true
+ },
+ "node_modules/jsonify": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
+ "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/jsonlines": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/jsonlines/-/jsonlines-0.1.1.tgz",
+ "integrity": "sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==",
+ "dev": true
+ },
+ "node_modules/jsonparse": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz",
+ "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==",
+ "dev": true,
+ "engines": [
+ "node >= 0.2.0"
+ ]
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/known-css-properties": {
+ "version": "0.29.0",
+ "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz",
+ "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==",
+ "dev": true
+ },
+ "node_modules/latest-version": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz",
+ "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==",
+ "dev": true,
+ "dependencies": {
+ "package-json": "^8.1.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
+ "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true
+ },
+ "node_modules/listr2": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/listr2/-/listr2-4.0.5.tgz",
+ "integrity": "sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==",
+ "dev": true,
+ "dependencies": {
+ "cli-truncate": "^2.1.0",
+ "colorette": "^2.0.16",
+ "log-update": "^4.0.0",
+ "p-map": "^4.0.0",
+ "rfdc": "^1.3.0",
+ "rxjs": "^7.5.5",
+ "through": "^2.3.8",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "enquirer": ">= 2.3.0 < 3"
+ },
+ "peerDependenciesMeta": {
+ "enquirer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/listr2/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/local-pkg": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz",
+ "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==",
+ "dev": true,
+ "dependencies": {
+ "mlly": "^1.4.2",
+ "pkg-types": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true
+ },
+ "node_modules/lodash.sortby": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
+ "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
+ "dev": true
+ },
+ "node_modules/log-symbols": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+ "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+ "dev": true,
+ "dependencies": {
+ "chalk": "^4.1.0",
+ "is-unicode-supported": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/log-update": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz",
+ "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==",
+ "dev": true,
+ "dependencies": {
+ "ansi-escapes": "^4.3.0",
+ "cli-cursor": "^3.1.0",
+ "slice-ansi": "^4.0.0",
+ "wrap-ansi": "^6.2.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/log-update/node_modules/slice-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+ "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "astral-regex": "^2.0.0",
+ "is-fullwidth-code-point": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "dev": true,
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+ "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+ "dev": true,
+ "dependencies": {
+ "get-func-name": "^2.0.1"
+ }
+ },
+ "node_modules/lower-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
+ "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/lower-case-first": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/lower-case-first/-/lower-case-first-2.0.2.tgz",
+ "integrity": "sha512-EVm/rR94FJTZi3zefZ82fLWab+GX14LJN4HrWBcuo6Evmsl9hEfnqxgcHCKb9q+mNf6EVdsjx/qucYFIIB84pg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/lowercase-keys": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
+ "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "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.30.8",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",
+ "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/make-fetch-happen": {
+ "version": "11.1.1",
+ "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz",
+ "integrity": "sha512-rLWS7GCSTcEujjVBs2YqG7Y4643u8ucvCJeSRqiLYhesrDuzeuFIk37xREzAsfQaqzl8b9rNCE4m6J8tvX4Q8w==",
+ "dev": true,
+ "dependencies": {
+ "agentkeepalive": "^4.2.1",
+ "cacache": "^17.0.0",
+ "http-cache-semantics": "^4.1.1",
+ "http-proxy-agent": "^5.0.0",
+ "https-proxy-agent": "^5.0.0",
+ "is-lambda": "^1.0.1",
+ "lru-cache": "^7.7.1",
+ "minipass": "^5.0.0",
+ "minipass-fetch": "^3.0.0",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "negotiator": "^0.6.3",
+ "promise-retry": "^2.0.1",
+ "socks-proxy-agent": "^7.0.0",
+ "ssri": "^10.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/make-fetch-happen/node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "dev": true,
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/make-fetch-happen/node_modules/http-proxy-agent": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
+ "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
+ "dev": true,
+ "dependencies": {
+ "@tootallnate/once": "2",
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/make-fetch-happen/node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "dev": true,
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/make-fetch-happen/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/map-cache": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz",
+ "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.0.30",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
+ "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/meros": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/meros/-/meros-1.3.0.tgz",
+ "integrity": "sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w==",
+ "dev": true,
+ "engines": {
+ "node": ">=13"
+ },
+ "peerDependencies": {
+ "@types/node": ">=13"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
+ "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+ "dev": true,
+ "dependencies": {
+ "braces": "^3.0.2",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
+ "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
+ "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-collect": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz",
+ "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-collect/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-collect/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/minipass-fetch": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz",
+ "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^7.0.3",
+ "minipass-sized": "^1.0.3",
+ "minizlib": "^2.1.2"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ },
+ "optionalDependencies": {
+ "encoding": "^0.1.13"
+ }
+ },
+ "node_modules/minipass-fetch/node_modules/minipass": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
+ "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minipass-flush": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz",
+ "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minipass-flush/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-flush/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/minipass-json-stream": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz",
+ "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==",
+ "dev": true,
+ "dependencies": {
+ "jsonparse": "^1.3.1",
+ "minipass": "^3.0.0"
+ }
+ },
+ "node_modules/minipass-json-stream/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-json-stream/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/minipass-pipeline": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz",
+ "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-pipeline/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-pipeline/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/minipass-sized": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz",
+ "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minipass-sized/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minizlib/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/mkdirp": {
+ "version": "0.5.6",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
+ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
+ "dev": true,
+ "dependencies": {
+ "minimist": "^1.2.6"
+ },
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.6.1.tgz",
+ "integrity": "sha512-vLgaHvaeunuOXHSmEbZ9izxPx3USsk8KCQ8iC+aTlp5sKRSoZvwhHh5L9VbKSaVC6sJDqbyohIS76E2VmHIPAA==",
+ "dev": true,
+ "dependencies": {
+ "acorn": "^8.11.3",
+ "pathe": "^1.1.2",
+ "pkg-types": "^1.0.3",
+ "ufo": "^1.3.2"
+ }
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
+ "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "node_modules/mute-stream": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
+ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
+ "dev": true
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/no-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
+ "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
+ "dev": true,
+ "dependencies": {
+ "lower-case": "^2.0.2",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "dev": true,
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-gyp": {
+ "version": "9.4.1",
+ "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz",
+ "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==",
+ "dev": true,
+ "dependencies": {
+ "env-paths": "^2.2.0",
+ "exponential-backoff": "^3.1.1",
+ "glob": "^7.1.4",
+ "graceful-fs": "^4.2.6",
+ "make-fetch-happen": "^10.0.3",
+ "nopt": "^6.0.0",
+ "npmlog": "^6.0.0",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.2",
+ "which": "^2.0.2"
+ },
+ "bin": {
+ "node-gyp": "bin/node-gyp.js"
+ },
+ "engines": {
+ "node": "^12.13 || ^14.13 || >=16"
+ }
+ },
+ "node_modules/node-gyp/node_modules/@npmcli/fs": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz",
+ "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==",
+ "dev": true,
+ "dependencies": {
+ "@gar/promisify": "^1.1.3",
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "dev": true,
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/cacache": {
+ "version": "16.1.3",
+ "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz",
+ "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==",
+ "dev": true,
+ "dependencies": {
+ "@npmcli/fs": "^2.1.0",
+ "@npmcli/move-file": "^2.0.0",
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.1.0",
+ "glob": "^8.0.1",
+ "infer-owner": "^1.0.4",
+ "lru-cache": "^7.7.1",
+ "minipass": "^3.1.6",
+ "minipass-collect": "^1.0.2",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "mkdirp": "^1.0.4",
+ "p-map": "^4.0.0",
+ "promise-inflight": "^1.0.1",
+ "rimraf": "^3.0.2",
+ "ssri": "^9.0.0",
+ "tar": "^6.1.11",
+ "unique-filename": "^2.0.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/cacache/node_modules/glob": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
+ "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
+ "dev": true,
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^5.0.1",
+ "once": "^1.3.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/node-gyp/node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/node-gyp/node_modules/http-proxy-agent": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
+ "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==",
+ "dev": true,
+ "dependencies": {
+ "@tootallnate/once": "2",
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/node-gyp/node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "dev": true,
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/node-gyp/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/node-gyp/node_modules/make-fetch-happen": {
+ "version": "10.2.1",
+ "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz",
+ "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==",
+ "dev": true,
+ "dependencies": {
+ "agentkeepalive": "^4.2.1",
+ "cacache": "^16.1.0",
+ "http-cache-semantics": "^4.1.0",
+ "http-proxy-agent": "^5.0.0",
+ "https-proxy-agent": "^5.0.0",
+ "is-lambda": "^1.0.1",
+ "lru-cache": "^7.7.1",
+ "minipass": "^3.1.6",
+ "minipass-collect": "^1.0.2",
+ "minipass-fetch": "^2.0.3",
+ "minipass-flush": "^1.0.5",
+ "minipass-pipeline": "^1.2.4",
+ "negotiator": "^0.6.3",
+ "promise-retry": "^2.0.1",
+ "socks-proxy-agent": "^7.0.0",
+ "ssri": "^9.0.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/minimatch": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+ "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-gyp/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/node-gyp/node_modules/minipass-fetch": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz",
+ "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.1.6",
+ "minipass-sized": "^1.0.3",
+ "minizlib": "^2.1.2"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ },
+ "optionalDependencies": {
+ "encoding": "^0.1.13"
+ }
+ },
+ "node_modules/node-gyp/node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "dev": true,
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-gyp/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-gyp/node_modules/semver/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/node-gyp/node_modules/ssri": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz",
+ "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.1.1"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/unique-filename": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz",
+ "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==",
+ "dev": true,
+ "dependencies": {
+ "unique-slug": "^3.0.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/unique-slug": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz",
+ "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==",
+ "dev": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/node-gyp/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/node-int64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+ "dev": true
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.14",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
+ "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==",
+ "dev": true
+ },
+ "node_modules/nopt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",
+ "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==",
+ "dev": true,
+ "dependencies": {
+ "abbrev": "^1.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/normalize-package-data": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-5.0.0.tgz",
+ "integrity": "sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==",
+ "dev": true,
+ "dependencies": {
+ "hosted-git-info": "^6.0.0",
+ "is-core-module": "^2.8.1",
+ "semver": "^7.3.5",
+ "validate-npm-package-license": "^3.0.4"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/normalize-package-data/node_modules/hosted-git-info": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.1.tgz",
+ "integrity": "sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^7.5.1"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/normalize-package-data/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/normalize-package-data/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/normalize-package-data/node_modules/semver/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/normalize-package-data/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/normalize-url": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.0.tgz",
+ "integrity": "sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-bundled": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz",
+ "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==",
+ "dev": true,
+ "dependencies": {
+ "npm-normalize-package-bin": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm-check-updates": {
+ "version": "16.14.15",
+ "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.15.tgz",
+ "integrity": "sha512-WH0wJ9j6CP7Azl+LLCxWAYqroT2IX02kRIzgK/fg0rPpMbETgHITWBdOPtrv521xmA3JMgeNsQ62zvVtS/nCmQ==",
+ "dev": true,
+ "dependencies": {
+ "chalk": "^5.3.0",
+ "cli-table3": "^0.6.3",
+ "commander": "^10.0.1",
+ "fast-memoize": "^2.5.2",
+ "find-up": "5.0.0",
+ "fp-and-or": "^0.1.4",
+ "get-stdin": "^8.0.0",
+ "globby": "^11.0.4",
+ "hosted-git-info": "^5.1.0",
+ "ini": "^4.1.1",
+ "js-yaml": "^4.1.0",
+ "json-parse-helpfulerror": "^1.0.3",
+ "jsonlines": "^0.1.1",
+ "lodash": "^4.17.21",
+ "make-fetch-happen": "^11.1.1",
+ "minimatch": "^9.0.3",
+ "p-map": "^4.0.0",
+ "pacote": "15.2.0",
+ "parse-github-url": "^1.0.2",
+ "progress": "^2.0.3",
+ "prompts-ncu": "^3.0.0",
+ "rc-config-loader": "^4.1.3",
+ "remote-git-tags": "^3.0.0",
+ "rimraf": "^5.0.5",
+ "semver": "^7.5.4",
+ "semver-utils": "^1.1.4",
+ "source-map-support": "^0.5.21",
+ "spawn-please": "^2.0.2",
+ "strip-ansi": "^7.1.0",
+ "strip-json-comments": "^5.0.1",
+ "untildify": "^4.0.0",
+ "update-notifier": "^6.0.2"
+ },
+ "bin": {
+ "ncu": "build/src/bin/cli.js",
+ "npm-check-updates": "build/src/bin/cli.js"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/npm-check-updates/node_modules/ansi-regex": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+ "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/npm-check-updates/node_modules/chalk": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+ "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+ "dev": true,
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/npm-check-updates/node_modules/glob": {
+ "version": "10.3.10",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+ "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+ "dev": true,
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^2.3.5",
+ "minimatch": "^9.0.1",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+ "path-scurry": "^1.10.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm-check-updates/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm-check-updates/node_modules/rimraf": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz",
+ "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^10.3.7"
+ },
+ "bin": {
+ "rimraf": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/npm-check-updates/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm-check-updates/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/npm-check-updates/node_modules/strip-json-comments": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.1.tgz",
+ "integrity": "sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-check-updates/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/npm-install-checks": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz",
+ "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^7.1.1"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm-install-checks/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm-install-checks/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm-install-checks/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/npm-normalize-package-bin": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz",
+ "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm-package-arg": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-10.1.0.tgz",
+ "integrity": "sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==",
+ "dev": true,
+ "dependencies": {
+ "hosted-git-info": "^6.0.0",
+ "proc-log": "^3.0.0",
+ "semver": "^7.3.5",
+ "validate-npm-package-name": "^5.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm-package-arg/node_modules/hosted-git-info": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.1.tgz",
+ "integrity": "sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^7.5.1"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm-package-arg/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/npm-package-arg/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm-package-arg/node_modules/semver/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm-package-arg/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/npm-packlist": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-7.0.4.tgz",
+ "integrity": "sha512-d6RGEuRrNS5/N84iglPivjaJPxhDbZmlbTwTDX2IbcRHG5bZCdtysYMhwiPvcF4GisXHGn7xsxv+GQ7T/02M5Q==",
+ "dev": true,
+ "dependencies": {
+ "ignore-walk": "^6.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm-pick-manifest": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-8.0.2.tgz",
+ "integrity": "sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg==",
+ "dev": true,
+ "dependencies": {
+ "npm-install-checks": "^6.0.0",
+ "npm-normalize-package-bin": "^3.0.0",
+ "npm-package-arg": "^10.0.0",
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm-pick-manifest/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm-pick-manifest/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/npm-pick-manifest/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/npm-registry-fetch": {
+ "version": "14.0.5",
+ "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-14.0.5.tgz",
+ "integrity": "sha512-kIDMIo4aBm6xg7jOttupWZamsZRkAqMqwqqbVXnUqstY5+tapvv6bkH/qMR76jdgV+YljEUCyWx3hRYMrJiAgA==",
+ "dev": true,
+ "dependencies": {
+ "make-fetch-happen": "^11.0.0",
+ "minipass": "^5.0.0",
+ "minipass-fetch": "^3.0.0",
+ "minipass-json-stream": "^1.0.1",
+ "minizlib": "^2.1.2",
+ "npm-package-arg": "^10.0.0",
+ "proc-log": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
+ "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
+ "dev": true,
+ "dependencies": {
+ "path-key": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-run-path/node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npmlog": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz",
+ "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==",
+ "dev": true,
+ "dependencies": {
+ "are-we-there-yet": "^3.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^4.0.3",
+ "set-blocking": "^2.0.0"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/nullthrows": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
+ "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==",
+ "dev": true
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
+ "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==",
+ "dev": true,
+ "dependencies": {
+ "@aashutoshrathi/word-wrap": "^1.2.3",
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/ora": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
+ "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
+ "dev": true,
+ "dependencies": {
+ "bl": "^4.1.0",
+ "chalk": "^4.1.0",
+ "cli-cursor": "^3.1.0",
+ "cli-spinners": "^2.5.0",
+ "is-interactive": "^1.0.0",
+ "is-unicode-supported": "^0.1.0",
+ "log-symbols": "^4.1.0",
+ "strip-ansi": "^6.0.0",
+ "wcwidth": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/os-tmpdir": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+ "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/p-cancelable": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz",
+ "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.20"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-map": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+ "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+ "dev": true,
+ "dependencies": {
+ "aggregate-error": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/package-json": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz",
+ "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==",
+ "dev": true,
+ "dependencies": {
+ "got": "^12.1.0",
+ "registry-auth-token": "^5.0.1",
+ "registry-url": "^6.0.0",
+ "semver": "^7.3.7"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-json/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/package-json/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/package-json/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/pacote": {
+ "version": "15.2.0",
+ "resolved": "https://registry.npmjs.org/pacote/-/pacote-15.2.0.tgz",
+ "integrity": "sha512-rJVZeIwHTUta23sIZgEIM62WYwbmGbThdbnkt81ravBplQv+HjyroqnLRNH2+sLJHcGZmLRmhPwACqhfTcOmnA==",
+ "dev": true,
+ "dependencies": {
+ "@npmcli/git": "^4.0.0",
+ "@npmcli/installed-package-contents": "^2.0.1",
+ "@npmcli/promise-spawn": "^6.0.1",
+ "@npmcli/run-script": "^6.0.0",
+ "cacache": "^17.0.0",
+ "fs-minipass": "^3.0.0",
+ "minipass": "^5.0.0",
+ "npm-package-arg": "^10.0.0",
+ "npm-packlist": "^7.0.0",
+ "npm-pick-manifest": "^8.0.0",
+ "npm-registry-fetch": "^14.0.0",
+ "proc-log": "^3.0.0",
+ "promise-retry": "^2.0.1",
+ "read-package-json": "^6.0.0",
+ "read-package-json-fast": "^3.0.0",
+ "sigstore": "^1.3.0",
+ "ssri": "^10.0.0",
+ "tar": "^6.1.11"
+ },
+ "bin": {
+ "pacote": "lib/bin.js"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/param-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
+ "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
+ "dev": true,
+ "dependencies": {
+ "dot-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-filepath": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
+ "integrity": "sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==",
+ "dev": true,
+ "dependencies": {
+ "is-absolute": "^1.0.0",
+ "map-cache": "^0.2.0",
+ "path-root": "^0.1.1"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/parse-github-url": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-github-url/-/parse-github-url-1.0.2.tgz",
+ "integrity": "sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw==",
+ "dev": true,
+ "bin": {
+ "parse-github-url": "cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pascal-case": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
+ "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
+ "dev": true,
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/path-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz",
+ "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==",
+ "dev": true,
+ "dependencies": {
+ "dot-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/path-root": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz",
+ "integrity": "sha512-QLcPegTHF11axjfojBIoDygmS2E3Lf+8+jI6wOVmNVenrKSo3mFdSGiIgdSHenczw3wPtlVMQaFVwGmM7BJdtg==",
+ "dev": true,
+ "dependencies": {
+ "path-root-regex": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-root-regex": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz",
+ "integrity": "sha512-4GlJ6rZDhQZFE0DPVKh0e9jmZ5egZfxTkp7bcRDuPlJXbAwhxcl2dINPUAsjLdejqaLsCeg8axcLjIbvBjN4pQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
+ "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^9.1.1 || ^10.0.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/path-scurry/node_modules/lru-cache": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz",
+ "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==",
+ "dev": true,
+ "engines": {
+ "node": "14 || >=16.14"
+ }
+ },
+ "node_modules/path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true
+ },
+ "node_modules/pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/periscopic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
+ "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^3.0.0",
+ "is-reference": "^3.0.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
+ "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/pkg-types": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz",
+ "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==",
+ "dev": true,
+ "dependencies": {
+ "jsonc-parser": "^3.2.0",
+ "mlly": "^1.2.0",
+ "pathe": "^1.1.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.4.35",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
+ "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "nanoid": "^3.3.7",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
+ "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
+ "dev": true,
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz",
+ "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==",
+ "dev": true,
+ "dependencies": {
+ "lilconfig": "^2.0.5",
+ "yaml": "^1.10.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ "peerDependencies": {
+ "postcss": ">=8.0.9",
+ "ts-node": ">=9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "postcss": {
+ "optional": true
+ },
+ "ts-node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-load-config/node_modules/yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz",
+ "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==",
+ "dev": true,
+ "dependencies": {
+ "postcss-selector-parser": "^6.0.11"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-safe-parser": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz",
+ "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ "peerDependencies": {
+ "postcss": "^8.3.3"
+ }
+ },
+ "node_modules/postcss-scss": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz",
+ "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss-scss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.29"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.0.15",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz",
+ "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==",
+ "dev": true,
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
+ "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
+ "dev": true,
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prettier-plugin-svelte": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.2.tgz",
+ "integrity": "sha512-ZzzE/wMuf48/1+Lf2Ffko0uDa6pyCfgHV6+uAhtg2U0AAXGrhCSW88vEJNAkAxW5qyrFY1y1zZ4J8TgHrjW++Q==",
+ "dev": true,
+ "peerDependencies": {
+ "prettier": "^3.0.0",
+ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
+ }
+ },
+ "node_modules/prettier-plugin-tailwindcss": {
+ "version": "0.5.11",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.11.tgz",
+ "integrity": "sha512-AvI/DNyMctyyxGOjyePgi/gqj5hJYClZ1avtQvLlqMT3uDZkRbi4HhGUpok3DRzv9z7Lti85Kdj3s3/1CeNI0w==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.21.3"
+ },
+ "peerDependencies": {
+ "@ianvs/prettier-plugin-sort-imports": "*",
+ "@prettier/plugin-pug": "*",
+ "@shopify/prettier-plugin-liquid": "*",
+ "@trivago/prettier-plugin-sort-imports": "*",
+ "prettier": "^3.0",
+ "prettier-plugin-astro": "*",
+ "prettier-plugin-css-order": "*",
+ "prettier-plugin-import-sort": "*",
+ "prettier-plugin-jsdoc": "*",
+ "prettier-plugin-marko": "*",
+ "prettier-plugin-organize-attributes": "*",
+ "prettier-plugin-organize-imports": "*",
+ "prettier-plugin-style-order": "*",
+ "prettier-plugin-svelte": "*"
+ },
+ "peerDependenciesMeta": {
+ "@ianvs/prettier-plugin-sort-imports": {
+ "optional": true
+ },
+ "@prettier/plugin-pug": {
+ "optional": true
+ },
+ "@shopify/prettier-plugin-liquid": {
+ "optional": true
+ },
+ "@trivago/prettier-plugin-sort-imports": {
+ "optional": true
+ },
+ "prettier-plugin-astro": {
+ "optional": true
+ },
+ "prettier-plugin-css-order": {
+ "optional": true
+ },
+ "prettier-plugin-import-sort": {
+ "optional": true
+ },
+ "prettier-plugin-jsdoc": {
+ "optional": true
+ },
+ "prettier-plugin-marko": {
+ "optional": true
+ },
+ "prettier-plugin-organize-attributes": {
+ "optional": true
+ },
+ "prettier-plugin-organize-imports": {
+ "optional": true
+ },
+ "prettier-plugin-style-order": {
+ "optional": true
+ },
+ "prettier-plugin-svelte": {
+ "optional": true
+ },
+ "prettier-plugin-twig-melody": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dev": true,
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/proc-log": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz",
+ "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/promise": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
+ "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
+ "dev": true,
+ "dependencies": {
+ "asap": "~2.0.3"
+ }
+ },
+ "node_modules/promise-inflight": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
+ "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==",
+ "dev": true
+ },
+ "node_modules/promise-retry": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz",
+ "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==",
+ "dev": true,
+ "dependencies": {
+ "err-code": "^2.0.2",
+ "retry": "^0.12.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/prompts-ncu": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/prompts-ncu/-/prompts-ncu-3.0.0.tgz",
+ "integrity": "sha512-qyz9UxZ5MlPKWVhWrCmSZ1ahm2GVYdjLb8og2sg0IPth1KRuhcggHGuijz0e41dkx35p1t1q3GRISGH7QGALFA==",
+ "dev": true,
+ "dependencies": {
+ "kleur": "^4.0.1",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/proto-list": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
+ "dev": true
+ },
+ "node_modules/punycode": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
+ "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
+ "dev": true
+ },
+ "node_modules/pupa": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz",
+ "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==",
+ "dev": true,
+ "dependencies": {
+ "escape-goat": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pvtsutils": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz",
+ "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.6.1"
+ }
+ },
+ "node_modules/pvutils": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
+ "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/quick-lru": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "dev": true,
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/rc-config-loader": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.3.tgz",
+ "integrity": "sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.3.4",
+ "js-yaml": "^4.1.0",
+ "json5": "^2.2.2",
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/rc/node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true
+ },
+ "node_modules/rc/node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==",
+ "dev": true
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/read-package-json": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-6.0.4.tgz",
+ "integrity": "sha512-AEtWXYfopBj2z5N5PbkAOeNHRPUg5q+Nen7QLxV8M2zJq1ym6/lCz3fYNTCXe19puu2d06jfHhrP7v/S2PtMMw==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^10.2.2",
+ "json-parse-even-better-errors": "^3.0.0",
+ "normalize-package-data": "^5.0.0",
+ "npm-normalize-package-bin": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/read-package-json-fast": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz",
+ "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==",
+ "dev": true,
+ "dependencies": {
+ "json-parse-even-better-errors": "^3.0.0",
+ "npm-normalize-package-bin": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/read-package-json-fast/node_modules/json-parse-even-better-errors": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz",
+ "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/read-package-json/node_modules/glob": {
+ "version": "10.3.10",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+ "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+ "dev": true,
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^2.3.5",
+ "minimatch": "^9.0.1",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+ "path-scurry": "^1.10.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/read-package-json/node_modules/json-parse-even-better-errors": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz",
+ "integrity": "sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg==",
+ "dev": true,
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "dev": true,
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
+ "dev": true
+ },
+ "node_modules/registry-auth-token": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.0.2.tgz",
+ "integrity": "sha512-o/3ikDxtXaA59BmZuZrJZDJv8NMDGSj+6j6XaeBmHw8eY1i1qd9+6H+LjVvQXx3HN6aRCGa1cUdJ9RaJZUugnQ==",
+ "dev": true,
+ "dependencies": {
+ "@pnpm/npm-conf": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/registry-url": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz",
+ "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==",
+ "dev": true,
+ "dependencies": {
+ "rc": "1.2.8"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/relay-runtime": {
+ "version": "12.0.0",
+ "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-12.0.0.tgz",
+ "integrity": "sha512-QU6JKr1tMsry22DXNy9Whsq5rmvwr3LSZiiWV/9+DFpuTWvp+WFhobWMc8TC4OjKFfNhEZy7mOiqUAn5atQtug==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.0.0",
+ "fbjs": "^3.0.0",
+ "invariant": "^2.2.4"
+ }
+ },
+ "node_modules/remedial": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/remedial/-/remedial-1.0.8.tgz",
+ "integrity": "sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==",
+ "dev": true,
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/remote-git-tags": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/remote-git-tags/-/remote-git-tags-3.0.0.tgz",
+ "integrity": "sha512-C9hAO4eoEsX+OXA4rla66pXZQ+TLQ8T9dttgQj18yuKlPMTVkIkdYXvlMC55IuUsIkV6DpmQYi10JKFLaU+l7w==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/remove-trailing-separator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz",
+ "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==",
+ "dev": true
+ },
+ "node_modules/remove-trailing-spaces": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/remove-trailing-spaces/-/remove-trailing-spaces-1.0.8.tgz",
+ "integrity": "sha512-O3vsMYfWighyFbTd8hk8VaSj9UAGENxAtX+//ugIst2RMk5e03h6RoIS+0ylsFxY1gvmPuAY/PO4It+gPEeySA==",
+ "dev": true
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "dev": true
+ },
+ "node_modules/resolve": {
+ "version": "1.22.8",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
+ "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
+ "dev": true,
+ "dependencies": {
+ "is-core-module": "^2.13.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-alpn": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
+ "dev": true
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/responselike": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz",
+ "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==",
+ "dev": true,
+ "dependencies": {
+ "lowercase-keys": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/restore-cursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
+ "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
+ "dev": true,
+ "dependencies": {
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
+ "dev": true,
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+ "dev": true,
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz",
+ "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==",
+ "dev": true
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.12.0.tgz",
+ "integrity": "sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==",
+ "dev": true,
+ "dependencies": {
+ "@types/estree": "1.0.5"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.12.0",
+ "@rollup/rollup-android-arm64": "4.12.0",
+ "@rollup/rollup-darwin-arm64": "4.12.0",
+ "@rollup/rollup-darwin-x64": "4.12.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.12.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.12.0",
+ "@rollup/rollup-linux-arm64-musl": "4.12.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.12.0",
+ "@rollup/rollup-linux-x64-gnu": "4.12.0",
+ "@rollup/rollup-linux-x64-musl": "4.12.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.12.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.12.0",
+ "@rollup/rollup-win32-x64-msvc": "4.12.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-async": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz",
+ "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
+ "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/sade": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+ "dev": true,
+ "dependencies": {
+ "mri": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ]
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true
+ },
+ "node_modules/sander": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz",
+ "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==",
+ "dev": true,
+ "dependencies": {
+ "es6-promise": "^3.1.2",
+ "graceful-fs": "^4.1.3",
+ "mkdirp": "^0.5.1",
+ "rimraf": "^2.5.2"
+ }
+ },
+ "node_modules/sander/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/scuid": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/scuid/-/scuid-1.1.0.tgz",
+ "integrity": "sha512-MuCAyrGZcTLfQoH2XoBlQ8C6bzwN88XT/0slOGz0pn8+gIP85BOAfYa44ZXQUTOwRwPU0QvgU+V+OSajl/59Xg==",
+ "dev": true
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/semver-diff": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz",
+ "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==",
+ "dev": true,
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/semver-diff/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/semver-diff/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/semver-diff/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/semver-utils": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/semver-utils/-/semver-utils-1.1.4.tgz",
+ "integrity": "sha512-EjnoLE5OGmDAVV/8YDoN5KiajNadjzIp9BAHOhYeQHt7j0UWxjmgsx4YD48wp4Ue1Qogq38F1GNUJNqF1kKKxA==",
+ "dev": true
+ },
+ "node_modules/sentence-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz",
+ "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==",
+ "dev": true,
+ "dependencies": {
+ "no-case": "^3.0.4",
+ "tslib": "^2.0.3",
+ "upper-case-first": "^2.0.2"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "dev": true
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz",
+ "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==",
+ "dev": true
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz",
+ "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==",
+ "dev": true,
+ "dependencies": {
+ "define-data-property": "^1.1.2",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.3",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "dev": true
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shell-quote": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz",
+ "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true
+ },
+ "node_modules/signedsource": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/signedsource/-/signedsource-1.0.0.tgz",
+ "integrity": "sha512-6+eerH9fEnNmi/hyM1DXcRK3pWdoMQtlkQ+ns0ntzunjKqp5i3sKCc80ym8Fib3iaYhdJUOPdhlJWj1tvge2Ww==",
+ "dev": true
+ },
+ "node_modules/sigstore": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-1.9.0.tgz",
+ "integrity": "sha512-0Zjz0oe37d08VeOtBIuB6cRriqXse2e8w+7yIy2XSXjshRKxbc2KkhXjL229jXSxEm7UbcjS76wcJDGQddVI9A==",
+ "dev": true,
+ "dependencies": {
+ "@sigstore/bundle": "^1.1.0",
+ "@sigstore/protobuf-specs": "^0.2.0",
+ "@sigstore/sign": "^1.0.0",
+ "@sigstore/tuf": "^1.0.3",
+ "make-fetch-happen": "^11.0.1"
+ },
+ "bin": {
+ "sigstore": "bin/sigstore.js"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/sirv": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
+ "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
+ "dev": true,
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "dev": true
+ },
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/slice-ansi": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
+ "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "astral-regex": "^2.0.0",
+ "is-fullwidth-code-point": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/snake-case": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
+ "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==",
+ "dev": true,
+ "dependencies": {
+ "dot-case": "^3.0.4",
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/socks": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz",
+ "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==",
+ "dev": true,
+ "dependencies": {
+ "ip-address": "^9.0.5",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz",
+ "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==",
+ "dev": true,
+ "dependencies": {
+ "agent-base": "^6.0.2",
+ "debug": "^4.3.3",
+ "socks": "^2.6.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/socks-proxy-agent/node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "dev": true,
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/sorcery": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz",
+ "integrity": "sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.14",
+ "buffer-crc32": "^0.2.5",
+ "minimist": "^1.2.0",
+ "sander": "^0.5.0"
+ },
+ "bin": {
+ "sorcery": "bin/sorcery"
+ }
+ },
+ "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==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+ "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/spawn-please": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/spawn-please/-/spawn-please-2.0.2.tgz",
+ "integrity": "sha512-KM8coezO6ISQ89c1BzyWNtcn2V2kAVtwIXd3cN/V5a0xPYc1F/vydrRc01wsKFEQ/p+V1a4sw4z2yMITIXrgGw==",
+ "dev": true,
+ "dependencies": {
+ "cross-spawn": "^7.0.3"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/spdx-correct": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
+ "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
+ "dev": true,
+ "dependencies": {
+ "spdx-expression-parse": "^3.0.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "node_modules/spdx-exceptions": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
+ "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
+ "dev": true
+ },
+ "node_modules/spdx-expression-parse": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+ "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+ "dev": true,
+ "dependencies": {
+ "spdx-exceptions": "^2.1.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "node_modules/spdx-license-ids": {
+ "version": "3.0.17",
+ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz",
+ "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==",
+ "dev": true
+ },
+ "node_modules/sponge-case": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/sponge-case/-/sponge-case-1.0.1.tgz",
+ "integrity": "sha512-dblb9Et4DAtiZ5YSUZHLl4XhH4uK80GhAZrVXdN4O2P4gQ40Wa5UIOPUHlA/nFd2PLblBZWUioLMMAVrgpoYcA==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "dev": true
+ },
+ "node_modules/ssri": {
+ "version": "10.0.5",
+ "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz",
+ "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^7.0.3"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/ssri/node_modules/minipass": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
+ "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true
+ },
+ "node_modules/std-env": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz",
+ "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==",
+ "dev": true
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "dev": true,
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-env-interpolation": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz",
+ "integrity": "sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==",
+ "dev": true
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+ "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.0.0.tgz",
+ "integrity": "sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==",
+ "dev": true,
+ "dependencies": {
+ "js-tokens": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/strip-literal/node_modules/js-tokens": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz",
+ "integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==",
+ "dev": true
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.0",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
+ "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "glob": "^10.3.10",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/sucrase/node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/sucrase/node_modules/glob": {
+ "version": "10.3.10",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
+ "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
+ "dev": true,
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^2.3.5",
+ "minimatch": "^9.0.1",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
+ "path-scurry": "^1.10.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/svelecte": {
+ "version": "3.17.3",
+ "resolved": "https://registry.npmjs.org/svelecte/-/svelecte-3.17.3.tgz",
+ "integrity": "sha512-wnvoRxJIFFkm+CmXgjL4R3i/TcuYUIBkE+jDJSBD7AdSOzk1K6u3+nW4zwxaGT29zyZpiZkWeiy7lO62r5F+tg==",
+ "dependencies": {
+ "svelte-tiny-virtual-list": "^2.0.0"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "4.2.12",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.12.tgz",
+ "integrity": "sha512-d8+wsh5TfPwqVzbm4/HCXC783/KPHV60NvwitJnyTA5lWn1elhXMNWhXGCJ7PwPa8qFUnyJNIyuIRt2mT0WMug==",
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.1",
+ "@jridgewell/sourcemap-codec": "^1.4.15",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "@types/estree": "^1.0.1",
+ "acorn": "^8.9.0",
+ "aria-query": "^5.3.0",
+ "axobject-query": "^4.0.0",
+ "code-red": "^1.0.3",
+ "css-tree": "^2.3.1",
+ "estree-walker": "^3.0.3",
+ "is-reference": "^3.0.1",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.4",
+ "periscopic": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/svelte-check": {
+ "version": "3.6.6",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.6.tgz",
+ "integrity": "sha512-b9q9rOHOMYF3U8XllK7LmXTq1LeWQ98waGfEJzrFutViadkNl1tgdEtxIQ8yuPx+VQ4l7YrknYol+0lfZocaZw==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.17",
+ "chokidar": "^3.4.1",
+ "fast-glob": "^3.2.7",
+ "import-fresh": "^3.2.1",
+ "picocolors": "^1.0.0",
+ "sade": "^1.7.4",
+ "svelte-preprocess": "^5.1.3",
+ "typescript": "^5.0.3"
+ },
+ "bin": {
+ "svelte-check": "bin/svelte-check"
+ },
+ "peerDependencies": {
+ "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0"
+ }
+ },
+ "node_modules/svelte-eslint-parser": {
+ "version": "0.33.1",
+ "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.33.1.tgz",
+ "integrity": "sha512-vo7xPGTlKBGdLH8T5L64FipvTrqv3OQRx9d2z5X05KKZDlF4rQk8KViZO4flKERY+5BiVdOh7zZ7JGJWo5P0uA==",
+ "dev": true,
+ "dependencies": {
+ "eslint-scope": "^7.0.0",
+ "eslint-visitor-keys": "^3.0.0",
+ "espree": "^9.0.0",
+ "postcss": "^8.4.29",
+ "postcss-scss": "^4.0.8"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ota-meshi"
+ },
+ "peerDependencies": {
+ "svelte": "^3.37.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "svelte": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/svelte-hmr": {
+ "version": "0.15.3",
+ "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz",
+ "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==",
+ "dev": true,
+ "engines": {
+ "node": "^12.20 || ^14.13.1 || >= 16"
+ },
+ "peerDependencies": {
+ "svelte": "^3.19.0 || ^4.0.0"
+ }
+ },
+ "node_modules/svelte-modals": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/svelte-modals/-/svelte-modals-1.3.0.tgz",
+ "integrity": "sha512-b1Ylnyv9O6b7VYeWGJVToaVU2lw7GtErVwiEdojyfnOuZcrhNlQ5eDqbTrL3xyKz8j2VTy/QiGUl1lm/6SnQ2A==",
+ "peerDependencies": {
+ "svelte": "^3.0.0 || ^4.0.0"
+ }
+ },
+ "node_modules/svelte-preprocess": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz",
+ "integrity": "sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "dependencies": {
+ "@types/pug": "^2.0.6",
+ "detect-indent": "^6.1.0",
+ "magic-string": "^0.30.5",
+ "sorcery": "^0.11.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 16.0.0",
+ "pnpm": "^8.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.10.2",
+ "coffeescript": "^2.5.1",
+ "less": "^3.11.3 || ^4.0.0",
+ "postcss": "^7 || ^8",
+ "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
+ "pug": "^3.0.0",
+ "sass": "^1.26.8",
+ "stylus": "^0.55.0",
+ "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0",
+ "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0",
+ "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "coffeescript": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "postcss-load-config": {
+ "optional": true
+ },
+ "pug": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/svelte-tiny-virtual-list": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/svelte-tiny-virtual-list/-/svelte-tiny-virtual-list-2.0.5.tgz",
+ "integrity": "sha512-xg9ckb8UeeIme4/5qlwCrl2QNmUZ8SCQYZn3Ji83cUsoASqRNy3KWjpmNmzYvPDqCHSZjruBBsoB7t5hwuzw5g=="
+ },
+ "node_modules/swap-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/swap-case/-/swap-case-2.0.2.tgz",
+ "integrity": "sha512-kc6S2YS/2yXbtkSMunBtKdah4VFETZ8Oh6ONSmSd9bRxhqTrtARUCBUiWXH3xVPpvR7tz2CSnkuXVE42EcGnMw==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz",
+ "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==",
+ "dev": true,
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.5.3",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.0",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.19.1",
+ "lilconfig": "^2.1.0",
+ "micromatch": "^4.0.5",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.0.0",
+ "postcss": "^8.4.23",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.1",
+ "postcss-nested": "^6.0.1",
+ "postcss-selector-parser": "^6.0.11",
+ "resolve": "^1.22.2",
+ "sucrase": "^3.32.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/postcss-load-config": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
+ "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "lilconfig": "^3.0.0",
+ "yaml": "^2.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ },
+ "peerDependencies": {
+ "postcss": ">=8.0.9",
+ "ts-node": ">=9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "postcss": {
+ "optional": true
+ },
+ "ts-node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tailwindcss/node_modules/postcss-load-config/node_modules/lilconfig": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz",
+ "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
+ "integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
+ "dev": true,
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/tar/node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "dev": true,
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tar/node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "dev": true,
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/tar/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+ "dev": true
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/through": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==",
+ "dev": true
+ },
+ "node_modules/tiny-glob": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
+ "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==",
+ "dev": true,
+ "dependencies": {
+ "globalyzer": "0.1.0",
+ "globrex": "^0.1.2"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.6.0.tgz",
+ "integrity": "sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==",
+ "dev": true
+ },
+ "node_modules/tinypool": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.2.tgz",
+ "integrity": "sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
+ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/title-case": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz",
+ "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/tmp": {
+ "version": "0.0.33",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
+ "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
+ "dev": true,
+ "dependencies": {
+ "os-tmpdir": "~1.0.2"
+ },
+ "engines": {
+ "node": ">=0.6.0"
+ }
+ },
+ "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": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "dev": true
+ },
+ "node_modules/ts-api-utils": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz",
+ "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==",
+ "dev": true,
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.2.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true
+ },
+ "node_modules/ts-log": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/ts-log/-/ts-log-2.2.5.tgz",
+ "integrity": "sha512-PGcnJoTBnVGy6yYNFxWVNkdcAuAMstvutN9MgDJIV6L0oG8fB+ZNNy1T+wJzah8RPGor1mZuPQkVfXNDpy9eHA==",
+ "dev": true
+ },
+ "node_modules/tslib": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
+ "dev": true
+ },
+ "node_modules/tuf-js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-1.1.7.tgz",
+ "integrity": "sha512-i3P9Kgw3ytjELUfpuKVDNBJvk4u5bXL6gskv572mcevPbSKCV3zt3djhmlEQ65yERjIbOSncy7U4cQJaB1CBCg==",
+ "dev": true,
+ "dependencies": {
+ "@tufjs/models": "1.0.4",
+ "debug": "^4.3.4",
+ "make-fetch-happen": "^11.1.1"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "dev": true,
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typedarray-to-buffer": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+ "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+ "dev": true,
+ "dependencies": {
+ "is-typedarray": "^1.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
+ "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
+ "dev": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/ua-parser-js": {
+ "version": "1.0.37",
+ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz",
+ "integrity": "sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ua-parser-js"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/faisalman"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/faisalman"
+ }
+ ],
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.4.0.tgz",
+ "integrity": "sha512-Hhy+BhRBleFjpJ2vchUNN40qgkh0366FWJGqVLYBHev0vpHTrXSA0ryT+74UiW6KWsldNurQMKGqCm1M2zBciQ==",
+ "dev": true
+ },
+ "node_modules/unc-path-regex": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz",
+ "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "5.26.5",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+ "dev": true
+ },
+ "node_modules/unique-filename": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz",
+ "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==",
+ "dev": true,
+ "dependencies": {
+ "unique-slug": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/unique-slug": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz",
+ "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==",
+ "dev": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/unique-string": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
+ "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
+ "dev": true,
+ "dependencies": {
+ "crypto-random-string": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/unixify": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unixify/-/unixify-1.0.0.tgz",
+ "integrity": "sha512-6bc58dPYhCMHHuwxldQxO3RRNZ4eCogZ/st++0+fcC1nr0jiGUtAdBJ2qzmLQWSxbtz42pWt4QQMiZ9HvZf5cg==",
+ "dev": true,
+ "dependencies": {
+ "normalize-path": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/unixify/node_modules/normalize-path": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz",
+ "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==",
+ "dev": true,
+ "dependencies": {
+ "remove-trailing-separator": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/untildify": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
+ "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==",
+ "dev": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
+ "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "dependencies": {
+ "escalade": "^3.1.1",
+ "picocolors": "^1.0.0"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/update-notifier": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-6.0.2.tgz",
+ "integrity": "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==",
+ "dev": true,
+ "dependencies": {
+ "boxen": "^7.0.0",
+ "chalk": "^5.0.1",
+ "configstore": "^6.0.0",
+ "has-yarn": "^3.0.0",
+ "import-lazy": "^4.0.0",
+ "is-ci": "^3.0.1",
+ "is-installed-globally": "^0.4.0",
+ "is-npm": "^6.0.0",
+ "is-yarn-global": "^0.4.0",
+ "latest-version": "^7.0.0",
+ "pupa": "^3.1.0",
+ "semver": "^7.3.7",
+ "semver-diff": "^4.0.0",
+ "xdg-basedir": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/yeoman/update-notifier?sponsor=1"
+ }
+ },
+ "node_modules/update-notifier/node_modules/chalk": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
+ "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+ "dev": true,
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/update-notifier/node_modules/lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/update-notifier/node_modules/semver": {
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
+ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "dev": true,
+ "dependencies": {
+ "lru-cache": "^6.0.0"
+ },
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/update-notifier/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "node_modules/upper-case": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz",
+ "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/upper-case-first": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz",
+ "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==",
+ "dev": true,
+ "dependencies": {
+ "tslib": "^2.0.3"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/uri-js/node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/urlpattern-polyfill": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz",
+ "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==",
+ "dev": true
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true
+ },
+ "node_modules/validate-npm-package-license": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+ "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+ "dev": true,
+ "dependencies": {
+ "spdx-correct": "^3.0.0",
+ "spdx-expression-parse": "^3.0.0"
+ }
+ },
+ "node_modules/validate-npm-package-name": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz",
+ "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==",
+ "dev": true,
+ "dependencies": {
+ "builtins": "^5.0.0"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/value-or-promise": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/value-or-promise/-/value-or-promise-1.0.12.tgz",
+ "integrity": "sha512-Z6Uz+TYwEqE7ZN50gwn+1LCVo9ZVrpxRPOhOLnncYkY1ZzOYtrX8Fwf/rFktZ8R5mJms6EZf5TqNOMeZmnPq9Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz",
+ "integrity": "sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==",
+ "dev": true,
+ "dependencies": {
+ "esbuild": "^0.19.3",
+ "postcss": "^8.4.35",
+ "rollup": "^4.2.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz",
+ "integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==",
+ "dev": true,
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.4",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
+ "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
+ "dev": true,
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz",
+ "integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/expect": "1.3.1",
+ "@vitest/runner": "1.3.1",
+ "@vitest/snapshot": "1.3.1",
+ "@vitest/spy": "1.3.1",
+ "@vitest/utils": "1.3.1",
+ "acorn-walk": "^8.3.2",
+ "chai": "^4.3.10",
+ "debug": "^4.3.4",
+ "execa": "^8.0.1",
+ "local-pkg": "^0.5.0",
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "std-env": "^3.5.0",
+ "strip-literal": "^2.0.0",
+ "tinybench": "^2.5.1",
+ "tinypool": "^0.8.2",
+ "vite": "^5.0.0",
+ "vite-node": "1.3.1",
+ "why-is-node-running": "^2.2.2"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "1.3.1",
+ "@vitest/ui": "1.3.1",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wcwidth": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
+ "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
+ "dev": true,
+ "dependencies": {
+ "defaults": "^1.0.3"
+ }
+ },
+ "node_modules/web-streams-polyfill": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
+ "dev": true,
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/webcrypto-core": {
+ "version": "1.7.8",
+ "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.7.8.tgz",
+ "integrity": "sha512-eBR98r9nQXTqXt/yDRtInszPMjTaSAMJAFDg2AHsgrnczawT1asx9YNBX6k5p+MekbPF4+s/UJJrr88zsTqkSg==",
+ "dev": true,
+ "dependencies": {
+ "@peculiar/asn1-schema": "^2.3.8",
+ "@peculiar/json-schema": "^1.1.12",
+ "asn1js": "^3.0.1",
+ "pvtsutils": "^1.3.5",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "dev": true
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "dev": true,
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "dev": true
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz",
+ "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==",
+ "dev": true,
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/widest-line": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
+ "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==",
+ "dev": true,
+ "dependencies": {
+ "string-width": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/widest-line/node_modules/ansi-regex": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
+ "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/widest-line/node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true
+ },
+ "node_modules/widest-line/node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/widest-line/node_modules/strip-ansi": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
+ "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
+ "dev": true,
+ "dependencies": {
+ "ansi-regex": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wonka": {
+ "version": "6.3.4",
+ "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.4.tgz",
+ "integrity": "sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg=="
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true
+ },
+ "node_modules/write-file-atomic": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+ "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
+ "dev": true,
+ "dependencies": {
+ "imurmurhash": "^0.1.4",
+ "is-typedarray": "^1.0.0",
+ "signal-exit": "^3.0.2",
+ "typedarray-to-buffer": "^3.1.5"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
+ "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xdg-basedir": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz",
+ "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true
+ },
+ "node_modules/yaml": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.0.tgz",
+ "integrity": "sha512-j9iR8g+/t0lArF4V6NE/QCfT+CO7iLqrXAHZbJdo+LfjqP1vR8Fg5bSiaq6Q2lOD1AUEVrEVIgABvBFYojJVYQ==",
+ "dev": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/yaml-ast-parser": {
+ "version": "0.0.43",
+ "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
+ "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
+ "dev": true
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..d5854f1
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "hircine",
+ "version": "0.1.0",
+ "license": "ISC",
+ "private": true,
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "lint": "prettier --plugin prettier-plugin-svelte --check . && eslint .",
+ "format": "prettier --plugin prettier-plugin-svelte --write .",
+ "generate": "graphql-codegen",
+ "test": "vitest"
+ },
+ "devDependencies": {
+ "@graphql-codegen/cli": "^5.0.2",
+ "@graphql-codegen/typed-document-node": "^5.0.6",
+ "@graphql-codegen/typescript-operations": "^4.2.0",
+ "@iconify-json/material-symbols": "^1.1.74",
+ "@iconify/tailwind": "^0.1.4",
+ "@sveltejs/adapter-static": "^3.0.1",
+ "@sveltejs/kit": "^2.5.2",
+ "@sveltejs/vite-plugin-svelte": "^3.0.2",
+ "@typescript-eslint/eslint-plugin": "^7.1.0",
+ "@typescript-eslint/parser": "^7.1.0",
+ "@zerodevx/svelte-toast": "^0.9.5",
+ "autoprefixer": "^10.4.18",
+ "date-fns": "^3.3.1",
+ "eslint": "^8.57.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-svelte": "^2.35.1",
+ "fast-deep-equal": "^3.1.3",
+ "npm-check-updates": "^16.14.15",
+ "postcss": "^8.4.35",
+ "prettier": "^3.2.5",
+ "prettier-plugin-svelte": "^3.2.2",
+ "prettier-plugin-tailwindcss": "^0.5.11",
+ "svelte": "^4.2.12",
+ "svelte-check": "^3.6.6",
+ "tailwindcss": "^3.4.1",
+ "tslib": "^2.6.2",
+ "typescript": "^5.3.3",
+ "vite": "^5.1.4",
+ "vitest": "^1.3.1"
+ },
+ "type": "module",
+ "dependencies": {
+ "@jsonurl/jsonurl": "^1.1.7",
+ "@urql/svelte": "^4.1.0",
+ "filesize": "^10.1.0",
+ "graphql": "npm:graphql-web-lite@^16.6.0",
+ "svelecte": "^3.17.3",
+ "svelte-modals": "^1.3.0"
+ }
+}
diff --git a/frontend/postcss.config.cjs b/frontend/postcss.config.cjs
new file mode 100644
index 0000000..054c147
--- /dev/null
+++ b/frontend/postcss.config.cjs
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+};
diff --git a/frontend/src/app.css b/frontend/src/app.css
new file mode 100644
index 0000000..13a7883
--- /dev/null
+++ b/frontend/src/app.css
@@ -0,0 +1,180 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ body {
+ display: grid;
+ grid-template-columns: 3rem 1fr;
+ scrollbar-color: theme('colors.gray.500') rgba(0, 0, 0, 0);
+ font-family: 'Noto Sans', sans-serif;
+ }
+
+ input,
+ textarea {
+ @apply w-full rounded bg-slate-900 p-[.4rem] focus:outline focus:outline-1 focus:outline-slate-500;
+ }
+
+ label {
+ @apply mb-0.5 inline-block;
+ }
+
+ form {
+ @apply flex flex-col gap-4 p-px text-sm;
+ }
+
+ .rounded-group > * {
+ @apply rounded-none first:rounded-l-sm last:rounded-r-sm !important;
+ }
+
+ .rounded-group-start > * {
+ @apply rounded-none first:rounded-l-sm !important;
+ }
+
+ .rounded-group-end > * {
+ @apply rounded-none last:rounded-r-sm !important;
+ }
+
+ .grid-labels {
+ @apply grid grid-cols-[1fr_5fr] gap-2;
+ }
+
+ header {
+ grid-area: header;
+ }
+
+ aside {
+ overflow: auto;
+ grid-area: sidebar;
+ @apply lg:w-[28rem] xl:w-[32rem] min-[1920px]:w-[36rem];
+ }
+
+ main {
+ grid-area: main;
+ }
+}
+
+@layer components {
+ .btn {
+ @apply flex items-center justify-center rounded-sm p-2 text-white transition-colors disabled:opacity-40;
+ }
+
+ .btn-xs {
+ @apply btn rounded-sm p-0.5 py-0;
+ }
+
+ .btn-blue {
+ @apply btn bg-blue-700 hover:bg-blue-600 disabled:bg-blue-900;
+ }
+
+ .btn-rose {
+ @apply btn bg-rose-700 hover:bg-rose-600 disabled:bg-rose-900;
+ }
+
+ .btn-slate {
+ @apply btn bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800;
+ }
+
+ .btn-indigo {
+ @apply btn bg-indigo-700 hover:bg-indigo-600 disabled:bg-indigo-800;
+ }
+
+ .icon-xs {
+ @apply text-[18px];
+ }
+
+ .icon-base {
+ @apply text-[24px];
+ }
+
+ .icon-lg {
+ @apply text-[28px];
+ }
+
+ .icon-2xl {
+ @apply text-[48px];
+ }
+
+ .icon-gray.hoverable:hover {
+ @apply text-gray-200/80;
+ }
+
+ .icon-gray.dim {
+ @apply text-gray-100/40;
+ }
+
+ .icon-yellow {
+ @apply text-yellow-300;
+ }
+
+ .icon-yellow.hoverable:hover {
+ @apply text-yellow-300/80;
+ }
+
+ .icon-yellow.dim {
+ @apply text-slate-100/40;
+ }
+}
+
+@layer utilities {
+ .toggled {
+ @apply bg-indigo-700 hover:bg-indigo-600;
+ }
+
+ .floating {
+ @apply rounded-full bg-black/50 p-1 text-white/80 backdrop-blur-sm hover:bg-black/50 hover:text-white;
+ }
+
+ .ellipsis-nowrap {
+ @apply overflow-hidden text-ellipsis whitespace-nowrap;
+ }
+
+ .rounded-inherit {
+ border-radius: inherit;
+ }
+
+ .grid-card-h {
+ grid-template-columns: 210px 1fr;
+ grid-template-rows: 300px;
+ }
+
+ .grid-card-cover-only {
+ @apply !grid-card-h;
+ }
+
+ .grid-card-v {
+ grid-template-columns: 1fr;
+ grid-template-rows: 500px 1fr;
+ }
+}
+
+.svelecte-control {
+ --sv-item-color: inherit !important;
+ --sv-bg: theme(colors.slate.900) !important;
+ --sv-disabled-bg: theme(colors.slate.900) !important;
+ --sv-border: 1px solid rgba(0, 0, 0, 0) !important;
+ --sv-disabled-border-color: rgba(0, 0, 0, 0) !important;
+ --sv-border-color: theme(colors.slate.600) !important;
+ --sv-active-border: 1px solid theme(colors.slate.500) !important;
+ --sv-item-selected-bg: theme(colors.indigo.800) !important;
+ --sv-item-active-bg: theme(colors.indigo.800) !important;
+ --sv-highlight-bg: none !important;
+ --sv-item-btn-bg-hover: theme(colors.rose.900) !important;
+ --sv-placeholder-color: theme(colors.gray.500) !important;
+}
+
+.svelecte-control input {
+ @apply !h-8;
+}
+
+.exclude .svelecte-control {
+ --sv-border: 1px solid theme('colors.red.900') !important;
+ --sv-active-border: 1px solid theme('colors.red.700') !important;
+
+ --sv-item-selected-bg: theme(colors.rose.800) !important;
+ --sv-item-active-bg: theme(colors.rose.800) !important;
+}
+
+.sv-dropdown {
+ @apply my-1 !bg-slate-950;
+}
diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts
new file mode 100644
index 0000000..41634fe
--- /dev/null
+++ b/frontend/src/app.d.ts
@@ -0,0 +1,12 @@
+/// <reference types="@sveltejs/kit" />
+/// <reference types="unplugin-icons/types/svelte" />
+
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+// and what to do when importing types
+declare namespace App {
+ // interface Locals {}
+ // interface PageData {}
+ // interface Error {}
+ // interface Platform {}
+}
diff --git a/frontend/src/app.html b/frontend/src/app.html
new file mode 100644
index 0000000..6303945
--- /dev/null
+++ b/frontend/src/app.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width" />
+ <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
+ <title>hircine</title>
+ %sveltekit.head%
+ </head>
+ <body data-sveltekit-preload-data="hover" class="h-screen bg-slate-800 text-gray-200">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/frontend/src/gql/Mutations.ts b/frontend/src/gql/Mutations.ts
new file mode 100644
index 0000000..d669b25
--- /dev/null
+++ b/frontend/src/gql/Mutations.ts
@@ -0,0 +1,244 @@
+import { toastSuccess } from '$lib/Toasts';
+import {
+ Client,
+ type AnyVariables,
+ type OperationResult,
+ type OperationResultSource
+} from '@urql/svelte';
+import { isError, isSuccess, type RequiredName } from './Utils';
+import * as gql from './graphql';
+
+export type DeleteMutation = (client: Client, args: { ids: number | number[] }) => Promise<unknown>;
+
+const comicTypes = ['Comic'];
+const comicUpsertTypes = comicTypes.concat([
+ 'Artist',
+ 'Character',
+ 'Circle',
+ 'Namespace',
+ 'Collection',
+ 'Tag',
+ 'World'
+]);
+
+function handleResult<D, I extends AnyVariables>(result: OperationResult<D, I>): Promise<D> {
+ return new Promise((resolve, reject) => {
+ if (result.error) {
+ return reject(`${result.error.name}: ${result.error.message}`);
+ }
+
+ if (!result.data) {
+ return reject('Mutation resolved, but result contains no data.');
+ }
+
+ const obj = Object.values(result.data)[0];
+
+ if (isError(obj)) {
+ reject(obj.message);
+ } else if (isSuccess(obj)) {
+ toastSuccess(obj.message);
+ resolve(result.data);
+ }
+
+ reject('This should not happen.');
+ });
+}
+
+async function handleMutation<D, V extends AnyVariables>(
+ mutation: OperationResultSource<OperationResult<D, V>>
+) {
+ return await mutation.toPromise().then(handleResult);
+}
+
+export async function addComic(client: Client, args: gql.AddComicMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddComicDocument, args, {
+ additionalTypenames: comicTypes.concat('Archive', 'Page')
+ })
+ );
+}
+
+export async function updateComics(client: Client, args: gql.UpdateComicsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateComicsDocument, args, {
+ additionalTypenames: comicTypes
+ })
+ );
+}
+
+export async function upsertComics(client: Client, args: gql.UpsertComicsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpsertComicsDocument, args, {
+ additionalTypenames: comicUpsertTypes
+ })
+ );
+}
+
+export async function updateArchives(client: Client, args: gql.UpdateArchivesMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateArchivesDocument, args, {
+ additionalTypenames: ['Archive']
+ })
+ );
+}
+
+export async function deleteArchives(client: Client, args: gql.DeleteArchivesMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteArchivesDocument, args, {
+ additionalTypenames: comicTypes.concat('Archive')
+ })
+ );
+}
+
+export async function deleteComics(client: Client, args: gql.DeleteComicsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteComicsDocument, args, { additionalTypenames: comicTypes })
+ );
+}
+
+export async function addArtist(client: Client, args: gql.AddArtistMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddArtistDocument, args, { additionalTypenames: ['ArtistFilterResult'] })
+ );
+}
+
+export async function updateArtists(client: Client, args: gql.UpdateArtistsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateArtistsDocument, args, { additionalTypenames: ['Artist'] })
+ );
+}
+
+export async function deleteArtists(client: Client, args: gql.DeleteArtistsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteArtistsDocument, args, {
+ additionalTypenames: ['Artist']
+ })
+ );
+}
+
+export async function addCharacter(client: Client, args: gql.AddCharacterMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddCharacterDocument, args, {
+ additionalTypenames: ['CharacterFilterResult']
+ })
+ );
+}
+
+export async function updateCharacters(
+ client: Client,
+ args: gql.UpdateCharactersMutationVariables
+) {
+ return await handleMutation(
+ client.mutation(gql.UpdateCharactersDocument, args, { additionalTypenames: ['Character'] })
+ );
+}
+
+export async function deleteCharacters(
+ client: Client,
+ args: gql.DeleteCharactersMutationVariables
+) {
+ return await handleMutation(
+ client.mutation(gql.DeleteCharactersDocument, args, {
+ additionalTypenames: ['Character']
+ })
+ );
+}
+
+export async function addCircle(client: Client, args: gql.AddCircleMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddCircleDocument, args, { additionalTypenames: ['CircleFilterResult'] })
+ );
+}
+
+export async function updateCircles(client: Client, args: gql.UpdateCirclesMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateCirclesDocument, args, { additionalTypenames: ['Circle'] })
+ );
+}
+
+export async function deleteCircles(client: Client, args: gql.DeleteCirclesMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteCirclesDocument, args, {
+ additionalTypenames: ['Circle']
+ })
+ );
+}
+
+export async function addNamespace(client: Client, args: gql.AddNamespaceMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddNamespaceDocument, args, {
+ additionalTypenames: ['NamespaceFilterResult', 'ComicTagFilterResult']
+ })
+ );
+}
+
+export async function updateNamespaces(
+ client: Client,
+ args: gql.UpdateNamespacesMutationVariables
+) {
+ return await handleMutation(
+ client.mutation(gql.UpdateNamespacesDocument, args, {
+ additionalTypenames: ['Namespace', 'ComicTag']
+ })
+ );
+}
+
+export async function deleteNamespaces(
+ client: Client,
+ args: gql.DeleteNamespacesMutationVariables
+) {
+ return await handleMutation(
+ client.mutation(gql.DeleteNamespacesDocument, args, {
+ additionalTypenames: ['NamespaceFilterResult', 'ComicTag']
+ })
+ );
+}
+
+export async function addTag(client: Client, args: gql.AddTagMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddTagDocument, args, {
+ additionalTypenames: ['TagFilterResult', 'ComicTagFilterResult']
+ })
+ );
+}
+
+export async function updateTags(client: Client, args: gql.UpdateTagsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateTagsDocument, args, {
+ additionalTypenames: ['Tag', 'ComicTag']
+ })
+ );
+}
+
+export async function deleteTags(client: Client, args: gql.DeleteTagsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteTagsDocument, args, {
+ additionalTypenames: ['TagFilterResult', 'ComicTag']
+ })
+ );
+}
+
+export async function addWorld(client: Client, args: gql.AddWorldMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddWorldDocument, args, { additionalTypenames: ['WorldFilterResult'] })
+ );
+}
+
+export async function updateWorlds(client: Client, args: gql.UpdateWorldsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateWorldsDocument, args, { additionalTypenames: ['World'] })
+ );
+}
+
+export async function deleteWorlds(client: Client, args: gql.DeleteWorldsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteWorldsDocument, args, { additionalTypenames: ['World'] })
+ );
+}
+
+export type ArtistInput = RequiredName<gql.UpdateArtistInput>;
+export type CharacterInput = RequiredName<gql.UpdateCharacterInput>;
+export type CircleInput = RequiredName<gql.UpdateCircleInput>;
+export type NamespaceInput = RequiredName<gql.UpdateNamespaceInput>;
+export type TagInput = RequiredName<gql.UpdateTagInput>;
+export type WorldInput = RequiredName<gql.UpdateWorldInput>;
diff --git a/frontend/src/gql/Queries.ts b/frontend/src/gql/Queries.ts
new file mode 100644
index 0000000..cc9dd4c
--- /dev/null
+++ b/frontend/src/gql/Queries.ts
@@ -0,0 +1,243 @@
+import { Client, queryStore, type OperationResult } from '@urql/svelte';
+import { isError } from './Utils';
+import * as gql from './graphql';
+
+function handleResult<D, T>(result: OperationResult<D>): Promise<T> {
+ return new Promise((resolve, reject) => {
+ if (result.error) {
+ return reject(`${result.error.name}: ${result.error.message}`);
+ }
+
+ if (!result.data) {
+ return reject('Query resolved, but result contains no data.');
+ }
+
+ const data = Object.values<T>(result.data)[0];
+
+ if (isError(data)) {
+ reject(data.message);
+ } else {
+ resolve(data);
+ }
+
+ reject('This should not happen.');
+ });
+}
+
+export function comicTagList(client: Client, args?: gql.ComicTagListQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ComicTagListDocument,
+ context: {
+ additionalTypenames: ['Namespace', 'Tag']
+ },
+ variables: args
+ });
+}
+
+export function artistList(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.ArtistListDocument,
+ context: {
+ additionalTypenames: ['Artist']
+ }
+ });
+}
+
+export function characterList(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.CharacterListDocument,
+ context: {
+ additionalTypenames: ['Character']
+ }
+ });
+}
+
+export function circleList(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.CircleListDocument,
+ context: {
+ additionalTypenames: ['Circle']
+ }
+ });
+}
+
+export function worldList(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.WorldListDocument,
+ context: {
+ additionalTypenames: ['World']
+ }
+ });
+}
+
+export function namespaceList(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.NamespaceListDocument,
+ context: {
+ additionalTypenames: ['Namespace']
+ }
+ });
+}
+
+export function comicScrapersQuery(client: Client, args: gql.ComicScrapersQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ComicScrapersDocument,
+ variables: args,
+ context: {
+ additionalTypenames: ['Comic']
+ }
+ });
+}
+
+export async function scrapeComic(client: Client, args: gql.ScrapeComicQueryVariables) {
+ return await client
+ .query(gql.ScrapeComicDocument, args, { additionalTypenames: ['Comic'] })
+ .toPromise();
+}
+
+export function archiveQuery(client: Client, args: gql.ArchiveQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ArchiveDocument,
+ variables: args,
+ context: { additionalTypenames: ['Archive'] }
+ });
+}
+
+export function archivesQuery(client: Client, args: gql.ArchivesQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ArchivesDocument,
+ variables: args,
+ context: { additionalTypenames: ['Archive'] }
+ });
+}
+
+export function artistsQuery(client: Client, args?: gql.ArtistsQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ArtistsDocument,
+ variables: args,
+ context: { additionalTypenames: ['Artist'] }
+ });
+}
+
+export function charactersQuery(client: Client, args?: gql.CharactersQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.CharactersDocument,
+ variables: args,
+ context: { additionalTypenames: ['Character'] }
+ });
+}
+
+export function circlesQuery(client: Client, args?: gql.CirclesQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.CirclesDocument,
+ variables: args,
+ context: { additionalTypenames: ['Circle'] }
+ });
+}
+
+export function comicQuery(client: Client, args: gql.ComicQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ComicDocument,
+ variables: args,
+ context: { additionalTypenames: ['Comic'] }
+ });
+}
+
+export function comicsQuery(client: Client, args?: gql.ComicsQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ComicsDocument,
+ variables: args,
+ context: { additionalTypenames: ['Comic'] }
+ });
+}
+
+export function namespacesQuery(client: Client, args?: gql.NamespacesQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.NamespacesDocument,
+ variables: args,
+ context: { additionalTypenames: ['Namespace'] }
+ });
+}
+
+export function tagsQuery(client: Client, args?: gql.TagsQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.TagsDocument,
+ variables: args,
+ context: { additionalTypenames: ['Tag'] }
+ });
+}
+
+export function worldsQuery(client: Client, args?: gql.WorldsQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.WorldsDocument,
+ variables: args,
+ context: { additionalTypenames: ['World'] }
+ });
+}
+
+export function frontpageQuery(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.FrontpageDocument,
+ requestPolicy: 'network-only'
+ });
+}
+
+export function fetchArtist(client: Client, id: number) {
+ return client
+ .query(gql.ArtistDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.ArtistQuery, gql.Artist>);
+}
+
+export function fetchCharacter(client: Client, id: number) {
+ return client
+ .query(gql.CharacterDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.CharacterQuery, gql.Character>);
+}
+
+export function fetchCircle(client: Client, id: number) {
+ return client
+ .query(gql.CircleDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.CircleQuery, gql.Circle>);
+}
+
+export function fetchNamespace(client: Client, id: number) {
+ return client
+ .query(gql.NamespaceDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.NamespaceQuery, gql.Namespace>);
+}
+
+export function fetchTag(client: Client, id: number) {
+ return client
+ .query(gql.TagDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.TagQuery, gql.FullTag>);
+}
+
+export function fetchWorld(client: Client, id: number) {
+ return client
+ .query(gql.WorldDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.WorldQuery, gql.World>);
+}
diff --git a/frontend/src/gql/Utils.ts b/frontend/src/gql/Utils.ts
new file mode 100644
index 0000000..dd21bbe
--- /dev/null
+++ b/frontend/src/gql/Utils.ts
@@ -0,0 +1,74 @@
+import equal from 'fast-deep-equal';
+import * as gql from './graphql';
+
+export type OmitIdentifiers<T> = Omit<T, 'id' | '__typename'>;
+export type RequiredName<T> = T & { name: string };
+
+export function isSuccess(object: any): object is gql.Success {
+ if (object.__typename === undefined) {
+ return false;
+ }
+
+ return object.__typename.endsWith('Success') && (object as gql.Success).message !== undefined;
+}
+
+export function isError(object: any): object is gql.Error {
+ if (object.__typename === undefined) {
+ return false;
+ }
+ return object.__typename.endsWith('Error') && (object as gql.Error).message !== undefined;
+}
+
+type Item = {
+ id: number | string;
+ name: string;
+};
+
+export function itemEquals(a: Item, b: Item) {
+ return a.name == b.name;
+}
+
+function assocEquals(as: Item[], bs: Item[]) {
+ return equal(
+ as.map((a) => a.id),
+ bs.map((b) => b.id)
+ );
+}
+
+function stringEquals(a: string | null | undefined, b: string | null | undefined) {
+ return (a ? a : null) == (b ? b : null);
+}
+
+export function tagEquals(a: gql.FullTag, b: gql.FullTag) {
+ return (
+ itemEquals(a, b) &&
+ stringEquals(a.description, b.description) &&
+ assocEquals(a.namespaces, b.namespaces)
+ );
+}
+
+export function comicEquals(
+ a: gql.FullComicFragment | undefined,
+ b: gql.FullComicFragment | undefined
+) {
+ if (a === undefined) return b === undefined;
+ if (b === undefined) return a === undefined;
+
+ return (
+ stringEquals(a.title, b.title) &&
+ stringEquals(a.originalTitle, b.originalTitle) &&
+ stringEquals(a.url, b.url) &&
+ stringEquals(a.date, b.date) &&
+ a.category == b.category &&
+ a.rating == b.rating &&
+ a.censorship == b.censorship &&
+ a.language == b.language &&
+ a.direction == b.direction &&
+ a.layout == b.layout &&
+ assocEquals(a.artists, b.artists) &&
+ assocEquals(a.circles, b.circles) &&
+ assocEquals(a.characters, b.characters) &&
+ assocEquals(a.tags, b.tags) &&
+ assocEquals(a.worlds, b.worlds)
+ );
+}
diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts
new file mode 100644
index 0000000..139068c
--- /dev/null
+++ b/frontend/src/gql/graphql.ts
@@ -0,0 +1,1764 @@
+import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
+export type Maybe<T> = T | null;
+export type InputMaybe<T> = Maybe<T>;
+export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
+export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
+export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
+export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
+export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
+/** All built-in and custom scalars, mapped to their actual values */
+export type Scalars = {
+ ID: { input: string; output: string; }
+ String: { input: string; output: string; }
+ Boolean: { input: boolean; output: boolean; }
+ Int: { input: number; output: number; }
+ Float: { input: number; output: number; }
+ Date: { input: string; output: string; }
+ DateTime: { input: string; output: string; }
+};
+
+export type AddArtistInput = {
+ name: Scalars['String']['input'];
+};
+
+export type AddCharacterInput = {
+ name: Scalars['String']['input'];
+};
+
+export type AddCircleInput = {
+ name: Scalars['String']['input'];
+};
+
+export type AddComicInput = {
+ archive: ArchiveInput;
+ cover: CoverInput;
+ pages: UniquePagesInput;
+ title: Scalars['String']['input'];
+};
+
+export type AddComicResponse = AddComicSuccess | IdNotFoundError | InvalidParameterError | PageClaimedError | PageRemoteError;
+
+export type AddComicSuccess = Success & {
+ __typename?: 'AddComicSuccess';
+ archivePagesRemaining: Scalars['Boolean']['output'];
+ id: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type AddNamespaceInput = {
+ name: Scalars['String']['input'];
+ sortName?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type AddResponse = AddSuccess | IdNotFoundError | InvalidParameterError | NameExistsError;
+
+export type AddSuccess = Success & {
+ __typename?: 'AddSuccess';
+ id: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type AddTagInput = {
+ description?: InputMaybe<Scalars['String']['input']>;
+ name: Scalars['String']['input'];
+ namespaces?: InputMaybe<NamespacesInput>;
+};
+
+export type AddWorldInput = {
+ name: Scalars['String']['input'];
+};
+
+export type Archive = {
+ __typename?: 'Archive';
+ cover: Image;
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+ organized: Scalars['Boolean']['output'];
+ pageCount: Scalars['Int']['output'];
+ path: Scalars['String']['output'];
+ size: Scalars['Int']['output'];
+};
+
+export type ArchiveFilter = {
+ organized?: InputMaybe<Scalars['Boolean']['input']>;
+ path?: InputMaybe<StringFilter>;
+};
+
+export type ArchiveFilterInput = {
+ exclude?: InputMaybe<ArchiveFilter>;
+ include?: InputMaybe<ArchiveFilter>;
+};
+
+export type ArchiveFilterResult = {
+ __typename?: 'ArchiveFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Archive>;
+};
+
+export type ArchiveInput = {
+ id: Scalars['Int']['input'];
+};
+
+export type ArchiveResponse = FullArchive | IdNotFoundError;
+
+export enum ArchiveSort {
+ CreatedAt = 'CREATED_AT',
+ PageCount = 'PAGE_COUNT',
+ Path = 'PATH',
+ Random = 'RANDOM',
+ Size = 'SIZE'
+}
+
+export type ArchiveSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: ArchiveSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type Artist = {
+ __typename?: 'Artist';
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type ArtistFilter = {
+ name?: InputMaybe<StringFilter>;
+};
+
+export type ArtistFilterInput = {
+ exclude?: InputMaybe<ArtistFilter>;
+ include?: InputMaybe<ArtistFilter>;
+};
+
+export type ArtistFilterResult = {
+ __typename?: 'ArtistFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Artist>;
+};
+
+export type ArtistResponse = Artist | IdNotFoundError;
+
+export enum ArtistSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type ArtistSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: ArtistSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ArtistsUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type ArtistsUpsertInput = {
+ names?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpsertOptions>;
+};
+
+export type AssociationFilter = {
+ all?: InputMaybe<Array<Scalars['Int']['input']>>;
+ any?: InputMaybe<Array<Scalars['Int']['input']>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+ exact?: InputMaybe<Array<Scalars['Int']['input']>>;
+};
+
+export enum Category {
+ Artbook = 'ARTBOOK',
+ Comic = 'COMIC',
+ Doujinshi = 'DOUJINSHI',
+ GameCg = 'GAME_CG',
+ ImageSet = 'IMAGE_SET',
+ Manga = 'MANGA',
+ VariantSet = 'VARIANT_SET',
+ Webtoon = 'WEBTOON'
+}
+
+export type CategoryFilter = {
+ any?: InputMaybe<Array<Category>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+};
+
+export enum Censorship {
+ Bar = 'BAR',
+ Full = 'FULL',
+ Mosaic = 'MOSAIC',
+ None = 'NONE'
+}
+
+export type CensorshipFilter = {
+ any?: InputMaybe<Array<Censorship>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+};
+
+export type Character = {
+ __typename?: 'Character';
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type CharacterFilter = {
+ name?: InputMaybe<StringFilter>;
+};
+
+export type CharacterFilterInput = {
+ exclude?: InputMaybe<CharacterFilter>;
+ include?: InputMaybe<CharacterFilter>;
+};
+
+export type CharacterFilterResult = {
+ __typename?: 'CharacterFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Character>;
+};
+
+export type CharacterResponse = Character | IdNotFoundError;
+
+export enum CharacterSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type CharacterSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: CharacterSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type CharactersUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type CharactersUpsertInput = {
+ names?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpsertOptions>;
+};
+
+export type Circle = {
+ __typename?: 'Circle';
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type CircleFilter = {
+ name?: InputMaybe<StringFilter>;
+};
+
+export type CircleFilterInput = {
+ exclude?: InputMaybe<CircleFilter>;
+ include?: InputMaybe<CircleFilter>;
+};
+
+export type CircleFilterResult = {
+ __typename?: 'CircleFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Circle>;
+};
+
+export type CircleResponse = Circle | IdNotFoundError;
+
+export enum CircleSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type CircleSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: CircleSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type CirclesUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type CirclesUpsertInput = {
+ names?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpsertOptions>;
+};
+
+export type Comic = {
+ __typename?: 'Comic';
+ artists: Array<Artist>;
+ bookmarked: Scalars['Boolean']['output'];
+ category?: Maybe<Category>;
+ censorship?: Maybe<Censorship>;
+ characters: Array<Character>;
+ circles: Array<Circle>;
+ cover: Image;
+ date?: Maybe<Scalars['Date']['output']>;
+ favourite: Scalars['Boolean']['output'];
+ id: Scalars['Int']['output'];
+ language?: Maybe<Language>;
+ organized: Scalars['Boolean']['output'];
+ originalTitle?: Maybe<Scalars['String']['output']>;
+ pageCount: Scalars['Int']['output'];
+ rating?: Maybe<Rating>;
+ tags: Array<ComicTag>;
+ title: Scalars['String']['output'];
+ worlds: Array<World>;
+};
+
+export type ComicFilter = {
+ artists?: InputMaybe<AssociationFilter>;
+ bookmarked?: InputMaybe<Scalars['Boolean']['input']>;
+ category?: InputMaybe<CategoryFilter>;
+ censorship?: InputMaybe<CensorshipFilter>;
+ characters?: InputMaybe<AssociationFilter>;
+ circles?: InputMaybe<AssociationFilter>;
+ favourite?: InputMaybe<Scalars['Boolean']['input']>;
+ language?: InputMaybe<LanguageFilter>;
+ organized?: InputMaybe<Scalars['Boolean']['input']>;
+ originalTitle?: InputMaybe<StringFilter>;
+ rating?: InputMaybe<RatingFilter>;
+ tags?: InputMaybe<TagAssociationFilter>;
+ title?: InputMaybe<StringFilter>;
+ url?: InputMaybe<StringFilter>;
+ worlds?: InputMaybe<AssociationFilter>;
+};
+
+export type ComicFilterInput = {
+ exclude?: InputMaybe<ComicFilter>;
+ include?: InputMaybe<ComicFilter>;
+};
+
+export type ComicFilterResult = {
+ __typename?: 'ComicFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Comic>;
+};
+
+export type ComicResponse = FullComic | IdNotFoundError;
+
+export type ComicScraper = {
+ __typename?: 'ComicScraper';
+ id: Scalars['String']['output'];
+ name: Scalars['String']['output'];
+};
+
+export enum ComicSort {
+ CreatedAt = 'CREATED_AT',
+ Date = 'DATE',
+ OriginalTitle = 'ORIGINAL_TITLE',
+ PageCount = 'PAGE_COUNT',
+ Random = 'RANDOM',
+ TagCount = 'TAG_COUNT',
+ Title = 'TITLE',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type ComicSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: ComicSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ComicTag = {
+ __typename?: 'ComicTag';
+ description?: Maybe<Scalars['String']['output']>;
+ id: Scalars['String']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type ComicTagFilterResult = {
+ __typename?: 'ComicTagFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<ComicTag>;
+};
+
+export type ComicTagsUpdateInput = {
+ ids?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type ComicTagsUpsertInput = {
+ names?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpsertOptions>;
+};
+
+export type CoverInput = {
+ id: Scalars['Int']['input'];
+};
+
+export type CoverUpdateInput = {
+ id: Scalars['Int']['input'];
+};
+
+export type DeleteResponse = DeleteSuccess | IdNotFoundError;
+
+export type DeleteSuccess = Success & {
+ __typename?: 'DeleteSuccess';
+ message: Scalars['String']['output'];
+};
+
+export enum Direction {
+ LeftToRight = 'LEFT_TO_RIGHT',
+ RightToLeft = 'RIGHT_TO_LEFT'
+}
+
+export type Error = {
+ message: Scalars['String']['output'];
+};
+
+export type FullArchive = {
+ __typename?: 'FullArchive';
+ comics: Array<Comic>;
+ cover: Image;
+ createdAt: Scalars['DateTime']['output'];
+ id: Scalars['Int']['output'];
+ mtime: Scalars['DateTime']['output'];
+ name: Scalars['String']['output'];
+ organized: Scalars['Boolean']['output'];
+ pageCount: Scalars['Int']['output'];
+ pages: Array<Page>;
+ path: Scalars['String']['output'];
+ size: Scalars['Int']['output'];
+};
+
+export type FullComic = {
+ __typename?: 'FullComic';
+ archive: Archive;
+ artists: Array<Artist>;
+ bookmarked: Scalars['Boolean']['output'];
+ category?: Maybe<Category>;
+ censorship?: Maybe<Censorship>;
+ characters: Array<Character>;
+ circles: Array<Circle>;
+ cover: Image;
+ createdAt: Scalars['DateTime']['output'];
+ date?: Maybe<Scalars['Date']['output']>;
+ direction: Direction;
+ favourite: Scalars['Boolean']['output'];
+ id: Scalars['Int']['output'];
+ language?: Maybe<Language>;
+ layout: Layout;
+ organized: Scalars['Boolean']['output'];
+ originalTitle?: Maybe<Scalars['String']['output']>;
+ pageCount: Scalars['Int']['output'];
+ pages: Array<Page>;
+ rating?: Maybe<Rating>;
+ tags: Array<ComicTag>;
+ title: Scalars['String']['output'];
+ updatedAt: Scalars['DateTime']['output'];
+ url?: Maybe<Scalars['String']['output']>;
+ worlds: Array<World>;
+};
+
+export type FullTag = {
+ __typename?: 'FullTag';
+ description?: Maybe<Scalars['String']['output']>;
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+ namespaces: Array<Namespace>;
+};
+
+export type IdNotFoundError = Error & {
+ __typename?: 'IDNotFoundError';
+ id: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type Image = {
+ __typename?: 'Image';
+ aspectRatio: Scalars['Float']['output'];
+ hash: Scalars['String']['output'];
+ height: Scalars['Int']['output'];
+ id: Scalars['Int']['output'];
+ width: Scalars['Int']['output'];
+};
+
+export type InvalidParameterError = Error & {
+ __typename?: 'InvalidParameterError';
+ message: Scalars['String']['output'];
+ parameter: Scalars['String']['output'];
+};
+
+export enum Language {
+ Aa = 'AA',
+ Ab = 'AB',
+ Ae = 'AE',
+ Af = 'AF',
+ Ak = 'AK',
+ Am = 'AM',
+ An = 'AN',
+ Ar = 'AR',
+ As = 'AS',
+ Av = 'AV',
+ Ay = 'AY',
+ Az = 'AZ',
+ Ba = 'BA',
+ Be = 'BE',
+ Bg = 'BG',
+ Bh = 'BH',
+ Bi = 'BI',
+ Bm = 'BM',
+ Bn = 'BN',
+ Bo = 'BO',
+ Br = 'BR',
+ Bs = 'BS',
+ Ca = 'CA',
+ Ce = 'CE',
+ Ch = 'CH',
+ Co = 'CO',
+ Cr = 'CR',
+ Cs = 'CS',
+ Cu = 'CU',
+ Cv = 'CV',
+ Cy = 'CY',
+ Da = 'DA',
+ De = 'DE',
+ Dv = 'DV',
+ Dz = 'DZ',
+ Ee = 'EE',
+ El = 'EL',
+ En = 'EN',
+ Eo = 'EO',
+ Es = 'ES',
+ Et = 'ET',
+ Eu = 'EU',
+ Fa = 'FA',
+ Ff = 'FF',
+ Fi = 'FI',
+ Fj = 'FJ',
+ Fo = 'FO',
+ Fr = 'FR',
+ Fy = 'FY',
+ Ga = 'GA',
+ Gd = 'GD',
+ Gl = 'GL',
+ Gn = 'GN',
+ Gu = 'GU',
+ Gv = 'GV',
+ Ha = 'HA',
+ He = 'HE',
+ Hi = 'HI',
+ Ho = 'HO',
+ Hr = 'HR',
+ Ht = 'HT',
+ Hu = 'HU',
+ Hy = 'HY',
+ Hz = 'HZ',
+ Ia = 'IA',
+ Id = 'ID',
+ Ie = 'IE',
+ Ig = 'IG',
+ Ii = 'II',
+ Ik = 'IK',
+ Io = 'IO',
+ Is = 'IS',
+ It = 'IT',
+ Iu = 'IU',
+ Ja = 'JA',
+ Jv = 'JV',
+ Ka = 'KA',
+ Kg = 'KG',
+ Ki = 'KI',
+ Kj = 'KJ',
+ Kk = 'KK',
+ Kl = 'KL',
+ Km = 'KM',
+ Kn = 'KN',
+ Ko = 'KO',
+ Kr = 'KR',
+ Ks = 'KS',
+ Ku = 'KU',
+ Kv = 'KV',
+ Kw = 'KW',
+ Ky = 'KY',
+ La = 'LA',
+ Lb = 'LB',
+ Lg = 'LG',
+ Li = 'LI',
+ Ln = 'LN',
+ Lo = 'LO',
+ Lt = 'LT',
+ Lu = 'LU',
+ Lv = 'LV',
+ Mg = 'MG',
+ Mh = 'MH',
+ Mi = 'MI',
+ Mk = 'MK',
+ Ml = 'ML',
+ Mn = 'MN',
+ Mr = 'MR',
+ Ms = 'MS',
+ Mt = 'MT',
+ My = 'MY',
+ Na = 'NA',
+ Nb = 'NB',
+ Nd = 'ND',
+ Ne = 'NE',
+ Ng = 'NG',
+ Nl = 'NL',
+ Nn = 'NN',
+ No = 'NO',
+ Nr = 'NR',
+ Nv = 'NV',
+ Ny = 'NY',
+ Oc = 'OC',
+ Oj = 'OJ',
+ Om = 'OM',
+ Or = 'OR',
+ Os = 'OS',
+ Pa = 'PA',
+ Pi = 'PI',
+ Pl = 'PL',
+ Ps = 'PS',
+ Pt = 'PT',
+ Qu = 'QU',
+ Rm = 'RM',
+ Rn = 'RN',
+ Ro = 'RO',
+ Ru = 'RU',
+ Rw = 'RW',
+ Sa = 'SA',
+ Sc = 'SC',
+ Sd = 'SD',
+ Se = 'SE',
+ Sg = 'SG',
+ Si = 'SI',
+ Sk = 'SK',
+ Sl = 'SL',
+ Sm = 'SM',
+ Sn = 'SN',
+ So = 'SO',
+ Sq = 'SQ',
+ Sr = 'SR',
+ Ss = 'SS',
+ St = 'ST',
+ Su = 'SU',
+ Sv = 'SV',
+ Sw = 'SW',
+ Ta = 'TA',
+ Te = 'TE',
+ Tg = 'TG',
+ Th = 'TH',
+ Ti = 'TI',
+ Tk = 'TK',
+ Tl = 'TL',
+ Tn = 'TN',
+ To = 'TO',
+ Tr = 'TR',
+ Ts = 'TS',
+ Tt = 'TT',
+ Tw = 'TW',
+ Ty = 'TY',
+ Ug = 'UG',
+ Uk = 'UK',
+ Ur = 'UR',
+ Uz = 'UZ',
+ Ve = 'VE',
+ Vi = 'VI',
+ Vo = 'VO',
+ Wa = 'WA',
+ Wo = 'WO',
+ Xh = 'XH',
+ Yi = 'YI',
+ Yo = 'YO',
+ Za = 'ZA',
+ Zh = 'ZH',
+ Zu = 'ZU'
+}
+
+export type LanguageFilter = {
+ any?: InputMaybe<Array<Language>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+};
+
+export enum Layout {
+ Double = 'DOUBLE',
+ DoubleOffset = 'DOUBLE_OFFSET',
+ Single = 'SINGLE'
+}
+
+export type Mutation = {
+ __typename?: 'Mutation';
+ addArtist: AddResponse;
+ addCharacter: AddResponse;
+ addCircle: AddResponse;
+ addComic: AddComicResponse;
+ addNamespace: AddResponse;
+ addTag: AddResponse;
+ addWorld: AddResponse;
+ deleteArchives: DeleteResponse;
+ deleteArtists: DeleteResponse;
+ deleteCharacters: DeleteResponse;
+ deleteCircles: DeleteResponse;
+ deleteComics: DeleteResponse;
+ deleteNamespaces: DeleteResponse;
+ deleteTags: DeleteResponse;
+ deleteWorlds: DeleteResponse;
+ updateArchives: UpdateResponse;
+ updateArtists: UpdateResponse;
+ updateCharacters: UpdateResponse;
+ updateCircles: UpdateResponse;
+ updateComics: UpdateResponse;
+ updateNamespaces: UpdateResponse;
+ updateTags: UpdateResponse;
+ updateWorlds: UpdateResponse;
+ upsertComics: UpsertResponse;
+};
+
+
+export type MutationAddArtistArgs = {
+ input: AddArtistInput;
+};
+
+
+export type MutationAddCharacterArgs = {
+ input: AddCharacterInput;
+};
+
+
+export type MutationAddCircleArgs = {
+ input: AddCircleInput;
+};
+
+
+export type MutationAddComicArgs = {
+ input: AddComicInput;
+};
+
+
+export type MutationAddNamespaceArgs = {
+ input: AddNamespaceInput;
+};
+
+
+export type MutationAddTagArgs = {
+ input: AddTagInput;
+};
+
+
+export type MutationAddWorldArgs = {
+ input: AddWorldInput;
+};
+
+
+export type MutationDeleteArchivesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteArtistsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteCharactersArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteCirclesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteComicsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteNamespacesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteTagsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteWorldsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationUpdateArchivesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateArchiveInput;
+};
+
+
+export type MutationUpdateArtistsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateArtistInput;
+};
+
+
+export type MutationUpdateCharactersArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateCharacterInput;
+};
+
+
+export type MutationUpdateCirclesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateCircleInput;
+};
+
+
+export type MutationUpdateComicsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateComicInput;
+};
+
+
+export type MutationUpdateNamespacesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateNamespaceInput;
+};
+
+
+export type MutationUpdateTagsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateTagInput;
+};
+
+
+export type MutationUpdateWorldsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateWorldInput;
+};
+
+
+export type MutationUpsertComicsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpsertComicInput;
+};
+
+export type NameExistsError = Error & {
+ __typename?: 'NameExistsError';
+ message: Scalars['String']['output'];
+};
+
+export type Namespace = {
+ __typename?: 'Namespace';
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+ sortName?: Maybe<Scalars['String']['output']>;
+};
+
+export type NamespaceFilter = {
+ name?: InputMaybe<StringFilter>;
+};
+
+export type NamespaceFilterInput = {
+ exclude?: InputMaybe<NamespaceFilter>;
+ include?: InputMaybe<NamespaceFilter>;
+};
+
+export type NamespaceFilterResult = {
+ __typename?: 'NamespaceFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Namespace>;
+};
+
+export type NamespaceResponse = IdNotFoundError | Namespace;
+
+export enum NamespaceSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ SortName = 'SORT_NAME',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type NamespaceSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: NamespaceSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type NamespacesInput = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+export type NamespacesUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export enum OnMissing {
+ Create = 'CREATE',
+ Ignore = 'IGNORE'
+}
+
+export type Page = {
+ __typename?: 'Page';
+ comicId?: Maybe<Scalars['Int']['output']>;
+ id: Scalars['Int']['output'];
+ image: Image;
+ path: Scalars['String']['output'];
+};
+
+export type PageClaimedError = Error & {
+ __typename?: 'PageClaimedError';
+ comicId: Scalars['Int']['output'];
+ id: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type PageRemoteError = Error & {
+ __typename?: 'PageRemoteError';
+ archiveId: Scalars['Int']['output'];
+ id: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type Pagination = {
+ items?: Scalars['Int']['input'];
+ page?: Scalars['Int']['input'];
+};
+
+export type Query = {
+ __typename?: 'Query';
+ archive: ArchiveResponse;
+ archives: ArchiveFilterResult;
+ artist: ArtistResponse;
+ artists: ArtistFilterResult;
+ character: CharacterResponse;
+ characters: CharacterFilterResult;
+ circle: CircleResponse;
+ circles: CircleFilterResult;
+ comic: ComicResponse;
+ comicScrapers: Array<ComicScraper>;
+ comicTags: ComicTagFilterResult;
+ comics: ComicFilterResult;
+ namespace: NamespaceResponse;
+ namespaces: NamespaceFilterResult;
+ scrapeComic: ScrapeComicResponse;
+ tag: TagResponse;
+ tags: TagFilterResult;
+ world: WorldResponse;
+ worlds: WorldFilterResult;
+};
+
+
+export type QueryArchiveArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryArchivesArgs = {
+ filter?: InputMaybe<ArchiveFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<ArchiveSortInput>;
+};
+
+
+export type QueryArtistArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryArtistsArgs = {
+ filter?: InputMaybe<ArtistFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<ArtistSortInput>;
+};
+
+
+export type QueryCharacterArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryCharactersArgs = {
+ filter?: InputMaybe<CharacterFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<CharacterSortInput>;
+};
+
+
+export type QueryCircleArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryCirclesArgs = {
+ filter?: InputMaybe<CircleFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<CircleSortInput>;
+};
+
+
+export type QueryComicArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryComicScrapersArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryComicTagsArgs = {
+ forFilter?: Scalars['Boolean']['input'];
+};
+
+
+export type QueryComicsArgs = {
+ filter?: InputMaybe<ComicFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<ComicSortInput>;
+};
+
+
+export type QueryNamespaceArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryNamespacesArgs = {
+ filter?: InputMaybe<NamespaceFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<NamespaceSortInput>;
+};
+
+
+export type QueryScrapeComicArgs = {
+ id: Scalars['Int']['input'];
+ scraper: Scalars['String']['input'];
+};
+
+
+export type QueryTagArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryTagsArgs = {
+ filter?: InputMaybe<TagFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<TagSortInput>;
+};
+
+
+export type QueryWorldArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryWorldsArgs = {
+ filter?: InputMaybe<WorldFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<WorldSortInput>;
+};
+
+export enum Rating {
+ Explicit = 'EXPLICIT',
+ Questionable = 'QUESTIONABLE',
+ Safe = 'SAFE'
+}
+
+export type RatingFilter = {
+ any?: InputMaybe<Array<Rating>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+};
+
+export type ScrapeComicResponse = IdNotFoundError | ScrapeComicResult | ScraperError | ScraperNotAvailableError | ScraperNotFoundError;
+
+export type ScrapeComicResult = {
+ __typename?: 'ScrapeComicResult';
+ data: ScrapedComic;
+ warnings: Array<Scalars['String']['output']>;
+};
+
+export type ScrapedComic = {
+ __typename?: 'ScrapedComic';
+ artists: Array<Scalars['String']['output']>;
+ category?: Maybe<Category>;
+ censorship?: Maybe<Censorship>;
+ characters: Array<Scalars['String']['output']>;
+ circles: Array<Scalars['String']['output']>;
+ date?: Maybe<Scalars['Date']['output']>;
+ direction?: Maybe<Direction>;
+ language?: Maybe<Language>;
+ layout?: Maybe<Layout>;
+ originalTitle?: Maybe<Scalars['String']['output']>;
+ rating?: Maybe<Rating>;
+ tags: Array<Scalars['String']['output']>;
+ title?: Maybe<Scalars['String']['output']>;
+ url?: Maybe<Scalars['String']['output']>;
+ worlds: Array<Scalars['String']['output']>;
+};
+
+export type ScraperError = Error & {
+ __typename?: 'ScraperError';
+ error: Scalars['String']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type ScraperNotAvailableError = Error & {
+ __typename?: 'ScraperNotAvailableError';
+ comicId: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+ scraper: Scalars['String']['output'];
+};
+
+export type ScraperNotFoundError = Error & {
+ __typename?: 'ScraperNotFoundError';
+ message: Scalars['String']['output'];
+ name: Scalars['String']['output'];
+};
+
+export enum SortDirection {
+ Ascending = 'ASCENDING',
+ Descending = 'DESCENDING'
+}
+
+export type StringFilter = {
+ contains?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type Success = {
+ message: Scalars['String']['output'];
+};
+
+export type Tag = {
+ __typename?: 'Tag';
+ description?: Maybe<Scalars['String']['output']>;
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type TagAssociationFilter = {
+ all?: InputMaybe<Array<Scalars['String']['input']>>;
+ any?: InputMaybe<Array<Scalars['String']['input']>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+ exact?: InputMaybe<Array<Scalars['String']['input']>>;
+};
+
+export type TagFilter = {
+ name?: InputMaybe<StringFilter>;
+ namespaces?: InputMaybe<AssociationFilter>;
+};
+
+export type TagFilterInput = {
+ exclude?: InputMaybe<TagFilter>;
+ include?: InputMaybe<TagFilter>;
+};
+
+export type TagFilterResult = {
+ __typename?: 'TagFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Tag>;
+};
+
+export type TagResponse = FullTag | IdNotFoundError;
+
+export enum TagSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type TagSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: TagSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type UniquePagesInput = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+export type UniquePagesUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type UpdateArchiveInput = {
+ cover?: InputMaybe<CoverUpdateInput>;
+ organized?: InputMaybe<Scalars['Boolean']['input']>;
+};
+
+export type UpdateArtistInput = {
+ name?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type UpdateCharacterInput = {
+ name?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type UpdateCircleInput = {
+ name?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type UpdateComicInput = {
+ artists?: InputMaybe<ArtistsUpdateInput>;
+ bookmarked?: InputMaybe<Scalars['Boolean']['input']>;
+ category?: InputMaybe<Category>;
+ censorship?: InputMaybe<Censorship>;
+ characters?: InputMaybe<CharactersUpdateInput>;
+ circles?: InputMaybe<CirclesUpdateInput>;
+ cover?: InputMaybe<CoverUpdateInput>;
+ date?: InputMaybe<Scalars['Date']['input']>;
+ direction?: InputMaybe<Direction>;
+ favourite?: InputMaybe<Scalars['Boolean']['input']>;
+ language?: InputMaybe<Language>;
+ layout?: InputMaybe<Layout>;
+ organized?: InputMaybe<Scalars['Boolean']['input']>;
+ originalTitle?: InputMaybe<Scalars['String']['input']>;
+ pages?: InputMaybe<UniquePagesUpdateInput>;
+ rating?: InputMaybe<Rating>;
+ tags?: InputMaybe<ComicTagsUpdateInput>;
+ title?: InputMaybe<Scalars['String']['input']>;
+ url?: InputMaybe<Scalars['String']['input']>;
+ worlds?: InputMaybe<WorldsUpdateInput>;
+};
+
+export enum UpdateMode {
+ Add = 'ADD',
+ Remove = 'REMOVE',
+ Replace = 'REPLACE'
+}
+
+export type UpdateNamespaceInput = {
+ name?: InputMaybe<Scalars['String']['input']>;
+ sortName?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type UpdateOptions = {
+ mode?: UpdateMode;
+};
+
+export type UpdateResponse = IdNotFoundError | InvalidParameterError | NameExistsError | PageClaimedError | PageRemoteError | UpdateSuccess;
+
+export type UpdateSuccess = Success & {
+ __typename?: 'UpdateSuccess';
+ message: Scalars['String']['output'];
+};
+
+export type UpdateTagInput = {
+ description?: InputMaybe<Scalars['String']['input']>;
+ name?: InputMaybe<Scalars['String']['input']>;
+ namespaces?: InputMaybe<NamespacesUpdateInput>;
+};
+
+export type UpdateWorldInput = {
+ name?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type UpsertComicInput = {
+ artists?: InputMaybe<ArtistsUpsertInput>;
+ bookmarked?: InputMaybe<Scalars['Boolean']['input']>;
+ category?: InputMaybe<Category>;
+ censorship?: InputMaybe<Censorship>;
+ characters?: InputMaybe<CharactersUpsertInput>;
+ circles?: InputMaybe<CirclesUpsertInput>;
+ date?: InputMaybe<Scalars['Date']['input']>;
+ direction?: InputMaybe<Direction>;
+ favourite?: InputMaybe<Scalars['Boolean']['input']>;
+ language?: InputMaybe<Language>;
+ layout?: InputMaybe<Layout>;
+ organized?: InputMaybe<Scalars['Boolean']['input']>;
+ originalTitle?: InputMaybe<Scalars['String']['input']>;
+ rating?: InputMaybe<Rating>;
+ tags?: InputMaybe<ComicTagsUpsertInput>;
+ title?: InputMaybe<Scalars['String']['input']>;
+ url?: InputMaybe<Scalars['String']['input']>;
+ worlds?: InputMaybe<WorldsUpsertInput>;
+};
+
+export type UpsertOptions = {
+ onMissing?: OnMissing;
+};
+
+export type UpsertResponse = InvalidParameterError | NameExistsError | UpsertSuccess;
+
+export type UpsertSuccess = Success & {
+ __typename?: 'UpsertSuccess';
+ message: Scalars['String']['output'];
+};
+
+export type World = {
+ __typename?: 'World';
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type WorldFilter = {
+ name?: InputMaybe<StringFilter>;
+};
+
+export type WorldFilterInput = {
+ exclude?: InputMaybe<WorldFilter>;
+ include?: InputMaybe<WorldFilter>;
+};
+
+export type WorldFilterResult = {
+ __typename?: 'WorldFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<World>;
+};
+
+export type WorldResponse = IdNotFoundError | World;
+
+export enum WorldSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type WorldSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: WorldSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type WorldsUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type WorldsUpsertInput = {
+ names?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpsertOptions>;
+};
+
+export type ImageFragment = { __typename?: 'Image', hash: string, width: number, height: number };
+
+export type PageFragment = { __typename?: 'Page', id: number, path: string, comicId?: number | null, image: { __typename?: 'Image', id: number, hash: string, aspectRatio: number, width: number, height: number } };
+
+export type ComicFragment = { __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> };
+
+export type FullArchiveFragment = { __typename?: 'FullArchive', id: number, name: string, path: string, size: number, createdAt: string, mtime: string, organized: boolean, pageCount: number, pages: Array<{ __typename?: 'Page', id: number, path: string, comicId?: number | null, image: { __typename?: 'Image', id: number, hash: string, aspectRatio: number, width: number, height: number } }>, comics: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> };
+
+export type ArchiveFragment = { __typename?: 'Archive', id: number, name: string, size: number, pageCount: number, cover: { __typename?: 'Image', hash: string, width: number, height: number } };
+
+export type FullComicFragment = { __typename?: 'FullComic', id: number, title: string, originalTitle?: string | null, url?: string | null, language?: Language | null, direction: Direction, date?: string | null, layout: Layout, rating?: Rating | null, category?: Category | null, censorship?: Censorship | null, favourite: boolean, createdAt: string, updatedAt: string, organized: boolean, bookmarked: boolean, pages: Array<{ __typename?: 'Page', id: number, path: string, comicId?: number | null, image: { __typename?: 'Image', id: number, hash: string, aspectRatio: number, width: number, height: number } }>, archive: { __typename?: 'Archive', id: number }, tags: Array<{ __typename?: 'ComicTag', id: string, name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', id: number, name: string }>, characters: Array<{ __typename?: 'Character', id: number, name: string }>, worlds: Array<{ __typename?: 'World', id: number, name: string }>, circles: Array<{ __typename?: 'Circle', id: number, name: string }> };
+
+export type ComicScraperFragment = { __typename?: 'ComicScraper', id: string, name: string };
+
+export type ScrapeComicResultFragment = { __typename?: 'ScrapeComicResult', warnings: Array<string>, data: { __typename?: 'ScrapedComic', artists: Array<string>, category?: Category | null, censorship?: Censorship | null, characters: Array<string>, circles: Array<string>, date?: string | null, direction?: Direction | null, language?: Language | null, layout?: Layout | null, originalTitle?: string | null, url?: string | null, rating?: Rating | null, tags: Array<string>, title?: string | null, worlds: Array<string> } };
+
+export type ComicsQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<ComicFilterInput>;
+ sort?: InputMaybe<ComicSortInput>;
+}>;
+
+
+export type ComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> } };
+
+export type ArchivesQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<ArchiveFilterInput>;
+ sort?: InputMaybe<ArchiveSortInput>;
+}>;
+
+
+export type ArchivesQuery = { __typename?: 'Query', archives: { __typename?: 'ArchiveFilterResult', count: number, edges: Array<{ __typename?: 'Archive', id: number, name: string, size: number, pageCount: number, cover: { __typename?: 'Image', hash: string, width: number, height: number } }> } };
+
+export type ArchiveQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type ArchiveQuery = { __typename?: 'Query', archive: { __typename?: 'FullArchive', id: number, name: string, path: string, size: number, createdAt: string, mtime: string, organized: boolean, pageCount: number, pages: Array<{ __typename?: 'Page', id: number, path: string, comicId?: number | null, image: { __typename?: 'Image', id: number, hash: string, aspectRatio: number, width: number, height: number } }>, comics: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type ComicQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type ComicQuery = { __typename?: 'Query', comic: { __typename?: 'FullComic', id: number, title: string, originalTitle?: string | null, url?: string | null, language?: Language | null, direction: Direction, date?: string | null, layout: Layout, rating?: Rating | null, category?: Category | null, censorship?: Censorship | null, favourite: boolean, createdAt: string, updatedAt: string, organized: boolean, bookmarked: boolean, pages: Array<{ __typename?: 'Page', id: number, path: string, comicId?: number | null, image: { __typename?: 'Image', id: number, hash: string, aspectRatio: number, width: number, height: number } }>, archive: { __typename?: 'Archive', id: number }, tags: Array<{ __typename?: 'ComicTag', id: string, name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', id: number, name: string }>, characters: Array<{ __typename?: 'Character', id: number, name: string }>, worlds: Array<{ __typename?: 'World', id: number, name: string }>, circles: Array<{ __typename?: 'Circle', id: number, name: string }> } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type TagQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type TagQuery = { __typename?: 'Query', tag: { __typename?: 'FullTag', id: number, name: string, description?: string | null, namespaces: Array<{ __typename?: 'Namespace', id: number, name: string }> } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type TagsQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<TagFilterInput>;
+ sort?: InputMaybe<TagSortInput>;
+}>;
+
+
+export type TagsQuery = { __typename?: 'Query', tags: { __typename?: 'TagFilterResult', count: number, edges: Array<{ __typename?: 'Tag', id: number, name: string, description?: string | null }> } };
+
+export type NamespaceQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type NamespaceQuery = { __typename?: 'Query', namespace: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'Namespace', id: number, name: string, sortName?: string | null } };
+
+export type NamespacesQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<NamespaceFilterInput>;
+ sort?: InputMaybe<NamespaceSortInput>;
+}>;
+
+
+export type NamespacesQuery = { __typename?: 'Query', namespaces: { __typename?: 'NamespaceFilterResult', count: number, edges: Array<{ __typename?: 'Namespace', id: number, name: string }> } };
+
+export type ComicTagListQueryVariables = Exact<{
+ forFilter?: InputMaybe<Scalars['Boolean']['input']>;
+}>;
+
+
+export type ComicTagListQuery = { __typename?: 'Query', comicTags: { __typename?: 'ComicTagFilterResult', edges: Array<{ __typename?: 'ComicTag', id: string, name: string }> } };
+
+export type ArtistListQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type ArtistListQuery = { __typename?: 'Query', artists: { __typename?: 'ArtistFilterResult', edges: Array<{ __typename?: 'Artist', id: number, name: string }> } };
+
+export type CharacterListQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type CharacterListQuery = { __typename?: 'Query', characters: { __typename?: 'CharacterFilterResult', edges: Array<{ __typename?: 'Character', id: number, name: string }> } };
+
+export type CircleListQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type CircleListQuery = { __typename?: 'Query', circles: { __typename?: 'CircleFilterResult', edges: Array<{ __typename?: 'Circle', id: number, name: string }> } };
+
+export type WorldListQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type WorldListQuery = { __typename?: 'Query', worlds: { __typename?: 'WorldFilterResult', edges: Array<{ __typename?: 'World', id: number, name: string }> } };
+
+export type NamespaceListQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type NamespaceListQuery = { __typename?: 'Query', namespaces: { __typename?: 'NamespaceFilterResult', edges: Array<{ __typename?: 'Namespace', id: number, name: string }> } };
+
+export type ArtistsQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<ArtistFilterInput>;
+ sort?: InputMaybe<ArtistSortInput>;
+}>;
+
+
+export type ArtistsQuery = { __typename?: 'Query', artists: { __typename?: 'ArtistFilterResult', count: number, edges: Array<{ __typename?: 'Artist', id: number, name: string }> } };
+
+export type ArtistQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type ArtistQuery = { __typename?: 'Query', artist: { __typename?: 'Artist', id: number, name: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type CharactersQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<CharacterFilterInput>;
+ sort?: InputMaybe<CharacterSortInput>;
+}>;
+
+
+export type CharactersQuery = { __typename?: 'Query', characters: { __typename?: 'CharacterFilterResult', count: number, edges: Array<{ __typename?: 'Character', id: number, name: string }> } };
+
+export type CharacterQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type CharacterQuery = { __typename?: 'Query', character: { __typename?: 'Character', id: number, name: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type CirclesQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<CircleFilterInput>;
+ sort?: InputMaybe<CircleSortInput>;
+}>;
+
+
+export type CirclesQuery = { __typename?: 'Query', circles: { __typename?: 'CircleFilterResult', count: number, edges: Array<{ __typename?: 'Circle', id: number, name: string }> } };
+
+export type CircleQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type CircleQuery = { __typename?: 'Query', circle: { __typename?: 'Circle', id: number, name: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type WorldsQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<WorldFilterInput>;
+ sort?: InputMaybe<WorldSortInput>;
+}>;
+
+
+export type WorldsQuery = { __typename?: 'Query', worlds: { __typename?: 'WorldFilterResult', count: number, edges: Array<{ __typename?: 'World', id: number, name: string }> } };
+
+export type WorldQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type WorldQuery = { __typename?: 'Query', world: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'World', id: number, name: string } };
+
+export type ComicScrapersQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type ComicScrapersQuery = { __typename?: 'Query', comicScrapers: Array<{ __typename?: 'ComicScraper', id: string, name: string }> };
+
+export type ScrapeComicQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+ scraper: Scalars['String']['input'];
+}>;
+
+
+export type ScrapeComicQuery = { __typename?: 'Query', scrapeComic: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'ScrapeComicResult', warnings: Array<string>, data: { __typename?: 'ScrapedComic', artists: Array<string>, category?: Category | null, censorship?: Censorship | null, characters: Array<string>, circles: Array<string>, date?: string | null, direction?: Direction | null, language?: Language | null, layout?: Layout | null, originalTitle?: string | null, url?: string | null, rating?: Rating | null, tags: Array<string>, title?: string | null, worlds: Array<string> } } | { __typename?: 'ScraperError', message: string } | { __typename?: 'ScraperNotAvailableError', message: string } | { __typename?: 'ScraperNotFoundError', message: string } };
+
+export type FrontpageQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type FrontpageQuery = { __typename?: 'Query', recent: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> }, favourites: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> }, bookmarked: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> } };
+
+export type AddComicMutationVariables = Exact<{
+ input: AddComicInput;
+}>;
+
+
+export type AddComicMutation = { __typename?: 'Mutation', addComic: { __typename?: 'AddComicSuccess', message: string, archivePagesRemaining: boolean } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } };
+
+export type UpdateArchivesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateArchiveInput;
+}>;
+
+
+export type UpdateArchivesMutation = { __typename?: 'Mutation', updateArchives: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type UpdateComicsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateComicInput;
+}>;
+
+
+export type UpdateComicsMutation = { __typename?: 'Mutation', updateComics: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type UpsertComicsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpsertComicInput;
+}>;
+
+
+export type UpsertComicsMutation = { __typename?: 'Mutation', upsertComics: { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'UpsertSuccess', message: string } };
+
+export type DeleteArchivesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteArchivesMutation = { __typename?: 'Mutation', deleteArchives: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type DeleteComicsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteComicsMutation = { __typename?: 'Mutation', deleteComics: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddTagMutationVariables = Exact<{
+ input: AddTagInput;
+}>;
+
+
+export type AddTagMutation = { __typename?: 'Mutation', addTag: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateTagsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateTagInput;
+}>;
+
+
+export type UpdateTagsMutation = { __typename?: 'Mutation', updateTags: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteTagsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteTagsMutation = { __typename?: 'Mutation', deleteTags: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddNamespaceMutationVariables = Exact<{
+ input: AddNamespaceInput;
+}>;
+
+
+export type AddNamespaceMutation = { __typename?: 'Mutation', addNamespace: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateNamespacesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateNamespaceInput;
+}>;
+
+
+export type UpdateNamespacesMutation = { __typename?: 'Mutation', updateNamespaces: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteNamespacesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteNamespacesMutation = { __typename?: 'Mutation', deleteNamespaces: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddArtistMutationVariables = Exact<{
+ input: AddArtistInput;
+}>;
+
+
+export type AddArtistMutation = { __typename?: 'Mutation', addArtist: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateArtistsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateArtistInput;
+}>;
+
+
+export type UpdateArtistsMutation = { __typename?: 'Mutation', updateArtists: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteArtistsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteArtistsMutation = { __typename?: 'Mutation', deleteArtists: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddCharacterMutationVariables = Exact<{
+ input: AddCharacterInput;
+}>;
+
+
+export type AddCharacterMutation = { __typename?: 'Mutation', addCharacter: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateCharactersMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateCharacterInput;
+}>;
+
+
+export type UpdateCharactersMutation = { __typename?: 'Mutation', updateCharacters: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteCharactersMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteCharactersMutation = { __typename?: 'Mutation', deleteCharacters: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddCircleMutationVariables = Exact<{
+ input: AddCircleInput;
+}>;
+
+
+export type AddCircleMutation = { __typename?: 'Mutation', addCircle: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateCirclesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateCircleInput;
+}>;
+
+
+export type UpdateCirclesMutation = { __typename?: 'Mutation', updateCircles: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteCirclesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteCirclesMutation = { __typename?: 'Mutation', deleteCircles: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddWorldMutationVariables = Exact<{
+ input: AddWorldInput;
+}>;
+
+
+export type AddWorldMutation = { __typename?: 'Mutation', addWorld: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateWorldsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateWorldInput;
+}>;
+
+
+export type UpdateWorldsMutation = { __typename?: 'Mutation', updateWorlds: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteWorldsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteWorldsMutation = { __typename?: 'Mutation', deleteWorlds: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export const PageFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Page"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Page"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"aspectRatio"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comicId"}}]}}]} as unknown as DocumentNode<PageFragment, unknown>;
+export const ImageFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}}]} as unknown as DocumentNode<ImageFragment, unknown>;
+export const ComicFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}}]} as unknown as DocumentNode<ComicFragment, unknown>;
+export const FullArchiveFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullArchive"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullArchive"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"mtime"}},{"kind":"Field","name":{"kind":"Name","value":"organized"}},{"kind":"Field","name":{"kind":"Name","value":"pageCount"}},{"kind":"Field","name":{"kind":"Name","value":"pages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Page"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Page"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Page"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"aspectRatio"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comicId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<FullArchiveFragment, unknown>;
+export const ArchiveFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Archive"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Archive"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"pageCount"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}}]} as unknown as DocumentNode<ArchiveFragment, unknown>;
+export const FullComicFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullComic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullComic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"censorship"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"organized"}},{"kind":"Field","name":{"kind":"Name","value":"bookmarked"}},{"kind":"Field","name":{"kind":"Name","value":"pages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Page"}}]}},{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Page"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Page"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"aspectRatio"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comicId"}}]}}]} as unknown as DocumentNode<FullComicFragment, unknown>;
+export const ComicScraperFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ComicScraper"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ComicScraper"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode<ComicScraperFragment, unknown>;
+export const ScrapeComicResultFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ScrapeComicResult"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ScrapeComicResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"censorship"}},{"kind":"Field","name":{"kind":"Name","value":"characters"}},{"kind":"Field","name":{"kind":"Name","value":"circles"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"worlds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"warnings"}}]}}]} as unknown as DocumentNode<ScrapeComicResultFragment, unknown>;
+export const ComicsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"comics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ComicFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ComicSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<ComicsQuery, ComicsQueryVariables>;
+export const ArchivesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"archives"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ArchiveFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ArchiveSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archives"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Archive"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Archive"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Archive"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"pageCount"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}}]}}]} as unknown as DocumentNode<ArchivesQuery, ArchivesQueryVariables>;
+export const ArchiveDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"archive"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archive"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullArchive"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullArchive"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Page"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Page"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"aspectRatio"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comicId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullArchive"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullArchive"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"mtime"}},{"kind":"Field","name":{"kind":"Name","value":"organized"}},{"kind":"Field","name":{"kind":"Name","value":"pageCount"}},{"kind":"Field","name":{"kind":"Name","value":"pages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Page"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}}]}}]} as unknown as DocumentNode<ArchiveQuery, ArchiveQueryVariables>;
+export const ComicDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"comic"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comic"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullComic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullComic"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Page"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Page"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"aspectRatio"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comicId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullComic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullComic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"censorship"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"organized"}},{"kind":"Field","name":{"kind":"Name","value":"bookmarked"}},{"kind":"Field","name":{"kind":"Name","value":"pages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Page"}}]}},{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<ComicQuery, ComicQueryVariables>;
+export const TagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"tag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullTag"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"namespaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<TagQuery, TagQueryVariables>;
+export const TagsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"tags"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"TagFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"TagSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tags"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]} as unknown as DocumentNode<TagsQuery, TagsQueryVariables>;
+export const NamespaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"namespace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"namespace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Namespace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sortName"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<NamespaceQuery, NamespaceQueryVariables>;
+export const NamespacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"namespaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NamespaceFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NamespaceSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"namespaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<NamespacesQuery, NamespacesQueryVariables>;
+export const ComicTagListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"comicTagList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"forFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comicTags"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"forFilter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"forFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<ComicTagListQuery, ComicTagListQueryVariables>;
+export const ArtistListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"artistList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<ArtistListQuery, ArtistListQueryVariables>;
+export const CharacterListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"characterList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<CharacterListQuery, CharacterListQueryVariables>;
+export const CircleListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"circleList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<CircleListQuery, CircleListQueryVariables>;
+export const WorldListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"worldList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<WorldListQuery, WorldListQueryVariables>;
+export const NamespaceListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"namespaceList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"namespaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<NamespaceListQuery, NamespaceListQueryVariables>;
+export const ArtistsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"artists"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ArtistFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ArtistSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<ArtistsQuery, ArtistsQueryVariables>;
+export const ArtistDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"artist"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artist"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Artist"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<ArtistQuery, ArtistQueryVariables>;
+export const CharactersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"characters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CharacterFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CharacterSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"characters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<CharactersQuery, CharactersQueryVariables>;
+export const CharacterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"character"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"character"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Character"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<CharacterQuery, CharacterQueryVariables>;
+export const CirclesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"circles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CircleFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CircleSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"circles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<CirclesQuery, CirclesQueryVariables>;
+export const CircleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"circle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"circle"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Circle"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<CircleQuery, CircleQueryVariables>;
+export const WorldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"worlds"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"WorldFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"WorldSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"worlds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<WorldsQuery, WorldsQueryVariables>;
+export const WorldDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"world"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"world"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"World"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<WorldQuery, WorldQueryVariables>;
+export const ComicScrapersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"comicScrapers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comicScrapers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<ComicScrapersQuery, ComicScrapersQueryVariables>;
+export const ScrapeComicDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"scrapeComic"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"scraper"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"scrapeComic"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"scraper"},"value":{"kind":"Variable","name":{"kind":"Name","value":"scraper"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ScrapeComicResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ScrapeComicResult"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ScrapeComicResult"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ScrapeComicResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"censorship"}},{"kind":"Field","name":{"kind":"Name","value":"characters"}},{"kind":"Field","name":{"kind":"Name","value":"circles"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"worlds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"warnings"}}]}}]} as unknown as DocumentNode<ScrapeComicQuery, ScrapeComicQueryVariables>;
+export const FrontpageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"frontpage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"recent"},"name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"items"},"value":{"kind":"IntValue","value":"6"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"on"},"value":{"kind":"EnumValue","value":"CREATED_AT"}},{"kind":"ObjectField","name":{"kind":"Name","value":"direction"},"value":{"kind":"EnumValue","value":"DESCENDING"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"favourites"},"name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"items"},"value":{"kind":"IntValue","value":"6"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"include"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"favourite"},"value":{"kind":"BooleanValue","value":true}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"on"},"value":{"kind":"EnumValue","value":"RANDOM"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"bookmarked"},"name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"items"},"value":{"kind":"IntValue","value":"6"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"include"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"bookmarked"},"value":{"kind":"BooleanValue","value":true}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"on"},"value":{"kind":"EnumValue","value":"RANDOM"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<FrontpageQuery, FrontpageQueryVariables>;
+export const AddComicDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addComic"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddComicInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addComic"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AddComicSuccess"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"archivePagesRemaining"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddComicMutation, AddComicMutationVariables>;
+export const UpdateArchivesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateArchives"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateArchiveInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateArchives"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateArchivesMutation, UpdateArchivesMutationVariables>;
+export const UpdateComicsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateComics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateComicInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateComics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateComicsMutation, UpdateComicsMutationVariables>;
+export const UpsertComicsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"upsertComics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpsertComicInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"upsertComics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpsertComicsMutation, UpsertComicsMutationVariables>;
+export const DeleteArchivesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteArchives"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteArchives"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteArchivesMutation, DeleteArchivesMutationVariables>;
+export const DeleteComicsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteComics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteComics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteComicsMutation, DeleteComicsMutationVariables>;
+export const AddTagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addTag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddTagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addTag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddTagMutation, AddTagMutationVariables>;
+export const UpdateTagsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateTags"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateTagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateTags"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateTagsMutation, UpdateTagsMutationVariables>;
+export const DeleteTagsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteTags"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteTags"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteTagsMutation, DeleteTagsMutationVariables>;
+export const AddNamespaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addNamespace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddNamespaceInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addNamespace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddNamespaceMutation, AddNamespaceMutationVariables>;
+export const UpdateNamespacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateNamespaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateNamespaceInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateNamespaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateNamespacesMutation, UpdateNamespacesMutationVariables>;
+export const DeleteNamespacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteNamespaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNamespaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteNamespacesMutation, DeleteNamespacesMutationVariables>;
+export const AddArtistDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addArtist"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddArtistInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addArtist"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddArtistMutation, AddArtistMutationVariables>;
+export const UpdateArtistsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateArtists"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateArtistInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateArtists"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateArtistsMutation, UpdateArtistsMutationVariables>;
+export const DeleteArtistsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteArtists"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteArtists"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteArtistsMutation, DeleteArtistsMutationVariables>;
+export const AddCharacterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addCharacter"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddCharacterInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addCharacter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddCharacterMutation, AddCharacterMutationVariables>;
+export const UpdateCharactersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateCharacters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateCharacterInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCharacters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateCharactersMutation, UpdateCharactersMutationVariables>;
+export const DeleteCharactersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteCharacters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteCharacters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteCharactersMutation, DeleteCharactersMutationVariables>;
+export const AddCircleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addCircle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddCircleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addCircle"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddCircleMutation, AddCircleMutationVariables>;
+export const UpdateCirclesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateCircles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateCircleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCircles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateCirclesMutation, UpdateCirclesMutationVariables>;
+export const DeleteCirclesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteCircles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteCircles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteCirclesMutation, DeleteCirclesMutationVariables>;
+export const AddWorldDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addWorld"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddWorldInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addWorld"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddWorldMutation, AddWorldMutationVariables>;
+export const UpdateWorldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateWorlds"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorldInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorlds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateWorldsMutation, UpdateWorldsMutationVariables>;
+export const DeleteWorldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteWorlds"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteWorlds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteWorldsMutation, DeleteWorldsMutationVariables>; \ No newline at end of file
diff --git a/frontend/src/lib/Actions.ts b/frontend/src/lib/Actions.ts
new file mode 100644
index 0000000..7231c2f
--- /dev/null
+++ b/frontend/src/lib/Actions.ts
@@ -0,0 +1,109 @@
+export function debounce(
+ node: HTMLInputElement,
+ { callback, timeout = 500 }: { callback: () => void; timeout?: number }
+) {
+ let timer: NodeJS.Timeout;
+
+ function trigger(event: KeyboardEvent) {
+ clearTimeout(timer);
+ if (event.key !== 'Enter') {
+ timer = setTimeout(callback, timeout);
+ } else {
+ callback();
+ }
+ }
+
+ node.addEventListener('keyup', trigger);
+
+ return {
+ destroy() {
+ clearTimeout(timer);
+ node.removeEventListener('keyup', trigger);
+ }
+ };
+}
+
+export function clickOutside(
+ node: HTMLElement,
+ { handler, ignore }: { handler: () => void; ignore?: HTMLElement }
+) {
+ const handle = (event: Event) => {
+ const target = event.target as HTMLElement;
+ if (!target || target === ignore) return;
+
+ if (node && !node.contains(target) && !event.defaultPrevented) {
+ handler();
+ }
+ };
+
+ document.addEventListener('click', handle, true);
+
+ return {
+ destroy() {
+ document.removeEventListener('click', handle, true);
+ }
+ };
+}
+
+export const focusableElements = [
+ 'a[href]',
+ 'area[href]',
+ 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
+ 'select:not([disabled]):not([aria-hidden])',
+ 'textarea:not([disabled]):not([aria-hidden])',
+ 'button:not([disabled]):not([aria-hidden])',
+ 'iframe',
+ 'object',
+ 'embed',
+ '[contenteditable]',
+ '[tabindex]:not([tabindex^="-"])'
+];
+
+let trapped: HTMLElement[] = [];
+
+export function trapFocus(node: HTMLElement) {
+ function handler(event: KeyboardEvent) {
+ if (event.target === window) return;
+
+ // return if we're not the topmost node to handle
+ if (trapped.at(0) !== node) return;
+
+ const focusable = node.querySelectorAll<HTMLElement>(focusableElements.join());
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+
+ if (event.key === 'Tab') {
+ if (!node.contains(document.activeElement)) {
+ first.focus();
+ event.preventDefault();
+ }
+
+ if (event.shiftKey && event.target === first) {
+ last.focus();
+ event.preventDefault();
+ } else if (!event.shiftKey && event.target === last) {
+ first.focus();
+ event.preventDefault();
+ }
+ }
+ }
+
+ if (document.activeElement instanceof HTMLElement) {
+ // if we trap focus, make sure to blur any previously selected external
+ // item such that focus does not remain outside of the node
+ if (!node.contains(document.activeElement)) {
+ document.activeElement.blur();
+ }
+ }
+
+ document.addEventListener('keydown', handler);
+ trapped.unshift(node);
+
+ return {
+ destroy() {
+ document.removeEventListener('keydown', handler);
+ trapped = trapped.filter((i) => i !== node);
+ }
+ };
+}
diff --git a/frontend/src/lib/Enums.ts b/frontend/src/lib/Enums.ts
new file mode 100644
index 0000000..876aec8
--- /dev/null
+++ b/frontend/src/lib/Enums.ts
@@ -0,0 +1,325 @@
+import {
+ ArchiveSort,
+ ArtistSort,
+ Category,
+ Censorship,
+ CharacterSort,
+ CircleSort,
+ ComicSort,
+ Direction,
+ Language,
+ Layout,
+ NamespaceSort,
+ Rating,
+ TagSort,
+ UpdateMode,
+ WorldSort
+} from '$gql/graphql';
+
+export interface EnumOption<T> {
+ id: T;
+ name: string;
+}
+
+export const DirectionLabel: Record<Direction, string> = {
+ [Direction.LeftToRight]: 'Left to Right',
+ [Direction.RightToLeft]: 'Right to Left'
+};
+
+export const LayoutLabel: Record<Layout, string> = {
+ [Layout.Single]: 'Single Page',
+ [Layout.Double]: 'Double Page',
+ [Layout.DoubleOffset]: 'Double Page, offset'
+};
+
+export const RatingLabel: Record<Rating, string> = {
+ [Rating.Safe]: 'Safe',
+ [Rating.Questionable]: 'Questionable',
+ [Rating.Explicit]: 'Explicit'
+};
+
+export const CensorshipLabel: Record<Censorship, string> = {
+ [Censorship.None]: 'None',
+ [Censorship.Bar]: 'Bars',
+ [Censorship.Mosaic]: 'Mosaic',
+ [Censorship.Full]: 'Full'
+};
+
+export const CategoryLabel: Record<Category, string> = {
+ [Category.Manga]: 'Manga',
+ [Category.Doujinshi]: 'Doujinshi',
+ [Category.Comic]: 'Comic',
+ [Category.Artbook]: 'Artbook',
+ [Category.GameCg]: 'Game CG',
+ [Category.ImageSet]: 'Image Set',
+ [Category.VariantSet]: 'Variant Set',
+ [Category.Webtoon]: 'Webtoon'
+};
+
+export const ArchiveSortLabel: Record<ArchiveSort, string> = {
+ [ArchiveSort.Path]: 'Path',
+ [ArchiveSort.Size]: 'File Size',
+ [ArchiveSort.CreatedAt]: 'Created At',
+ [ArchiveSort.PageCount]: 'Page Count',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const ComicSortLabel: Record<ComicSort, string> = {
+ [ComicSort.Title]: 'Title',
+ [ComicSort.OriginalTitle]: 'Original Title',
+ [ComicSort.Date]: 'Date',
+ [ComicSort.CreatedAt]: 'Created At',
+ [ComicSort.UpdatedAt]: 'Updated At',
+ [ComicSort.TagCount]: 'Tag Count',
+ [ComicSort.PageCount]: 'Page Count',
+ [ComicSort.Random]: 'Random'
+};
+
+export const ArtistSortLabel: Record<ArtistSort, string> = {
+ [ArtistSort.Name]: 'Name',
+ [ArtistSort.CreatedAt]: 'Created At',
+ [ArtistSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const CharacterSortLabel: Record<CharacterSort, string> = {
+ [CharacterSort.Name]: 'Name',
+ [CharacterSort.CreatedAt]: 'Created At',
+ [CharacterSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const CircleSortLabel: Record<CircleSort, string> = {
+ [CircleSort.Name]: 'Name',
+ [CircleSort.CreatedAt]: 'Created At',
+ [CircleSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const NamespaceSortLabel: Record<NamespaceSort, string> = {
+ [NamespaceSort.Name]: 'Name',
+ [NamespaceSort.SortName]: 'Sort Name',
+ [NamespaceSort.CreatedAt]: 'Created At',
+ [NamespaceSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const TagSortLabel: Record<TagSort, string> = {
+ [TagSort.Name]: 'Name',
+ [TagSort.CreatedAt]: 'Created At',
+ [TagSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const WorldSortLabel: Record<WorldSort, string> = {
+ [WorldSort.Name]: 'Name',
+ [WorldSort.CreatedAt]: 'Created At',
+ [WorldSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const UpdateModeLabel: Record<UpdateMode, string> = {
+ [UpdateMode.Add]: 'Add',
+ [UpdateMode.Remove]: 'Remove',
+ [UpdateMode.Replace]: 'Replace'
+};
+
+export const LanguageLabel: Record<Language, string> = {
+ [Language.Ab]: 'Abkhazian',
+ [Language.Aa]: 'Afar',
+ [Language.Af]: 'Afrikaans',
+ [Language.Ak]: 'Akan',
+ [Language.Sq]: 'Albanian',
+ [Language.Am]: 'Amharic',
+ [Language.Ar]: 'Arabic',
+ [Language.An]: 'Aragonese',
+ [Language.Hy]: 'Armenian',
+ [Language.As]: 'Assamese',
+ [Language.Av]: 'Avaric',
+ [Language.Ae]: 'Avestan',
+ [Language.Ay]: 'Aymara',
+ [Language.Az]: 'Azerbaijani',
+ [Language.Bm]: 'Bambara',
+ [Language.Ba]: 'Bashkir',
+ [Language.Eu]: 'Basque',
+ [Language.Be]: 'Belarusian',
+ [Language.Bn]: 'Bengali',
+ [Language.Bh]: 'Bihari languages',
+ [Language.Bi]: 'Bislama',
+ [Language.Bs]: 'Bosnian',
+ [Language.Br]: 'Breton',
+ [Language.Bg]: 'Bulgarian',
+ [Language.My]: 'Burmese',
+ [Language.Ca]: 'Catalan',
+ [Language.Km]: 'Central Khmer',
+ [Language.Ch]: 'Chamorro',
+ [Language.Ce]: 'Chechen',
+ [Language.Ny]: 'Chichewa',
+ [Language.Zh]: 'Chinese',
+ [Language.Cu]: 'Church Slavic',
+ [Language.Cv]: 'Chuvash',
+ [Language.Kw]: 'Cornish',
+ [Language.Co]: 'Corsican',
+ [Language.Cr]: 'Cree',
+ [Language.Hr]: 'Croatian',
+ [Language.Cs]: 'Czech',
+ [Language.Da]: 'Danish',
+ [Language.Dv]: 'Divehi',
+ [Language.Nl]: 'Dutch',
+ [Language.Dz]: 'Dzongkha',
+ [Language.En]: 'English',
+ [Language.Eo]: 'Esperanto',
+ [Language.Et]: 'Estonian',
+ [Language.Ee]: 'Ewe',
+ [Language.Fo]: 'Faroese',
+ [Language.Fj]: 'Fijian',
+ [Language.Fi]: 'Finnish',
+ [Language.Fr]: 'French',
+ [Language.Ff]: 'Fulah',
+ [Language.Gd]: 'Gaelic',
+ [Language.Gl]: 'Galician',
+ [Language.Lg]: 'Ganda',
+ [Language.Ka]: 'Georgian',
+ [Language.De]: 'German',
+ [Language.Gn]: 'Guarani',
+ [Language.Gu]: 'Gujarati',
+ [Language.Ht]: 'Haitian',
+ [Language.Ha]: 'Hausa',
+ [Language.He]: 'Hebrew',
+ [Language.Hz]: 'Herero',
+ [Language.Hi]: 'Hindi',
+ [Language.Ho]: 'Hiri Motu',
+ [Language.Hu]: 'Hungarian',
+ [Language.Is]: 'Icelandic',
+ [Language.Io]: 'Ido',
+ [Language.Ig]: 'Igbo',
+ [Language.Id]: 'Indonesian',
+ [Language.Ia]: 'Interlingua',
+ [Language.Ie]: 'Interlingue',
+ [Language.Iu]: 'Inuktitut',
+ [Language.Ik]: 'Inupiaq',
+ [Language.Ga]: 'Irish',
+ [Language.It]: 'Italian',
+ [Language.Ja]: 'Japanese',
+ [Language.Jv]: 'Javanese',
+ [Language.Kl]: 'Kalaallisut',
+ [Language.Kn]: 'Kannada',
+ [Language.Kr]: 'Kanuri',
+ [Language.Ks]: 'Kashmiri',
+ [Language.Kk]: 'Kazakh',
+ [Language.Ki]: 'Kikuyu',
+ [Language.Rw]: 'Kinyarwanda',
+ [Language.Ky]: 'Kirghiz',
+ [Language.Kv]: 'Komi',
+ [Language.Kg]: 'Kongo',
+ [Language.Ko]: 'Korean',
+ [Language.Kj]: 'Kuanyama',
+ [Language.Ku]: 'Kurdish',
+ [Language.Lo]: 'Lao',
+ [Language.La]: 'Latin',
+ [Language.Lv]: 'Latvian',
+ [Language.Li]: 'Limburgan',
+ [Language.Ln]: 'Lingala',
+ [Language.Lt]: 'Lithuanian',
+ [Language.Lu]: 'Luba-Katanga',
+ [Language.Lb]: 'Luxembourgish',
+ [Language.Mk]: 'Macedonian',
+ [Language.Mg]: 'Malagasy',
+ [Language.Ms]: 'Malay',
+ [Language.Ml]: 'Malayalam',
+ [Language.Mt]: 'Maltese',
+ [Language.Gv]: 'Manx',
+ [Language.Mi]: 'Maori',
+ [Language.Mr]: 'Marathi',
+ [Language.Mh]: 'Marshallese',
+ [Language.El]: 'Modern Greek',
+ [Language.Mn]: 'Mongolian',
+ [Language.Na]: 'Nauru',
+ [Language.Nv]: 'Navajo',
+ [Language.Ng]: 'Ndonga',
+ [Language.Ne]: 'Nepali',
+ [Language.Se]: 'Northern Sami',
+ [Language.Nd]: 'North Ndebele',
+ [Language.No]: 'Norwegian',
+ [Language.Nb]: 'Norwegian Bokmål',
+ [Language.Nn]: 'Norwegian Nynorsk',
+ [Language.Oc]: 'Occitan',
+ [Language.Oj]: 'Ojibwa',
+ [Language.Or]: 'Oriya',
+ [Language.Om]: 'Oromo',
+ [Language.Os]: 'Ossetian',
+ [Language.Pi]: 'Pali',
+ [Language.Pa]: 'Panjabi',
+ [Language.Fa]: 'Persian',
+ [Language.Pl]: 'Polish',
+ [Language.Pt]: 'Portuguese',
+ [Language.Ps]: 'Pushto',
+ [Language.Qu]: 'Quechua',
+ [Language.Ro]: 'Romanian',
+ [Language.Rm]: 'Romansh',
+ [Language.Rn]: 'Rundi',
+ [Language.Ru]: 'Russian',
+ [Language.Sm]: 'Samoan',
+ [Language.Sg]: 'Sango',
+ [Language.Sa]: 'Sanskrit',
+ [Language.Sc]: 'Sardinian',
+ [Language.Sr]: 'Serbian',
+ [Language.Sn]: 'Shona',
+ [Language.Ii]: 'Sichuan Yi',
+ [Language.Sd]: 'Sindhi',
+ [Language.Si]: 'Sinhala',
+ [Language.Sk]: 'Slovak',
+ [Language.Sl]: 'Slovenian',
+ [Language.So]: 'Somali',
+ [Language.St]: 'Southern Sotho',
+ [Language.Nr]: 'South Ndebele',
+ [Language.Es]: 'Spanish',
+ [Language.Su]: 'Sundanese',
+ [Language.Sw]: 'Swahili',
+ [Language.Ss]: 'Swati',
+ [Language.Sv]: 'Swedish',
+ [Language.Tl]: 'Tagalog',
+ [Language.Ty]: 'Tahitian',
+ [Language.Tg]: 'Tajik',
+ [Language.Ta]: 'Tamil',
+ [Language.Tt]: 'Tatar',
+ [Language.Te]: 'Telugu',
+ [Language.Th]: 'Thai',
+ [Language.Bo]: 'Tibetan',
+ [Language.Ti]: 'Tigrinya',
+ [Language.To]: 'Tonga',
+ [Language.Ts]: 'Tsonga',
+ [Language.Tn]: 'Tswana',
+ [Language.Tr]: 'Turkish',
+ [Language.Tk]: 'Turkmen',
+ [Language.Tw]: 'Twi',
+ [Language.Ug]: 'Uighur',
+ [Language.Uk]: 'Ukrainian',
+ [Language.Ur]: 'Urdu',
+ [Language.Uz]: 'Uzbek',
+ [Language.Ve]: 'Venda',
+ [Language.Vi]: 'Vietnamese',
+ [Language.Vo]: 'Volapük',
+ [Language.Wa]: 'Walloon',
+ [Language.Cy]: 'Welsh',
+ [Language.Fy]: 'Western Frisian',
+ [Language.Wo]: 'Wolof',
+ [Language.Xh]: 'Xhosa',
+ [Language.Yi]: 'Yiddish',
+ [Language.Yo]: 'Yoruba',
+ [Language.Za]: 'Zhuang',
+ [Language.Zu]: 'Zulu'
+};
+
+export const directions: EnumOption<Direction>[] = optionsFromLabel(DirectionLabel);
+export const layouts: EnumOption<Layout>[] = optionsFromLabel(LayoutLabel);
+export const ratings: EnumOption<Rating>[] = optionsFromLabel(RatingLabel);
+export const censorships: EnumOption<Censorship>[] = optionsFromLabel(CensorshipLabel);
+export const categories: EnumOption<Category>[] = optionsFromLabel(CategoryLabel);
+export const languages: EnumOption<Language>[] = optionsFromLabel(LanguageLabel);
+
+function optionsFromLabel<T extends string | number | symbol>(
+ labels: Record<T, string>
+): EnumOption<T>[] {
+ return Object.entries(labels).map(([k, v]) => ({ id: k as T, name: v as string }));
+}
diff --git a/frontend/src/lib/Filter.ts b/frontend/src/lib/Filter.ts
new file mode 100644
index 0000000..8e419f3
--- /dev/null
+++ b/frontend/src/lib/Filter.ts
@@ -0,0 +1,365 @@
+import {
+ type ArchiveFilter,
+ type ArchiveFilterInput,
+ type ComicFilter,
+ type ComicFilterInput,
+ type StringFilter,
+ type TagFilter,
+ type TagFilterInput
+} from '$gql/graphql';
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+import { navigate } from './Navigation';
+import { numKeys } from './Utils';
+
+interface FilterInput<T> {
+ include?: T | null;
+ exclude?: T | null;
+}
+
+interface BasicFilter {
+ name?: { contains?: string | null } | null;
+}
+
+type FilterMode = 'any' | 'all' | 'exact';
+
+type Key = string | number | symbol;
+
+type Filter<T, K extends Key> = {
+ [Property in K]?: T | null;
+};
+
+type AssocFilter<T, K extends Key> = Filter<
+ {
+ any?: T[] | null;
+ all?: T[] | null;
+ exact?: T[] | null;
+ empty?: boolean | null;
+ },
+ K
+>;
+
+type EnumFilter<K extends Key> = Filter<
+ {
+ any?: string[] | null;
+ empty?: boolean | null;
+ },
+ K
+>;
+
+interface Integrateable<F> {
+ integrate(filter: F): void;
+}
+
+class ComplexMember<K extends Key> {
+ values: unknown[] = [];
+ key: K;
+ mode: FilterMode;
+ empty?: boolean | null;
+
+ constructor(key: K, mode: FilterMode) {
+ this.key = key;
+ this.mode = mode;
+ }
+
+ integrate(filter: AssocFilter<unknown, K>) {
+ if (this.values.length > 0) {
+ filter[this.key] = { [this.mode]: this.values };
+ }
+
+ if (this.empty) {
+ filter[this.key] = { ...filter[this.key], empty: this.empty };
+ }
+ }
+}
+
+export class Association<K extends Key> extends ComplexMember<K> {
+ values: (string | number)[] = [];
+
+ constructor(key: K, mode: FilterMode, filter?: AssocFilter<string | number, K> | null) {
+ super(key, mode);
+
+ if (!filter) {
+ return;
+ }
+
+ const prop = filter[key];
+ this.empty = prop?.empty;
+
+ if (prop?.all && prop.all.length > 0) {
+ this.mode = 'all';
+ this.values = prop.all;
+ } else if (prop?.any && prop.any.length > 0) {
+ this.mode = 'any';
+ this.values = prop.any;
+ } else if (prop?.exact && prop.exact.length > 0) {
+ this.mode = 'exact';
+ this.values = prop.exact;
+ }
+ }
+}
+
+export class Enum<K extends Key> extends ComplexMember<K> {
+ values: string[] = [];
+
+ constructor(key: K, filter?: EnumFilter<K> | null) {
+ super(key, 'any');
+
+ if (!filter) {
+ return;
+ }
+
+ this.empty = filter[key]?.empty;
+
+ const prop = filter[key];
+ if (prop?.any) {
+ this.values = prop.any;
+ }
+ }
+}
+
+class Bool<K extends Key> {
+ key: K;
+ value?: boolean = undefined;
+
+ constructor(key: K, filter?: Filter<boolean, K> | null) {
+ this.key = key;
+
+ if (filter) {
+ this.value = filter[key] ?? undefined;
+ }
+ }
+
+ integrate(filter: Filter<boolean, K>) {
+ if (this.value !== undefined) {
+ filter[this.key] = this.value;
+ }
+ }
+}
+
+class Str<K extends Key> {
+ key: K;
+ contains = '';
+
+ constructor(key: K, filter?: Filter<StringFilter, K> | null) {
+ this.key = key;
+
+ if (filter) {
+ this.contains = filter[key]?.contains ?? '';
+ }
+ }
+
+ integrate(filter: Filter<StringFilter, K>) {
+ if (this.contains) {
+ filter[this.key] = { contains: this.contains };
+ }
+ }
+}
+
+abstract class Controls<F> {
+ buildFilter() {
+ const filter = {} as F;
+ Object.values(this).forEach((v: Integrateable<F>) => v.integrate(filter));
+ return filter;
+ }
+}
+
+export class ArchiveFilterControls extends Controls<ArchiveFilter> {
+ path: Str<'path'>;
+ organized: Bool<'organized'>;
+
+ constructor(filter: ArchiveFilter | null | undefined) {
+ super();
+
+ this.path = new Str('path', filter);
+ this.organized = new Bool('organized', filter);
+ }
+}
+
+export class ComicFilterControls extends Controls<ComicFilter> {
+ title: Str<'title'>;
+ categories: Enum<'category'>;
+ censorships: Enum<'censorship'>;
+ ratings: Enum<'rating'>;
+ tags: Association<'tags'>;
+ languages: Enum<'language'>;
+ artists: Association<'artists'>;
+ circles: Association<'circles'>;
+ characters: Association<'characters'>;
+ worlds: Association<'worlds'>;
+ favourite: Bool<'favourite'>;
+ organized: Bool<'organized'>;
+ bookmarked: Bool<'bookmarked'>;
+
+ constructor(filter: ComicFilter | null | undefined, mode: FilterMode);
+ constructor(filter: ComicFilter | null | undefined, mode: FilterMode);
+ constructor(filter: ComicFilter | null | undefined, mode: FilterMode) {
+ super();
+
+ this.title = new Str('title', filter);
+ this.favourite = new Bool('favourite', filter);
+ this.organized = new Bool('organized', filter);
+ this.bookmarked = new Bool('bookmarked', filter);
+ this.tags = new Association('tags', mode, filter);
+ this.languages = new Enum('language', filter);
+ this.categories = new Enum('category', filter);
+ this.censorships = new Enum('censorship', filter);
+ this.ratings = new Enum('rating', filter);
+ this.artists = new Association('artists', mode, filter);
+ this.circles = new Association('circles', mode, filter);
+ this.characters = new Association('characters', mode, filter);
+ this.worlds = new Association('worlds', mode, filter);
+ }
+}
+
+export class BasicFilterControls extends Controls<BasicFilter> {
+ name: Str<'name'>;
+
+ constructor(filter?: BasicFilter | null) {
+ super();
+
+ this.name = new Str('name', filter);
+ }
+}
+
+export class TagFilterControls extends BasicFilterControls {
+ namespaces: Association<'namespaces'>;
+
+ constructor(filter: TagFilter | null | undefined, mode: FilterMode) {
+ super(filter);
+
+ this.namespaces = new Association('namespaces', mode, filter);
+ }
+}
+
+function buildFilterInput<F>(include?: F, exclude?: F) {
+ const input: FilterInput<F> = {};
+
+ if (include && Object.keys(include).length > 0) {
+ input.include = include;
+ }
+
+ if (exclude && Object.keys(exclude).length > 0) {
+ input.exclude = exclude;
+ }
+
+ return input;
+}
+
+abstract class FilterContext<F> {
+ include!: { controls: Controls<F>; size: number };
+ exclude!: { controls: Controls<F>; size: number };
+
+ apply(params: URLSearchParams) {
+ navigate(
+ {
+ filter: buildFilterInput(
+ this.include.controls.buildFilter(),
+ this.exclude.controls.buildFilter()
+ )
+ },
+ params
+ );
+ }
+}
+
+export class ArchiveFilterContext extends FilterContext<ArchiveFilter> {
+ include: { controls: ArchiveFilterControls; size: number };
+ exclude: { controls: ArchiveFilterControls; size: number };
+ private static ignore = ['organized'];
+
+ constructor(filter: ArchiveFilterInput) {
+ super();
+
+ this.include = {
+ controls: new ArchiveFilterControls(filter.include),
+ size: numKeys(filter.include, ArchiveFilterContext.ignore)
+ };
+ this.exclude = {
+ controls: new ArchiveFilterControls(filter.exclude),
+ size: numKeys(filter.exclude, ArchiveFilterContext.ignore)
+ };
+ }
+}
+
+export class ComicFilterContext extends FilterContext<ComicFilter> {
+ include: { controls: ComicFilterControls; size: number };
+ exclude: { controls: ComicFilterControls; size: number };
+ private static ignore = ['title', 'favourite', 'organized', 'bookmarked'];
+
+ constructor(filter: ComicFilterInput) {
+ super();
+
+ this.include = {
+ controls: new ComicFilterControls(filter.include, 'all'),
+ size: numKeys(filter.include, ComicFilterContext.ignore)
+ };
+ this.exclude = {
+ controls: new ComicFilterControls(filter.exclude, 'any'),
+ size: numKeys(filter.exclude, ComicFilterContext.ignore)
+ };
+ }
+}
+
+export class BasicFilterContext extends FilterContext<BasicFilter> {
+ include: { controls: BasicFilterControls; size: number };
+ exclude: { controls: BasicFilterControls; size: number };
+
+ constructor(filter: FilterInput<BasicFilter>) {
+ super();
+
+ this.include = {
+ controls: new BasicFilterControls(filter.include),
+ size: numKeys(filter.include)
+ };
+ this.exclude = {
+ controls: new BasicFilterControls(),
+ size: 0
+ };
+ }
+}
+
+export class TagFilterContext extends FilterContext<TagFilter> {
+ include: { controls: TagFilterControls; size: number };
+ exclude: { controls: TagFilterControls; size: number };
+ private static ignore = ['name'];
+
+ constructor(filter: TagFilterInput) {
+ super();
+
+ this.include = {
+ controls: new TagFilterControls(filter.include, 'all'),
+ size: numKeys(filter.include, TagFilterContext.ignore)
+ };
+ this.exclude = {
+ controls: new TagFilterControls(filter.exclude, 'any'),
+ size: numKeys(filter.exclude, TagFilterContext.ignore)
+ };
+ }
+}
+
+export function initFilterContext<F extends FilterContext<unknown>>() {
+ return setContext<Writable<F>>('filter', writable());
+}
+
+export function getFilterContext<F extends FilterContext<unknown>>() {
+ return getContext<Writable<F>>('filter');
+}
+
+export function cycleBooleanFilter(value: boolean | undefined, tristate = true) {
+ if (tristate) {
+ if (value === undefined) {
+ return true;
+ } else if (value) {
+ return false;
+ } else {
+ return undefined;
+ }
+ } else {
+ if (value) {
+ return undefined;
+ } else {
+ return true;
+ }
+ }
+}
diff --git a/frontend/src/lib/Meta.ts b/frontend/src/lib/Meta.ts
new file mode 100644
index 0000000..8cfad6b
--- /dev/null
+++ b/frontend/src/lib/Meta.ts
@@ -0,0 +1 @@
+export const codename = 'Satanic Satyr';
diff --git a/frontend/src/lib/Navigation.ts b/frontend/src/lib/Navigation.ts
new file mode 100644
index 0000000..e6b17cd
--- /dev/null
+++ b/frontend/src/lib/Navigation.ts
@@ -0,0 +1,114 @@
+import { goto as svelteGoto } from '$app/navigation';
+import { SortDirection } from '$gql/graphql';
+import JsonURL from '@jsonurl/jsonurl';
+import { type PaginationData } from './Pagination';
+import { type SortData } from './Sort';
+import { toastError } from './Toasts';
+
+function paramToNum<T>(value: string | null, fallback: T) {
+ if (value) {
+ const number = +value;
+
+ if (Number.isNaN(number) || number < 0) {
+ return fallback;
+ }
+
+ return number;
+ }
+
+ return fallback;
+}
+
+export function parseSortData<T>(params: URLSearchParams, fallback: T): SortData<T> {
+ return {
+ on: (params.get('s') as T) || fallback,
+ direction: (params.get('d') as SortDirection) || SortDirection.Ascending,
+ seed: paramToNum(params.get('r'), undefined)
+ };
+}
+
+export function parsePaginationData(params: URLSearchParams, defaultItems = 120): PaginationData {
+ return {
+ page: paramToNum(params.get('p'), 1),
+ items: paramToNum(params.get('i'), defaultItems)
+ };
+}
+
+export function parseFilter<T>(params: URLSearchParams): T {
+ const param = params.get('f');
+
+ if (!param) return {} as T;
+
+ try {
+ return JsonURL.parse(param, { AQF: true, impliedObject: {} }) as T;
+ } catch (e) {
+ return {} as T;
+ }
+}
+
+interface NavigationOptions {
+ to?: string;
+ params: URLSearchParams;
+ options?: Parameters<typeof svelteGoto>[1];
+}
+
+export function goto({ to = '', params, options }: NavigationOptions) {
+ svelteGoto(`${to}?${params.toString()}`, options).catch(() => toastError('Navigation failed'));
+}
+
+interface NavigationParameters<T> {
+ filter?: T;
+ sort?: Partial<SortData<string>>;
+ pagination?: Partial<PaginationData>;
+}
+
+function paramsFrom<T>(
+ { pagination, filter, sort }: NavigationParameters<T>,
+ current?: URLSearchParams
+) {
+ const params = new URLSearchParams(current);
+
+ if (filter !== undefined) {
+ const json = JsonURL.stringify(filter, { AQF: true, impliedObject: true });
+ if (json) {
+ params.set('f', json);
+ } else {
+ params.delete('f');
+ }
+ }
+
+ if (sort !== undefined) {
+ if (sort.on !== undefined) {
+ params.set('s', sort.on);
+ }
+ if (sort.direction !== undefined) {
+ params.set('d', sort.direction);
+ }
+ if (sort.seed !== undefined) {
+ params.set('r', sort.seed.toString());
+ }
+ }
+
+ params.delete('p');
+
+ if (pagination?.items) {
+ params.set('i', pagination.items.toString());
+ }
+
+ if (pagination?.page) {
+ params.set('p', pagination.page.toString());
+ }
+
+ return params;
+}
+
+export function navigate(parameters: NavigationParameters<object>, current?: URLSearchParams) {
+ goto({
+ params: paramsFrom(parameters, current),
+ options: { noScroll: false, keepFocus: true, replaceState: true }
+ });
+}
+
+export function href<T>(base: string, params: NavigationParameters<T>) {
+ return `/${base}/?${paramsFrom(params).toString()}`;
+}
diff --git a/frontend/src/lib/Pagination.ts b/frontend/src/lib/Pagination.ts
new file mode 100644
index 0000000..f05492b
--- /dev/null
+++ b/frontend/src/lib/Pagination.ts
@@ -0,0 +1,31 @@
+import { navigate } from '$lib/Navigation';
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+
+export interface PaginationData {
+ page: number;
+ items: number;
+}
+
+export class PaginationContext {
+ page = 0;
+ items = 0;
+ total = 0;
+
+ set update({ page, items }: PaginationData) {
+ this.page = page;
+ this.items = items;
+ }
+
+ apply(params: URLSearchParams) {
+ navigate({ pagination: { items: this.items } }, params);
+ }
+}
+
+export function initPaginationContext() {
+ return setContext<Writable<PaginationContext>>('pagination', writable(new PaginationContext()));
+}
+
+export function getPaginationContext() {
+ return getContext<Writable<PaginationContext>>('pagination');
+}
diff --git a/frontend/src/lib/Reader.ts b/frontend/src/lib/Reader.ts
new file mode 100644
index 0000000..8777b9b
--- /dev/null
+++ b/frontend/src/lib/Reader.ts
@@ -0,0 +1,62 @@
+import { Layout, type PageFragment } from '$gql/graphql';
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+
+export interface Chunk {
+ main: PageFragment;
+ secondary?: PageFragment;
+ index: number;
+}
+
+class ReaderContext {
+ visible = false;
+ sidebar = false;
+ pages: PageFragment[] = [];
+ page = 0;
+
+ open(page: number) {
+ this.page = page;
+ this.visible = true;
+
+ return this;
+ }
+}
+
+export function initReaderContext() {
+ return setContext<Writable<ReaderContext>>('reader', writable(new ReaderContext()));
+}
+
+export function getReaderContext() {
+ return getContext<Writable<ReaderContext>>('reader');
+}
+
+export function partition(pages: PageFragment[], layout: Layout): [Chunk[], number[]] {
+ const single = layout === Layout.Single;
+ const offset = layout === Layout.DoubleOffset;
+
+ const chunks: Chunk[] = [];
+ const lookup: number[] = Array<number>(pages.length);
+
+ for (let chunkIndex = 0, pageIndex = 0; pageIndex < pages.length; chunkIndex++) {
+ const wide = () => pages[pageIndex].image.aspectRatio > 1;
+
+ const nextPage = () => {
+ lookup[pageIndex] = chunkIndex;
+ return pages[pageIndex++];
+ };
+
+ const offsetFirst = pageIndex === 0 && offset;
+ const full = single || wide() || offsetFirst;
+
+ const chunk: Chunk = { index: pageIndex, main: nextPage() };
+
+ if (!full && pageIndex < pages.length) {
+ if (!wide()) {
+ chunk.secondary = nextPage();
+ }
+ }
+
+ chunks.push(chunk);
+ }
+ return [chunks, lookup];
+}
diff --git a/frontend/src/lib/Scraper.ts b/frontend/src/lib/Scraper.ts
new file mode 100644
index 0000000..4baf370
--- /dev/null
+++ b/frontend/src/lib/Scraper.ts
@@ -0,0 +1,156 @@
+import {
+ Category,
+ Censorship,
+ Direction,
+ Language,
+ Layout,
+ OnMissing,
+ Rating,
+ type FullComicFragment,
+ type ScrapedComic,
+ type UpsertComicInput,
+ type UpsertOptions
+} from '$gql/graphql';
+import {
+ CategoryLabel,
+ CensorshipLabel,
+ DirectionLabel,
+ LanguageLabel,
+ LayoutLabel,
+ RatingLabel
+} from '$lib/Enums';
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+
+interface ScraperContext {
+ scraper: string;
+ warnings: string[];
+ selector?: ScrapedComicSelector;
+}
+
+export function initScraperContext() {
+ return setContext<Writable<ScraperContext>>('scraper', writable({ scraper: '', warnings: [] }));
+}
+
+export function getScraperContext() {
+ return getContext<Writable<ScraperContext>>('scraper');
+}
+
+export class Selector<T extends string> {
+ keep = true;
+ value: T;
+ display: string | undefined;
+
+ constructor(value: T, display?: string) {
+ this.value = value;
+ this.display = display;
+ }
+
+ toString() {
+ return this.display ?? this.value;
+ }
+
+ static from<T extends string>(
+ scraped: T | undefined | null,
+ have: string | undefined | null,
+ label?: Record<string, string>
+ ) {
+ if (scraped && have !== scraped) {
+ return new Selector(scraped, label ? label[scraped] : undefined);
+ }
+ return undefined;
+ }
+
+ static fromList(scraped: string[], have: { name: string }[]) {
+ const haves = new Set(have.map((i) => i.name));
+
+ return scraped.filter((i) => !haves.has(i)).map((i) => new Selector(i));
+ }
+}
+
+function keepItem<T extends string>(selector?: Selector<T>): T | undefined | null {
+ if (selector?.keep) {
+ return selector.value;
+ }
+ return undefined;
+}
+
+function keepList<T extends string>(
+ selectorList: Selector<T>[],
+ onMissing: OnMissing
+): { names: T[]; options: UpsertOptions } {
+ return {
+ names: selectorList.filter((v) => v.keep).map((v) => v.value),
+ options: { onMissing }
+ };
+}
+
+export class ScrapedComicSelector {
+ title?: Selector<string>;
+ originalTitle?: Selector<string>;
+ url?: Selector<string>;
+ date?: Selector<string>;
+ category?: Selector<Category>;
+ censorship?: Selector<Censorship>;
+ rating?: Selector<Rating>;
+ language?: Selector<Language>;
+ direction?: Selector<Direction>;
+ layout?: Selector<Layout>;
+ artists: Selector<string>[];
+ circles: Selector<string>[];
+ characters: Selector<string>[];
+ worlds: Selector<string>[];
+ tags: Selector<string>[];
+
+ constructor(scraped: ScrapedComic, comic: FullComicFragment) {
+ this.title = Selector.from(scraped.title, comic.title);
+ this.originalTitle = Selector.from(scraped.originalTitle, comic.originalTitle);
+ this.url = Selector.from(scraped.url, comic.url);
+ this.date = Selector.from(scraped.date, comic.date);
+ this.category = Selector.from(scraped.category, comic.category, CategoryLabel);
+ this.censorship = Selector.from(scraped.censorship, comic.censorship, CensorshipLabel);
+ this.rating = Selector.from(scraped.rating, comic.rating, RatingLabel);
+ this.language = Selector.from(scraped.language, comic.language, LanguageLabel);
+ this.direction = Selector.from(scraped.direction, comic.direction, DirectionLabel);
+ this.layout = Selector.from(scraped.layout, comic.layout, LayoutLabel);
+
+ this.artists = Selector.fromList(scraped.artists, comic.artists);
+ this.circles = Selector.fromList(scraped.circles, comic.circles);
+ this.characters = Selector.fromList(scraped.characters, comic.characters);
+ this.tags = Selector.fromList(scraped.tags, comic.tags);
+ this.worlds = Selector.fromList(scraped.worlds, comic.worlds);
+ }
+
+ hasData() {
+ return (
+ Object.values(this).filter((i) => {
+ if (i === undefined) {
+ return false;
+ } else if (Array.isArray(i) && i.length === 0) {
+ return false;
+ }
+ return true;
+ }).length > 0
+ );
+ }
+
+ toInput(onMissing: OnMissing): UpsertComicInput {
+ return {
+ title: keepItem(this.title),
+ originalTitle: keepItem(this.originalTitle),
+ url: keepItem(this.url),
+ date: keepItem(this.date),
+ category: keepItem(this.category),
+ censorship: keepItem(this.censorship),
+ rating: keepItem(this.rating),
+ language: keepItem(this.language),
+ direction: keepItem(this.direction),
+ layout: keepItem(this.layout),
+ artists: keepList(this.artists, onMissing),
+ circles: keepList(this.circles, onMissing),
+ characters: keepList(this.characters, onMissing),
+ worlds: keepList(this.worlds, onMissing),
+ tags: keepList(this.tags, onMissing)
+ };
+ }
+}
diff --git a/frontend/src/lib/Selection.ts b/frontend/src/lib/Selection.ts
new file mode 100644
index 0000000..0ea85cc
--- /dev/null
+++ b/frontend/src/lib/Selection.ts
@@ -0,0 +1,141 @@
+import { getContext, hasContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+import { range } from './Utils';
+
+interface Item {
+ id: number;
+}
+
+export const hasSelectionContext = () => hasContext('selection');
+
+export function getSelectionContext<T extends Item>() {
+ return getContext<Writable<ItemSelection<T>>>('selection');
+}
+
+export function initSelectionContext<T extends Item>(
+ typename?: string,
+ toName?: (item: T) => string
+) {
+ return setContext<Writable<ItemSelection<T>>>(
+ 'selection',
+ writable(new ItemSelection(typename, toName))
+ );
+}
+
+export class ItemSelection<T extends Item> {
+ active = false;
+ typename: string;
+ #toName: (item: T) => string;
+
+ #view: T[] = [];
+ selectable: (item: T) => boolean = () => true;
+
+ #ids = new Set<number>();
+ #masked = new Set<number>();
+
+ constructor(typename?: string, toName?: (item: T) => string) {
+ this.typename = typename ?? 'unknown';
+ this.#toName = toName ?? (() => 'unknown');
+ }
+
+ set view(view: T[]) {
+ this.#view = view;
+ this.#updateMasked();
+ }
+
+ #indexOf = (id: number) => this.#view.findIndex((v) => v.id === id);
+
+ update(index: number, shift: boolean) {
+ const id = this.#view[index].id;
+
+ const selectableRange = (first: number, last: number) =>
+ range(first, last)
+ .filter((i) => this.selectable(this.#view[i]))
+ .map((i) => this.#view[i].id);
+
+ if (shift) {
+ const indices = this.indices;
+
+ const first = indices.at(0);
+ const last = indices.at(-1);
+
+ if (first === undefined || last === undefined) {
+ this.#ids.add(id);
+ } else if (index === first || index === last) {
+ this.#ids.clear();
+ } else if (index > last) {
+ this.#ids = new Set([...this.#ids, ...selectableRange(last, index)]);
+ } else if (index < last) {
+ this.#ids = new Set([...this.#ids, ...selectableRange(index, last)]);
+ }
+ } else {
+ if (this.#ids.has(id)) {
+ this.#ids.delete(id);
+ } else {
+ this.#ids.add(id);
+ }
+ }
+
+ this.#updateMasked();
+
+ return this;
+ }
+
+ toggle() {
+ this.active = !this.active;
+
+ if (!this.active) {
+ return this.none();
+ }
+
+ return this;
+ }
+
+ all() {
+ this.#ids = new Set(this.#view.filter(this.selectable).map((i) => i.id));
+ this.#updateMasked();
+
+ return this;
+ }
+
+ none() {
+ this.#ids.clear();
+ this.#masked.clear();
+
+ return this;
+ }
+
+ clear() {
+ this.active = false;
+
+ return this.none();
+ }
+
+ contains(id: number) {
+ return this.#masked.has(id);
+ }
+
+ #updateMasked() {
+ this.#masked = new Set([...this.#ids].filter((i) => this.#indexOf(i) >= 0));
+ }
+
+ get ids() {
+ return [...this.#masked];
+ }
+
+ get size() {
+ return this.#masked.size;
+ }
+
+ get indices() {
+ return [...this.#ids].map(this.#indexOf).filter((i) => i >= 0);
+ }
+
+ get items() {
+ return this.indices.map((i) => this.#view[i]);
+ }
+
+ get names() {
+ return this.items.map(this.#toName);
+ }
+}
diff --git a/frontend/src/lib/Shortcuts.ts b/frontend/src/lib/Shortcuts.ts
new file mode 100644
index 0000000..063bd40
--- /dev/null
+++ b/frontend/src/lib/Shortcuts.ts
@@ -0,0 +1,153 @@
+import { closeModal, modals } from 'svelte-modals';
+import { get } from 'svelte/store';
+
+type LowercaseLetter =
+ | 'a'
+ | 'b'
+ | 'c'
+ | 'd'
+ | 'e'
+ | 'f'
+ | 'g'
+ | 'h'
+ | 'i'
+ | 'j'
+ | 'l'
+ | 'm'
+ | 'n'
+ | 'o'
+ | 'p'
+ | 'q'
+ | 'r'
+ | 's'
+ | 't'
+ | 'u'
+ | 'v'
+ | 'w'
+ | 'x'
+ | 'y'
+ | 'z';
+
+type UppercaseLetter = Uppercase<LowercaseLetter>;
+type Letter = LowercaseLetter | UppercaseLetter;
+type Special = '?' | 'Enter' | 'Escape' | 'Delete';
+
+const modeSwitches = ['n', 'g', 'i'] as const;
+type ModeSwitch = (typeof modeSwitches)[number];
+
+function isModeSwitch(s: string): s is ModeSwitch {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
+ return modeSwitches.indexOf(s as any) !== -1;
+}
+
+type Key = Letter | Special;
+type KeyCombo = `${ModeSwitch}${Letter}`;
+export type Shortcut = Key | KeyCombo;
+
+type EventAction = (event: KeyboardEvent) => void;
+type FocusAction = HTMLInputElement;
+type ClickAction = HTMLElement;
+
+type Action = EventAction | FocusAction | ClickAction;
+
+const handlers = new Map<string, Action>();
+let mode: ModeSwitch | undefined;
+
+export function handleShortcuts(event: KeyboardEvent) {
+ if (isInputElement(event.target)) {
+ if (event.key === 'Escape') {
+ event.target.blur();
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+ return;
+ }
+
+ if (event.ctrlKey) {
+ return;
+ }
+
+ if (event.key === 'Escape') {
+ if (get(modals).length > 0) {
+ closeModal();
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ return;
+ }
+ }
+
+ if (isModeSwitch(event.key) && mode === undefined) {
+ mode = event.key;
+ event.preventDefault();
+ return;
+ }
+
+ const handler = handlers.get(mode === undefined ? event.key : `${mode}${event.key}`);
+
+ if (!handler || get(modals).length > 0) {
+ mode = undefined;
+ return;
+ }
+
+ if (handler instanceof HTMLInputElement) {
+ handler.focus();
+ } else if (handler instanceof HTMLElement) {
+ handler.click();
+ } else {
+ handler(event);
+ }
+
+ mode = undefined;
+ event.preventDefault();
+}
+
+export function accelerator(node: HTMLElement | HTMLInputElement, sc: Shortcut) {
+ handlers.set(sc, node);
+
+ return {
+ destroy() {
+ handlers.delete(sc);
+ }
+ };
+}
+
+export function binds(node: Document, scs: [string, EventAction][]) {
+ const handlers = new Map<string, EventAction>();
+
+ for (const [k, a] of scs) {
+ handlers.set(k, a);
+ }
+
+ function keydown(event: KeyboardEvent) {
+ if (isInputElement(event.target)) return;
+
+ const handler = handlers.get(event.key);
+
+ if (!handler) return;
+
+ handler(event);
+ event.preventDefault();
+ }
+
+ node.addEventListener('keydown', keydown);
+
+ return {
+ destroy() {
+ node.removeEventListener('keydown', keydown);
+ }
+ };
+}
+
+export function addShortcut(sc: Shortcut, action: EventAction) {
+ handlers.set(sc, action);
+}
+
+function isInputElement(target: EventTarget | null): target is HTMLElement {
+ return (
+ target instanceof HTMLElement &&
+ (target instanceof HTMLInputElement ||
+ target instanceof HTMLSelectElement ||
+ target instanceof HTMLTextAreaElement ||
+ target.isContentEditable)
+ );
+}
diff --git a/frontend/src/lib/Sort.ts b/frontend/src/lib/Sort.ts
new file mode 100644
index 0000000..4c9a353
--- /dev/null
+++ b/frontend/src/lib/Sort.ts
@@ -0,0 +1,42 @@
+import { SortDirection } from '$gql/graphql';
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+import { navigate } from './Navigation';
+
+export interface SortData<T> {
+ on: T;
+ direction: SortDirection;
+ seed: number | undefined;
+}
+
+export class SortContext<T extends string> {
+ on: T;
+ direction: SortDirection;
+ seed: number | undefined;
+ labels: Record<T, string>;
+
+ constructor({ on, direction, seed }: SortData<T>, labels: Record<T, string>) {
+ this.on = on;
+ this.direction = direction;
+ this.seed = seed;
+ this.labels = labels;
+ }
+
+ set update({ on, direction, seed }: SortData<T>) {
+ this.on = on;
+ this.direction = direction;
+ this.seed = seed;
+ }
+
+ apply(params: URLSearchParams) {
+ navigate({ sort: { on: this.on, direction: this.direction, seed: this.seed } }, params);
+ }
+}
+
+export function initSortContext<T extends string>(sort: SortData<T>, labels: Record<T, string>) {
+ return setContext<Writable<SortContext<T>>>('sort', writable(new SortContext(sort, labels)));
+}
+
+export function getSortContext<T extends string>() {
+ return getContext<Writable<SortContext<T>>>('sort');
+}
diff --git a/frontend/src/lib/Tabs.ts b/frontend/src/lib/Tabs.ts
new file mode 100644
index 0000000..1c43068
--- /dev/null
+++ b/frontend/src/lib/Tabs.ts
@@ -0,0 +1,18 @@
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+
+type Tab = string;
+type Tabs = Record<Tab, { title: string; badge?: boolean }>;
+
+interface TabContext {
+ tabs: Tabs;
+ current: Tab;
+}
+
+export function setTabContext(context: TabContext) {
+ return setContext<Writable<TabContext>>('tabs', writable(context));
+}
+
+export function getTabContext() {
+ return getContext<Writable<TabContext>>('tabs');
+}
diff --git a/frontend/src/lib/Toasts.ts b/frontend/src/lib/Toasts.ts
new file mode 100644
index 0000000..abc9a7d
--- /dev/null
+++ b/frontend/src/lib/Toasts.ts
@@ -0,0 +1,19 @@
+import { toast } from '@zerodevx/svelte-toast';
+
+export function toastSuccess(message: string) {
+ toast.push(message, {
+ theme: { '--toastBackground': 'rgba(72, 187, 120, 0.9)', '--toastColor': 'mintcream' },
+ duration: 1000
+ });
+}
+
+export function toastError(message: string) {
+ toast.push(message, {
+ theme: { '--toastBackground': 'rgba(187, 72, 72, 0.9)', '--toastColor': 'lavenderblush' },
+ duration: 5000,
+ pausable: true
+ });
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
+export const toastFinally = (reason: any) => toastError(reason);
diff --git a/frontend/src/lib/Transitions.ts b/frontend/src/lib/Transitions.ts
new file mode 100644
index 0000000..59ebaf2
--- /dev/null
+++ b/frontend/src/lib/Transitions.ts
@@ -0,0 +1,10 @@
+import { quartInOut } from 'svelte/easing';
+import type { FadeParams, SlideParams } from 'svelte/transition';
+
+export const fadeFast: FadeParams = { duration: 60 };
+export const fadeDefault: FadeParams = { duration: 100 };
+
+export const slideYDefault: SlideParams = { axis: 'y', duration: 300, easing: quartInOut };
+
+export const slideXDefault: SlideParams = { axis: 'x', duration: 300, easing: quartInOut };
+export const slideXFast: SlideParams = { axis: 'x', duration: 200 };
diff --git a/frontend/src/lib/Update.ts b/frontend/src/lib/Update.ts
new file mode 100644
index 0000000..507dd52
--- /dev/null
+++ b/frontend/src/lib/Update.ts
@@ -0,0 +1,97 @@
+import {
+ UpdateMode,
+ type UpdateComicInput,
+ type UpdateOptions,
+ type UpdateTagInput
+} from '$gql/graphql';
+
+type Key = string | number | symbol;
+
+interface AssociationUpdate {
+ ids?: number[] | string[] | null;
+ options?: UpdateOptions | null;
+}
+
+type Input<T, K extends Key> = {
+ [Property in K]?: T | null;
+};
+
+abstract class Entry<K extends Key> {
+ key: K;
+
+ constructor(key: K) {
+ this.key = key;
+ }
+
+ abstract integrate(input: Input<unknown, K>): void;
+ abstract hasInput(): boolean;
+}
+
+class Association<K extends Key> extends Entry<K> {
+ ids = [];
+ options = {
+ mode: UpdateMode.Add
+ };
+
+ constructor(key: K) {
+ super(key);
+ }
+
+ integrate(input: Input<AssociationUpdate, K>) {
+ if (this.hasInput()) {
+ input[this.key] = { ids: this.ids, options: this.options };
+ }
+ }
+
+ hasInput() {
+ return this.ids.length > 0;
+ }
+}
+
+class Enum<K extends Key> extends Entry<K> {
+ value?: string = undefined;
+
+ constructor(key: K) {
+ super(key);
+ }
+
+ integrate(input: Input<string, K>): void {
+ if (this.hasInput()) {
+ input[this.key] = this.value;
+ }
+ }
+
+ hasInput() {
+ return this.value !== undefined && this.value !== null;
+ }
+}
+
+abstract class Controls<I> {
+ toInput() {
+ const input = {} as I;
+ Object.values(this).forEach((v: Entry<keyof I>) => v.integrate(input));
+ return input;
+ }
+
+ hasInput() {
+ return Object.values(this).some((i: Entry<keyof I>) => i.hasInput());
+ }
+}
+
+export class UpdateTagsControls extends Controls<UpdateTagInput> {
+ namespaces = new Association('namespaces');
+}
+
+export class UpdateComicsControls extends Controls<UpdateComicInput> {
+ artists = new Association('artists');
+ category = new Enum('category');
+ censorship = new Enum('censorship');
+ direction = new Enum('direction');
+ layout = new Enum('layout');
+ characters = new Association('characters');
+ circles = new Association('circles');
+ language = new Enum('language');
+ rating = new Enum('rating');
+ tags = new Association('tags');
+ worlds = new Association('worlds');
+}
diff --git a/frontend/src/lib/Utils.ts b/frontend/src/lib/Utils.ts
new file mode 100644
index 0000000..1a07be1
--- /dev/null
+++ b/frontend/src/lib/Utils.ts
@@ -0,0 +1,108 @@
+import { isError } from '$gql/Utils';
+import type { ImageFragment } from '$gql/graphql';
+import type { BeforeNavigate } from '@sveltejs/kit';
+import type { OperationResultState } from '@urql/svelte';
+import { openModal } from 'svelte-modals';
+import ConfirmDeletion from './dialogs/ConfirmDeletion.svelte';
+
+export function range(from: number, to: number) {
+ return Array.from({ length: to - from + 1 }, (_, k) => k + from);
+}
+
+export function getRandomInt(min: number, max: number) {
+ const minCeiled = Math.ceil(min);
+ const maxFloored = Math.floor(max);
+
+ return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
+}
+
+export interface ListItem {
+ id: number | string;
+ name: string;
+}
+
+export interface ResultState {
+ fetching: boolean;
+ message?: string;
+}
+
+export function getResultState(state: OperationResultState): ResultState {
+ let message: string | undefined;
+
+ if (state.error) {
+ message = `${state.error.name}: ${state.error.message}`;
+ } else if (state.data) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ const obj = Object.values(state.data)[0];
+ if (isError(obj)) {
+ message = obj.message;
+ }
+ }
+
+ return { fetching: state.fetching, message: message };
+}
+
+export function src(image: ImageFragment, type: 'full' | 'thumb' = 'thumb') {
+ const dir = image.hash.slice(0, 2);
+ const file = image.hash.slice(2);
+
+ return `/objects/${dir}/${file}_${type}.webp`;
+}
+
+export function numKeys(obj?: object | null, ignore: string[] = []) {
+ if (!obj) return 0;
+
+ const len = Object.keys(obj).length;
+ let ignored = 0;
+
+ for (const i of ignore) {
+ if (Object.hasOwn(obj, i)) ignored++;
+ }
+
+ return len - ignored;
+}
+
+export function confirmDeletion(
+ typename: string,
+ names: string | string[],
+ callback: () => void,
+ warning?: string
+) {
+ openModal(
+ ConfirmDeletion,
+ { names: Array.isArray(names) ? names : [names], typename, callback: callback, warning },
+ { replace: true }
+ );
+}
+
+export function idFromLabel(label: string) {
+ return label.toLowerCase().replaceAll(' ', '-');
+}
+
+export function pluralize(singular: string, size: number) {
+ return `${singular}${size > 1 ? 's' : ''}`;
+}
+
+export function formatListSize(word: string, size: number) {
+ return `${size} ${pluralize(word, size)}`;
+}
+
+export function joinText(items: string[], separator = ', ') {
+ return items.filter((i) => i).join(separator);
+}
+
+export function confirmPending() {
+ return confirm('There are pending changes. Click Cancel to keep editing or OK to dismiss them.');
+}
+
+export function preventOnPending({ to, cancel }: BeforeNavigate, pending: boolean) {
+ if (!pending) return;
+
+ if (to) {
+ if (confirmPending()) {
+ return;
+ }
+ }
+
+ cancel();
+}
diff --git a/frontend/src/lib/assets/logo.webp b/frontend/src/lib/assets/logo.webp
new file mode 100644
index 0000000..e41cbb0
--- /dev/null
+++ b/frontend/src/lib/assets/logo.webp
Binary files differ
diff --git a/frontend/src/lib/components/AddButton.svelte b/frontend/src/lib/components/AddButton.svelte
new file mode 100644
index 0000000..9c0ab29
--- /dev/null
+++ b/frontend/src/lib/components/AddButton.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ export let title: string;
+</script>
+
+<button class="btn-blue" {title} on:click>
+ <span class="icon-base icon-[material-symbols--add]" />
+</button>
diff --git a/frontend/src/lib/components/Badge.svelte b/frontend/src/lib/components/Badge.svelte
new file mode 100644
index 0000000..7ad3173
--- /dev/null
+++ b/frontend/src/lib/components/Badge.svelte
@@ -0,0 +1,15 @@
+<script lang="ts">
+ import { fadeDefault } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+
+ export let number: number;
+</script>
+
+{#if number > 0}
+ <span
+ class="absolute -right-[3px] -top-[6px] z-[1] rounded-lg bg-teal-600 px-1 text-xs"
+ transition:fade={fadeDefault}
+ >
+ {number}
+ </span>
+{/if}
diff --git a/frontend/src/lib/components/BookmarkButton.svelte b/frontend/src/lib/components/BookmarkButton.svelte
new file mode 100644
index 0000000..89570e6
--- /dev/null
+++ b/frontend/src/lib/components/BookmarkButton.svelte
@@ -0,0 +1,9 @@
+<script lang="ts">
+ import Bookmark from '$lib/icons/Bookmark.svelte';
+
+ export let bookmarked: boolean;
+</script>
+
+<button type="button" title="Toggle bookmark" class="flex text-base" on:click>
+ <Bookmark hoverable {bookmarked} />
+</button>
diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte
new file mode 100644
index 0000000..2384799
--- /dev/null
+++ b/frontend/src/lib/components/Card.svelte
@@ -0,0 +1,106 @@
+<script lang="ts" context="module">
+ import type { ComicFragment, ImageFragment } from '$gql/graphql';
+
+ interface CardDetails {
+ title: string;
+ favourite?: boolean;
+ subtitle?: string | null;
+ cover?: ImageFragment;
+ }
+
+ export function comicCard(comic: ComicFragment) {
+ return {
+ href: `/comics/${comic.id.toString()}`,
+ details: {
+ title: comic.title,
+ subtitle: comic.originalTitle,
+ favourite: comic.favourite,
+ cover: comic.cover
+ }
+ };
+ }
+</script>
+
+<script lang="ts">
+ import { src } from '$lib/Utils';
+ import Star from '$lib/icons/Star.svelte';
+
+ export let href: string;
+ export let details: CardDetails;
+ export let compact = false;
+ export let coverOnly = false;
+ export let ellipsis = true;
+</script>
+
+<a
+ {href}
+ class="grid-card-v sm:grid-card-h relative grid overflow-hidden rounded bg-slate-900 shadow-md shadow-slate-950/30"
+ class:compact
+ class:grid-card-cover-only={coverOnly}
+ on:click
+>
+ <slot name="overlay" />
+ {#if details.cover}
+ <img
+ class="h-full w-full object-cover object-[center_top]"
+ width={details.cover.width}
+ height={details.cover.height}
+ src={src(details.cover)}
+ alt=""
+ title={details.title}
+ />
+ {/if}
+ {#if !coverOnly}
+ <article class="flex h-full flex-col gap-2 p-2">
+ <header>
+ <h2
+ class:ellipsis-nowrap={ellipsis}
+ class="self-center text-sm font-medium [grid-area:title]"
+ title={details.title}
+ >
+ {details.title}
+ </h2>
+ {#if details.subtitle}
+ <h3
+ class="ellipsis-nowrap text-xs opacity-60 [grid-area:subtitle]"
+ title={details.subtitle}
+ >
+ {details.subtitle}
+ </h3>
+ {/if}
+ {#if details.favourite}
+ <div class="flex items-center text-lg [grid-area:fav]">
+ <Star favourite />
+ </div>
+ {/if}
+ </header>
+
+ <section class="max-h-full grow overflow-auto border-t border-slate-800/80 pt-2 text-xs">
+ <slot />
+ </section>
+ </article>
+ {/if}
+</a>
+
+<style>
+ a.compact {
+ grid-template-columns: 175px 1fr;
+ grid-template-rows: 250px;
+ }
+
+ img {
+ border-start-start-radius: inherit;
+ border-end-start-radius: inherit;
+ }
+
+ article > header {
+ display: grid;
+
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto;
+
+ grid-template-areas:
+ 'title fav'
+ 'subtitle fav';
+ }
+</style>
diff --git a/frontend/src/lib/components/Cardlet.svelte b/frontend/src/lib/components/Cardlet.svelte
new file mode 100644
index 0000000..04d8599
--- /dev/null
+++ b/frontend/src/lib/components/Cardlet.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ import type { ComicFilter } from '$gql/graphql';
+ import { href } from '$lib/Navigation';
+
+ export let name: string;
+ export let title: string | null | undefined = undefined;
+
+ export let filter: keyof ComicFilter | undefined = undefined;
+ export let id: number | string | undefined = undefined;
+
+ const handleAux = (e: MouseEvent) => {
+ if (filter === undefined || id === undefined || e.button !== 1) return;
+ window.open(href('comics', { filter: { include: { [filter]: { all: [id] } } } }));
+ };
+</script>
+
+<button
+ type="button"
+ class="relative flex overflow-hidden rounded bg-slate-900 text-left shadow-md shadow-slate-950/20"
+ {title}
+ on:click
+ on:auxclick={handleAux}
+>
+ <slot name="overlay" />
+ <article class="group h-full grow items-center gap-2 p-2 text-xs">
+ <h2 class="ellipsis-nowrap text-sm font-medium">{name}</h2>
+ </article>
+</button>
+
+<style>
+ article {
+ display: grid;
+
+ grid-template-columns: 1fr auto;
+ grid-template-rows: 2em;
+ }
+</style>
diff --git a/frontend/src/lib/components/DeleteButton.svelte b/frontend/src/lib/components/DeleteButton.svelte
new file mode 100644
index 0000000..8f5f116
--- /dev/null
+++ b/frontend/src/lib/components/DeleteButton.svelte
@@ -0,0 +1,15 @@
+<script>
+ import { accelerator } from '$lib/Shortcuts';
+
+ export let prominent = false;
+</script>
+
+<button
+ type="button"
+ class={prominent ? 'btn-rose' : 'btn-slate hover:bg-rose-700'}
+ title="Delete forever"
+ on:click
+ use:accelerator={'Delete'}
+>
+ <span class="icon-base icon-[material-symbols--delete-forever]" />
+</button>
diff --git a/frontend/src/lib/components/Dialog.svelte b/frontend/src/lib/components/Dialog.svelte
new file mode 100644
index 0000000..a0bbe5e
--- /dev/null
+++ b/frontend/src/lib/components/Dialog.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+ import { trapFocus } from '$lib/Actions';
+ import { fadeDefault } from '$lib/Transitions';
+ import { closeModal } from 'svelte-modals';
+ import { fade } from 'svelte/transition';
+
+ export let isOpen: boolean;
+</script>
+
+{#if isOpen}
+ <div
+ role="dialog"
+ class="pointer-events-none fixed bottom-0 left-0 right-0 top-0 z-30 flex items-center justify-center"
+ transition:fade|global={fadeDefault}
+ use:trapFocus
+ >
+ <div
+ class="pointer-events-auto flex flex-col rounded-md bg-slate-800 shadow-md shadow-slate-900"
+ >
+ <header class="flex items-center gap-1 border-b-2 border-slate-700/50 p-2">
+ <slot name="header" />
+ <button
+ type="button"
+ class="ml-auto flex items-center text-white/30 hover:text-white"
+ title="Cancel"
+ on:click={closeModal}
+ >
+ <span class="icon-base icon-[material-symbols--close]" />
+ </button>
+ </header>
+ <main class="m-3 w-80 sm:w-[34rem]">
+ <slot />
+ </main>
+ </div>
+ </div>
+{/if}
diff --git a/frontend/src/lib/components/Dropdown.svelte b/frontend/src/lib/components/Dropdown.svelte
new file mode 100644
index 0000000..9e935e4
--- /dev/null
+++ b/frontend/src/lib/components/Dropdown.svelte
@@ -0,0 +1,18 @@
+<script lang="ts">
+ import { clickOutside } from '$lib/Actions';
+ import { fadeFast } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+
+ export let visible: boolean;
+ export let parent: HTMLElement;
+</script>
+
+{#if visible}
+ <div
+ class="absolute z-[1] mt-1 w-max rounded bg-slate-700 p-1 shadow-sm shadow-slate-900"
+ transition:fade={fadeFast}
+ use:clickOutside={{ handler: () => (visible = false), ignore: parent }}
+ >
+ <slot />
+ </div>
+{/if}
diff --git a/frontend/src/lib/components/Empty.svelte b/frontend/src/lib/components/Empty.svelte
new file mode 100644
index 0000000..7f9557c
--- /dev/null
+++ b/frontend/src/lib/components/Empty.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ import logo from '$lib/assets/logo.webp';
+</script>
+
+<div class="col-span-full flex flex-col items-center text-4xl font-medium text-gray-600">
+ <img src={logo} class="w-1/5 opacity-60 grayscale" alt="" />
+ <div class="flex items-center gap-2">
+ <h2>There is nothing here...</h2>
+ </div>
+</div>
diff --git a/frontend/src/lib/components/Expander.svelte b/frontend/src/lib/components/Expander.svelte
new file mode 100644
index 0000000..a382658
--- /dev/null
+++ b/frontend/src/lib/components/Expander.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ export let expanded: boolean;
+ export let title: string;
+</script>
+
+<button
+ class="flex items-center text-base hover:text-white"
+ type="button"
+ on:click={() => (expanded = !expanded)}
+>
+ {#if expanded}
+ <span class="icon-base icon-[material-symbols--expand-less]" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--expand-more]" />
+ {/if}
+ {title}
+</button>
diff --git a/frontend/src/lib/components/Guard.svelte b/frontend/src/lib/components/Guard.svelte
new file mode 100644
index 0000000..fd7ded4
--- /dev/null
+++ b/frontend/src/lib/components/Guard.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ import { getResultState } from '$lib/Utils';
+ import Spinner from './Spinner.svelte';
+
+ export let result;
+ $: state = getResultState($result);
+</script>
+
+{#if state.fetching}
+ <Spinner />
+{:else}
+ <p>{state.message}</p>
+{/if}
diff --git a/frontend/src/lib/components/Head.svelte b/frontend/src/lib/components/Head.svelte
new file mode 100644
index 0000000..b4aed5b
--- /dev/null
+++ b/frontend/src/lib/components/Head.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ export let section: string;
+ export let title = '';
+
+ function formatTitle(section: string, title?: string) {
+ return [title, section, 'hircine'].filter((i) => i).join(' · ');
+ }
+</script>
+
+<svelte:head>
+ <title>{formatTitle(section, title)}</title>
+</svelte:head>
diff --git a/frontend/src/lib/components/Labelled.svelte b/frontend/src/lib/components/Labelled.svelte
new file mode 100644
index 0000000..4b36ad6
--- /dev/null
+++ b/frontend/src/lib/components/Labelled.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ import { idFromLabel } from '$lib/Utils';
+
+ export let label: string;
+
+ const id = idFromLabel(label);
+</script>
+
+<label class="self-center" for={id}>{label}</label>
+<slot {id} />
diff --git a/frontend/src/lib/components/LabelledBlock.svelte b/frontend/src/lib/components/LabelledBlock.svelte
new file mode 100644
index 0000000..feb563e
--- /dev/null
+++ b/frontend/src/lib/components/LabelledBlock.svelte
@@ -0,0 +1,18 @@
+<script lang="ts">
+ import { idFromLabel } from '$lib/Utils';
+
+ export let label: string;
+
+ const id = idFromLabel(label);
+</script>
+
+<div class="flex flex-col">
+ <div class="flex">
+ <label for={id}>{label}</label>
+ {#if $$slots.controls}
+ <div class="grow" />
+ <slot name="controls" />
+ {/if}
+ </div>
+ <slot {id} />
+</div>
diff --git a/frontend/src/lib/components/OrganizedButton.svelte b/frontend/src/lib/components/OrganizedButton.svelte
new file mode 100644
index 0000000..9be985c
--- /dev/null
+++ b/frontend/src/lib/components/OrganizedButton.svelte
@@ -0,0 +1,9 @@
+<script lang="ts">
+ import Organized from '$lib/icons/Organized.svelte';
+
+ export let organized: boolean;
+</script>
+
+<button type="button" title="Toggle organized" class="flex text-base" on:click>
+ <Organized hoverable {organized} />
+</button>
diff --git a/frontend/src/lib/components/RefreshButton.svelte b/frontend/src/lib/components/RefreshButton.svelte
new file mode 100644
index 0000000..afab640
--- /dev/null
+++ b/frontend/src/lib/components/RefreshButton.svelte
@@ -0,0 +1,3 @@
+<button class="btn-blue" title="Refresh" on:click>
+ <span class="icon-base icon-[material-symbols--sync]" />
+</button>
diff --git a/frontend/src/lib/components/RemovePageButton.svelte b/frontend/src/lib/components/RemovePageButton.svelte
new file mode 100644
index 0000000..e23c079
--- /dev/null
+++ b/frontend/src/lib/components/RemovePageButton.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ import { accelerator } from '$lib/Shortcuts';
+</script>
+
+<button
+ type="button"
+ class="btn-rose"
+ title="Remove selected pages"
+ on:click
+ use:accelerator={'Delete'}
+>
+ <span class="icon-base icon-[material-symbols--scan-delete]" />
+</button>
diff --git a/frontend/src/lib/components/Select.svelte b/frontend/src/lib/components/Select.svelte
new file mode 100644
index 0000000..83f026c
--- /dev/null
+++ b/frontend/src/lib/components/Select.svelte
@@ -0,0 +1,55 @@
+<script lang="ts">
+ import type { ListItem } from '$lib/Utils';
+ /* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any */
+
+ // @ts-ignore
+ import Svelecte from 'svelecte';
+
+ let inputId: string;
+ let valueAsObject = false;
+ let multiple = false;
+
+ type Value = (number | string | ListItem)[] | number | string | ListItem | undefined | null;
+
+ export let clearable = false;
+ export let placeholder = 'Select...';
+ export let options: ListItem[] | undefined;
+ export let value: Value;
+
+ export { inputId as id, valueAsObject as object, multiple as multi };
+
+ function optionsPlaceholder(from: Value) {
+ if (from === undefined || from === null) return [];
+
+ return Array.isArray(from) ? value : [value];
+ }
+</script>
+
+{#if options !== null && options !== undefined}
+ <Svelecte
+ virtualList
+ valueField="id"
+ labelField="name"
+ {options}
+ {multiple}
+ {clearable}
+ {inputId}
+ {valueAsObject}
+ {placeholder}
+ bind:value
+ />
+{:else}
+ <Svelecte
+ virtualList
+ valueField="id"
+ labelField="name"
+ disabled
+ options={optionsPlaceholder(value)}
+ {multiple}
+ {clearable}
+ {inputId}
+ {valueAsObject}
+ {placeholder}
+ {value}
+ />
+{/if}
diff --git a/frontend/src/lib/components/Spinner.svelte b/frontend/src/lib/components/Spinner.svelte
new file mode 100644
index 0000000..946329c
--- /dev/null
+++ b/frontend/src/lib/components/Spinner.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+ import { onDestroy } from 'svelte';
+
+ let show = false;
+ const timeout = setTimeout(() => (show = true), 150);
+
+ onDestroy(() => clearTimeout(timeout));
+</script>
+
+{#if show}
+ <div class="flex h-full w-full items-center justify-center">
+ <span class="spinner" />
+ </div>
+{/if}
+
+<style lang="postcss">
+ .spinner {
+ width: 64px;
+ height: 64px;
+ border: 5px solid theme(colors.gray.200);
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+ }
+
+ @keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+</style>
diff --git a/frontend/src/lib/components/SubmitButton.svelte b/frontend/src/lib/components/SubmitButton.svelte
new file mode 100644
index 0000000..8ac90b9
--- /dev/null
+++ b/frontend/src/lib/components/SubmitButton.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ export let active = false;
+
+ $: title = active ? 'Save pending changes' : 'Save (no changes pending)';
+</script>
+
+<button type="submit" class:active class="btn-slate [&.active]:btn-blue" {title}>Save</button>
diff --git a/frontend/src/lib/components/Titlebar.svelte b/frontend/src/lib/components/Titlebar.svelte
new file mode 100644
index 0000000..8aab2dd
--- /dev/null
+++ b/frontend/src/lib/components/Titlebar.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+ import Star from '$lib/icons/Star.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ export let title: string;
+ export let subtitle: string | null = '';
+ export let favourite: boolean | undefined = undefined;
+
+ const dispatch = createEventDispatcher<{ favourite: null }>();
+</script>
+
+<div class="flex flex-wrap gap-x-4">
+ <div class="flex overflow-hidden">
+ {#if favourite !== undefined}
+ <button
+ type="button"
+ class="mr-1 flex items-center"
+ title="Toggle favourite"
+ on:click={() => dispatch('favourite')}
+ >
+ <Star large hoverable {favourite} />
+ </button>
+ {/if}
+ <h1 class="xl:ellipsis-nowrap text-2xl font-semibold">{title}</h1>
+ </div>
+
+ {#if subtitle}
+ <h2 class="xl:ellipsis-nowrap self-end text-lg font-light text-gray-400">
+ {subtitle}
+ </h2>
+ {/if}
+</div>
diff --git a/frontend/src/lib/containers/Cardlets.svelte b/frontend/src/lib/containers/Cardlets.svelte
new file mode 100644
index 0000000..129da61
--- /dev/null
+++ b/frontend/src/lib/containers/Cardlets.svelte
@@ -0,0 +1,11 @@
+<script>
+ import { fadeDefault } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+</script>
+
+<div
+ class="grid gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 min-[1600px]:grid-cols-8 min-[1920px]:grid-cols-10"
+ in:fade={fadeDefault}
+>
+ <slot />
+</div>
diff --git a/frontend/src/lib/containers/Cards.svelte b/frontend/src/lib/containers/Cards.svelte
new file mode 100644
index 0000000..a19e8be
--- /dev/null
+++ b/frontend/src/lib/containers/Cards.svelte
@@ -0,0 +1,8 @@
+<script>
+ import { fadeDefault } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+</script>
+
+<div class="grid gap-4 xl:grid-cols-2 min-[1920px]:grid-cols-3" in:fade|global={fadeDefault}>
+ <slot />
+</div>
diff --git a/frontend/src/lib/containers/Carousel.svelte b/frontend/src/lib/containers/Carousel.svelte
new file mode 100644
index 0000000..1268a78
--- /dev/null
+++ b/frontend/src/lib/containers/Carousel.svelte
@@ -0,0 +1,15 @@
+<script lang="ts">
+ export let title: string;
+ export let href: string;
+</script>
+
+<div class="flex flex-col gap-1">
+ <h2 class="flex text-2xl font-medium">
+ <a class="hover:text-white" {href}>
+ {title}
+ </a>
+ </h2>
+ <div class="flex flex-wrap gap-5">
+ <slot />
+ </div>
+</div>
diff --git a/frontend/src/lib/containers/Column.svelte b/frontend/src/lib/containers/Column.svelte
new file mode 100644
index 0000000..05daece
--- /dev/null
+++ b/frontend/src/lib/containers/Column.svelte
@@ -0,0 +1,3 @@
+<div class="flex flex-col gap-4">
+ <slot />
+</div>
diff --git a/frontend/src/lib/containers/Grid.svelte b/frontend/src/lib/containers/Grid.svelte
new file mode 100644
index 0000000..1224156
--- /dev/null
+++ b/frontend/src/lib/containers/Grid.svelte
@@ -0,0 +1,23 @@
+<script>
+ import { fadeDefault } from '$lib/Transitions';
+
+ import { fade } from 'svelte/transition';
+</script>
+
+<div
+ class="flex flex-col gap-1 lg:grid lg:h-full lg:max-h-full lg:overflow-auto"
+ in:fade|global={fadeDefault}
+>
+ <slot />
+</div>
+
+<style>
+ div {
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto 1fr;
+
+ grid-template-areas:
+ 'header header'
+ 'sidebar main';
+ }
+</style>
diff --git a/frontend/src/lib/dialogs/AddArtist.svelte b/frontend/src/lib/dialogs/AddArtist.svelte
new file mode 100644
index 0000000..6ec93c5
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddArtist.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addArtist, type ArtistInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import ArtistForm from '$lib/forms/ArtistForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let artist = { name: '' };
+
+ function add(event: CustomEvent<ArtistInput>) {
+ addArtist(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add Artist</h2>
+ </svelte:fragment>
+ <ArtistForm bind:artist on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={artist.name.length > 0} />
+ </div>
+ </ArtistForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/AddCharacter.svelte b/frontend/src/lib/dialogs/AddCharacter.svelte
new file mode 100644
index 0000000..23fea08
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddCharacter.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addCharacter, type CharacterInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import CharacterForm from '$lib/forms/CharacterForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let character = { name: '' };
+
+ function add(event: CustomEvent<CharacterInput>) {
+ addCharacter(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add Character</h2>
+ </svelte:fragment>
+ <CharacterForm bind:character on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={character.name.length > 0} />
+ </div>
+ </CharacterForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/AddCircle.svelte b/frontend/src/lib/dialogs/AddCircle.svelte
new file mode 100644
index 0000000..f0ef014
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddCircle.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addCircle, type CircleInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import CircleForm from '$lib/forms/CircleForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let circle = { name: '' };
+
+ function add(event: CustomEvent<CircleInput>) {
+ addCircle(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add Circle</h2>
+ </svelte:fragment>
+ <CircleForm bind:circle on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={circle.name.length > 0} />
+ </div>
+ </CircleForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/AddNamespace.svelte b/frontend/src/lib/dialogs/AddNamespace.svelte
new file mode 100644
index 0000000..e81b22a
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddNamespace.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addNamespace, type NamespaceInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import NamespaceForm from '$lib/forms/NamespaceForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let namespace = { name: '' };
+
+ function add(event: CustomEvent<NamespaceInput>) {
+ addNamespace(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add Namespace</h2>
+ </svelte:fragment>
+ <NamespaceForm bind:namespace on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={namespace.name.length > 0} />
+ </div>
+ </NamespaceForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/AddTag.svelte b/frontend/src/lib/dialogs/AddTag.svelte
new file mode 100644
index 0000000..00d3a03
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddTag.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addTag, type TagInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import TagForm from '$lib/forms/TagForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let tag = { name: '', namespaces: [] };
+
+ function add(event: CustomEvent<TagInput>) {
+ addTag(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add Tag</h2>
+ </svelte:fragment>
+ <TagForm bind:tag on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={tag.name.length > 0} />
+ </div>
+ </TagForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/AddWorld.svelte b/frontend/src/lib/dialogs/AddWorld.svelte
new file mode 100644
index 0000000..ceb946e
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddWorld.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addWorld, type WorldInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import WorldForm from '$lib/forms/WorldForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let world = { name: '' };
+
+ function add(event: CustomEvent<WorldInput>) {
+ addWorld(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add World</h2>
+ </svelte:fragment>
+ <WorldForm bind:world on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={world.name.length > 0} />
+ </div>
+ </WorldForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/ConfirmDeletion.svelte b/frontend/src/lib/dialogs/ConfirmDeletion.svelte
new file mode 100644
index 0000000..6b0cbf8
--- /dev/null
+++ b/frontend/src/lib/dialogs/ConfirmDeletion.svelte
@@ -0,0 +1,51 @@
+<script lang="ts">
+ import { accelerator } from '$lib/Shortcuts';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import { closeModal } from 'svelte-modals';
+
+ export let isOpen: boolean;
+ export let callback: () => void;
+
+ export let names: string[];
+ export let typename: string;
+ export let warning: string | undefined = undefined;
+ const multiple = names.length > 1;
+ const formattedTypename = multiple ? `${typename}s` : typename;
+ const formattedNames = multiple ? `${names.length} ${formattedTypename}` : names[0];
+
+ function confirm() {
+ callback();
+ closeModal();
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Delete {formattedTypename}</h2>
+ </svelte:fragment>
+ <form on:submit|preventDefault={confirm}>
+ <div class="flex flex-col">
+ <p class="mb-3">
+ Are you sure you want to delete <span class="font-semibold">{formattedNames}</span>?
+ </p>
+ {#if multiple}
+ <ul class="mb-3 ml-8 list-disc">
+ {#each names.slice(0, 10) as name}
+ <li>{name}</li>
+ {/each}
+ </ul>
+ {#if names.length - 10 > 0}
+ <p>... and {names.length - 10} more.</p>
+ {/if}
+ {/if}
+ {#if warning}
+ <p class="font-medium text-red-600">Warning: {warning}</p>
+ {/if}
+ </div>
+
+ <div class="flex justify-end gap-4">
+ <button type="submit" class="btn-rose" use:accelerator={'Enter'}>Delete</button>
+ <button type="button" on:click={closeModal} class="btn-slate">Cancel</button>
+ </div>
+ </form>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditArtist.svelte b/frontend/src/lib/dialogs/EditArtist.svelte
new file mode 100644
index 0000000..dd08bc6
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditArtist.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import { deleteArtists, updateArtists, type ArtistInput } from '$gql/Mutations';
+ import { itemEquals } from '$gql/Utils';
+ import { type Artist } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import ArtistForm from '$lib/forms/ArtistForm.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let artist: Artist;
+ const original = structuredClone(artist);
+ $: pending = !itemEquals(artist, original);
+
+ function save(event: CustomEvent<ArtistInput>) {
+ updateArtists(client, { ids: artist.id, input: event.detail })
+ .then(closeModal)
+ .catch(toastFinally);
+ }
+
+ function deleteArtist() {
+ confirmDeletion('Artist', artist.name, () => {
+ deleteArtists(client, { ids: artist.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Artist</h2>
+ </svelte:fragment>
+ <ArtistForm bind:artist on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteArtist} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </ArtistForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditCharacter.svelte b/frontend/src/lib/dialogs/EditCharacter.svelte
new file mode 100644
index 0000000..3b45e78
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditCharacter.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import { deleteCharacters, updateCharacters, type CharacterInput } from '$gql/Mutations';
+ import { itemEquals } from '$gql/Utils';
+ import { type Character } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import CharacterForm from '$lib/forms/CharacterForm.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let character: Character;
+ const original = structuredClone(character);
+ $: pending = !itemEquals(original, character);
+
+ function save(event: CustomEvent<CharacterInput>) {
+ updateCharacters(client, { ids: character.id, input: event.detail })
+ .then(closeModal)
+ .catch(toastFinally);
+ }
+
+ function deleteCharacter() {
+ confirmDeletion('Character', character.name, () => {
+ deleteCharacters(client, { ids: character.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Character</h2>
+ </svelte:fragment>
+ <CharacterForm bind:character on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteCharacter} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </CharacterForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditCircle.svelte b/frontend/src/lib/dialogs/EditCircle.svelte
new file mode 100644
index 0000000..bdc1217
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditCircle.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import { deleteCircles, updateCircles, type CircleInput } from '$gql/Mutations';
+ import { itemEquals } from '$gql/Utils';
+ import { type Circle } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import CircleForm from '$lib/forms/CircleForm.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let circle: Circle;
+ const original = structuredClone(circle);
+ $: pending = !itemEquals(original, circle);
+
+ function save(event: CustomEvent<CircleInput>) {
+ updateCircles(client, { ids: circle.id, input: event.detail })
+ .then(closeModal)
+ .catch(toastFinally);
+ }
+
+ function deleteCircle() {
+ confirmDeletion('Circle', circle.name, () => {
+ deleteCircles(client, { ids: circle.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Circle</h2>
+ </svelte:fragment>
+ <CircleForm bind:circle on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteCircle} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </CircleForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditNamespace.svelte b/frontend/src/lib/dialogs/EditNamespace.svelte
new file mode 100644
index 0000000..f398b21
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditNamespace.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import { deleteNamespaces, updateNamespaces, type NamespaceInput } from '$gql/Mutations';
+ import { itemEquals } from '$gql/Utils';
+ import { type Namespace } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import NamespaceForm from '$lib/forms/NamespaceForm.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let namespace: Namespace;
+ const original = structuredClone(namespace);
+ $: pending = !itemEquals(original, namespace);
+
+ function save(event: CustomEvent<NamespaceInput>) {
+ updateNamespaces(client, { ids: namespace.id, input: event.detail })
+ .then(closeModal)
+ .catch(toastFinally);
+ }
+
+ function deleteNamespace() {
+ confirmDeletion('Namespace', namespace.name, () => {
+ deleteNamespaces(client, { ids: namespace.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Namespace</h2>
+ </svelte:fragment>
+ <NamespaceForm bind:namespace on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteNamespace} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </NamespaceForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditTag.svelte b/frontend/src/lib/dialogs/EditTag.svelte
new file mode 100644
index 0000000..d2d0013
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditTag.svelte
@@ -0,0 +1,44 @@
+<script lang="ts">
+ import { deleteTags, updateTags, type TagInput } from '$gql/Mutations';
+ import { tagEquals } from '$gql/Utils';
+ import { type FullTag } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import TagForm from '$lib/forms/TagForm.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let tag: FullTag;
+ const original = structuredClone(tag);
+ $: pending = !tagEquals(original, tag);
+
+ function save(event: CustomEvent<TagInput>) {
+ updateTags(client, { ids: tag.id, input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+
+ function deleteTag() {
+ confirmDeletion('Tag', tag.name, () => {
+ deleteTags(client, { ids: tag.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Tag</h2>
+ </svelte:fragment>
+ <TagForm bind:tag on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteTag} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </TagForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditWorld.svelte b/frontend/src/lib/dialogs/EditWorld.svelte
new file mode 100644
index 0000000..82afe6a
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditWorld.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import { type World } from '$gql/graphql';
+ import { deleteWorlds, updateWorlds, type WorldInput } from '$gql/Mutations';
+ import { itemEquals } from '$gql/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import WorldForm from '$lib/forms/WorldForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let world: World;
+ const original = structuredClone(world);
+ $: pending = !itemEquals(original, world);
+
+ function save(event: CustomEvent<WorldInput>) {
+ updateWorlds(client, { ids: world.id, input: event.detail })
+ .then(closeModal)
+ .catch(toastFinally);
+ }
+
+ function deleteWorld() {
+ confirmDeletion('World', world.name, () => {
+ deleteWorlds(client, { ids: world.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit World</h2>
+ </svelte:fragment>
+ <WorldForm bind:world on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteWorld} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </WorldForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/UpdateComics.svelte b/frontend/src/lib/dialogs/UpdateComics.svelte
new file mode 100644
index 0000000..8de9622
--- /dev/null
+++ b/frontend/src/lib/dialogs/UpdateComics.svelte
@@ -0,0 +1,96 @@
+<script lang="ts">
+ import { updateComics } from '$gql/Mutations';
+ import { artistList, characterList, circleList, comicTagList, worldList } from '$gql/Queries';
+ import { categories, censorships, directions, languages, layouts, ratings } from '$lib/Enums';
+ import { toastFinally } from '$lib/Toasts';
+ import { UpdateComicsControls } from '$lib/Update';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import LabelledBlock from '$lib/components/LabelledBlock.svelte';
+ import Select from '$lib/components/Select.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+ import UpdateModeSelector from './components/UpdateModeSelector.svelte';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+ export let ids: number[];
+
+ $: tagsQuery = comicTagList(client);
+ $: artistsQuery = artistList(client);
+ $: charactersQuery = characterList(client);
+ $: circlesQuery = circleList(client);
+ $: worldsQuery = worldList(client);
+
+ $: tags = $tagsQuery.data?.comicTags.edges;
+ $: artists = $artistsQuery.data?.artists.edges;
+ $: characters = $charactersQuery.data?.characters.edges;
+ $: circles = $circlesQuery.data?.circles.edges;
+ $: worlds = $worldsQuery.data?.worlds.edges;
+
+ const controls = new UpdateComicsControls();
+
+ const update = () => {
+ updateComics(client, {
+ ids: ids,
+ input: controls.toInput()
+ })
+ .then(closeModal)
+ .catch(toastFinally);
+ };
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Comics</h2>
+ </svelte:fragment>
+ <form on:submit|preventDefault={update}>
+ <div class="grid-labels">
+ <Labelled label="Category" let:id>
+ <Select clearable {id} options={categories} bind:value={controls.category.value} />
+ </Labelled>
+ <Labelled label="Rating" let:id>
+ <Select clearable {id} options={ratings} bind:value={controls.rating.value} />
+ </Labelled>
+ <Labelled label="Censorship" let:id>
+ <Select clearable {id} options={censorships} bind:value={controls.censorship.value} />
+ </Labelled>
+ <Labelled label="Language" let:id>
+ <Select clearable {id} options={languages} bind:value={controls.language.value} />
+ </Labelled>
+ <Labelled label="Direction" let:id>
+ <Select clearable {id} options={directions} bind:value={controls.direction.value} />
+ </Labelled>
+ <Labelled label="Layout" let:id>
+ <Select clearable {id} options={layouts} bind:value={controls.layout.value} />
+ </Labelled>
+ </div>
+
+ <LabelledBlock label="Artists" let:id>
+ <Select multi {id} options={artists} bind:value={controls.artists.ids} />
+ <UpdateModeSelector bind:mode={controls.artists.options.mode} slot="controls" />
+ </LabelledBlock>
+ <LabelledBlock label="Circles" let:id>
+ <Select multi {id} options={circles} bind:value={controls.circles.ids} />
+ <UpdateModeSelector bind:mode={controls.circles.options.mode} slot="controls" />
+ </LabelledBlock>
+ <LabelledBlock label="Characters" let:id>
+ <Select multi {id} options={characters} bind:value={controls.characters.ids} />
+ <UpdateModeSelector bind:mode={controls.characters.options.mode} slot="controls" />
+ </LabelledBlock>
+ <LabelledBlock label="Worlds" let:id>
+ <Select multi {id} options={worlds} bind:value={controls.worlds.ids} />
+ <UpdateModeSelector bind:mode={controls.worlds.options.mode} slot="controls" />
+ </LabelledBlock>
+ <LabelledBlock label="Tags" let:id>
+ <Select multi {id} options={tags} bind:value={controls.tags.ids} />
+ <UpdateModeSelector bind:mode={controls.tags.options.mode} slot="controls" />
+ </LabelledBlock>
+
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={controls.hasInput()} />
+ </div>
+ </form>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/UpdateTags.svelte b/frontend/src/lib/dialogs/UpdateTags.svelte
new file mode 100644
index 0000000..f753c7f
--- /dev/null
+++ b/frontend/src/lib/dialogs/UpdateTags.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+ import { updateTags } from '$gql/Mutations';
+ import { namespaceList } from '$gql/Queries';
+ import { toastFinally } from '$lib/Toasts';
+ import { UpdateTagsControls } from '$lib/Update';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import LabelledBlock from '$lib/components/LabelledBlock.svelte';
+ import Select from '$lib/components/Select.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+ import UpdateModeSelector from './components/UpdateModeSelector.svelte';
+
+ const client = getContextClient();
+
+ $: namespaceQuery = namespaceList(client);
+ $: namespaces = $namespaceQuery.data?.namespaces.edges;
+
+ export let isOpen: boolean;
+ export let ids: number[];
+
+ const controls = new UpdateTagsControls();
+
+ const update = () => {
+ updateTags(client, { ids: ids, input: controls.toInput() })
+ .then(closeModal)
+ .catch(toastFinally);
+ };
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Tags</h2>
+ </svelte:fragment>
+ <form on:submit|preventDefault={update}>
+ <LabelledBlock label="Namespaces" let:id>
+ <Select multi {id} options={namespaces} bind:value={controls.namespaces.ids} />
+ <UpdateModeSelector bind:mode={controls.namespaces.options.mode} slot="controls" />
+ </LabelledBlock>
+
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={controls.hasInput()} />
+ </div>
+ </form>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte b/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte
new file mode 100644
index 0000000..e4b4479
--- /dev/null
+++ b/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { UpdateMode } from '$gql/graphql';
+ import { UpdateModeLabel } from '$lib/Enums';
+
+ export let mode: UpdateMode;
+
+ function select(e: string) {
+ mode = e as UpdateMode;
+ }
+</script>
+
+<div class="flex gap-1 pb-1 text-xs">
+ {#each Object.entries(UpdateModeLabel) as [e, label]}
+ <button
+ type="button"
+ class:active={mode === e}
+ class:dangerous={mode !== UpdateMode.Add}
+ class="btn btn-xs hover:bg-slate-700 [&.active.dangerous]:bg-rose-800 [&.active]:bg-indigo-700"
+ on:click={() => select(e)}
+ >
+ {label}
+ </button>
+ {/each}
+</div>
diff --git a/frontend/src/lib/filter/ComicFilterForm.svelte b/frontend/src/lib/filter/ComicFilterForm.svelte
new file mode 100644
index 0000000..13b5320
--- /dev/null
+++ b/frontend/src/lib/filter/ComicFilterForm.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { artistList, characterList, circleList, comicTagList, worldList } from '$gql/Queries';
+ import { ComicFilterContext, getFilterContext } from '$lib/Filter';
+ import { getContextClient } from '@urql/svelte';
+ import ComicFilterGroup from './components/ComicFilterGroup.svelte';
+ import FilterForm from './components/FilterForm.svelte';
+
+ const client = getContextClient();
+
+ $: tagsQuery = comicTagList(client, { forFilter: true });
+ $: artistsQuery = artistList(client);
+ $: charactersQuery = characterList(client);
+ $: circlesQuery = circleList(client);
+ $: worldsQuery = worldList(client);
+
+ $: tags = $tagsQuery.data?.comicTags.edges;
+ $: artists = $artistsQuery.data?.artists.edges;
+ $: characters = $charactersQuery.data?.characters.edges;
+ $: circles = $circlesQuery.data?.circles.edges;
+ $: worlds = $worldsQuery.data?.worlds.edges;
+
+ const filter = getFilterContext<ComicFilterContext>();
+ const apply = () => $filter.apply($page.url.searchParams);
+</script>
+
+<FilterForm type="grid" on:submit={apply}>
+ <ComicFilterGroup
+ slot="include"
+ type="include"
+ bind:controls={$filter.include.controls}
+ {tags}
+ {artists}
+ {characters}
+ {circles}
+ {worlds}
+ />
+ <ComicFilterGroup
+ slot="exclude"
+ type="exclude"
+ bind:controls={$filter.exclude.controls}
+ {tags}
+ {artists}
+ {characters}
+ {circles}
+ {worlds}
+ />
+</FilterForm>
diff --git a/frontend/src/lib/filter/TagFilterForm.svelte b/frontend/src/lib/filter/TagFilterForm.svelte
new file mode 100644
index 0000000..be5996e
--- /dev/null
+++ b/frontend/src/lib/filter/TagFilterForm.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { namespaceList } from '$gql/Queries';
+ import { TagFilterContext, getFilterContext } from '$lib/Filter';
+ import { getContextClient } from '@urql/svelte';
+ import FilterForm from './components/FilterForm.svelte';
+ import TagFilterGroup from './components/TagFilterGroup.svelte';
+
+ const client = getContextClient();
+
+ $: namespaceQuery = namespaceList(client);
+ $: namespaces = $namespaceQuery.data?.namespaces.edges;
+
+ const filter = getFilterContext<TagFilterContext>();
+ const apply = () => $filter.apply($page.url.searchParams);
+</script>
+
+<FilterForm on:submit={apply}>
+ <TagFilterGroup
+ slot="include"
+ type="include"
+ bind:controls={$filter.include.controls}
+ {namespaces}
+ />
+ <TagFilterGroup
+ slot="exclude"
+ type="exclude"
+ bind:controls={$filter.exclude.controls}
+ {namespaces}
+ />
+</FilterForm>
diff --git a/frontend/src/lib/filter/components/ComicFilterGroup.svelte b/frontend/src/lib/filter/components/ComicFilterGroup.svelte
new file mode 100644
index 0000000..d302de4
--- /dev/null
+++ b/frontend/src/lib/filter/components/ComicFilterGroup.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+ import { categories, censorships, languages, ratings } from '$lib/Enums';
+ import { ComicFilterControls } from '$lib/Filter';
+ import type { ListItem } from '$lib/Utils';
+ import { setContext } from 'svelte';
+ import Filter from './Filter.svelte';
+
+ export let tags: ListItem[] | undefined;
+ export let artists: ListItem[] | undefined;
+ export let circles: ListItem[] | undefined;
+ export let characters: ListItem[] | undefined;
+ export let worlds: ListItem[] | undefined;
+ export let controls: ComicFilterControls;
+ export let type: 'include' | 'exclude';
+
+ setContext('filter-type', type);
+</script>
+
+<Filter title="Tags" options={tags} bind:filter={controls.tags} --grid-column="span 2" />
+<Filter title="Artists" options={artists} bind:filter={controls.artists} />
+<Filter title="Circles" options={circles} bind:filter={controls.circles} />
+<Filter title="Characters" options={characters} bind:filter={controls.characters} />
+<Filter title="Worlds" options={worlds} bind:filter={controls.worlds} />
+<Filter title="Categories" options={categories} bind:filter={controls.categories} />
+<Filter title="Ratings" options={ratings} bind:filter={controls.ratings} />
+<Filter title="Censorship" options={censorships} bind:filter={controls.censorships} />
+<Filter title="Languages" options={languages} bind:filter={controls.languages} />
diff --git a/frontend/src/lib/filter/components/Filter.svelte b/frontend/src/lib/filter/components/Filter.svelte
new file mode 100644
index 0000000..ead5c4d
--- /dev/null
+++ b/frontend/src/lib/filter/components/Filter.svelte
@@ -0,0 +1,77 @@
+<script lang="ts">
+ import { Association, Enum } from '$lib/Filter';
+ import type { ListItem } from '$lib/Utils';
+ import Select from '$lib/components/Select.svelte';
+ import { getContext } from 'svelte';
+
+ export let title: string;
+ const context: 'include' | 'exclude' = getContext('filter-type');
+ $: exclude = context === 'exclude';
+
+ const id = `${context}-${title.toLowerCase()}`;
+
+ export let options: ListItem[] | undefined;
+ export let filter: Association<string> | Enum<string>;
+</script>
+
+<div class:exclude class="filter-container">
+ <div class="flex gap-2">
+ <label for={id}>{title}</label>
+ <div class="ml-auto flex items-center gap-1 self-center text-xs">
+ {#if filter instanceof Association}
+ <button
+ type="button"
+ title="matches all"
+ class:active={filter.mode === 'all'}
+ class="btn btn-xs"
+ on:click={() => (filter.mode = 'all')}
+ >
+ &forall;
+ </button>
+ <button
+ type="button"
+ title="matches any of"
+ class:active={filter.mode === 'any'}
+ class="btn btn-xs"
+ on:click={() => (filter.mode = 'any')}
+ >
+ &exist;
+ </button>
+ <button
+ type="button"
+ title="matches exactly"
+ class:active={filter.mode === 'exact'}
+ class="btn btn-xs"
+ on:click={() => (filter.mode = 'exact')}
+ >
+ &equals;
+ </button>
+ <hr class="border-px border-slate-600" />
+ {/if}
+ <button
+ type="button"
+ title="empty"
+ class:active={filter.empty}
+ class="btn btn-xs"
+ on:click={() => (filter.empty = !filter.empty)}
+ >
+ &empty;
+ </button>
+ </div>
+ </div>
+ <Select multi clearable {options} {id} bind:value={filter.values} />
+</div>
+
+<style lang="postcss">
+ button:hover {
+ @apply bg-slate-700;
+ }
+
+ button.active {
+ @apply bg-indigo-800;
+ }
+
+ .filter-container {
+ grid-column: var(--grid-column);
+ }
+</style>
diff --git a/frontend/src/lib/filter/components/FilterForm.svelte b/frontend/src/lib/filter/components/FilterForm.svelte
new file mode 100644
index 0000000..6fc4c90
--- /dev/null
+++ b/frontend/src/lib/filter/components/FilterForm.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ import Expander from '$lib/components/Expander.svelte';
+ import { getFilterContext } from '$lib/Filter';
+
+ const filter = getFilterContext();
+ export let type: 'grid' | 'row' = 'row';
+
+ let exclude = false;
+
+ $: if ($filter.exclude.size > 0) {
+ exclude = true;
+ }
+</script>
+
+<form on:submit|preventDefault class="gap-0">
+ {#if type === 'grid'}
+ <div class="flex flex-col gap-4 px-2 md:grid md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
+ <slot name="include" />
+ </div>
+ <div class="my-2 flex justify-start">
+ <Expander title="Exclude" bind:expanded={exclude} />
+ </div>
+ {#if exclude}
+ <div
+ class="flex flex-col gap-4 bg-rose-950/50 p-2 md:grid md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"
+ >
+ <slot name="exclude" />
+ </div>
+ {/if}
+ {:else}
+ <div
+ class="flex flex-wrap justify-center gap-2 [&>*]:basis-full xl:[&>*]:basis-1/3 2xl:[&>*]:basis-1/5"
+ >
+ <div class="p-2">
+ <slot name="include" />
+ </div>
+ <div class="bg-rose-950/50 p-2">
+ <slot name="exclude" />
+ </div>
+ </div>
+ {/if}
+ <div class=" mt-4 flex items-center">
+ <hr class="flex-1 border-slate-700/70" />
+ <button type="submit" class="btn-blue mx-2">Apply</button>
+ <hr class="flex-1 border-slate-700/70" />
+ </div>
+</form>
diff --git a/frontend/src/lib/filter/components/TagFilterGroup.svelte b/frontend/src/lib/filter/components/TagFilterGroup.svelte
new file mode 100644
index 0000000..83b6997
--- /dev/null
+++ b/frontend/src/lib/filter/components/TagFilterGroup.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+ import { TagFilterControls } from '$lib/Filter';
+ import type { ListItem } from '$lib/Utils';
+ import { setContext } from 'svelte';
+ import Filter from './Filter.svelte';
+
+ export let namespaces: ListItem[] | undefined;
+ export let controls: TagFilterControls;
+ export let type: 'include' | 'exclude';
+
+ setContext('filter-type', type);
+</script>
+
+<Filter title="Namespaces" options={namespaces} bind:filter={controls.namespaces} />
diff --git a/frontend/src/lib/forms/ArtistForm.svelte b/frontend/src/lib/forms/ArtistForm.svelte
new file mode 100644
index 0000000..7df5e8b
--- /dev/null
+++ b/frontend/src/lib/forms/ArtistForm.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import { type ArtistInput } from '$gql/Mutations';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import { type Artist } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const dispatch = createEventDispatcher<{ submit: ArtistInput }>();
+
+ export let artist: OmitIdentifiers<Artist>;
+
+ function submit() {
+ dispatch('submit', { name: artist.name });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input autofocus required {id} bind:value={artist.name} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/CharacterForm.svelte b/frontend/src/lib/forms/CharacterForm.svelte
new file mode 100644
index 0000000..4cec37c
--- /dev/null
+++ b/frontend/src/lib/forms/CharacterForm.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import { type CharacterInput } from '$gql/Mutations';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import { type Character } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const dispatch = createEventDispatcher<{ submit: CharacterInput }>();
+
+ export let character: OmitIdentifiers<Character>;
+
+ function submit() {
+ dispatch('submit', { name: character.name });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input autofocus required {id} bind:value={character.name} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/CircleForm.svelte b/frontend/src/lib/forms/CircleForm.svelte
new file mode 100644
index 0000000..b71256c
--- /dev/null
+++ b/frontend/src/lib/forms/CircleForm.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import { type CircleInput } from '$gql/Mutations';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import { type Circle } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const dispatch = createEventDispatcher<{ submit: CircleInput }>();
+
+ export let circle: OmitIdentifiers<Circle>;
+
+ function submit() {
+ dispatch('submit', { name: circle.name });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input required autofocus {id} bind:value={circle.name} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/ComicForm.svelte b/frontend/src/lib/forms/ComicForm.svelte
new file mode 100644
index 0000000..74051c8
--- /dev/null
+++ b/frontend/src/lib/forms/ComicForm.svelte
@@ -0,0 +1,100 @@
+<script lang="ts">
+ import { artistList, characterList, circleList, comicTagList, worldList } from '$gql/Queries';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import type { FullComicFragment, UpdateComicInput } from '$gql/graphql';
+ import { categories, censorships, directions, languages, layouts, ratings } from '$lib/Enums';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import LabelledBlock from '$lib/components/LabelledBlock.svelte';
+ import Select from '$lib/components/Select.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const client = getContextClient();
+ const dispatch = createEventDispatcher<{ submit: UpdateComicInput }>();
+
+ export let comic: OmitIdentifiers<FullComicFragment>;
+
+ $: tagsQuery = comicTagList(client);
+ $: artistsQuery = artistList(client);
+ $: charactersQuery = characterList(client);
+ $: circlesQuery = circleList(client);
+ $: worldsQuery = worldList(client);
+
+ $: tags = $tagsQuery.data?.comicTags.edges;
+ $: artists = $artistsQuery.data?.artists.edges;
+ $: characters = $charactersQuery.data?.characters.edges;
+ $: circles = $circlesQuery.data?.circles.edges;
+ $: worlds = $worldsQuery.data?.worlds.edges;
+
+ function submit() {
+ dispatch('submit', {
+ direction: comic.direction,
+ layout: comic.layout,
+ rating: comic.rating,
+ category: comic.category,
+ censorship: comic.censorship,
+ title: comic.title,
+ originalTitle: comic.originalTitle,
+ url: comic.url,
+ date: comic.date === '' ? null : comic.date,
+ language: comic.language,
+ tags: { ids: comic.tags.map((t) => t.id) },
+ artists: { ids: comic.artists.map((a) => a.id) },
+ characters: { ids: comic.characters.map((c) => c.id) },
+ circles: { ids: comic.circles.map((c) => c.id) },
+ worlds: { ids: comic.worlds.map((w) => w.id) }
+ });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Title" let:id>
+ <input required {id} bind:value={comic.title} title={comic.title} />
+ </Labelled>
+ <Labelled label="Original Title" let:id>
+ <input {id} bind:value={comic.originalTitle} title={comic.originalTitle} />
+ </Labelled>
+ <Labelled label="URL" let:id>
+ <input {id} bind:value={comic.url} />
+ </Labelled>
+ <Labelled label="Date" let:id>
+ <input {id} type="date" bind:value={comic.date} pattern={'d{4}-d{2}-d{2}'} />
+ </Labelled>
+ <Labelled label="Category" let:id>
+ <Select {id} options={categories} bind:value={comic.category} />
+ </Labelled>
+ <Labelled label="Rating" let:id>
+ <Select {id} options={ratings} bind:value={comic.rating} />
+ </Labelled>
+ <Labelled label="Censorship" let:id>
+ <Select {id} options={censorships} bind:value={comic.censorship} />
+ </Labelled>
+ <Labelled label="Language" let:id>
+ <Select {id} options={languages} bind:value={comic.language} />
+ </Labelled>
+ <Labelled label="Direction" let:id>
+ <Select {id} options={directions} bind:value={comic.direction} />
+ </Labelled>
+ <Labelled label="Layout" let:id>
+ <Select {id} options={layouts} bind:value={comic.layout} />
+ </Labelled>
+ </div>
+
+ <LabelledBlock label="Artists" let:id>
+ <Select multi object {id} options={artists} bind:value={comic.artists} />
+ </LabelledBlock>
+ <LabelledBlock label="Circles" let:id>
+ <Select multi object {id} options={circles} bind:value={comic.circles} />
+ </LabelledBlock>
+ <LabelledBlock label="Characters" let:id>
+ <Select multi object {id} options={characters} bind:value={comic.characters} />
+ </LabelledBlock>
+ <LabelledBlock label="Worlds" let:id>
+ <Select multi object {id} options={worlds} bind:value={comic.worlds} />
+ </LabelledBlock>
+ <LabelledBlock label="Tags" let:id>
+ <Select multi object {id} options={tags} bind:value={comic.tags} />
+ </LabelledBlock>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/NamespaceForm.svelte b/frontend/src/lib/forms/NamespaceForm.svelte
new file mode 100644
index 0000000..c05b6d8
--- /dev/null
+++ b/frontend/src/lib/forms/NamespaceForm.svelte
@@ -0,0 +1,28 @@
+<script lang="ts">
+ import { type NamespaceInput } from '$gql/Mutations';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import { type Namespace } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const dispatch = createEventDispatcher<{ submit: NamespaceInput }>();
+
+ export let namespace: OmitIdentifiers<Namespace>;
+
+ function submit() {
+ dispatch('submit', { name: namespace.name, sortName: namespace.sortName });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input required autofocus {id} bind:value={namespace.name} />
+ </Labelled>
+ <Labelled label="Sort name" let:id>
+ <input {id} bind:value={namespace.sortName} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/TagForm.svelte b/frontend/src/lib/forms/TagForm.svelte
new file mode 100644
index 0000000..6cc2227
--- /dev/null
+++ b/frontend/src/lib/forms/TagForm.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import type { TagInput } from '$gql/Mutations';
+ import { namespaceList } from '$gql/Queries';
+ import type { OmitIdentifiers } from '$gql/Utils';
+ import type { FullTag } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import Select from '$lib/components/Select.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const client = getContextClient();
+ const dispatch = createEventDispatcher<{ submit: TagInput }>();
+
+ export let tag: OmitIdentifiers<FullTag>;
+
+ $: namespaceQuery = namespaceList(client);
+ $: namespaces = $namespaceQuery.data?.namespaces.edges;
+
+ function submit() {
+ dispatch('submit', {
+ name: tag.name,
+ description: tag.description,
+ namespaces: { ids: tag.namespaces.map((n) => n.id) }
+ });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input autofocus required {id} bind:value={tag.name} />
+ </Labelled>
+ <Labelled label="Description" let:id>
+ <textarea rows={3} {id} bind:value={tag.description} />
+ </Labelled>
+ <Labelled label="Namespaces" let:id>
+ <Select multi object {id} options={namespaces} bind:value={tag.namespaces} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/WorldForm.svelte b/frontend/src/lib/forms/WorldForm.svelte
new file mode 100644
index 0000000..103dd5b
--- /dev/null
+++ b/frontend/src/lib/forms/WorldForm.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import { type WorldInput } from '$gql/Mutations';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import { type World } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const dispatch = createEventDispatcher<{ submit: WorldInput }>();
+
+ export let world: OmitIdentifiers<World>;
+
+ function submit() {
+ dispatch('submit', { name: world.name });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input autofocus required {id} bind:value={world.name} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/gallery/Gallery.svelte b/frontend/src/lib/gallery/Gallery.svelte
new file mode 100644
index 0000000..c3b6386
--- /dev/null
+++ b/frontend/src/lib/gallery/Gallery.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import type { PageFragment } from '$gql/graphql';
+ import GalleryPage from './GalleryPage.svelte';
+
+ export let pages: PageFragment[];
+</script>
+
+<div class="max-h-full gap-2 overflow-auto p-1 pr-3">
+ {#each pages as page, index}
+ <GalleryPage {page} {index} on:open on:cover />
+ {/each}
+</div>
+
+<style>
+ :root {
+ --gallery-image-size: 100px;
+ }
+
+ @media (min-width: 1280px) {
+ :root {
+ --gallery-image-size: 180px;
+ }
+ }
+
+ @media (min-width: 1600px) {
+ :root {
+ --gallery-image-size: 200px;
+ }
+ }
+
+ @media (min-width: 1920px) {
+ :root {
+ --gallery-image-size: 240px;
+ }
+ }
+
+ div {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(var(--gallery-image-size), 1fr));
+ grid-auto-rows: fit-content(400px);
+ }
+</style>
diff --git a/frontend/src/lib/gallery/GalleryPage.svelte b/frontend/src/lib/gallery/GalleryPage.svelte
new file mode 100644
index 0000000..449321c
--- /dev/null
+++ b/frontend/src/lib/gallery/GalleryPage.svelte
@@ -0,0 +1,93 @@
+<script lang="ts">
+ import type { PageFragment } from '$gql/graphql';
+ import { getSelectionContext } from '$lib/Selection';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import { src } from '$lib/Utils';
+ import { createEventDispatcher } from 'svelte';
+
+ export let page: PageFragment;
+ export let index: number;
+
+ const selection = getSelectionContext<PageFragment>();
+
+ let span: 'single' | 'double' | 'triple';
+
+ $: page.image.aspectRatio, updateSpan();
+
+ function updateSpan() {
+ const aspectRatio = page.image.aspectRatio;
+
+ if (aspectRatio <= 1) {
+ span = 'single';
+ } else if (aspectRatio > 1 && aspectRatio <= 2) {
+ span = 'double';
+ } else if (aspectRatio > 2) {
+ span = 'triple';
+ }
+ }
+
+ const dispatch = createEventDispatcher<{ open: number; cover: number }>();
+
+ function press(event: MouseEvent | KeyboardEvent) {
+ if (event instanceof KeyboardEvent && event.key !== 'Enter') {
+ return;
+ }
+
+ if ($selection.active) {
+ if (event.ctrlKey) {
+ dispatch('open', index);
+ } else if (selectable) {
+ $selection = $selection.update(index, event.shiftKey);
+ }
+ } else if (event.ctrlKey) {
+ dispatch('cover', page.id);
+ } else {
+ dispatch('open', index);
+ }
+
+ event.preventDefault();
+ }
+
+ $: selectable = $selection.selectable(page);
+ $: dim = $selection.active && !selectable;
+ $: selected = $selection.contains(page.id);
+</script>
+
+<div
+ class:dim
+ role="button"
+ tabindex="0"
+ class="{span} relative overflow-hidden rounded"
+ on:click={press}
+ on:keydown={press}
+>
+ <SelectionOverlay position="top" {selected} />
+ <img
+ class="h-full w-full object-cover object-[center_top] transition-opacity"
+ loading="lazy"
+ alt=""
+ width={page.image.width}
+ height={page.image.height}
+ src={src(page.image)}
+ title={`${page.path} (${page.image.width} x ${page.image.height})`}
+ />
+</div>
+
+<style>
+ .dim {
+ cursor: not-allowed;
+ }
+
+ .dim > img {
+ opacity: 0.2;
+ filter: grayscale(1);
+ }
+
+ .double {
+ grid-column: span 2;
+ }
+
+ .triple {
+ grid-column: span 3;
+ }
+</style>
diff --git a/frontend/src/lib/icons/Bookmark.svelte b/frontend/src/lib/icons/Bookmark.svelte
new file mode 100644
index 0000000..6f8e192
--- /dev/null
+++ b/frontend/src/lib/icons/Bookmark.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ export let bookmarked: boolean | undefined = undefined;
+ export let hoverable = false;
+</script>
+
+{#if bookmarked}
+ <span class:hoverable class="icon-gray icon-base icon-[material-symbols--bookmark]" />
+{:else}
+ <span class:hoverable class="icon-gray icon-base dim icon-[material-symbols--bookmark-outline]" />
+{/if}
diff --git a/frontend/src/lib/icons/Female.svelte b/frontend/src/lib/icons/Female.svelte
new file mode 100644
index 0000000..c772a6a
--- /dev/null
+++ b/frontend/src/lib/icons/Female.svelte
@@ -0,0 +1 @@
+<span class="icon-xs icon-[material-symbols--female] -mx-[3px]" />
diff --git a/frontend/src/lib/icons/Location.svelte b/frontend/src/lib/icons/Location.svelte
new file mode 100644
index 0000000..e345f83
--- /dev/null
+++ b/frontend/src/lib/icons/Location.svelte
@@ -0,0 +1 @@
+<span class="icon-xs icon-[material-symbols--location-on-outline]" />
diff --git a/frontend/src/lib/icons/Male.svelte b/frontend/src/lib/icons/Male.svelte
new file mode 100644
index 0000000..e3578b7
--- /dev/null
+++ b/frontend/src/lib/icons/Male.svelte
@@ -0,0 +1 @@
+<span class="icon-xs icon-[material-symbols--male] -mx-px" />
diff --git a/frontend/src/lib/icons/Organized.svelte b/frontend/src/lib/icons/Organized.svelte
new file mode 100644
index 0000000..66b5b00
--- /dev/null
+++ b/frontend/src/lib/icons/Organized.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ export let organized: boolean | undefined = undefined;
+ export let hoverable = false;
+ export let tristate = false;
+ export let dim = false;
+</script>
+
+{#if organized}
+ <span class:hoverable class="icon-gray icon-base icon-[material-symbols--check-circle]" />
+{:else if organized === undefined || !tristate}
+ <span
+ class:hoverable
+ class="icon-gray dim icon-base icon-[material-symbols--check-circle-outline]"
+ />
+{:else}
+ <span
+ class:hoverable
+ class:dim
+ class="icon-gray icon-base icon-[material-symbols--unpublished]"
+ />
+{/if}
diff --git a/frontend/src/lib/icons/Star.svelte b/frontend/src/lib/icons/Star.svelte
new file mode 100644
index 0000000..7613c55
--- /dev/null
+++ b/frontend/src/lib/icons/Star.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ export let large = false;
+ export let favourite: boolean | undefined = undefined;
+ export let hoverable = false;
+</script>
+
+{#if favourite}
+ <span class:hoverable class:large class="icon-yellow icon-[material-symbols--star-rounded]" />
+{:else}
+ <span
+ class:hoverable
+ class:large
+ class="icon-yellow dim icon-[material-symbols--star-outline-rounded]"
+ />
+{/if}
+
+<style lang="postcss">
+ span {
+ @apply -m-px -translate-y-px text-[26px];
+ }
+
+ span.large {
+ @apply text-[34px];
+ }
+</style>
diff --git a/frontend/src/lib/icons/Transgender.svelte b/frontend/src/lib/icons/Transgender.svelte
new file mode 100644
index 0000000..7d9adc6
--- /dev/null
+++ b/frontend/src/lib/icons/Transgender.svelte
@@ -0,0 +1 @@
+<span class="icon-xs icon-[material-symbols--transgender]" />
diff --git a/frontend/src/lib/navigation/Link.svelte b/frontend/src/lib/navigation/Link.svelte
new file mode 100644
index 0000000..7297a69
--- /dev/null
+++ b/frontend/src/lib/navigation/Link.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { accelerator, type Shortcut } from '$lib/Shortcuts';
+ import type { HTMLAttributeAnchorTarget } from 'svelte/elements';
+
+ export let href: string;
+ export let title: string;
+ export let accel: Shortcut;
+ export let matchExact = false;
+ export let target: HTMLAttributeAnchorTarget | undefined = undefined;
+ $: active = matchExact ? $page.url.pathname === href : $page.url.pathname.startsWith(href);
+</script>
+
+<li class:active class="items-center hover:bg-indigo-700 [&.active]:bg-indigo-700">
+ <a class="flex items-center" {target} {title} {href} use:accelerator={accel}>
+ <div class="flex p-3">
+ <slot />
+ </div>
+ </a>
+</li>
diff --git a/frontend/src/lib/navigation/Navigation.svelte b/frontend/src/lib/navigation/Navigation.svelte
new file mode 100644
index 0000000..76096c8
--- /dev/null
+++ b/frontend/src/lib/navigation/Navigation.svelte
@@ -0,0 +1,5 @@
+<nav>
+ <ul class="flex h-full flex-col bg-slate-700/70 font-medium">
+ <slot />
+ </ul>
+</nav>
diff --git a/frontend/src/lib/pagination/Pagination.svelte b/frontend/src/lib/pagination/Pagination.svelte
new file mode 100644
index 0000000..51612f4
--- /dev/null
+++ b/frontend/src/lib/pagination/Pagination.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+ import { getPaginationContext } from '$lib/Pagination';
+ import Target from './Target.svelte';
+
+ const pagination = getPaginationContext();
+ export let context = 2;
+
+ $: totalPages = Math.ceil($pagination.total / $pagination.items);
+ $: rightBoundary = $pagination.page - context;
+ $: leftBoundary = $pagination.page + context;
+
+ $: shiftRight = leftBoundary - totalPages;
+ $: shiftLeft = 1 - rightBoundary;
+
+ $: containedLeft = leftBoundary <= totalPages;
+ $: containedRight = rightBoundary > 0;
+
+ $: start = Math.max(1, containedLeft ? rightBoundary : rightBoundary - shiftRight);
+ $: end = Math.min(totalPages, containedRight ? leftBoundary : leftBoundary + shiftLeft);
+
+ $: leftmost = $pagination.page <= 1;
+ $: rightmost = $pagination.page >= totalPages;
+</script>
+
+{#if totalPages > 1}
+ <div class="flex justify-center gap-2">
+ <Target disabled={leftmost} page={1}>
+ <span class="icon-base icon-[material-symbols--keyboard-double-arrow-left]" />
+ </Target>
+ <Target disabled={leftmost} page={$pagination.page - 1}>
+ <span class="icon-base icon-[material-symbols--keyboard-arrow-left]" />
+ </Target>
+ {#each Array.from({ length: end + 1 - start }, (_, i) => i + start) as page}
+ <Target active={$pagination.page === page} {page}>
+ <p>{page.toString()}</p>
+ </Target>
+ {/each}
+ <Target disabled={rightmost} page={$pagination.page + 1}>
+ <span class="icon-base icon-[material-symbols--keyboard-arrow-right]" />
+ </Target>
+ <Target disabled={rightmost} page={totalPages}>
+ <span class="icon-base icon-[material-symbols--keyboard-double-arrow-right]" />
+ </Target>
+ </div>
+{/if}
diff --git a/frontend/src/lib/pagination/Target.svelte b/frontend/src/lib/pagination/Target.svelte
new file mode 100644
index 0000000..9044bb9
--- /dev/null
+++ b/frontend/src/lib/pagination/Target.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import { page as pageStore } from '$app/stores';
+ import { navigate } from '$lib/Navigation';
+
+ export let active = false;
+
+ export let disabled = false;
+ export let page: number;
+</script>
+
+<button
+ on:click={() => {
+ navigate({ pagination: { page: page } }, $pageStore.url.searchParams);
+ }}
+ class:bg-slate-700={active}
+ class:bg-slate-800={!active}
+ class="flex h-8 w-8 items-center justify-center rounded-sm p-0 text-base hover:text-white disabled:text-slate-600"
+ {disabled}
+>
+ <slot />
+</button>
diff --git a/frontend/src/lib/pills/AssociationPill.svelte b/frontend/src/lib/pills/AssociationPill.svelte
new file mode 100644
index 0000000..85dbe39
--- /dev/null
+++ b/frontend/src/lib/pills/AssociationPill.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import Pill from './Pill.svelte';
+
+ type Association = 'artist' | 'circle' | 'world' | 'character';
+
+ export let name: string;
+ export let type: Association;
+</script>
+
+<Pill {name}>
+ <span class={`${type} icon-xs`} slot="icon" />
+</Pill>
+
+<style lang="postcss">
+ .artist {
+ @apply icon-[material-symbols--person] -mx-px;
+ }
+
+ .character {
+ @apply icon-[material-symbols--face];
+ }
+
+ .circle {
+ @apply icon-[material-symbols--group] mx-px;
+ }
+
+ .world {
+ @apply icon-[material-symbols--public];
+ }
+</style>
diff --git a/frontend/src/lib/pills/ComicPills.svelte b/frontend/src/lib/pills/ComicPills.svelte
new file mode 100644
index 0000000..671bbf2
--- /dev/null
+++ b/frontend/src/lib/pills/ComicPills.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ import type { ComicFragment } from '$gql/graphql';
+ import AssociationPill from '$lib/pills/AssociationPill.svelte';
+ import TagPill from '$lib/pills/TagPill.svelte';
+
+ export let comic: ComicFragment;
+</script>
+
+<div class="flex flex-col gap-1">
+ {#if comic.artists.length || comic.circles.length}
+ <div class="flex flex-wrap gap-1">
+ {#each comic.artists as { name } (name)}
+ <AssociationPill {name} type="artist" />
+ {/each}
+ {#each comic.circles as { name } (name)}
+ <AssociationPill {name} type="circle" />
+ {/each}
+ </div>
+ {/if}
+ {#if comic.characters.length || comic.worlds.length}
+ <div class="flex flex-wrap gap-1">
+ {#each comic.worlds as { name } (name)}
+ <AssociationPill {name} type="world" />
+ {/each}
+ {#each comic.characters as { name } (name)}
+ <AssociationPill {name} type="character" />
+ {/each}
+ </div>
+ {/if}
+ {#if comic.tags.length}
+ <div class="flex flex-wrap gap-1">
+ {#each comic.tags as { name, description } (name)}
+ <TagPill {name} {description} />
+ {/each}
+ </div>
+ {/if}
+</div>
diff --git a/frontend/src/lib/pills/Pill.svelte b/frontend/src/lib/pills/Pill.svelte
new file mode 100644
index 0000000..7aa9670
--- /dev/null
+++ b/frontend/src/lib/pills/Pill.svelte
@@ -0,0 +1,40 @@
+<script lang="ts" context="module">
+ export type PillColour = 'pink' | 'blue' | 'violet' | 'amber' | 'zinc' | 'sky';
+</script>
+
+<script lang="ts">
+ export let name: string;
+ export let tooltip: string | null | undefined = undefined;
+ export let colour: PillColour = 'zinc';
+</script>
+
+<div class="flex items-center rounded border p-0.5 {colour}" title={tooltip}>
+ <slot name="icon" />
+ <span>{name}</span>
+</div>
+
+<style lang="postcss">
+ .pink {
+ @apply border-pink-800 bg-pink-800/20 text-pink-200;
+ }
+
+ .blue {
+ @apply border-blue-800 bg-blue-800/20 text-blue-200;
+ }
+
+ .violet {
+ @apply border-violet-800 bg-violet-800/20 text-violet-200;
+ }
+
+ .amber {
+ @apply border-amber-800 bg-amber-800/20 text-amber-200;
+ }
+
+ .sky {
+ @apply border-sky-800 bg-sky-800/20 text-sky-200;
+ }
+
+ .zinc {
+ @apply border-zinc-700 bg-zinc-700/20 text-zinc-300;
+ }
+</style>
diff --git a/frontend/src/lib/pills/TagPill.svelte b/frontend/src/lib/pills/TagPill.svelte
new file mode 100644
index 0000000..60221bd
--- /dev/null
+++ b/frontend/src/lib/pills/TagPill.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ import Female from '$lib/icons/Female.svelte';
+ import Location from '$lib/icons/Location.svelte';
+ import Male from '$lib/icons/Male.svelte';
+ import Transgender from '$lib/icons/Transgender.svelte';
+ import { SvelteComponent } from 'svelte';
+ import Pill, { type PillColour } from './Pill.svelte';
+
+ export let name: string;
+ export let description: string | undefined | null = undefined;
+
+ let [namespace, tag] = name.split(':');
+
+ const styles: Record<string, PillColour> = {
+ female: 'pink',
+ male: 'blue',
+ trans: 'violet',
+ mixed: 'amber',
+ location: 'sky',
+ rest: 'zinc'
+ };
+
+ const icons: Record<string, typeof SvelteComponent<Record<string, unknown>>> = {
+ female: Female,
+ male: Male,
+ trans: Transgender,
+ location: Location
+ };
+
+ const colour = styles[namespace] ?? styles.rest;
+ const icon = icons[namespace];
+
+ function formatTooltip() {
+ return [name, description].filter((v) => v).join('\n\n');
+ }
+</script>
+
+<Pill name={tag} tooltip={formatTooltip()} {colour}>
+ <svelte:component this={icon} slot="icon" />
+</Pill>
diff --git a/frontend/src/lib/reader/PageView.svelte b/frontend/src/lib/reader/PageView.svelte
new file mode 100644
index 0000000..cc4d10e
--- /dev/null
+++ b/frontend/src/lib/reader/PageView.svelte
@@ -0,0 +1,67 @@
+<script lang="ts">
+ import { Direction, Layout, type PageFragment } from '$gql/graphql';
+ import { getReaderContext, partition, type Chunk } from '$lib/Reader';
+ import { binds } from '$lib/Shortcuts';
+ import ReaderPage from './ReaderPage.svelte';
+
+ const reader = getReaderContext();
+
+ export let direction: Direction;
+ export let layout: Layout;
+
+ let chunks: Chunk[] = [];
+ let lookup: number[] = [];
+
+ let main: PageFragment;
+ let secondary: PageFragment | undefined;
+
+ function gotoChunk(to: number) {
+ if (to < 0 || to >= chunks.length) return;
+
+ $reader.page = chunks[to].index;
+ }
+
+ const next = () => gotoChunk(lookup[$reader.page] + 1);
+ const prev = () => gotoChunk(lookup[$reader.page] - 1);
+
+ const clickLeft = () => (direction === Direction.LeftToRight ? prev() : next());
+ const clickRight = () => (direction === Direction.RightToLeft ? prev() : next());
+
+ function clickMain(event: MouseEvent & { currentTarget: EventTarget | null }) {
+ if (event.currentTarget instanceof Element) {
+ const rect = event.currentTarget.getBoundingClientRect();
+
+ if (event.clientX - rect.left < rect.width / 2) {
+ clickLeft();
+ } else {
+ clickRight();
+ }
+ }
+ }
+
+ $: [chunks, lookup] = partition($reader.pages, layout);
+ $: layout, ({ main, secondary } = chunks[lookup[$reader.page]]);
+</script>
+
+<svelte:document
+ use:binds={[
+ ['ArrowLeft', clickLeft],
+ ['ArrowRight', clickRight],
+ ['ArrowUp', prev],
+ ['ArrowDown', next],
+ ['PageUp', prev],
+ ['PageDown', next],
+ [' ', next],
+ ['Backspace', prev]
+ ]}
+/>
+
+{#if !secondary}
+ <ReaderPage page={main} on:click={clickMain} --justify="center" />
+{:else if direction === Direction.LeftToRight}
+ <ReaderPage page={main} on:click={prev} --justify="flex-end" />
+ <ReaderPage page={secondary} on:click={next} --justify="flex-start" />
+{:else}
+ <ReaderPage page={secondary} on:click={next} --justify="flex-end" />
+ <ReaderPage page={main} on:click={prev} --justify="flex-start" />
+{/if}
diff --git a/frontend/src/lib/reader/Reader.svelte b/frontend/src/lib/reader/Reader.svelte
new file mode 100644
index 0000000..0b1450a
--- /dev/null
+++ b/frontend/src/lib/reader/Reader.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ import { trapFocus } from '$lib/Actions';
+ import { getReaderContext } from '$lib/Reader';
+ import { fadeDefault, slideXDefault } from '$lib/Transitions';
+ import { fade, slide } from 'svelte/transition';
+ import CloseReaderButton from './components/CloseReaderButton.svelte';
+ import ReaderMenuButton from './components/ReaderMenuButton.svelte';
+
+ const reader = getReaderContext();
+</script>
+
+{#if $reader.visible}
+ <div
+ role="dialog"
+ class="fixed bottom-0 left-0 right-0 top-0 z-10 flex h-full w-full bg-black"
+ transition:fade={fadeDefault}
+ use:trapFocus
+ >
+ {#if $$slots.sidebar && $reader.sidebar}
+ <aside class="w-[36rem] shrink-0 bg-slate-800" transition:slide={slideXDefault}>
+ <div class="flex h-full min-w-[36rem] flex-col gap-4 overflow-auto p-4">
+ <slot name="sidebar" />
+ </div>
+ </aside>
+ {/if}
+ <main class="relative flex grow">
+ <div class="absolute flex w-full p-1 text-lg [&>*:last-child]:ml-auto">
+ {#if $$slots.sidebar}
+ <ReaderMenuButton />
+ {/if}
+ <CloseReaderButton />
+ </div>
+
+ <div class="flex grow">
+ <slot />
+ </div>
+ </main>
+ </div>
+{/if}
diff --git a/frontend/src/lib/reader/ReaderPage.svelte b/frontend/src/lib/reader/ReaderPage.svelte
new file mode 100644
index 0000000..fb3e780
--- /dev/null
+++ b/frontend/src/lib/reader/ReaderPage.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import type { PageFragment } from '$gql/graphql';
+ import { src } from '$lib/Utils';
+
+ export let page: PageFragment;
+</script>
+
+<!-- svelte-ignore a11y-click-events-have-key-events -->
+<!-- svelte-ignore a11y-no-static-element-interactions -->
+<div class="flex grow" on:click>
+ <img
+ class="h-auto w-auto object-contain"
+ width={page.image.width}
+ height={page.image.height}
+ src={src(page.image, 'full')}
+ alt={page.path}
+ />
+</div>
+
+<style>
+ div {
+ justify-content: var(--justify);
+ }
+</style>
diff --git a/frontend/src/lib/reader/components/CloseReaderButton.svelte b/frontend/src/lib/reader/components/CloseReaderButton.svelte
new file mode 100644
index 0000000..0c88323
--- /dev/null
+++ b/frontend/src/lib/reader/components/CloseReaderButton.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+ import { getReaderContext } from '$lib/Reader';
+ import { accelerator } from '$lib/Shortcuts';
+
+ const reader = getReaderContext();
+</script>
+
+<button
+ type="button"
+ class="btn floating"
+ title="Close reader"
+ on:click={() => {
+ $reader.visible = false;
+ $reader.sidebar = false;
+ }}
+ use:accelerator={'Escape'}
+>
+ <span class="icon-lg icon-[material-symbols--close]" />
+</button>
diff --git a/frontend/src/lib/reader/components/ReaderMenuButton.svelte b/frontend/src/lib/reader/components/ReaderMenuButton.svelte
new file mode 100644
index 0000000..aa20206
--- /dev/null
+++ b/frontend/src/lib/reader/components/ReaderMenuButton.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+ import { getReaderContext } from '$lib/Reader';
+ import { accelerator } from '$lib/Shortcuts';
+
+ const reader = getReaderContext();
+</script>
+
+<button
+ type="button"
+ class="btn floating invisible xl:visible"
+ title={`${$reader.sidebar ? 'Hide' : 'Show'} menu`}
+ on:click={() => ($reader.sidebar = !$reader.sidebar)}
+ use:accelerator={'z'}
+>
+ <span class="icon-lg icon-[material-symbols--dock-to-right]" />
+</button>
diff --git a/frontend/src/lib/scraper/ComicScrapeForm.svelte b/frontend/src/lib/scraper/ComicScrapeForm.svelte
new file mode 100644
index 0000000..30ad89b
--- /dev/null
+++ b/frontend/src/lib/scraper/ComicScrapeForm.svelte
@@ -0,0 +1,138 @@
+<script lang="ts">
+ import { upsertComics } from '$gql/Mutations';
+ import { comicScrapersQuery, scrapeComic } from '$gql/Queries';
+ import { isError } from '$gql/Utils';
+ import { OnMissing, type FullComicFragment } from '$gql/graphql';
+ import { ScrapedComicSelector, getScraperContext } from '$lib/Scraper';
+ import { toastError, toastFinally } from '$lib/Toasts';
+ import Select from '$lib/components/Select.svelte';
+ import Spinner from '$lib/components/Spinner.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import SelectorGroup from './components/SelectorGroup.svelte';
+ import SelectorItem from './components/SelectorItem.svelte';
+
+ let client = getContextClient();
+ const context = getScraperContext();
+
+ export let comic: FullComicFragment;
+ let createMissing = false;
+ let loading = false;
+
+ $: scrapersResult = comicScrapersQuery(client, { id: comic.id });
+ $: scrapers = $scrapersResult.data?.comicScrapers;
+
+ function scrape() {
+ loading = true;
+ scrapeComic(client, { id: comic.id, scraper: $context.scraper })
+ .then((result) => {
+ if (result.error) {
+ toastError(result.error.message);
+ return;
+ }
+
+ if (result.data) {
+ if (isError(result.data.scrapeComic)) {
+ toastError(result.data.scrapeComic.message);
+ return;
+ }
+
+ if (result.data.scrapeComic.__typename === 'ScrapeComicResult') {
+ $context.selector = new ScrapedComicSelector(result.data.scrapeComic.data, comic);
+ $context.warnings = result.data.scrapeComic.warnings;
+ }
+ }
+ })
+ .catch(toastFinally)
+ .finally(() => (loading = false));
+ }
+
+ function updateFromScrape(createMissing: boolean) {
+ if (!$context.selector) return;
+
+ upsertComics(client, {
+ ids: comic.id,
+ input: $context.selector.toInput(createMissing ? OnMissing.Create : OnMissing.Ignore)
+ })
+ .then(() => {
+ $context.selector = undefined;
+ $context.warnings = [];
+ })
+ .catch(toastFinally);
+ }
+</script>
+
+<div class="flex flex-col gap-4 text-sm">
+ {#if scrapers && scrapers.length === 0}
+ <h2 class="text-base">No scrapers available.</h2>
+ {:else}
+ <form on:submit|preventDefault={scrape}>
+ <div class="grid grid-cols-6 gap-2">
+ <div class="col-span-5">
+ <Select
+ id="scrapers"
+ options={scrapers}
+ placeholder={'Select scraper...'}
+ bind:value={$context.scraper}
+ />
+ </div>
+ <button type="submit" disabled={!$context.scraper} class="btn-blue">Scrape</button>
+ </div>
+ </form>
+ {/if}
+
+ {#if loading}
+ <Spinner />
+ {:else if $context.selector}
+ {#if $context.warnings.length > 0}
+ <div class="flex flex-col gap-2">
+ <h2 class="flex gap-1 border-b border-slate-700 text-base font-medium">Warnings</h2>
+ <ul class="ml-2 list-inside list-disc">
+ {#each $context.warnings as warning}
+ <li>{warning}</li>
+ {/each}
+ </ul>
+ </div>
+ {/if}
+ {#if !$context.selector.hasData()}
+ <h2 class="text-base">No data to merge.</h2>
+ {:else}
+ <div class="flex flex-col gap-2">
+ <h2 class="border-b border-slate-700 text-base font-medium">Results</h2>
+ <form on:submit|preventDefault={() => updateFromScrape(createMissing)}>
+ <div class="grid grid-cols-6 gap-4 pb-2">
+ <SelectorItem title="Title" selector={$context.selector.title} />
+ <SelectorItem title="Original Title" selector={$context.selector.originalTitle} />
+ <SelectorItem title="URL" selector={$context.selector.url} />
+ <SelectorItem title="Date" selector={$context.selector.date} --span="2" />
+ <SelectorItem title="Category" selector={$context.selector.category} --span="2" />
+ <SelectorItem title="Language" selector={$context.selector.language} --span="2" />
+ <SelectorItem title="Rating" selector={$context.selector.rating} --span="2" />
+ <SelectorItem title="Censorship" selector={$context.selector.censorship} --span="2" />
+ <SelectorItem title="Direction" selector={$context.selector.direction} --span="2" />
+ <SelectorItem title="Layout" selector={$context.selector.layout} --span="2" />
+ <SelectorGroup title="Artists" selectors={$context.selector.artists} />
+ <SelectorGroup title="Circles" selectors={$context.selector.circles} />
+ <SelectorGroup title="Characters" selectors={$context.selector.characters} />
+ <SelectorGroup title="Worlds" selectors={$context.selector.worlds} />
+ <SelectorGroup title="Tags" selectors={$context.selector.tags} />
+ </div>
+ <div class="flex flex-col gap-2">
+ <h2 class="border-b border-slate-700 text-base font-medium">Options</h2>
+ <div class="flex items-center gap-1">
+ <input
+ class="h-4 w-4"
+ type="checkbox"
+ id="create-missing"
+ bind:checked={createMissing}
+ />
+ <label class="shrink-0" for="create-missing">Create missing items</label>
+ </div>
+ </div>
+ <div class="flex gap-4">
+ <button type="submit" class="btn-blue">Merge</button>
+ </div>
+ </form>
+ </div>
+ {/if}
+ {/if}
+</div>
diff --git a/frontend/src/lib/scraper/components/SelectorButton.svelte b/frontend/src/lib/scraper/components/SelectorButton.svelte
new file mode 100644
index 0000000..b786f89
--- /dev/null
+++ b/frontend/src/lib/scraper/components/SelectorButton.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+ import { Selector } from '$lib/Scraper';
+
+ export let selector: Selector<string>;
+</script>
+
+<button
+ type="button"
+ class="ml-1 flex rounded-sm border-slate-700 bg-slate-900 hover:brightness-110"
+ on:click={() => (selector.keep = !selector.keep)}
+>
+ <div class="flex self-center pl-1">
+ {#if selector.keep}
+ <span class="icon-base icon-[material-symbols--check] text-green-400" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--close] text-red-400" />
+ {/if}
+ </div>
+ <p class:opacity-50={!selector.keep} class="p-1 text-left">
+ {selector}
+ </p>
+</button>
diff --git a/frontend/src/lib/scraper/components/SelectorGroup.svelte b/frontend/src/lib/scraper/components/SelectorGroup.svelte
new file mode 100644
index 0000000..ae7287a
--- /dev/null
+++ b/frontend/src/lib/scraper/components/SelectorGroup.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+ import { Selector } from '$lib/Scraper';
+ import SelectorButton from './SelectorButton.svelte';
+
+ export let title: string;
+ export let selectors: Selector<string>[];
+
+ function invert() {
+ for (let selector of selectors) {
+ selector.keep = !selector.keep;
+ }
+ selectors = selectors;
+ }
+</script>
+
+{#if selectors.length > 0}
+ <div class="group col-span-6 flex flex-col gap-1">
+ <div class="flex gap-2">
+ <h2>{title}</h2>
+ <button
+ type="button"
+ class="flex items-end opacity-0 brightness-75 transition-opacity hover:brightness-110 group-hover:opacity-100"
+ on:click={invert}
+ title="Invert selection"
+ >
+ <span class="icon-xs icon-[material-symbols--compare-arrows]"></span>
+ </button>
+ </div>
+ <div class="flex flex-wrap gap-y-1">
+ {#each selectors as selector}
+ <SelectorButton {selector} />
+ {/each}
+ </div>
+ </div>
+{/if}
diff --git a/frontend/src/lib/scraper/components/SelectorItem.svelte b/frontend/src/lib/scraper/components/SelectorItem.svelte
new file mode 100644
index 0000000..dd3f5b4
--- /dev/null
+++ b/frontend/src/lib/scraper/components/SelectorItem.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { Selector } from '$lib/Scraper';
+ import SelectorButton from './SelectorButton.svelte';
+
+ export let title: string;
+ export let selector: Selector<string> | undefined;
+</script>
+
+{#if selector}
+ <div class="flex flex-col gap-1">
+ <h2>{title}</h2>
+ <SelectorButton {selector} />
+ </div>
+{/if}
+
+<style>
+ :root {
+ --span: 6;
+ }
+
+ div {
+ grid-column: span var(--span) / span var(--span);
+ }
+</style>
diff --git a/frontend/src/lib/selection/Selectable.svelte b/frontend/src/lib/selection/Selectable.svelte
new file mode 100644
index 0000000..48b6ac7
--- /dev/null
+++ b/frontend/src/lib/selection/Selectable.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+
+ export let id: number;
+ export let index: number;
+
+ export let edit: ((id: number) => void) | undefined = undefined;
+
+ const selection = getSelectionContext();
+
+ $: selected = $selection.contains(id);
+
+ const handle = (event: MouseEvent) => {
+ if ($selection.active) {
+ $selection = $selection.update(index, event.shiftKey);
+ event.preventDefault();
+ } else if (edit) {
+ edit(id);
+ event.preventDefault();
+ }
+ };
+</script>
+
+<slot {handle} {selected} />
diff --git a/frontend/src/lib/selection/SelectionOverlay.svelte b/frontend/src/lib/selection/SelectionOverlay.svelte
new file mode 100644
index 0000000..04ff382
--- /dev/null
+++ b/frontend/src/lib/selection/SelectionOverlay.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+ export let selected: boolean;
+ export let position: 'top' | 'right' | 'left' | 'bottom';
+ export let centered = false;
+</script>
+
+{#if selected}
+ <div
+ class:items-center={centered}
+ class="{position} pointer-events-none absolute z-[1] flex bg-emerald-700/95"
+ >
+ <span class="icon-base icon-[material-symbols--check] text-[2rem]" />
+ </div>
+{/if}
+
+<style lang="postcss">
+ .top,
+ .bottom {
+ width: 100%;
+ }
+
+ .left,
+ .right {
+ height: 100%;
+ }
+
+ .bottom {
+ bottom: 0;
+ }
+
+ .right {
+ right: 0;
+ }
+</style>
diff --git a/frontend/src/lib/tabs/AddOverlay.svelte b/frontend/src/lib/tabs/AddOverlay.svelte
new file mode 100644
index 0000000..b1c98bf
--- /dev/null
+++ b/frontend/src/lib/tabs/AddOverlay.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+ import { updateComics } from '$gql/Mutations';
+ import { UpdateMode } from '$gql/graphql';
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import { fadeDefault } from '$lib/Transitions';
+ import { getContextClient } from '@urql/svelte';
+ import { fade } from 'svelte/transition';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let id: number;
+
+ function addPages() {
+ updateComics(client, {
+ ids: id,
+ input: { pages: { ids: $selection.ids, options: { mode: UpdateMode.Add } } }
+ })
+ .then(() => ($selection = $selection.none()))
+ .catch(toastFinally);
+ }
+</script>
+
+{#if $selection.size > 0}
+ <div class="absolute left-1 top-1" transition:fade={fadeDefault}>
+ <button
+ type="button"
+ class="btn-blue rounded-full shadow-sm shadow-black"
+ title="Add to this comic"
+ on:click|preventDefault={addPages}
+ >
+ <span class="icon-base icon-[material-symbols--note-add]" />
+ </button>
+ </div>
+{/if}
diff --git a/frontend/src/lib/tabs/ArchiveDelete.svelte b/frontend/src/lib/tabs/ArchiveDelete.svelte
new file mode 100644
index 0000000..b0e3c58
--- /dev/null
+++ b/frontend/src/lib/tabs/ArchiveDelete.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import { goto } from '$app/navigation';
+ import { deleteArchives } from '$gql/Mutations';
+ import type { FullArchiveFragment } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import { getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+
+ export let archive: FullArchiveFragment;
+
+ function deleteArchive() {
+ confirmDeletion('Archive', archive.name, () => {
+ deleteArchives(client, { ids: archive.id })
+ .then(() => goto('/archives/'))
+ .catch(toastFinally);
+ });
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ <div>
+ <p>
+ Deleting this archive will remove the
+ <span class="cursor-help font-medium underline" title={archive.path}>archive file</span> on disk.
+ </p>
+ {#if archive.comics.length > 0}
+ <p>The following comics will also be deleted:</p>
+ <ul class="ml-8 list-disc">
+ {#each archive.comics as comic}
+ <li><a href="/comics/{comic.id}" class="underline">{comic.title}</a></li>
+ {/each}
+ </ul>
+ {/if}
+ <p class="mt-2 font-medium">This action is irrevocable.</p>
+ </div>
+ <div class="flex">
+ <DeleteButton prominent on:click={deleteArchive} />
+ </div>
+</div>
diff --git a/frontend/src/lib/tabs/ArchiveDetails.svelte b/frontend/src/lib/tabs/ArchiveDetails.svelte
new file mode 100644
index 0000000..9554557
--- /dev/null
+++ b/frontend/src/lib/tabs/ArchiveDetails.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+ import type { FullArchiveFragment } from '$gql/graphql';
+ import { formatListSize, joinText } from '$lib/Utils';
+ import Card, { comicCard } from '$lib/components/Card.svelte';
+ import ComicPills from '$lib/pills/ComicPills.svelte';
+ import { formatDistance, formatISO9075 } from 'date-fns';
+ import { filesize } from 'filesize';
+ import Header from './DetailsHeader.svelte';
+ import Section from './DetailsSection.svelte';
+
+ export let archive: FullArchiveFragment;
+
+ const now = Date.now();
+ const modifiedDate = new Date(archive.mtime);
+ const createdDate = new Date(archive.createdAt);
+
+ const title = joinText(['Archive', formatListSize('image', archive.pageCount)]);
+</script>
+
+<div class="flex flex-col gap-4 text-sm">
+ <Header {title} />
+ <div class="grid grid-cols-3 gap-4">
+ <Section title="Size">
+ <span>{filesize(archive.size, { base: 2 })}</span>
+ </Section>
+ <Section title="Created">
+ <span title={formatISO9075(createdDate)}>
+ {formatDistance(createdDate, now, { addSuffix: true })}
+ </span>
+ </Section>
+ <Section title="File last modified">
+ <span title={formatISO9075(modifiedDate)}>
+ {formatDistance(modifiedDate, now, { addSuffix: true })}
+ </span>
+ </Section>
+ </div>
+
+ {#if archive.comics.length > 0}
+ <div class="flex flex-col gap-1">
+ <h2 class="text-base font-medium">Comics</h2>
+ <div class="flex shrink-0 flex-col gap-4">
+ {#each archive.comics as comic}
+ <Card compact {...comicCard(comic)}>
+ <ComicPills {comic} />
+ </Card>
+ {/each}
+ </div>
+ </div>
+ {/if}
+</div>
diff --git a/frontend/src/lib/tabs/ArchiveEdit.svelte b/frontend/src/lib/tabs/ArchiveEdit.svelte
new file mode 100644
index 0000000..80efaed
--- /dev/null
+++ b/frontend/src/lib/tabs/ArchiveEdit.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import { addComic, updateArchives } from '$gql/Mutations';
+ import { type FullArchiveFragment } from '$gql/graphql';
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Card, { comicCard } from '$lib/components/Card.svelte';
+ import OrganizedButton from '$lib/components/OrganizedButton.svelte';
+ import ComicPills from '$lib/pills/ComicPills.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import AddOverlay from './AddOverlay.svelte';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let archive: FullArchiveFragment;
+
+ function addNew() {
+ addComic(client, {
+ input: {
+ archive: { id: archive.id },
+ title: archive.name,
+ pages: { ids: $selection.ids },
+ cover: { id: archive.pages[$selection.indices.toSorted((a, b) => a - b)[0]].id }
+ }
+ })
+ .then((mutatation) => {
+ const data = mutatation.addComic;
+ if (data.__typename === 'AddComicSuccess' && !data.archivePagesRemaining) {
+ $selection = $selection.clear();
+ } else {
+ $selection = $selection.none();
+ }
+ })
+ .catch(toastFinally);
+ }
+
+ function toggleOrganized() {
+ updateArchives(client, { ids: archive.id, input: { organized: !archive.organized } }).catch(
+ toastFinally
+ );
+ }
+</script>
+
+<div class="flex flex-col gap-4">
+ <div class="flex gap-2 text-sm">
+ <SelectionControls page>
+ <AddButton title="Add Comic from selected" on:click={addNew} />
+ </SelectionControls>
+ <div class="grow" />
+ <OrganizedButton organized={archive.organized} on:click={toggleOrganized} />
+ </div>
+
+ {#if archive.comics.length > 0}
+ <div class="flex flex-col gap-1">
+ <h2 class="text-base font-medium">Comics</h2>
+ <div class="flex shrink-0 flex-col gap-4">
+ {#each archive.comics as comic}
+ <Card compact {...comicCard(comic)}>
+ <AddOverlay slot="overlay" id={comic.id} />
+ <ComicPills {comic} />
+ </Card>
+ {/each}
+ </div>
+ </div>
+ {/if}
+</div>
diff --git a/frontend/src/lib/tabs/ComicDelete.svelte b/frontend/src/lib/tabs/ComicDelete.svelte
new file mode 100644
index 0000000..a10f6b2
--- /dev/null
+++ b/frontend/src/lib/tabs/ComicDelete.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+ import { goto } from '$app/navigation';
+ import { deleteComics } from '$gql/Mutations';
+ import type { FullComicFragment } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import { getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+
+ export let comic: FullComicFragment;
+
+ function deleteComic() {
+ confirmDeletion('Comic', comic.title, () => {
+ deleteComics(client, { ids: comic.id })
+ .then(() => goto('/comics/'))
+ .catch(toastFinally);
+ });
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ <div>
+ <p>
+ Deleting this comic will make all of its pages available again for allocation. All of its
+ metadata will be lost.
+ </p>
+ <p class="mt-2 font-medium">This action is irrevocable.</p>
+ </div>
+ <div class="flex">
+ <DeleteButton prominent on:click={deleteComic} />
+ </div>
+</div>
diff --git a/frontend/src/lib/tabs/ComicDetails.svelte b/frontend/src/lib/tabs/ComicDetails.svelte
new file mode 100644
index 0000000..0a131af
--- /dev/null
+++ b/frontend/src/lib/tabs/ComicDetails.svelte
@@ -0,0 +1,121 @@
+<script lang="ts">
+ import type { ComicFilter, FullComicFragment } from '$gql/graphql';
+ import { CategoryLabel, CensorshipLabel, LanguageLabel, RatingLabel } from '$lib/Enums';
+ import { href } from '$lib/Navigation';
+ import { formatListSize, joinText } from '$lib/Utils';
+ import AssociationPill from '$lib/pills/AssociationPill.svelte';
+ import TagPill from '$lib/pills/TagPill.svelte';
+ import { formatDistance, formatISO9075 } from 'date-fns';
+ import Header from './DetailsHeader.svelte';
+ import Section from './DetailsSection.svelte';
+
+ export let comic: FullComicFragment;
+
+ const now = Date.now();
+ const updatedDate = new Date(comic.updatedAt);
+ const createdDate = new Date(comic.createdAt);
+
+ const title = joinText([
+ comic.category ? CategoryLabel[comic.category] : '',
+ formatListSize('page', comic.pages.length)
+ ]);
+
+ function filterFor(filter: keyof ComicFilter, id: number | string) {
+ return href('comics', { filter: { include: { [filter]: { all: [id] } } } });
+ }
+</script>
+
+<div class="flex flex-col gap-4 text-sm">
+ <Header {title}>
+ {#if comic.url}
+ <a href={comic.url} target="_blank" rel="noreferrer" class="btn-slate" title="Open URL">
+ <span class="icon-base icon-[material-symbols--link]" />
+ </a>
+ {/if}
+ <a href={`/archives/${comic.archive.id}`} class="btn-slate" title="Go to Archive">
+ <span class="icon-base icon-[material-symbols--folder-zip]" />
+ </a>
+ </Header>
+
+ <div class="grid grid-cols-3 gap-4">
+ {#if comic.language}
+ <Section title="Language">
+ <span>{LanguageLabel[comic.language]}</span>
+ </Section>
+ {/if}
+ {#if comic.censorship}
+ <Section title="Censorship">
+ <span>{CensorshipLabel[comic.censorship]}</span>
+ </Section>
+ {/if}
+ {#if comic.rating}
+ <Section title="Rating">
+ <span>{RatingLabel[comic.rating]}</span>
+ </Section>
+ {/if}
+ </div>
+
+ <div class="grid grid-cols-3 gap-4">
+ {#if comic.date}
+ <Section title="Released">
+ <span>{formatISO9075(new Date(comic.date), { representation: 'date' })}</span>
+ </Section>
+ {/if}
+ <Section title="Created">
+ <span title={formatISO9075(createdDate)}>
+ {formatDistance(createdDate, now, { addSuffix: true })}
+ </span>
+ </Section>
+ <Section title="Updated">
+ <span title={formatISO9075(updatedDate)}>
+ {formatDistance(updatedDate, now, { addSuffix: true })}
+ </span>
+ </Section>
+ </div>
+
+ {#if comic.artists.length}
+ <Section title="Artists">
+ {#each comic.artists as { id, name } (id)}
+ <a href={filterFor('artists', id)}>
+ <AssociationPill {name} type="artist" />
+ </a>
+ {/each}
+ </Section>
+ {/if}
+ {#if comic.circles.length}
+ <Section title="Circles">
+ {#each comic.circles as { id, name } (id)}
+ <a href={filterFor('circles', id)}>
+ <AssociationPill {name} type="circle" />
+ </a>
+ {/each}
+ </Section>
+ {/if}
+ {#if comic.characters.length}
+ <Section title="Characters">
+ {#each comic.characters as { id, name } (id)}
+ <a href={filterFor('characters', id)}>
+ <AssociationPill {name} type="character" />
+ </a>
+ {/each}
+ </Section>
+ {/if}
+ {#if comic.worlds.length}
+ <Section title="Worlds">
+ {#each comic.worlds as { id, name } (id)}
+ <a href={filterFor('worlds', id)}>
+ <AssociationPill {name} type="world" />
+ </a>
+ {/each}
+ </Section>
+ {/if}
+ {#if comic.tags.length}
+ <Section title="Tags">
+ {#each comic.tags as { id, name, description } (id)}
+ <a href={filterFor('tags', id)}>
+ <TagPill {name} {description} />
+ </a>
+ {/each}
+ </Section>
+ {/if}
+</div>
diff --git a/frontend/src/lib/tabs/DetailsHeader.svelte b/frontend/src/lib/tabs/DetailsHeader.svelte
new file mode 100644
index 0000000..f980f75
--- /dev/null
+++ b/frontend/src/lib/tabs/DetailsHeader.svelte
@@ -0,0 +1,11 @@
+<script lang="ts">
+ export let title: string;
+</script>
+
+<div class="flex items-center gap-2">
+ <h2 class="flex text-base">
+ {title}
+ </h2>
+ <div class="grow"></div>
+ <slot />
+</div>
diff --git a/frontend/src/lib/tabs/DetailsSection.svelte b/frontend/src/lib/tabs/DetailsSection.svelte
new file mode 100644
index 0000000..9a6ad51
--- /dev/null
+++ b/frontend/src/lib/tabs/DetailsSection.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ export let title: string;
+</script>
+
+<section class="flex flex-col gap-1">
+ <h2 class="text-base font-medium">{title}</h2>
+ <div class="flex flex-wrap gap-1 text-gray-300">
+ <slot />
+ </div>
+</section>
diff --git a/frontend/src/lib/tabs/Tab.svelte b/frontend/src/lib/tabs/Tab.svelte
new file mode 100644
index 0000000..0a6be57
--- /dev/null
+++ b/frontend/src/lib/tabs/Tab.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+ import { getTabContext } from '$lib/Tabs';
+ import { fadeDefault } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+
+ const context = getTabContext();
+ export let id: string;
+</script>
+
+{#if $context.current === id}
+ <div class="h-full overflow-auto py-2 pe-3" in:fade={fadeDefault}>
+ <slot />
+ </div>
+{/if}
diff --git a/frontend/src/lib/tabs/Tabs.svelte b/frontend/src/lib/tabs/Tabs.svelte
new file mode 100644
index 0000000..09cdbdd
--- /dev/null
+++ b/frontend/src/lib/tabs/Tabs.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ import { getTabContext } from '$lib/Tabs';
+ import { fadeFast } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+
+ const context = getTabContext();
+</script>
+
+<div class="flex h-full max-h-full flex-col">
+ <nav>
+ <ul class="me-3 flex border-b-2 border-slate-700 text-sm">
+ {#each Object.entries($context.tabs) as [id, { title, badge }]}
+ <li class="-mb-0.5">
+ <button
+ type="button"
+ class:active={$context.current === id}
+ class="relative flex gap-1 p-1 px-3 hover:border-b-2 hover:border-slate-200"
+ on:click={() => ($context.current = id)}
+ >
+ {#if badge}
+ <div
+ class="absolute right-0 top-1 h-2 w-2 rounded-full bg-emerald-400"
+ title="There are pending changes"
+ transition:fade={fadeFast}
+ />
+ {/if}
+ <span>{title}</span>
+ </button>
+ </li>
+ {/each}
+ </ul>
+ </nav>
+ <slot />
+</div>
+
+<style lang="postcss">
+ button.active {
+ @apply border-b-2 border-indigo-500;
+ }
+</style>
diff --git a/frontend/src/lib/toolbar/DeleteSelection.svelte b/frontend/src/lib/toolbar/DeleteSelection.svelte
new file mode 100644
index 0000000..7459a87
--- /dev/null
+++ b/frontend/src/lib/toolbar/DeleteSelection.svelte
@@ -0,0 +1,26 @@
+<script lang="ts">
+ import type { DeleteMutation } from '$gql/Mutations';
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import { getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let mutation: DeleteMutation;
+ export let warning: string | undefined = undefined;
+
+ function remove() {
+ const mutate = () => {
+ mutation(client, { ids: $selection.ids })
+ .then(() => ($selection = $selection.clear()))
+ .catch(toastFinally);
+ };
+
+ confirmDeletion($selection.typename, $selection.names, mutate, warning);
+ }
+</script>
+
+<DeleteButton on:click={remove} />
diff --git a/frontend/src/lib/toolbar/EditSelection.svelte b/frontend/src/lib/toolbar/EditSelection.svelte
new file mode 100644
index 0000000..50e6656
--- /dev/null
+++ b/frontend/src/lib/toolbar/EditSelection.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+ import { accelerator } from '$lib/Shortcuts';
+ import type { SvelteComponent } from 'svelte';
+ import { openModal } from 'svelte-modals';
+
+ const selection = getSelectionContext();
+
+ export let dialog: typeof SvelteComponent<{
+ isOpen: boolean;
+ ids: number[];
+ }>;
+
+ function edit() {
+ openModal(dialog, {
+ ids: $selection.ids
+ });
+ }
+</script>
+
+<button
+ type="button"
+ class="btn-slate hover:bg-blue-700"
+ title="Edit selection"
+ on:click={edit}
+ use:accelerator={'e'}
+>
+ <span class="icon-base icon-[material-symbols--edit]" />
+</button>
diff --git a/frontend/src/lib/toolbar/FilterBookmarked.svelte b/frontend/src/lib/toolbar/FilterBookmarked.svelte
new file mode 100644
index 0000000..bcbe295
--- /dev/null
+++ b/frontend/src/lib/toolbar/FilterBookmarked.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { ComicFilterContext, cycleBooleanFilter, getFilterContext } from '$lib/Filter';
+ import { accelerator } from '$lib/Shortcuts';
+ import Bookmark from '$lib/icons/Bookmark.svelte';
+
+ const filter = getFilterContext<ComicFilterContext>();
+ $: bookmarked = $filter.include.controls.bookmarked.value;
+
+ const toggle = () => {
+ $filter.include.controls.bookmarked.value = cycleBooleanFilter(bookmarked, false);
+ $filter.apply($page.url.searchParams);
+ };
+</script>
+
+<button
+ class:toggled={bookmarked}
+ class="btn-slate"
+ title="Filter bookmarked"
+ on:click={toggle}
+ use:accelerator={'b'}
+>
+ <Bookmark {bookmarked} />
+</button>
diff --git a/frontend/src/lib/toolbar/FilterFavourites.svelte b/frontend/src/lib/toolbar/FilterFavourites.svelte
new file mode 100644
index 0000000..6591cef
--- /dev/null
+++ b/frontend/src/lib/toolbar/FilterFavourites.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { ComicFilterContext, cycleBooleanFilter, getFilterContext } from '$lib/Filter';
+ import { accelerator } from '$lib/Shortcuts';
+ import Star from '$lib/icons/Star.svelte';
+
+ const filter = getFilterContext<ComicFilterContext>();
+ $: favourite = $filter.include.controls.favourite.value;
+
+ const toggle = () => {
+ $filter.include.controls.favourite.value = cycleBooleanFilter(favourite, false);
+ $filter.apply($page.url.searchParams);
+ };
+</script>
+
+<button
+ class:toggled={favourite}
+ class="btn-slate"
+ title="Filter favourites"
+ on:click={toggle}
+ use:accelerator={'f'}
+>
+ <Star {favourite} />
+</button>
diff --git a/frontend/src/lib/toolbar/FilterOrganized.svelte b/frontend/src/lib/toolbar/FilterOrganized.svelte
new file mode 100644
index 0000000..754e663
--- /dev/null
+++ b/frontend/src/lib/toolbar/FilterOrganized.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import {
+ ArchiveFilterContext,
+ ComicFilterContext,
+ cycleBooleanFilter,
+ getFilterContext
+ } from '$lib/Filter';
+ import { accelerator } from '$lib/Shortcuts';
+ import Organized from '$lib/icons/Organized.svelte';
+
+ const filter = getFilterContext<ArchiveFilterContext | ComicFilterContext>();
+ $: organized = $filter.include.controls.organized.value;
+
+ const toggle = () => {
+ $filter.include.controls.organized.value = cycleBooleanFilter(organized);
+ $filter.apply($page.url.searchParams);
+ };
+</script>
+
+<button
+ type="button"
+ class:toggled={organized !== undefined}
+ class="btn-slate"
+ title="Filter organized"
+ on:click={toggle}
+ use:accelerator={'o'}
+>
+ <Organized tristate {organized} />
+</button>
diff --git a/frontend/src/lib/toolbar/MarkBookmark.svelte b/frontend/src/lib/toolbar/MarkBookmark.svelte
new file mode 100644
index 0000000..792b84f
--- /dev/null
+++ b/frontend/src/lib/toolbar/MarkBookmark.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import Bookmark from '$lib/icons/Bookmark.svelte';
+ import { Client, getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let mutation: (
+ client: Client,
+ args: { ids: number[]; input: { bookmarked: boolean } }
+ ) => Promise<unknown>;
+
+ function mutate(bookmarked: boolean) {
+ mutation(client, { ids: $selection.ids, input: { bookmarked } }).catch(toastFinally);
+ }
+</script>
+
+<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(true)}>
+ <Bookmark bookmarked={true} />
+ <span>Bookmark</span>
+</button>
+<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(false)}>
+ <Bookmark bookmarked={false} />
+ <span>Unbookmark</span>
+</button>
diff --git a/frontend/src/lib/toolbar/MarkFavourite.svelte b/frontend/src/lib/toolbar/MarkFavourite.svelte
new file mode 100644
index 0000000..42eaa39
--- /dev/null
+++ b/frontend/src/lib/toolbar/MarkFavourite.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import Star from '$lib/icons/Star.svelte';
+ import { Client, getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let mutation: (
+ client: Client,
+ args: { ids: number[]; input: { favourite: boolean } }
+ ) => Promise<unknown>;
+
+ function mutate(favourite: boolean) {
+ mutation(client, { ids: $selection.ids, input: { favourite } }).catch(toastFinally);
+ }
+</script>
+
+<button type="button" class="btn-slate justify-start gap-1" on:click={() => mutate(true)}>
+ <Star favourite={true} />
+ <span>Favourite</span>
+</button>
+<button type="button" class="btn-slate justify-start gap-1" on:click={() => mutate(false)}>
+ <Star favourite={false} />
+ <span>Unfavourite</span>
+</button>
diff --git a/frontend/src/lib/toolbar/MarkOrganized.svelte b/frontend/src/lib/toolbar/MarkOrganized.svelte
new file mode 100644
index 0000000..4dc3a83
--- /dev/null
+++ b/frontend/src/lib/toolbar/MarkOrganized.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import Organized from '$lib/icons/Organized.svelte';
+ import { Client, getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let mutation: (
+ client: Client,
+ args: { ids: number[]; input: { organized: boolean } }
+ ) => Promise<unknown>;
+
+ function mutate(organized: boolean) {
+ mutation(client, { ids: $selection.ids, input: { organized } }).catch(toastFinally);
+ }
+</script>
+
+<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(true)}>
+ <Organized tristate organized={true} />
+ <span>Organized</span>
+</button>
+<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(false)}>
+ <Organized dim tristate organized={false} />
+ <span>Unorganized</span>
+</button>
diff --git a/frontend/src/lib/toolbar/MarkSelection.svelte b/frontend/src/lib/toolbar/MarkSelection.svelte
new file mode 100644
index 0000000..27eb2c7
--- /dev/null
+++ b/frontend/src/lib/toolbar/MarkSelection.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import Dropdown from '$lib/components/Dropdown.svelte';
+
+ let visible = false;
+ let button: HTMLElement;
+</script>
+
+<div class="relative">
+ <button
+ type="button"
+ class="btn-slate rounded-inherit relative hover:bg-blue-700 [&:not(:only-child)]:bg-blue-700"
+ title="Set flag..."
+ bind:this={button}
+ on:click={() => (visible = !visible)}
+ >
+ <span class="icon-base icon-[material-symbols--flag] pointer-events-none" />
+ </button>
+
+ <Dropdown parent={button} bind:visible>
+ <div class="grid grid-cols-[min-content_min-content] gap-1">
+ <slot />
+ </div>
+ </Dropdown>
+</div>
diff --git a/frontend/src/lib/toolbar/Search.svelte b/frontend/src/lib/toolbar/Search.svelte
new file mode 100644
index 0000000..f033258
--- /dev/null
+++ b/frontend/src/lib/toolbar/Search.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { debounce } from '$lib/Actions';
+ import { BasicFilterContext, getFilterContext } from '$lib/Filter';
+ import { accelerator } from '$lib/Shortcuts';
+
+ const filter = getFilterContext<BasicFilterContext>();
+
+ export let name: string;
+ export let field: string;
+</script>
+
+<input
+ type="text"
+ size={25}
+ class="btn-slate w-min"
+ placeholder="Search {name}..."
+ bind:value={field}
+ use:debounce={{ callback: () => $filter.apply($page.url.searchParams) }}
+ use:accelerator={'F'}
+/>
diff --git a/frontend/src/lib/toolbar/SelectItems.svelte b/frontend/src/lib/toolbar/SelectItems.svelte
new file mode 100644
index 0000000..7ff339e
--- /dev/null
+++ b/frontend/src/lib/toolbar/SelectItems.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { getPaginationContext } from '$lib/Pagination';
+
+ const pagination = getPaginationContext();
+
+ $: values = new Set([24, 48, 72, 90, 120, 150, 180, $pagination.items].sort((a, b) => a - b));
+</script>
+
+<select
+ class="btn-slate"
+ bind:value={$pagination.items}
+ on:change={() => $pagination.apply($page.url.searchParams)}
+ title="Limit displayed items to..."
+>
+ {#each values as value}
+ <option {value}>{value}</option>
+ {/each}
+</select>
diff --git a/frontend/src/lib/toolbar/SelectSort.svelte b/frontend/src/lib/toolbar/SelectSort.svelte
new file mode 100644
index 0000000..fdcb057
--- /dev/null
+++ b/frontend/src/lib/toolbar/SelectSort.svelte
@@ -0,0 +1,61 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { SortDirection } from '$gql/graphql';
+
+ import { getSortContext } from '$lib/Sort';
+ import { slideXFast } from '$lib/Transitions';
+ import { getRandomInt } from '$lib/Utils';
+ import { slide } from 'svelte/transition';
+
+ const sort = getSortContext();
+
+ function toggle() {
+ if ($sort.direction === SortDirection.Ascending) {
+ $sort.direction = SortDirection.Descending;
+ } else {
+ $sort.direction = SortDirection.Ascending;
+ }
+
+ apply();
+ }
+
+ function apply() {
+ if ($sort.on === 'RANDOM' && $sort.seed === undefined) {
+ $sort.seed = getRandomInt(0, 1000000000);
+ }
+ $sort.apply($page.url.searchParams);
+ }
+
+ function reshuffle() {
+ $sort.seed = undefined;
+ apply();
+ }
+</script>
+
+<div class="rounded-group flex flex-row">
+ <select class="btn-slate" bind:value={$sort.on} on:change={apply} title="Sort on...">
+ {#each Object.entries($sort.labels) as [value, label]}
+ <option {value}>{label}</option>
+ {/each}
+ </select>
+ <button type="button" class="btn-slate" title="Toggle sort direction" on:click={toggle}>
+ {#if $sort.direction === SortDirection.Ascending}
+ <span class="icon-base icon-[material-symbols--sort] -scale-y-100" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--sort]" />
+ {/if}
+ </button>
+ {#if $sort.on === 'RANDOM'}
+ <button
+ type="button"
+ class="btn-slate"
+ title="Reshuffle"
+ on:click={reshuffle}
+ transition:slide={slideXFast}
+ >
+ <div class="flex">
+ <span class="icon-base icon-[material-symbols--shuffle]" />
+ </div>
+ </button>
+ {/if}
+</div>
diff --git a/frontend/src/lib/toolbar/SelectionControls.svelte b/frontend/src/lib/toolbar/SelectionControls.svelte
new file mode 100644
index 0000000..4d309df
--- /dev/null
+++ b/frontend/src/lib/toolbar/SelectionControls.svelte
@@ -0,0 +1,57 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+ import { accelerator } from '$lib/Shortcuts';
+ import { fadeDefault, slideXFast } from '$lib/Transitions';
+ import Badge from '$lib/components/Badge.svelte';
+ import { onDestroy } from 'svelte';
+ import { fade, slide } from 'svelte/transition';
+
+ const selection = getSelectionContext();
+
+ export let page = false;
+
+ const toggle = () => ($selection = $selection.toggle());
+ const all = () => ($selection = $selection.all());
+ const none = () => ($selection = $selection.none());
+
+ onDestroy(() => ($selection = $selection.clear()));
+</script>
+
+<div class="rounded-group flex">
+ <button
+ type="button"
+ class="btn-slate relative"
+ class:toggled={$selection.active}
+ title={`${$selection.active ? 'Exit' : 'Enter'} ${page ? 'page ' : ' '}selection mode`}
+ on:click={toggle}
+ use:accelerator={'s'}
+ >
+ {#if $selection.active}
+ {#if page}
+ <span class="icon-base icon-[material-symbols--edit-document]" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--remove-selection]" />
+ {/if}
+ {:else if page}
+ <span class="icon-base icon-[material-symbols--edit-document-outline]" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--select]" />
+ {/if}
+ <Badge number={$selection.size} />
+ </button>
+ {#if $selection.active}
+ <div class="rounded-group-end flex" transition:slide={slideXFast}>
+ <button type="button" class="btn-slate" title="Select all" on:click={all}>
+ <span class="icon-base icon-[material-symbols--select-all]" />
+ </button>
+ <button type="button" class="btn-slate" title="Select none" on:click={none}>
+ <span class="icon-base icon-[material-symbols--deselect]" />
+ </button>
+ </div>
+ {/if}
+</div>
+{#if $selection.size > 0}
+ <div class="rounded-group flex" transition:fade={fadeDefault}>
+ <slot />
+ </div>
+{/if}
diff --git a/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte b/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte
new file mode 100644
index 0000000..2e7869f
--- /dev/null
+++ b/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { getFilterContext } from '$lib/Filter';
+ import { navigate } from '$lib/Navigation';
+ import { slideXFast } from '$lib/Transitions';
+ import Badge from '$lib/components/Badge.svelte';
+ import { slide } from 'svelte/transition';
+ import { getToolbarContext } from './Toolbar.svelte';
+
+ const toolbar = getToolbarContext();
+ const filter = getFilterContext();
+</script>
+
+<div class="rounded-group flex">
+ <button
+ class:toggled={$toolbar.expand}
+ class="btn-slate relative"
+ title={`${$toolbar.expand ? 'Hide' : 'Show'} filters`}
+ on:click={() => ($toolbar.expand = !$toolbar.expand)}
+ >
+ {#if $toolbar.expand}
+ <span class="icon-base icon-[material-symbols--filter-alt]" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--filter-alt-outline]" />
+ {/if}
+ <Badge number={$filter.include.size + $filter.exclude.size} />
+ </button>
+ {#if $filter.include.size + $filter.exclude.size > 0}
+ <button
+ class="btn-slate relative hover:bg-rose-700"
+ on:click={() => navigate({ filter: {} }, $page.url.searchParams)}
+ transition:slide={slideXFast}
+ title="Reset filters"
+ >
+ <div class="flex">
+ <span class="icon-base icon-[material-symbols--filter-alt-off]" />
+ </div>
+ </button>
+ {/if}
+</div>
diff --git a/frontend/src/lib/toolbar/Toolbar.svelte b/frontend/src/lib/toolbar/Toolbar.svelte
new file mode 100644
index 0000000..e87d731
--- /dev/null
+++ b/frontend/src/lib/toolbar/Toolbar.svelte
@@ -0,0 +1,42 @@
+<script lang="ts" context="module">
+ import { writable, type Writable } from 'svelte/store';
+
+ interface ToolbarContext {
+ expand: boolean;
+ }
+
+ function initToolbarContext() {
+ return setContext<Writable<ToolbarContext>>('toolbar', writable({ expand: false }));
+ }
+
+ export function getToolbarContext() {
+ return getContext<Writable<ToolbarContext>>('toolbar');
+ }
+</script>
+
+<script lang="ts">
+ import { getContext, setContext } from 'svelte';
+
+ const toolbar = initToolbarContext();
+</script>
+
+<div class="flex flex-col">
+ <div
+ class="flex flex-row flex-wrap gap-4 text-sm xl:grid xl:grid-flow-col xl:grid-cols-[1fr_2fr_1fr]"
+ >
+ <div class="flex flex-row justify-start gap-2">
+ <slot name="start" />
+ </div>
+ <div class="flex flex-row flex-wrap justify-start gap-2 xl:flex-nowrap xl:justify-center">
+ <slot name="center" />
+ </div>
+ <div class="flex flex-row justify-end gap-2">
+ <slot name="end" />
+ </div>
+ </div>
+ {#if $toolbar.expand}
+ <div class="mt-4">
+ <slot />
+ </div>
+ {/if}
+</div>
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
new file mode 100644
index 0000000..0eefed1
--- /dev/null
+++ b/frontend/src/routes/+layout.svelte
@@ -0,0 +1,95 @@
+<script lang="ts">
+ import { addShortcut, handleShortcuts } from '$lib/Shortcuts';
+ import { fadeDefault } from '$lib/Transitions';
+ import AddArtist from '$lib/dialogs/AddArtist.svelte';
+ import AddCharacter from '$lib/dialogs/AddCharacter.svelte';
+ import AddCircle from '$lib/dialogs/AddCircle.svelte';
+ import AddNamespace from '$lib/dialogs/AddNamespace.svelte';
+ import AddTag from '$lib/dialogs/AddTag.svelte';
+ import AddWorld from '$lib/dialogs/AddWorld.svelte';
+ import Link from '$lib/navigation/Link.svelte';
+ import Navigation from '$lib/navigation/Navigation.svelte';
+ import { cacheExchange, fetchExchange, initContextClient } from '@urql/svelte';
+ import { SvelteToast } from '@zerodevx/svelte-toast';
+ import { Modals, closeModal, openModal } from 'svelte-modals';
+ import { fade } from 'svelte/transition';
+ import '../app.css';
+
+ initContextClient({
+ url: import.meta.env.VITE_GQL_ENDPOINT ?? '/graphql',
+ exchanges: [cacheExchange, fetchExchange]
+ });
+
+ addShortcut('na', () => openModal(AddArtist));
+ addShortcut('nh', () => openModal(AddCharacter));
+ addShortcut('ni', () => openModal(AddCircle));
+ addShortcut('nn', () => openModal(AddNamespace));
+ addShortcut('nt', () => openModal(AddTag));
+ addShortcut('nw', () => openModal(AddWorld));
+
+ function keydown(event: KeyboardEvent) {
+ handleShortcuts(event);
+ }
+</script>
+
+<svelte:document on:keydown={keydown} />
+
+<Navigation>
+ <Link matchExact href="/" title="Home" accel="go">
+ <span class="icon-base icon-[material-symbols--home]" />
+ </Link>
+ <Link href="/comics/" title="Comics" accel="gc">
+ <span class="icon-base icon-[material-symbols--menu-book]" />
+ </Link>
+ <Link href="/namespaces/" title="Namespaces" accel="gn">
+ <span class="icon-base icon-[material-symbols--inbox]" />
+ </Link>
+ <Link href="/tags/" title="Tags" accel="gt">
+ <span class="icon-base icon-[material-symbols--label]" />
+ </Link>
+ <Link href="/artists/" title="Artists" accel="ga">
+ <span class="icon-base icon-[material-symbols--person]" />
+ </Link>
+ <Link href="/circles/" title="Circles" accel="gi">
+ <span class="icon-base icon-[material-symbols--group]" />
+ </Link>
+ <Link href="/characters/" title="Characters" accel="gh">
+ <span class="icon-base icon-[material-symbols--face]" />
+ </Link>
+ <Link href="/worlds/" title="Worlds" accel="gw">
+ <span class="icon-base icon-[material-symbols--public]" />
+ </Link>
+ <Link href="/archives/" title="Archives" accel="gz">
+ <span class="icon-base icon-[material-symbols--folder-zip]" />
+ </Link>
+ <div class="mb-auto" />
+ <Link href="/help/" title="Help" accel="?" target="_blank">
+ <span class="icon-base icon-[material-symbols--help]" />
+ </Link>
+</Navigation>
+
+<div class="min-w-[360px] overflow-auto p-4">
+ <slot />
+</div>
+
+<Modals>
+ <!-- svelte-ignore a11y-no-static-element-interactions -->
+ <!-- svelte-ignore a11y-click-events-have-key-events -->
+ <div
+ slot="backdrop"
+ on:click={closeModal}
+ transition:fade={fadeDefault}
+ class="fixed bottom-0 left-0 right-0 top-0 z-20 bg-stone-800/80"
+ />
+</Modals>
+
+<SvelteToast options={{ reversed: true, intro: { y: 192 } }} />
+
+<style>
+ :root {
+ --toastBarHeight: 0;
+ --toastContainerTop: auto;
+ --toastContainerLeft: 4rem;
+ --toastContainerBottom: 1rem;
+ }
+</style>
diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts
new file mode 100644
index 0000000..a3d1578
--- /dev/null
+++ b/frontend/src/routes/+layout.ts
@@ -0,0 +1 @@
+export const ssr = false;
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
new file mode 100644
index 0000000..97a7a60
--- /dev/null
+++ b/frontend/src/routes/+page.svelte
@@ -0,0 +1,66 @@
+<script lang="ts">
+ import { version } from '$app/environment';
+ import { frontpageQuery } from '$gql/Queries';
+ import { ComicSort, SortDirection } from '$gql/graphql';
+ import { codename } from '$lib/Meta';
+ import { href } from '$lib/Navigation';
+ import { fadeDefault } from '$lib/Transitions';
+ import logo from '$lib/assets/logo.webp';
+ import Card, { comicCard } from '$lib/components/Card.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Carousel from '$lib/containers/Carousel.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { fade } from 'svelte/transition';
+
+ const bookmarkLink = href('comics', { filter: { include: { bookmarked: true } } });
+ const recentLink = href('comics', {
+ sort: { on: ComicSort.CreatedAt, direction: SortDirection.Descending }
+ });
+ const favouriteLink = href('comics', { filter: { include: { favourite: true } } });
+
+ $: query = frontpageQuery(getContextClient());
+ $: recent = $query.data?.recent;
+ $: favourites = $query.data?.favourites;
+ $: bookmarked = $query.data?.bookmarked;
+</script>
+
+<Head section="Home" />
+
+<div class="flex flex-col justify-center gap-16 xl:flex-row">
+ {#if $query.data}
+ <div class="flex flex-col items-center gap-1">
+ <img src={logo} width="512" height="512" class="min-w-[400px]" alt="" />
+ <h1 class="text-4xl font-medium">
+ <span>hircine</span>
+ <span>{version}</span>
+ </h1>
+ <h2 class="text-2xl font-light text-zinc-400">{codename}</h2>
+ </div>
+ <div class="flex flex-col gap-8" in:fade={fadeDefault}>
+ {#if recent && recent.count > 0}
+ <Carousel title="Recently added" href={recentLink}>
+ {#each recent.edges as comic}
+ <Card coverOnly {...comicCard(comic)} />
+ {/each}
+ </Carousel>
+ {/if}
+ {#if favourites && favourites.count > 0}
+ <Carousel title="Favourites" href={favouriteLink}>
+ {#each favourites.edges as comic}
+ <Card coverOnly {...comicCard(comic)} />
+ {/each}
+ </Carousel>
+ {/if}
+ {#if bookmarked && bookmarked.count > 0}
+ <Carousel title="Bookmarks" href={bookmarkLink}>
+ {#each bookmarked.edges as comic}
+ <Card coverOnly {...comicCard(comic)} />
+ {/each}
+ </Carousel>
+ {/if}
+ </div>
+ {:else}
+ <Guard result={query} />
+ {/if}
+</div>
diff --git a/frontend/src/routes/archives/+page.svelte b/frontend/src/routes/archives/+page.svelte
new file mode 100644
index 0000000..545058a
--- /dev/null
+++ b/frontend/src/routes/archives/+page.svelte
@@ -0,0 +1,119 @@
+<script lang="ts">
+ import { deleteArchives, updateArchives } from '$gql/Mutations';
+ import { archivesQuery } from '$gql/Queries';
+ import type { ArchiveFragment } from '$gql/graphql';
+ import { ArchiveSortLabel } from '$lib/Enums';
+ import { ArchiveFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import Card from '$lib/components/Card.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import RefreshButton from '$lib/components/RefreshButton.svelte';
+ import Cards from '$lib/containers/Cards.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Pill from '$lib/pills/Pill.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import FilterOrganized from '$lib/toolbar/FilterOrganized.svelte';
+ import MarkOrganized from '$lib/toolbar/MarkOrganized.svelte';
+ import MarkSelection from '$lib/toolbar/MarkSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { filesize } from 'filesize';
+ import type { PageData } from './$types';
+
+ let client = getContextClient();
+
+ export let data: PageData;
+
+ $: result = archivesQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: archives = $result.data?.archives;
+
+ const selection = initSelectionContext<ArchiveFragment>('Archive', (a) => a.name);
+ $: if (archives) {
+ $selection.view = archives.edges;
+ $pagination.total = archives.count;
+ }
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const filter = initFilterContext<ArchiveFilterContext>();
+ $: $filter = new ArchiveFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, ArchiveSortLabel);
+ $: $sort.update = data.sort;
+
+ function refresh() {
+ result.reexecute({ requestPolicy: 'network-only' });
+ }
+</script>
+
+<Head section="Archives" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <MarkSelection>
+ <MarkOrganized mutation={updateArchives} />
+ </MarkSelection>
+ <DeleteSelection
+ mutation={deleteArchives}
+ warning="Deleting an archive will also delete its archive file on disk as well as all comics that belong to it."
+ />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Archives" bind:field={$filter.include.controls.path.contains} />
+ <FilterOrganized />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <RefreshButton slot="end" on:click={refresh} />
+ </Toolbar>
+ {#if archives}
+ <Pagination />
+ <main>
+ <Cards>
+ {#each archives.edges as { id, name, cover, size, pageCount }, index (id)}
+ <Selectable {index} {id} let:handle let:selected>
+ <Card
+ ellipsis={false}
+ href={id.toString()}
+ details={{ title: name, cover: cover }}
+ on:click={handle}
+ >
+ <SelectionOverlay position="left" {selected} slot="overlay" />
+ <div class="flex gap-1 text-xs">
+ <Pill name={`${pageCount} pages`}>
+ <span class="icon-[material-symbols--note] mr-0.5" slot="icon" />
+ </Pill>
+ <Pill name={filesize(size, { base: 2 })}>
+ <span class="icon-[material-symbols--hard-drive] mr-0.5" slot="icon" />
+ </Pill>
+ </div>
+ </Card>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cards>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/archives/+page.ts b/frontend/src/routes/archives/+page.ts
new file mode 100644
index 0000000..88acade
--- /dev/null
+++ b/frontend/src/routes/archives/+page.ts
@@ -0,0 +1,12 @@
+import { ArchiveSort, type ArchiveFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, ArchiveSort.Path),
+ filter: parseFilter<ArchiveFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams, 24)
+ };
+}
diff --git a/frontend/src/routes/archives/[id]/+page.svelte b/frontend/src/routes/archives/[id]/+page.svelte
new file mode 100644
index 0000000..50a2940
--- /dev/null
+++ b/frontend/src/routes/archives/[id]/+page.svelte
@@ -0,0 +1,99 @@
+<script lang="ts">
+ import { updateArchives } from '$gql/Mutations';
+ import { archiveQuery } from '$gql/Queries';
+ import { Direction, Layout, type FullArchiveFragment, type PageFragment } from '$gql/graphql';
+ import { initReaderContext } from '$lib/Reader';
+ import { initSelectionContext } from '$lib/Selection';
+ import { setTabContext } from '$lib/Tabs';
+ import { toastFinally } from '$lib/Toasts';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Titlebar from '$lib/components/Titlebar.svelte';
+ import Grid from '$lib/containers/Grid.svelte';
+ import Gallery from '$lib/gallery/Gallery.svelte';
+ import PageView from '$lib/reader/PageView.svelte';
+ import Reader from '$lib/reader/Reader.svelte';
+ import ArchiveDelete from '$lib/tabs/ArchiveDelete.svelte';
+ import ArchiveDetails from '$lib/tabs/ArchiveDetails.svelte';
+ import ArchiveEdit from '$lib/tabs/ArchiveEdit.svelte';
+ import Tab from '$lib/tabs/Tab.svelte';
+ import Tabs from '$lib/tabs/Tabs.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import type { PageData } from './$types';
+
+ export let data: PageData;
+ const client = getContextClient();
+ const reader = initReaderContext();
+ setTabContext({
+ tabs: {
+ details: { title: 'Details' },
+ edit: { title: 'Edit' },
+ deletion: { title: 'Delete' }
+ },
+ current: 'details'
+ });
+
+ $: result = archiveQuery(client, { id: data.id });
+
+ function updateCover(event: CustomEvent<number>) {
+ updateArchives(client, { ids: archive.id, input: { cover: { id: event.detail } } }).catch(
+ toastFinally
+ );
+ }
+
+ let archive: FullArchiveFragment;
+
+ $: $result, update();
+ function update() {
+ if (!$result.stale && $result.data?.archive.__typename === 'FullArchive') {
+ archive = structuredClone($result.data.archive);
+
+ $reader.pages = archive.pages;
+ }
+ }
+
+ const selection = initSelectionContext<PageFragment>('Page', (p) => p.path);
+ $selection.selectable = (p) => p.comicId === null;
+
+ $: if (archive) {
+ $selection.view = archive.pages;
+ }
+</script>
+
+<Head section="Archive" title={archive?.name} />
+
+{#if archive}
+ <Grid>
+ <header>
+ <Titlebar title={archive.name} />
+ </header>
+
+ <aside>
+ <Tabs>
+ <Tab id="details">
+ <ArchiveDetails {archive} />
+ </Tab>
+ <Tab id="edit">
+ <ArchiveEdit {archive} />
+ </Tab>
+ <Tab id="deletion">
+ <ArchiveDelete {archive} />
+ </Tab>
+ </Tabs>
+ </aside>
+
+ <main class="overflow-auto">
+ <Gallery
+ pages={archive.pages}
+ on:open={(e) => ($reader = $reader.open(e.detail))}
+ on:cover={updateCover}
+ />
+ </main>
+ </Grid>
+{:else}
+ <Guard {result} />
+{/if}
+
+<Reader>
+ <PageView layout={Layout.Single} direction={Direction.LeftToRight} />
+</Reader>
diff --git a/frontend/src/routes/archives/[id]/+page.ts b/frontend/src/routes/archives/[id]/+page.ts
new file mode 100644
index 0000000..d872ba2
--- /dev/null
+++ b/frontend/src/routes/archives/[id]/+page.ts
@@ -0,0 +1,5 @@
+export function load({ params }: { params: Record<string, string> }) {
+ return {
+ id: +params.id
+ };
+}
diff --git a/frontend/src/routes/artists/+page.svelte b/frontend/src/routes/artists/+page.svelte
new file mode 100644
index 0000000..e07338c
--- /dev/null
+++ b/frontend/src/routes/artists/+page.svelte
@@ -0,0 +1,101 @@
+<script lang="ts">
+ import { deleteArtists } from '$gql/Mutations';
+ import { artistsQuery, fetchArtist } from '$gql/Queries';
+ import type { Artist } from '$gql/graphql';
+ import { ArtistSortLabel } from '$lib/Enums';
+ import { BasicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddArtist from '$lib/dialogs/AddArtist.svelte';
+ import EditArtist from '$lib/dialogs/EditArtist.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+ export let data: PageData;
+
+ $: result = artistsQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: artists = $result.data?.artists;
+
+ const selection = initSelectionContext<Artist>('Artist', (a) => a.name);
+ $: if (artists) {
+ $selection.view = artists.edges;
+ $pagination.total = artists.count;
+ }
+
+ const filter = initFilterContext<BasicFilterContext>();
+ $: $filter = new BasicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, ArtistSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchArtist(client, id)
+ .then((artist) => openModal(EditArtist, { artist }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="artists" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <DeleteSelection mutation={deleteArtists} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Artists" bind:field={$filter.include.controls.name.contains} />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add Artist" on:click={() => openModal(AddArtist)} />
+ </svelte:fragment>
+ </Toolbar>
+ {#if artists}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each artists.edges as { id, name }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} on:click={handle} filter="artists" {id}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/artists/+page.ts b/frontend/src/routes/artists/+page.ts
new file mode 100644
index 0000000..5a76550
--- /dev/null
+++ b/frontend/src/routes/artists/+page.ts
@@ -0,0 +1,12 @@
+import { ArtistSort, type ArtistFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, ArtistSort.Name),
+ filter: parseFilter<ArtistFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/src/routes/characters/+page.svelte b/frontend/src/routes/characters/+page.svelte
new file mode 100644
index 0000000..0934bab
--- /dev/null
+++ b/frontend/src/routes/characters/+page.svelte
@@ -0,0 +1,101 @@
+<script lang="ts">
+ import { deleteCharacters } from '$gql/Mutations';
+ import { charactersQuery, fetchCharacter } from '$gql/Queries';
+ import type { Character } from '$gql/graphql';
+ import { CharacterSortLabel } from '$lib/Enums';
+ import { BasicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddCharacter from '$lib/dialogs/AddCharacter.svelte';
+ import EditCharacter from '$lib/dialogs/EditCharacter.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+ export let data: PageData;
+
+ $: result = charactersQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: characters = $result.data?.characters;
+
+ const selection = initSelectionContext<Character>('Character', (c) => c.name);
+ $: if (characters) {
+ $selection.view = characters.edges;
+ $pagination.total = characters.count;
+ }
+
+ const filter = initFilterContext<BasicFilterContext>();
+ $: $filter = new BasicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, CharacterSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchCharacter(client, id)
+ .then((character) => openModal(EditCharacter, { character }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="characters" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <DeleteSelection mutation={deleteCharacters} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Characters" bind:field={$filter.include.controls.name.contains} />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add Character" on:click={() => openModal(AddCharacter)} />
+ </svelte:fragment>
+ </Toolbar>
+ {#if characters}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each characters.edges as { id, name }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} on:click={handle} filter="characters" {id}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/characters/+page.ts b/frontend/src/routes/characters/+page.ts
new file mode 100644
index 0000000..4f7a3cf
--- /dev/null
+++ b/frontend/src/routes/characters/+page.ts
@@ -0,0 +1,12 @@
+import { CharacterSort, type CharacterFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, CharacterSort.Name),
+ filter: parseFilter<CharacterFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/src/routes/circles/+page.svelte b/frontend/src/routes/circles/+page.svelte
new file mode 100644
index 0000000..14b0866
--- /dev/null
+++ b/frontend/src/routes/circles/+page.svelte
@@ -0,0 +1,101 @@
+<script lang="ts">
+ import { deleteCircles } from '$gql/Mutations';
+ import { circlesQuery, fetchCircle } from '$gql/Queries';
+ import type { Circle } from '$gql/graphql';
+ import { CircleSortLabel } from '$lib/Enums';
+ import { BasicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddCircle from '$lib/dialogs/AddCircle.svelte';
+ import EditCircle from '$lib/dialogs/EditCircle.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+ export let data: PageData;
+
+ $: result = circlesQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: circles = $result.data?.circles;
+
+ const selection = initSelectionContext<Circle>('Circle', (c) => c.name);
+ $: if (circles) {
+ $selection.view = circles.edges;
+ $pagination.total = circles.count;
+ }
+
+ const filter = initFilterContext<BasicFilterContext>();
+ $: $filter = new BasicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, CircleSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchCircle(client, id)
+ .then((circle) => openModal(EditCircle, { circle }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="circles" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <DeleteSelection mutation={deleteCircles} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Circles" bind:field={$filter.include.controls.name.contains} />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add Circle" on:click={() => openModal(AddCircle)} />
+ </svelte:fragment>
+ </Toolbar>
+ {#if circles}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each circles.edges as { id, name }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} on:click={handle} filter="circles" {id}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/circles/+page.ts b/frontend/src/routes/circles/+page.ts
new file mode 100644
index 0000000..ea5c3df
--- /dev/null
+++ b/frontend/src/routes/circles/+page.ts
@@ -0,0 +1,12 @@
+import { CircleSort, type CircleFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, CircleSort.Name),
+ filter: parseFilter<CircleFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/src/routes/comics/+page.svelte b/frontend/src/routes/comics/+page.svelte
new file mode 100644
index 0000000..353d69c
--- /dev/null
+++ b/frontend/src/routes/comics/+page.svelte
@@ -0,0 +1,116 @@
+<script lang="ts">
+ import { deleteComics, updateComics } from '$gql/Mutations';
+ import { comicsQuery } from '$gql/Queries';
+ import { type ComicFragment } from '$gql/graphql';
+ import { ComicSortLabel } from '$lib/Enums';
+ import { ComicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import Card, { comicCard } from '$lib/components/Card.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cards from '$lib/containers/Cards.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import UpdateComicsDialog from '$lib/dialogs/UpdateComics.svelte';
+ import ComicFilterForm from '$lib/filter/ComicFilterForm.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import ComicPills from '$lib/pills/ComicPills.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import EditSelection from '$lib/toolbar/EditSelection.svelte';
+ import FilterBookmarked from '$lib/toolbar/FilterBookmarked.svelte';
+ import FilterFavourites from '$lib/toolbar/FilterFavourites.svelte';
+ import FilterOrganized from '$lib/toolbar/FilterOrganized.svelte';
+ import MarkBookmark from '$lib/toolbar/MarkBookmark.svelte';
+ import MarkFavourite from '$lib/toolbar/MarkFavourite.svelte';
+ import MarkOrganized from '$lib/toolbar/MarkOrganized.svelte';
+ import MarkSelection from '$lib/toolbar/MarkSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import ToggleAdvancedFilters from '$lib/toolbar/ToggleAdvancedFilters.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import type { PageData } from './$types';
+
+ export let data: PageData;
+
+ const client = getContextClient();
+
+ $: result = comicsQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: comics = $result.data?.comics;
+
+ const selection = initSelectionContext<ComicFragment>('Comic', (c) => c.title);
+ $: if (comics) {
+ $selection.view = comics.edges;
+ $pagination.total = comics.count;
+ }
+
+ const filter = initFilterContext<ComicFilterContext>();
+ $: $filter = new ComicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, ComicSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+</script>
+
+<Head section="Comics" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <MarkSelection>
+ <MarkFavourite mutation={updateComics} />
+ <hr class="col-span-2 border-slate-600" />
+ <MarkBookmark mutation={updateComics} />
+ <hr class="col-span-2 border-slate-600" />
+ <MarkOrganized mutation={updateComics} />
+ </MarkSelection>
+ <EditSelection dialog={UpdateComicsDialog} />
+ <DeleteSelection mutation={deleteComics} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Comics" bind:field={$filter.include.controls.title.contains} />
+ <ToggleAdvancedFilters />
+ <div class="rounded-group flex">
+ <FilterFavourites />
+ <FilterBookmarked />
+ <FilterOrganized />
+ </div>
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <ComicFilterForm />
+ </Toolbar>
+ {#if comics}
+ <Pagination />
+ <main>
+ <Cards>
+ {#each comics.edges as comic, index (comic.id)}
+ <Selectable {index} id={comic.id} let:handle let:selected>
+ <Card {...comicCard(comic)} on:click={handle}>
+ <SelectionOverlay position="left" {selected} slot="overlay" />
+ <ComicPills {comic} />
+ </Card>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cards>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/comics/+page.ts b/frontend/src/routes/comics/+page.ts
new file mode 100644
index 0000000..4558804
--- /dev/null
+++ b/frontend/src/routes/comics/+page.ts
@@ -0,0 +1,12 @@
+import { ComicSort, type ComicFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, ComicSort.Title),
+ filter: parseFilter<ComicFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams, 24)
+ };
+}
diff --git a/frontend/src/routes/comics/[id]/+page.svelte b/frontend/src/routes/comics/[id]/+page.svelte
new file mode 100644
index 0000000..cfc5840
--- /dev/null
+++ b/frontend/src/routes/comics/[id]/+page.svelte
@@ -0,0 +1,176 @@
+<script lang="ts">
+ import { beforeNavigate } from '$app/navigation';
+ import { updateComics } from '$gql/Mutations';
+ import { comicQuery } from '$gql/Queries';
+ import { comicEquals } from '$gql/Utils';
+ import { UpdateMode, type FullComicFragment, type UpdateComicInput } from '$gql/graphql';
+ import { initReaderContext } from '$lib/Reader';
+ import { initScraperContext } from '$lib/Scraper';
+ import { initSelectionContext } from '$lib/Selection';
+ import { setTabContext } from '$lib/Tabs';
+ import { toastFinally } from '$lib/Toasts';
+ import { preventOnPending } from '$lib/Utils';
+ import BookmarkButton from '$lib/components/BookmarkButton.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import OrganizedButton from '$lib/components/OrganizedButton.svelte';
+ import RemovePageButton from '$lib/components/RemovePageButton.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import Titlebar from '$lib/components/Titlebar.svelte';
+ import Grid from '$lib/containers/Grid.svelte';
+ import ComicForm from '$lib/forms/ComicForm.svelte';
+ import Gallery from '$lib/gallery/Gallery.svelte';
+ import PageView from '$lib/reader/PageView.svelte';
+ import Reader from '$lib/reader/Reader.svelte';
+ import ComicScrapeForm from '$lib/scraper/ComicScrapeForm.svelte';
+ import ComicDelete from '$lib/tabs/ComicDelete.svelte';
+ import ComicDetails from '$lib/tabs/ComicDetails.svelte';
+ import Tab from '$lib/tabs/Tab.svelte';
+ import Tabs from '$lib/tabs/Tabs.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+ const reader = initReaderContext();
+ const selection = initSelectionContext();
+ const scraper = initScraperContext();
+ const tabContext = setTabContext({
+ tabs: {
+ details: { title: 'Details' },
+ edit: { title: 'Edit' },
+ scrape: { title: 'Scrape' },
+ deletion: { title: 'Delete' }
+ },
+ current: 'details'
+ });
+
+ export let data: PageData;
+ $: result = comicQuery(client, { id: data.id });
+
+ let comic: FullComicFragment;
+ let original: Readonly<FullComicFragment>;
+ let updatePartial = false;
+
+ $: $result, update();
+ $: pending = !comicEquals(comic, original);
+ $: $tabContext.tabs.edit.badge = pending;
+
+ function update() {
+ if (!$result.stale && $result.data?.comic.__typename === 'FullComic') {
+ original = $result.data.comic;
+ if (updatePartial) {
+ comic.pages = structuredClone(original.pages);
+ comic.favourite = original.favourite;
+ comic.bookmarked = original.bookmarked;
+ comic.organized = original.organized;
+ comic.updatedAt = original.updatedAt;
+ updatePartial = false;
+ } else {
+ comic = structuredClone(original);
+ }
+
+ $reader.pages = original.pages;
+ $selection.view = comic.pages;
+ $scraper.selector = undefined;
+ }
+ }
+
+ function toggle(field: keyof Omit<UpdateComicInput, 'cover'>) {
+ updateComics(client, { ids: comic.id, input: { [field]: !comic[field] } })
+ .then(() => (updatePartial = true))
+ .catch(toastFinally);
+ }
+
+ function updateComic(event: CustomEvent<UpdateComicInput>) {
+ updateComics(client, { ids: comic.id, input: event.detail }).catch(toastFinally);
+ }
+
+ function updateCover(event: CustomEvent<number>) {
+ updateComics(client, { ids: comic.id, input: { cover: { id: event.detail } } })
+ .then(() => (updatePartial = true))
+ .catch(toastFinally);
+ }
+
+ function removePages() {
+ updateComics(client, {
+ ids: comic.id,
+ input: { pages: { ids: $selection.ids, options: { mode: UpdateMode.Remove } } }
+ })
+ .then(() => {
+ updatePartial = true;
+ $selection = $selection.clear();
+ })
+ .catch(toastFinally);
+ }
+
+ beforeNavigate((navigation) => preventOnPending(navigation, pending));
+</script>
+
+<Head section="Comic" title={original?.title} />
+
+{#if comic}
+ <Grid>
+ <header>
+ <Titlebar
+ title={original.title}
+ subtitle={original.originalTitle}
+ bind:favourite={comic.favourite}
+ on:favourite={() => toggle('favourite')}
+ />
+ </header>
+
+ <aside>
+ <Tabs>
+ <Tab id="details">
+ <ComicDetails comic={original} />
+ </Tab>
+ <Tab id="edit">
+ <div class="flex flex-col gap-4">
+ <div class="flex gap-2 text-sm">
+ <SelectionControls page>
+ <RemovePageButton on:click={removePages} />
+ </SelectionControls>
+ <div class="grow" />
+ <BookmarkButton bookmarked={comic.bookmarked} on:click={() => toggle('bookmarked')} />
+ <OrganizedButton organized={comic.organized} on:click={() => toggle('organized')} />
+ </div>
+ <ComicForm bind:comic on:submit={updateComic}>
+ <div class="flex gap-2">
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </ComicForm>
+ </div>
+ </Tab>
+ <Tab id="scrape">
+ <ComicScrapeForm {comic} />
+ </Tab>
+ <Tab id="deletion">
+ <ComicDelete {comic} />
+ </Tab>
+ </Tabs>
+ </aside>
+
+ <main class="overflow-auto">
+ <Gallery
+ pages={comic.pages}
+ on:open={(e) => ($reader = $reader.open(e.detail))}
+ on:cover={updateCover}
+ />
+ </main>
+ </Grid>
+
+ <Reader>
+ <PageView layout={comic.layout} direction={comic.direction} />
+ <svelte:fragment slot="sidebar">
+ <ComicForm bind:comic on:submit={updateComic}>
+ <div class="flex justify-end gap-2">
+ <SubmitButton active={pending} />
+ </div>
+ </ComicForm>
+ </svelte:fragment>
+ </Reader>
+{:else}
+ <Guard {result} />
+{/if}
diff --git a/frontend/src/routes/comics/[id]/+page.ts b/frontend/src/routes/comics/[id]/+page.ts
new file mode 100644
index 0000000..d872ba2
--- /dev/null
+++ b/frontend/src/routes/comics/[id]/+page.ts
@@ -0,0 +1,5 @@
+export function load({ params }: { params: Record<string, string> }) {
+ return {
+ id: +params.id
+ };
+}
diff --git a/frontend/src/routes/namespaces/+page.svelte b/frontend/src/routes/namespaces/+page.svelte
new file mode 100644
index 0000000..f6568f9
--- /dev/null
+++ b/frontend/src/routes/namespaces/+page.svelte
@@ -0,0 +1,101 @@
+<script lang="ts">
+ import { deleteNamespaces } from '$gql/Mutations';
+ import { fetchNamespace, namespacesQuery } from '$gql/Queries';
+ import type { Namespace } from '$gql/graphql';
+ import { NamespaceSortLabel } from '$lib/Enums';
+ import { BasicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddNamespace from '$lib/dialogs/AddNamespace.svelte';
+ import EditNamespace from '$lib/dialogs/EditNamespace.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+ export let data: PageData;
+
+ $: result = namespacesQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: namespaces = $result.data?.namespaces;
+
+ const selection = initSelectionContext<Namespace>('Namespace', (n) => n.name);
+ $: if (namespaces) {
+ $selection.view = namespaces.edges;
+ $pagination.total = namespaces.count;
+ }
+
+ const filter = initFilterContext<BasicFilterContext>();
+ $: $filter = new BasicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, NamespaceSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchNamespace(client, id)
+ .then((namespace) => openModal(EditNamespace, { namespace }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="Namespaces" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <DeleteSelection mutation={deleteNamespaces} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Namespaces" bind:field={$filter.include.controls.name.contains} />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add Namespace" on:click={() => openModal(AddNamespace)} />
+ </svelte:fragment>
+ </Toolbar>
+ {#if namespaces}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each namespaces.edges as { id, name }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} on:click={handle} filter="tags" id={`${id}:`}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/namespaces/+page.ts b/frontend/src/routes/namespaces/+page.ts
new file mode 100644
index 0000000..893b540
--- /dev/null
+++ b/frontend/src/routes/namespaces/+page.ts
@@ -0,0 +1,12 @@
+import { NamespaceSort, type NamespaceFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, NamespaceSort.Name),
+ filter: parseFilter<NamespaceFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/src/routes/tags/+page.svelte b/frontend/src/routes/tags/+page.svelte
new file mode 100644
index 0000000..e0909ad
--- /dev/null
+++ b/frontend/src/routes/tags/+page.svelte
@@ -0,0 +1,109 @@
+<script lang="ts">
+ import { deleteTags } from '$gql/Mutations';
+ import { fetchTag, tagsQuery } from '$gql/Queries';
+ import { type Tag } from '$gql/graphql';
+ import { TagSortLabel } from '$lib/Enums';
+ import { TagFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddTag from '$lib/dialogs/AddTag.svelte';
+ import EditTag from '$lib/dialogs/EditTag.svelte';
+ import UpdateTagsDialog from '$lib/dialogs/UpdateTags.svelte';
+ import TagFilterForm from '$lib/filter/TagFilterForm.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import EditSelection from '$lib/toolbar/EditSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import ToggleAdvancedFilters from '$lib/toolbar/ToggleAdvancedFilters.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+
+ export let data: PageData;
+
+ $: result = tagsQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: tags = $result.data?.tags;
+
+ const selection = initSelectionContext<Tag>('Tag', (t) => t.name);
+ $: if (tags) {
+ $selection.view = tags.edges;
+ $pagination.total = tags.count;
+ }
+
+ const filter = initFilterContext<TagFilterContext>();
+ $: $filter = new TagFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, TagSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchTag(client, id)
+ .then((tag) => openModal(EditTag, { tag }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="Tags" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <EditSelection dialog={UpdateTagsDialog} />
+ <DeleteSelection mutation={deleteTags} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Tags" bind:field={$filter.include.controls.name.contains} />
+ <ToggleAdvancedFilters />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add Tag" on:click={() => openModal(AddTag)} />
+ </svelte:fragment>
+ <TagFilterForm />
+ </Toolbar>
+ {#if tags}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each tags.edges as { id, name, description }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} title={description} on:click={handle} filter="tags" id={`:${id}`}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/tags/+page.ts b/frontend/src/routes/tags/+page.ts
new file mode 100644
index 0000000..f584b6f
--- /dev/null
+++ b/frontend/src/routes/tags/+page.ts
@@ -0,0 +1,12 @@
+import { TagSort, type TagFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, TagSort.Name),
+ filter: parseFilter<TagFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/src/routes/worlds/+page.svelte b/frontend/src/routes/worlds/+page.svelte
new file mode 100644
index 0000000..e0366e9
--- /dev/null
+++ b/frontend/src/routes/worlds/+page.svelte
@@ -0,0 +1,102 @@
+<script lang="ts">
+ import { deleteWorlds } from '$gql/Mutations';
+ import { fetchWorld, worldsQuery } from '$gql/Queries';
+ import type { World } from '$gql/graphql';
+ import { WorldSortLabel } from '$lib/Enums';
+ import { BasicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddWorld from '$lib/dialogs/AddWorld.svelte';
+ import EditWorld from '$lib/dialogs/EditWorld.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+
+ export let data: PageData;
+
+ $: result = worldsQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: worlds = $result.data?.worlds;
+
+ const selection = initSelectionContext<World>('World', (w) => w.name);
+ $: if (worlds) {
+ $selection.view = worlds.edges;
+ $pagination.total = worlds.count;
+ }
+
+ const filter = initFilterContext<BasicFilterContext>();
+ $: $filter = new BasicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, WorldSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchWorld(client, id)
+ .then((world) => openModal(EditWorld, { world }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="Worlds" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <DeleteSelection mutation={deleteWorlds} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Worlds" bind:field={$filter.include.controls.name.contains} />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add World" on:click={() => openModal(AddWorld)} />
+ </svelte:fragment>
+ </Toolbar>
+ {#if worlds}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each worlds.edges as { id, name }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} on:click={handle} filter="worlds" {id}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/worlds/+page.ts b/frontend/src/routes/worlds/+page.ts
new file mode 100644
index 0000000..3b85f4c
--- /dev/null
+++ b/frontend/src/routes/worlds/+page.ts
@@ -0,0 +1,12 @@
+import { WorldSort, type WorldFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, WorldSort.Name),
+ filter: parseFilter<WorldFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/static/favicon.svg b/frontend/static/favicon.svg
new file mode 100644
index 0000000..6c7be45
--- /dev/null
+++ b/frontend/static/favicon.svg
@@ -0,0 +1,25 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64" height="64">
+ <defs>
+ <linearGradient id="b">
+ <stop offset=".261" stop-color="#d9825f"/>
+ <stop offset="1" stop-color="#f1d6b0"/>
+ </linearGradient>
+ <linearGradient id="a">
+ <stop offset="0" stop-color="#b46643"/>
+ <stop offset=".521" stop-color="#400d02"/>
+ </linearGradient>
+ <linearGradient xlink:href="#a" id="d" x1="6.023" x2="57.975" y1="31.667" y2="31.667" gradientUnits="userSpaceOnUse"/>
+ <linearGradient xlink:href="#a" id="e" x1="5.523" x2="58.48" y1="31.67" y2="31.67" gradientUnits="userSpaceOnUse"/>
+ <radialGradient xlink:href="#b" id="c" cx="35.966" cy="29.958" r="17.403" fx="35.966" fy="29.958" gradientTransform="matrix(.98534 0 0 .67716 .782 9.643)" gradientUnits="userSpaceOnUse"/>
+ <filter id="f" width="1.316" height="1.453" x="-.167" y="-.24" color-interpolation-filters="sRGB">
+ <feFlood flood-color="#b46643" flood-opacity=".498" result="flood"/>
+ <feGaussianBlur in="SourceGraphic" result="blur" stdDeviation="3"/>
+ <feOffset dx="-1" dy="-1" in="blur" result="offset"/>
+ <feComposite in="flood" in2="offset" operator="in" result="comp1"/>
+ <feComposite in="SourceGraphic" in2="comp1" result="comp2"/>
+ </filter>
+ </defs>
+ <path fill="url(#c)" d="M51.66 18.146c-2.207 0-23.763 6.417-28.092 8.362-3.382 1.52-4.51 3.09-4.495 6.252.016 3.116-3.929 3.866-.79 5.738 3.693 2.203 18.797 3.788 23.607 1.634 1.805-.809 4.193-2.037 5.309-2.733 2.642-1.647 5.336-8.207 5.926-14.434.452-4.77.436-4.82-1.464-4.82z" transform="translate(-5.778 -5.386) scale(1.1806)"/>
+ <path fill="url(#d)" stroke="url(#e)" d="M53.375 13.541c-1.724.004-23.937 8.24-28.492 10.564-3.433 1.753-4.371 2.931-7.09 8.909-1.287 2.828-3.65 6.58-5.252 8.334-4.408 4.826-6.518 7.542-6.518 8.392 0 .422 2.505-1.723 5.565-4.767 6.321-6.289 5.42-6.1 15.758-3.297 16.264 4.408 28.65-4.69 30.476-22.383.33-3.2.208-3.676-1.197-4.66-.86-.603-2.323-1.094-3.25-1.092Zm-1.467 4.662c1.626 0 1.639.047 1.252 4.656-.504 6.018-2.808 12.358-5.068 13.95-.954.672-2.997 1.859-4.541 2.64-4.114 2.082-12.428 2.03-15.586-.1-2.686-1.809-3.918-4.011-3.932-7.023-.013-3.056.952-4.574 3.846-6.043 3.703-1.88 22.14-8.08 24.03-8.08zM21.014 32.086l.42 2.055c.23 1.13.795 2.506 1.253 3.058.72.867.459 1.004-1.906 1.004h-2.74l1.486-3.058z" filter="url(#f)" transform="translate(-5.778 -5.386) scale(1.1806)"/>
+ <path fill="#360c03" d="M30.712 34.768c-1.46-1.157-2.165-2.45-2.165-3.973 0-2.867.69-3.258 6.233-3.543a20.459 20.459 0 0 0 8.539-2.328c5.15-2.693 9.132-2.536 9.447.372.574 5.307-6.13 10.22-14.774 10.828-4.394.308-5.421.117-7.28-1.356z"/>
+</svg>
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
new file mode 100644
index 0000000..dbfdf53
--- /dev/null
+++ b/frontend/svelte.config.js
@@ -0,0 +1,44 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+import adapter from '@sveltejs/adapter-static';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+import { readFileSync } from 'fs';
+import { fileURLToPath } from 'url';
+
+const packageFile = fileURLToPath(new URL('package.json', import.meta.url));
+const packageJSON = readFileSync(packageFile, 'utf8');
+const pkg = JSON.parse(packageJSON);
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ // Consult https://kit.svelte.dev/docs/integrations#preprocessors
+ // for more information about preprocessors
+ preprocess: vitePreprocess(),
+
+ kit: {
+ adapter: adapter({
+ fallback: 'index.html',
+ pages: '../src/hircine/static/app'
+ }),
+ prerender: { entries: [] },
+ alias: {
+ $gql: './src/gql'
+ },
+ version: {
+ name: pkg.version
+ },
+ typescript: {
+ config: (tsconfig) => {
+ const { ...compilerOptions } = tsconfig.compilerOptions;
+
+ return {
+ ...tsconfig,
+ compilerOptions: {
+ ...compilerOptions
+ }
+ };
+ }
+ }
+ }
+};
+
+export default config;
diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs
new file mode 100644
index 0000000..99307cd
--- /dev/null
+++ b/frontend/tailwind.config.cjs
@@ -0,0 +1,7 @@
+const { addDynamicIconSelectors } = require('@iconify/tailwind');
+
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: ['./src/**/*.{html,js,svelte,ts}'],
+ plugins: [addDynamicIconSelectors()]
+};
diff --git a/frontend/tests/Reader.test.ts b/frontend/tests/Reader.test.ts
new file mode 100644
index 0000000..e12d69b
--- /dev/null
+++ b/frontend/tests/Reader.test.ts
@@ -0,0 +1,45 @@
+import { Layout, type PageFragment } from '$gql/graphql';
+import { partition, type Chunk } from '$lib/Reader';
+import { expect, test } from 'vitest';
+
+const normalAttrs = { aspectRatio: 0.7, width: 140, height: 200 };
+const wideAttrs = { aspectRatio: 1.4, width: 280, height: 200 };
+
+const pages: PageFragment[] = [
+ { id: 0, path: '000.png', image: { id: 0, hash: '0', ...normalAttrs } },
+ { id: 1, path: '001.png', image: { id: 1, hash: '1', ...normalAttrs } },
+ { id: 2, path: '002.png', image: { id: 2, hash: '2', ...normalAttrs } },
+ { id: 3, path: '003.png', image: { id: 3, hash: '3', ...normalAttrs } },
+ { id: 4, path: '004.png', image: { id: 4, hash: '4', ...wideAttrs } },
+ { id: 5, path: '005.png', image: { id: 5, hash: '5', ...normalAttrs } },
+ { id: 6, path: '006.png', image: { id: 6, hash: '6', ...normalAttrs } }
+];
+
+const ids = (chunks: Chunk[]) =>
+ chunks.map((c) => (c.secondary ? [c.main.id, c.secondary.id] : [c.main.id]));
+
+const indices = (chunks: Chunk[]) => chunks.map((c) => c.index);
+
+test('partitions single layout', () => {
+ const [chunks, lookup] = partition(pages, Layout.Single);
+
+ expect(ids(chunks)).toStrictEqual([[0], [1], [2], [3], [4], [5], [6]]);
+ expect(indices(chunks)).toStrictEqual([0, 1, 2, 3, 4, 5, 6]);
+ expect(lookup).toStrictEqual([0, 1, 2, 3, 4, 5, 6]);
+});
+
+test('partitions double layout', () => {
+ const [chunks, lookup] = partition(pages, Layout.Double);
+
+ expect(ids(chunks)).toStrictEqual([[0, 1], [2, 3], [4], [5, 6]]);
+ expect(indices(chunks)).toStrictEqual([0, 2, 4, 5]);
+ expect(lookup).toStrictEqual([0, 0, 1, 1, 2, 3, 3]);
+});
+
+test('partitions double (offset) layout', () => {
+ const [chunks, lookup] = partition(pages, Layout.DoubleOffset);
+
+ expect(ids(chunks)).toStrictEqual([[0], [1, 2], [3], [4], [5, 6]]);
+ expect(indices(chunks)).toStrictEqual([0, 1, 3, 4, 5]);
+ expect(lookup).toStrictEqual([0, 1, 1, 2, 3, 4, 4]);
+});
diff --git a/frontend/tests/Selection.test.ts b/frontend/tests/Selection.test.ts
new file mode 100644
index 0000000..67e8c4c
--- /dev/null
+++ b/frontend/tests/Selection.test.ts
@@ -0,0 +1,183 @@
+import { ItemSelection } from '$lib/Selection';
+import { expect, test } from 'vitest';
+
+interface TestItem {
+ id: number;
+ selectable: boolean;
+}
+
+const items: TestItem[] = [
+ { id: 1, selectable: true },
+ { id: 2, selectable: true },
+ { id: 3, selectable: false },
+ { id: 4, selectable: true }
+];
+
+const all = items.map((i) => i.id);
+const selectable = items.filter((i) => i.selectable).map((i) => i.id);
+
+const setup = () => {
+ const selection = new ItemSelection<TestItem>();
+ selection.view = items;
+ return selection;
+};
+
+test('selects a single item', () => {
+ let selection = setup();
+
+ selection = selection.update(0, false);
+
+ expect(selection.ids).toStrictEqual([items[0].id]);
+});
+
+test('selects a single item (with empty shift select)', () => {
+ let selection = setup();
+
+ selection = selection.update(0, true);
+
+ expect(selection.ids).toStrictEqual([items[0].id]);
+});
+
+test('selects multiple items (forwards)', () => {
+ let selection = setup();
+
+ selection = selection.update(0, false);
+ selection = selection.update(2, true);
+
+ expect(selection.ids.toSorted((a, b) => a - b)).toStrictEqual(all.slice(0, 3));
+});
+
+test('selects multiple items (backwards)', () => {
+ let selection = setup();
+
+ selection = selection.update(2, false);
+ selection = selection.update(0, true);
+
+ expect(selection.ids.toSorted((a, b) => a - b)).toStrictEqual(all.slice(0, 3));
+});
+
+test('selects multiple items (only selectables)', () => {
+ let selection = setup();
+ selection.selectable = (i) => i.selectable;
+
+ selection = selection.update(0, false);
+ selection = selection.update(3, true);
+
+ expect(selection.ids).toStrictEqual(selectable);
+});
+
+test('selects all', () => {
+ const selection = setup().all();
+
+ expect(selection.ids).toStrictEqual(all);
+});
+
+test('selects all selectables', () => {
+ let selection = setup();
+ selection.selectable = (i) => i.selectable;
+
+ selection = selection.all();
+
+ expect(selection.ids).toStrictEqual(selectable);
+});
+
+test('deselects all', () => {
+ let selection = setup().all();
+
+ selection = selection.none();
+
+ expect(selection.ids).empty;
+});
+
+test('deselects a single item', () => {
+ let selection = setup().all();
+
+ selection = selection.update(0, false);
+
+ expect(selection.ids).toStrictEqual(all.slice(1));
+});
+
+test('deselects multiple items', () => {
+ let selection = setup();
+
+ selection = selection.update(0, false);
+ selection = selection.update(2, true);
+ selection = selection.update(2, true);
+
+ expect(selection.ids).empty;
+});
+
+test('retains selection', () => {
+ let selection = setup();
+
+ selection = selection.all();
+
+ selection.view = items.slice(0, 2);
+ expect(selection.ids).toStrictEqual(all.slice(0, 2));
+
+ selection.view = items;
+ expect(selection.ids).toStrictEqual(all);
+});
+
+test('is inactive by default', () => {
+ const selection = setup();
+ expect(selection.active).toBe(false);
+});
+
+test('is inactive after clearing', () => {
+ let selection = setup();
+
+ selection.active = true;
+
+ selection = selection.clear();
+ expect(selection.active).false;
+});
+
+test('can be toggled', () => {
+ let selection = setup();
+
+ selection = selection.toggle();
+ expect(selection.active).toBe(true);
+
+ selection = selection.all();
+ selection = selection.toggle();
+ expect(selection.active).toBe(false);
+ expect(selection.ids).empty;
+});
+
+test('can be cleared', () => {
+ let selection = setup();
+
+ selection = selection.all();
+
+ selection = selection.clear();
+ expect(selection.ids).empty;
+});
+
+test('reports selected items', () => {
+ let selection = setup();
+
+ selection = selection.update(0, false);
+ selection = selection.update(2, false);
+
+ expect(selection.contains(all[0])).toBeTruthy();
+ expect(selection.contains(all[1])).toBeFalsy();
+ expect(selection.contains(all[2])).toBeTruthy();
+ expect(selection.contains(all[3])).toBeFalsy();
+});
+
+test('reports size', () => {
+ const selection = setup().all();
+
+ expect(selection.size).toBe(all.length);
+});
+
+test('reports size of visible items', () => {
+ const selection = setup().all();
+
+ selection.view = items.slice(0, 2);
+ expect(selection.size).toBe(2);
+
+ selection.view = items;
+ expect(selection.size).toBe(all.length);
+});
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..95c4753
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "target": "ES2022",
+ "module": "ES2022"
+ }
+ // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
+ //
+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
+ // from the referenced tsconfig.json - TypeScript does not merge them in
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..145901f
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,11 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ server: {
+ fs: {
+ allow: ['objects']
+ }
+ }
+});
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..75b9b46
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,1641 @@
+# This file is automatically @generated by Poetry 1.8.1 and should not be changed by hand.
+
+[[package]]
+name = "aiosqlite"
+version = "0.20.0"
+description = "asyncio bridge to the standard sqlite3 module"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6"},
+ {file = "aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7"},
+]
+
+[package.dependencies]
+typing_extensions = ">=4.0"
+
+[package.extras]
+dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"]
+docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"]
+
+[[package]]
+name = "alabaster"
+version = "0.7.16"
+description = "A light, configurable Sphinx theme"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"},
+ {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"},
+]
+
+[[package]]
+name = "alembic"
+version = "1.13.1"
+description = "A database migration tool for SQLAlchemy."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "alembic-1.13.1-py3-none-any.whl", hash = "sha256:2edcc97bed0bd3272611ce3a98d98279e9c209e7186e43e75bbb1b2bdfdbcc43"},
+ {file = "alembic-1.13.1.tar.gz", hash = "sha256:4932c8558bf68f2ee92b9bbcb8218671c627064d5b08939437af6d77dc05e595"},
+]
+
+[package.dependencies]
+Mako = "*"
+SQLAlchemy = ">=1.3.0"
+typing-extensions = ">=4"
+
+[package.extras]
+tz = ["backports.zoneinfo"]
+
+[[package]]
+name = "anyio"
+version = "4.3.0"
+description = "High level compatibility layer for multiple asynchronous event loop implementations"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"},
+ {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"},
+]
+
+[package.dependencies]
+idna = ">=2.8"
+sniffio = ">=1.1"
+
+[package.extras]
+doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
+test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
+trio = ["trio (>=0.23)"]
+
+[[package]]
+name = "babel"
+version = "2.14.0"
+description = "Internationalization utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"},
+ {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"},
+]
+
+[package.extras]
+dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.12.3"
+description = "Screen-scraping library"
+optional = false
+python-versions = ">=3.6.0"
+files = [
+ {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"},
+ {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"},
+]
+
+[package.dependencies]
+soupsieve = ">1.2"
+
+[package.extras]
+cchardet = ["cchardet"]
+chardet = ["chardet"]
+charset-normalizer = ["charset-normalizer"]
+html5lib = ["html5lib"]
+lxml = ["lxml"]
+
+[[package]]
+name = "black"
+version = "24.2.0"
+description = "The uncompromising code formatter."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"},
+ {file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"},
+ {file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"},
+ {file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"},
+ {file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"},
+ {file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"},
+ {file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"},
+ {file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"},
+ {file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"},
+ {file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"},
+ {file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"},
+ {file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"},
+ {file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"},
+ {file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"},
+ {file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"},
+ {file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"},
+ {file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"},
+ {file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"},
+ {file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"},
+ {file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"},
+ {file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"},
+ {file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"},
+]
+
+[package.dependencies]
+click = ">=8.0.0"
+mypy-extensions = ">=0.4.3"
+packaging = ">=22.0"
+pathspec = ">=0.9.0"
+platformdirs = ">=2"
+
+[package.extras]
+colorama = ["colorama (>=0.4.3)"]
+d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"]
+jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
+uvloop = ["uvloop (>=0.15.2)"]
+
+[[package]]
+name = "blake3"
+version = "0.4.1"
+description = "Python bindings for the Rust blake3 crate"
+optional = false
+python-versions = "*"
+files = [
+ {file = "blake3-0.4.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1a086cc9401fb0b09f9b4ba14444457d9b04a6d8086cd96b45ebf252afc49109"},
+ {file = "blake3-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:283860fe58b3a6d74e5be1ece78bbcd7de819b48476d7a534b989dd6ab49a083"},
+ {file = "blake3-0.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef534c59ae76faba1c7e1531930dadecaa7817e25aa6e6c825150c04ed243a3d"},
+ {file = "blake3-0.4.1-cp310-none-win32.whl", hash = "sha256:e0fc4914750b63bbb15f71b2092a75b24a63fd86f6fbd621a8c133795f3d6371"},
+ {file = "blake3-0.4.1-cp310-none-win_amd64.whl", hash = "sha256:d51b3da140d04cd8b680bf2b3a5dc1f0cbb4f1e62b08e3d6f3b17d75b6285c41"},
+ {file = "blake3-0.4.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3cc656cab955ab6c18b587a8b4faa33930fea089981f76a7c64f33e4a26c1dac"},
+ {file = "blake3-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9633e0198174eb77196f9f8b18d75449d86e8fa234727c98d685d5404f84eb8e"},
+ {file = "blake3-0.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d653623361da8db3406f4a90b39d38016f9f678e22099df0d5f8ab77efb7b4ae"},
+ {file = "blake3-0.4.1-cp311-none-win32.whl", hash = "sha256:931d1d0d4650a400838a7f1bf0d260209d10e9bd1981a6ed033f32361b96ab7b"},
+ {file = "blake3-0.4.1-cp311-none-win_amd64.whl", hash = "sha256:c6c50122d9484a97a56888f09fcbbd23fdba94c4bf1e6fdeb036b17accae9f0c"},
+ {file = "blake3-0.4.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:fb98f18bc5e218ff1134acb3b9f0e3588ad5e6f38b7279cce4559c8ae9d780e6"},
+ {file = "blake3-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe31163eb08fc3f82a2325e90cea88f2d7ad0265314a03de716f906b2a43be96"},
+ {file = "blake3-0.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a51f48ec21706a22b4954fc17da72bd177d82d22ee434da0c5dc3aafeef5b8d3"},
+ {file = "blake3-0.4.1-cp312-none-win32.whl", hash = "sha256:510fd32d207ef2e28df3597847d5044117d110b0e549b2e467afa30a9f3ab7ee"},
+ {file = "blake3-0.4.1-cp312-none-win_amd64.whl", hash = "sha256:2a08eeb324da701b212f348e91ba5d2708c0a596bd6691207f2504f4f771644c"},
+ {file = "blake3-0.4.1-cp37-cp37m-macosx_10_12_x86_64.whl", hash = "sha256:a73c5940454bd693d7172af8fad23019c2f5a9b910ed961c20bdf5a91babd9f2"},
+ {file = "blake3-0.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8fa53818f1170611d781aadbcae809ecc2334679212b4a4b3feabb55deb594d"},
+ {file = "blake3-0.4.1-cp37-none-win32.whl", hash = "sha256:46ffb411a477009dfa99a592d4408e43ff885ea7df30ed8c8f284e87866be56e"},
+ {file = "blake3-0.4.1-cp37-none-win_amd64.whl", hash = "sha256:0c3ce6142e385222f6de5312a9fb886270b7e63d9ffaa792571b03c4c83a7521"},
+ {file = "blake3-0.4.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:fb6a62ef04c5ec4dd4630615c6a22ddab16eb0b7887276b3449946c12eeb37a2"},
+ {file = "blake3-0.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:34d7da38898ad4e0da7b8fe0bffb8c9d2788093ec202e01cd3ab24bc14049153"},
+ {file = "blake3-0.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87a9fc37260d355569f0be751e0054e0b37e6a4ec022f4b7107ffeede419dde2"},
+ {file = "blake3-0.4.1-cp38-none-win32.whl", hash = "sha256:d264ca87f0990f44985cf580b493508534dc6e72ace52a140cf725e42d602695"},
+ {file = "blake3-0.4.1-cp38-none-win_amd64.whl", hash = "sha256:47316bdc9b4689601cefcc63e00a3e015cee1fa9864463be2b4f2e12473cb47f"},
+ {file = "blake3-0.4.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:4d99136b7f7c8adcee0f7484e74b159fd3ea58e7d1e94d5351f0e98d9cfc522f"},
+ {file = "blake3-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:aa4989ea8f8bcfa057e50014b5b26cd8cfe0b1f06aa98d433976f45caf3a5580"},
+ {file = "blake3-0.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6c555d882117d638830b2f5f0fd9980bcd63286ad4c9959bc16b3df77042d6f"},
+ {file = "blake3-0.4.1-cp39-none-win32.whl", hash = "sha256:a95cce3e8bfd7e717f901de80068ee4b5c77dc421f83eef00cf3eddd3ec8b87a"},
+ {file = "blake3-0.4.1-cp39-none-win_amd64.whl", hash = "sha256:796e65ae333831bafed5969c691ac806fe4957b6f39e52b4c3cf20f3c00c576f"},
+ {file = "blake3-0.4.1.tar.gz", hash = "sha256:0625c8679203d5a1d30f859696a3fd75b2f50587984690adab839ef112f4c043"},
+]
+
+[[package]]
+name = "certifi"
+version = "2024.2.2"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"},
+ {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"},
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.3.2"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7.0"
+files = [
+ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
+ {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
+ {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
+ {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"},
+ {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"},
+ {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"},
+ {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"},
+ {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
+]
+
+[[package]]
+name = "click"
+version = "8.1.7"
+description = "Composable command line interface toolkit"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
+ {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "platform_system == \"Windows\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "coverage"
+version = "7.4.3"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "coverage-7.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8580b827d4746d47294c0e0b92854c85a92c2227927433998f0d3320ae8a71b6"},
+ {file = "coverage-7.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:718187eeb9849fc6cc23e0d9b092bc2348821c5e1a901c9f8975df0bc785bfd4"},
+ {file = "coverage-7.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:767b35c3a246bcb55b8044fd3a43b8cd553dd1f9f2c1eeb87a302b1f8daa0524"},
+ {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae7f19afe0cce50039e2c782bff379c7e347cba335429678450b8fe81c4ef96d"},
+ {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba3a8aaed13770e970b3df46980cb068d1c24af1a1968b7818b69af8c4347efb"},
+ {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ee866acc0861caebb4f2ab79f0b94dbfbdbfadc19f82e6e9c93930f74e11d7a0"},
+ {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:506edb1dd49e13a2d4cac6a5173317b82a23c9d6e8df63efb4f0380de0fbccbc"},
+ {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd6545d97c98a192c5ac995d21c894b581f1fd14cf389be90724d21808b657e2"},
+ {file = "coverage-7.4.3-cp310-cp310-win32.whl", hash = "sha256:f6a09b360d67e589236a44f0c39218a8efba2593b6abdccc300a8862cffc2f94"},
+ {file = "coverage-7.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:18d90523ce7553dd0b7e23cbb28865db23cddfd683a38fb224115f7826de78d0"},
+ {file = "coverage-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbbe5e739d45a52f3200a771c6d2c7acf89eb2524890a4a3aa1a7fa0695d2a47"},
+ {file = "coverage-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:489763b2d037b164846ebac0cbd368b8a4ca56385c4090807ff9fad817de4113"},
+ {file = "coverage-7.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:451f433ad901b3bb00184d83fd83d135fb682d780b38af7944c9faeecb1e0bfe"},
+ {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcc66e222cf4c719fe7722a403888b1f5e1682d1679bd780e2b26c18bb648cdc"},
+ {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ec74cfef2d985e145baae90d9b1b32f85e1741b04cd967aaf9cfa84c1334f3"},
+ {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:abbbd8093c5229c72d4c2926afaee0e6e3140de69d5dcd918b2921f2f0c8baba"},
+ {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:35eb581efdacf7b7422af677b92170da4ef34500467381e805944a3201df2079"},
+ {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8249b1c7334be8f8c3abcaaa996e1e4927b0e5a23b65f5bf6cfe3180d8ca7840"},
+ {file = "coverage-7.4.3-cp311-cp311-win32.whl", hash = "sha256:cf30900aa1ba595312ae41978b95e256e419d8a823af79ce670835409fc02ad3"},
+ {file = "coverage-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:18c7320695c949de11a351742ee001849912fd57e62a706d83dfc1581897fa2e"},
+ {file = "coverage-7.4.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b51bfc348925e92a9bd9b2e48dad13431b57011fd1038f08316e6bf1df107d10"},
+ {file = "coverage-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cdecaedea1ea9e033d8adf6a0ab11107b49571bbb9737175444cea6eb72328"},
+ {file = "coverage-7.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b2eccb883368f9e972e216c7b4c7c06cabda925b5f06dde0650281cb7666a30"},
+ {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c00cdc8fa4e50e1cc1f941a7f2e3e0f26cb2a1233c9696f26963ff58445bac7"},
+ {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9a4a8dd3dcf4cbd3165737358e4d7dfbd9d59902ad11e3b15eebb6393b0446e"},
+ {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:062b0a75d9261e2f9c6d071753f7eef0fc9caf3a2c82d36d76667ba7b6470003"},
+ {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ebe7c9e67a2d15fa97b77ea6571ce5e1e1f6b0db71d1d5e96f8d2bf134303c1d"},
+ {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c0a120238dd71c68484f02562f6d446d736adcc6ca0993712289b102705a9a3a"},
+ {file = "coverage-7.4.3-cp312-cp312-win32.whl", hash = "sha256:37389611ba54fd6d278fde86eb2c013c8e50232e38f5c68235d09d0a3f8aa352"},
+ {file = "coverage-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:d25b937a5d9ffa857d41be042b4238dd61db888533b53bc76dc082cb5a15e914"},
+ {file = "coverage-7.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:28ca2098939eabab044ad68850aac8f8db6bf0b29bc7f2887d05889b17346454"},
+ {file = "coverage-7.4.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:280459f0a03cecbe8800786cdc23067a8fc64c0bd51dc614008d9c36e1659d7e"},
+ {file = "coverage-7.4.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c0cdedd3500e0511eac1517bf560149764b7d8e65cb800d8bf1c63ebf39edd2"},
+ {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a9babb9466fe1da12417a4aed923e90124a534736de6201794a3aea9d98484e"},
+ {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dec9de46a33cf2dd87a5254af095a409ea3bf952d85ad339751e7de6d962cde6"},
+ {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:16bae383a9cc5abab9bb05c10a3e5a52e0a788325dc9ba8499e821885928968c"},
+ {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2c854ce44e1ee31bda4e318af1dbcfc929026d12c5ed030095ad98197eeeaed0"},
+ {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ce8c50520f57ec57aa21a63ea4f325c7b657386b3f02ccaedeccf9ebe27686e1"},
+ {file = "coverage-7.4.3-cp38-cp38-win32.whl", hash = "sha256:708a3369dcf055c00ddeeaa2b20f0dd1ce664eeabde6623e516c5228b753654f"},
+ {file = "coverage-7.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:1bf25fbca0c8d121a3e92a2a0555c7e5bc981aee5c3fdaf4bb7809f410f696b9"},
+ {file = "coverage-7.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b253094dbe1b431d3a4ac2f053b6d7ede2664ac559705a704f621742e034f1f"},
+ {file = "coverage-7.4.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:77fbfc5720cceac9c200054b9fab50cb2a7d79660609200ab83f5db96162d20c"},
+ {file = "coverage-7.4.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6679060424faa9c11808598504c3ab472de4531c571ab2befa32f4971835788e"},
+ {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4af154d617c875b52651dd8dd17a31270c495082f3d55f6128e7629658d63765"},
+ {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8640f1fde5e1b8e3439fe482cdc2b0bb6c329f4bb161927c28d2e8879c6029ee"},
+ {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:69b9f6f66c0af29642e73a520b6fed25ff9fd69a25975ebe6acb297234eda501"},
+ {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0842571634f39016a6c03e9d4aba502be652a6e4455fadb73cd3a3a49173e38f"},
+ {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a78ed23b08e8ab524551f52953a8a05d61c3a760781762aac49f8de6eede8c45"},
+ {file = "coverage-7.4.3-cp39-cp39-win32.whl", hash = "sha256:c0524de3ff096e15fcbfe8f056fdb4ea0bf497d584454f344d59fce069d3e6e9"},
+ {file = "coverage-7.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:0209a6369ccce576b43bb227dc8322d8ef9e323d089c6f3f26a597b09cb4d2aa"},
+ {file = "coverage-7.4.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:7cbde573904625509a3f37b6fecea974e363460b556a627c60dc2f47e2fffa51"},
+ {file = "coverage-7.4.3.tar.gz", hash = "sha256:276f6077a5c61447a48d133ed13e759c09e62aff0dc84274a68dc18660104d52"},
+]
+
+[package.extras]
+toml = ["tomli"]
+
+[[package]]
+name = "docutils"
+version = "0.20.1"
+description = "Docutils -- Python Documentation Utilities"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"},
+ {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"},
+]
+
+[[package]]
+name = "furo"
+version = "2024.1.29"
+description = "A clean customisable Sphinx documentation theme."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "furo-2024.1.29-py3-none-any.whl", hash = "sha256:3548be2cef45a32f8cdc0272d415fcb3e5fa6a0eb4ddfe21df3ecf1fe45a13cf"},
+ {file = "furo-2024.1.29.tar.gz", hash = "sha256:4d6b2fe3f10a6e36eb9cc24c1e7beb38d7a23fc7b3c382867503b7fcac8a1e02"},
+]
+
+[package.dependencies]
+beautifulsoup4 = "*"
+pygments = ">=2.7"
+sphinx = ">=6.0,<8.0"
+sphinx-basic-ng = "*"
+
+[[package]]
+name = "graphql-core"
+version = "3.2.3"
+description = "GraphQL implementation for Python, a port of GraphQL.js, the JavaScript reference implementation for GraphQL."
+optional = false
+python-versions = ">=3.6,<4"
+files = [
+ {file = "graphql-core-3.2.3.tar.gz", hash = "sha256:06d2aad0ac723e35b1cb47885d3e5c45e956a53bc1b209a9fc5369007fe46676"},
+ {file = "graphql_core-3.2.3-py3-none-any.whl", hash = "sha256:5766780452bd5ec8ba133f8bf287dc92713e3868ddd83aee4faab9fc3e303dc3"},
+]
+
+[[package]]
+name = "greenlet"
+version = "3.0.3"
+description = "Lightweight in-process concurrent programming"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"},
+ {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"},
+ {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"},
+ {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"},
+ {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"},
+ {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"},
+ {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"},
+ {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"},
+ {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"},
+ {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"},
+ {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"},
+ {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"},
+ {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"},
+ {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"},
+ {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"},
+ {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"},
+ {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"},
+ {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"},
+ {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"},
+ {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"},
+ {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"},
+ {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"},
+ {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"},
+ {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"},
+ {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"},
+ {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"},
+ {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"},
+ {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"},
+ {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"},
+ {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"},
+ {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"},
+ {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"},
+ {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"},
+ {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"},
+ {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"},
+ {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"},
+ {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"},
+ {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"},
+ {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"},
+ {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"},
+ {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"},
+ {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"},
+ {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"},
+ {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"},
+ {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"},
+ {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"},
+ {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"},
+ {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"},
+ {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"},
+ {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"},
+ {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"},
+ {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"},
+ {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"},
+ {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"},
+ {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"},
+ {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"},
+ {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"},
+ {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"},
+]
+
+[package.extras]
+docs = ["Sphinx", "furo"]
+test = ["objgraph", "psutil"]
+
+[[package]]
+name = "h11"
+version = "0.14.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
+ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
+]
+
+[[package]]
+name = "httptools"
+version = "0.6.1"
+description = "A collection of framework independent HTTP protocol utils."
+optional = false
+python-versions = ">=3.8.0"
+files = [
+ {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2f6c3c4cb1948d912538217838f6e9960bc4a521d7f9b323b3da579cd14532f"},
+ {file = "httptools-0.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:00d5d4b68a717765b1fabfd9ca755bd12bf44105eeb806c03d1962acd9b8e563"},
+ {file = "httptools-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:639dc4f381a870c9ec860ce5c45921db50205a37cc3334e756269736ff0aac58"},
+ {file = "httptools-0.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e57997ac7fb7ee43140cc03664de5f268813a481dff6245e0075925adc6aa185"},
+ {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0ac5a0ae3d9f4fe004318d64b8a854edd85ab76cffbf7ef5e32920faef62f142"},
+ {file = "httptools-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3f30d3ce413088a98b9db71c60a6ada2001a08945cb42dd65a9a9fe228627658"},
+ {file = "httptools-0.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:1ed99a373e327f0107cb513b61820102ee4f3675656a37a50083eda05dc9541b"},
+ {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7a7ea483c1a4485c71cb5f38be9db078f8b0e8b4c4dc0210f531cdd2ddac1ef1"},
+ {file = "httptools-0.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:85ed077c995e942b6f1b07583e4eb0a8d324d418954fc6af913d36db7c05a5a0"},
+ {file = "httptools-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b0bb634338334385351a1600a73e558ce619af390c2b38386206ac6a27fecfc"},
+ {file = "httptools-0.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9ceb2c957320def533671fc9c715a80c47025139c8d1f3797477decbc6edd2"},
+ {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4f0f8271c0a4db459f9dc807acd0eadd4839934a4b9b892f6f160e94da309837"},
+ {file = "httptools-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6a4f5ccead6d18ec072ac0b84420e95d27c1cdf5c9f1bc8fbd8daf86bd94f43d"},
+ {file = "httptools-0.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:5cceac09f164bcba55c0500a18fe3c47df29b62353198e4f37bbcc5d591172c3"},
+ {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:75c8022dca7935cba14741a42744eee13ba05db00b27a4b940f0d646bd4d56d0"},
+ {file = "httptools-0.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:48ed8129cd9a0d62cf4d1575fcf90fb37e3ff7d5654d3a5814eb3d55f36478c2"},
+ {file = "httptools-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f58e335a1402fb5a650e271e8c2d03cfa7cea46ae124649346d17bd30d59c90"},
+ {file = "httptools-0.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ad80d7176aa5788902f207a4e79885f0576134695dfb0fefc15b7a4648d503"},
+ {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9bb68d3a085c2174c2477eb3ffe84ae9fb4fde8792edb7bcd09a1d8467e30a84"},
+ {file = "httptools-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b512aa728bc02354e5ac086ce76c3ce635b62f5fbc32ab7082b5e582d27867bb"},
+ {file = "httptools-0.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:97662ce7fb196c785344d00d638fc9ad69e18ee4bfb4000b35a52efe5adcc949"},
+ {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8e216a038d2d52ea13fdd9b9c9c7459fb80d78302b257828285eca1c773b99b3"},
+ {file = "httptools-0.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3e802e0b2378ade99cd666b5bffb8b2a7cc8f3d28988685dc300469ea8dd86cb"},
+ {file = "httptools-0.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4bd3e488b447046e386a30f07af05f9b38d3d368d1f7b4d8f7e10af85393db97"},
+ {file = "httptools-0.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe467eb086d80217b7584e61313ebadc8d187a4d95bb62031b7bab4b205c3ba3"},
+ {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3c3b214ce057c54675b00108ac42bacf2ab8f85c58e3f324a4e963bbc46424f4"},
+ {file = "httptools-0.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ae5b97f690badd2ca27cbf668494ee1b6d34cf1c464271ef7bfa9ca6b83ffaf"},
+ {file = "httptools-0.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:405784577ba6540fa7d6ff49e37daf104e04f4b4ff2d1ac0469eaa6a20fde084"},
+ {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:95fb92dd3649f9cb139e9c56604cc2d7c7bf0fc2e7c8d7fbd58f96e35eddd2a3"},
+ {file = "httptools-0.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dcbab042cc3ef272adc11220517278519adf8f53fd3056d0e68f0a6f891ba94e"},
+ {file = "httptools-0.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cf2372e98406efb42e93bfe10f2948e467edfd792b015f1b4ecd897903d3e8d"},
+ {file = "httptools-0.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:678fcbae74477a17d103b7cae78b74800d795d702083867ce160fc202104d0da"},
+ {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e0b281cf5a125c35f7f6722b65d8542d2e57331be573e9e88bc8b0115c4a7a81"},
+ {file = "httptools-0.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:95658c342529bba4e1d3d2b1a874db16c7cca435e8827422154c9da76ac4e13a"},
+ {file = "httptools-0.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:7ebaec1bf683e4bf5e9fbb49b8cc36da482033596a415b3e4ebab5a4c0d7ec5e"},
+ {file = "httptools-0.6.1.tar.gz", hash = "sha256:c6e26c30455600b95d94b1b836085138e82f177351454ee841c148f93a9bad5a"},
+]
+
+[package.extras]
+test = ["Cython (>=0.29.24,<0.30.0)"]
+
+[[package]]
+name = "idna"
+version = "3.6"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"},
+ {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
+]
+
+[[package]]
+name = "imagesize"
+version = "1.4.1"
+description = "Getting image size from png/jpeg/jpeg2000/gif file"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+files = [
+ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
+ {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
+ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.3"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"},
+ {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "mako"
+version = "1.3.2"
+description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "Mako-1.3.2-py3-none-any.whl", hash = "sha256:32a99d70754dfce237019d17ffe4a282d2d3351b9c476e90d8a60e63f133b80c"},
+ {file = "Mako-1.3.2.tar.gz", hash = "sha256:2a0c8ad7f6274271b3bb7467dd37cf9cc6dab4bc19cb69a4ef10669402de698e"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=0.9.2"
+
+[package.extras]
+babel = ["Babel"]
+lingua = ["lingua"]
+testing = ["pytest"]
+
+[[package]]
+name = "markupsafe"
+version = "2.1.5"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"},
+ {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"},
+ {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"},
+ {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"},
+ {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"},
+ {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"},
+ {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"},
+ {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"},
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.0.0"
+description = "Type system extensions for programs checked with the mypy type checker."
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
+ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
+]
+
+[[package]]
+name = "natsort"
+version = "8.4.0"
+description = "Simple yet flexible natural sorting in Python."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c"},
+ {file = "natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581"},
+]
+
+[package.extras]
+fast = ["fastnumbers (>=2.0.0)"]
+icu = ["PyICU (>=1.0.0)"]
+
+[[package]]
+name = "packaging"
+version = "23.2"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
+ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
+]
+
+[[package]]
+name = "pathspec"
+version = "0.12.1"
+description = "Utility library for gitignore style pattern matching of file paths."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
+ {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
+]
+
+[[package]]
+name = "pillow"
+version = "10.2.0"
+description = "Python Imaging Library (Fork)"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pillow-10.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e"},
+ {file = "pillow-10.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588"},
+ {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452"},
+ {file = "pillow-10.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4"},
+ {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563"},
+ {file = "pillow-10.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2"},
+ {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c"},
+ {file = "pillow-10.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0"},
+ {file = "pillow-10.2.0-cp310-cp310-win32.whl", hash = "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023"},
+ {file = "pillow-10.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72"},
+ {file = "pillow-10.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad"},
+ {file = "pillow-10.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5"},
+ {file = "pillow-10.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67"},
+ {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61"},
+ {file = "pillow-10.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e"},
+ {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f"},
+ {file = "pillow-10.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311"},
+ {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1"},
+ {file = "pillow-10.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757"},
+ {file = "pillow-10.2.0-cp311-cp311-win32.whl", hash = "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068"},
+ {file = "pillow-10.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56"},
+ {file = "pillow-10.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1"},
+ {file = "pillow-10.2.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef"},
+ {file = "pillow-10.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac"},
+ {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c"},
+ {file = "pillow-10.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa"},
+ {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2"},
+ {file = "pillow-10.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04"},
+ {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f"},
+ {file = "pillow-10.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb"},
+ {file = "pillow-10.2.0-cp312-cp312-win32.whl", hash = "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f"},
+ {file = "pillow-10.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9"},
+ {file = "pillow-10.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48"},
+ {file = "pillow-10.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9"},
+ {file = "pillow-10.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483"},
+ {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129"},
+ {file = "pillow-10.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e"},
+ {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213"},
+ {file = "pillow-10.2.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d"},
+ {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6"},
+ {file = "pillow-10.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe"},
+ {file = "pillow-10.2.0-cp38-cp38-win32.whl", hash = "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e"},
+ {file = "pillow-10.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39"},
+ {file = "pillow-10.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67"},
+ {file = "pillow-10.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364"},
+ {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb"},
+ {file = "pillow-10.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e"},
+ {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01"},
+ {file = "pillow-10.2.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13"},
+ {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7"},
+ {file = "pillow-10.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591"},
+ {file = "pillow-10.2.0-cp39-cp39-win32.whl", hash = "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516"},
+ {file = "pillow-10.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8"},
+ {file = "pillow-10.2.0-cp39-cp39-win_arm64.whl", hash = "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a"},
+ {file = "pillow-10.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6"},
+ {file = "pillow-10.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a"},
+ {file = "pillow-10.2.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868"},
+ {file = "pillow-10.2.0.tar.gz", hash = "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e"},
+]
+
+[package.extras]
+docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"]
+fpx = ["olefile"]
+mic = ["olefile"]
+tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
+typing = ["typing-extensions"]
+xmp = ["defusedxml"]
+
+[[package]]
+name = "platformdirs"
+version = "4.2.0"
+description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"},
+ {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"},
+]
+
+[package.extras]
+docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
+test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
+
+[[package]]
+name = "pluggy"
+version = "1.4.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"},
+ {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pygments"
+version = "2.17.2"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"},
+ {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"},
+]
+
+[package.extras]
+plugins = ["importlib-metadata"]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "pytest"
+version = "8.0.2"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"},
+ {file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+iniconfig = "*"
+packaging = "*"
+pluggy = ">=1.3.0,<2.0"
+
+[package.extras]
+testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-cov"
+version = "4.1.0"
+description = "Pytest plugin for measuring coverage."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"},
+ {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"},
+]
+
+[package.dependencies]
+coverage = {version = ">=5.2.1", extras = ["toml"]}
+pytest = ">=4.6"
+
+[package.extras]
+testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
+[[package]]
+name = "python-dotenv"
+version = "1.0.1"
+description = "Read key-value pairs from a .env file and set them as environment variables"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
+ {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
+]
+
+[package.extras]
+cli = ["click (>=5.0)"]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.1"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.6"
+files = [
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
+ {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
+ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+ {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
+ {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
+ {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
+ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+ {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
+ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+ {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
+ {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
+ {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
+ {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
+ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+ {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
+ {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
+ {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
+ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
+ {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
+]
+
+[[package]]
+name = "requests"
+version = "2.31.0"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
+ {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset-normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "ruff"
+version = "0.3.0"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "ruff-0.3.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7deb528029bacf845bdbb3dbb2927d8ef9b4356a5e731b10eef171e3f0a85944"},
+ {file = "ruff-0.3.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e1e0d4381ca88fb2b73ea0766008e703f33f460295de658f5467f6f229658c19"},
+ {file = "ruff-0.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f7dbba46e2827dfcb0f0cc55fba8e96ba7c8700e0a866eb8cef7d1d66c25dcb"},
+ {file = "ruff-0.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23dbb808e2f1d68eeadd5f655485e235c102ac6f12ad31505804edced2a5ae77"},
+ {file = "ruff-0.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ef655c51f41d5fa879f98e40c90072b567c666a7114fa2d9fe004dffba00932"},
+ {file = "ruff-0.3.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d0d3d7ef3d4f06433d592e5f7d813314a34601e6c5be8481cccb7fa760aa243e"},
+ {file = "ruff-0.3.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b08b356d06a792e49a12074b62222f9d4ea2a11dca9da9f68163b28c71bf1dd4"},
+ {file = "ruff-0.3.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9343690f95710f8cf251bee1013bf43030072b9f8d012fbed6ad702ef70d360a"},
+ {file = "ruff-0.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1f3ed501a42f60f4dedb7805fa8d4534e78b4e196f536bac926f805f0743d49"},
+ {file = "ruff-0.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cc30a9053ff2f1ffb505a585797c23434d5f6c838bacfe206c0e6cf38c921a1e"},
+ {file = "ruff-0.3.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5da894a29ec018a8293d3d17c797e73b374773943e8369cfc50495573d396933"},
+ {file = "ruff-0.3.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:755c22536d7f1889be25f2baf6fedd019d0c51d079e8417d4441159f3bcd30c2"},
+ {file = "ruff-0.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd73fe7f4c28d317855da6a7bc4aa29a1500320818dd8f27df95f70a01b8171f"},
+ {file = "ruff-0.3.0-py3-none-win32.whl", hash = "sha256:19eacceb4c9406f6c41af806418a26fdb23120dfe53583df76d1401c92b7c14b"},
+ {file = "ruff-0.3.0-py3-none-win_amd64.whl", hash = "sha256:128265876c1d703e5f5e5a4543bd8be47c73a9ba223fd3989d4aa87dd06f312f"},
+ {file = "ruff-0.3.0-py3-none-win_arm64.whl", hash = "sha256:e3a4a6d46aef0a84b74fcd201a4401ea9a6cd85614f6a9435f2d33dd8cefbf83"},
+ {file = "ruff-0.3.0.tar.gz", hash = "sha256:0886184ba2618d815067cf43e005388967b67ab9c80df52b32ec1152ab49f53a"},
+]
+
+[[package]]
+name = "six"
+version = "1.16.0"
+description = "Python 2 and 3 compatibility utilities"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
+files = [
+ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
+ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+description = "Sniff out which async library your code is running under"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
+ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
+]
+
+[[package]]
+name = "snowballstemmer"
+version = "2.2.0"
+description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
+optional = false
+python-versions = "*"
+files = [
+ {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
+ {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.5"
+description = "A modern CSS selector implementation for Beautiful Soup."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"},
+ {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"},
+]
+
+[[package]]
+name = "sphinx"
+version = "7.2.6"
+description = "Python documentation generator"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "sphinx-7.2.6-py3-none-any.whl", hash = "sha256:1e09160a40b956dc623c910118fa636da93bd3ca0b9876a7b3df90f07d691560"},
+ {file = "sphinx-7.2.6.tar.gz", hash = "sha256:9a5160e1ea90688d5963ba09a2dcd8bdd526620edbb65c328728f1b2228d5ab5"},
+]
+
+[package.dependencies]
+alabaster = ">=0.7,<0.8"
+babel = ">=2.9"
+colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
+docutils = ">=0.18.1,<0.21"
+imagesize = ">=1.3"
+Jinja2 = ">=3.0"
+packaging = ">=21.0"
+Pygments = ">=2.14"
+requests = ">=2.25.0"
+snowballstemmer = ">=2.0"
+sphinxcontrib-applehelp = "*"
+sphinxcontrib-devhelp = "*"
+sphinxcontrib-htmlhelp = ">=2.0.0"
+sphinxcontrib-jsmath = "*"
+sphinxcontrib-qthelp = "*"
+sphinxcontrib-serializinghtml = ">=1.1.9"
+
+[package.extras]
+docs = ["sphinxcontrib-websupport"]
+lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"]
+test = ["cython (>=3.0)", "filelock", "html5lib", "pytest (>=4.6)", "setuptools (>=67.0)"]
+
+[[package]]
+name = "sphinx-basic-ng"
+version = "1.0.0b2"
+description = "A modern skeleton for Sphinx themes."
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"},
+ {file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"},
+]
+
+[package.dependencies]
+sphinx = ">=4.0"
+
+[package.extras]
+docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"]
+
+[[package]]
+name = "sphinxcontrib-applehelp"
+version = "1.0.8"
+description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "sphinxcontrib_applehelp-1.0.8-py3-none-any.whl", hash = "sha256:cb61eb0ec1b61f349e5cc36b2028e9e7ca765be05e49641c97241274753067b4"},
+ {file = "sphinxcontrib_applehelp-1.0.8.tar.gz", hash = "sha256:c40a4f96f3776c4393d933412053962fac2b84f4c99a7982ba42e09576a70619"},
+]
+
+[package.extras]
+lint = ["docutils-stubs", "flake8", "mypy"]
+standalone = ["Sphinx (>=5)"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-devhelp"
+version = "1.0.6"
+description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "sphinxcontrib_devhelp-1.0.6-py3-none-any.whl", hash = "sha256:6485d09629944511c893fa11355bda18b742b83a2b181f9a009f7e500595c90f"},
+ {file = "sphinxcontrib_devhelp-1.0.6.tar.gz", hash = "sha256:9893fd3f90506bc4b97bdb977ceb8fbd823989f4316b28c3841ec128544372d3"},
+]
+
+[package.extras]
+lint = ["docutils-stubs", "flake8", "mypy"]
+standalone = ["Sphinx (>=5)"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-htmlhelp"
+version = "2.0.5"
+description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "sphinxcontrib_htmlhelp-2.0.5-py3-none-any.whl", hash = "sha256:393f04f112b4d2f53d93448d4bce35842f62b307ccdc549ec1585e950bc35e04"},
+ {file = "sphinxcontrib_htmlhelp-2.0.5.tar.gz", hash = "sha256:0dc87637d5de53dd5eec3a6a01753b1ccf99494bd756aafecd74b4fa9e729015"},
+]
+
+[package.extras]
+lint = ["docutils-stubs", "flake8", "mypy"]
+standalone = ["Sphinx (>=5)"]
+test = ["html5lib", "pytest"]
+
+[[package]]
+name = "sphinxcontrib-jsmath"
+version = "1.0.1"
+description = "A sphinx extension which renders display math in HTML via JavaScript"
+optional = false
+python-versions = ">=3.5"
+files = [
+ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
+ {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
+]
+
+[package.extras]
+test = ["flake8", "mypy", "pytest"]
+
+[[package]]
+name = "sphinxcontrib-qthelp"
+version = "1.0.7"
+description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "sphinxcontrib_qthelp-1.0.7-py3-none-any.whl", hash = "sha256:e2ae3b5c492d58fcbd73281fbd27e34b8393ec34a073c792642cd8e529288182"},
+ {file = "sphinxcontrib_qthelp-1.0.7.tar.gz", hash = "sha256:053dedc38823a80a7209a80860b16b722e9e0209e32fea98c90e4e6624588ed6"},
+]
+
+[package.extras]
+lint = ["docutils-stubs", "flake8", "mypy"]
+standalone = ["Sphinx (>=5)"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-serializinghtml"
+version = "1.1.10"
+description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "sphinxcontrib_serializinghtml-1.1.10-py3-none-any.whl", hash = "sha256:326369b8df80a7d2d8d7f99aa5ac577f51ea51556ed974e7716cfd4fca3f6cb7"},
+ {file = "sphinxcontrib_serializinghtml-1.1.10.tar.gz", hash = "sha256:93f3f5dc458b91b192fe10c397e324f262cf163d79f3282c158e8436a2c4511f"},
+]
+
+[package.extras]
+lint = ["docutils-stubs", "flake8", "mypy"]
+standalone = ["Sphinx (>=5)"]
+test = ["pytest"]
+
+[[package]]
+name = "sqlalchemy"
+version = "2.0.28"
+description = "Database Abstraction Library"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "SQLAlchemy-2.0.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0b148ab0438f72ad21cb004ce3bdaafd28465c4276af66df3b9ecd2037bf252"},
+ {file = "SQLAlchemy-2.0.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bbda76961eb8f27e6ad3c84d1dc56d5bc61ba8f02bd20fcf3450bd421c2fcc9c"},
+ {file = "SQLAlchemy-2.0.28-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5da98815f82dce0cb31fd1e873a0cb30934971d15b74e0d78cf21f9e1b05953f"},
+ {file = "SQLAlchemy-2.0.28-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:56856b871146bfead25fbcaed098269d90b744eea5cb32a952df00d542cdd368"},
+ {file = "SQLAlchemy-2.0.28-cp310-cp310-win32.whl", hash = "sha256:943aa74a11f5806ab68278284a4ddd282d3fb348a0e96db9b42cb81bf731acdc"},
+ {file = "SQLAlchemy-2.0.28-cp310-cp310-win_amd64.whl", hash = "sha256:c6c4da4843e0dabde41b8f2e8147438330924114f541949e6318358a56d1875a"},
+ {file = "SQLAlchemy-2.0.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46a3d4e7a472bfff2d28db838669fc437964e8af8df8ee1e4548e92710929adc"},
+ {file = "SQLAlchemy-2.0.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0d3dd67b5d69794cfe82862c002512683b3db038b99002171f624712fa71aeaa"},
+ {file = "SQLAlchemy-2.0.28-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0315d9125a38026227f559488fe7f7cee1bd2fbc19f9fd637739dc50bb6380b2"},
+ {file = "SQLAlchemy-2.0.28-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:81ba314a08c7ab701e621b7ad079c0c933c58cdef88593c59b90b996e8b58fa5"},
+ {file = "SQLAlchemy-2.0.28-cp311-cp311-win32.whl", hash = "sha256:1ee8bd6d68578e517943f5ebff3afbd93fc65f7ef8f23becab9fa8fb315afb1d"},
+ {file = "SQLAlchemy-2.0.28-cp311-cp311-win_amd64.whl", hash = "sha256:ad7acbe95bac70e4e687a4dc9ae3f7a2f467aa6597049eeb6d4a662ecd990bb6"},
+ {file = "SQLAlchemy-2.0.28-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d3499008ddec83127ab286c6f6ec82a34f39c9817f020f75eca96155f9765097"},
+ {file = "SQLAlchemy-2.0.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b66fcd38659cab5d29e8de5409cdf91e9986817703e1078b2fdaad731ea66f5"},
+ {file = "SQLAlchemy-2.0.28-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:124202b4e0edea7f08a4db8c81cc7859012f90a0d14ba2bf07c099aff6e96462"},
+ {file = "SQLAlchemy-2.0.28-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b6303bfd78fb3221847723104d152e5972c22367ff66edf09120fcde5ddc2e2"},
+ {file = "SQLAlchemy-2.0.28-cp312-cp312-win32.whl", hash = "sha256:a921002be69ac3ab2cf0c3017c4e6a3377f800f1fca7f254c13b5f1a2f10022c"},
+ {file = "SQLAlchemy-2.0.28-cp312-cp312-win_amd64.whl", hash = "sha256:b4a2cf92995635b64876dc141af0ef089c6eea7e05898d8d8865e71a326c0385"},
+ {file = "SQLAlchemy-2.0.28-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e91b5e341f8c7f1e5020db8e5602f3ed045a29f8e27f7f565e0bdee3338f2c7"},
+ {file = "SQLAlchemy-2.0.28-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3eba73ef2c30695cb7eabcdb33bb3d0b878595737479e152468f3ba97a9c22a4"},
+ {file = "SQLAlchemy-2.0.28-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2858bbab1681ee5406650202950dc8f00e83b06a198741b7c656e63818633526"},
+ {file = "SQLAlchemy-2.0.28-cp37-cp37m-win32.whl", hash = "sha256:9461802f2e965de5cff80c5a13bc945abea7edaa1d29360b485c3d2b56cdb075"},
+ {file = "SQLAlchemy-2.0.28-cp37-cp37m-win_amd64.whl", hash = "sha256:a6bec1c010a6d65b3ed88c863d56b9ea5eeefdf62b5e39cafd08c65f5ce5198b"},
+ {file = "SQLAlchemy-2.0.28-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:843a882cadebecc655a68bd9a5b8aa39b3c52f4a9a5572a3036fb1bb2ccdc197"},
+ {file = "SQLAlchemy-2.0.28-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dbb990612c36163c6072723523d2be7c3eb1517bbdd63fe50449f56afafd1133"},
+ {file = "SQLAlchemy-2.0.28-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0a5354cb4de9b64bccb6ea33162cb83e03dbefa0d892db88a672f5aad638a75"},
+ {file = "SQLAlchemy-2.0.28-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aca7b6d99a4541b2ebab4494f6c8c2f947e0df4ac859ced575238e1d6ca5716b"},
+ {file = "SQLAlchemy-2.0.28-cp38-cp38-win32.whl", hash = "sha256:8c7f10720fc34d14abad5b647bc8202202f4948498927d9f1b4df0fb1cf391b7"},
+ {file = "SQLAlchemy-2.0.28-cp38-cp38-win_amd64.whl", hash = "sha256:243feb6882b06a2af68ecf4bec8813d99452a1b62ba2be917ce6283852cf701b"},
+ {file = "SQLAlchemy-2.0.28-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fc4974d3684f28b61b9a90fcb4c41fb340fd4b6a50c04365704a4da5a9603b05"},
+ {file = "SQLAlchemy-2.0.28-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87724e7ed2a936fdda2c05dbd99d395c91ea3c96f029a033a4a20e008dd876bf"},
+ {file = "SQLAlchemy-2.0.28-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:328529f7c7f90adcd65aed06a161851f83f475c2f664a898af574893f55d9e53"},
+ {file = "SQLAlchemy-2.0.28-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:426f2fa71331a64f5132369ede5171c52fd1df1bd9727ce621f38b5b24f48750"},
+ {file = "SQLAlchemy-2.0.28-cp39-cp39-win32.whl", hash = "sha256:33157920b233bc542ce497a81a2e1452e685a11834c5763933b440fedd1d8e2d"},
+ {file = "SQLAlchemy-2.0.28-cp39-cp39-win_amd64.whl", hash = "sha256:2f60843068e432311c886c5f03c4664acaef507cf716f6c60d5fde7265be9d7b"},
+ {file = "SQLAlchemy-2.0.28-py3-none-any.whl", hash = "sha256:78bb7e8da0183a8301352d569900d9d3594c48ac21dc1c2ec6b3121ed8b6c986"},
+ {file = "SQLAlchemy-2.0.28.tar.gz", hash = "sha256:dd53b6c4e6d960600fd6532b79ee28e2da489322fcf6648738134587faf767b6"},
+]
+
+[package.dependencies]
+aiosqlite = {version = "*", optional = true, markers = "extra == \"aiosqlite\""}
+greenlet = {version = "!=0.4.17", optional = true, markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or extra == \"asyncio\" or extra == \"aiosqlite\""}
+typing-extensions = {version = ">=4.6.0", optional = true, markers = "extra == \"aiosqlite\""}
+
+[package.extras]
+aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"]
+aioodbc = ["aioodbc", "greenlet (!=0.4.17)"]
+aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"]
+asyncio = ["greenlet (!=0.4.17)"]
+asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"]
+mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"]
+mssql = ["pyodbc"]
+mssql-pymssql = ["pymssql"]
+mssql-pyodbc = ["pyodbc"]
+mypy = ["mypy (>=0.910)"]
+mysql = ["mysqlclient (>=1.4.0)"]
+mysql-connector = ["mysql-connector-python"]
+oracle = ["cx_oracle (>=8)"]
+oracle-oracledb = ["oracledb (>=1.0.1)"]
+postgresql = ["psycopg2 (>=2.7)"]
+postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
+postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
+postgresql-psycopg = ["psycopg (>=3.0.7)"]
+postgresql-psycopg2binary = ["psycopg2-binary"]
+postgresql-psycopg2cffi = ["psycopg2cffi"]
+postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
+pymysql = ["pymysql"]
+sqlcipher = ["sqlcipher3_binary"]
+
+[[package]]
+name = "starlette"
+version = "0.37.1"
+description = "The little ASGI library that shines."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "starlette-0.37.1-py3-none-any.whl", hash = "sha256:92a816002d4e8c552477b089520e3085bb632e854eb32cef99acb6f6f7830b69"},
+ {file = "starlette-0.37.1.tar.gz", hash = "sha256:345cfd562236b557e76a045715ac66fdc355a1e7e617b087834a76a87dcc6533"},
+]
+
+[package.dependencies]
+anyio = ">=3.4.0,<5"
+
+[package.extras]
+full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
+
+[[package]]
+name = "strawberry-graphql"
+version = "0.219.2"
+description = "A library for creating GraphQL APIs"
+optional = false
+python-versions = ">=3.8,<4.0"
+files = [
+ {file = "strawberry_graphql-0.219.2-py3-none-any.whl", hash = "sha256:6b26994bcf092714dbd6def4c69a36c279fbdbfdc2390fb6294728dd076ba863"},
+ {file = "strawberry_graphql-0.219.2.tar.gz", hash = "sha256:b7a9b3115398402ec04fceb76b0dd3908dd90009cf20d344071be985c039aac7"},
+]
+
+[package.dependencies]
+graphql-core = ">=3.2.0,<3.3.0"
+python-dateutil = ">=2.7.0,<3.0.0"
+typing-extensions = ">=4.5.0"
+
+[package.extras]
+aiohttp = ["aiohttp (>=3.7.4.post0,<4.0.0)"]
+asgi = ["python-multipart (>=0.0.7)", "starlette (>=0.18.0)"]
+chalice = ["chalice (>=1.22,<2.0)"]
+channels = ["asgiref (>=3.2,<4.0)", "channels (>=3.0.5)"]
+cli = ["libcst (>=0.4.7)", "pygments (>=2.3,<3.0)", "rich (>=12.0.0)", "typer (>=0.7.0)"]
+debug = ["libcst (>=0.4.7)", "rich (>=12.0.0)"]
+debug-server = ["libcst (>=0.4.7)", "pygments (>=2.3,<3.0)", "python-multipart (>=0.0.7)", "rich (>=12.0.0)", "starlette (>=0.18.0)", "typer (>=0.7.0)", "uvicorn (>=0.11.6)"]
+django = ["Django (>=3.2)", "asgiref (>=3.2,<4.0)"]
+fastapi = ["fastapi (>=0.65.2)", "python-multipart (>=0.0.7)"]
+flask = ["flask (>=1.1)"]
+litestar = ["litestar (>=2)"]
+opentelemetry = ["opentelemetry-api (<2)", "opentelemetry-sdk (<2)"]
+pydantic = ["pydantic (>1.6.1)"]
+pyinstrument = ["pyinstrument (>=4.0.0)"]
+quart = ["quart (>=0.19.3)"]
+sanic = ["sanic (>=20.12.2)"]
+starlite = ["starlite (>=1.48.0)"]
+
+[[package]]
+name = "types-pillow"
+version = "10.2.0.20240213"
+description = "Typing stubs for Pillow"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "types-Pillow-10.2.0.20240213.tar.gz", hash = "sha256:4800b61bf7eabdae2f1b17ade0d080709ed33e9f26a2e900e470e8b56ebe2387"},
+ {file = "types_Pillow-10.2.0.20240213-py3-none-any.whl", hash = "sha256:062c5a0f20301a30f2df4db583f15b3c2a1283a12518d1f9d81396154e12c1af"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.10.0"
+description = "Backported and Experimental Type Hints for Python 3.8+"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"},
+ {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"},
+]
+
+[[package]]
+name = "urllib3"
+version = "2.2.1"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"},
+ {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "uvicorn"
+version = "0.27.1"
+description = "The lightning-fast ASGI server."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"},
+ {file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"},
+]
+
+[package.dependencies]
+click = ">=7.0"
+colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""}
+h11 = ">=0.8"
+httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""}
+python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
+pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
+uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
+watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
+websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
+
+[package.extras]
+standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"]
+
+[[package]]
+name = "uvloop"
+version = "0.19.0"
+description = "Fast implementation of asyncio event loop on top of libuv"
+optional = false
+python-versions = ">=3.8.0"
+files = [
+ {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de4313d7f575474c8f5a12e163f6d89c0a878bc49219641d49e6f1444369a90e"},
+ {file = "uvloop-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5588bd21cf1fcf06bded085f37e43ce0e00424197e7c10e77afd4bbefffef428"},
+ {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1fd71c3843327f3bbc3237bedcdb6504fd50368ab3e04d0410e52ec293f5b8"},
+ {file = "uvloop-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a05128d315e2912791de6088c34136bfcdd0c7cbc1cf85fd6fd1bb321b7c849"},
+ {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cd81bdc2b8219cb4b2556eea39d2e36bfa375a2dd021404f90a62e44efaaf957"},
+ {file = "uvloop-0.19.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5f17766fb6da94135526273080f3455a112f82570b2ee5daa64d682387fe0dcd"},
+ {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ce6b0af8f2729a02a5d1575feacb2a94fc7b2e983868b009d51c9a9d2149bef"},
+ {file = "uvloop-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:31e672bb38b45abc4f26e273be83b72a0d28d074d5b370fc4dcf4c4eb15417d2"},
+ {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:570fc0ed613883d8d30ee40397b79207eedd2624891692471808a95069a007c1"},
+ {file = "uvloop-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5138821e40b0c3e6c9478643b4660bd44372ae1e16a322b8fc07478f92684e24"},
+ {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:91ab01c6cd00e39cde50173ba4ec68a1e578fee9279ba64f5221810a9e786533"},
+ {file = "uvloop-0.19.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:47bf3e9312f63684efe283f7342afb414eea4d3011542155c7e625cd799c3b12"},
+ {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:da8435a3bd498419ee8c13c34b89b5005130a476bda1d6ca8cfdde3de35cd650"},
+ {file = "uvloop-0.19.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:02506dc23a5d90e04d4f65c7791e65cf44bd91b37f24cfc3ef6cf2aff05dc7ec"},
+ {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693049be9d36fef81741fddb3f441673ba12a34a704e7b4361efb75cf30befc"},
+ {file = "uvloop-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7010271303961c6f0fe37731004335401eb9075a12680738731e9c92ddd96ad6"},
+ {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5daa304d2161d2918fa9a17d5635099a2f78ae5b5960e742b2fcfbb7aefaa593"},
+ {file = "uvloop-0.19.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7207272c9520203fea9b93843bb775d03e1cf88a80a936ce760f60bb5add92f3"},
+ {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:78ab247f0b5671cc887c31d33f9b3abfb88d2614b84e4303f1a63b46c046c8bd"},
+ {file = "uvloop-0.19.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:472d61143059c84947aa8bb74eabbace30d577a03a1805b77933d6bd13ddebbd"},
+ {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45bf4c24c19fb8a50902ae37c5de50da81de4922af65baf760f7c0c42e1088be"},
+ {file = "uvloop-0.19.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271718e26b3e17906b28b67314c45d19106112067205119dddbd834c2b7ce797"},
+ {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:34175c9fd2a4bc3adc1380e1261f60306344e3407c20a4d684fd5f3be010fa3d"},
+ {file = "uvloop-0.19.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e27f100e1ff17f6feeb1f33968bc185bf8ce41ca557deee9d9bbbffeb72030b7"},
+ {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:13dfdf492af0aa0a0edf66807d2b465607d11c4fa48f4a1fd41cbea5b18e8e8b"},
+ {file = "uvloop-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6e3d4e85ac060e2342ff85e90d0c04157acb210b9ce508e784a944f852a40e67"},
+ {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca4956c9ab567d87d59d49fa3704cf29e37109ad348f2d5223c9bf761a332e7"},
+ {file = "uvloop-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f467a5fd23b4fc43ed86342641f3936a68ded707f4627622fa3f82a120e18256"},
+ {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:492e2c32c2af3f971473bc22f086513cedfc66a130756145a931a90c3958cb17"},
+ {file = "uvloop-0.19.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2df95fca285a9f5bfe730e51945ffe2fa71ccbfdde3b0da5772b4ee4f2e770d5"},
+ {file = "uvloop-0.19.0.tar.gz", hash = "sha256:0246f4fd1bf2bf702e06b0d45ee91677ee5c31242f39aab4ea6fe0c51aedd0fd"},
+]
+
+[package.extras]
+docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"]
+test = ["Cython (>=0.29.36,<0.30.0)", "aiohttp (==3.9.0b0)", "aiohttp (>=3.8.1)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"]
+
+[[package]]
+name = "watchfiles"
+version = "0.21.0"
+description = "Simple, modern and high performance file watching and code reload in python."
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "watchfiles-0.21.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:27b4035013f1ea49c6c0b42d983133b136637a527e48c132d368eb19bf1ac6aa"},
+ {file = "watchfiles-0.21.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c81818595eff6e92535ff32825f31c116f867f64ff8cdf6562cd1d6b2e1e8f3e"},
+ {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6c107ea3cf2bd07199d66f156e3ea756d1b84dfd43b542b2d870b77868c98c03"},
+ {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d9ac347653ebd95839a7c607608703b20bc07e577e870d824fa4801bc1cb124"},
+ {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5eb86c6acb498208e7663ca22dbe68ca2cf42ab5bf1c776670a50919a56e64ab"},
+ {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f564bf68404144ea6b87a78a3f910cc8de216c6b12a4cf0b27718bf4ec38d303"},
+ {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d0f32ebfaa9c6011f8454994f86108c2eb9c79b8b7de00b36d558cadcedaa3d"},
+ {file = "watchfiles-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d45d9b699ecbac6c7bd8e0a2609767491540403610962968d258fd6405c17c"},
+ {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:aff06b2cac3ef4616e26ba17a9c250c1fe9dd8a5d907d0193f84c499b1b6e6a9"},
+ {file = "watchfiles-0.21.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d9792dff410f266051025ecfaa927078b94cc7478954b06796a9756ccc7e14a9"},
+ {file = "watchfiles-0.21.0-cp310-none-win32.whl", hash = "sha256:214cee7f9e09150d4fb42e24919a1e74d8c9b8a9306ed1474ecaddcd5479c293"},
+ {file = "watchfiles-0.21.0-cp310-none-win_amd64.whl", hash = "sha256:1ad7247d79f9f55bb25ab1778fd47f32d70cf36053941f07de0b7c4e96b5d235"},
+ {file = "watchfiles-0.21.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:668c265d90de8ae914f860d3eeb164534ba2e836811f91fecc7050416ee70aa7"},
+ {file = "watchfiles-0.21.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a23092a992e61c3a6a70f350a56db7197242f3490da9c87b500f389b2d01eef"},
+ {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e7941bbcfdded9c26b0bf720cb7e6fd803d95a55d2c14b4bd1f6a2772230c586"},
+ {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11cd0c3100e2233e9c53106265da31d574355c288e15259c0d40a4405cbae317"},
+ {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78f30cbe8b2ce770160d3c08cff01b2ae9306fe66ce899b73f0409dc1846c1b"},
+ {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6674b00b9756b0af620aa2a3346b01f8e2a3dc729d25617e1b89cf6af4a54eb1"},
+ {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fd7ac678b92b29ba630d8c842d8ad6c555abda1b9ef044d6cc092dacbfc9719d"},
+ {file = "watchfiles-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c873345680c1b87f1e09e0eaf8cf6c891b9851d8b4d3645e7efe2ec20a20cc7"},
+ {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:49f56e6ecc2503e7dbe233fa328b2be1a7797d31548e7a193237dcdf1ad0eee0"},
+ {file = "watchfiles-0.21.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:02d91cbac553a3ad141db016e3350b03184deaafeba09b9d6439826ee594b365"},
+ {file = "watchfiles-0.21.0-cp311-none-win32.whl", hash = "sha256:ebe684d7d26239e23d102a2bad2a358dedf18e462e8808778703427d1f584400"},
+ {file = "watchfiles-0.21.0-cp311-none-win_amd64.whl", hash = "sha256:4566006aa44cb0d21b8ab53baf4b9c667a0ed23efe4aaad8c227bfba0bf15cbe"},
+ {file = "watchfiles-0.21.0-cp311-none-win_arm64.whl", hash = "sha256:c550a56bf209a3d987d5a975cdf2063b3389a5d16caf29db4bdddeae49f22078"},
+ {file = "watchfiles-0.21.0-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:51ddac60b96a42c15d24fbdc7a4bfcd02b5a29c047b7f8bf63d3f6f5a860949a"},
+ {file = "watchfiles-0.21.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:511f0b034120cd1989932bf1e9081aa9fb00f1f949fbd2d9cab6264916ae89b1"},
+ {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:cfb92d49dbb95ec7a07511bc9efb0faff8fe24ef3805662b8d6808ba8409a71a"},
+ {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f92944efc564867bbf841c823c8b71bb0be75e06b8ce45c084b46411475a915"},
+ {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:642d66b75eda909fd1112d35c53816d59789a4b38c141a96d62f50a3ef9b3360"},
+ {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d23bcd6c8eaa6324fe109d8cac01b41fe9a54b8c498af9ce464c1aeeb99903d6"},
+ {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18d5b4da8cf3e41895b34e8c37d13c9ed294954907929aacd95153508d5d89d7"},
+ {file = "watchfiles-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b8d1eae0f65441963d805f766c7e9cd092f91e0c600c820c764a4ff71a0764c"},
+ {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1fd9a5205139f3c6bb60d11f6072e0552f0a20b712c85f43d42342d162be1235"},
+ {file = "watchfiles-0.21.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a1e3014a625bcf107fbf38eece0e47fa0190e52e45dc6eee5a8265ddc6dc5ea7"},
+ {file = "watchfiles-0.21.0-cp312-none-win32.whl", hash = "sha256:9d09869f2c5a6f2d9df50ce3064b3391d3ecb6dced708ad64467b9e4f2c9bef3"},
+ {file = "watchfiles-0.21.0-cp312-none-win_amd64.whl", hash = "sha256:18722b50783b5e30a18a8a5db3006bab146d2b705c92eb9a94f78c72beb94094"},
+ {file = "watchfiles-0.21.0-cp312-none-win_arm64.whl", hash = "sha256:a3b9bec9579a15fb3ca2d9878deae789df72f2b0fdaf90ad49ee389cad5edab6"},
+ {file = "watchfiles-0.21.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:4ea10a29aa5de67de02256a28d1bf53d21322295cb00bd2d57fcd19b850ebd99"},
+ {file = "watchfiles-0.21.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:40bca549fdc929b470dd1dbfcb47b3295cb46a6d2c90e50588b0a1b3bd98f429"},
+ {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9b37a7ba223b2f26122c148bb8d09a9ff312afca998c48c725ff5a0a632145f7"},
+ {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec8c8900dc5c83650a63dd48c4d1d245343f904c4b64b48798c67a3767d7e165"},
+ {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ad3fe0a3567c2f0f629d800409cd528cb6251da12e81a1f765e5c5345fd0137"},
+ {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d353c4cfda586db2a176ce42c88f2fc31ec25e50212650c89fdd0f560ee507b"},
+ {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:83a696da8922314ff2aec02987eefb03784f473281d740bf9170181829133765"},
+ {file = "watchfiles-0.21.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a03651352fc20975ee2a707cd2d74a386cd303cc688f407296064ad1e6d1562"},
+ {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:3ad692bc7792be8c32918c699638b660c0de078a6cbe464c46e1340dadb94c19"},
+ {file = "watchfiles-0.21.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06247538e8253975bdb328e7683f8515ff5ff041f43be6c40bff62d989b7d0b0"},
+ {file = "watchfiles-0.21.0-cp38-none-win32.whl", hash = "sha256:9a0aa47f94ea9a0b39dd30850b0adf2e1cd32a8b4f9c7aa443d852aacf9ca214"},
+ {file = "watchfiles-0.21.0-cp38-none-win_amd64.whl", hash = "sha256:8d5f400326840934e3507701f9f7269247f7c026d1b6cfd49477d2be0933cfca"},
+ {file = "watchfiles-0.21.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:7f762a1a85a12cc3484f77eee7be87b10f8c50b0b787bb02f4e357403cad0c0e"},
+ {file = "watchfiles-0.21.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6e9be3ef84e2bb9710f3f777accce25556f4a71e15d2b73223788d528fcc2052"},
+ {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4c48a10d17571d1275701e14a601e36959ffada3add8cdbc9e5061a6e3579a5d"},
+ {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c889025f59884423428c261f212e04d438de865beda0b1e1babab85ef4c0f01"},
+ {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66fac0c238ab9a2e72d026b5fb91cb902c146202bbd29a9a1a44e8db7b710b6f"},
+ {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4a21f71885aa2744719459951819e7bf5a906a6448a6b2bbce8e9cc9f2c8128"},
+ {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c9198c989f47898b2c22201756f73249de3748e0fc9de44adaf54a8b259cc0c"},
+ {file = "watchfiles-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f57c4461cd24fda22493109c45b3980863c58a25b8bec885ca8bea6b8d4b28"},
+ {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:853853cbf7bf9408b404754b92512ebe3e3a83587503d766d23e6bf83d092ee6"},
+ {file = "watchfiles-0.21.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d5b1dc0e708fad9f92c296ab2f948af403bf201db8fb2eb4c8179db143732e49"},
+ {file = "watchfiles-0.21.0-cp39-none-win32.whl", hash = "sha256:59137c0c6826bd56c710d1d2bda81553b5e6b7c84d5a676747d80caf0409ad94"},
+ {file = "watchfiles-0.21.0-cp39-none-win_amd64.whl", hash = "sha256:6cb8fdc044909e2078c248986f2fc76f911f72b51ea4a4fbbf472e01d14faa58"},
+ {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:ab03a90b305d2588e8352168e8c5a1520b721d2d367f31e9332c4235b30b8994"},
+ {file = "watchfiles-0.21.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:927c589500f9f41e370b0125c12ac9e7d3a2fd166b89e9ee2828b3dda20bfe6f"},
+ {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd467213195e76f838caf2c28cd65e58302d0254e636e7c0fca81efa4a2e62c"},
+ {file = "watchfiles-0.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02b73130687bc3f6bb79d8a170959042eb56eb3a42df3671c79b428cd73f17cc"},
+ {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:08dca260e85ffae975448e344834d765983237ad6dc308231aa16e7933db763e"},
+ {file = "watchfiles-0.21.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ccceb50c611c433145502735e0370877cced72a6c70fd2410238bcbc7fe51d8"},
+ {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57d430f5fb63fea141ab71ca9c064e80de3a20b427ca2febcbfcef70ff0ce895"},
+ {file = "watchfiles-0.21.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dd5fad9b9c0dd89904bbdea978ce89a2b692a7ee8a0ce19b940e538c88a809c"},
+ {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:be6dd5d52b73018b21adc1c5d28ac0c68184a64769052dfeb0c5d9998e7f56a2"},
+ {file = "watchfiles-0.21.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b3cab0e06143768499384a8a5efb9c4dc53e19382952859e4802f294214f36ec"},
+ {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6ed10c2497e5fedadf61e465b3ca12a19f96004c15dcffe4bd442ebadc2d85"},
+ {file = "watchfiles-0.21.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43babacef21c519bc6631c5fce2a61eccdfc011b4bcb9047255e9620732c8097"},
+ {file = "watchfiles-0.21.0.tar.gz", hash = "sha256:c76c635fabf542bb78524905718c39f736a98e5ab25b23ec6d4abede1a85a6a3"},
+]
+
+[package.dependencies]
+anyio = ">=3.0.0"
+
+[[package]]
+name = "websockets"
+version = "12.0"
+description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"},
+ {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"},
+ {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"},
+ {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"},
+ {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"},
+ {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"},
+ {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"},
+ {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"},
+ {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"},
+ {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"},
+ {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"},
+ {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"},
+ {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"},
+ {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"},
+ {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"},
+ {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"},
+ {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"},
+ {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"},
+ {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"},
+ {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"},
+ {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"},
+ {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"},
+ {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"},
+ {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"},
+ {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"},
+ {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"},
+ {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"},
+ {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"},
+ {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"},
+ {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"},
+ {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"},
+ {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"},
+ {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"},
+ {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"},
+ {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"},
+ {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"},
+ {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"},
+ {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"},
+ {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"},
+ {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"},
+ {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"},
+ {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"},
+ {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"},
+ {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"},
+ {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"},
+ {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"},
+ {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"},
+ {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"},
+ {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"},
+ {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"},
+ {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"},
+ {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"},
+ {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"},
+ {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"},
+ {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"},
+ {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"},
+ {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"},
+ {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"},
+ {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"},
+ {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"},
+ {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"},
+ {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"},
+ {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"},
+ {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"},
+ {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"},
+ {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"},
+ {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"},
+ {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"},
+ {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"},
+ {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"},
+ {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"},
+ {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"},
+]
+
+[metadata]
+lock-version = "2.0"
+python-versions = "^3.12"
+content-hash = "9758f498e625ff0b711cd49072b67731c84c4549fe6e12ae4db74649b123dc23"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..2af84e9
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,61 @@
+[tool.poetry]
+name = "hircine"
+version = "0.1.0"
+description = "A web-based comic organizer"
+authors = ["Wolfgang Müller <wolf@oriole.systems>"]
+license = "ISC"
+include = [{path = "src/hircine/static/**/*"}]
+
+[tool.poetry.scripts]
+hircine = 'hircine.cli:main'
+
+[tool.poetry.plugins."hircine.scraper"]
+gallery_dl = "hircine.plugins.scrapers.gallery_dl:GalleryDLScraper"
+ehentai_api = "hircine.plugins.scrapers.ehentai_api:EHentaiAPIScraper"
+anchira_yaml = "hircine.plugins.scrapers.anchira:AnchiraYamlScraper"
+
+[tool.poetry.dependencies]
+python = "^3.12"
+sqlalchemy = {version = "^2.0.0", extras = ["aiosqlite", "asyncio"]}
+strawberry-graphql = "^0.219.1"
+starlette = "^0.37.0"
+uvicorn = {extras = ["standard"], version = "^0.27.0.post1"}
+pillow = "^10.1.0"
+blake3 = "^0.4.1"
+alembic = "^1.13.1"
+requests = "^2.31.0"
+pyyaml = "^6.0.1"
+natsort = "^8.4.0"
+
+[tool.poetry.group.dev.dependencies]
+black = "^24.1.1"
+pytest = {extras = ["asyncio"], version = "^8.0.0"}
+ruff = "^0.3.0"
+types-pillow = "^10.1.0.2"
+pytest-cov = "^4.0.0"
+sphinx = "^7.2.6"
+furo = "^2024.1.29"
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.ruff]
+lint.select = ["E", "F", "I", "W"]
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+
+[tool.coverage.html]
+directory = "coverage"
+
+[tool.coverage.run]
+concurrency = ["greenlet", "multiprocessing"]
+omit = ["tests/*", "src/hircine/cli.py", "src/hircine/plugins/scrapers/*"]
+
+[tool.coverage.report]
+exclude_also = [
+ "if __name__ == \"__main__\":",
+ "def __repr__",
+ "@(abc\\.)?abstractmethod",
+]
diff --git a/scripts/dev.sh b/scripts/dev.sh
new file mode 100644
index 0000000..be7121c
--- /dev/null
+++ b/scripts/dev.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+export HIRCINE_DEV=1
+
+tmux new-session -ds hircine -n uvicorn -- '(cd instance/ && poetry run python -m hircine.app)'
+tmux new-window -t hircine: -n lint -- 'fd . -epy src/ tests/ | entr -c sh scripts/lint.sh'
+tmux new-window -t hircine: -n docs -- 'fd . -erst -epy docs/ | entr -c make docs'
+tmux attach-session -t hircine
diff --git a/scripts/lint.sh b/scripts/lint.sh
new file mode 100644
index 0000000..24289b0
--- /dev/null
+++ b/scripts/lint.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+
+poetry run ruff check --fix --show-fixes .
+poetry run black --quiet .
diff --git a/src/hircine/__init__.py b/src/hircine/__init__.py
new file mode 100644
index 0000000..38b969d
--- /dev/null
+++ b/src/hircine/__init__.py
@@ -0,0 +1 @@
+codename = "Satanic Satyr"
diff --git a/src/hircine/api/__init__.py b/src/hircine/api/__init__.py
new file mode 100644
index 0000000..951f375
--- /dev/null
+++ b/src/hircine/api/__init__.py
@@ -0,0 +1,28 @@
+import strawberry
+
+int = strawberry.scalar(int, name="int")
+
+
+class APIException(Exception):
+ def __init__(self, graphql_error):
+ self.graphql_error = graphql_error
+
+
+class MutationContext:
+ """
+ Relevant information and data for mutations as they are being resolved.
+
+ Attributes:
+ input: The strawberry input object for the mutation
+ root: The root object being modified by the mutation
+ session: The active SQLAlchemy session
+ model: The SQLAlchemy modelclass of the object being modified by the mutation
+ multiple: True if multiple objects in the database are being modified
+ """
+
+ def __init__(self, input, root, session, multiple=False):
+ self.session = session
+ self.input = input
+ self.root = root
+ self.model = type(root)
+ self.multiple = multiple
diff --git a/src/hircine/api/filters.py b/src/hircine/api/filters.py
new file mode 100644
index 0000000..ab44cf9
--- /dev/null
+++ b/src/hircine/api/filters.py
@@ -0,0 +1,347 @@
+from abc import ABC, abstractmethod
+from typing import Generic, List, Optional, TypeVar
+
+import strawberry
+from sqlalchemy import and_, func, or_, select
+from strawberry import UNSET
+
+import hircine.db
+from hircine.db.models import ComicTag
+from hircine.enums import Category, Censorship, Language, Rating
+
+T = TypeVar("T")
+
+
+class Matchable(ABC):
+ """
+ The filter interface is comprised of two methods, include and exclude, that
+ can freely modify an SQL statement passed to them.
+ """
+
+ @abstractmethod
+ def include(self, sql):
+ return sql
+
+ @abstractmethod
+ def exclude(self, sql):
+ return sql
+
+
+@strawberry.input
+class AssociationFilter(Matchable):
+ any: Optional[List[int]] = strawberry.field(default_factory=lambda: None)
+ all: Optional[List[int]] = strawberry.field(default_factory=lambda: None)
+ exact: Optional[List[int]] = strawberry.field(default_factory=lambda: None)
+ empty: Optional[bool] = None
+
+ def _exists(self, condition):
+ # The property.primaryjoin expression specifies the primary join path
+ # between the parent object of the column that was handed to the
+ # Matchable instance and the associated object.
+ #
+ # For example, if this AssociationFilter is parametrized as
+ # AssociationFilter[World], and is present on an input class that is
+ # mapped to the Comic model, the primaryjoin expression is as follows:
+ #
+ # comic.id = comic_worlds.comic_id
+ #
+ # This expression is used to correlate the subquery with the main query
+ # for the parent object.
+ #
+ # condition specifies any additional conditions we should match on.
+ # Usually these will come from the where generator, which correlates
+ # the secondary objects with the user-supplied ids.
+ return select(1).where((self.column.property.primaryjoin) & condition).exists()
+
+ def _any_exist(self, items):
+ return self._exists(or_(*self._collect(items)))
+
+ def _where_any_exist(self, sql):
+ return sql.where(self._any_exist(self.any))
+
+ def _where_none_exist(self, sql):
+ return sql.where(~self._any_exist(self.any))
+
+ def _all_exist(self, items):
+ return and_(self._exists(c) for c in self._collect(items))
+
+ def _where_all_exist(self, sql):
+ return sql.where(self._all_exist(self.all))
+
+ def _where_not_all_exist(self, sql):
+ return sql.where(~self._all_exist(self.all))
+
+ def _empty(self):
+ if self.empty:
+ return ~self._exists(True)
+ else:
+ return self._exists(True)
+
+ def _count_of(self, column):
+ return (
+ select(func.count(column))
+ .where(self.column.property.primaryjoin)
+ .scalar_subquery()
+ )
+
+ def _exact(self):
+ return and_(
+ self._all_exist(self.exact),
+ self._count_of(self.remote_column) == len(self.exact),
+ )
+
+ def _collect(self, ids):
+ for id in ids:
+ yield from self.where(id)
+
+ @property
+ def remote_column(self):
+ _, remote = self.column.property.local_remote_pairs
+ _, remote_column = remote
+
+ return remote_column
+
+ def where(self, id):
+ yield self.remote_column == id
+
+ def include(self, sql):
+ # ignore if any/all is None, but when the user specifically includes an
+ # empty list, make sure to return no items
+ if self.any:
+ sql = self._where_any_exist(sql)
+ elif self.any == []:
+ sql = sql.where(False)
+
+ if self.all:
+ sql = self._where_all_exist(sql)
+ elif self.all == []:
+ sql = sql.where(False)
+
+ if self.empty is not None:
+ sql = sql.where(self._empty())
+
+ if self.exact is not None:
+ sql = sql.where(self._exact())
+
+ return sql
+
+ def exclude(self, sql):
+ # in contrast to include() we can fully ignore if any/all is None or
+ # the empty list and just return all items, since the user effectively
+ # asks to exclude "nothing"
+ if self.any:
+ sql = self._where_none_exist(sql)
+ if self.all:
+ sql = self._where_not_all_exist(sql)
+
+ if self.empty is not None:
+ sql = sql.where(~self._empty())
+
+ if self.exact is not None:
+ sql = sql.where(~self._exact())
+
+ return sql
+
+
+@strawberry.input
+class Root:
+ def match(self, sql, negate):
+ """
+ Collect all relevant matchers from the input and construct the final
+ SQL statement.
+
+ If the matcher is a boolean value (like favourite, organized, etc), use
+ it directly. Otherwise consult a Matchable's include or exclude method.
+ """
+
+ for field, matcher in self.__dict__.items():
+ if matcher is UNSET:
+ continue
+
+ column = getattr(self._model, field, None)
+
+ if issubclass(type(matcher), Matchable):
+ matcher.column = column
+ if not negate:
+ sql = matcher.include(sql)
+ else:
+ sql = matcher.exclude(sql)
+
+ if isinstance(matcher, bool):
+ if not negate:
+ sql = sql.where(column == matcher)
+ else:
+ sql = sql.where(column != matcher)
+
+ return sql
+
+
+# When resolving names for types that extend Generic, strawberry prepends the
+# name of the type variable to the name of the generic class. Since all classes
+# that extend this class already end in "Filter", we have to make sure not to
+# name it "FilterInput" lest we end up with "ComicFilterFilterInput".
+#
+# For now, use the very generic "Input" name so that we end up with sane
+# GraphQL type names like "ComicFilterInput".
+@strawberry.input
+class Input(Generic[T]):
+ include: Optional["T"] = UNSET
+ exclude: Optional["T"] = UNSET
+
+
+@strawberry.input
+class StringFilter(Matchable):
+ contains: Optional[str] = UNSET
+
+ def _conditions(self):
+ if self.contains is not UNSET:
+ yield self.column.contains(self.contains)
+
+ def include(self, sql):
+ conditions = list(self._conditions())
+ if not conditions:
+ return sql
+
+ return sql.where(and_(*conditions))
+
+ def exclude(self, sql):
+ conditions = [~c for c in self._conditions()]
+ if not conditions:
+ return sql
+
+ return sql.where(and_(*conditions))
+
+
+@strawberry.input
+class TagAssociationFilter(AssociationFilter):
+ """
+ Tags need special handling since their IDs are strings instead of numbers.
+ We can keep the full logic of AssociationFilter and only need to make sure
+ we unpack the database IDs from the input IDs.
+ """
+
+ any: Optional[List[str]] = strawberry.field(default_factory=lambda: None)
+ all: Optional[List[str]] = strawberry.field(default_factory=lambda: None)
+ exact: Optional[List[str]] = strawberry.field(default_factory=lambda: None)
+
+ def where(self, id):
+ try:
+ nid, tid = id.split(":")
+ except ValueError:
+ # invalid specification, force False and stop generator
+ yield False
+ return
+
+ predicates = []
+ if nid:
+ predicates.append(ComicTag.namespace_id == nid)
+ if tid:
+ predicates.append(ComicTag.tag_id == tid)
+
+ if not predicates:
+ # empty specification, force False and stop generator
+ yield False
+ return
+
+ yield and_(*predicates)
+
+ @property
+ def remote_column(self):
+ return ComicTag.comic_id
+
+
+@strawberry.input
+class Filter(Matchable, Generic[T]):
+ any: Optional[List["T"]] = strawberry.field(default_factory=lambda: None)
+ empty: Optional[bool] = None
+
+ def _empty(self):
+ if self.empty:
+ return self.column.is_(None)
+ else:
+ return ~self.column.is_(None)
+
+ def _any_exist(self):
+ return self.column.in_(self.any)
+
+ def include(self, sql):
+ if self.any:
+ sql = sql.where(self._any_exist())
+
+ if self.empty is not None:
+ sql = sql.where(self._empty())
+
+ return sql
+
+ def exclude(self, sql):
+ if self.any:
+ sql = sql.where(~self._any_exist())
+
+ if self.empty is not None:
+ sql = sql.where(~self._empty())
+
+ return sql
+
+
+@hircine.db.model("Comic")
+@strawberry.input
+class ComicFilter(Root):
+ title: Optional[StringFilter] = UNSET
+ original_title: Optional[StringFilter] = UNSET
+ url: Optional[StringFilter] = UNSET
+ language: Optional[Filter[Language]] = UNSET
+ tags: Optional[TagAssociationFilter] = UNSET
+ artists: Optional[AssociationFilter] = UNSET
+ characters: Optional[AssociationFilter] = UNSET
+ circles: Optional[AssociationFilter] = UNSET
+ worlds: Optional[AssociationFilter] = UNSET
+ category: Optional[Filter[Category]] = UNSET
+ censorship: Optional[Filter[Censorship]] = UNSET
+ rating: Optional[Filter[Rating]] = UNSET
+ favourite: Optional[bool] = UNSET
+ organized: Optional[bool] = UNSET
+ bookmarked: Optional[bool] = UNSET
+
+
+@hircine.db.model("Archive")
+@strawberry.input
+class ArchiveFilter(Root):
+ path: Optional[StringFilter] = UNSET
+ organized: Optional[bool] = UNSET
+
+
+@hircine.db.model("Artist")
+@strawberry.input
+class ArtistFilter(Root):
+ name: Optional[StringFilter] = UNSET
+
+
+@hircine.db.model("Character")
+@strawberry.input
+class CharacterFilter(Root):
+ name: Optional[StringFilter] = UNSET
+
+
+@hircine.db.model("Circle")
+@strawberry.input
+class CircleFilter(Root):
+ name: Optional[StringFilter] = UNSET
+
+
+@hircine.db.model("Namespace")
+@strawberry.input
+class NamespaceFilter(Root):
+ name: Optional[StringFilter] = UNSET
+
+
+@hircine.db.model("Tag")
+@strawberry.input
+class TagFilter(Root):
+ name: Optional[StringFilter] = UNSET
+ namespaces: Optional[AssociationFilter] = UNSET
+
+
+@hircine.db.model("World")
+@strawberry.input
+class WorldFilter(Root):
+ name: Optional[StringFilter] = UNSET
diff --git a/src/hircine/api/inputs.py b/src/hircine/api/inputs.py
new file mode 100644
index 0000000..c88bcce
--- /dev/null
+++ b/src/hircine/api/inputs.py
@@ -0,0 +1,578 @@
+import datetime
+from abc import ABC, abstractmethod
+from typing import List, Optional, Type
+
+import strawberry
+from sqlalchemy.orm.util import identity_key
+from strawberry import UNSET
+
+import hircine.db
+import hircine.db.ops as ops
+from hircine.api import APIException, MutationContext
+from hircine.api.responses import (
+ IDNotFoundError,
+ InvalidParameterError,
+ PageClaimedError,
+ PageRemoteError,
+)
+from hircine.db.models import Archive, Base, Comic, ComicTag, Namespace, Tag
+from hircine.enums import (
+ Category,
+ Censorship,
+ Direction,
+ Language,
+ Layout,
+ OnMissing,
+ Rating,
+ UpdateMode,
+)
+
+
+def add_input_cls(modelcls):
+ return globals().get(f"Add{modelcls.__name__}Input")
+
+
+def update_input_cls(modelcls):
+ return globals().get(f"Update{modelcls.__name__}Input")
+
+
+def upsert_input_cls(modelcls):
+ return globals().get(f"Upsert{modelcls.__name__}Input")
+
+
+class Fetchable(ABC):
+ """
+ When mutating a model's associations, the API requires the user to pass
+ referential IDs. These may be referencing new items to add to a list of
+ associations, or items that should be removed.
+
+ For example, the updateTags mutation requires as its input for the
+ "namespaces" field a list of numerical IDs:
+
+ mutation updateTags {
+ updateTags(ids: 1, input: {namespaces: {ids: [1, 2]}}) { [...] }
+ }
+
+ Mutations make heavy use of SQLAlchemy's ORM features to reconcile changes
+ between related objects (like Tags and Namespaces). In the example above,
+ to reconcile the changes made to a Tag's valid Namespaces, SQLAlchemy needs
+ to know about three objects: the Tag that is being modified and the two
+ Namespaces being added to it.
+
+ This way SQLAlchemy can figure out whether it needs to add those Namespaces
+ to the Tag (or whether they're already there and can be skipped) and will,
+ upon commit, update the relevant tables automatically without us having to
+ emit custom SQL.
+
+ SQLAlchemy cannot know about an object's relationships by ID alone, so it
+ needs to be fetched from the database first. The Fetchable class
+ facilitates this. It provides an abstract "fetch" method that, given a
+ MutationContext, will return any relevant objects from the database.
+
+ Additionally, fetched items can be "constrained" to enforce API rules.
+ """
+
+ _model: Type[Base]
+
+ @abstractmethod
+ async def fetch(self, ctx: MutationContext):
+ pass
+
+ def update_mode(self):
+ try:
+ return self.options.mode
+ except AttributeError:
+ return UpdateMode.REPLACE
+
+ @classmethod
+ async def constrain_item(cls, item, ctx: MutationContext):
+ pass
+
+
+class FetchableID(Fetchable):
+ """
+ A Fetchable for numerical IDs. Database queries are batched to avoid an
+ excess amount of SQL queries.
+ """
+
+ @classmethod
+ async def get_from_id(cls, id, ctx: MutationContext):
+ item, *_ = await cls.get_from_ids([id], ctx)
+
+ return item
+
+ @classmethod
+ async def get_from_ids(cls, ids, ctx: MutationContext):
+ items, missing = await ops.get_all(
+ ctx.session, cls._model, ids, use_identity_map=True
+ )
+
+ if missing:
+ raise APIException(IDNotFoundError(cls._model, missing.pop()))
+
+ for item in items:
+ await cls.constrain_item(item, ctx)
+
+ return items
+
+
+class FetchableName(Fetchable):
+ """
+ A Fetchable for textual IDs (used only for Tags). As with FetchableID,
+ queries are batched.
+ """
+
+ @classmethod
+ async def get_from_names(cls, names, ctx: MutationContext, on_missing: OnMissing):
+ for name in names:
+ if not name:
+ raise APIException(
+ InvalidParameterError(
+ parameter=f"{cls._model.__name__}.name", text="cannot be empty"
+ )
+ )
+
+ items, missing = await ops.get_all_names(ctx.session, cls._model, names)
+
+ if on_missing == OnMissing.CREATE:
+ for m in missing:
+ items.append(cls._model(name=m))
+
+ return items
+
+
+@strawberry.input
+class Input(FetchableID):
+ id: int
+
+ async def fetch(self, ctx: MutationContext):
+ return await self.get_from_id(self.id, ctx)
+
+
+@strawberry.input
+class InputList(FetchableID):
+ ids: List[int]
+
+ async def fetch(self, ctx: MutationContext):
+ if not self.ids:
+ return []
+
+ return await self.get_from_ids(self.ids, ctx)
+
+
+@strawberry.input
+class UpdateOptions:
+ mode: UpdateMode = UpdateMode.REPLACE
+
+
+@strawberry.input
+class UpdateInputList(InputList):
+ options: Optional[UpdateOptions] = UNSET
+
+
+@strawberry.input
+class Pagination:
+ page: int = 1
+ items: int = 40
+
+
+@hircine.db.model("Archive")
+@strawberry.input
+class ArchiveInput(Input):
+ pass
+
+
+@hircine.db.model("Page")
+@strawberry.input
+class UniquePagesInput(InputList):
+ @classmethod
+ async def constrain_item(cls, page, ctx):
+ if page.comic_id:
+ raise APIException(PageClaimedError(id=page.id, comic_id=page.comic_id))
+
+ if page.archive_id != ctx.input.archive.id:
+ raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id))
+
+
+@hircine.db.model("Page")
+@strawberry.input
+class UniquePagesUpdateInput(UpdateInputList):
+ @classmethod
+ async def constrain_item(cls, page, ctx):
+ if page.comic_id and page.comic_id != ctx.root.id:
+ raise APIException(PageClaimedError(id=page.id, comic_id=page.comic_id))
+
+ if page.archive_id != ctx.root.archive_id:
+ raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id))
+
+
+@hircine.db.model("Namespace")
+@strawberry.input
+class NamespacesInput(InputList):
+ pass
+
+
+@hircine.db.model("Namespace")
+@strawberry.input
+class NamespacesUpdateInput(UpdateInputList):
+ pass
+
+
+@hircine.db.model("Page")
+@strawberry.input
+class CoverInput(Input):
+ async def fetch(self, ctx: MutationContext):
+ page = await self.get_from_id(self.id, ctx)
+ return page.image
+
+ @classmethod
+ async def constrain_item(cls, page, ctx):
+ if page.archive_id != ctx.input.archive.id:
+ raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id))
+
+
+@hircine.db.model("Page")
+@strawberry.input
+class CoverUpdateInput(CoverInput):
+ @classmethod
+ async def constrain_item(cls, page, ctx):
+ if ctx.model == Comic:
+ id = ctx.root.archive_id
+ elif ctx.model == Archive:
+ id = ctx.root.id
+
+ if page.archive_id != id:
+ raise APIException(PageRemoteError(id=page.id, archive_id=page.archive_id))
+
+
+@hircine.db.model("Character")
+@strawberry.input
+class CharactersUpdateInput(UpdateInputList):
+ pass
+
+
+@hircine.db.model("Artist")
+@strawberry.input
+class ArtistsUpdateInput(UpdateInputList):
+ pass
+
+
+@hircine.db.model("Circle")
+@strawberry.input
+class CirclesUpdateInput(UpdateInputList):
+ pass
+
+
+@hircine.db.model("World")
+@strawberry.input
+class WorldsUpdateInput(UpdateInputList):
+ pass
+
+
+@strawberry.input
+class ComicTagsUpdateInput(UpdateInputList):
+ ids: List[str] = strawberry.field(default_factory=lambda: [])
+
+ @classmethod
+ def parse_input(cls, id):
+ try:
+ return [int(i) for i in id.split(":")]
+ except ValueError:
+ raise APIException(
+ InvalidParameterError(
+ parameter="id",
+ text="ComicTag ID must be specified as <namespace_id>:<tag_id>",
+ )
+ )
+
+ @classmethod
+ async def get_from_ids(cls, ids, ctx: MutationContext):
+ comic = ctx.root
+
+ ctags = []
+ remaining = set()
+
+ for id in ids:
+ nid, tid = cls.parse_input(id)
+
+ key = identity_key(ComicTag, (comic.id, nid, tid))
+ item = ctx.session.identity_map.get(key, None)
+
+ if item is not None:
+ ctags.append(item)
+ else:
+ remaining.add((nid, tid))
+
+ if not remaining:
+ return ctags
+
+ nids, tids = zip(*remaining)
+
+ namespaces, missing = await ops.get_all(
+ ctx.session, Namespace, nids, use_identity_map=True
+ )
+ if missing:
+ raise APIException(IDNotFoundError(Namespace, missing.pop()))
+
+ tags, missing = await ops.get_all(ctx.session, Tag, tids, use_identity_map=True)
+ if missing:
+ raise APIException(IDNotFoundError(Tag, missing.pop()))
+
+ for nid, tid in remaining:
+ namespace = ctx.session.identity_map.get(identity_key(Namespace, nid))
+ tag = ctx.session.identity_map.get(identity_key(Tag, tid))
+
+ ctags.append(ComicTag(namespace=namespace, tag=tag))
+
+ return ctags
+
+
+@strawberry.input
+class UpsertOptions:
+ on_missing: OnMissing = OnMissing.IGNORE
+
+
+@strawberry.input
+class UpsertInputList(FetchableName):
+ names: List[str] = strawberry.field(default_factory=lambda: [])
+ options: Optional[UpsertOptions] = UNSET
+
+ async def fetch(self, ctx: MutationContext):
+ if not self.names:
+ return []
+
+ options = self.options or UpsertOptions()
+ return await self.get_from_names(self.names, ctx, on_missing=options.on_missing)
+
+ def update_mode(self):
+ return UpdateMode.ADD
+
+
+@hircine.db.model("Character")
+@strawberry.input
+class CharactersUpsertInput(UpsertInputList):
+ pass
+
+
+@hircine.db.model("Artist")
+@strawberry.input
+class ArtistsUpsertInput(UpsertInputList):
+ pass
+
+
+@hircine.db.model("Circle")
+@strawberry.input
+class CirclesUpsertInput(UpsertInputList):
+ pass
+
+
+@hircine.db.model("World")
+@strawberry.input
+class WorldsUpsertInput(UpsertInputList):
+ pass
+
+
+@strawberry.input
+class ComicTagsUpsertInput(UpsertInputList):
+ @classmethod
+ def parse_input(cls, name):
+ try:
+ namespace, tag = name.split(":")
+
+ if not namespace or not tag:
+ raise ValueError()
+
+ return namespace, tag
+ except ValueError:
+ raise APIException(
+ InvalidParameterError(
+ parameter="name",
+ text="ComicTag name must be specified as <namespace>:<tag>",
+ )
+ )
+
+ @classmethod
+ async def get_from_names(cls, input, ctx: MutationContext, on_missing: OnMissing):
+ comic = ctx.root
+
+ names = set()
+ for name in input:
+ names.add(cls.parse_input(name))
+
+ ctags, missing = await ops.get_ctag_names(ctx.session, comic.id, names)
+
+ if not missing:
+ return ctags
+
+ async def lookup(names, model):
+ have, missing = await ops.get_all_names(
+ ctx.session, model, names, options=model.load_full()
+ )
+ dict = {}
+
+ for item in have:
+ dict[item.name] = (item, True)
+ for item in missing:
+ dict[item] = (model(name=item), False)
+
+ return dict
+
+ remaining_ns, remaining_tags = zip(*missing)
+
+ namespaces = await lookup(remaining_ns, Namespace)
+ tags = await lookup(remaining_tags, Tag)
+
+ if on_missing == OnMissing.CREATE:
+ for ns, tag in missing:
+ namespace, _ = namespaces[ns]
+ tag, _ = tags[tag]
+
+ tag.namespaces.append(namespace)
+
+ ctags.append(ComicTag(namespace=namespace, tag=tag))
+
+ elif on_missing == OnMissing.IGNORE:
+ resident = []
+
+ for ns, tag in missing:
+ namespace, namespace_resident = namespaces[ns]
+ tag, tag_resident = tags[tag]
+
+ if namespace_resident and tag_resident:
+ resident.append((namespace, tag))
+
+ restrictions = await ops.tag_restrictions(
+ ctx.session, [(ns.id, tag.id) for ns, tag in resident]
+ )
+
+ for namespace, tag in resident:
+ if namespace.id in restrictions[tag.id]:
+ ctags.append(ComicTag(namespace=namespace, tag=tag))
+
+ return ctags
+
+
+@strawberry.input
+class UpdateArchiveInput:
+ cover: Optional[CoverUpdateInput] = UNSET
+ organized: Optional[bool] = UNSET
+
+
+@strawberry.input
+class AddComicInput:
+ title: str
+ archive: ArchiveInput
+ pages: UniquePagesInput
+ cover: CoverInput
+
+
+@strawberry.input
+class UpdateComicInput:
+ title: Optional[str] = UNSET
+ original_title: Optional[str] = UNSET
+ cover: Optional[CoverUpdateInput] = UNSET
+ pages: Optional[UniquePagesUpdateInput] = UNSET
+ url: Optional[str] = UNSET
+ language: Optional[Language] = UNSET
+ date: Optional[datetime.date] = UNSET
+ direction: Optional[Direction] = UNSET
+ layout: Optional[Layout] = UNSET
+ rating: Optional[Rating] = UNSET
+ category: Optional[Category] = UNSET
+ censorship: Optional[Censorship] = UNSET
+ tags: Optional[ComicTagsUpdateInput] = UNSET
+ artists: Optional[ArtistsUpdateInput] = UNSET
+ characters: Optional[CharactersUpdateInput] = UNSET
+ circles: Optional[CirclesUpdateInput] = UNSET
+ worlds: Optional[WorldsUpdateInput] = UNSET
+ favourite: Optional[bool] = UNSET
+ organized: Optional[bool] = UNSET
+ bookmarked: Optional[bool] = UNSET
+
+
+@strawberry.input
+class UpsertComicInput:
+ title: Optional[str] = UNSET
+ original_title: Optional[str] = UNSET
+ url: Optional[str] = UNSET
+ language: Optional[Language] = UNSET
+ date: Optional[datetime.date] = UNSET
+ direction: Optional[Direction] = UNSET
+ layout: Optional[Layout] = UNSET
+ rating: Optional[Rating] = UNSET
+ category: Optional[Category] = UNSET
+ censorship: Optional[Censorship] = UNSET
+ tags: Optional[ComicTagsUpsertInput] = UNSET
+ artists: Optional[ArtistsUpsertInput] = UNSET
+ characters: Optional[CharactersUpsertInput] = UNSET
+ circles: Optional[CirclesUpsertInput] = UNSET
+ worlds: Optional[WorldsUpsertInput] = UNSET
+ favourite: Optional[bool] = UNSET
+ organized: Optional[bool] = UNSET
+ bookmarked: Optional[bool] = UNSET
+
+
+@strawberry.input
+class AddNamespaceInput:
+ name: str
+ sort_name: Optional[str] = UNSET
+
+
+@strawberry.input
+class UpdateNamespaceInput:
+ name: Optional[str] = UNSET
+ sort_name: Optional[str] = UNSET
+
+
+@strawberry.input
+class AddTagInput:
+ name: str
+ description: Optional[str] = None
+ namespaces: Optional[NamespacesInput] = UNSET
+
+
+@strawberry.input
+class UpdateTagInput:
+ name: Optional[str] = UNSET
+ description: Optional[str] = UNSET
+ namespaces: Optional[NamespacesUpdateInput] = UNSET
+
+
+@strawberry.input
+class AddArtistInput:
+ name: str
+
+
+@strawberry.input
+class UpdateArtistInput:
+ name: Optional[str] = UNSET
+
+
+@strawberry.input
+class AddCharacterInput:
+ name: str
+
+
+@strawberry.input
+class UpdateCharacterInput:
+ name: Optional[str] = UNSET
+
+
+@strawberry.input
+class AddCircleInput:
+ name: str
+
+
+@strawberry.input
+class UpdateCircleInput:
+ name: Optional[str] = UNSET
+
+
+@strawberry.input
+class AddWorldInput:
+ name: str
+
+
+@strawberry.input
+class UpdateWorldInput:
+ name: Optional[str] = UNSET
diff --git a/src/hircine/api/mutation/__init__.py b/src/hircine/api/mutation/__init__.py
new file mode 100644
index 0000000..93c2b4a
--- /dev/null
+++ b/src/hircine/api/mutation/__init__.py
@@ -0,0 +1,69 @@
+import strawberry
+
+from hircine.api.responses import (
+ AddComicResponse,
+ AddResponse,
+ DeleteResponse,
+ UpdateResponse,
+ UpsertResponse,
+)
+from hircine.db.models import (
+ Archive,
+ Artist,
+ Character,
+ Circle,
+ Comic,
+ Namespace,
+ Tag,
+ World,
+)
+
+from .resolvers import (
+ add,
+ delete,
+ post_add_comic,
+ post_delete_archive,
+ update,
+ upsert,
+)
+
+
+def mutate(resolver):
+ return strawberry.mutation(resolver=resolver)
+
+
+@strawberry.type
+class Mutation:
+ update_archives: UpdateResponse = mutate(update(Archive))
+ delete_archives: DeleteResponse = mutate(
+ delete(Archive, post_delete=post_delete_archive)
+ )
+
+ add_comic: AddComicResponse = mutate(add(Comic, post_add=post_add_comic))
+ delete_comics: DeleteResponse = mutate(delete(Comic))
+ update_comics: UpdateResponse = mutate(update(Comic))
+ upsert_comics: UpsertResponse = mutate(upsert(Comic))
+
+ add_namespace: AddResponse = mutate(add(Namespace))
+ delete_namespaces: DeleteResponse = mutate(delete(Namespace))
+ update_namespaces: UpdateResponse = mutate(update(Namespace))
+
+ add_tag: AddResponse = mutate(add(Tag))
+ delete_tags: DeleteResponse = mutate(delete(Tag))
+ update_tags: UpdateResponse = mutate(update(Tag))
+
+ add_circle: AddResponse = mutate(add(Circle))
+ delete_circles: DeleteResponse = mutate(delete(Circle))
+ update_circles: UpdateResponse = mutate(update(Circle))
+
+ add_artist: AddResponse = mutate(add(Artist))
+ delete_artists: DeleteResponse = mutate(delete(Artist))
+ update_artists: UpdateResponse = mutate(update(Artist))
+
+ add_character: AddResponse = mutate(add(Character))
+ delete_characters: DeleteResponse = mutate(delete(Character))
+ update_characters: UpdateResponse = mutate(update(Character))
+
+ add_world: AddResponse = mutate(add(World))
+ delete_worlds: DeleteResponse = mutate(delete(World))
+ update_worlds: UpdateResponse = mutate(update(World))
diff --git a/src/hircine/api/mutation/resolvers.py b/src/hircine/api/mutation/resolvers.py
new file mode 100644
index 0000000..069669e
--- /dev/null
+++ b/src/hircine/api/mutation/resolvers.py
@@ -0,0 +1,217 @@
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import List
+
+from strawberry import UNSET
+
+import hircine.db as db
+import hircine.db.ops as ops
+import hircine.thumbnailer as thumb
+from hircine.api import APIException, MutationContext
+from hircine.api.inputs import (
+ Fetchable,
+ add_input_cls,
+ update_input_cls,
+ upsert_input_cls,
+)
+from hircine.api.responses import (
+ AddComicSuccess,
+ AddSuccess,
+ DeleteSuccess,
+ IDNotFoundError,
+ InvalidParameterError,
+ NameExistsError,
+ UpdateSuccess,
+ UpsertSuccess,
+)
+from hircine.config import get_dir_structure
+from hircine.db.models import Comic, Image, MixinModifyDates
+from hircine.enums import UpdateMode
+
+
+async def fetch_fields(input, ctx: MutationContext):
+ """
+ Given a mutation input and a context, fetch and yield all relevant objects
+ from the database.
+
+ If the item requested is a Fetchable input, await its resolution, otherwise
+ use the item "verbatim" after checking any API restrictions.
+ """
+
+ for field, value in input.__dict__.items():
+ if field == "id" or value == UNSET:
+ continue
+
+ if issubclass(type(value), Fetchable):
+ yield field, await value.fetch(ctx), value.update_mode()
+ else:
+ if isinstance(value, str) and not value:
+ value = None
+
+ await check_constraints(ctx, field, value)
+ yield field, value, UpdateMode.REPLACE
+
+
+async def check_constraints(ctx, field, value):
+ column = getattr(ctx.model.__table__.c, field)
+
+ if value is None and not column.nullable:
+ raise APIException(
+ InvalidParameterError(parameter=field, text="cannot be empty")
+ )
+
+ if column.unique and ctx.multiple:
+ raise APIException(
+ InvalidParameterError(
+ parameter="name", text="Cannot bulk-update unique fields"
+ )
+ )
+
+ if column.unique and field == "name":
+ if value != ctx.root.name:
+ if await ops.has_with_name(ctx.session, ctx.model, value):
+ raise APIException(NameExistsError(ctx.model))
+
+
+# Mutation resolvers use the factory pattern. Given a modelcls, the factory
+# will return a strawberry resolver that is passed the corresponding Input
+# type.
+
+
+def add(modelcls, post_add=None):
+ async def inner(input: add_input_cls(modelcls)):
+ returnval = None
+
+ async with db.session() as s:
+ try:
+ object = modelcls()
+ ctx = MutationContext(input, object, s)
+
+ async for field, value, _ in fetch_fields(input, ctx):
+ setattr(object, field, value)
+ except APIException as e:
+ return e.graphql_error
+
+ s.add(object)
+ await s.flush()
+
+ if post_add:
+ returnval = await post_add(s, input, object)
+
+ await s.commit()
+
+ if returnval:
+ return returnval
+ else:
+ return AddSuccess(modelcls, object.id)
+
+ return inner
+
+
+async def post_add_comic(session, input, comic):
+ remaining_pages = await ops.get_remaining_pages_for(session, input.archive.id)
+ has_remaining = len(remaining_pages) > 0
+
+ if not has_remaining:
+ comic.archive.organized = True
+
+ return AddComicSuccess(Comic, comic.id, has_remaining)
+
+
+def update_attr(object, field, value, mode):
+ if mode != UpdateMode.REPLACE and isinstance(value, list):
+ attr = getattr(object, field)
+ match mode:
+ case UpdateMode.ADD:
+ value.extend(attr)
+ case UpdateMode.REMOVE:
+ value = list(set(attr) - set(value))
+
+ setattr(object, field, value)
+
+
+async def _update(ids: List[int], modelcls, input, successcls):
+ multiple = len(ids) > 1
+
+ async with db.session() as s:
+ needed = [k for k, v in input.__dict__.items() if v is not UNSET]
+
+ objects, missing = await ops.get_all(
+ s, modelcls, ids, modelcls.load_update(needed)
+ )
+
+ if missing:
+ return IDNotFoundError(modelcls, missing.pop())
+
+ for object in objects:
+ s.add(object)
+
+ try:
+ ctx = MutationContext(input, object, s, multiple=multiple)
+
+ async for field, value, mode in fetch_fields(input, ctx):
+ update_attr(object, field, value, mode)
+ except APIException as e:
+ return e.graphql_error
+
+ if isinstance(object, MixinModifyDates) and s.is_modified(object):
+ object.updated_at = datetime.now(tz=timezone.utc)
+
+ await s.commit()
+
+ return successcls()
+
+
+def update(modelcls):
+ async def inner(ids: List[int], input: update_input_cls(modelcls)):
+ return await _update(ids, modelcls, input, UpdateSuccess)
+
+ return inner
+
+
+def upsert(modelcls):
+ async def inner(ids: List[int], input: upsert_input_cls(modelcls)):
+ return await _update(ids, modelcls, input, UpsertSuccess)
+
+ return inner
+
+
+def delete(modelcls, post_delete=None):
+ async def inner(ids: List[int]):
+ async with db.session() as s:
+ objects, missing = await ops.get_all(s, modelcls, ids)
+ if missing:
+ return IDNotFoundError(modelcls, missing.pop())
+
+ for object in objects:
+ await s.delete(object)
+
+ await s.flush()
+
+ if post_delete:
+ await post_delete(s, objects)
+
+ await s.commit()
+
+ return DeleteSuccess()
+
+ return inner
+
+
+async def post_delete_archive(session, objects):
+ for archive in objects:
+ Path(archive.path).unlink(missing_ok=True)
+
+ dirs = get_dir_structure()
+ orphans = await ops.get_image_orphans(session)
+
+ ids = []
+ for id, hash in orphans:
+ ids.append(id)
+ for suffix in ["full", "thumb"]:
+ Path(thumb.object_path(dirs.objects, hash, suffix)).unlink(missing_ok=True)
+
+ if not ids:
+ return
+
+ await ops.delete_all(session, Image, ids)
diff --git a/src/hircine/api/query/__init__.py b/src/hircine/api/query/__init__.py
new file mode 100644
index 0000000..9d81989
--- /dev/null
+++ b/src/hircine/api/query/__init__.py
@@ -0,0 +1,54 @@
+from typing import List
+
+import strawberry
+
+import hircine.api.responses as rp
+import hircine.db.models as models
+from hircine.api.types import (
+ Archive,
+ Artist,
+ Character,
+ Circle,
+ Comic,
+ ComicScraper,
+ ComicTag,
+ FilterResult,
+ Namespace,
+ Tag,
+ World,
+)
+
+from .resolvers import (
+ all,
+ comic_scrapers,
+ comic_tags,
+ scrape_comic,
+ single,
+)
+
+
+def query(resolver):
+ return strawberry.field(resolver=resolver)
+
+
+@strawberry.type
+class Query:
+ archive: rp.ArchiveResponse = query(single(models.Archive, full=True))
+ archives: FilterResult[Archive] = query(all(models.Archive))
+ artist: rp.ArtistResponse = query(single(models.Artist))
+ artists: FilterResult[Artist] = query(all(models.Artist))
+ character: rp.CharacterResponse = query(single(models.Character))
+ characters: FilterResult[Character] = query(all(models.Character))
+ circle: rp.CircleResponse = query(single(models.Circle))
+ circles: FilterResult[Circle] = query(all(models.Circle))
+ comic: rp.ComicResponse = query(single(models.Comic, full=True))
+ comic_scrapers: List[ComicScraper] = query(comic_scrapers)
+ comic_tags: FilterResult[ComicTag] = query(comic_tags)
+ comics: FilterResult[Comic] = query(all(models.Comic))
+ namespace: rp.NamespaceResponse = query(single(models.Namespace))
+ namespaces: FilterResult[Namespace] = query(all(models.Namespace))
+ tag: rp.TagResponse = query(single(models.Tag, full=True))
+ tags: FilterResult[Tag] = query(all(models.Tag))
+ world: rp.WorldResponse = query(single(models.World))
+ worlds: FilterResult[World] = query(all(models.World))
+ scrape_comic: rp.ScrapeComicResponse = query(scrape_comic)
diff --git a/src/hircine/api/query/resolvers.py b/src/hircine/api/query/resolvers.py
new file mode 100644
index 0000000..a18e63e
--- /dev/null
+++ b/src/hircine/api/query/resolvers.py
@@ -0,0 +1,146 @@
+from typing import Optional
+
+import hircine.api.filters as filters
+import hircine.api.sort as sort
+import hircine.api.types as types
+import hircine.db as db
+import hircine.db.models as models
+import hircine.db.ops as ops
+import hircine.plugins as plugins
+from hircine.api.filters import Input as FilterInput
+from hircine.api.inputs import Pagination
+from hircine.api.responses import (
+ IDNotFoundError,
+ ScraperError,
+ ScraperNotAvailableError,
+ ScraperNotFoundError,
+)
+from hircine.api.sort import Input as SortInput
+from hircine.api.types import (
+ ComicScraper,
+ ComicTag,
+ FilterResult,
+ FullComic,
+ ScrapeComicResult,
+ ScrapedComic,
+)
+from hircine.scraper import ScrapeError
+
+# Query resolvers use the factory pattern. Given a model, the factory will
+# return a strawberry resolver that is passed the corresponding IDs
+
+
+def single(model, full=False):
+ modelname = model.__name__
+ if full:
+ modelname = f"Full{modelname}"
+
+ typecls = getattr(types, modelname)
+
+ async def inner(id: int):
+ async with db.session() as s:
+ options = model.load_full() if full else []
+ obj = await s.get(model, id, options=options)
+
+ if not obj:
+ return IDNotFoundError(model, id)
+
+ return typecls(obj)
+
+ return inner
+
+
+def all(model):
+ typecls = getattr(types, model.__name__)
+ filtercls = getattr(filters, f"{model.__name__}Filter")
+ sortcls = getattr(sort, f"{model.__name__}Sort")
+
+ async def inner(
+ pagination: Optional[Pagination] = None,
+ filter: Optional[FilterInput[filtercls]] = None,
+ sort: Optional[SortInput[sortcls]] = None,
+ ):
+ async with db.session() as s:
+ count, objs = await ops.query_all(
+ s, model, pagination=pagination, filter=filter, sort=sort
+ )
+
+ return FilterResult(count=count, edges=[typecls(obj) for obj in objs])
+
+ return inner
+
+
+def namespace_tag_combinations_for(namespaces, tags, restrictions):
+ for namespace in namespaces:
+ for tag in tags:
+ valid_ids = restrictions.get(tag.id, [])
+
+ if namespace.id in valid_ids:
+ yield ComicTag(namespace=namespace, tag=tag)
+
+
+async def comic_tags(for_filter: bool = False):
+ async with db.session() as s:
+ _, tags = await ops.query_all(s, models.Tag)
+ _, namespaces = await ops.query_all(s, models.Namespace)
+ restrictions = await ops.tag_restrictions(s)
+
+ combinations = list(namespace_tag_combinations_for(namespaces, tags, restrictions))
+
+ if not for_filter:
+ return FilterResult(count=len(combinations), edges=combinations)
+
+ matchers = []
+
+ for namespace in namespaces:
+ matchers.append(ComicTag(namespace=namespace))
+ for tag in tags:
+ matchers.append(ComicTag(tag=tag))
+
+ matchers.extend(combinations)
+
+ return FilterResult(count=len(matchers), edges=matchers)
+
+
+async def comic_scrapers(id: int):
+ async with db.session() as s:
+ comic = await s.get(models.Comic, id, options=models.Comic.load_full())
+
+ if not comic:
+ return []
+
+ scrapers = []
+ for id, cls in sorted(plugins.get_scrapers(), key=lambda p: p[1].name):
+ scraper = cls(comic)
+ if scraper.is_available:
+ scrapers.append(ComicScraper(id, scraper))
+
+ return scrapers
+
+
+async def scrape_comic(id: int, scraper: str):
+ scrapercls = plugins.get_scraper(scraper)
+
+ if not scrapercls:
+ return ScraperNotFoundError(name=scraper)
+
+ async with db.session() as s:
+ comic = await s.get(models.Comic, id, options=models.Comic.load_full())
+
+ if not comic:
+ return IDNotFoundError(models.Comic, id)
+
+ instance = scrapercls(FullComic(comic))
+
+ if not instance.is_available:
+ return ScraperNotAvailableError(scraper=scraper, comic_id=id)
+
+ gen = instance.collect(plugins.transformers)
+
+ try:
+ return ScrapeComicResult(
+ data=ScrapedComic.from_generator(gen),
+ warnings=instance.get_warnings(),
+ )
+ except ScrapeError as e:
+ return ScraperError(error=str(e))
diff --git a/src/hircine/api/responses.py b/src/hircine/api/responses.py
new file mode 100644
index 0000000..99d5113
--- /dev/null
+++ b/src/hircine/api/responses.py
@@ -0,0 +1,219 @@
+from typing import Annotated, Union
+
+import strawberry
+
+from hircine.api.types import (
+ Artist,
+ Character,
+ Circle,
+ FullArchive,
+ FullComic,
+ FullTag,
+ Namespace,
+ ScrapeComicResult,
+ World,
+)
+
+
+@strawberry.interface
+class Success:
+ message: str
+
+
+@strawberry.type
+class AddSuccess(Success):
+ id: int
+
+ def __init__(self, modelcls, id):
+ self.id = id
+ self.message = f"{modelcls.__name__} added"
+
+
+@strawberry.type
+class AddComicSuccess(AddSuccess):
+ archive_pages_remaining: bool
+
+ def __init__(self, modelcls, id, archive_pages_remaining):
+ super().__init__(modelcls, id)
+ self.archive_pages_remaining = archive_pages_remaining
+
+
+@strawberry.type
+class UpdateSuccess(Success):
+ def __init__(self):
+ self.message = "Changes saved"
+
+
+@strawberry.type
+class UpsertSuccess(Success):
+ def __init__(self):
+ self.message = "Changes saved"
+
+
+@strawberry.type
+class DeleteSuccess(Success):
+ def __init__(self):
+ self.message = "Deletion successful"
+
+
+@strawberry.interface
+class Error:
+ @strawberry.field
+ def message(self) -> str: # pragma: no cover
+ return "An error occurred"
+
+
+@strawberry.type
+class InvalidParameterError(Error):
+ parameter: str
+ text: strawberry.Private[str]
+
+ @strawberry.field
+ def message(self) -> str:
+ return f"Invalid parameter '{self.parameter}': {self.text}"
+
+
+@strawberry.type
+class IDNotFoundError(Error):
+ id: int
+ model: strawberry.Private[str]
+
+ def __init__(self, modelcls, id):
+ self.id = id
+ self.model = modelcls.__name__
+
+ @strawberry.field
+ def message(self) -> str:
+ return f"{self.model} ID not found: '{self.id}'"
+
+
+@strawberry.type
+class ScraperNotFoundError(Error):
+ name: str
+
+ @strawberry.field
+ def message(self) -> str:
+ return f"Scraper not found: '{self.name}'"
+
+
+@strawberry.type
+class NameExistsError(Error):
+ model: strawberry.Private[str]
+
+ def __init__(self, modelcls):
+ self.model = modelcls.__name__
+
+ @strawberry.field
+ def message(self) -> str:
+ return f"Another {self.model} with this name exists"
+
+
+@strawberry.type
+class PageClaimedError(Error):
+ id: int
+ comic_id: int
+
+ @strawberry.field
+ def message(self) -> str:
+ return f"Page ID {self.id} is already claimed by comic ID {self.comic_id}"
+
+
+@strawberry.type
+class PageRemoteError(Error):
+ id: int
+ archive_id: int
+
+ @strawberry.field
+ def message(self) -> str:
+ return f"Page ID {self.id} comes from remote archive ID {self.archive_id}"
+
+
+@strawberry.type
+class ScraperError(Error):
+ error: str
+
+ @strawberry.field
+ def message(self) -> str:
+ return f"Scraping failed: {self.error}"
+
+
+@strawberry.type
+class ScraperNotAvailableError(Error):
+ scraper: str
+ comic_id: int
+
+ @strawberry.field
+ def message(self) -> str:
+ return f"Scraper {self.scraper} not available for comic ID {self.comic_id}"
+
+
+AddComicResponse = Annotated[
+ Union[
+ AddComicSuccess,
+ IDNotFoundError,
+ PageClaimedError,
+ PageRemoteError,
+ InvalidParameterError,
+ ],
+ strawberry.union("AddComicResponse"),
+]
+AddResponse = Annotated[
+ Union[AddSuccess, IDNotFoundError, NameExistsError, InvalidParameterError],
+ strawberry.union("AddResponse"),
+]
+ArchiveResponse = Annotated[
+ Union[FullArchive, IDNotFoundError], strawberry.union("ArchiveResponse")
+]
+ArtistResponse = Annotated[
+ Union[Artist, IDNotFoundError], strawberry.union("ArtistResponse")
+]
+CharacterResponse = Annotated[
+ Union[Character, IDNotFoundError], strawberry.union("CharacterResponse")
+]
+CircleResponse = Annotated[
+ Union[Circle, IDNotFoundError], strawberry.union("CircleResponse")
+]
+ComicResponse = Annotated[
+ Union[FullComic, IDNotFoundError], strawberry.union("ComicResponse")
+]
+DeleteResponse = Annotated[
+ Union[DeleteSuccess, IDNotFoundError], strawberry.union("DeleteResponse")
+]
+NamespaceResponse = Annotated[
+ Union[Namespace, IDNotFoundError], strawberry.union("NamespaceResponse")
+]
+ScrapeComicResponse = Annotated[
+ Union[
+ ScrapeComicResult,
+ ScraperNotFoundError,
+ ScraperNotAvailableError,
+ IDNotFoundError,
+ ScraperError,
+ ],
+ strawberry.union("ScrapeComicResponse"),
+]
+TagResponse = Annotated[
+ Union[FullTag, IDNotFoundError], strawberry.union("TagResponse")
+]
+UpdateResponse = Annotated[
+ Union[
+ UpdateSuccess,
+ NameExistsError,
+ IDNotFoundError,
+ InvalidParameterError,
+ PageRemoteError,
+ PageClaimedError,
+ ],
+ strawberry.union("UpdateResponse"),
+]
+UpsertResponse = Annotated[
+ Union[
+ UpsertSuccess,
+ NameExistsError,
+ InvalidParameterError,
+ ],
+ strawberry.union("UpsertResponse"),
+]
+WorldResponse = Annotated[
+ Union[World, IDNotFoundError], strawberry.union("WorldResponse")
+]
diff --git a/src/hircine/api/sort.py b/src/hircine/api/sort.py
new file mode 100644
index 0000000..17043a6
--- /dev/null
+++ b/src/hircine/api/sort.py
@@ -0,0 +1,94 @@
+import enum
+from typing import Generic, Optional, TypeVar
+
+import sqlalchemy
+import strawberry
+
+import hircine.db.models as models
+
+T = TypeVar("T")
+
+
+@strawberry.enum
+class SortDirection(enum.Enum):
+ ASCENDING = strawberry.enum_value(sqlalchemy.asc)
+ DESCENDING = strawberry.enum_value(sqlalchemy.desc)
+
+
+@strawberry.enum
+class ComicSort(enum.Enum):
+ TITLE = strawberry.enum_value(models.Comic.title)
+ ORIGINAL_TITLE = strawberry.enum_value(models.Comic.original_title)
+ DATE = strawberry.enum_value(models.Comic.date)
+ CREATED_AT = strawberry.enum_value(models.Comic.created_at)
+ UPDATED_AT = strawberry.enum_value(models.Comic.updated_at)
+ TAG_COUNT = strawberry.enum_value(models.Comic.tag_count)
+ PAGE_COUNT = strawberry.enum_value(models.Comic.page_count)
+ RANDOM = "Random"
+
+
+@strawberry.enum
+class ArchiveSort(enum.Enum):
+ PATH = strawberry.enum_value(models.Archive.path)
+ SIZE = strawberry.enum_value(models.Archive.size)
+ CREATED_AT = strawberry.enum_value(models.Archive.created_at)
+ PAGE_COUNT = strawberry.enum_value(models.Archive.page_count)
+ RANDOM = "Random"
+
+
+@strawberry.enum
+class ArtistSort(enum.Enum):
+ NAME = strawberry.enum_value(models.Artist.name)
+ CREATED_AT = strawberry.enum_value(models.Artist.created_at)
+ UPDATED_AT = strawberry.enum_value(models.Artist.updated_at)
+ RANDOM = "Random"
+
+
+@strawberry.enum
+class CharacterSort(enum.Enum):
+ NAME = strawberry.enum_value(models.Character.name)
+ CREATED_AT = strawberry.enum_value(models.Character.created_at)
+ UPDATED_AT = strawberry.enum_value(models.Character.updated_at)
+ RANDOM = "Random"
+
+
+@strawberry.enum
+class CircleSort(enum.Enum):
+ NAME = strawberry.enum_value(models.Circle.name)
+ CREATED_AT = strawberry.enum_value(models.Circle.created_at)
+ UPDATED_AT = strawberry.enum_value(models.Circle.updated_at)
+ RANDOM = "Random"
+
+
+@strawberry.enum
+class NamespaceSort(enum.Enum):
+ SORT_NAME = strawberry.enum_value(models.Namespace.sort_name)
+ NAME = strawberry.enum_value(models.Namespace.name)
+ CREATED_AT = strawberry.enum_value(models.Namespace.created_at)
+ UPDATED_AT = strawberry.enum_value(models.Namespace.updated_at)
+ RANDOM = "Random"
+
+
+@strawberry.enum
+class TagSort(enum.Enum):
+ NAME = strawberry.enum_value(models.Tag.name)
+ CREATED_AT = strawberry.enum_value(models.Tag.created_at)
+ UPDATED_AT = strawberry.enum_value(models.Tag.updated_at)
+ RANDOM = "Random"
+
+
+@strawberry.enum
+class WorldSort(enum.Enum):
+ NAME = strawberry.enum_value(models.World.name)
+ CREATED_AT = strawberry.enum_value(models.World.created_at)
+ UPDATED_AT = strawberry.enum_value(models.World.updated_at)
+ RANDOM = "Random"
+
+
+# Use a generic "Input" name so that we end up with sane GraphQL type names
+# See also: filter.py
+@strawberry.input
+class Input(Generic[T]):
+ on: T
+ direction: Optional[SortDirection] = SortDirection.ASCENDING
+ seed: Optional[int] = strawberry.UNSET
diff --git a/src/hircine/api/types.py b/src/hircine/api/types.py
new file mode 100644
index 0000000..b9fe0e7
--- /dev/null
+++ b/src/hircine/api/types.py
@@ -0,0 +1,337 @@
+import datetime
+from typing import Generic, List, Optional, TypeVar
+
+import strawberry
+
+import hircine.scraper.types as scraped
+from hircine.enums import Category, Censorship, Direction, Language, Layout, Rating
+
+T = TypeVar("T")
+
+
+@strawberry.type
+class Base:
+ id: int
+
+ def __init__(self, model):
+ self.id = model.id
+
+
+@strawberry.type
+class MixinName:
+ name: str
+
+ def __init__(self, model):
+ self.name = model.name
+ super().__init__(model)
+
+
+@strawberry.type
+class MixinFavourite:
+ favourite: bool
+
+ def __init__(self, model):
+ self.favourite = model.favourite
+ super().__init__(model)
+
+
+@strawberry.type
+class MixinOrganized:
+ organized: bool
+
+ def __init__(self, model):
+ self.organized = model.organized
+ super().__init__(model)
+
+
+@strawberry.type
+class MixinBookmarked:
+ bookmarked: bool
+
+ def __init__(self, model):
+ self.bookmarked = model.bookmarked
+ super().__init__(model)
+
+
+@strawberry.type
+class MixinCreatedAt:
+ created_at: datetime.datetime
+
+ def __init__(self, model):
+ self.created_at = model.created_at
+ super().__init__(model)
+
+
+@strawberry.type
+class MixinModifyDates(MixinCreatedAt):
+ updated_at: datetime.datetime
+
+ def __init__(self, model):
+ self.updated_at = model.updated_at
+ super().__init__(model)
+
+
+@strawberry.type
+class FilterResult(Generic[T]):
+ count: int
+ edges: List["T"]
+
+
+@strawberry.type
+class Archive(MixinName, MixinOrganized, Base):
+ cover: "Image"
+ path: str
+ size: int
+ page_count: int
+
+ def __init__(self, model):
+ super().__init__(model)
+ self.path = model.path
+ self.cover = Image(model.cover)
+ self.size = model.size
+ self.page_count = model.page_count
+
+
+@strawberry.type
+class FullArchive(MixinCreatedAt, Archive):
+ pages: List["Page"]
+ comics: List["Comic"]
+ mtime: datetime.datetime
+
+ def __init__(self, model):
+ super().__init__(model)
+ self.mtime = model.mtime
+ self.pages = [Page(p) for p in model.pages]
+ self.comics = [Comic(c) for c in model.comics]
+
+
+@strawberry.type
+class Page(Base):
+ path: str
+ image: "Image"
+ comic_id: Optional[int]
+
+ def __init__(self, model):
+ super().__init__(model)
+ self.path = model.path
+ self.image = Image(model.image)
+ self.comic_id = model.comic_id
+
+
+@strawberry.type
+class Image(Base):
+ hash: str
+ width: int
+ height: int
+ aspect_ratio: float
+
+ def __init__(self, model):
+ super().__init__(model)
+ self.hash = model.hash
+ self.width = model.width
+ self.height = model.height
+ self.aspect_ratio = model.aspect_ratio
+
+
+@strawberry.type
+class Comic(MixinFavourite, MixinOrganized, MixinBookmarked, Base):
+ title: str
+ original_title: Optional[str]
+ language: Optional[Language]
+ date: Optional[datetime.date]
+ cover: "Image"
+ rating: Optional[Rating]
+ category: Optional[Category]
+ censorship: Optional[Censorship]
+ tags: List["ComicTag"]
+ artists: List["Artist"]
+ characters: List["Character"]
+ circles: List["Circle"]
+ worlds: List["World"]
+ page_count: int
+
+ def __init__(self, model):
+ super().__init__(model)
+ self.title = model.title
+ self.original_title = model.original_title
+ self.language = model.language
+ self.date = model.date
+ self.cover = Image(model.cover)
+ self.rating = model.rating
+ self.category = model.category
+ self.censorship = model.censorship
+ self.tags = [ComicTag(t.namespace, t.tag) for t in model.tags]
+ self.artists = [Artist(a) for a in model.artists]
+ self.characters = [Character(c) for c in model.characters]
+ self.worlds = [World(w) for w in model.worlds]
+ self.circles = [Circle(g) for g in model.circles]
+ self.page_count = model.page_count
+
+
+@strawberry.type
+class FullComic(MixinModifyDates, Comic):
+ archive: "Archive"
+ url: Optional[str]
+ pages: List["Page"]
+ direction: Direction
+ layout: Layout
+
+ def __init__(self, model):
+ super().__init__(model)
+ self.direction = model.direction
+ self.layout = model.layout
+ self.archive = Archive(model.archive)
+ self.pages = [Page(p) for p in model.pages]
+ self.url = model.url
+
+
+@strawberry.type
+class Tag(MixinName, Base):
+ description: Optional[str]
+
+ def __init__(self, model):
+ super().__init__(model)
+ self.description = model.description
+
+
+@strawberry.type
+class FullTag(Tag):
+ namespaces: List["Namespace"]
+
+ def __init__(self, model):
+ super().__init__(model)
+ self.namespaces = [Namespace(n) for n in model.namespaces]
+
+
+@strawberry.type
+class Namespace(MixinName, Base):
+ sort_name: Optional[str]
+
+ def __init__(self, model):
+ super().__init__(model)
+ self.sort_name = model.sort_name
+
+
+@strawberry.type
+class ComicTag:
+ id: str
+ name: str
+ description: Optional[str]
+
+ def __init__(self, namespace=None, tag=None):
+ tag_name, tag_id = ("", "")
+ ns_name, ns_id = ("", "")
+
+ if tag:
+ tag_name, tag_id = (tag.name, tag.id)
+ if namespace:
+ ns_name, ns_id = (namespace.name, namespace.id)
+
+ self.name = f"{ns_name}:{tag_name}"
+ self.id = f"{ns_id}:{tag_id}"
+ if tag:
+ self.description = tag.description
+
+
+@strawberry.type
+class Artist(MixinName, Base):
+ def __init__(self, model):
+ super().__init__(model)
+
+
+@strawberry.type
+class Character(MixinName, Base):
+ def __init__(self, model):
+ super().__init__(model)
+
+
+@strawberry.type
+class Circle(MixinName, Base):
+ def __init__(self, model):
+ super().__init__(model)
+
+
+@strawberry.type
+class World(MixinName, Base):
+ def __init__(self, model):
+ super().__init__(model)
+
+
+@strawberry.type
+class ComicScraper:
+ id: str
+ name: str
+
+ def __init__(self, id, scraper):
+ self.id = id
+ self.name = scraper.name
+
+
+@strawberry.type
+class ScrapeComicResult:
+ data: "ScrapedComic"
+ warnings: List[str] = strawberry.field(default_factory=lambda: [])
+
+
+@strawberry.type
+class ScrapedComic:
+ title: Optional[str] = None
+ original_title: Optional[str] = None
+ url: Optional[str] = None
+ language: Optional[Language] = None
+ date: Optional[datetime.date] = None
+ rating: Optional[Rating] = None
+ category: Optional[Category] = None
+ censorship: Optional[Censorship] = None
+ direction: Optional[Direction] = None
+ layout: Optional[Layout] = None
+ tags: List[str] = strawberry.field(default_factory=lambda: [])
+ artists: List[str] = strawberry.field(default_factory=lambda: [])
+ characters: List[str] = strawberry.field(default_factory=lambda: [])
+ circles: List[str] = strawberry.field(default_factory=lambda: [])
+ worlds: List[str] = strawberry.field(default_factory=lambda: [])
+
+ @classmethod
+ def from_generator(cls, generator):
+ data = cls()
+
+ seen = set()
+ for item in generator:
+ if not item or item in seen:
+ continue
+
+ seen.add(item)
+
+ match item:
+ case scraped.Title():
+ data.title = item.value
+ case scraped.OriginalTitle():
+ data.original_title = item.value
+ case scraped.URL():
+ data.url = item.value
+ case scraped.Language():
+ data.language = item.value
+ case scraped.Date():
+ data.date = item.value
+ case scraped.Rating():
+ data.rating = item.value
+ case scraped.Category():
+ data.category = item.value
+ case scraped.Censorship():
+ data.censorship = item.value
+ case scraped.Direction():
+ data.direction = item.value
+ case scraped.Layout():
+ data.layout = item.value
+ case scraped.Tag():
+ data.tags.append(item.to_string())
+ case scraped.Artist():
+ data.artists.append(item.name)
+ case scraped.Character():
+ data.characters.append(item.name)
+ case scraped.Circle():
+ data.circles.append(item.name)
+ case scraped.World():
+ data.worlds.append(item.name)
+
+ return data
diff --git a/src/hircine/app.py b/src/hircine/app.py
new file mode 100644
index 0000000..f22396b
--- /dev/null
+++ b/src/hircine/app.py
@@ -0,0 +1,79 @@
+import asyncio
+import os
+
+import strawberry
+import uvicorn
+from starlette.applications import Starlette
+from starlette.middleware import Middleware
+from starlette.middleware.cors import CORSMiddleware
+from starlette.routing import Mount, Route
+from starlette.staticfiles import StaticFiles
+from strawberry.asgi import GraphQL
+
+import hircine.db as db
+from hircine.api.mutation import Mutation
+from hircine.api.query import Query
+from hircine.config import init_dir_structure
+
+schema = strawberry.Schema(query=Query, mutation=Mutation)
+graphql: GraphQL = GraphQL(schema)
+
+
+class SinglePageApplication(StaticFiles): # pragma: no cover
+ def __init__(self, index="index.html"):
+ self.index = index
+ super().__init__(
+ packages=[("hircine", "static/app")], html=True, check_dir=True
+ )
+
+ def lookup_path(self, path):
+ full_path, stat_result = super().lookup_path(path)
+
+ if stat_result is None:
+ return super().lookup_path(self.index)
+
+ return (full_path, stat_result)
+
+
+class HelpFiles(StaticFiles): # pragma: no cover
+ def __init__(self, index="index.html"):
+ self.index = index
+ super().__init__(
+ packages=[("hircine", "static/help")], html=True, check_dir=True
+ )
+
+
+def app(): # pragma: no cover
+ dirs = init_dir_structure()
+ db.configure(dirs)
+
+ routes = [
+ Route("/graphql", endpoint=graphql),
+ Mount("/objects", app=StaticFiles(directory=dirs.objects), name="objects"),
+ Mount("/help", app=HelpFiles(), name="help"),
+ Mount("/", app=SinglePageApplication(), name="app"),
+ ]
+
+ middleware = []
+
+ if "HIRCINE_DEV" in os.environ:
+ middleware = [
+ Middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+ ]
+
+ return Starlette(routes=routes, middleware=middleware)
+
+
+if __name__ == "__main__":
+ dirs = init_dir_structure()
+ db.ensuredb(dirs)
+
+ engine = db.configure(dirs)
+ asyncio.run(db.ensure_current_revision(engine))
+
+ uvicorn.run("hircine.app:app", host="::", reload=True, factory=True, lifespan="on")
diff --git a/src/hircine/cli.py b/src/hircine/cli.py
new file mode 100644
index 0000000..6941e2c
--- /dev/null
+++ b/src/hircine/cli.py
@@ -0,0 +1,128 @@
+import argparse
+import asyncio
+import configparser
+import importlib.metadata
+import os
+import sys
+from datetime import datetime, timezone
+
+import alembic.config
+
+import hircine.db as db
+from hircine import codename
+from hircine.config import init_dir_structure
+from hircine.scanner import Scanner
+
+
+class SubcommandHelpFormatter(argparse.RawDescriptionHelpFormatter):
+ def _format_action(self, action):
+ parts = super(argparse.RawDescriptionHelpFormatter, self)._format_action(action)
+ if action.nargs == argparse.PARSER:
+ parts = "\n".join(parts.split("\n")[1:])
+ return parts
+
+
+def init(config, dirs, engine, args):
+ if os.path.exists(dirs.database):
+ sys.exit("Database already initialized.")
+
+ dirs.mkdirs()
+
+ print("Initializing database...")
+ asyncio.run(db.initialize(engine))
+ print("Done.")
+
+
+def scan(config, dirs, engine, args):
+ db.ensuredb(dirs)
+
+ asyncio.run(db.ensure_current_revision(engine))
+
+ scanner = Scanner(config, dirs, reprocess=args.reprocess)
+ asyncio.run(scanner.scan())
+ scanner.report()
+
+
+def backup(config, dirs, engine, args, tag="manual"):
+ db.ensuredb(dirs)
+
+ os.makedirs(dirs.backups, exist_ok=True)
+
+ date = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d_%H%M%S")
+ filename = f"{os.path.basename(dirs.database)}.{tag}.{date}"
+ path = os.path.join(dirs.backups, filename)
+
+ asyncio.run(db.backup(engine, path))
+
+
+def migrate(config, dirs, engine, args):
+ db.ensuredb(dirs)
+
+ backup(config, dirs, engine, args, tag="pre-migrate")
+ alembic.config.main(argv=["--config", db.alembic_ini, "upgrade", "head"])
+
+
+def vacuum(config, dirs, engine, args):
+ db.ensuredb(dirs)
+
+ asyncio.run(db.vacuum(engine))
+
+
+def version(config, dirs, engine, args):
+ version = importlib.metadata.metadata("hircine")["Version"]
+ print(f'hircine {version} "{codename}"')
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ prog="hircine", formatter_class=SubcommandHelpFormatter
+ )
+ parser.add_argument("-C", dest="dir", help="run as if launched in DIR")
+
+ subparsers = parser.add_subparsers(title="commands", required=True)
+
+ parser_init = subparsers.add_parser("init", help="initialize a database")
+ parser_init.set_defaults(func=init)
+
+ parser_import = subparsers.add_parser("import", help="import archives")
+ parser_import.set_defaults(func=scan)
+ parser_import.add_argument(
+ "-r", "--reprocess", action="store_true", help="reprocess all image files"
+ )
+
+ parser_migrate = subparsers.add_parser("migrate", help="run database migrations")
+ parser_migrate.set_defaults(func=migrate)
+
+ parser_backup = subparsers.add_parser(
+ "backup", help="create a backup of the database"
+ )
+ parser_backup.set_defaults(func=backup)
+
+ parser_vacuum = subparsers.add_parser(
+ "vacuum", help="repack and optimize the database"
+ )
+ parser_vacuum.set_defaults(func=vacuum)
+
+ parser_version = subparsers.add_parser("version", help="show version and exit")
+ parser_version.set_defaults(func=version)
+
+ args = parser.parse_args()
+
+ if args.dir:
+ try:
+ os.chdir(args.dir)
+ except OSError as e:
+ sys.exit(e)
+
+ dirs = init_dir_structure()
+
+ config = configparser.ConfigParser()
+ config.read(dirs.config)
+
+ engine = db.configure(dirs)
+
+ args.func(config, dirs, engine, args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/hircine/config.py b/src/hircine/config.py
new file mode 100644
index 0000000..fda783e
--- /dev/null
+++ b/src/hircine/config.py
@@ -0,0 +1,38 @@
+import os
+
+dir_structure = None
+
+
+class DirectoryStructure:
+ def __init__(
+ self,
+ database="hircine.db",
+ scan="content/",
+ objects="objects/",
+ backups="backups/",
+ config="hircine.ini",
+ ):
+ self.database = database
+ self.scan = scan
+ self.objects = objects
+ self.backups = backups
+ self.config = config
+
+ def mkdirs(self): # pragma: no cover
+ os.makedirs(self.objects, exist_ok=True)
+ os.makedirs(self.scan, exist_ok=True)
+ os.makedirs(self.backups, exist_ok=True)
+
+
+def init_dir_structure(): # pragma: no cover
+ global dir_structure
+
+ dir_structure = DirectoryStructure()
+
+ return dir_structure
+
+
+def get_dir_structure():
+ global dir_structure
+
+ return dir_structure
diff --git a/src/hircine/db/__init__.py b/src/hircine/db/__init__.py
new file mode 100644
index 0000000..493bd91
--- /dev/null
+++ b/src/hircine/db/__init__.py
@@ -0,0 +1,99 @@
+import os
+import sys
+from pathlib import Path
+
+from alembic import command as alembic_command
+from alembic import script as alembic_script
+from alembic.config import Config as AlembicConfig
+from alembic.runtime import migration
+from sqlalchemy import event, text
+from sqlalchemy.engine import Engine
+from sqlalchemy.ext.asyncio import (
+ async_sessionmaker,
+ create_async_engine,
+)
+
+from . import models
+
+alembic_ini = f"{Path(__file__).parent.parent}/migrations/alembic.ini"
+session = async_sessionmaker(expire_on_commit=False, autoflush=False)
+
+
+def ensuredb(dirs): # pragma: no cover
+ if not os.path.exists(dirs.database):
+ sys.exit("No database found.")
+
+
+def sqlite_url(path):
+ return f"sqlite+aiosqlite:///{path}"
+
+
+def model(model):
+ def decorator(cls):
+ cls._model = getattr(models, model)
+ return cls
+
+ return decorator
+
+
+def stamp_alembic(connection):
+ cfg = AlembicConfig(alembic_ini)
+ cfg.attributes["connection"] = connection
+ cfg.attributes["silent"] = True
+
+ alembic_command.stamp(cfg, "head")
+
+
+def check_current_head(connection): # pragma: no cover
+ directory = alembic_script.ScriptDirectory.from_config(AlembicConfig(alembic_ini))
+
+ context = migration.MigrationContext.configure(connection)
+ return set(context.get_current_heads()) == set(directory.get_heads())
+
+
+async def ensure_current_revision(engine): # pragma: no cover
+ async with engine.begin() as conn:
+ if not await conn.run_sync(check_current_head):
+ sys.exit("Database is not up to date, please run 'hircine migrate'.")
+
+
+async def initialize(engine):
+ async with engine.begin() as conn:
+ await conn.run_sync(models.Base.metadata.drop_all)
+ await conn.run_sync(models.Base.metadata.create_all)
+ await conn.run_sync(stamp_alembic)
+
+
+async def backup(engine, path): # pragma: no cover
+ async with engine.connect() as conn:
+ await conn.execute(text("VACUUM INTO :path"), {"path": path})
+
+
+async def vacuum(engine): # pragma: no cover
+ async with engine.connect() as conn:
+ await conn.execute(text("VACUUM"))
+
+
+@event.listens_for(Engine, "connect")
+def set_sqlite_pragma(dbapi_connection, connection_record):
+ cursor = dbapi_connection.cursor()
+ cursor.execute("PRAGMA foreign_keys=ON")
+ cursor.execute("PRAGMA journal_mode=WAL")
+ cursor.close()
+
+
+def create_engine(path, echo=False):
+ return create_async_engine(
+ sqlite_url(path),
+ connect_args={"check_same_thread": False},
+ echo=echo,
+ )
+
+
+def configure(dirs): # pragma: no cover
+ echo = "HIRCINE_DEV" in os.environ
+
+ engine = create_engine(dirs.database, echo=echo)
+ session.configure(bind=engine)
+
+ return engine
diff --git a/src/hircine/db/models.py b/src/hircine/db/models.py
new file mode 100644
index 0000000..575771b
--- /dev/null
+++ b/src/hircine/db/models.py
@@ -0,0 +1,379 @@
+import os
+from datetime import date, datetime, timezone
+from typing import List, Optional
+
+from sqlalchemy import (
+ DateTime,
+ ForeignKey,
+ MetaData,
+ TypeDecorator,
+ event,
+ func,
+ select,
+)
+from sqlalchemy.orm import (
+ DeclarativeBase,
+ Mapped,
+ declared_attr,
+ deferred,
+ joinedload,
+ mapped_column,
+ relationship,
+ selectinload,
+)
+
+from hircine.api import APIException
+from hircine.api.responses import InvalidParameterError
+from hircine.enums import Category, Censorship, Direction, Language, Layout, Rating
+
+naming_convention = {
+ "ix": "ix_%(column_0_label)s",
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
+}
+
+
+class DateTimeUTC(TypeDecorator):
+ impl = DateTime
+ cache_ok = True
+
+ def process_bind_param(self, value, dialect):
+ if value is not None:
+ if not value.tzinfo:
+ raise TypeError("tzinfo is required")
+ value = value.astimezone(timezone.utc).replace(tzinfo=None)
+ return value
+
+ def process_result_value(self, value, dialect):
+ if value is not None:
+ value = value.replace(tzinfo=timezone.utc)
+ return value
+
+
+class Base(DeclarativeBase):
+ metadata = MetaData(naming_convention=naming_convention)
+
+ @declared_attr.directive
+ def __tablename__(cls) -> str:
+ return cls.__name__.lower()
+
+ __mapper_args__ = {"eager_defaults": True}
+
+ @classmethod
+ def load_update(cls, fields):
+ return []
+
+
+class MixinID:
+ id: Mapped[int] = mapped_column(primary_key=True)
+
+
+class MixinName:
+ name: Mapped[str] = mapped_column(unique=True)
+
+ @classmethod
+ def default_order(cls):
+ return [cls.name]
+
+
+class MixinFavourite:
+ favourite: Mapped[bool] = mapped_column(insert_default=False)
+
+
+class MixinOrganized:
+ organized: Mapped[bool] = mapped_column(insert_default=False)
+
+
+class MixinBookmarked:
+ bookmarked: Mapped[bool] = mapped_column(insert_default=False)
+
+
+class MixinCreatedAt:
+ created_at: Mapped[datetime] = mapped_column(DateTimeUTC, server_default=func.now())
+
+
+class MixinModifyDates(MixinCreatedAt):
+ updated_at: Mapped[datetime] = mapped_column(DateTimeUTC, server_default=func.now())
+
+
+class Archive(MixinID, MixinCreatedAt, MixinOrganized, Base):
+ hash: Mapped[str] = mapped_column(unique=True)
+ path: Mapped[str] = mapped_column(unique=True)
+ size: Mapped[int]
+ mtime: Mapped[datetime] = mapped_column(DateTimeUTC)
+
+ cover_id: Mapped[int] = mapped_column(ForeignKey("image.id"))
+ cover: Mapped["Image"] = relationship(lazy="joined", innerjoin=True)
+
+ pages: Mapped[List["Page"]] = relationship(
+ back_populates="archive",
+ order_by="(Page.index)",
+ cascade="save-update, merge, expunge, delete, delete-orphan",
+ )
+ comics: Mapped[List["Comic"]] = relationship(
+ back_populates="archive",
+ cascade="save-update, merge, expunge, delete, delete-orphan",
+ )
+
+ page_count: Mapped[int]
+
+ @property
+ def name(self):
+ return os.path.basename(self.path)
+
+ @classmethod
+ def default_order(cls):
+ return [cls.path]
+
+ @classmethod
+ def load_full(cls):
+ return [
+ joinedload(cls.pages, innerjoin=True),
+ selectinload(cls.comics),
+ ]
+
+
+class Image(MixinID, Base):
+ hash: Mapped[str] = mapped_column(unique=True)
+ width: Mapped[int]
+ height: Mapped[int]
+
+ @property
+ def aspect_ratio(self):
+ return self.width / self.height
+
+
+class Page(MixinID, Base):
+ path: Mapped[str]
+ index: Mapped[int]
+
+ archive_id: Mapped[int] = mapped_column(ForeignKey("archive.id"))
+ archive: Mapped["Archive"] = relationship(back_populates="pages")
+
+ image_id: Mapped[int] = mapped_column(ForeignKey("image.id"))
+ image: Mapped["Image"] = relationship(lazy="joined", innerjoin=True)
+
+ comic_id: Mapped[Optional[int]] = mapped_column(ForeignKey("comic.id"))
+
+
+class Comic(
+ MixinID, MixinModifyDates, MixinFavourite, MixinOrganized, MixinBookmarked, Base
+):
+ title: Mapped[str]
+ original_title: Mapped[Optional[str]]
+ url: Mapped[Optional[str]]
+ language: Mapped[Optional[Language]]
+ date: Mapped[Optional[date]]
+
+ direction: Mapped[Direction] = mapped_column(insert_default=Direction.LEFT_TO_RIGHT)
+ layout: Mapped[Layout] = mapped_column(insert_default=Layout.SINGLE)
+ rating: Mapped[Optional[Rating]]
+ category: Mapped[Optional[Category]]
+ censorship: Mapped[Optional[Censorship]]
+
+ cover_id: Mapped[int] = mapped_column(ForeignKey("image.id"))
+ cover: Mapped["Image"] = relationship(lazy="joined", innerjoin=True)
+
+ archive_id: Mapped[int] = mapped_column(ForeignKey("archive.id"))
+ archive: Mapped["Archive"] = relationship(back_populates="comics")
+
+ pages: Mapped[List["Page"]] = relationship(order_by="(Page.index)")
+ page_count: Mapped[int]
+
+ tags: Mapped[List["ComicTag"]] = relationship(
+ lazy="selectin",
+ cascade="save-update, merge, expunge, delete, delete-orphan",
+ passive_deletes=True,
+ )
+
+ artists: Mapped[List["Artist"]] = relationship(
+ secondary="comicartist",
+ lazy="selectin",
+ order_by="(Artist.name, Artist.id)",
+ passive_deletes=True,
+ )
+
+ characters: Mapped[List["Character"]] = relationship(
+ secondary="comiccharacter",
+ lazy="selectin",
+ order_by="(Character.name, Character.id)",
+ passive_deletes=True,
+ )
+
+ circles: Mapped[List["Circle"]] = relationship(
+ secondary="comiccircle",
+ lazy="selectin",
+ order_by="(Circle.name, Circle.id)",
+ passive_deletes=True,
+ )
+
+ worlds: Mapped[List["World"]] = relationship(
+ secondary="comicworld",
+ lazy="selectin",
+ order_by="(World.name, World.id)",
+ passive_deletes=True,
+ )
+
+ @classmethod
+ def default_order(cls):
+ return [cls.title]
+
+ @classmethod
+ def load_full(cls):
+ return [
+ joinedload(cls.archive, innerjoin=True),
+ joinedload(cls.pages, innerjoin=True),
+ ]
+
+ @classmethod
+ def load_update(cls, fields):
+ if "pages" in fields:
+ return [joinedload(cls.pages, innerjoin=True)]
+ return []
+
+
+class Tag(MixinID, MixinModifyDates, MixinName, Base):
+ description: Mapped[Optional[str]]
+ namespaces: Mapped[List["Namespace"]] = relationship(
+ secondary="tagnamespaces",
+ passive_deletes=True,
+ order_by="(Namespace.sort_name, Namespace.name, Namespace.id)",
+ )
+
+ @classmethod
+ def load_full(cls):
+ return [selectinload(cls.namespaces)]
+
+ @classmethod
+ def load_update(cls, fields):
+ if "namespaces" in fields:
+ return cls.load_full()
+ return []
+
+
+class Namespace(MixinID, MixinModifyDates, MixinName, Base):
+ sort_name: Mapped[Optional[str]]
+
+ @classmethod
+ def default_order(cls):
+ return [cls.sort_name, cls.name]
+
+ @classmethod
+ def load_full(cls):
+ return []
+
+
+class TagNamespaces(Base):
+ namespace_id: Mapped[int] = mapped_column(
+ ForeignKey("namespace.id", ondelete="CASCADE"), primary_key=True
+ )
+ tag_id: Mapped[int] = mapped_column(
+ ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
+ )
+
+
+class ComicTag(Base):
+ comic_id: Mapped[int] = mapped_column(
+ ForeignKey("comic.id", ondelete="CASCADE"), primary_key=True
+ )
+ namespace_id: Mapped[int] = mapped_column(
+ ForeignKey("namespace.id", ondelete="CASCADE"), primary_key=True
+ )
+ tag_id: Mapped[int] = mapped_column(
+ ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
+ )
+
+ namespace: Mapped["Namespace"] = relationship(
+ lazy="joined",
+ innerjoin=True,
+ order_by="(Namespace.sort_name, Namespace.name, Namespace.id)",
+ )
+
+ tag: Mapped["Tag"] = relationship(
+ lazy="joined",
+ innerjoin=True,
+ order_by="(Tag.name, Tag.id)",
+ )
+
+ @property
+ def name(self):
+ return f"{self.namespace.name}:{self.tag.name}"
+
+ @property
+ def id(self):
+ return f"{self.namespace.id}:{self.tag.id}"
+
+
+class Artist(MixinID, MixinModifyDates, MixinName, Base):
+ pass
+
+
+class ComicArtist(Base):
+ comic_id: Mapped[int] = mapped_column(
+ ForeignKey("comic.id", ondelete="CASCADE"), primary_key=True
+ )
+ artist_id: Mapped[int] = mapped_column(
+ ForeignKey("artist.id", ondelete="CASCADE"), primary_key=True
+ )
+
+
+class Character(MixinID, MixinModifyDates, MixinName, Base):
+ pass
+
+
+class ComicCharacter(Base):
+ comic_id: Mapped[int] = mapped_column(
+ ForeignKey("comic.id", ondelete="CASCADE"), primary_key=True
+ )
+ character_id: Mapped[int] = mapped_column(
+ ForeignKey("character.id", ondelete="CASCADE"), primary_key=True
+ )
+
+
+class Circle(MixinID, MixinModifyDates, MixinName, Base):
+ pass
+
+
+class ComicCircle(Base):
+ comic_id: Mapped[int] = mapped_column(
+ ForeignKey("comic.id", ondelete="CASCADE"), primary_key=True
+ )
+ circle_id: Mapped[int] = mapped_column(
+ ForeignKey("circle.id", ondelete="CASCADE"), primary_key=True
+ )
+
+
+class World(MixinID, MixinModifyDates, MixinName, Base):
+ pass
+
+
+class ComicWorld(Base):
+ comic_id: Mapped[int] = mapped_column(
+ ForeignKey("comic.id", ondelete="CASCADE"), primary_key=True
+ )
+ world_id: Mapped[int] = mapped_column(
+ ForeignKey("world.id", ondelete="CASCADE"), primary_key=True
+ )
+
+
+def defer_relationship_count(relationship, secondary=False):
+ left, right = relationship.property.synchronize_pairs[0]
+
+ return deferred(
+ select(func.count(right))
+ .select_from(right.table)
+ .where(left == right)
+ .scalar_subquery()
+ )
+
+
+Comic.tag_count = defer_relationship_count(Comic.tags)
+
+
+@event.listens_for(Comic.pages, "bulk_replace")
+def on_comic_pages_bulk_replace(target, values, initiator):
+ if not values:
+ raise APIException(
+ InvalidParameterError(parameter="pages", text="cannot be empty")
+ )
+
+ target.page_count = len(values)
diff --git a/src/hircine/db/ops.py b/src/hircine/db/ops.py
new file mode 100644
index 0000000..c164cd2
--- /dev/null
+++ b/src/hircine/db/ops.py
@@ -0,0 +1,200 @@
+import random
+from collections import defaultdict
+
+from sqlalchemy import delete, func, null, select, text, tuple_
+from sqlalchemy.orm import contains_eager, undefer
+from sqlalchemy.orm.util import identity_key
+from strawberry import UNSET
+
+from hircine.db.models import (
+ Archive,
+ ComicTag,
+ Image,
+ Namespace,
+ Page,
+ Tag,
+ TagNamespaces,
+)
+
+
+def paginate(sql, pagination):
+ if not pagination:
+ return sql
+
+ if pagination.items < 1 or pagination.page < 1:
+ return sql.limit(0)
+
+ sql = sql.limit(pagination.items)
+
+ if pagination.page > 0:
+ sql = sql.offset((pagination.page - 1) * pagination.items)
+
+ return sql
+
+
+def apply_filter(sql, filter):
+ if not filter:
+ return sql
+
+ if filter.include is not UNSET:
+ sql = filter.include.match(sql, False)
+ if filter.exclude is not UNSET:
+ sql = filter.exclude.match(sql, True)
+
+ return sql
+
+
+def sort_random(seed):
+ if seed:
+ seed = seed % 1000000000
+ else:
+ seed = random.randrange(1000000000)
+
+ # https://www.sqlite.org/forum/forumpost/e2216583a4
+ return text("sin(iid + :seed)").bindparams(seed=seed)
+
+
+def apply_sort(sql, sort, default, tiebreaker):
+ if not sort:
+ return sql.order_by(*default, tiebreaker)
+
+ direction = sort.direction.value
+
+ if sort.on.value == "Random":
+ return sql.order_by(direction(sort_random(sort.seed)))
+
+ sql = sql.options(undefer(sort.on.value))
+
+ return sql.order_by(direction(sort.on.value), tiebreaker)
+
+
+async def query_all(session, model, pagination=None, filter=None, sort=None):
+ sql = select(
+ model, func.count(model.id).over().label("count"), model.id.label("iid")
+ )
+ sql = apply_filter(sql, filter)
+ sql = apply_sort(sql, sort, model.default_order(), model.id)
+ sql = paginate(sql, pagination)
+
+ count = 0
+ objs = []
+
+ for row in await session.execute(sql):
+ if count == 0:
+ count = row.count
+
+ objs.append(row[0])
+
+ return count, objs
+
+
+async def has_with_name(session, model, name):
+ sql = select(model.id).where(model.name == name)
+ return bool((await session.scalars(sql)).unique().first())
+
+
+async def tag_restrictions(session, tuples=None):
+ sql = select(TagNamespaces)
+
+ if tuples:
+ sql = sql.where(
+ tuple_(TagNamespaces.namespace_id, TagNamespaces.tag_id).in_(tuples)
+ )
+
+ namespaces = (await session.scalars(sql)).unique().all()
+
+ ns_map = defaultdict(set)
+
+ for n in namespaces:
+ ns_map[n.tag_id].add(n.namespace_id)
+
+ return ns_map
+
+
+def lookup_identity(session, model, ids):
+ objects = []
+ satisfied = set()
+
+ for id in ids:
+ object = session.identity_map.get(identity_key(model, id), None)
+ if object is not None:
+ objects.append(object)
+ satisfied.add(id)
+
+ return objects, satisfied
+
+
+async def get_all(session, model, ids, options=[], use_identity_map=False):
+ objects = []
+ ids = set(ids)
+
+ if use_identity_map:
+ objects, satisfied = lookup_identity(session, model, ids)
+
+ ids = ids - satisfied
+
+ if not ids:
+ return objects, set()
+
+ sql = select(model).where(model.id.in_(ids)).options(*options)
+
+ objects += (await session.scalars(sql)).unique().all()
+
+ fetched_ids = [object.id for object in objects]
+ missing = set(ids) - set(fetched_ids)
+
+ return objects, missing
+
+
+async def get_all_names(session, model, names, options=[]):
+ names = set(names)
+
+ sql = select(model).where(model.name.in_(names)).options(*options)
+
+ objects = (await session.scalars(sql)).unique().all()
+
+ fetched_names = [object.name for object in objects]
+ missing = set(names) - set(fetched_names)
+
+ return objects, missing
+
+
+async def get_ctag_names(session, comic_id, tuples):
+ sql = (
+ select(ComicTag)
+ .join(ComicTag.namespace)
+ .options(contains_eager(ComicTag.namespace))
+ .join(ComicTag.tag)
+ .options(contains_eager(ComicTag.tag))
+ .where(ComicTag.comic_id == comic_id)
+ .where(tuple_(Namespace.name, Tag.name).in_(tuples))
+ )
+ objects = (await session.scalars(sql)).unique().all()
+
+ fetched_tags = [(o.namespace.name, o.tag.name) for o in objects]
+ missing = set(tuples) - set(fetched_tags)
+
+ return objects, missing
+
+
+async def get_image_orphans(session):
+ sql = select(Image.id, Image.hash).join(Page, isouter=True).where(Page.id == null())
+
+ return (await session.execute(sql)).t
+
+
+async def get_remaining_pages_for(session, archive_id):
+ sql = (
+ select(Page.id)
+ .join(Archive)
+ .where(Archive.id == archive_id)
+ .where(Page.comic_id == null())
+ )
+
+ return (await session.execute(sql)).scalars().all()
+
+
+async def delete_all(session, model, ids):
+ result = await session.execute(delete(model).where(model.id.in_(ids)))
+
+ return result.rowcount
diff --git a/src/hircine/enums.py b/src/hircine/enums.py
new file mode 100644
index 0000000..7f95f02
--- /dev/null
+++ b/src/hircine/enums.py
@@ -0,0 +1,244 @@
+import enum
+
+import strawberry
+
+
+@strawberry.enum
+class Direction(enum.Enum):
+ LEFT_TO_RIGHT = "Left to Right"
+ RIGHT_TO_LEFT = "Right to Left"
+
+
+@strawberry.enum
+class Layout(enum.Enum):
+ SINGLE = "Single Page"
+ DOUBLE = "Double Page"
+ DOUBLE_OFFSET = "Double Page, offset"
+
+
+@strawberry.enum
+class Rating(enum.Enum):
+ SAFE = "Safe"
+ QUESTIONABLE = "Questionable"
+ EXPLICIT = "Explicit"
+
+
+@strawberry.enum
+class Category(enum.Enum):
+ MANGA = "Manga"
+ DOUJINSHI = "Doujinshi"
+ COMIC = "Comic"
+ GAME_CG = "Game CG"
+ IMAGE_SET = "Image Set"
+ ARTBOOK = "Artbook"
+ VARIANT_SET = "Variant Set"
+ WEBTOON = "Webtoon"
+
+
+@strawberry.enum
+class Censorship(enum.Enum):
+ NONE = "None"
+ BAR = "Bars"
+ MOSAIC = "Mosaic"
+ FULL = "Full"
+
+
+@strawberry.enum
+class UpdateMode(enum.Enum):
+ REPLACE = "Replace"
+ ADD = "Add"
+ REMOVE = "Remove"
+
+
+@strawberry.enum
+class OnMissing(enum.Enum):
+ IGNORE = "Ignore"
+ CREATE = "Create"
+
+
+@strawberry.enum
+class Language(enum.Enum):
+ AA = "Afar"
+ AB = "Abkhazian"
+ AE = "Avestan"
+ AF = "Afrikaans"
+ AK = "Akan"
+ AM = "Amharic"
+ AN = "Aragonese"
+ AR = "Arabic"
+ AS = "Assamese"
+ AV = "Avaric"
+ AY = "Aymara"
+ AZ = "Azerbaijani"
+ BA = "Bashkir"
+ BE = "Belarusian"
+ BG = "Bulgarian"
+ BH = "Bihari languages"
+ BI = "Bislama"
+ BM = "Bambara"
+ BN = "Bengali"
+ BO = "Tibetan"
+ BR = "Breton"
+ BS = "Bosnian"
+ CA = "Catalan"
+ CE = "Chechen"
+ CH = "Chamorro"
+ CO = "Corsican"
+ CR = "Cree"
+ CS = "Czech"
+ CU = "Church Slavic"
+ CV = "Chuvash"
+ CY = "Welsh"
+ DA = "Danish"
+ DE = "German"
+ DV = "Divehi"
+ DZ = "Dzongkha"
+ EE = "Ewe"
+ EL = "Modern Greek"
+ EN = "English"
+ EO = "Esperanto"
+ ES = "Spanish"
+ ET = "Estonian"
+ EU = "Basque"
+ FA = "Persian"
+ FF = "Fulah"
+ FI = "Finnish"
+ FJ = "Fijian"
+ FO = "Faroese"
+ FR = "French"
+ FY = "Western Frisian"
+ GA = "Irish"
+ GD = "Gaelic"
+ GL = "Galician"
+ GN = "Guarani"
+ GU = "Gujarati"
+ GV = "Manx"
+ HA = "Hausa"
+ HE = "Hebrew"
+ HI = "Hindi"
+ HO = "Hiri Motu"
+ HR = "Croatian"
+ HT = "Haitian"
+ HU = "Hungarian"
+ HY = "Armenian"
+ HZ = "Herero"
+ IA = "Interlingua"
+ ID = "Indonesian"
+ IE = "Interlingue"
+ IG = "Igbo"
+ II = "Sichuan Yi"
+ IK = "Inupiaq"
+ IO = "Ido"
+ IS = "Icelandic"
+ IT = "Italian"
+ IU = "Inuktitut"
+ JA = "Japanese"
+ JV = "Javanese"
+ KA = "Georgian"
+ KG = "Kongo"
+ KI = "Kikuyu"
+ KJ = "Kuanyama"
+ KK = "Kazakh"
+ KL = "Kalaallisut"
+ KM = "Central Khmer"
+ KN = "Kannada"
+ KO = "Korean"
+ KR = "Kanuri"
+ KS = "Kashmiri"
+ KU = "Kurdish"
+ KV = "Komi"
+ KW = "Cornish"
+ KY = "Kirghiz"
+ LA = "Latin"
+ LB = "Luxembourgish"
+ LG = "Ganda"
+ LI = "Limburgan"
+ LN = "Lingala"
+ LO = "Lao"
+ LT = "Lithuanian"
+ LU = "Luba-Katanga"
+ LV = "Latvian"
+ MG = "Malagasy"
+ MH = "Marshallese"
+ MI = "Maori"
+ MK = "Macedonian"
+ ML = "Malayalam"
+ MN = "Mongolian"
+ MR = "Marathi"
+ MS = "Malay"
+ MT = "Maltese"
+ MY = "Burmese"
+ NA = "Nauru"
+ NB = "Norwegian Bokmål"
+ ND = "North Ndebele"
+ NE = "Nepali"
+ NG = "Ndonga"
+ NL = "Dutch"
+ NN = "Norwegian Nynorsk"
+ NO = "Norwegian"
+ NR = "South Ndebele"
+ NV = "Navajo"
+ NY = "Chichewa"
+ OC = "Occitan"
+ OJ = "Ojibwa"
+ OM = "Oromo"
+ OR = "Oriya"
+ OS = "Ossetian"
+ PA = "Panjabi"
+ PI = "Pali"
+ PL = "Polish"
+ PS = "Pushto"
+ PT = "Portuguese"
+ QU = "Quechua"
+ RM = "Romansh"
+ RN = "Rundi"
+ RO = "Romanian"
+ RU = "Russian"
+ RW = "Kinyarwanda"
+ SA = "Sanskrit"
+ SC = "Sardinian"
+ SD = "Sindhi"
+ SE = "Northern Sami"
+ SG = "Sango"
+ SI = "Sinhala"
+ SK = "Slovak"
+ SL = "Slovenian"
+ SM = "Samoan"
+ SN = "Shona"
+ SO = "Somali"
+ SQ = "Albanian"
+ SR = "Serbian"
+ SS = "Swati"
+ ST = "Southern Sotho"
+ SU = "Sundanese"
+ SV = "Swedish"
+ SW = "Swahili"
+ TA = "Tamil"
+ TE = "Telugu"
+ TG = "Tajik"
+ TH = "Thai"
+ TI = "Tigrinya"
+ TK = "Turkmen"
+ TL = "Tagalog"
+ TN = "Tswana"
+ TO = "Tonga"
+ TR = "Turkish"
+ TS = "Tsonga"
+ TT = "Tatar"
+ TW = "Twi"
+ TY = "Tahitian"
+ UG = "Uighur"
+ UK = "Ukrainian"
+ UR = "Urdu"
+ UZ = "Uzbek"
+ VE = "Venda"
+ VI = "Vietnamese"
+ VO = "Volapük"
+ WA = "Walloon"
+ WO = "Wolof"
+ XH = "Xhosa"
+ YI = "Yiddish"
+ YO = "Yoruba"
+ ZA = "Zhuang"
+ ZH = "Chinese"
+ ZU = "Zulu"
diff --git a/src/hircine/migrations/alembic.ini b/src/hircine/migrations/alembic.ini
new file mode 100644
index 0000000..4e2bfca
--- /dev/null
+++ b/src/hircine/migrations/alembic.ini
@@ -0,0 +1,37 @@
+[alembic]
+script_location = %(here)s
+sqlalchemy.url = sqlite+aiosqlite:///hircine.db
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/src/hircine/migrations/env.py b/src/hircine/migrations/env.py
new file mode 100644
index 0000000..6df03ec
--- /dev/null
+++ b/src/hircine/migrations/env.py
@@ -0,0 +1,96 @@
+import asyncio
+from logging.config import fileConfig
+
+from alembic import context
+from hircine.db.models import Base
+from sqlalchemy import pool
+from sqlalchemy.engine import Connection
+from sqlalchemy.ext.asyncio import async_engine_from_config
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None and not config.attributes.get("silent", False):
+ fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = Base.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def do_run_migrations(connection: Connection) -> None:
+ context.configure(
+ connection=connection, target_metadata=target_metadata, render_as_batch=True
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+async def run_async_migrations() -> None:
+ """In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+
+ connectable = async_engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ async with connectable.connect() as connection:
+ await connection.run_sync(do_run_migrations)
+
+ await connectable.dispose()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode."""
+
+ connectable = config.attributes.get("connection", None)
+
+ if connectable is None:
+ asyncio.run(run_async_migrations())
+ else:
+ do_run_migrations(connectable)
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/src/hircine/migrations/script.py.mako b/src/hircine/migrations/script.py.mako
new file mode 100644
index 0000000..fbc4b07
--- /dev/null
+++ b/src/hircine/migrations/script.py.mako
@@ -0,0 +1,26 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/src/hircine/plugins/__init__.py b/src/hircine/plugins/__init__.py
new file mode 100644
index 0000000..27e55a7
--- /dev/null
+++ b/src/hircine/plugins/__init__.py
@@ -0,0 +1,49 @@
+from importlib.metadata import entry_points
+from typing import Dict, Type
+
+from hircine.scraper import Scraper
+
+scraper_registry: Dict[str, Type[Scraper]] = {}
+transformers = []
+
+
+def get_scraper(name):
+ return scraper_registry.get(name, None)
+
+
+def get_scrapers():
+ return scraper_registry.items()
+
+
+def register_scraper(name, cls):
+ scraper_registry[name] = cls
+
+
+def transformer(function):
+ """
+ Marks the decorated function as a transformer.
+
+ The decorated function must be a generator function that yields
+ :ref:`scraped-data`. The following parameters will be available to the
+ decorated function:
+
+ :param generator: The scraper's generator function.
+ :param ScraperInfo info: Information on the scraper.
+ """
+
+ def _decorate(function):
+ transformers.append(function)
+ return function
+
+ return _decorate(function)
+
+
+def load(): # pragma: nocover
+ for entry in entry_points(group="hircine.scraper"):
+ register_scraper(entry.name, entry.load())
+
+ for entry in entry_points(group="hircine.transformer"):
+ entry.load()
+
+
+load()
diff --git a/src/hircine/plugins/scrapers/__init__.py b/src/hircine/plugins/scrapers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/hircine/plugins/scrapers/__init__.py
diff --git a/src/hircine/plugins/scrapers/anchira.py b/src/hircine/plugins/scrapers/anchira.py
new file mode 100644
index 0000000..aa224b9
--- /dev/null
+++ b/src/hircine/plugins/scrapers/anchira.py
@@ -0,0 +1,101 @@
+import re
+
+import yaml
+
+import hircine.enums as enums
+from hircine.scraper import Scraper
+from hircine.scraper.types import (
+ URL,
+ Artist,
+ Censorship,
+ Circle,
+ Date,
+ Direction,
+ Language,
+ Rating,
+ Tag,
+ Title,
+ World,
+)
+from hircine.scraper.utils import open_archive_file
+
+URL_REGEX = re.compile(r"^https?://anchira\.to/g/")
+
+
+class AnchiraYamlScraper(Scraper):
+ """
+ A scraper for ``info.yaml`` files found in archives downloaded from
+ *anchira.to*.
+
+ .. list-table::
+ :align: left
+
+ * - **Requires**
+ - ``info.yaml`` in the archive or as a sidecar.
+ * - **Source**
+ - ``anchira.to``
+ """
+
+ name = "anchira.to info.yaml"
+ source = "anchira.to"
+
+ def __init__(self, comic):
+ super().__init__(comic)
+
+ self.data = self.load()
+ source = self.data.get("Source")
+
+ if source and re.match(URL_REGEX, source):
+ self.is_available = True
+
+ def load(self):
+ try:
+ with open_archive_file(self.comic.archive, "info.yaml") as yif:
+ return yaml.safe_load(yif)
+ except Exception:
+ return {}
+
+ def scrape(self):
+ parsers = {
+ "Title": Title,
+ "Artist": Artist,
+ "URL": URL,
+ "Released": Date.from_timestamp,
+ "Circle": Circle,
+ "Parody": self.parse_world,
+ "Tags": self.parse_tag,
+ }
+
+ for field, parser in parsers.items():
+ if field not in self.data:
+ continue
+
+ value = self.data[field]
+
+ if isinstance(value, list):
+ yield from [lambda i=x: parser(i) for x in value]
+ else:
+ yield lambda: parser(value)
+
+ yield Language(enums.Language.EN)
+ yield Direction(enums.Direction.RIGHT_TO_LEFT)
+
+ def parse_world(self, input):
+ match input:
+ case "Original Work":
+ return
+
+ return World(input)
+
+ def parse_tag(self, input):
+ match input:
+ case "Unlimited":
+ return
+ case "Hentai":
+ return Rating(value=enums.Rating.EXPLICIT)
+ case "Non-H" | "Ecchi":
+ return Rating(value=enums.Rating.QUESTIONABLE)
+ case "Uncensored":
+ return Censorship(value=enums.Censorship.NONE)
+ case _:
+ return Tag.from_string(input)
diff --git a/src/hircine/plugins/scrapers/ehentai_api.py b/src/hircine/plugins/scrapers/ehentai_api.py
new file mode 100644
index 0000000..70fcf57
--- /dev/null
+++ b/src/hircine/plugins/scrapers/ehentai_api.py
@@ -0,0 +1,75 @@
+import html
+import json
+import re
+
+import requests
+
+from hircine.scraper import ScrapeError, Scraper
+
+from .handlers.exhentai import ExHentaiHandler
+
+API_URL = "https://api.e-hentai.org/api.php"
+URL_REGEX = re.compile(
+ r"^https?://(?:exhentai|e-hentai).org/g/(?P<id>\d+)/(?P<token>[0-9a-fA-F]+).*"
+)
+
+
+class EHentaiAPIScraper(Scraper):
+ """
+ A scraper for the `E-Hentai API <https://ehwiki.org/wiki/API>`_.
+
+ .. list-table::
+ :align: left
+
+ * - **Requires**
+ - The comic :attr:`URL <hircine.api.types.FullComic.url>` pointing to
+ a gallery on *e-hentai.org* or *exhentai.org*
+ * - **Source**
+ - ``exhentai``
+
+ """
+
+ name = "e-hentai.org API"
+ source = "exhentai"
+
+ def __init__(self, comic):
+ super().__init__(comic)
+
+ if self.comic.url:
+ match = re.fullmatch(URL_REGEX, self.comic.url)
+
+ if match:
+ self.is_available = True
+ self.id = int(match.group("id"))
+ self.token = match.group("token")
+
+ def scrape(self):
+ data = json.dumps(
+ {
+ "method": "gdata",
+ "gidlist": [[self.id, self.token]],
+ "namespace": 1,
+ },
+ separators=(",", ":"),
+ )
+
+ request = requests.post(API_URL, data=data)
+
+ if request.status_code == requests.codes.ok:
+ try:
+ response = json.loads(request.text)["gmetadata"][0]
+
+ title = response.get("title")
+ if title:
+ response["title"] = html.unescape(title)
+
+ title_jpn = response.get("title_jpn")
+ if title_jpn:
+ response["title_jpn"] = html.unescape(title_jpn)
+
+ handler = ExHentaiHandler()
+ yield from handler.scrape(response)
+ except json.JSONDecodeError:
+ raise ScrapeError("Could not parse JSON response")
+ else:
+ raise ScrapeError(f"Request failed with status code {request.status_code}'")
diff --git a/src/hircine/plugins/scrapers/gallery_dl.py b/src/hircine/plugins/scrapers/gallery_dl.py
new file mode 100644
index 0000000..a6cebc4
--- /dev/null
+++ b/src/hircine/plugins/scrapers/gallery_dl.py
@@ -0,0 +1,54 @@
+import json
+
+from hircine.scraper import Scraper
+from hircine.scraper.utils import open_archive_file
+
+from .handlers.dynastyscans import DynastyScansHandler
+from .handlers.e621 import E621Handler
+from .handlers.exhentai import ExHentaiHandler
+from .handlers.mangadex import MangadexHandler
+
+HANDLERS = {
+ "dynastyscans": DynastyScansHandler,
+ "e621": E621Handler,
+ "exhentai": ExHentaiHandler,
+ "mangadex": MangadexHandler,
+}
+
+
+class GalleryDLScraper(Scraper):
+ """
+ A scraper for `gallery-dl's <https://github.com/mikf/gallery-dl>`_
+ ``info.json`` files. For now supports only a select subset of extractors.
+
+ .. list-table::
+ :align: left
+
+ * - **Requires**
+ - ``info.json`` in the archive or as a sidecar.
+ * - **Source**
+ - ``dynastyscans``, ``e621``, ``exhentai``, ``mangadex``
+ """
+
+ def __init__(self, comic):
+ super().__init__(comic)
+
+ self.data = self.load()
+ category = self.data.get("category")
+
+ if category in HANDLERS.keys():
+ self.is_available = True
+
+ self.handler = HANDLERS.get(category)()
+ self.source = self.handler.source
+ self.name = f"gallery-dl info.json ({self.source})"
+
+ def load(self):
+ try:
+ with open_archive_file(self.comic.archive, "info.json") as jif:
+ return json.load(jif)
+ except Exception:
+ return {}
+
+ def scrape(self):
+ yield from self.handler.scrape(self.data)
diff --git a/src/hircine/plugins/scrapers/handlers/__init__.py b/src/hircine/plugins/scrapers/handlers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/hircine/plugins/scrapers/handlers/__init__.py
diff --git a/src/hircine/plugins/scrapers/handlers/dynastyscans.py b/src/hircine/plugins/scrapers/handlers/dynastyscans.py
new file mode 100644
index 0000000..ded015b
--- /dev/null
+++ b/src/hircine/plugins/scrapers/handlers/dynastyscans.py
@@ -0,0 +1,41 @@
+import hircine.enums as enums
+from hircine.scraper import ScrapeWarning
+from hircine.scraper.types import (
+ Artist,
+ Circle,
+ Date,
+ Language,
+ Title,
+)
+from hircine.scraper.utils import parse_dict
+
+
+class DynastyScansHandler:
+ source = "dynastyscans"
+
+ def scrape(self, data):
+ parsers = {
+ "date": Date.from_iso,
+ "lang": self.parse_language,
+ "author": Artist,
+ "group": Circle,
+ }
+
+ yield from parse_dict(parsers, data)
+
+ if manga := data.get("manga"):
+ title = manga
+
+ if chapter := data.get("chapter"):
+ title = title + f" Ch. {chapter}"
+
+ if subtitle := data.get("title"):
+ title = title + f": {subtitle}"
+
+ yield Title(title)
+
+ def parse_language(self, input):
+ try:
+ return Language(value=enums.Language[input.upper()])
+ except (KeyError, ValueError) as e:
+ raise ScrapeWarning(f"Could not parse language: '{input}'") from e
diff --git a/src/hircine/plugins/scrapers/handlers/e621.py b/src/hircine/plugins/scrapers/handlers/e621.py
new file mode 100644
index 0000000..6b798fd
--- /dev/null
+++ b/src/hircine/plugins/scrapers/handlers/e621.py
@@ -0,0 +1,81 @@
+import hircine.enums as enums
+from hircine.scraper import ScrapeWarning
+from hircine.scraper.types import (
+ URL,
+ Artist,
+ Category,
+ Censorship,
+ Character,
+ Date,
+ Language,
+ Rating,
+ Tag,
+ Title,
+ World,
+)
+from hircine.scraper.utils import parse_dict
+
+
+def replace_underscore(fun):
+ return lambda input: fun(input.replace("_", " "))
+
+
+class E621Handler:
+ source = "e621"
+
+ ratings = {
+ "e": Rating(enums.Rating.EXPLICIT),
+ "q": Rating(enums.Rating.QUESTIONABLE),
+ "s": Rating(enums.Rating.SAFE),
+ }
+
+ def scrape(self, data):
+ match data.get("subcategory"):
+ case "pool":
+ yield from self.scrape_pool(data)
+
+ def scrape_pool(self, data):
+ parsers = {
+ "date": Date.from_iso,
+ "rating": self.ratings.get,
+ "pool": {
+ "id": lambda pid: URL(f"https://e621.net/pools/{pid}"),
+ "name": Title,
+ },
+ "tags": {
+ "general": replace_underscore(Tag.from_string),
+ "artist": replace_underscore(Artist),
+ "character": replace_underscore(Character),
+ "copyright": replace_underscore(World),
+ "species": replace_underscore(Tag.from_string),
+ "meta": self.parse_meta,
+ },
+ }
+
+ self.is_likely_uncensored = True
+
+ yield from parse_dict(parsers, data)
+
+ if self.is_likely_uncensored:
+ yield Censorship(enums.Censorship.NONE)
+
+ def parse_meta(self, input):
+ match input:
+ case "comic":
+ return Category(enums.Category.COMIC)
+ case "censor_bar":
+ self.is_likely_uncensored = False
+ return Censorship(enums.Censorship.BAR)
+ case "mosaic_censorship":
+ self.is_likely_uncensored = False
+ return Censorship(enums.Censorship.MOSAIC)
+ case "uncensored":
+ return Censorship(enums.Censorship.NONE)
+
+ if input.endswith("_text"):
+ lang, _ = input.split("_text", 1)
+
+ try:
+ return Language(value=enums.Language(lang.capitalize()))
+ except ValueError as e:
+ raise ScrapeWarning(f"Could not parse language: '{input}'") from e
diff --git a/src/hircine/plugins/scrapers/handlers/exhentai.py b/src/hircine/plugins/scrapers/handlers/exhentai.py
new file mode 100644
index 0000000..12c22d7
--- /dev/null
+++ b/src/hircine/plugins/scrapers/handlers/exhentai.py
@@ -0,0 +1,139 @@
+import re
+
+import hircine.enums as enums
+from hircine.scraper import ScrapeWarning
+from hircine.scraper.types import (
+ URL,
+ Artist,
+ Category,
+ Censorship,
+ Character,
+ Circle,
+ Date,
+ Direction,
+ Language,
+ OriginalTitle,
+ Rating,
+ Tag,
+ Title,
+ World,
+)
+from hircine.scraper.utils import parse_dict
+
+
+def sanitize(title, split=False):
+ text = re.sub(r"\[[^\]]+\]|{[^}]+}|=[^=]+=|^\([^)]+\)", "", title)
+ if "|" in text and split:
+ orig, text = text.split("|", 1)
+
+ return re.sub(r"\s{2,}", " ", text).strip()
+
+
+class ExHentaiHandler:
+ source = "exhentai"
+
+ def scrape(self, data):
+ category_field = "eh_category" if "eh_category" in data else "category"
+
+ parsers = {
+ category_field: self.parse_category,
+ "posted": Date.from_timestamp,
+ "date": Date.from_iso,
+ "lang": self.parse_language,
+ "tags": self.parse_tag,
+ "title": lambda t: Title(sanitize(t, split=True)),
+ "title_jpn": lambda t: OriginalTitle(sanitize(t)),
+ }
+
+ self.is_likely_pornographic = True
+ self.is_likely_rtl = False
+ self.has_censorship_tag = False
+ self.is_western = False
+
+ yield from parse_dict(parsers, data)
+
+ if self.is_likely_pornographic:
+ yield Rating(enums.Rating.EXPLICIT)
+
+ if not self.has_censorship_tag:
+ if self.is_western:
+ yield Censorship(enums.Censorship.NONE)
+ else:
+ yield Censorship(enums.Censorship.BAR)
+
+ if self.is_likely_rtl:
+ yield Direction(enums.Direction.RIGHT_TO_LEFT)
+
+ if (gid := data["gid"]) and (token := data["token"]):
+ yield URL(f"https://exhentai.org/g/{gid}/{token}")
+
+ def parse_category(self, input):
+ match input.lower():
+ case "doujinshi":
+ self.is_likely_rtl = True
+ return Category(value=enums.Category.DOUJINSHI)
+ case "manga":
+ self.is_likely_rtl = True
+ return Category(value=enums.Category.MANGA)
+ case "western":
+ self.is_western = True
+ case "artist cg":
+ return Category(value=enums.Category.COMIC)
+ case "game cg":
+ return Category(value=enums.Category.GAME_CG)
+ case "image set":
+ return Category(value=enums.Category.IMAGE_SET)
+ case "non-h":
+ self.is_likely_pornographic = False
+ return Rating(value=enums.Rating.QUESTIONABLE)
+
+ def parse_tag(self, input):
+ match input.split(":"):
+ case ["parody", value]:
+ return World(value)
+ case ["group", value]:
+ return Circle(value)
+ case ["artist", value]:
+ return Artist(value)
+ case ["character", value]:
+ return Character(value)
+ case ["language", value]:
+ return self.parse_language(value, from_value=True)
+ case ["other", "artbook"]:
+ return Category(enums.Category.ARTBOOK)
+ case ["other", "full censorship"]:
+ self.has_censorship_tag = True
+ return Censorship(enums.Censorship.FULL)
+ case ["other", "mosaic censorship"]:
+ self.has_censorship_tag = True
+ return Censorship(enums.Censorship.MOSAIC)
+ case ["other", "uncensored"]:
+ self.has_censorship_tag = True
+ return Censorship(enums.Censorship.NONE)
+ case ["other", "non-h imageset" | "western imageset"]:
+ return Category(value=enums.Category.IMAGE_SET)
+ case ["other", "western non-h"]:
+ self.is_likely_pornographic = False
+ return Rating(value=enums.Rating.QUESTIONABLE)
+ case ["other", "comic"]:
+ return Category(value=enums.Category.COMIC)
+ case ["other", "variant set"]:
+ return Category(value=enums.Category.VARIANT_SET)
+ case ["other", "webtoon"]:
+ return Category(value=enums.Category.WEBTOON)
+ case [namespace, tag]:
+ return Tag(namespace=namespace, tag=tag)
+ case [tag]:
+ return Tag(namespace=None, tag=tag)
+
+ def parse_language(self, input, from_value=False):
+ if not input or input in ["translated", "speechless", "N/A"]:
+ return
+
+ try:
+ if from_value:
+ return Language(value=enums.Language(input.capitalize()))
+ else:
+ return Language(value=enums.Language[input.upper()])
+ except (KeyError, ValueError) as e:
+ raise ScrapeWarning(f"Could not parse language: '{input}'") from e
diff --git a/src/hircine/plugins/scrapers/handlers/mangadex.py b/src/hircine/plugins/scrapers/handlers/mangadex.py
new file mode 100644
index 0000000..7bc371d
--- /dev/null
+++ b/src/hircine/plugins/scrapers/handlers/mangadex.py
@@ -0,0 +1,54 @@
+import hircine.enums as enums
+from hircine.scraper import ScrapeWarning
+from hircine.scraper.types import (
+ URL,
+ Artist,
+ Circle,
+ Date,
+ Language,
+ Tag,
+ Title,
+)
+from hircine.scraper.utils import parse_dict
+
+
+class MangadexHandler:
+ source = "mangadex"
+
+ def scrape(self, data):
+ parsers = {
+ "date": Date.from_iso,
+ "lang": self.parse_language,
+ "tags": Tag.from_string,
+ "artist": Artist,
+ "author": Artist,
+ "group": Circle,
+ }
+
+ yield from parse_dict(parsers, data)
+
+ if chapter_id := data.get("chapter_id"):
+ yield URL(f"https://mangadex.org/chapter/{chapter_id}")
+
+ if manga := data.get("manga"):
+ title = manga
+
+ if volume := data.get("volume"):
+ title = title + f" Vol. {volume}"
+
+ if chapter := data.get("chapter"):
+ if volume:
+ title = title + f", Ch. {chapter}"
+ else:
+ title = title + f"Ch. {chapter}"
+
+ if subtitle := data.get("title"):
+ title = title + f": {subtitle}"
+
+ yield Title(title)
+
+ def parse_language(self, input):
+ try:
+ return Language(value=enums.Language[input.upper()])
+ except (KeyError, ValueError) as e:
+ raise ScrapeWarning(f"Could not parse language: '{input}'") from e
diff --git a/src/hircine/scanner.py b/src/hircine/scanner.py
new file mode 100644
index 0000000..162e1f0
--- /dev/null
+++ b/src/hircine/scanner.py
@@ -0,0 +1,320 @@
+import asyncio
+import multiprocessing
+import os
+import platform
+from collections import defaultdict
+from concurrent.futures import ProcessPoolExecutor
+from datetime import datetime, timezone
+from enum import Enum
+from hashlib import file_digest
+from typing import List, NamedTuple
+from zipfile import ZipFile, is_zipfile
+
+from blake3 import blake3
+from natsort import natsorted, ns
+from sqlalchemy import insert, select, update
+from sqlalchemy.dialects.sqlite import insert as sqlite_upsert
+from sqlalchemy.orm import raiseload
+
+import hircine.db as db
+from hircine.db.models import Archive, Image, Page
+from hircine.thumbnailer import Thumbnailer, params_from
+
+
+class Status(Enum):
+ NEW = "+"
+ UNCHANGED = "="
+ UPDATED = "*"
+ RENAMED = ">"
+ IGNORED = "I"
+ CONFLICT = "!"
+ MISSING = "?"
+ REIMAGE = "~"
+
+
+def log(status, path, renamed_to=None):
+ if status == Status.UNCHANGED:
+ return
+
+ print(f"[{status.value}]", end=" ")
+ print(f"{os.path.basename(path)}", end=" " if renamed_to else "\n")
+
+ if renamed_to:
+ print(f"-> {os.path.basename(renamed_to)}", end="\n")
+
+
+class Registry:
+ def __init__(self):
+ self.paths = set()
+ self.orphans = {}
+ self.conflicts = {}
+ self.marked = defaultdict(list)
+
+ def mark(self, status, hash, path, renamed_to=None):
+ log(status, path, renamed_to)
+ self.marked[hash].append((path, status))
+
+ @property
+ def duplicates(self):
+ for hash, value in self.marked.items():
+ if len(value) > 1:
+ yield value
+
+
+class Member(NamedTuple):
+ path: str
+ hash: str
+ width: int
+ height: int
+
+
+class UpdateArchive(NamedTuple):
+ id: int
+ path: str
+ mtime: datetime
+
+ async def execute(self, session):
+ await session.execute(
+ update(Archive)
+ .values(path=self.path, mtime=self.mtime)
+ .where(Archive.id == self.id)
+ )
+
+
+class AddArchive(NamedTuple):
+ hash: str
+ path: str
+ size: int
+ mtime: datetime
+ members: List[Member]
+
+ async def upsert_images(self, session):
+ input = [
+ {
+ "hash": member.hash,
+ "width": member.width,
+ "height": member.height,
+ }
+ for member in self.members
+ ]
+
+ images = {
+ image.hash: image.id
+ for image in await session.scalars(
+ sqlite_upsert(Image)
+ .returning(Image)
+ .on_conflict_do_nothing(index_elements=["hash"]),
+ input,
+ )
+ }
+
+ missing = [member.hash for member in self.members if member.hash not in images]
+ if missing:
+ for image in await session.scalars(
+ select(Image).where(Image.hash.in_(missing))
+ ):
+ images[image.hash] = image.id
+
+ return images
+
+ async def execute(self, session):
+ images = await self.upsert_images(session)
+
+ archive = (
+ await session.scalars(
+ insert(Archive).returning(Archive),
+ {
+ "hash": self.hash,
+ "path": self.path,
+ "size": self.size,
+ "mtime": self.mtime,
+ "cover_id": images[self.members[0].hash],
+ "page_count": len(self.members),
+ },
+ )
+ ).one()
+
+ await session.execute(
+ insert(Page),
+ [
+ {
+ "index": index,
+ "path": member.path,
+ "image_id": images[member.hash],
+ "archive_id": archive.id,
+ }
+ for index, member in enumerate(self.members)
+ ],
+ )
+
+
+class Scanner:
+ def __init__(self, config, dirs, reprocess=False):
+ self.directory = dirs.scan
+ self.thumbnailer = Thumbnailer(dirs.objects, params_from(config))
+ self.registry = Registry()
+
+ self.reprocess = reprocess
+
+ async def scan(self):
+ if platform.system() == "Windows":
+ ctx = multiprocessing.get_context("spawn") # pragma: no cover
+ else:
+ ctx = multiprocessing.get_context("forkserver")
+
+ workers = multiprocessing.cpu_count() // 2
+
+ with ProcessPoolExecutor(max_workers=workers, mp_context=ctx) as pool:
+ async with db.session() as s:
+ sql = select(Archive).options(raiseload(Archive.cover))
+
+ for archive in await s.scalars(sql):
+ action = await self.scan_existing(archive, pool)
+
+ if action:
+ await action.execute(s)
+
+ async for action in self.scan_dir(self.directory, pool):
+ await action.execute(s)
+
+ await s.commit()
+
+ def report(self): # pragma: no cover
+ if self.registry.orphans:
+ print()
+ print(
+ "WARNING: The following paths are referenced in the DB, but do not exist in the file system:" # noqa: E501
+ )
+ for orphan in self.registry.orphans.values():
+ _, path = orphan
+ log(Status.MISSING, path)
+
+ for duplicate in self.registry.duplicates:
+ print()
+ print("WARNING: The following archives contain the same data:")
+ for path, status in duplicate:
+ log(status, path)
+
+ for path, conflict in self.registry.conflicts.items():
+ db_hash, fs_hash = conflict
+ print()
+ print("ERROR: The contents of the following archive have changed:")
+ log(Status.CONFLICT, path)
+ print(f" Database: {db_hash}")
+ print(f" File system: {fs_hash}")
+
+ async def scan_existing(self, archive, pool):
+ try:
+ stat = os.stat(archive.path, follow_symlinks=False)
+ except FileNotFoundError:
+ self.registry.orphans[archive.hash] = (archive.id, archive.path)
+ return None
+
+ self.registry.paths.add(archive.path)
+
+ mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
+
+ if mtime == archive.mtime:
+ if self.reprocess:
+ await self.process_zip(archive.path, pool)
+
+ self.registry.mark(Status.REIMAGE, archive.hash, archive.path)
+ return None
+ else:
+ self.registry.mark(Status.UNCHANGED, archive.hash, archive.path)
+ return None
+
+ hash, _ = await self.process_zip(archive.path, pool)
+
+ if archive.hash == hash:
+ self.registry.mark(Status.UPDATED, archive.hash, archive.path)
+ return UpdateArchive(id=archive.id, path=archive.path, mtime=mtime)
+ else:
+ log(Status.CONFLICT, archive.path)
+ self.registry.conflicts[archive.path] = (archive.hash, hash)
+
+ return None
+
+ async def scan_dir(self, path, pool):
+ path = os.path.realpath(path)
+
+ for root, dirs, files in os.walk(path):
+ for file in files:
+ absolute = os.path.join(path, root, file)
+
+ if os.path.islink(absolute):
+ continue
+
+ if not is_zipfile(absolute):
+ continue
+
+ if absolute in self.registry.paths:
+ continue
+
+ async for result in self.scan_zip(absolute, pool):
+ yield result
+
+ async def scan_zip(self, path, pool):
+ stat = os.stat(path, follow_symlinks=False)
+ mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
+
+ hash, members = await self.process_zip(path, pool)
+
+ if hash in self.registry.marked:
+ self.registry.mark(Status.IGNORED, hash, path)
+ return
+
+ if hash in self.registry.orphans:
+ id, old_path = self.registry.orphans[hash]
+ del self.registry.orphans[hash]
+
+ self.registry.mark(Status.RENAMED, hash, old_path, renamed_to=path)
+ yield UpdateArchive(id=id, path=path, mtime=mtime)
+ return
+ elif members:
+ self.registry.mark(Status.NEW, hash, path)
+ yield AddArchive(
+ hash=hash,
+ path=path,
+ size=stat.st_size,
+ mtime=mtime,
+ members=natsorted(members, key=lambda m: m.path, alg=ns.P | ns.IC),
+ )
+
+ async def process_zip(self, path, pool):
+ members = []
+ hash = blake3()
+
+ with ZipFile(path, mode="r") as z:
+ input = [(path, info.filename) for info in z.infolist()]
+
+ loop = asyncio.get_event_loop()
+
+ tasks = [loop.run_in_executor(pool, self.process_member, i) for i in input]
+ results = await asyncio.gather(*tasks)
+ for digest, entry in results:
+ hash.update(digest)
+ if entry:
+ members.append(entry)
+
+ return hash.hexdigest(), members
+
+ def process_member(self, input):
+ path, name = input
+
+ with ZipFile(path, mode="r") as zip:
+ with zip.open(name, mode="r") as member:
+ _, ext = os.path.splitext(name)
+ digest = file_digest(member, blake3).digest()
+
+ if self.thumbnailer.can_process(ext):
+ hash = digest.hex()
+
+ width, height = self.thumbnailer.process(
+ member, hash, reprocess=self.reprocess
+ )
+ return digest, Member(
+ path=member.name, hash=hash, width=width, height=height
+ )
+
+ return digest, None
diff --git a/src/hircine/scraper/__init__.py b/src/hircine/scraper/__init__.py
new file mode 100644
index 0000000..c04265a
--- /dev/null
+++ b/src/hircine/scraper/__init__.py
@@ -0,0 +1,108 @@
+from abc import ABC, abstractmethod
+
+
+class ScraperInfo:
+ """
+ A class containing informational data on a scraper.
+
+ :param str name: The name of the scraper.
+ :param str source: The data source, usually a well-defined name.
+ For names used by built-in plugins, refer to the :ref:`Scrapers
+ reference <builtin-scrapers>`.
+ :param FullComic comic: The comic being scraped.
+ """
+
+ def __init__(self, name, source, comic):
+ self.name = name
+ self.source = source
+ self.comic = comic
+
+
+class ScrapeWarning(Exception):
+ """
+ An exception signalling a non-fatal error. Its message will be shown to the
+ user once the scraping process concludes.
+
+ This is usually raised within a callable yielded by
+ :meth:`~hircine.scraper.Scraper.scrape` and should generally only be used
+ to notify the user that a piece of metadata was ignored because it was
+ malformed.
+ """
+
+ pass
+
+
+class ScrapeError(Exception):
+ """
+ An exception signalling a fatal error, stopping the scraping process
+ immediately.
+
+ This should only be raised if it is impossible for the scraping process to
+ continue, for example if a file or URL is inaccessible.
+ """
+
+ pass
+
+
+class Scraper(ABC):
+ """
+ The abstract base class for scrapers.
+
+ The following variables **must** be accessible after the instance is initialized:
+
+ :var str name: The name of the scraper (displayed in the scraper dropdown).
+ :var str source: The data source. Usually a well-defined name.
+ :var bool is_available: Whether this scraper is available for the given comic.
+ """
+
+ name = "Abstract Scraper"
+
+ source = None
+ is_available = False
+
+ def __init__(self, comic):
+ """
+ Initializes a scraper with the instance of the comic it is scraping.
+
+ :param FullComic comic: The comic being scraped.
+ """
+ self.comic = comic
+ self.warnings = []
+
+ @abstractmethod
+ def scrape(self):
+ """
+ A generator function that yields :ref:`scraped-data` or a callable
+ returning such data.
+
+ A callable may raise the :exc:`~hircine.scraper.ScrapeWarning`
+ exception. This exception will be caught automatically and its message
+ will be collected for display to the user after the scraping process concludes.
+ """
+ pass
+
+ def collect(self, transformers=[]):
+ def generator():
+ for result in self.scrape():
+ if callable(result):
+ try:
+ yield result()
+ except ScrapeWarning as e:
+ self.log_warning(e)
+ else:
+ yield result
+
+ gen = generator()
+
+ info = ScraperInfo(name=self.name, source=self.source, comic=self.comic)
+
+ for fun in transformers:
+ gen = fun(gen, info)
+
+ return gen
+
+ def log_warning(self, warning):
+ self.warnings.append(warning)
+
+ def get_warnings(self):
+ return list(map(str, self.warnings))
diff --git a/src/hircine/scraper/types.py b/src/hircine/scraper/types.py
new file mode 100644
index 0000000..534792b
--- /dev/null
+++ b/src/hircine/scraper/types.py
@@ -0,0 +1,246 @@
+from dataclasses import dataclass
+from datetime import date, datetime
+
+import hircine.enums
+
+from . import ScrapeWarning
+
+
+@dataclass(frozen=True)
+class Tag:
+ """
+ A :term:`qualified tag`, represented by strings.
+
+ :param str namespace: The namespace.
+ :param str tag: The tag.
+ """
+
+ namespace: str
+ tag: str
+
+ @classmethod
+ def from_string(cls, string, delimiter=":"):
+ """
+ Returns a new instance of this class given a textual representation,
+ usually a qualified tag in the format ``<namespace>:<tag>``. If no
+ delimiter is found, the namespace is assumed to be ``none`` and the
+ given string is used as a tag instead.
+
+ :param str string: The string of text representing a qualified tag.
+ :param str delimiter: The string with which the namespace is delimited
+ from the tag.
+ """
+ match string.split(delimiter, 1):
+ case [namespace, tag]:
+ return cls(namespace=namespace, tag=tag)
+ return cls(namespace="none", tag=string)
+
+ def to_string(self):
+ return f"{self.namespace}:{self.tag}"
+
+ def __bool__(self):
+ return bool(self.namespace) and bool(self.tag)
+
+
+@dataclass(frozen=True)
+class Date:
+ """
+ A scraped date.
+
+ :param :class:`~datetime.date` value: The date.
+ """
+
+ value: date
+
+ @classmethod
+ def from_iso(cls, datestring):
+ """
+ Returns a new instance of this class given a textual representation of
+ a date in the format ``YYYY-MM-DD``. See :meth:`datetime.date.fromisoformat`.
+
+ :param str datestring: The string of text representing a date.
+ :raise: :exc:`~hircine.scraper.ScrapeWarning` if the date string could
+ not be parsed.
+ """
+ try:
+ return cls(value=datetime.fromisoformat(datestring).date())
+ except ValueError as e:
+ raise ScrapeWarning(
+ f"Could not parse date: '{datestring}' as ISO 8601"
+ ) from e
+
+ @classmethod
+ def from_timestamp(cls, timestamp):
+ """
+ Returns a new instance of this class given a textual representation of
+ a POSIX timestamp. See :meth:`datetime.date.fromtimestamp`.
+
+ :param str timestamp: The string of text representing a POSIX timestamp.
+ :raise: :exc:`~hircine.scraper.ScrapeWarning` if the timestamp could
+ not be parsed.
+ """
+ try:
+ return cls(value=datetime.fromtimestamp(int(timestamp)).date())
+ except (OverflowError, OSError, ValueError) as e:
+ raise ScrapeWarning(
+ f"Could not parse date: '{timestamp}' as POSIX timestamp"
+ ) from e
+
+ def __bool__(self):
+ return self.value is not None
+
+
+@dataclass(frozen=True)
+class Rating:
+ """
+ A scraped rating, represented by an enum.
+ """
+
+ value: hircine.enums.Rating
+
+ def __bool__(self):
+ return self.value is not None
+
+
+@dataclass(frozen=True)
+class Category:
+ """
+ A scraped category, represented by an enum.
+ """
+
+ value: hircine.enums.Category
+
+ def __bool__(self):
+ return self.value is not None
+
+
+@dataclass(frozen=True)
+class Censorship:
+ """
+ A scraped censorship specifier, represented by an enum.
+ """
+
+ value: hircine.enums.Censorship
+
+ def __bool__(self):
+ return self.value is not None
+
+
+@dataclass(frozen=True)
+class Language:
+ """
+ A scraped language, represented by an enum.
+ """
+
+ value: hircine.enums.Language
+
+ def __bool__(self):
+ return self.value is not None
+
+
+@dataclass(frozen=True)
+class Direction:
+ """
+ A scraped direction, represented by an enum.
+ """
+
+ value: hircine.enums.Direction
+
+ def __bool__(self):
+ return self.value is not None
+
+
+@dataclass(frozen=True)
+class Layout:
+ """
+ A scraped layout, represented by an enum.
+ """
+
+ value: hircine.enums.Layout
+
+ def __bool__(self):
+ return self.value is not None
+
+
+@dataclass(frozen=True)
+class Title:
+ """
+ A scraped comic title.
+ """
+
+ value: str
+
+ def __bool__(self):
+ return bool(self.value)
+
+
+@dataclass(frozen=True)
+class OriginalTitle:
+ """
+ A scraped original title.
+ """
+
+ value: str
+
+ def __bool__(self):
+ return bool(self.value)
+
+
+@dataclass(frozen=True)
+class Artist:
+ """
+ A scraped artist.
+ """
+
+ name: str
+
+ def __bool__(self):
+ return bool(self.name)
+
+
+@dataclass(frozen=True)
+class Character:
+ """
+ A scraped character.
+ """
+
+ name: str
+
+ def __bool__(self):
+ return bool(self.name)
+
+
+@dataclass(frozen=True)
+class Circle:
+ """
+ A scraped circle.
+ """
+
+ name: str
+
+ def __bool__(self):
+ return bool(self.name)
+
+
+@dataclass(frozen=True)
+class World:
+ """
+ A scraped world.
+ """
+
+ name: str
+
+ def __bool__(self):
+ return bool(self.name)
+
+
+@dataclass(frozen=True)
+class URL:
+ """
+ A scraped URL.
+ """
+
+ value: str
+
+ def __bool__(self):
+ return bool(self.value)
diff --git a/src/hircine/scraper/utils.py b/src/hircine/scraper/utils.py
new file mode 100644
index 0000000..6afa2ed
--- /dev/null
+++ b/src/hircine/scraper/utils.py
@@ -0,0 +1,62 @@
+import os
+from contextlib import contextmanager
+from zipfile import ZipFile
+
+
+def parse_dict(parsers, data):
+ """
+ Make a generator that yields callables applying parser functions to their
+ matching input data. *parsers* and *data* must both be dictionaries. Parser
+ functions are matched to input data using their dictionary keys. If a
+ parser's key is not present in *data*, it is ignored.
+
+ A key in *parsers* may map to another dictionary of parsers. In this case,
+ this function will be applied recursively to the matching value in *data*,
+ which is assumed to be a dictionary as well.
+
+ If a parser is matched to a list type, one callable for each list item is
+ yielded.
+
+ :param dict parsers: A mapping of parsers.
+ :param dict data: A mapping of data to be parsed.
+ """
+ for field, parser in parsers.items():
+ if field not in data:
+ continue
+
+ value = data[field]
+
+ if isinstance(value, list):
+ yield from [lambda i=x: parser(i) for x in value]
+ elif isinstance(value, dict):
+ yield from parse_dict(parser, value)
+ else:
+ yield lambda: parser(value)
+
+
+@contextmanager
+def open_archive_file(archive, member, check_sidecar=True): # pragma: no cover
+ """
+ Open an archive file for use with the :ref:`with <with>` statement. Yields
+ a :term:`file object` obtained from:
+
+ 1. The archive's :ref:`sidecar file <sidecar-files>`, if it exists and
+ *check_sidecar* is ``True``.
+ 2. Otherwise, the archive itself.
+
+ :param Archive archive: The archive.
+ :param str member: The name of the file within the archive (or its sidecar suffix).
+ :param bool check_sidecar: Whether to check for the sidecar file.
+ """
+ if check_sidecar:
+ sidecar = f"{archive.path}.{member}"
+
+ if os.path.exists(sidecar):
+ with open(sidecar, "r") as file:
+ yield file
+
+ return
+
+ with ZipFile(archive.path, "r") as zip:
+ with zip.open(member, "r") as file:
+ yield file
diff --git a/src/hircine/thumbnailer.py b/src/hircine/thumbnailer.py
new file mode 100644
index 0000000..ed565d5
--- /dev/null
+++ b/src/hircine/thumbnailer.py
@@ -0,0 +1,75 @@
+import os
+from typing import NamedTuple
+
+from PIL import Image
+
+pillow_extensions = {
+ ext for ext, f in Image.registered_extensions().items() if f in Image.OPEN
+}
+
+
+class ThumbnailParameters(NamedTuple):
+ bounds: tuple[int, int]
+ options: dict
+
+
+def params_from(config):
+ return {
+ "full": ThumbnailParameters(
+ bounds=(
+ config.getint("import.scale.full", "width", fallback=4200),
+ config.getint("import.scale.full", "height", fallback=2000),
+ ),
+ options={"quality": 82, "method": 5},
+ ),
+ "thumb": ThumbnailParameters(
+ bounds=(
+ config.getint("import.scale.thumb", "width", fallback=1680),
+ config.getint("import.scale.thumb", "height", fallback=800),
+ ),
+ options={"quality": 75, "method": 5},
+ ),
+ }
+
+
+def object_path(directory, hash, suffix):
+ return os.path.join(directory, hash[:2], f"{hash[2:]}_{suffix}.webp")
+
+
+class Thumbnailer:
+ def __init__(self, directory, params):
+ self.directory = directory
+ self.params = params
+
+ @classmethod
+ def can_process(cls, extension):
+ return extension in pillow_extensions
+
+ def object(self, hash, suffix):
+ return object_path(self.directory, hash, suffix)
+
+ def process(self, handle, hash, reprocess=False):
+ size = None
+
+ for suffix, parameters in self.params.items():
+ source = Image.open(handle, mode="r")
+
+ if not size:
+ size = source.size
+
+ output = self.object(hash, suffix)
+
+ if os.path.exists(output) and not reprocess:
+ continue
+ else:
+ os.makedirs(os.path.dirname(output), exist_ok=True)
+
+ if source.mode != "RGB":
+ target = source.convert()
+ else:
+ target = source
+
+ target.thumbnail(parameters.bounds, resample=Image.Resampling.LANCZOS)
+ target.save(output, **parameters.options)
+
+ return size
diff --git a/tests/api/test_archive.py b/tests/api/test_archive.py
new file mode 100644
index 0000000..0ef3425
--- /dev/null
+++ b/tests/api/test_archive.py
@@ -0,0 +1,388 @@
+import os
+from datetime import datetime as dt
+from pathlib import Path
+
+import hircine.config
+import hircine.db as database
+import hircine.thumbnailer as thumb
+import pytest
+from conftest import DB, Response
+from hircine.db.models import Archive, Comic, Image, Page
+from sqlalchemy import select
+
+
+@pytest.fixture
+def query_archive(execute_id):
+ query = """
+ query archive($id: Int!) {
+ archive(id: $id) {
+ __typename
+ ... on FullArchive {
+ id
+ name
+ createdAt
+ mtime
+ size
+ path
+ pageCount
+ organized
+ comics {
+ __typename
+ id
+ }
+ cover {
+ __typename
+ id
+ }
+ pages {
+ __typename
+ id
+ image {
+ __typename
+ id
+ }
+ }
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_id(query)
+
+
+@pytest.fixture
+def query_archives(execute):
+ query = """
+ query archives {
+ archives {
+ __typename
+ count
+ edges {
+ id
+ name
+ size
+ pageCount
+ organized
+ cover {
+ __typename
+ id
+ }
+ }
+ }
+ }
+ """
+
+ return execute(query)
+
+
+@pytest.fixture
+def update_archives(execute_update):
+ mutation = """
+ mutation updateArchives($ids: [Int!]!, $input: UpdateArchiveInput!) {
+ updateArchives(ids: $ids, input: $input) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on PageRemoteError {
+ id
+ archiveId
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_update(mutation)
+
+
+@pytest.fixture
+def delete_archives(execute_delete):
+ mutation = """
+ mutation deleteArchives($ids: [Int!]!) {
+ deleteArchives(ids: $ids) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_delete(mutation)
+
+
+def assert_image_matches(obj, model):
+ assert obj["__typename"] == "Image"
+ assert obj["id"] == model.id
+
+
+def assert_page_matches(obj, model):
+ assert obj["__typename"] == "Page"
+ assert obj["id"] == model.id
+
+
+@pytest.mark.anyio
+async def test_query_archive(query_archive, gen_archive):
+ archive = next(gen_archive)
+ pages = archive.pages
+
+ await DB.add(archive)
+
+ response = Response(await query_archive(archive.id))
+ response.assert_is("FullArchive")
+
+ assert response.id == archive.id
+ assert response.name == archive.name
+ assert dt.fromisoformat(response.createdAt) == archive.created_at
+ assert dt.fromisoformat(response.mtime) == archive.mtime
+ assert response.size == archive.size
+ assert response.path == archive.path
+ assert response.comics == []
+ assert response.pageCount == archive.page_count
+ assert response.organized == archive.organized
+ assert_image_matches(response.cover, pages[0].image)
+
+ assert len(response.pages) == len(pages)
+
+ page_iter = iter(sorted(pages, key=lambda page: page.index))
+ for page in response.pages:
+ matching_page = next(page_iter)
+ assert_page_matches(page, matching_page)
+ assert_image_matches(page["image"], matching_page.image)
+
+
+@pytest.mark.anyio
+async def test_query_archive_sorts_pages(query_archive, gen_jumbled_archive):
+ archive = await DB.add(next(gen_jumbled_archive))
+
+ response = Response(await query_archive(archive.id))
+ response.assert_is("FullArchive")
+
+ page_iter = iter(sorted(archive.pages, key=lambda page: page.index))
+ for page in response.pages:
+ matching_page = next(page_iter)
+ assert_page_matches(page, matching_page)
+ assert_image_matches(page["image"], matching_page.image)
+
+
+@pytest.mark.anyio
+async def test_query_archive_fails_not_found(query_archive):
+ response = Response(await query_archive(1))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Archive ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_query_archives(query_archives, gen_archive):
+ archives = await DB.add_all(*gen_archive)
+
+ response = Response(await query_archives())
+ response.assert_is("ArchiveFilterResult")
+
+ assert response.count == len(archives)
+ assert isinstance((response.edges), list)
+ assert len(response.edges) == len(archives)
+
+ edges = iter(response.edges)
+ for archive in sorted(archives, key=lambda a: a.name):
+ edge = next(edges)
+ assert edge["id"] == archive.id
+ assert edge["name"] == archive.name
+ assert edge["size"] == archive.size
+ assert edge["pageCount"] == archive.page_count
+ assert_image_matches(edge["cover"], archive.cover)
+
+
+@pytest.fixture
+def gen_archive_with_files(tmpdir, monkeypatch, gen_archive):
+ content_dir = os.path.join(tmpdir, "content/")
+ object_dir = os.path.join(tmpdir, "objects/")
+ os.mkdir(content_dir)
+ os.mkdir(object_dir)
+
+ dirs = hircine.config.DirectoryStructure(scan=content_dir, objects=object_dir)
+ monkeypatch.setattr(hircine.config, "dir_structure", dirs)
+
+ archive = next(gen_archive)
+
+ archive_path = Path(os.path.join(content_dir, "archive.zip"))
+ archive_path.touch()
+ archive.path = str(archive_path)
+
+ img_paths = []
+ for page in archive.pages:
+ for suffix in ["full", "thumb"]:
+ img_path = Path(thumb.object_path(object_dir, page.image.hash, suffix))
+ os.makedirs(os.path.dirname(img_path), exist_ok=True)
+ img_path.touch()
+
+ img_paths.append(img_path)
+
+ yield archive, content_dir, object_dir, img_paths
+
+
+@pytest.mark.anyio
+async def test_delete_archive(delete_archives, gen_archive_with_files):
+ archive, content_dir, object_dir, img_paths = gen_archive_with_files
+ archive_path = archive.path
+
+ archive = await DB.add(archive)
+ page_ids = [page.id for page in archive.pages]
+ image_ids = [page.image.id for page in archive.pages]
+
+ response = Response(await delete_archives(archive.id))
+ response.assert_is("DeleteSuccess")
+
+ archive = await DB.get(Archive, archive.id)
+ assert archive is None
+
+ async with database.session() as s:
+ db_pages = (await s.scalars(select(Page).where(Page.id.in_(page_ids)))).all()
+ db_images = (
+ await s.scalars(select(Image).where(Image.id.in_(image_ids)))
+ ).all()
+
+ assert db_pages == []
+ assert db_images == []
+
+ assert os.path.exists(archive_path) is False
+ for img_path in img_paths:
+ assert os.path.exists(img_path) is False
+
+
+@pytest.mark.anyio
+async def test_delete_archive_deletes_images_only_when_necessary(
+ delete_archives, gen_archive_with_files, gen_archive
+):
+ archive, content_dir, object_dir, img_paths = gen_archive_with_files
+ archive_path = archive.path
+
+ archive = await DB.add(archive)
+ page_ids = [page.id for page in archive.pages]
+ image_ids = [page.image.id for page in archive.pages]
+
+ another = next(gen_archive)
+ another.pages = [
+ Page(path="foo", index=1, image_id=id, archive=another) for id in image_ids
+ ]
+ another.cover = archive.cover
+ await DB.add(another)
+
+ response = Response(await delete_archives(archive.id))
+ response.assert_is("DeleteSuccess")
+
+ archive = await DB.get(Archive, archive.id)
+ assert archive is None
+
+ async with database.session() as s:
+ db_pages = (await s.scalars(select(Page).where(Page.id.in_(page_ids)))).all()
+ db_images = (
+ await s.scalars(select(Image.id).where(Image.id.in_(image_ids)))
+ ).all()
+
+ assert db_pages == []
+ assert db_images == image_ids
+
+ assert os.path.exists(archive_path) is False
+ for img_path in img_paths:
+ assert os.path.exists(img_path) is True
+
+
+@pytest.mark.anyio
+async def test_delete_archive_cascades_on_comic(
+ delete_archives, gen_archive_with_files
+):
+ archive, *_ = gen_archive_with_files
+ comic = Comic(
+ id=1,
+ title="Hic Sunt Dracones",
+ archive=archive,
+ cover=archive.cover,
+ pages=archive.pages,
+ )
+
+ comic = await DB.add(comic)
+
+ response = Response(await delete_archives(comic.archive.id))
+ response.assert_is("DeleteSuccess")
+
+ archive = await DB.get(Archive, archive.id)
+ assert archive is None
+
+ comic = await DB.get(Comic, comic.id)
+ assert comic is None
+
+
+@pytest.mark.anyio
+async def test_update_archives(update_archives, gen_archive):
+ old_archive = await DB.add(next(gen_archive))
+
+ response = Response(
+ await update_archives(
+ old_archive.id,
+ {"cover": {"id": old_archive.pages[1].id}, "organized": True},
+ )
+ )
+ response.assert_is("UpdateSuccess")
+
+ archive = await DB.get(Archive, old_archive.id)
+
+ assert archive.cover_id == old_archive.pages[1].image.id
+ assert archive.organized is True
+
+
+@pytest.mark.anyio
+async def test_update_archive_fails_archive_not_found(update_archives, gen_archive):
+ archive = await DB.add(next(gen_archive))
+
+ response = Response(
+ await update_archives(100, {"cover": {"id": archive.pages[1].id}})
+ )
+ response.assert_is("IDNotFoundError")
+ assert response.id == 100
+ assert response.message == "Archive ID not found: '100'"
+
+
+@pytest.mark.anyio
+async def test_update_archive_cover_fails_page_not_found(update_archives, gen_archive):
+ archive = await DB.add(next(gen_archive))
+
+ response = Response(await update_archives(archive.id, {"cover": {"id": 100}}))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 100
+ assert response.message == "Page ID not found: '100'"
+
+
+@pytest.mark.anyio
+async def test_update_archive_cover_fails_page_remote(update_archives, gen_archive):
+ archive = await DB.add(next(gen_archive))
+ another = await DB.add(next(gen_archive))
+ remote_id = another.pages[0].id
+
+ response = Response(await update_archives(archive.id, {"cover": {"id": remote_id}}))
+ response.assert_is("PageRemoteError")
+ assert response.id == remote_id
+ assert response.archiveId == another.id
+ assert (
+ response.message
+ == f"Page ID {remote_id} comes from remote archive ID {another.id}"
+ )
diff --git a/tests/api/test_artist.py b/tests/api/test_artist.py
new file mode 100644
index 0000000..8cb2f1a
--- /dev/null
+++ b/tests/api/test_artist.py
@@ -0,0 +1,278 @@
+from datetime import datetime as dt
+from datetime import timezone
+
+import pytest
+from conftest import DB, Response
+from hircine.db.models import Artist
+
+
+@pytest.fixture
+def query_artist(execute_id):
+ query = """
+ query artist($id: Int!) {
+ artist(id: $id) {
+ __typename
+ ... on Artist {
+ id
+ name
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_id(query)
+
+
+@pytest.fixture
+def query_artists(execute):
+ query = """
+ query artists {
+ artists {
+ __typename
+ count
+ edges {
+ id
+ name
+ }
+ }
+ }
+ """
+
+ return execute(query)
+
+
+@pytest.fixture
+def add_artist(execute_add):
+ mutation = """
+ mutation addArtist($input: AddArtistInput!) {
+ addArtist(input: $input) {
+ __typename
+ ... on AddSuccess {
+ id
+ }
+ ... on Error {
+ message
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """
+
+ return execute_add(mutation)
+
+
+@pytest.fixture
+def update_artists(execute_update):
+ mutation = """
+ mutation updateArtists($ids: [Int!]!, $input: UpdateArtistInput!) {
+ updateArtists(ids: $ids, input: $input) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """ # noqa: E501
+
+ return execute_update(mutation)
+
+
+@pytest.fixture
+def delete_artists(execute_delete):
+ mutation = """
+ mutation deleteArtists($ids: [Int!]!) {
+ deleteArtists(ids: $ids) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_delete(mutation)
+
+
+@pytest.mark.anyio
+async def test_query_artist(query_artist, gen_artist):
+ artist = await DB.add(next(gen_artist))
+
+ response = Response(await query_artist(artist.id))
+ response.assert_is("Artist")
+
+ assert response.id == artist.id
+ assert response.name == artist.name
+
+
+@pytest.mark.anyio
+async def test_query_artist_fails_not_found(query_artist):
+ response = Response(await query_artist(1))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Artist ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_query_artists(query_artists, gen_artist):
+ artists = await DB.add_all(*gen_artist)
+
+ response = Response(await query_artists())
+ response.assert_is("ArtistFilterResult")
+
+ assert response.count == len(artists)
+ assert isinstance((response.edges), list)
+ assert len(response.edges) == len(artists)
+
+ edges = iter(response.edges)
+ for artist in sorted(artists, key=lambda a: a.name):
+ edge = next(edges)
+ assert edge["id"] == artist.id
+ assert edge["name"] == artist.name
+
+
+@pytest.mark.anyio
+async def test_add_artist(add_artist):
+ response = Response(await add_artist({"name": "added artist"}))
+ response.assert_is("AddSuccess")
+
+ artist = await DB.get(Artist, response.id)
+ assert artist is not None
+ assert artist.name == "added artist"
+
+
+@pytest.mark.anyio
+async def test_add_artist_fails_empty_parameter(add_artist):
+ response = Response(await add_artist({"name": ""}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_add_artist_fails_exists(add_artist, gen_artist):
+ artist = await DB.add(next(gen_artist))
+
+ response = Response(await add_artist({"name": artist.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another Artist with this name exists"
+
+
+@pytest.mark.anyio
+async def test_delete_artist(delete_artists, gen_artist):
+ artist = await DB.add(next(gen_artist))
+ id = artist.id
+
+ response = Response(await delete_artists(id))
+ response.assert_is("DeleteSuccess")
+
+ artist = await DB.get(Artist, id)
+ assert artist is None
+
+
+@pytest.mark.anyio
+async def test_delete_artist_not_found(delete_artists):
+ response = Response(await delete_artists(1))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Artist ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_artist(update_artists, gen_artist):
+ artist = await DB.add(next(gen_artist))
+
+ input = {"name": "updated artist"}
+ response = Response(await update_artists(artist.id, input))
+ response.assert_is("UpdateSuccess")
+
+ artist = await DB.get(Artist, artist.id)
+ assert artist is not None
+ assert artist.name == "updated artist"
+
+
+@pytest.mark.anyio
+async def test_update_artist_fails_exists(update_artists, gen_artist):
+ first = await DB.add(next(gen_artist))
+ second = await DB.add(next(gen_artist))
+
+ response = Response(await update_artists(second.id, {"name": first.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another Artist with this name exists"
+
+
+@pytest.mark.anyio
+async def test_update_artist_fails_not_found(update_artists):
+ response = Response(await update_artists(1, {"name": "updated artist"}))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Artist ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_artists_cannot_bulk_edit_name(update_artists, gen_artist):
+ first = await DB.add(next(gen_artist))
+ second = await DB.add(next(gen_artist))
+
+ response = Response(await update_artists([first.id, second.id], {"name": "unique"}))
+ response.assert_is("InvalidParameterError")
+
+
+@pytest.mark.parametrize(
+ "empty",
+ [
+ None,
+ "",
+ ],
+ ids=[
+ "none",
+ "empty string",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_artist_fails_empty_parameter(update_artists, gen_artist, empty):
+ artist = await DB.add(next(gen_artist))
+
+ response = Response(await update_artists(artist.id, {"name": empty}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_update_artist_changes_updated_at(update_artists):
+ original_artist = Artist(name="artist")
+ original_artist.updated_at = dt(2023, 1, 1, tzinfo=timezone.utc)
+ original_artist = await DB.add(original_artist)
+
+ response = Response(await update_artists(original_artist.id, {"name": "updated"}))
+ response.assert_is("UpdateSuccess")
+
+ artist = await DB.get(Artist, original_artist.id)
+ assert artist.updated_at > original_artist.updated_at
diff --git a/tests/api/test_character.py b/tests/api/test_character.py
new file mode 100644
index 0000000..567d2a4
--- /dev/null
+++ b/tests/api/test_character.py
@@ -0,0 +1,285 @@
+from datetime import datetime as dt
+from datetime import timezone
+
+import pytest
+from conftest import DB, Response
+from hircine.db.models import Character
+
+
+@pytest.fixture
+def query_character(execute_id):
+ query = """
+ query character($id: Int!) {
+ character(id: $id) {
+ __typename
+ ... on Character {
+ id
+ name
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_id(query)
+
+
+@pytest.fixture
+def query_characters(execute):
+ query = """
+ query characters {
+ characters {
+ __typename
+ count
+ edges {
+ id
+ name
+ }
+ }
+ }
+ """
+
+ return execute(query)
+
+
+@pytest.fixture
+def add_character(execute_add):
+ mutation = """
+ mutation addCharacter($input: AddCharacterInput!) {
+ addCharacter(input: $input) {
+ __typename
+ ... on AddSuccess {
+ id
+ }
+ ... on Error {
+ message
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """
+
+ return execute_add(mutation)
+
+
+@pytest.fixture
+def update_characters(execute_update):
+ mutation = """
+ mutation updateCharacters($ids: [Int!]!, $input: UpdateCharacterInput!) {
+ updateCharacters(ids: $ids, input: $input) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """ # noqa: E501
+
+ return execute_update(mutation)
+
+
+@pytest.fixture
+def delete_characters(execute_delete):
+ mutation = """
+ mutation deleteCharacters($ids: [Int!]!) {
+ deleteCharacters(ids: $ids) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_delete(mutation)
+
+
+@pytest.mark.anyio
+async def test_query_character(query_character, gen_character):
+ character = await DB.add(next(gen_character))
+
+ response = Response(await query_character(character.id))
+ response.assert_is("Character")
+
+ assert response.id == character.id
+ assert response.name == character.name
+
+
+@pytest.mark.anyio
+async def test_query_character_fails_not_found(query_character):
+ response = Response(await query_character(1))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Character ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_query_characters(query_characters, gen_character):
+ characters = await DB.add_all(*gen_character)
+
+ response = Response(await query_characters())
+ response.assert_is("CharacterFilterResult")
+
+ assert response.count == len(characters)
+ assert isinstance((response.edges), list)
+ assert len(response.edges) == len(characters)
+
+ edges = iter(response.edges)
+ for character in sorted(characters, key=lambda a: a.name):
+ edge = next(edges)
+ assert edge["id"] == character.id
+ assert edge["name"] == character.name
+
+
+@pytest.mark.anyio
+async def test_add_character(add_character):
+ response = Response(await add_character({"name": "added character"}))
+ response.assert_is("AddSuccess")
+
+ character = await DB.get(Character, response.id)
+ assert character is not None
+ assert character.name == "added character"
+
+
+@pytest.mark.anyio
+async def test_add_character_fails_empty_parameter(add_character):
+ response = Response(await add_character({"name": ""}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_add_character_fails_exists(add_character, gen_character):
+ character = await DB.add(next(gen_character))
+
+ response = Response(await add_character({"name": character.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another Character with this name exists"
+
+
+@pytest.mark.anyio
+async def test_delete_character(delete_characters, gen_character):
+ character = await DB.add(next(gen_character))
+ id = character.id
+
+ response = Response(await delete_characters(id))
+ response.assert_is("DeleteSuccess")
+
+ character = await DB.get(Character, id)
+ assert character is None
+
+
+@pytest.mark.anyio
+async def test_delete_character_not_found(delete_characters):
+ response = Response(await delete_characters(1))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Character ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_character(update_characters, gen_character):
+ character = await DB.add(next(gen_character))
+
+ input = {"name": "updated character"}
+ response = Response(await update_characters(character.id, input))
+ response.assert_is("UpdateSuccess")
+
+ character = await DB.get(Character, character.id)
+ assert character is not None
+ assert character.name == "updated character"
+
+
+@pytest.mark.anyio
+async def test_update_character_fails_exists(update_characters, gen_character):
+ first = await DB.add(next(gen_character))
+ second = await DB.add(next(gen_character))
+
+ response = Response(await update_characters(second.id, {"name": first.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another Character with this name exists"
+
+
+@pytest.mark.anyio
+async def test_update_character_fails_not_found(update_characters):
+ response = Response(await update_characters(1, {"name": "updated_character"}))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Character ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_characters_cannot_bulk_edit_name(
+ update_characters, gen_character
+):
+ first = await DB.add(next(gen_character))
+ second = await DB.add(next(gen_character))
+
+ response = Response(
+ await update_characters([first.id, second.id], {"name": "unique"})
+ )
+ response.assert_is("InvalidParameterError")
+
+
+@pytest.mark.parametrize(
+ "empty",
+ [
+ None,
+ "",
+ ],
+ ids=[
+ "none",
+ "empty string",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_character_fails_empty_parameter(
+ update_characters, gen_character, empty
+):
+ character = await DB.add(next(gen_character))
+ response = Response(await update_characters(character.id, {"name": empty}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_update_character_changes_updated_at(update_characters):
+ original_character = Character(name="character")
+ original_character.updated_at = dt(2023, 1, 1, tzinfo=timezone.utc)
+ original_character = await DB.add(original_character)
+
+ response = Response(
+ await update_characters(original_character.id, {"name": "updated"})
+ )
+ response.assert_is("UpdateSuccess")
+
+ character = await DB.get(Character, original_character.id)
+ assert character.updated_at > original_character.updated_at
diff --git a/tests/api/test_circle.py b/tests/api/test_circle.py
new file mode 100644
index 0000000..a03ba89
--- /dev/null
+++ b/tests/api/test_circle.py
@@ -0,0 +1,278 @@
+from datetime import datetime as dt
+from datetime import timezone
+
+import pytest
+from conftest import DB, Response
+from hircine.db.models import Circle
+
+
+@pytest.fixture
+def query_circle(execute_id):
+ query = """
+ query circle($id: Int!) {
+ circle(id: $id) {
+ __typename
+ ... on Circle {
+ id
+ name
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_id(query)
+
+
+@pytest.fixture
+def query_circles(execute):
+ query = """
+ query circles {
+ circles {
+ __typename
+ count
+ edges {
+ id
+ name
+ }
+ }
+ }
+ """
+
+ return execute(query)
+
+
+@pytest.fixture
+def add_circle(execute_add):
+ mutation = """
+ mutation addCircle($input: AddCircleInput!) {
+ addCircle(input: $input) {
+ __typename
+ ... on AddSuccess {
+ id
+ }
+ ... on Error {
+ message
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """
+
+ return execute_add(mutation)
+
+
+@pytest.fixture
+def update_circles(execute_update):
+ mutation = """
+ mutation updateCircles($ids: [Int!]!, $input: UpdateCircleInput!) {
+ updateCircles(ids: $ids, input: $input) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ ... on Error {
+ message
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """ # noqa: E501
+
+ return execute_update(mutation)
+
+
+@pytest.fixture
+def delete_circles(execute_delete):
+ mutation = """
+ mutation deleteCircles($ids: [Int!]!) {
+ deleteCircles(ids: $ids) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_delete(mutation)
+
+
+@pytest.mark.anyio
+async def test_query_circle(query_circle, gen_circle):
+ circle = await DB.add(next(gen_circle))
+
+ response = Response(await query_circle(circle.id))
+ response.assert_is("Circle")
+
+ assert response.id == circle.id
+ assert response.name == circle.name
+
+
+@pytest.mark.anyio
+async def test_query_circle_fails_not_found(query_circle):
+ response = Response(await query_circle(1))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Circle ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_query_circles(query_circles, gen_circle):
+ circles = await DB.add_all(*gen_circle)
+
+ response = Response(await query_circles())
+ response.assert_is("CircleFilterResult")
+
+ assert response.count == len(circles)
+ assert isinstance((response.edges), list)
+ assert len(response.edges) == len(circles)
+
+ edges = iter(response.edges)
+ for circle in sorted(circles, key=lambda a: a.name):
+ edge = next(edges)
+ assert edge["id"] == circle.id
+ assert edge["name"] == circle.name
+
+
+@pytest.mark.anyio
+async def test_add_circle(add_circle):
+ response = Response(await add_circle({"name": "added circle"}))
+ response.assert_is("AddSuccess")
+
+ circle = await DB.get(Circle, response.id)
+ assert circle is not None
+ assert circle.name == "added circle"
+
+
+@pytest.mark.anyio
+async def test_add_circle_fails_empty_parameter(add_circle):
+ response = Response(await add_circle({"name": ""}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_add_circle_fails_exists(add_circle, gen_circle):
+ circle = await DB.add(next(gen_circle))
+
+ response = Response(await add_circle({"name": circle.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another Circle with this name exists"
+
+
+@pytest.mark.anyio
+async def test_delete_circle(delete_circles, gen_circle):
+ circle = await DB.add(next(gen_circle))
+ id = circle.id
+
+ response = Response(await delete_circles(id))
+ response.assert_is("DeleteSuccess")
+
+ circle = await DB.get(Circle, id)
+ assert circle is None
+
+
+@pytest.mark.anyio
+async def test_delete_circle_not_found(delete_circles):
+ response = Response(await delete_circles(1))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Circle ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_circle(update_circles, gen_circle):
+ circle = await DB.add(next(gen_circle))
+
+ input = {"name": "updated circle"}
+ response = Response(await update_circles(circle.id, input))
+ response.assert_is("UpdateSuccess")
+
+ circle = await DB.get(Circle, circle.id)
+ assert circle is not None
+ assert circle.name == "updated circle"
+
+
+@pytest.mark.anyio
+async def test_update_circle_fails_exists(update_circles, gen_circle):
+ first = await DB.add(next(gen_circle))
+ second = await DB.add(next(gen_circle))
+
+ response = Response(await update_circles(second.id, {"name": first.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another Circle with this name exists"
+
+
+@pytest.mark.anyio
+async def test_update_circle_fails_not_found(update_circles):
+ response = Response(await update_circles(1, {"name": "updated circle"}))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Circle ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_circles_cannot_bulk_edit_name(update_circles, gen_circle):
+ first = await DB.add(next(gen_circle))
+ second = await DB.add(next(gen_circle))
+
+ response = Response(await update_circles([first.id, second.id], {"name": "unique"}))
+ response.assert_is("InvalidParameterError")
+
+
+@pytest.mark.parametrize(
+ "empty",
+ [
+ None,
+ "",
+ ],
+ ids=[
+ "none",
+ "empty string",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_circle_fails_empty_parameter(update_circles, gen_circle, empty):
+ circle = await DB.add(next(gen_circle))
+
+ response = Response(await update_circles(circle.id, {"name": empty}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_update_circle_changes_updated_at(update_circles):
+ original_circle = Circle(name="circle")
+ original_circle.updated_at = dt(2023, 1, 1, tzinfo=timezone.utc)
+ original_circle = await DB.add(original_circle)
+
+ response = Response(await update_circles(original_circle.id, {"name": "updated"}))
+ response.assert_is("UpdateSuccess")
+
+ circle = await DB.get(Circle, original_circle.id)
+ assert circle.updated_at > original_circle.updated_at
diff --git a/tests/api/test_collection.py b/tests/api/test_collection.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/api/test_collection.py
diff --git a/tests/api/test_comic.py b/tests/api/test_comic.py
new file mode 100644
index 0000000..d3fa51e
--- /dev/null
+++ b/tests/api/test_comic.py
@@ -0,0 +1,1505 @@
+from datetime import date, timezone
+from datetime import datetime as dt
+
+import pytest
+from conftest import DB, Response
+from hircine.db.models import (
+ Artist,
+ Circle,
+ Comic,
+ ComicArtist,
+ ComicTag,
+ Namespace,
+ Tag,
+ World,
+)
+from hircine.enums import Category, Censorship, Direction, Language, Layout, Rating
+
+full_comic_fragment = """
+ fragment FullComic on FullComic {
+ id
+ title
+ category
+ censorship
+ createdAt
+ date
+ direction
+ language
+ layout
+ originalTitle
+ url
+ rating
+ pageCount
+ updatedAt
+ organized
+ bookmarked
+ archive {
+ __typename
+ id
+ }
+ artists {
+ __typename
+ id
+ }
+ characters {
+ __typename
+ id
+ }
+ circles {
+ __typename
+ id
+ }
+ cover {
+ __typename
+ id
+ }
+ pages {
+ __typename
+ id
+ }
+ tags {
+ __typename
+ id
+ name
+ }
+ worlds {
+ __typename
+ id
+ }
+ }
+"""
+
+comic_fragment = """
+ fragment Comic on Comic {
+ id
+ title
+ category
+ censorship
+ date
+ language
+ originalTitle
+ rating
+ pageCount
+ organized
+ bookmarked
+ artists {
+ __typename
+ id
+ }
+ characters {
+ __typename
+ id
+ }
+ circles {
+ __typename
+ id
+ }
+ cover {
+ __typename
+ id
+ }
+ tags {
+ __typename
+ id
+ name
+ }
+ worlds {
+ __typename
+ id
+ }
+ }
+"""
+
+
+@pytest.fixture
+def query_comic(execute_id):
+ query = """
+ query comic($id: Int!) {
+ comic(id: $id) {
+ __typename
+ ... on FullComic {
+ ...FullComic
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_id(full_comic_fragment + query)
+
+
+@pytest.fixture
+def query_comics(execute):
+ query = """
+ query comics {
+ comics {
+ __typename
+ count
+ edges {
+ ...Comic
+ }
+ }
+ }
+ """
+
+ return execute(comic_fragment + query)
+
+
+@pytest.fixture
+def add_comic(execute_add):
+ mutation = """
+ mutation addComic($input: AddComicInput!) {
+ addComic(input: $input) {
+ __typename
+ ... on AddComicSuccess {
+ id
+ archivePagesRemaining
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ ... on PageClaimedError {
+ comicId
+ id
+ }
+ ... on PageRemoteError {
+ archiveId
+ id
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """
+
+ return execute_add(mutation)
+
+
+@pytest.fixture
+def delete_comics(execute_delete):
+ mutation = """
+ mutation deleteComics($ids: [Int!]!) {
+ deleteComics(ids: $ids) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_delete(mutation)
+
+
+@pytest.fixture
+def update_comics(execute_update):
+ mutation = """
+ mutation updateComics($ids: [Int!]!, $input: UpdateComicInput!) {
+ updateComics(ids: $ids, input: $input) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on PageRemoteError {
+ id
+ archiveId
+ }
+ ... on PageClaimedError {
+ id
+ comicId
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """ # noqa: E501
+
+ return execute_update(mutation)
+
+
+@pytest.fixture
+def upsert_comics(execute_update):
+ mutation = """
+ mutation upsertComics($ids: [Int!]!, $input: UpsertComicInput!) {
+ upsertComics(ids: $ids, input: $input) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """ # noqa: E501
+
+ return execute_update(mutation)
+
+
+def assert_association_matches(obj, model, typename):
+ assert obj["__typename"] == typename
+ assert obj["id"] == model.id
+
+
+def assert_associations_match(objlist, modellist, typename, sortkey):
+ assert isinstance((objlist), list)
+ assert len(objlist) == len(modellist)
+ model = iter(sorted(modellist, key=sortkey))
+ for obj in objlist:
+ assert_association_matches(obj, next(model), typename)
+
+
+def assert_comic_item_matches(data, comic):
+ assert data["id"] == comic.id
+ assert data["title"] == comic.title
+ assert data["originalTitle"] == comic.original_title
+ assert date.fromisoformat(data["date"]) == comic.date
+ assert Rating[data["rating"]] == comic.rating
+ assert Language[data["language"]] == comic.language
+ assert data["pageCount"] == comic.page_count
+ assert data["organized"] == comic.organized
+ assert data["bookmarked"] == comic.bookmarked
+
+ if data["category"]:
+ assert Category[data["category"]] == comic.category
+ else:
+ assert comic.category is None
+
+ if data["censorship"]:
+ assert Censorship[data["censorship"]] == comic.censorship
+ else:
+ assert comic.censorship is None
+
+ assert_association_matches(data["cover"], comic.cover, "Image")
+ assert_associations_match(
+ data["artists"], comic.artists, "Artist", lambda a: a.name
+ )
+ assert_associations_match(
+ data["characters"], comic.characters, "Character", lambda c: c.name
+ )
+ assert_associations_match(
+ data["circles"], comic.circles, "Circle", lambda c: c.name
+ )
+ assert_associations_match(data["tags"], comic.tags, "ComicTag", lambda t: t.name)
+ assert_associations_match(data["worlds"], comic.worlds, "World", lambda w: w.name)
+
+
+def assert_comic_matches(data, comic):
+ assert_comic_item_matches(data, comic)
+ assert dt.fromisoformat(data["createdAt"]) == comic.created_at
+ assert dt.fromisoformat(data["updatedAt"]) == comic.updated_at
+ assert Direction[data["direction"]] == comic.direction
+ assert Layout[data["layout"]] == comic.layout
+ assert data["url"] == comic.url
+
+ assert_association_matches(data["archive"], comic.archive, "Archive")
+ assert_associations_match(data["pages"], comic.pages, "Page", lambda p: p.index)
+
+
+@pytest.mark.anyio
+async def test_query_comic(query_comic, gen_comic):
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await query_comic(comic.id))
+ response.assert_is("FullComic")
+
+ assert_comic_matches(response.data, comic)
+
+
+@pytest.mark.anyio
+async def test_query_comic_sorts_pages(query_comic, gen_jumbled_archive):
+ archive = next(gen_jumbled_archive)
+
+ comic = await DB.add(
+ Comic(
+ id=1,
+ title="A Jumbled Mess",
+ archive=archive,
+ pages=archive.pages,
+ cover=archive.cover,
+ )
+ )
+
+ response = Response(await query_comic(comic.id))
+ response.assert_is("FullComic")
+
+ assert_associations_match(response.pages, comic.pages, "Page", lambda p: p.index)
+
+
+@pytest.mark.anyio
+async def test_query_comic_fails_not_found(query_comic):
+ response = Response(await query_comic(1))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Comic ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_query_comics(query_comics, gen_comic):
+ comics = await DB.add_all(*gen_comic)
+
+ response = Response(await query_comics())
+ response.assert_is("ComicFilterResult")
+
+ assert response.count == len(comics)
+ assert isinstance((response.edges), list)
+ assert len(response.edges) == len(comics)
+
+ edge = iter(response.edges)
+ for comic in sorted(comics, key=lambda c: c.title):
+ assert_comic_item_matches(next(edge), comic)
+
+
+@pytest.mark.anyio
+async def test_add_comic(add_comic, gen_archive):
+ archive = next(gen_archive)
+ await DB.add(archive)
+
+ before = dt.now(timezone.utc).replace(microsecond=0)
+
+ response = Response(
+ await add_comic(
+ {
+ "title": "The Comically Bad Comic",
+ "archive": {"id": archive.id},
+ "pages": {"ids": [p.id for p in archive.pages]},
+ "cover": {"id": archive.pages[0].id},
+ }
+ )
+ )
+ response.assert_is("AddComicSuccess")
+ assert response.archivePagesRemaining is False
+
+ after = dt.now(timezone.utc).replace(microsecond=0)
+
+ comic = await DB.get(Comic, response.id, full=True)
+ assert comic is not None
+ assert comic.title == "The Comically Bad Comic"
+
+ assert comic.archive.id == archive.id
+ assert comic.archive.organized is True
+
+ assert set([page.id for page in comic.pages]) == set(
+ [page.id for page in archive.pages]
+ )
+
+ assert comic.cover.id == archive.cover.id
+
+ assert comic.category is None
+ assert comic.censorship is None
+ assert comic.created_at >= before
+ assert comic.created_at <= after
+ assert comic.date is None
+ assert comic.language is None
+ assert comic.layout == Layout.SINGLE
+ assert comic.original_title is None
+ assert comic.url is None
+ assert comic.rating is None
+
+ assert comic.artists == []
+ assert comic.characters == []
+ assert comic.circles == []
+ assert comic.tags == []
+ assert comic.worlds == []
+
+
+@pytest.mark.anyio
+async def test_add_comic_pages_remaining(add_comic, gen_archive):
+ archive = next(gen_archive)
+ await DB.add(archive)
+
+ response = Response(
+ await add_comic(
+ {
+ "title": "The Unfinished Comic",
+ "archive": {"id": archive.id},
+ "pages": {"ids": [p.id for p in archive.pages][:2]},
+ "cover": {"id": archive.pages[0].id},
+ }
+ )
+ )
+ response.assert_is("AddComicSuccess")
+ assert response.archivePagesRemaining is True
+
+ comic = await DB.get(Comic, response.id, full=True)
+ assert comic.archive.organized is False
+
+
+@pytest.mark.anyio
+async def test_add_comic_fails_archive_not_found(add_comic, gen_archive):
+ archive = next(gen_archive)
+ await DB.add(archive)
+
+ response = Response(
+ await add_comic(
+ {
+ "title": "Voidful Comic",
+ "archive": {"id": 10},
+ "pages": {"ids": [p.id for p in archive.pages]},
+ "cover": {"id": archive.pages[0].id},
+ }
+ )
+ )
+ response.assert_is("IDNotFoundError")
+ assert response.id == 10
+ assert response.message == "Archive ID not found: '10'"
+
+
+@pytest.mark.anyio
+async def test_add_comic_fails_page_not_found(add_comic, gen_archive):
+ archive = next(gen_archive)
+ await DB.add(archive)
+
+ response = Response(
+ await add_comic(
+ {
+ "title": "Pageless Comic",
+ "archive": {"id": archive.id},
+ "pages": {"ids": [10]},
+ "cover": {"id": archive.pages[0].id},
+ }
+ )
+ )
+ response.assert_is("IDNotFoundError")
+ assert response.id == 10
+ assert response.message == "Page ID not found: '10'"
+
+
+@pytest.mark.anyio
+async def test_add_comic_fails_page_claimed(add_comic, gen_archive):
+ other_archive = next(gen_archive)
+ other_comic = await DB.add(
+ Comic(
+ title="Lawful Comic",
+ archive=other_archive,
+ cover=other_archive.cover,
+ pages=other_archive.pages,
+ )
+ )
+
+ claimed_page = other_comic.pages[0]
+
+ archive = next(gen_archive)
+ await DB.add(archive)
+
+ response = Response(
+ await add_comic(
+ {
+ "title": "Comic of Attempted Burglary",
+ "archive": {"id": archive.id},
+ "pages": {"ids": [claimed_page.id]},
+ "cover": {"id": archive.pages[0].id},
+ }
+ )
+ )
+
+ response.assert_is("PageClaimedError")
+ assert response.id == claimed_page.id
+ assert response.comicId == other_comic.id
+ assert (
+ response.message
+ == f"Page ID {claimed_page.id} is already claimed by comic ID {other_comic.id}"
+ )
+
+
+@pytest.mark.anyio
+async def test_add_comic_fails_empty_parameter(add_comic, gen_archive):
+ archive = next(gen_archive)
+ await DB.add(archive)
+
+ response = Response(
+ await add_comic(
+ {
+ "title": "",
+ "archive": {"id": archive.id},
+ "pages": {"ids": [p.id for p in archive.pages]},
+ "cover": {"id": archive.pages[0].id},
+ }
+ )
+ )
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "title"
+
+
+@pytest.mark.anyio
+async def test_add_comic_fails_page_remote(add_comic, gen_archive):
+ other_archive = await DB.add(next(gen_archive))
+ other_page = other_archive.pages[0]
+
+ archive = await DB.add(next(gen_archive))
+
+ response = Response(
+ await add_comic(
+ {
+ "title": "Comic of Multiple Archives",
+ "archive": {"id": archive.id},
+ "pages": {"ids": [other_page.id]},
+ "cover": {"id": archive.pages[0].id},
+ }
+ )
+ )
+
+ response.assert_is("PageRemoteError")
+ assert response.id == other_page.id
+ assert response.archiveId == other_archive.id
+ assert (
+ response.message
+ == f"Page ID {other_page.id} comes from remote archive ID {other_archive.id}"
+ )
+
+
+@pytest.mark.anyio
+async def test_add_comic_fails_cover_remote(add_comic, gen_archive):
+ other_archive = await DB.add(next(gen_archive))
+ other_page = other_archive.pages[0]
+
+ archive = await DB.add(next(gen_archive))
+
+ response = Response(
+ await add_comic(
+ {
+ "title": "Comic of Multiple Archives",
+ "archive": {"id": archive.id},
+ "pages": {"ids": [p.id for p in archive.pages]},
+ "cover": {"id": other_page.id},
+ }
+ )
+ )
+
+ response.assert_is("PageRemoteError")
+ assert response.id == other_page.id
+ assert response.archiveId == other_archive.id
+ assert (
+ response.message
+ == f"Page ID {other_page.id} comes from remote archive ID {other_archive.id}"
+ )
+
+
+@pytest.mark.anyio
+async def test_delete_comic(delete_comics, gen_comic):
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await delete_comics(comic.id))
+ response.assert_is("DeleteSuccess")
+
+ comic = await DB.get(Comic, comic.id)
+ assert comic is None
+
+
+@pytest.mark.anyio
+async def test_delete_comic_not_found(delete_comics):
+ response = Response(await delete_comics(1))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Comic ID not found: '1'"
+
+
+def assert_assocs_match(assocs, collection, name_only=False):
+ assert set([o.name for o in assocs]) == set([o.name for o in collection])
+ assert set([o.id for o in assocs]) == set([o.id for o in collection])
+
+
+@pytest.mark.anyio
+async def test_update_comic(update_comics, gen_comic):
+ original_comic = await DB.add(next(gen_comic))
+
+ artists = await DB.add_all(Artist(name="arty"), Artist(name="farty"))
+ circles = await DB.add_all(Circle(name="round"), Circle(name="oval"))
+ worlds = await DB.add_all(World(name="animal world"), World(name="no spiders"))
+
+ namespace = await DB.add(Namespace(name="emus"))
+ tag = await DB.add(Tag(name="creepy"))
+ ct = ComicTag(namespace=namespace, tag=tag)
+
+ new_tags = [ct] + original_comic.tags
+ new_pages = [p.id for p in original_comic.pages[:2]]
+
+ input = {
+ "title": "Saucy Savannah Adventures (now in Italian)",
+ "url": "file:///home/savannah/avventura",
+ "originalTitle": original_comic.title,
+ "cover": {"id": original_comic.pages[1].id},
+ "pages": {"ids": new_pages},
+ "favourite": False,
+ "organized": True,
+ "bookmarked": True,
+ "artists": {"ids": [a.id for a in artists]},
+ "circles": {"ids": [c.id for c in circles]},
+ "worlds": {"ids": [w.id for w in worlds]},
+ "tags": {"ids": [ct.id for ct in new_tags]},
+ "date": "2010-07-06",
+ "direction": "LEFT_TO_RIGHT",
+ "language": "IT",
+ "layout": "DOUBLE_OFFSET",
+ "rating": "EXPLICIT",
+ "censorship": "BAR",
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert comic is not None
+ assert comic.title == "Saucy Savannah Adventures (now in Italian)"
+ assert comic.original_title == original_comic.title
+ assert comic.cover.id == original_comic.pages[1].image.id
+ assert comic.url == "file:///home/savannah/avventura"
+ assert comic.favourite is False
+ assert comic.organized is True
+ assert comic.bookmarked is True
+
+ assert set([p.id for p in comic.pages]) == set(new_pages)
+
+ assert_assocs_match(comic.artists, artists)
+ assert_assocs_match(comic.circles, circles)
+ assert_assocs_match(comic.characters, original_comic.characters)
+ assert_assocs_match(comic.worlds, worlds)
+ assert_assocs_match(comic.tags, new_tags)
+
+ assert comic.date == date(2010, 7, 6)
+ assert comic.direction == Direction.LEFT_TO_RIGHT
+ assert comic.layout == Layout.DOUBLE_OFFSET
+ assert comic.rating == Rating.EXPLICIT
+ assert comic.language == Language.IT
+ assert comic.censorship == Censorship.BAR
+
+
+@pytest.mark.anyio
+async def test_update_comic_clears_associations(update_comics, gen_comic):
+ original_comic = await DB.add(next(gen_comic))
+
+ empty = {"ids": []}
+
+ input = {
+ "artists": empty,
+ "circles": empty,
+ "worlds": empty,
+ "tags": empty,
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert comic is not None
+
+ assert comic.artists == []
+ assert comic.circles == []
+ assert comic.worlds == []
+ assert comic.tags == []
+
+
+@pytest.mark.anyio
+async def test_update_comic_clears_enums(update_comics, gen_comic):
+ original_comic = await DB.add(next(gen_comic))
+
+ input = {
+ "category": None,
+ "censorship": None,
+ "rating": None,
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert comic is not None
+
+ assert comic.rating is None
+ assert comic.category is None
+ assert comic.censorship is None
+
+
+@pytest.mark.parametrize(
+ "empty",
+ [
+ None,
+ "",
+ ],
+ ids=[
+ "with None",
+ "with empty string",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_comic_clears_string_fields(update_comics, gen_comic, empty):
+ original_comic = await DB.add(next(gen_comic))
+
+ input = {
+ "originalTitle": empty,
+ "url": empty,
+ "date": None,
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert comic is not None
+
+ assert comic.original_title is None
+ assert comic.date is None
+ assert comic.url is None
+
+
+@pytest.mark.anyio
+async def test_update_comic_fails_comic_not_found(update_comics):
+ response = Response(await update_comics(1, {"title": "This Will Not Happen"}))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Comic ID not found: '1'"
+
+
+@pytest.mark.parametrize(
+ "parameter,empty",
+ [
+ ("title", ""),
+ ("title", None),
+ ("direction", None),
+ ("layout", None),
+ ],
+ ids=[
+ "title (empty string)",
+ "title (none)",
+ "direction",
+ "layout",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_comic_fails_empty_parameter(
+ update_comics, gen_archive, parameter, empty
+):
+ archive = next(gen_archive)
+ comic = await DB.add(
+ Comic(
+ title="Dusty Old Comic",
+ archive=archive,
+ cover=archive.cover,
+ pages=archive.pages,
+ )
+ )
+
+ response = Response(await update_comics(comic.id, {parameter: empty}))
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == parameter
+ assert response.message == f"Invalid parameter '{parameter}': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_update_comic_fails_namespace_not_found(update_comics, gen_archive):
+ archive = next(gen_archive)
+ comic = await DB.add(
+ Comic(
+ title="Dusty Old Comic",
+ archive=archive,
+ cover=archive.cover,
+ pages=archive.pages,
+ )
+ )
+
+ tag = await DB.add(Tag(name="shiny"))
+
+ response = Response(
+ await update_comics(comic.id, {"tags": {"ids": [f"1:{tag.id}"]}})
+ )
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Namespace ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_comic_fails_tag_not_found(update_comics, gen_archive):
+ archive = next(gen_archive)
+ comic = await DB.add(
+ Comic(
+ title="Dusty Old Comic",
+ archive=archive,
+ cover=archive.cover,
+ pages=archive.pages,
+ )
+ )
+
+ namespace = await DB.add(Namespace(name="height"))
+
+ response = Response(
+ await update_comics(comic.id, {"tags": {"ids": [f"{namespace.id}:1"]}})
+ )
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Tag ID not found: '1'"
+
+
+@pytest.mark.parametrize(
+ "input",
+ [
+ "",
+ ":1",
+ "1:",
+ "a:b",
+ "tag",
+ ],
+ ids=[
+ "empty",
+ "namespacing missing",
+ "tag missing",
+ "no numeric ids",
+ "wrong format",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_comic_fails_invalid_tag(update_comics, gen_archive, input):
+ archive = next(gen_archive)
+ comic = await DB.add(
+ Comic(
+ title="Dusty Old Comic",
+ archive=archive,
+ cover=archive.cover,
+ pages=archive.pages,
+ )
+ )
+
+ response = Response(await update_comics(comic.id, {"tags": {"ids": [input]}}))
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "id"
+
+ msg = "Invalid parameter 'id': ComicTag ID must be specified as <namespace_id>:<tag_id>" # noqa: E501
+ assert response.message == msg
+
+
+@pytest.mark.parametrize(
+ "name,key,id",
+ [
+ ("Artist", "artists", 1),
+ ("Character", "characters", 1),
+ ("Circle", "circles", 1),
+ ("World", "worlds", 1),
+ ],
+ ids=[
+ "artist",
+ "character",
+ "circle",
+ "world",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_comic_fails_assoc_not_found(
+ update_comics, gen_archive, name, key, id
+):
+ archive = next(gen_archive)
+ comic = await DB.add(
+ Comic(
+ title="Dusty Old Comic",
+ archive=archive,
+ cover=archive.cover,
+ pages=archive.pages,
+ )
+ )
+
+ response = Response(await update_comics(comic.id, {key: {"ids": [id]}}))
+ response.assert_is("IDNotFoundError")
+ assert response.id == id
+ assert response.message == f"{name} ID not found: '{id}'"
+
+
+@pytest.mark.parametrize(
+ "option",
+ [
+ None,
+ {},
+ {"onMissing": "IGNORE"},
+ ],
+ ids=[
+ "default option",
+ "empty option",
+ "explicit option",
+ ],
+)
+@pytest.mark.anyio
+async def test_upsert_comic_ignores_with(upsert_comics, gen_comic, option):
+ original_comic = await DB.add(next(gen_comic))
+
+ new_artists = await DB.add_all(Artist(name="arty"), Artist(name="farty"))
+
+ input = {
+ "artists": {
+ "names": [a.name for a in new_artists] + ["newy"],
+ "options": option,
+ },
+ }
+ response = Response(await upsert_comics(original_comic.id, input))
+ response.assert_is("UpsertSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert comic is not None
+
+ assert_assocs_match(comic.artists, original_comic.artists + list(new_artists))
+
+
+@pytest.mark.parametrize(
+ "option",
+ [
+ None,
+ {},
+ {"onMissing": "IGNORE"},
+ ],
+ ids=[
+ "default option",
+ "empty option",
+ "explicit option",
+ ],
+)
+@pytest.mark.anyio
+async def test_upsert_comic_ignores_missing_tags_with(upsert_comics, gen_comic, option):
+ original_comic = await DB.add(next(gen_comic))
+
+ tags = await DB.add_all(
+ ComicTag(
+ comic_id=original_comic.id,
+ namespace=Namespace(name="foo"),
+ tag=Tag(name="bar"),
+ )
+ )
+
+ input = {
+ "tags": {"names": ["foo:bar", "baz:qux"], "options": option},
+ }
+ response = Response(await upsert_comics(original_comic.id, input))
+ response.assert_is("UpsertSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert comic is not None
+
+ assert_assocs_match(comic.tags, original_comic.tags + list(tags))
+
+
+@pytest.mark.parametrize(
+ "option",
+ [
+ None,
+ {},
+ {"onMissing": "IGNORE"},
+ {"onMissing": "CREATE"},
+ ],
+ ids=[
+ "default option",
+ "empty option",
+ "IGNORE",
+ "CREATE",
+ ],
+)
+@pytest.mark.anyio
+async def test_upsert_comic_skips_existing_tags(upsert_comics, gen_comic, option):
+ comic = await DB.add(next(gen_comic))
+
+ ctag = comic.tags[0]
+ names = [f"{ctag.namespace.name}:{ctag.tag.name}"]
+
+ input = {
+ "tags": {"names": names, "options": option},
+ }
+ response = Response(await upsert_comics(comic.id, input))
+ response.assert_is("UpsertSuccess")
+
+ comic = await DB.get(Comic, comic.id, full=True)
+ assert comic is not None
+
+ assert_assocs_match(comic.tags, comic.tags)
+
+
+@pytest.mark.parametrize(
+ "valid",
+ [
+ True,
+ False,
+ ],
+ ids=[
+ "valid combination",
+ "invalid combination",
+ ],
+)
+@pytest.mark.anyio
+async def test_upsert_comic_ignore_missing_handles_resident(
+ upsert_comics, gen_comic, valid
+):
+ original_comic = await DB.add(next(gen_comic))
+
+ namespace = await DB.add(Namespace(name="foo"))
+ tag = Tag(name="bar")
+ if valid:
+ tag.namespaces = [namespace]
+
+ tag = await DB.add(tag)
+ ctag = ComicTag(namespace=namespace, tag=tag)
+
+ expected_tags = original_comic.tags
+
+ if valid:
+ expected_tags.append(ctag)
+
+ input = {
+ "tags": {"names": ["foo:bar"], "options": {"onMissing": "IGNORE"}},
+ }
+ response = Response(await upsert_comics(original_comic.id, input))
+ response.assert_is("UpsertSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert comic is not None
+
+ assert_assocs_match(comic.tags, expected_tags)
+
+
+@pytest.mark.anyio
+async def test_upsert_comic_tags_uses_existing(upsert_comics, empty_comic):
+ original_comic = await DB.add(empty_comic)
+
+ await DB.add_all(Namespace(name="foo"))
+ await DB.add_all(Tag(name="bar"))
+
+ tag_names = ["foo:bar"]
+
+ response = Response(
+ await upsert_comics(
+ original_comic.id,
+ {"tags": {"names": tag_names, "options": {"onMissing": "CREATE"}}},
+ )
+ )
+ response.assert_is("UpsertSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert comic is not None
+
+ assert set(tag_names) == set(
+ [f"{t.namespace.name}:{t.tag.name}" for t in comic.tags]
+ )
+
+
+@pytest.mark.parametrize(
+ "key,list",
+ [
+ ("artists", ["arty", "farty"]),
+ ("tags", ["alien:medium", "human:tiny"]),
+ ("artists", ["arty", "arty"]),
+ ("tags", ["foo:good", "bar:good"]),
+ ("tags", ["foo:good", "foo:bad"]),
+ ("artists", []),
+ ],
+ ids=[
+ "artists",
+ "tags",
+ "artists (duplicate)",
+ "tags (duplicate)",
+ "namespace (duplicate)",
+ "artists (empty)",
+ ],
+)
+@pytest.mark.anyio
+async def test_upsert_comic_creates(upsert_comics, empty_comic, key, list):
+ original_comic = await DB.add(empty_comic)
+
+ input = {
+ key: {"names": list, "options": {"onMissing": "CREATE"}},
+ }
+ response = Response(await upsert_comics(original_comic.id, input))
+ response.assert_is("UpsertSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert comic is not None
+
+ assert set(list) == set([o.name for o in getattr(comic, key)])
+
+
+@pytest.mark.anyio
+async def test_upsert_comic_fails_creating_empty_assoc_name(upsert_comics, gen_comic):
+ comic = await DB.add(next(gen_comic))
+
+ input = {
+ "artists": {"names": ""},
+ }
+ response = Response(await upsert_comics(comic.id, input))
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "Artist.name"
+
+
+@pytest.mark.anyio
+async def test_upsert_comic_does_not_replace(upsert_comics, gen_comic):
+ original_comic = await DB.add(next(gen_comic))
+ original_artists = set([a.name for a in original_comic.artists])
+
+ input = {
+ "artists": {"names": []},
+ }
+ response = Response(await upsert_comics(original_comic.id, input))
+ response.assert_is("UpsertSuccess")
+
+ comic = await DB.get(Comic, original_comic.id)
+ artists = set([a.name for a in comic.artists])
+
+ assert artists == original_artists
+
+
+@pytest.mark.parametrize(
+ "input",
+ [
+ "",
+ ":tiny",
+ "human:",
+ "medium",
+ ],
+ ids=[
+ "empty",
+ "namespace missing",
+ "tag missing",
+ "wrong format",
+ ],
+)
+@pytest.mark.anyio
+async def test_upsert_comic_fails_creating_invalid_tag(upsert_comics, gen_comic, input):
+ comic = await DB.add(next(gen_comic))
+
+ input = {
+ "tags": {"names": [input]},
+ }
+ response = Response(await upsert_comics(comic.id, input))
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ msg = "Invalid parameter 'name': ComicTag name must be specified as <namespace>:<tag>" # noqa: E501
+ assert response.message == msg
+
+
+@pytest.mark.parametrize(
+ "options",
+ [
+ None,
+ {},
+ {"mode": "REPLACE"},
+ ],
+ ids=[
+ "by default (none)",
+ "by default (empty record)",
+ "when defined explicitly",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_comic_replaces_assocs(update_comics, gen_comic, options):
+ original_comic = await DB.add(next(gen_comic))
+ new_artist = await DB.add(Artist(name="max"))
+
+ input = {
+ "artists": {"ids": [new_artist.id]},
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+
+ assert_assocs_match(comic.artists, [new_artist])
+
+
+@pytest.mark.anyio
+async def test_update_comic_adds_assocs(update_comics, gen_comic):
+ original_comic = await DB.add(next(gen_comic))
+ new_artist = await DB.add(Artist(name="max"))
+ added_artists = original_comic.artists + [new_artist]
+
+ input = {
+ "artists": {"ids": [new_artist.id], "options": {"mode": "ADD"}},
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+
+ assert_assocs_match(comic.artists, added_artists)
+
+
+@pytest.mark.anyio
+async def test_update_comic_adds_existing_assocs(update_comics, gen_comic):
+ original_comic = await DB.add(next(gen_comic))
+ artists = original_comic.artists
+
+ input = {
+ "artists": {
+ "ids": [artist.id for artist in artists],
+ "options": {"mode": "ADD"},
+ },
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+
+ assert_assocs_match(comic.artists, artists)
+
+
+@pytest.mark.anyio
+async def test_update_comic_adds_tags(update_comics, gen_comic):
+ original_comic = await DB.add(next(gen_comic))
+ new_namespace = await DB.add(Namespace(name="new"))
+ new_tag = await DB.add(Tag(name="new"))
+ added_tags = original_comic.tags + [
+ ComicTag(comic_id=original_comic.id, tag=new_tag, namespace=new_namespace)
+ ]
+
+ input = {
+ "tags": {
+ "ids": [f"{new_namespace.id}:{new_tag.id}"],
+ "options": {"mode": "ADD"},
+ },
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert_assocs_match(comic.tags, added_tags)
+
+
+@pytest.mark.anyio
+async def test_update_comic_adds_existing_tags(update_comics, gen_comic):
+ original_comic = await DB.add(next(gen_comic))
+ tags = original_comic.tags
+
+ input = {
+ "tags": {
+ "ids": [f"{tag.namespace.id}:{tag.tag.id}" for tag in tags],
+ "options": {"mode": "ADD"},
+ },
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+ assert_assocs_match(comic.tags, tags)
+
+
+@pytest.mark.anyio
+async def test_update_comic_removes_assocs(update_comics, empty_comic):
+ original_comic = empty_comic
+ removed_artist = Artist(id=1, name="sam")
+ remaining_artist = Artist(id=2, name="max")
+ original_comic.artists = [removed_artist, remaining_artist]
+ original_comic = await DB.add(original_comic)
+
+ input = {
+ "artists": {"ids": [removed_artist.id], "options": {"mode": "REMOVE"}},
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+
+ assert_assocs_match(comic.artists, [remaining_artist])
+
+
+@pytest.mark.anyio
+async def test_update_comic_removes_tags(update_comics, empty_comic):
+ original_comic = empty_comic
+ removed_tag = ComicTag(
+ comic_id=original_comic.id,
+ tag=Tag(id=1, name="gone"),
+ namespace=Namespace(id=1, name="all"),
+ )
+ remaining_tag = ComicTag(
+ comic_id=original_comic.id,
+ tag=Tag(id=2, name="there"),
+ namespace=Namespace(id=2, name="still"),
+ )
+ original_comic.tags = [removed_tag, remaining_tag]
+ original_comic = await DB.add(original_comic)
+
+ input = {
+ "tags": {"ids": ["1:1"], "options": {"mode": "REMOVE"}},
+ }
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id, full=True)
+
+ assert_assocs_match(comic.tags, [remaining_tag])
+
+
+@pytest.mark.parametrize(
+ "rows,input",
+ [
+ ([], {"title": "Updated Comic"}),
+ (
+ [Artist(id=1, name="artist")],
+ {"artists": {"ids": [1]}},
+ ),
+ (
+ [Artist(id=1, name="artist"), ComicArtist(artist_id=1, comic_id=100)],
+ {"title": "Updated Comic", "artists": {"ids": [1]}},
+ ),
+ (
+ [
+ Namespace(id=1, name="ns"),
+ Tag(id=1, name="artist"),
+ ],
+ {"tags": {"ids": ["1:1"]}},
+ ),
+ (
+ [
+ Namespace(id=1, name="ns"),
+ Tag(id=1, name="artist"),
+ ComicTag(namespace_id=1, tag_id=1, comic_id=100),
+ ],
+ {"title": "Updated Comic", "tags": {"ids": ["1:1"]}},
+ ),
+ ],
+ ids=[
+ "with scalar",
+ "with assoc",
+ "with scalar and existing assoc",
+ "with tag",
+ "with scalar and existing tag",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_comic_changes_updated_at(update_comics, empty_comic, rows, input):
+ original_comic = empty_comic
+ original_comic.updated_at = dt(2023, 1, 1, tzinfo=timezone.utc)
+ original_comic = await DB.add(original_comic)
+
+ await DB.add_all(*rows)
+
+ response = Response(await update_comics(original_comic.id, input))
+ response.assert_is("UpdateSuccess")
+
+ comic = await DB.get(Comic, original_comic.id)
+ assert comic.updated_at > original_comic.updated_at
+
+
+@pytest.mark.anyio
+async def test_update_comic_cover_fails_page_not_found(update_comics, gen_comic):
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await update_comics(comic.id, {"cover": {"id": 100}}))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 100
+ assert response.message == "Page ID not found: '100'"
+
+
+@pytest.mark.anyio
+async def test_update_comic_cover_fails_page_remote(
+ update_comics, gen_comic, gen_archive
+):
+ comic = await DB.add(next(gen_comic))
+ other_archive = await DB.add(next(gen_archive))
+ remote_id = other_archive.pages[0].id
+
+ response = Response(await update_comics(comic.id, {"cover": {"id": remote_id}}))
+ response.assert_is("PageRemoteError")
+ assert response.id == remote_id
+ assert response.archiveId == other_archive.id
+ assert (
+ response.message
+ == f"Page ID {remote_id} comes from remote archive ID {other_archive.id}"
+ )
+
+
+@pytest.mark.anyio
+async def test_update_comic_pages_fails_page_not_found(update_comics, gen_comic):
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await update_comics(comic.id, {"pages": {"ids": 100}}))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 100
+ assert response.message == "Page ID not found: '100'"
+
+
+@pytest.mark.anyio
+async def test_update_comic_pages_fails_page_remote(
+ update_comics, gen_comic, gen_archive
+):
+ comic = await DB.add(next(gen_comic))
+ other_archive = await DB.add(next(gen_archive))
+ remote_id = other_archive.pages[0].id
+
+ response = Response(await update_comics(comic.id, {"pages": {"ids": [remote_id]}}))
+ response.assert_is("PageRemoteError")
+ assert response.id == remote_id
+ assert response.archiveId == other_archive.id
+ assert (
+ response.message
+ == f"Page ID {remote_id} comes from remote archive ID {other_archive.id}"
+ )
+
+
+@pytest.mark.anyio
+async def test_update_comic_pages_fails_page_claimed(update_comics, gen_archive):
+ archive = await DB.add(next(gen_archive))
+
+ comic = await DB.add(
+ Comic(
+ id=1,
+ title="A Very Good Comic",
+ archive=archive,
+ cover=archive.pages[0].image,
+ pages=[archive.pages[0], archive.pages[1]],
+ )
+ )
+
+ claiming = await DB.add(
+ Comic(
+ id=2,
+ title="A Very Claiming Comic",
+ archive=archive,
+ cover=archive.pages[2].image,
+ pages=[archive.pages[2], archive.pages[3]],
+ )
+ )
+
+ claimed_id = claiming.pages[0].id
+
+ response = Response(await update_comics(comic.id, {"pages": {"ids": [claimed_id]}}))
+ response.assert_is("PageClaimedError")
+ assert response.id == claimed_id
+ assert response.comicId == claiming.id
+ assert (
+ response.message
+ == f"Page ID {claimed_id} is already claimed by comic ID {claiming.id}"
+ )
+
+
+@pytest.mark.parametrize(
+ "mode",
+ [
+ ("REPLACE"),
+ ("REMOVE"),
+ ],
+)
+@pytest.mark.anyio
+async def test_update_comic_pages_fails_empty(update_comics, gen_comic, mode):
+ comic = await DB.add(next(gen_comic))
+
+ ids = [] if mode == "REPLACE" else [p.id for p in comic.pages]
+
+ response = Response(
+ await update_comics(
+ comic.id, {"pages": {"ids": ids, "options": {"mode": mode}}}
+ )
+ )
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "pages"
+ assert response.message == "Invalid parameter 'pages': cannot be empty"
diff --git a/tests/api/test_comic_tag.py b/tests/api/test_comic_tag.py
new file mode 100644
index 0000000..f536b79
--- /dev/null
+++ b/tests/api/test_comic_tag.py
@@ -0,0 +1,134 @@
+from functools import partial
+
+import pytest
+from conftest import DB, Response
+from hircine.db.models import Namespace, Tag
+
+
+@pytest.fixture
+def query_comic_tags(schema_execute):
+ query = """
+ query comicTags($forFilter: Boolean) {
+ comicTags(forFilter: $forFilter) {
+ edges {
+ __typename
+ id
+ name
+ }
+ count
+ }
+ }
+ """
+
+ def wrapper(q):
+ async def _execute(for_filter=False):
+ return await schema_execute(q, {"forFilter": for_filter})
+
+ return _execute
+
+ return wrapper(query)
+
+
+def build_item(namespace, tag):
+ nid, tid = "", ""
+ nname, tname = "", ""
+
+ if namespace:
+ nid, nname = namespace.id, namespace.name
+
+ if tag:
+ tid, tname = tag.id, tag.name
+
+ item = {
+ "__typename": "ComicTag",
+ "id": f"{nid}:{tid}",
+ "name": f"{nname}:{tname}",
+ }
+
+ return item
+
+
+@pytest.mark.anyio
+async def test_query_comic_tags_cross(query_comic_tags):
+ ns_foo = Namespace(id=1, name="foo")
+ ns_bar = Namespace(id=2, name="bar")
+ tag_qoo = Tag(id=1, name="qoo", namespaces=[ns_foo, ns_bar])
+ tag_qar = Tag(id=2, name="qar", namespaces=[ns_foo, ns_bar])
+
+ await DB.add_all(ns_foo, ns_bar)
+ await DB.add_all(tag_qoo, tag_qar)
+
+ builder = partial(build_item)
+
+ response = Response(await query_comic_tags())
+ assert response.data["edges"] == [
+ builder(ns_bar, tag_qar),
+ builder(ns_bar, tag_qoo),
+ builder(ns_foo, tag_qar),
+ builder(ns_foo, tag_qoo),
+ ]
+
+
+@pytest.mark.anyio
+async def test_query_comic_tags_restricted_namespace(query_comic_tags):
+ ns_foo = Namespace(id=1, name="foo")
+ ns_bar = Namespace(id=2, name="bar")
+ tag_qoo = Tag(id=1, name="qoo", namespaces=[ns_bar])
+ tag_qar = Tag(id=2, name="qar", namespaces=[ns_foo])
+
+ await DB.add_all(ns_foo, ns_bar)
+ await DB.add_all(tag_qoo, tag_qar)
+
+ builder = partial(build_item)
+
+ response = Response(await query_comic_tags())
+ assert response.data["edges"] == [
+ builder(ns_bar, tag_qoo),
+ builder(ns_foo, tag_qar),
+ ]
+
+
+@pytest.mark.anyio
+async def test_query_comic_tag_matchers_cross(query_comic_tags):
+ ns_foo = Namespace(id=1, name="foo")
+ ns_bar = Namespace(id=2, name="bar")
+ tag_qoo = Tag(id=1, name="qoo", namespaces=[ns_foo, ns_bar])
+ tag_qar = Tag(id=2, name="qar", namespaces=[ns_foo, ns_bar])
+
+ await DB.add_all(ns_foo, ns_bar, tag_qoo, tag_qar)
+
+ builder = partial(build_item)
+
+ response = Response(await query_comic_tags(for_filter=True))
+ assert response.data["edges"] == [
+ builder(ns_bar, None),
+ builder(ns_foo, None),
+ builder(None, tag_qar),
+ builder(None, tag_qoo),
+ builder(ns_bar, tag_qar),
+ builder(ns_bar, tag_qoo),
+ builder(ns_foo, tag_qar),
+ builder(ns_foo, tag_qoo),
+ ]
+
+
+@pytest.mark.anyio
+async def test_query_comic_tag_matchers_restricted_namespace(query_comic_tags):
+ ns_foo = Namespace(id=1, name="foo")
+ ns_bar = Namespace(id=2, name="bar")
+ tag_qoo = Tag(id=1, name="qoo", namespaces=[ns_bar])
+ tag_qar = Tag(id=2, name="qar", namespaces=[ns_foo])
+
+ await DB.add_all(ns_foo, ns_bar, tag_qoo, tag_qar)
+
+ builder = partial(build_item)
+
+ response = Response(await query_comic_tags(for_filter=True))
+ assert response.data["edges"] == [
+ builder(ns_bar, None),
+ builder(ns_foo, None),
+ builder(None, tag_qar),
+ builder(None, tag_qoo),
+ builder(ns_bar, tag_qoo),
+ builder(ns_foo, tag_qar),
+ ]
diff --git a/tests/api/test_db.py b/tests/api/test_db.py
new file mode 100644
index 0000000..f53b90f
--- /dev/null
+++ b/tests/api/test_db.py
@@ -0,0 +1,324 @@
+from datetime import datetime, timedelta, timezone
+
+import hircine.db as database
+import hircine.db.models as models
+import hircine.db.ops as ops
+import pytest
+from conftest import DB
+from hircine.db.models import (
+ Artist,
+ Base,
+ Comic,
+ ComicTag,
+ DateTimeUTC,
+ MixinID,
+ Namespace,
+ Tag,
+ TagNamespaces,
+)
+from sqlalchemy.exc import StatementError
+from sqlalchemy.orm import (
+ Mapped,
+ mapped_column,
+)
+
+
+class Date(MixinID, Base):
+ date: Mapped[datetime] = mapped_column(DateTimeUTC)
+
+
+@pytest.mark.anyio
+async def test_db_requires_tzinfo():
+ with pytest.raises(StatementError, match="tzinfo is required"):
+ await DB.add(Date(date=datetime(2019, 4, 22)))
+
+
+@pytest.mark.anyio
+async def test_db_converts_date_input_to_utc():
+ date = datetime(2019, 4, 22, tzinfo=timezone(timedelta(hours=-4)))
+ await DB.add(Date(date=date))
+
+ item = await DB.get(Date, 1)
+
+ assert item.date.tzinfo == timezone.utc
+ assert item.date == date
+
+
+@pytest.mark.parametrize(
+ "modelcls,assoccls",
+ [
+ (models.Artist, models.ComicArtist),
+ (models.Circle, models.ComicCircle),
+ (models.Character, models.ComicCharacter),
+ (models.World, models.ComicWorld),
+ ],
+ ids=["artists", "circles", "characters", "worlds"],
+)
+@pytest.mark.anyio
+async def test_models_retained_when_clearing_association(
+ empty_comic, modelcls, assoccls
+):
+ model = modelcls(id=1, name="foo")
+ key = f"{modelcls.__name__.lower()}s"
+
+ comic = empty_comic
+ setattr(comic, key, [model])
+ comic = await DB.add(comic)
+
+ async with database.session() as s:
+ object = await s.get(Comic, comic.id)
+ setattr(object, key, [])
+ await s.commit()
+
+ assert await DB.get(assoccls, (comic.id, model.id)) is None
+ assert await DB.get(Comic, comic.id) is not None
+ assert await DB.get(modelcls, model.id) is not None
+
+
+@pytest.mark.anyio
+async def test_models_retained_when_clearing_comictag(empty_comic):
+ comic = await DB.add(empty_comic)
+
+ namespace = Namespace(id=1, name="foo")
+ tag = Tag(id=1, name="bar")
+ ct = ComicTag(comic_id=comic.id, namespace=namespace, tag=tag)
+
+ await DB.add(ct)
+
+ async with database.session() as s:
+ object = await s.get(Comic, comic.id)
+ object.tags = []
+ await s.commit()
+
+ assert await DB.get(ComicTag, (comic.id, ct.namespace_id, ct.tag_id)) is None
+ assert await DB.get(Namespace, namespace.id) is not None
+ assert await DB.get(Tag, tag.id) is not None
+ assert await DB.get(Comic, comic.id) is not None
+
+
+@pytest.mark.parametrize(
+ "modelcls,assoccls",
+ [
+ (models.Artist, models.ComicArtist),
+ (models.Circle, models.ComicCircle),
+ (models.Character, models.ComicCharacter),
+ (models.World, models.ComicWorld),
+ ],
+ ids=["artists", "circles", "characters", "worlds"],
+)
+@pytest.mark.anyio
+async def test_only_association_cleared_when_deleting(empty_comic, modelcls, assoccls):
+ model = modelcls(id=1, name="foo")
+
+ comic = empty_comic
+ setattr(comic, f"{modelcls.__name__.lower()}s", [model])
+ comic = await DB.add(comic)
+
+ await DB.delete(modelcls, model.id)
+ assert await DB.get(assoccls, (comic.id, model.id)) is None
+ assert await DB.get(Comic, comic.id) is not None
+
+
+@pytest.mark.parametrize(
+ "deleted",
+ [
+ "namespace",
+ "tag",
+ ],
+)
+@pytest.mark.anyio
+async def test_only_comictag_association_cleared_when_deleting(empty_comic, deleted):
+ comic = await DB.add(empty_comic)
+
+ namespace = Namespace(id=1, name="foo")
+ tag = Tag(id=1, name="bar")
+
+ await DB.add(ComicTag(comic_id=comic.id, namespace=namespace, tag=tag))
+
+ if deleted == "namespace":
+ await DB.delete(Namespace, namespace.id)
+ elif deleted == "tag":
+ await DB.delete(Tag, tag.id)
+
+ assert await DB.get(ComicTag, (comic.id, namespace.id, tag.id)) is None
+ if deleted == "namespace":
+ assert await DB.get(Tag, tag.id) is not None
+ elif deleted == "tag":
+ assert await DB.get(Namespace, namespace.id) is not None
+ assert await DB.get(Comic, comic.id) is not None
+
+
+@pytest.mark.parametrize(
+ "modelcls,assoccls",
+ [
+ (models.Artist, models.ComicArtist),
+ (models.Circle, models.ComicCircle),
+ (models.Character, models.ComicCharacter),
+ (models.World, models.ComicWorld),
+ ],
+ ids=["artists", "circles", "characters", "worlds"],
+)
+@pytest.mark.anyio
+async def test_deleting_comic_only_clears_association(empty_comic, modelcls, assoccls):
+ model = modelcls(id=1, name="foo")
+
+ comic = empty_comic
+ setattr(comic, f"{modelcls.__name__.lower()}s", [model])
+ comic = await DB.add(comic)
+
+ await DB.delete(Comic, comic.id)
+ assert await DB.get(assoccls, (comic.id, model.id)) is None
+ assert await DB.get(modelcls, model.id) is not None
+
+
+@pytest.mark.anyio
+async def test_deleting_comic_only_clears_comictag(empty_comic):
+ comic = await DB.add(empty_comic)
+
+ namespace = Namespace(id=1, name="foo")
+ tag = Tag(id=1, name="bar")
+
+ await DB.add(ComicTag(comic_id=comic.id, namespace=namespace, tag=tag))
+ await DB.delete(Comic, comic.id)
+
+ assert await DB.get(ComicTag, (comic.id, namespace.id, tag.id)) is None
+ assert await DB.get(Tag, tag.id) is not None
+ assert await DB.get(Namespace, namespace.id) is not None
+
+
+@pytest.mark.anyio
+async def test_models_retained_when_clearing_tagnamespace():
+ namespace = Namespace(id=1, name="foo")
+ tag = Tag(id=1, name="foo", namespaces=[namespace])
+
+ tag = await DB.add(tag)
+
+ async with database.session() as s:
+ db_tag = await s.get(Tag, tag.id, options=Tag.load_full())
+ db_tag.namespaces = []
+ await s.commit()
+
+ assert await DB.get(TagNamespaces, (namespace.id, tag.id)) is None
+ assert await DB.get(Namespace, namespace.id) is not None
+ assert await DB.get(Tag, tag.id) is not None
+
+
+@pytest.mark.anyio
+async def test_only_tagnamespace_cleared_when_deleting_tag():
+ namespace = Namespace(id=1, name="foo")
+ tag = Tag(id=1, name="foo", namespaces=[namespace])
+
+ tag = await DB.add(tag)
+
+ await DB.delete(Tag, tag.id)
+
+ assert await DB.get(TagNamespaces, (namespace.id, tag.id)) is None
+ assert await DB.get(Namespace, namespace.id) is not None
+ assert await DB.get(Tag, tag.id) is None
+
+
+@pytest.mark.anyio
+async def test_only_tagnamespace_cleared_when_deleting_namespace():
+ namespace = Namespace(id=1, name="foo")
+ tag = Tag(id=1, name="foo", namespaces=[namespace])
+
+ tag = await DB.add(tag)
+
+ await DB.delete(Namespace, namespace.id)
+
+ assert await DB.get(TagNamespaces, (namespace.id, tag.id)) is None
+ assert await DB.get(Namespace, namespace.id) is None
+ assert await DB.get(Tag, tag.id) is not None
+
+
+@pytest.mark.parametrize(
+ "use_identity_map",
+ [False, True],
+ ids=["without identity lookup", "with identity lookup"],
+)
+@pytest.mark.anyio
+async def test_ops_get_all(gen_artist, use_identity_map):
+ artist = await DB.add(next(gen_artist))
+ have = list(await DB.add_all(*gen_artist))
+ have.append(artist)
+
+ missing_ids = [10, 20]
+
+ async with database.session() as s:
+ if use_identity_map:
+ s.add(artist)
+
+ artists, missing = await ops.get_all(
+ s,
+ Artist,
+ [a.id for a in have] + missing_ids,
+ use_identity_map=use_identity_map,
+ )
+
+ assert set([a.id for a in artists]) == set([a.id for a in have])
+ assert missing == set(missing_ids)
+
+
+@pytest.mark.anyio
+async def test_ops_get_all_names(gen_artist):
+ have = await DB.add_all(*gen_artist)
+ missing_names = ["arty", "farty"]
+
+ async with database.session() as s:
+ artists, missing = await ops.get_all_names(
+ s, Artist, [a.name for a in have] + missing_names
+ )
+
+ assert set([a.name for a in artists]) == set([a.name for a in have])
+ assert missing == set(missing_names)
+
+
+@pytest.mark.parametrize(
+ "missing",
+ [[("foo", "bar"), ("qux", "qaz")], []],
+ ids=["missing", "no missing"],
+)
+@pytest.mark.anyio
+async def test_ops_get_ctag_names(gen_comic, gen_tag, gen_namespace, missing):
+ comic = await DB.add(next(gen_comic))
+ have = [(ct.namespace.name, ct.tag.name) for ct in comic.tags]
+
+ async with database.session() as s:
+ cts, missing = await ops.get_ctag_names(s, comic.id, have + missing)
+
+ assert set(have) == set([(ct.namespace.name, ct.tag.name) for ct in cts])
+ assert missing == set(missing)
+
+
+@pytest.mark.anyio
+async def test_ops_lookup_identity(gen_artist):
+ one = await DB.add(next(gen_artist))
+ two = await DB.add(next(gen_artist))
+ rest = await DB.add_all(*gen_artist)
+
+ async with database.session() as s:
+ get_one = await s.get(Artist, one.id)
+ get_two = await s.get(Artist, two.id)
+ s.add(get_one, get_two)
+
+ artists, satisfied = ops.lookup_identity(
+ s, Artist, [a.id for a in [one, two] + list(rest)]
+ )
+
+ assert set([a.name for a in artists]) == set([a.name for a in [one, two]])
+ assert satisfied == set([one.id, two.id])
+
+
+@pytest.mark.anyio
+async def test_ops_get_image_orphans(gen_archive, gen_image):
+ await DB.add(next(gen_archive))
+
+ orphan_one = await DB.add(next(gen_image))
+ orphan_two = await DB.add(next(gen_image))
+
+ async with database.session() as s:
+ orphans = set(await ops.get_image_orphans(s))
+
+ assert orphans == set(
+ [(orphan_one.id, orphan_one.hash), (orphan_two.id, orphan_two.hash)]
+ )
diff --git a/tests/api/test_filter.py b/tests/api/test_filter.py
new file mode 100644
index 0000000..67a953f
--- /dev/null
+++ b/tests/api/test_filter.py
@@ -0,0 +1,521 @@
+import pytest
+from conftest import DB, Response
+from hircine.db.models import Namespace, Tag
+
+
+@pytest.fixture
+def query_comic_filter(execute_filter):
+ query = """
+ query comics($filter: ComicFilterInput) {
+ comics(filter: $filter) {
+ __typename
+ count
+ edges {
+ id
+ title
+ }
+ }
+ }
+ """
+
+ return execute_filter(query)
+
+
+@pytest.fixture
+def query_string_filter(execute_filter):
+ query = """
+ query artists($filter: ArtistFilterInput) {
+ artists(filter: $filter) {
+ __typename
+ count
+ edges {
+ id
+ name
+ }
+ }
+ }
+ """
+
+ return execute_filter(query)
+
+
+@pytest.fixture
+def query_tag_filter(execute_filter):
+ query = """
+ query tags($filter: TagFilterInput) {
+ tags(filter: $filter) {
+ __typename
+ count
+ edges {
+ id
+ name
+ }
+ }
+ }
+ """
+
+ return execute_filter(query)
+
+
+def id_list(edges):
+ return sorted([int(edge["id"]) for edge in edges])
+
+
+@pytest.mark.parametrize(
+ "filter,ids",
+ [
+ (
+ {"include": {"name": {"contains": "robin"}}},
+ [3, 4],
+ ),
+ ({"exclude": {"name": {"contains": "smith"}}}, [2, 3]),
+ (
+ {
+ "exclude": {"name": {"contains": "robin"}},
+ "include": {"name": {"contains": "smith"}},
+ },
+ [1],
+ ),
+ ],
+ ids=[
+ "includes",
+ "excludes",
+ "includes and excludes",
+ ],
+)
+@pytest.mark.anyio
+async def test_string_filter(query_string_filter, gen_artist, filter, ids):
+ await DB.add_all(*gen_artist)
+
+ response = Response(await query_string_filter(filter))
+ response.assert_is("ArtistFilterResult")
+
+ assert id_list(response.edges) == ids
+
+
+@pytest.mark.parametrize(
+ "filter,empty_response",
+ [
+ ({"include": {"name": {"contains": ""}}}, False),
+ ({"include": {"name": {}}}, False),
+ ({"exclude": {"name": {"contains": ""}}}, True),
+ ({"exclude": {"name": {}}}, False),
+ ],
+ ids=[
+ "string (include)",
+ "field (include)",
+ "string (exclude)",
+ "field (exclude)",
+ ],
+)
+@pytest.mark.anyio
+async def test_string_filter_handles_empty(
+ query_string_filter, gen_artist, filter, empty_response
+):
+ artists = await DB.add_all(*gen_artist)
+
+ response = Response(await query_string_filter(filter))
+ response.assert_is("ArtistFilterResult")
+
+ if empty_response:
+ assert response.edges == []
+ assert response.count == 0
+ else:
+ assert len(response.edges) == len(artists)
+ assert response.count == len(artists)
+
+
+@pytest.mark.parametrize(
+ "filter",
+ [
+ {"include": {}},
+ {"exclude": {}},
+ ],
+ ids=[
+ "include",
+ "exclude",
+ ],
+)
+@pytest.mark.anyio
+async def test_filter_handles_empty_field(query_string_filter, gen_artist, filter):
+ artists = await DB.add_all(*gen_artist)
+
+ response = Response(await query_string_filter(filter))
+ response.assert_is("ArtistFilterResult")
+
+ assert len(response.edges) == len(artists)
+ assert response.count == len(artists)
+
+
+@pytest.mark.parametrize(
+ "filter,ids",
+ [
+ (
+ {"include": {"artists": {"any": 1}}},
+ [1, 3],
+ ),
+ (
+ {"include": {"artists": {"all": 1}}},
+ [1, 3],
+ ),
+ (
+ {"include": {"artists": {"any": [1, 4]}}},
+ [1, 3, 4],
+ ),
+ (
+ {"include": {"artists": {"all": [1, 4]}}},
+ [3],
+ ),
+ (
+ {"exclude": {"artists": {"any": 1}}},
+ [2, 4],
+ ),
+ (
+ {"exclude": {"artists": {"all": 1}}},
+ [2, 4],
+ ),
+ (
+ {"exclude": {"artists": {"any": [1, 4]}}},
+ [2],
+ ),
+ (
+ {"exclude": {"artists": {"all": [1, 4]}}},
+ [1, 2, 4],
+ ),
+ (
+ {
+ "include": {"artists": {"any": [1]}},
+ "exclude": {"artists": {"all": [4]}},
+ },
+ [1],
+ ),
+ (
+ {
+ "include": {"artists": {"any": [1, 4]}},
+ "exclude": {"artists": {"all": [1, 4]}},
+ },
+ [1, 4],
+ ),
+ (
+ {
+ "include": {"artists": {"any": [1, 4], "all": [1, 2]}},
+ },
+ [1],
+ ),
+ ],
+ ids=[
+ "includes any (single)",
+ "includes all (single)",
+ "includes any (list)",
+ "includes all (list)",
+ "excludes any (single)",
+ "excludes all (single)",
+ "excludes any (list)",
+ "excludes all (list)",
+ "includes and excludes (single)",
+ "includes and excludes (list)",
+ "includes any and all",
+ ],
+)
+@pytest.mark.anyio
+async def test_assoc_filter(query_comic_filter, gen_comic, filter, ids):
+ await DB.add_all(*gen_comic)
+
+ response = Response(await query_comic_filter(filter))
+ response.assert_is("ComicFilterResult")
+
+ assert id_list(response.edges) == ids
+
+
+@pytest.mark.parametrize(
+ "filter,empty_response",
+ [
+ ({"include": {"artists": {"any": []}}}, True),
+ ({"include": {"artists": {"all": []}}}, True),
+ ({"include": {"artists": {}}}, False),
+ ({"exclude": {"artists": {"any": []}}}, False),
+ ({"exclude": {"artists": {"all": []}}}, False),
+ ({"exclude": {"artists": {}}}, False),
+ ({"include": {"tags": {"any": ""}}}, True),
+ ({"include": {"tags": {"any": ":"}}}, True),
+ ],
+ ids=[
+ "list (include any)",
+ "list (include all)",
+ "field (include)",
+ "list (exclude any)",
+ "list (exclude all)",
+ "field (exclude)",
+ "string (tags)",
+ "specifier (tags)",
+ ],
+)
+@pytest.mark.anyio
+async def test_assoc_filter_handles_empty(
+ query_comic_filter, gen_comic, filter, empty_response
+):
+ comics = await DB.add_all(*gen_comic)
+
+ response = Response(await query_comic_filter(filter))
+
+ response.assert_is("ComicFilterResult")
+
+ if empty_response:
+ assert response.edges == []
+ assert response.count == 0
+ else:
+ assert len(response.edges) == len(comics)
+ assert response.count == len(comics)
+
+
+@pytest.mark.parametrize(
+ "filter,ids",
+ [
+ (
+ {"include": {"tags": {"any": "1:"}}},
+ [1, 2, 3],
+ ),
+ (
+ {"include": {"tags": {"any": ":2"}}},
+ [1, 4],
+ ),
+ (
+ {"include": {"tags": {"exact": ["1:3", "2:1"]}}},
+ [2],
+ ),
+ (
+ {"include": {"tags": {"exact": ["1:"]}}},
+ [3],
+ ),
+ (
+ {"include": {"tags": {"exact": [":4"]}}},
+ [3],
+ ),
+ (
+ {"exclude": {"tags": {"all": ["1:4", "1:1"]}}},
+ [2, 3, 4],
+ ),
+ (
+ {"exclude": {"tags": {"exact": ["2:1", "2:2", "2:3"]}}},
+ [1, 2, 3],
+ ),
+ (
+ {"exclude": {"tags": {"exact": ["1:"]}}},
+ [1, 2, 4],
+ ),
+ (
+ {"exclude": {"tags": {"exact": [":4"]}}},
+ [1, 2, 4],
+ ),
+ ],
+ ids=[
+ "includes any namespace",
+ "includes any tag",
+ "includes exact tags",
+ "includes exact namespace",
+ "includes exact tag",
+ "excludes all tags",
+ "includes exact tags",
+ "includes exact namespace",
+ "includes exact tag",
+ ],
+)
+@pytest.mark.anyio
+async def test_assoc_tag_filter(query_comic_filter, gen_comic, filter, ids):
+ await DB.add_all(*gen_comic)
+
+ response = Response(await query_comic_filter(filter))
+ response.assert_is("ComicFilterResult")
+
+ assert id_list(response.edges) == ids
+
+
+@pytest.mark.parametrize(
+ "filter,ids",
+ [
+ (
+ {"include": {"favourite": True}},
+ [1],
+ ),
+ (
+ {"include": {"rating": {"any": "EXPLICIT"}}},
+ [3],
+ ),
+ (
+ {"include": {"category": {"any": "MANGA"}}},
+ [1, 2],
+ ),
+ (
+ {"include": {"censorship": {"any": "MOSAIC"}}},
+ [3],
+ ),
+ (
+ {"exclude": {"favourite": True}},
+ [2, 3, 4],
+ ),
+ (
+ {"exclude": {"rating": {"any": ["EXPLICIT", "QUESTIONABLE"]}}},
+ [1, 4],
+ ),
+ ],
+ ids=[
+ "includes favourite",
+ "includes rating",
+ "includes category",
+ "includes censorship",
+ "excludes favourite",
+ "excludes ratings",
+ ],
+)
+@pytest.mark.anyio
+async def test_field_filter(query_comic_filter, gen_comic, filter, ids):
+ await DB.add_all(*gen_comic)
+
+ response = Response(await query_comic_filter(filter))
+ response.assert_is("ComicFilterResult")
+
+ assert id_list(response.edges) == ids
+
+
+@pytest.mark.parametrize(
+ "filter,ids",
+ [
+ (
+ {"include": {"rating": {"empty": True}}},
+ [100],
+ ),
+ (
+ {"include": {"rating": {"empty": False}}},
+ [1, 2],
+ ),
+ (
+ {"exclude": {"rating": {"empty": True}}},
+ [1, 2],
+ ),
+ (
+ {"exclude": {"rating": {"empty": False}}},
+ [100],
+ ),
+ ],
+ ids=[
+ "includes rating empty",
+ "includes rating not empty",
+ "excludes rating empty",
+ "excludes rating not empty",
+ ],
+)
+@pytest.mark.anyio
+async def test_field_presence(query_comic_filter, gen_comic, empty_comic, filter, ids):
+ await DB.add(next(gen_comic))
+ await DB.add(next(gen_comic))
+ await DB.add(empty_comic)
+
+ response = Response(await query_comic_filter(filter))
+ response.assert_is("ComicFilterResult")
+
+ assert id_list(response.edges) == ids
+
+
+@pytest.mark.parametrize(
+ "filter,ids",
+ [
+ (
+ {"include": {"artists": {"empty": True}}},
+ [100],
+ ),
+ (
+ {"include": {"artists": {"empty": False}}},
+ [1, 2],
+ ),
+ (
+ {"exclude": {"artists": {"empty": True}}},
+ [1, 2],
+ ),
+ (
+ {"exclude": {"artists": {"empty": False}}},
+ [100],
+ ),
+ (
+ {"include": {"tags": {"empty": True}}},
+ [100],
+ ),
+ (
+ {"include": {"tags": {"empty": False}}},
+ [1, 2],
+ ),
+ (
+ {"exclude": {"tags": {"empty": True}}},
+ [1, 2],
+ ),
+ (
+ {"exclude": {"tags": {"empty": False}}},
+ [100],
+ ),
+ ],
+ ids=[
+ "includes artist empty",
+ "includes artist not empty",
+ "excludes artist empty",
+ "excludes artist not empty",
+ "includes tags empty",
+ "includes tags not empty",
+ "excludes tags empty",
+ "excludes tags not empty",
+ ],
+)
+@pytest.mark.anyio
+async def test_assoc_presence(query_comic_filter, gen_comic, empty_comic, filter, ids):
+ await DB.add(next(gen_comic))
+ await DB.add(next(gen_comic))
+ await DB.add(empty_comic)
+
+ response = Response(await query_comic_filter(filter))
+ response.assert_is("ComicFilterResult")
+
+ assert id_list(response.edges) == ids
+
+
+@pytest.mark.parametrize(
+ "filter,ids",
+ [
+ (
+ {"include": {"namespaces": {"any": 1}}},
+ [1, 2],
+ ),
+ (
+ {"include": {"namespaces": {"all": [1, 2]}}},
+ [2],
+ ),
+ (
+ {"include": {"namespaces": {"exact": [1]}}},
+ [1],
+ ),
+ (
+ {"exclude": {"namespaces": {"any": 2}}},
+ [1],
+ ),
+ (
+ {"exclude": {"namespaces": {"exact": [1]}}},
+ [2],
+ ),
+ ],
+ ids=[
+ "includes any namespace",
+ "includes all namespace",
+ "includes exact namespaces",
+ "excludes any namespace",
+ "excludes exact namespaces",
+ ],
+)
+@pytest.mark.anyio
+async def test_tag_assoc_filter(query_tag_filter, gen_namespace, gen_tag, filter, ids):
+ foo = await DB.add(Namespace(id=1, name="foo"))
+ bar = await DB.add(Namespace(id=2, name="bar"))
+
+ await DB.add(Tag(id=1, name="small", namespaces=[foo]))
+ await DB.add(Tag(id=2, name="large", namespaces=[foo, bar]))
+
+ response = Response(await query_tag_filter(filter))
+ response.assert_is("TagFilterResult")
+
+ assert id_list(response.edges) == ids
diff --git a/tests/api/test_image.py b/tests/api/test_image.py
new file mode 100644
index 0000000..c8c26b3
--- /dev/null
+++ b/tests/api/test_image.py
@@ -0,0 +1,16 @@
+import pytest
+from conftest import DB
+from hircine.api.types import Image
+
+
+@pytest.mark.anyio
+async def test_image(gen_image):
+ images = await DB.add_all(*gen_image)
+
+ for db_image in images:
+ image = Image(db_image)
+ assert image.id == db_image.id
+ assert image.hash == db_image.hash
+ assert image.width == db_image.width
+ assert image.height == db_image.height
+ assert image.aspect_ratio == db_image.width / db_image.height
diff --git a/tests/api/test_namespace.py b/tests/api/test_namespace.py
new file mode 100644
index 0000000..450075b
--- /dev/null
+++ b/tests/api/test_namespace.py
@@ -0,0 +1,291 @@
+from datetime import datetime as dt
+from datetime import timezone
+
+import pytest
+from conftest import DB, Response
+from hircine.db.models import Namespace
+
+
+@pytest.fixture
+def query_namespace(execute_id):
+ query = """
+ query namespace($id: Int!) {
+ namespace(id: $id) {
+ __typename
+ ... on Namespace {
+ id
+ name
+ sortName
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_id(query)
+
+
+@pytest.fixture
+def query_namespaces(execute):
+ query = """
+ query namespaces {
+ namespaces {
+ __typename
+ count
+ edges {
+ id
+ name
+ }
+ }
+ }
+ """
+
+ return execute(query)
+
+
+@pytest.fixture
+def add_namespace(execute_add):
+ mutation = """
+ mutation addNamespace($input: AddNamespaceInput!) {
+ addNamespace(input: $input) {
+ __typename
+ ... on AddSuccess {
+ id
+ }
+ ... on Error {
+ message
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_add(mutation)
+
+
+@pytest.fixture
+def update_namespaces(execute_update):
+ mutation = """
+ mutation updateNamespaces($ids: [Int!]!, $input: UpdateNamespaceInput!) {
+ updateNamespaces(ids: $ids, input: $input) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """ # noqa: E501
+
+ return execute_update(mutation)
+
+
+@pytest.fixture
+def delete_namespaces(execute_delete):
+ mutation = """
+ mutation deleteNamespaces($ids: [Int!]!) {
+ deleteNamespaces(ids: $ids) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_delete(mutation)
+
+
+@pytest.mark.anyio
+async def test_query_namespace(query_namespace, gen_namespace):
+ namespace = await DB.add(next(gen_namespace))
+
+ response = Response(await query_namespace(namespace.id))
+ response.assert_is("Namespace")
+
+ assert response.id == namespace.id
+ assert response.name == namespace.name
+ assert response.sortName == namespace.sort_name
+
+
+@pytest.mark.anyio
+async def test_query_namespace_fails_not_found(query_namespace):
+ response = Response(await query_namespace(1))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Namespace ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_query_namespaces(query_namespaces, gen_namespace):
+ namespaces = await DB.add_all(*gen_namespace)
+ response = Response(await query_namespaces())
+ response.assert_is("NamespaceFilterResult")
+
+ assert response.count == len(namespaces)
+ assert isinstance((response.edges), list)
+ assert len(response.edges) == len(namespaces)
+
+ edges = iter(response.edges)
+ for namespace in sorted(namespaces, key=lambda a: a.name):
+ edge = next(edges)
+ assert edge["id"] == namespace.id
+ assert edge["name"] == namespace.name
+
+
+@pytest.mark.anyio
+async def test_add_namespace(add_namespace):
+ response = Response(await add_namespace({"name": "added", "sortName": "foo"}))
+ response.assert_is("AddSuccess")
+
+ namespace = await DB.get(Namespace, response.id)
+ assert namespace is not None
+ assert namespace.name == "added"
+ assert namespace.sort_name == "foo"
+
+
+@pytest.mark.anyio
+async def test_add_namespace_fails_empty_parameter(add_namespace):
+ response = Response(await add_namespace({"name": ""}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_add_namespace_fails_exists(add_namespace, gen_namespace):
+ namespace = await DB.add(next(gen_namespace))
+
+ response = Response(await add_namespace({"name": namespace.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another Namespace with this name exists"
+
+
+@pytest.mark.anyio
+async def test_delete_namespace(delete_namespaces, gen_namespace):
+ namespace = await DB.add(next(gen_namespace))
+ id = namespace.id
+
+ response = Response(await delete_namespaces(id))
+ response.assert_is("DeleteSuccess")
+
+ namespace = await DB.get(Namespace, id)
+ assert namespace is None
+
+
+@pytest.mark.anyio
+async def test_delete_namespace_not_found(delete_namespaces):
+ response = Response(await delete_namespaces(1))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Namespace ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_namespace(update_namespaces, gen_namespace):
+ namespace = await DB.add(next(gen_namespace))
+
+ input = {"name": "updated", "sortName": "foo"}
+ response = Response(await update_namespaces(namespace.id, input))
+ response.assert_is("UpdateSuccess")
+
+ namespace = await DB.get(Namespace, namespace.id)
+ assert namespace is not None
+ assert namespace.name == "updated"
+ assert namespace.sort_name == "foo"
+
+
+@pytest.mark.anyio
+async def test_update_namespace_fails_exists(update_namespaces, gen_namespace):
+ first = await DB.add(next(gen_namespace))
+ second = await DB.add(next(gen_namespace))
+
+ response = Response(await update_namespaces(second.id, {"name": first.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another Namespace with this name exists"
+
+
+@pytest.mark.anyio
+async def test_update_namespace_fails_not_found(update_namespaces):
+ response = Response(await update_namespaces(1, {"name": "updated"}))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Namespace ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_namespaces_cannot_bulk_edit_name(
+ update_namespaces, gen_namespace
+):
+ first = await DB.add(next(gen_namespace))
+ second = await DB.add(next(gen_namespace))
+
+ response = Response(
+ await update_namespaces([first.id, second.id], {"name": "unique"})
+ )
+ response.assert_is("InvalidParameterError")
+
+
+@pytest.mark.parametrize(
+ "empty",
+ [
+ None,
+ "",
+ ],
+ ids=[
+ "none",
+ "empty string",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_namespace_fails_empty_parameter(
+ update_namespaces, gen_namespace, empty
+):
+ namespace = await DB.add(next(gen_namespace))
+ response = Response(await update_namespaces(namespace.id, {"name": empty}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_update_namespace_changes_updated_at(update_namespaces):
+ original_namespace = Namespace(name="namespace")
+ original_namespace.updated_at = dt(2023, 1, 1, tzinfo=timezone.utc)
+ original_namespace = await DB.add(original_namespace)
+
+ response = Response(
+ await update_namespaces(original_namespace.id, {"name": "updated"})
+ )
+ response.assert_is("UpdateSuccess")
+
+ namespace = await DB.get(Namespace, original_namespace.id)
+ assert namespace.updated_at > original_namespace.updated_at
diff --git a/tests/api/test_page.py b/tests/api/test_page.py
new file mode 100644
index 0000000..debd69a
--- /dev/null
+++ b/tests/api/test_page.py
@@ -0,0 +1,39 @@
+from datetime import datetime, timezone
+
+import pytest
+from conftest import DB
+from hircine.api.types import Page
+from hircine.db.models import Archive
+
+
+@pytest.mark.anyio
+async def test_page(gen_page):
+ pages = list(gen_page)
+ images = [p.image for p in pages]
+
+ # persist images and pages in database by binding them to a throwaway
+ # archive
+ archive = await DB.add(
+ Archive(
+ hash="339e3a32e5648fdeb2597f05cb2e1ef6",
+ path="some/archive.zip",
+ size=78631597,
+ mtime=datetime(1999, 12, 27, tzinfo=timezone.utc),
+ cover=images[0],
+ pages=pages,
+ page_count=len(pages),
+ )
+ )
+
+ assert len(archive.pages) == len(pages)
+
+ page_iter = iter(pages)
+ image_iter = iter(images)
+ for page in [Page(p) for p in archive.pages]:
+ matching_page = next(page_iter)
+ matching_image = next(image_iter)
+
+ assert page.id == matching_page.id
+ assert page.comic_id is None
+ assert page.image.id == matching_image.id
+ assert page.path == matching_page.path
diff --git a/tests/api/test_pagination.py b/tests/api/test_pagination.py
new file mode 100644
index 0000000..67854c3
--- /dev/null
+++ b/tests/api/test_pagination.py
@@ -0,0 +1,61 @@
+import pytest
+from conftest import DB, Response
+
+
+@pytest.fixture
+def query_pagination(schema_execute):
+ query = """
+ query artists($pagination: Pagination) {
+ artists(pagination: $pagination) {
+ __typename
+ count
+ edges {
+ id
+ }
+ }
+ }
+ """
+
+ async def _execute(pagination=None):
+ return await schema_execute(
+ query, {"pagination": pagination} if pagination else None
+ )
+
+ return _execute
+
+
+@pytest.mark.parametrize(
+ "pagination,count,length",
+ [
+ (None, 4, 4),
+ ({"items": 3, "page": 1}, 4, 3),
+ ({"items": 3, "page": 2}, 4, 1),
+ ({"items": 3, "page": 3}, 0, 0),
+ ({"items": 10, "page": 1}, 4, 4),
+ ({"items": 0, "page": 1}, 0, 0),
+ ({"items": 2, "page": 0}, 0, 0),
+ ({"items": -1, "page": 0}, 0, 0),
+ ({"items": 0, "page": -1}, 0, 0),
+ ],
+ ids=[
+ "is missing and lists all",
+ "lists first page",
+ "lists last page",
+ "lists none (no more items)",
+ "lists all",
+ "lists none (zero items)",
+ "lists none (page zero)",
+ "lists none (negative items)",
+ "lists none (negative page)",
+ ],
+)
+@pytest.mark.anyio
+async def test_pagination(query_pagination, gen_artist, pagination, count, length):
+ await DB.add_all(*gen_artist)
+
+ response = Response(await query_pagination(pagination))
+ response.assert_is("ArtistFilterResult")
+
+ assert response.count == count
+ assert isinstance((response.edges), list)
+ assert len(response.edges) == length
diff --git a/tests/api/test_scraper_api.py b/tests/api/test_scraper_api.py
new file mode 100644
index 0000000..1edd74f
--- /dev/null
+++ b/tests/api/test_scraper_api.py
@@ -0,0 +1,395 @@
+import hircine.enums as enums
+import hircine.plugins
+import hircine.scraper.types as scraped
+import pytest
+from conftest import DB, Response
+from hircine.scraper import ScrapeError, Scraper, ScrapeWarning
+
+
+@pytest.fixture
+def query_comic_scrapers(schema_execute):
+ query = """
+ query comicScrapers($id: Int!) {
+ comicScrapers(id: $id) {
+ __typename
+ id
+ name
+ }
+ }
+ """
+
+ async def _execute(id):
+ return await schema_execute(query, {"id": id})
+
+ return _execute
+
+
+@pytest.fixture
+def query_scrape_comic(schema_execute):
+ query = """
+ query scrapeComic($id: Int!, $scraper: String!) {
+ scrapeComic(id: $id, scraper: $scraper) {
+ __typename
+ ... on ScrapeComicResult {
+ data {
+ title
+ originalTitle
+ url
+ artists
+ category
+ censorship
+ characters
+ circles
+ date
+ direction
+ language
+ layout
+ rating
+ tags
+ worlds
+ }
+ warnings
+ }
+ ... on Error {
+ message
+ }
+ ... on ScraperNotFoundError {
+ name
+ }
+ ... on ScraperNotAvailableError {
+ scraper
+ comicId
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ async def _execute(id, scraper):
+ return await schema_execute(query, {"id": id, "scraper": scraper})
+
+ return _execute
+
+
+@pytest.fixture
+def scrapers(empty_plugins):
+ class GoodScraper(Scraper):
+ name = "Good Scraper"
+ is_available = True
+ source = "good"
+
+ def scrape(self):
+ yield scraped.Title("Arid Savannah Adventures")
+ yield scraped.OriginalTitle("Arid Savannah Hijinx")
+ yield scraped.URL("file:///home/savannah/adventures")
+ yield scraped.Language(enums.Language.EN)
+ yield scraped.Date.from_iso("2010-07-05")
+ yield scraped.Direction(enums.Direction["LEFT_TO_RIGHT"])
+ yield scraped.Layout(enums.Layout.SINGLE)
+ yield scraped.Rating(enums.Rating.SAFE)
+ yield scraped.Category(enums.Category.MANGA)
+ yield scraped.Censorship(enums.Censorship.NONE)
+ yield scraped.Tag.from_string("animal:small")
+ yield scraped.Tag.from_string("animal:medium")
+ yield scraped.Tag.from_string("animal:big")
+ yield scraped.Tag.from_string("animal:massive")
+ yield scraped.Artist("alan smithee")
+ yield scraped.Artist("david agnew")
+ yield scraped.Character("greta giraffe")
+ yield scraped.Character("bob bear")
+ yield scraped.Character("rico rhinoceros")
+ yield scraped.Character("ziggy zebra")
+ yield scraped.Circle("archimedes")
+ yield scraped.World("animal friends")
+
+ class DuplicateScraper(Scraper):
+ name = "Duplicate Scraper"
+ is_available = True
+ source = "dupe"
+
+ def gen(self):
+ yield scraped.Title("Arid Savannah Adventures")
+ yield scraped.OriginalTitle("Arid Savannah Hijinx")
+ yield scraped.URL("file:///home/savannah/adventures")
+ yield scraped.Language(enums.Language.EN)
+ yield scraped.Date.from_iso("2010-07-05")
+ yield scraped.Direction(enums.Direction["LEFT_TO_RIGHT"])
+ yield scraped.Layout(enums.Layout.SINGLE)
+ yield scraped.Rating(enums.Rating.SAFE)
+ yield scraped.Category(enums.Category.MANGA)
+ yield scraped.Censorship(enums.Censorship.NONE)
+ yield scraped.Tag.from_string("animal:small")
+ yield scraped.Tag.from_string("animal:medium")
+ yield scraped.Tag.from_string("animal:big")
+ yield scraped.Tag.from_string("animal:massive")
+ yield scraped.Artist("alan smithee")
+ yield scraped.Artist("david agnew")
+ yield scraped.Character("greta giraffe")
+ yield scraped.Character("bob bear")
+ yield scraped.Character("rico rhinoceros")
+ yield scraped.Character("ziggy zebra")
+ yield scraped.Circle("archimedes")
+ yield scraped.World("animal friends")
+
+ def scrape(self):
+ yield from self.gen()
+ yield from self.gen()
+
+ class WarnScraper(Scraper):
+ name = "Warn Scraper"
+ is_available = True
+ source = "warn"
+
+ def warn_on_purpose(self, item):
+ raise ScrapeWarning(f"Could not parse: {item}")
+
+ def scrape(self):
+ yield scraped.Title("Arid Savannah Adventures")
+ yield lambda: self.warn_on_purpose("Arid Savannah Hijinx")
+ yield scraped.Language(enums.Language.EN)
+
+ class FailScraper(Scraper):
+ name = "Fail Scraper"
+ is_available = True
+ source = "fail"
+
+ def scrape(self):
+ yield scraped.Title("Arid Savannah Adventures")
+ raise ScrapeError("Could not continue")
+ yield scraped.Language(enums.Language.EN)
+
+ class UnavailableScraper(Scraper):
+ name = "Unavailable Scraper"
+ is_available = False
+ source = "unavail"
+
+ def scrape(self):
+ yield None
+
+ hircine.plugins.register_scraper("good", GoodScraper)
+ hircine.plugins.register_scraper("dupe", DuplicateScraper)
+ hircine.plugins.register_scraper("warn", WarnScraper)
+ hircine.plugins.register_scraper("fail", FailScraper)
+ hircine.plugins.register_scraper("unavail", UnavailableScraper)
+
+ return [
+ ("good", GoodScraper),
+ ("dupe", DuplicateScraper),
+ ("warn", WarnScraper),
+ ("fail", FailScraper),
+ ("unavail", UnavailableScraper),
+ ]
+
+
+@pytest.mark.anyio
+async def test_comic_scrapers(gen_comic, query_comic_scrapers, scrapers):
+ comic = await DB.add(next(gen_comic))
+ response = Response(await query_comic_scrapers(comic.id))
+
+ assert isinstance((response.data), list)
+
+ available_scrapers = []
+ for name, cls in sorted(scrapers, key=lambda s: s[1].name):
+ instance = cls(comic)
+ if instance.is_available:
+ available_scrapers.append((name, cls))
+
+ assert len(response.data) == len(available_scrapers)
+
+ data = iter(response.data)
+ for id, scraper in available_scrapers:
+ field = next(data)
+ assert field["__typename"] == "ComicScraper"
+ assert field["id"] == id
+ assert field["name"] == scraper.name
+
+
+@pytest.mark.anyio
+async def test_comic_empty_for_missing_comic(gen_comic, query_comic_scrapers, scrapers):
+ response = Response(await query_comic_scrapers(1))
+
+ assert response.data == []
+
+
+@pytest.mark.anyio
+async def test_scrape_comic(gen_comic, query_scrape_comic, scrapers):
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await query_scrape_comic(comic.id, "good"))
+ response.assert_is("ScrapeComicResult")
+
+ assert response.warnings == []
+
+ scraped_comic = response.data["data"]
+
+ assert scraped_comic["title"] == "Arid Savannah Adventures"
+ assert scraped_comic["originalTitle"] == "Arid Savannah Hijinx"
+ assert scraped_comic["url"] == "file:///home/savannah/adventures"
+ assert scraped_comic["language"] == "EN"
+ assert scraped_comic["date"] == "2010-07-05"
+ assert scraped_comic["rating"] == "SAFE"
+ assert scraped_comic["category"] == "MANGA"
+ assert scraped_comic["direction"] == "LEFT_TO_RIGHT"
+ assert scraped_comic["layout"] == "SINGLE"
+ assert scraped_comic["tags"] == [
+ "animal:small",
+ "animal:medium",
+ "animal:big",
+ "animal:massive",
+ ]
+ assert scraped_comic["artists"] == ["alan smithee", "david agnew"]
+ assert scraped_comic["characters"] == [
+ "greta giraffe",
+ "bob bear",
+ "rico rhinoceros",
+ "ziggy zebra",
+ ]
+ assert scraped_comic["circles"] == ["archimedes"]
+ assert scraped_comic["worlds"] == ["animal friends"]
+
+
+@pytest.mark.anyio
+async def test_scrape_comic_removes_duplicates(gen_comic, query_scrape_comic, scrapers):
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await query_scrape_comic(comic.id, "dupe"))
+ response.assert_is("ScrapeComicResult")
+
+ assert response.warnings == []
+
+ scraped_comic = response.data["data"]
+
+ assert scraped_comic["title"] == "Arid Savannah Adventures"
+ assert scraped_comic["originalTitle"] == "Arid Savannah Hijinx"
+ assert scraped_comic["url"] == "file:///home/savannah/adventures"
+ assert scraped_comic["language"] == "EN"
+ assert scraped_comic["date"] == "2010-07-05"
+ assert scraped_comic["rating"] == "SAFE"
+ assert scraped_comic["category"] == "MANGA"
+ assert scraped_comic["direction"] == "LEFT_TO_RIGHT"
+ assert scraped_comic["layout"] == "SINGLE"
+ assert scraped_comic["tags"] == [
+ "animal:small",
+ "animal:medium",
+ "animal:big",
+ "animal:massive",
+ ]
+ assert scraped_comic["artists"] == ["alan smithee", "david agnew"]
+ assert scraped_comic["characters"] == [
+ "greta giraffe",
+ "bob bear",
+ "rico rhinoceros",
+ "ziggy zebra",
+ ]
+ assert scraped_comic["circles"] == ["archimedes"]
+ assert scraped_comic["worlds"] == ["animal friends"]
+
+
+@pytest.mark.anyio
+async def test_scrape_comic_fails_comic_not_found(query_scrape_comic, scrapers):
+ response = Response(await query_scrape_comic(1, "good"))
+ response.assert_is("IDNotFoundError")
+
+ assert response.id == 1
+ assert response.message == "Comic ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_scrape_comic_fails_scraper_not_found(
+ gen_comic, query_scrape_comic, scrapers
+):
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await query_scrape_comic(comic.id, "missing"))
+ response.assert_is("ScraperNotFoundError")
+
+ assert response.name == "missing"
+ assert response.message == "Scraper not found: 'missing'"
+
+
+@pytest.mark.anyio
+async def test_scrape_comic_fails_scraper_not_available(
+ gen_comic, query_scrape_comic, scrapers
+):
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await query_scrape_comic(comic.id, "unavail"))
+ response.assert_is("ScraperNotAvailableError")
+
+ assert response.scraper == "unavail"
+ assert response.comicId == comic.id
+ assert response.message == f"Scraper unavail not available for comic ID {comic.id}"
+
+
+async def test_scrape_comic_with_transformer(gen_comic, query_scrape_comic, scrapers):
+ def keep(generator, info):
+ for item in generator:
+ match item:
+ case scraped.Title():
+ yield item
+
+ hircine.plugins.transformers = [keep]
+
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await query_scrape_comic(comic.id, "good"))
+ response.assert_is("ScrapeComicResult")
+
+ assert response.warnings == []
+
+ scraped_comic = response.data["data"]
+
+ assert scraped_comic["title"] == "Arid Savannah Adventures"
+ assert scraped_comic["originalTitle"] is None
+ assert scraped_comic["url"] is None
+ assert scraped_comic["language"] is None
+ assert scraped_comic["date"] is None
+ assert scraped_comic["rating"] is None
+ assert scraped_comic["category"] is None
+ assert scraped_comic["censorship"] is None
+ assert scraped_comic["direction"] is None
+ assert scraped_comic["layout"] is None
+ assert scraped_comic["tags"] == []
+ assert scraped_comic["artists"] == []
+ assert scraped_comic["characters"] == []
+ assert scraped_comic["circles"] == []
+ assert scraped_comic["worlds"] == []
+
+
+@pytest.mark.anyio
+async def test_scrape_comic_catches_warnings(gen_comic, query_scrape_comic, scrapers):
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await query_scrape_comic(comic.id, "warn"))
+ response.assert_is("ScrapeComicResult")
+
+ assert response.warnings == ["Could not parse: Arid Savannah Hijinx"]
+
+ scraped_comic = response.data["data"]
+
+ assert scraped_comic["title"] == "Arid Savannah Adventures"
+ assert scraped_comic["originalTitle"] is None
+ assert scraped_comic["language"] == "EN"
+ assert scraped_comic["date"] is None
+ assert scraped_comic["rating"] is None
+ assert scraped_comic["category"] is None
+ assert scraped_comic["direction"] is None
+ assert scraped_comic["layout"] is None
+ assert scraped_comic["tags"] == []
+ assert scraped_comic["artists"] == []
+ assert scraped_comic["characters"] == []
+ assert scraped_comic["circles"] == []
+ assert scraped_comic["worlds"] == []
+
+
+@pytest.mark.anyio
+async def test_scrape_comic_fails_with_scraper_error(
+ gen_comic, query_scrape_comic, scrapers
+):
+ comic = await DB.add(next(gen_comic))
+
+ response = Response(await query_scrape_comic(comic.id, "fail"))
+ response.assert_is("ScraperError")
+ assert response.message == "Scraping failed: Could not continue"
diff --git a/tests/api/test_sort.py b/tests/api/test_sort.py
new file mode 100644
index 0000000..b3c8562
--- /dev/null
+++ b/tests/api/test_sort.py
@@ -0,0 +1,137 @@
+import pytest
+from conftest import DB, Response
+from hircine.db.models import Namespace
+
+
+@pytest.fixture
+def query_comic_sort(execute_sort):
+ query = """
+ query comics($sort: ComicSortInput) {
+ comics(sort: $sort) {
+ __typename
+ count
+ edges {
+ id
+ title
+ }
+ }
+ }
+ """
+
+ return execute_sort(query)
+
+
+@pytest.fixture
+def query_namespace_sort(execute_sort):
+ query = """
+ query namespaces($sort: NamespaceSortInput) {
+ namespaces(sort: $sort) {
+ __typename
+ count
+ edges {
+ id
+ name
+ }
+ }
+ }
+ """
+
+ return execute_sort(query)
+
+
+@pytest.mark.parametrize(
+ "sort,reverse",
+ [
+ ({"on": "DATE"}, False),
+ ({"on": "DATE", "direction": "DESCENDING"}, True),
+ ({"on": "DATE", "direction": "ASCENDING"}, False),
+ ],
+ ids=[
+ "ascending (default)",
+ "descending",
+ "ascending",
+ ],
+)
+@pytest.mark.anyio
+async def test_query_comics_sort_date(gen_comic, query_comic_sort, sort, reverse):
+ comics = await DB.add_all(*gen_comic)
+ ids = [c.id for c in sorted(comics, key=lambda c: c.date, reverse=reverse)]
+
+ response = Response(await query_comic_sort(sort))
+ response.assert_is("ComicFilterResult")
+
+ assert ids == [edge["id"] for edge in response.edges]
+
+
+@pytest.mark.parametrize(
+ "sort,reverse",
+ [
+ ({"on": "TAG_COUNT"}, False),
+ ({"on": "TAG_COUNT", "direction": "DESCENDING"}, True),
+ ({"on": "TAG_COUNT", "direction": "ASCENDING"}, False),
+ ],
+ ids=[
+ "ascending (default)",
+ "descending",
+ "ascending",
+ ],
+)
+@pytest.mark.anyio
+async def test_query_comics_sort_tag_count(gen_comic, query_comic_sort, sort, reverse):
+ comics = await DB.add_all(*gen_comic)
+ ids = [c.id for c in sorted(comics, key=lambda c: len(c.tags), reverse=reverse)]
+
+ response = Response(await query_comic_sort(sort))
+ response.assert_is("ComicFilterResult")
+
+ assert ids == [edge["id"] for edge in response.edges]
+
+
+@pytest.mark.anyio
+async def test_query_comics_sort_random(gen_comic, query_comic_sort):
+ comics = await DB.add_all(*gen_comic)
+ ids = set([c.id for c in comics])
+
+ response = Response(await query_comic_sort({"on": "RANDOM"}))
+ response.assert_is("ComicFilterResult")
+
+ assert ids == set(edge["id"] for edge in response.edges)
+
+
+@pytest.mark.anyio
+async def test_query_comics_sort_random_seed_direction(gen_comic, query_comic_sort):
+ comics = await DB.add_all(*gen_comic)
+ ids = set([c.id for c in comics])
+
+ response = Response(
+ await query_comic_sort(
+ {"on": "RANDOM", "seed": 42069, "direction": "ASCENDING"}
+ )
+ )
+ response.assert_is("ComicFilterResult")
+
+ ascending_ids = [edge["id"] for edge in response.edges]
+
+ assert ids == set(ascending_ids)
+
+ response = Response(
+ await query_comic_sort(
+ {"on": "RANDOM", "seed": 42069, "direction": "DESCENDING"}
+ )
+ )
+ response.assert_is("ComicFilterResult")
+
+ descending_ids = [edge["id"] for edge in response.edges]
+
+ assert ascending_ids == descending_ids[::-1]
+
+
+@pytest.mark.anyio
+async def test_query_namespace_sort_sort_name(query_namespace_sort):
+ await DB.add(Namespace(name="one", sort_name="2"))
+ await DB.add(Namespace(name="two", sort_name="1"))
+
+ response = Response(await query_namespace_sort({"on": "SORT_NAME"}))
+ response.assert_is("NamespaceFilterResult")
+
+ assert ["two", "one"] == [edge["name"] for edge in response.edges]
diff --git a/tests/api/test_tag.py b/tests/api/test_tag.py
new file mode 100644
index 0000000..c863a00
--- /dev/null
+++ b/tests/api/test_tag.py
@@ -0,0 +1,441 @@
+from datetime import datetime as dt
+from datetime import timezone
+
+import pytest
+from conftest import DB, Response
+from hircine.db.models import Namespace, Tag
+
+
+@pytest.fixture
+def query_tag(execute_id):
+ query = """
+ query tag($id: Int!) {
+ tag(id: $id) {
+ __typename
+ ... on FullTag {
+ id
+ name
+ description
+ namespaces {
+ __typename
+ id
+ }
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_id(query)
+
+
+@pytest.fixture
+def query_tags(execute):
+ query = """
+ query tags {
+ tags {
+ __typename
+ count
+ edges {
+ id
+ name
+ description
+ }
+ }
+ }
+ """
+
+ return execute(query)
+
+
+@pytest.fixture
+def add_tag(execute_add):
+ mutation = """
+ mutation addTag($input: AddTagInput!) {
+ addTag(input: $input) {
+ __typename
+ ... on AddSuccess {
+ id
+ }
+ ... on Error {
+ message
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_add(mutation)
+
+
+@pytest.fixture
+def update_tags(execute_update):
+ mutation = """
+ mutation updateTags($ids: [Int!]!, $input: UpdateTagInput!) {
+ updateTags(ids: $ids, input: $input) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """ # noqa: E501
+
+ return execute_update(mutation)
+
+
+@pytest.fixture
+def delete_tags(execute_delete):
+ mutation = """
+ mutation deleteTags($ids: [Int!]!) {
+ deleteTags(ids: $ids) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_delete(mutation)
+
+
+@pytest.mark.anyio
+async def test_query_tag(query_tag, gen_tag):
+ tag = await DB.add(next(gen_tag))
+
+ response = Response(await query_tag(tag.id))
+ response.assert_is("FullTag")
+
+ assert response.id == tag.id
+ assert response.name == tag.name
+ assert response.description == tag.description
+ assert set([n["id"] for n in response.namespaces]) == set(
+ [n.id for n in tag.namespaces]
+ )
+
+
+@pytest.mark.anyio
+async def test_query_tag_fails_not_found(query_tag):
+ response = Response(await query_tag(1))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Tag ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_query_tags(query_tags, gen_tag):
+ tags = await DB.add_all(*gen_tag)
+ response = Response(await query_tags())
+ response.assert_is("TagFilterResult")
+
+ assert response.count == len(tags)
+ assert isinstance((response.edges), list)
+ assert len(response.edges) == len(tags)
+
+ edges = iter(response.edges)
+ for tag in sorted(tags, key=lambda a: a.name):
+ edge = next(edges)
+ assert edge["id"] == tag.id
+ assert edge["name"] == tag.name
+ assert edge["description"] == tag.description
+
+
+@pytest.mark.anyio
+async def test_add_tag(add_tag):
+ response = Response(
+ await add_tag({"name": "added", "description": "it's been added!"})
+ )
+ response.assert_is("AddSuccess")
+
+ tag = await DB.get(Tag, response.id)
+ assert tag is not None
+ assert tag.name == "added"
+ assert tag.description == "it's been added!"
+
+
+@pytest.mark.anyio
+async def test_add_tag_with_namespace(add_tag):
+ namespace = await DB.add(Namespace(id=1, name="new"))
+
+ response = Response(await add_tag({"name": "added", "namespaces": {"ids": [1]}}))
+ response.assert_is("AddSuccess")
+
+ tag = await DB.get(Tag, response.id, full=True)
+ assert tag is not None
+ assert tag.name == "added"
+ assert tag.namespaces[0].id == namespace.id
+ assert tag.namespaces[0].name == namespace.name
+
+
+@pytest.mark.anyio
+async def test_add_tag_fails_empty_parameter(add_tag):
+ response = Response(await add_tag({"name": ""}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_add_tag_fails_namespace_not_found(add_tag):
+ response = Response(await add_tag({"name": "added", "namespaces": {"ids": [1]}}))
+ response.assert_is("IDNotFoundError")
+
+ assert response.id == 1
+ assert response.message == "Namespace ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_add_tag_fails_exists(add_tag, gen_tag):
+ tag = await DB.add(next(gen_tag))
+
+ response = Response(await add_tag({"name": tag.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another Tag with this name exists"
+
+
+@pytest.mark.anyio
+async def test_delete_tag(delete_tags, gen_tag):
+ tag = await DB.add(next(gen_tag))
+ id = tag.id
+
+ response = Response(await delete_tags(id))
+ response.assert_is("DeleteSuccess")
+
+ tag = await DB.get(Tag, id)
+ assert tag is None
+
+
+@pytest.mark.anyio
+async def test_delete_tag_not_found(delete_tags):
+ response = Response(await delete_tags(1))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Tag ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_tag(update_tags, gen_tag, gen_namespace):
+ tag = await DB.add(next(gen_tag))
+ namespace = await DB.add(next(gen_namespace))
+
+ input = {
+ "name": "updated",
+ "description": "how different, how unique",
+ "namespaces": {"ids": [1]},
+ }
+ response = Response(await update_tags(tag.id, input))
+ response.assert_is("UpdateSuccess")
+
+ tag = await DB.get(Tag, tag.id, full=True)
+ assert tag is not None
+ assert tag.name == "updated"
+ assert tag.description == "how different, how unique"
+ assert tag.namespaces[0].id == namespace.id
+ assert tag.namespaces[0].name == namespace.name
+
+
+@pytest.mark.parametrize(
+ "empty",
+ [
+ None,
+ "",
+ ],
+ ids=[
+ "with None",
+ "with empty string",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_tag_clears_description(update_tags, gen_tag, empty):
+ tag = await DB.add(next(gen_tag))
+
+ input = {
+ "description": empty,
+ }
+ response = Response(await update_tags(tag.id, input))
+ response.assert_is("UpdateSuccess")
+
+ tag = await DB.get(Tag, tag.id)
+ assert tag is not None
+ assert tag.description is None
+
+
+@pytest.mark.anyio
+async def test_update_tag_fails_exists(update_tags, gen_tag):
+ first = await DB.add(next(gen_tag))
+ second = await DB.add(next(gen_tag))
+
+ response = Response(await update_tags(second.id, {"name": first.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another Tag with this name exists"
+
+
+@pytest.mark.anyio
+async def test_update_tag_fails_not_found(update_tags):
+ response = Response(await update_tags(1, {"name": "updated"}))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "Tag ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_tags_cannot_bulk_edit_name(update_tags, gen_tag):
+ first = await DB.add(next(gen_tag))
+ second = await DB.add(next(gen_tag))
+
+ response = Response(await update_tags([first.id, second.id], {"name": "unique"}))
+ response.assert_is("InvalidParameterError")
+
+
+@pytest.mark.parametrize(
+ "empty",
+ [
+ None,
+ "",
+ ],
+ ids=[
+ "none",
+ "empty string",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_tag_fails_empty_parameter(update_tags, gen_tag, empty):
+ tag = await DB.add(next(gen_tag))
+ response = Response(await update_tags(tag.id, {"name": empty}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_update_tag_fails_namespace_not_found(update_tags, gen_tag):
+ tag = await DB.add(next(gen_tag))
+ response = Response(await update_tags(tag.id, {"namespaces": {"ids": [1]}}))
+ response.assert_is("IDNotFoundError")
+
+ assert response.id == 1
+ assert response.message == "Namespace ID not found: '1'"
+
+
+@pytest.mark.parametrize(
+ "options",
+ [
+ None,
+ {},
+ {"mode": "REPLACE"},
+ ],
+ ids=[
+ "by default (none)",
+ "by default (empty record)",
+ "when defined explicitly",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_tag_replaces_assocs(update_tags, gen_tag, options):
+ original_tag = await DB.add(next(gen_tag))
+ new_namespace = await DB.add(Namespace(name="new"))
+
+ input = {
+ "namespaces": {"ids": [new_namespace.id]},
+ }
+ response = Response(await update_tags(original_tag.id, input))
+ response.assert_is("UpdateSuccess")
+
+ tag = await DB.get(Tag, original_tag.id, full=True)
+
+ assert set([o.id for o in tag.namespaces]) == set([o.id for o in [new_namespace]])
+
+
+@pytest.mark.anyio
+async def test_update_tag_adds_assocs(update_tags, gen_tag):
+ original_tag = await DB.add(next(gen_tag))
+ new_namespace = await DB.add(Namespace(name="new"))
+ added_namespaces = original_tag.namespaces + [new_namespace]
+
+ input = {
+ "namespaces": {"ids": [new_namespace.id], "options": {"mode": "ADD"}},
+ }
+ response = Response(await update_tags(original_tag.id, input))
+ response.assert_is("UpdateSuccess")
+
+ tag = await DB.get(Tag, original_tag.id, full=True)
+
+ assert set([o.id for o in tag.namespaces]) == set([o.id for o in added_namespaces])
+
+
+@pytest.mark.anyio
+async def test_update_tag_removes_assocs(update_tags):
+ removed_namespace = Namespace(id=1, name="new")
+ remaining_namespace = Namespace(id=2, name="newtwo")
+ original_tag = await DB.add(
+ Tag(id=1, name="tag", namespaces=[removed_namespace, remaining_namespace])
+ )
+
+ input = {
+ "namespaces": {"ids": [removed_namespace.id], "options": {"mode": "REMOVE"}},
+ }
+ response = Response(await update_tags(original_tag.id, input))
+ response.assert_is("UpdateSuccess")
+
+ tag = await DB.get(Tag, original_tag.id, full=True)
+
+ assert set([o.id for o in tag.namespaces]) == set([remaining_namespace.id])
+
+
+@pytest.mark.anyio
+async def test_update_tag_changes_updated_at(update_tags):
+ original_tag = Tag(name="tag")
+ original_tag.updated_at = dt(2023, 1, 1, tzinfo=timezone.utc)
+ original_tag = await DB.add(original_tag)
+
+ response = Response(await update_tags(original_tag.id, {"name": "updated"}))
+ response.assert_is("UpdateSuccess")
+
+ tag = await DB.get(Tag, original_tag.id)
+ assert tag.updated_at > original_tag.updated_at
+
+
+@pytest.mark.anyio
+async def test_update_tag_assoc_changes_updated_at(update_tags):
+ original_tag = Tag(name="tag")
+ original_tag.updated_at = dt(2023, 1, 1, tzinfo=timezone.utc)
+ original_tag = await DB.add(original_tag)
+ await DB.add(Namespace(id=1, name="namespace"))
+
+ response = Response(
+ await update_tags(original_tag.id, {"namespaces": {"ids": [1]}})
+ )
+ response.assert_is("UpdateSuccess")
+
+ tag = await DB.get(Tag, original_tag.id)
+ assert tag.updated_at > original_tag.updated_at
diff --git a/tests/api/test_world.py b/tests/api/test_world.py
new file mode 100644
index 0000000..a3926d1
--- /dev/null
+++ b/tests/api/test_world.py
@@ -0,0 +1,278 @@
+from datetime import datetime as dt
+from datetime import timezone
+
+import pytest
+from conftest import DB, Response
+from hircine.db.models import World
+
+
+@pytest.fixture
+def query_world(execute_id):
+ query = """
+ query world($id: Int!) {
+ world(id: $id) {
+ __typename
+ ... on World {
+ id
+ name
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_id(query)
+
+
+@pytest.fixture
+def query_worlds(execute):
+ query = """
+ query worlds {
+ worlds {
+ __typename
+ count
+ edges {
+ id
+ name
+ }
+ }
+ }
+ """
+
+ return execute(query)
+
+
+@pytest.fixture
+def add_world(execute_add):
+ mutation = """
+ mutation addWorld($input: AddWorldInput!) {
+ addWorld(input: $input) {
+ __typename
+ ... on AddSuccess {
+ id
+ }
+ ... on Error {
+ message
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """
+
+ return execute_add(mutation)
+
+
+@pytest.fixture
+def update_worlds(execute_update):
+ mutation = """
+ mutation updateWorlds($ids: [Int!]!, $input: UpdateWorldInput!) {
+ updateWorlds(ids: $ids, input: $input) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ ... on InvalidParameterError {
+ parameter
+ }
+ }
+ }
+ """ # noqa: E501
+
+ return execute_update(mutation)
+
+
+@pytest.fixture
+def delete_worlds(execute_delete):
+ mutation = """
+ mutation deleteWorlds($ids: [Int!]!) {
+ deleteWorlds(ids: $ids) {
+ __typename
+ ... on Success {
+ message
+ }
+ ... on Error {
+ message
+ }
+ ... on IDNotFoundError {
+ id
+ }
+ }
+ }
+ """
+
+ return execute_delete(mutation)
+
+
+@pytest.mark.anyio
+async def test_query_world(query_world, gen_world):
+ world = await DB.add(next(gen_world))
+
+ response = Response(await query_world(world.id))
+ response.assert_is("World")
+
+ assert response.id == world.id
+ assert response.name == world.name
+
+
+@pytest.mark.anyio
+async def test_query_world_fails_not_found(query_world):
+ response = Response(await query_world(1))
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "World ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_query_worlds(query_worlds, gen_world):
+ worlds = await DB.add_all(*gen_world)
+
+ response = Response(await query_worlds())
+ response.assert_is("WorldFilterResult")
+
+ assert response.count == len(worlds)
+ assert isinstance((response.edges), list)
+ assert len(response.edges) == len(worlds)
+
+ edges = iter(response.edges)
+ for world in sorted(worlds, key=lambda a: a.name):
+ edge = next(edges)
+ assert edge["id"] == world.id
+ assert edge["name"] == world.name
+
+
+@pytest.mark.anyio
+async def test_add_world(add_world):
+ response = Response(await add_world({"name": "added world"}))
+ response.assert_is("AddSuccess")
+
+ world = await DB.get(World, response.id)
+ assert world is not None
+ assert world.name == "added world"
+
+
+@pytest.mark.anyio
+async def test_add_world_fails_empty_parameter(add_world):
+ response = Response(await add_world({"name": ""}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_add_world_fails_exists(add_world, gen_world):
+ world = await DB.add(next(gen_world))
+
+ response = Response(await add_world({"name": world.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another World with this name exists"
+
+
+@pytest.mark.anyio
+async def test_delete_world(delete_worlds, gen_world):
+ world = await DB.add(next(gen_world))
+ id = world.id
+
+ response = Response(await delete_worlds(id))
+ response.assert_is("DeleteSuccess")
+
+ world = await DB.get(World, id)
+ assert world is None
+
+
+@pytest.mark.anyio
+async def test_delete_world_not_found(delete_worlds):
+ response = Response(await delete_worlds(1))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "World ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_world(update_worlds, gen_world):
+ world = await DB.add(next(gen_world))
+
+ input = {"name": "updated world"}
+ response = Response(await update_worlds(world.id, input))
+ response.assert_is("UpdateSuccess")
+
+ world = await DB.get(World, world.id)
+ assert world is not None
+ assert world.name == "updated world"
+
+
+@pytest.mark.anyio
+async def test_update_world_fails_exists(update_worlds, gen_world):
+ first = await DB.add(next(gen_world))
+ second = await DB.add(next(gen_world))
+
+ response = Response(await update_worlds(second.id, {"name": first.name}))
+ response.assert_is("NameExistsError")
+ assert response.message == "Another World with this name exists"
+
+
+@pytest.mark.anyio
+async def test_update_world_fails_not_found(update_worlds):
+ response = Response(await update_worlds(1, {"name": "updated world"}))
+
+ response.assert_is("IDNotFoundError")
+ assert response.id == 1
+ assert response.message == "World ID not found: '1'"
+
+
+@pytest.mark.anyio
+async def test_update_worlds_cannot_bulk_edit_name(update_worlds, gen_world):
+ first = await DB.add(next(gen_world))
+ second = await DB.add(next(gen_world))
+
+ response = Response(await update_worlds([first.id, second.id], {"name": "unique"}))
+ response.assert_is("InvalidParameterError")
+
+
+@pytest.mark.parametrize(
+ "empty",
+ [
+ None,
+ "",
+ ],
+ ids=[
+ "none",
+ "empty string",
+ ],
+)
+@pytest.mark.anyio
+async def test_update_world_fails_empty_parameter(update_worlds, gen_world, empty):
+ world = await DB.add(next(gen_world))
+
+ response = Response(await update_worlds(world.id, {"name": empty}))
+
+ response.assert_is("InvalidParameterError")
+ assert response.parameter == "name"
+ assert response.message == "Invalid parameter 'name': cannot be empty"
+
+
+@pytest.mark.anyio
+async def test_update_world_changes_updated_at(update_worlds):
+ original_world = World(name="world")
+ original_world.updated_at = dt(2023, 1, 1, tzinfo=timezone.utc)
+ original_world = await DB.add(original_world)
+
+ response = Response(await update_worlds(original_world.id, {"name": "updated"}))
+ response.assert_is("UpdateSuccess")
+
+ world = await DB.get(World, original_world.id)
+ assert world.updated_at > original_world.updated_at
diff --git a/tests/config/data/config.toml b/tests/config/data/config.toml
new file mode 100644
index 0000000..2a21e03
--- /dev/null
+++ b/tests/config/data/config.toml
@@ -0,0 +1,3 @@
+database = "foo"
+scan = "bar"
+objects = "baz"
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..a36be2d
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,594 @@
+import os
+import shutil
+from datetime import date, timedelta
+from datetime import datetime as dt
+from datetime import timezone as tz
+
+import hircine
+import hircine.db as database
+import hircine.db.models as models
+import hircine.plugins
+import pytest
+from hircine.app import schema
+from hircine.enums import Category, Censorship, Direction, Language, Layout, Rating
+from sqlalchemy.ext.asyncio import AsyncSession
+
+
+@pytest.fixture(scope="session")
+def anyio_backend():
+ return "asyncio"
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--sql-echo",
+ action="store_true",
+ help="Enable logging of SQL statements",
+ )
+
+
+@pytest.fixture
+def empty_plugins(monkeypatch):
+ monkeypatch.setattr(hircine.plugins, "scraper_registry", {})
+ monkeypatch.setattr(hircine.plugins, "transformers", [])
+
+
+@pytest.fixture
+def data(tmpdir, request):
+ file = request.module.__file__
+ data = os.path.join(os.path.dirname(file), "data")
+
+ if os.path.isdir(data):
+ shutil.copytree(data, tmpdir, dirs_exist_ok=True)
+
+ return lambda dir: os.path.join(tmpdir, dir)
+
+
+@pytest.fixture(scope="session")
+def engine(pytestconfig):
+ yield database.create_engine(":memory:", echo=pytestconfig.option.sql_echo)
+
+
+@pytest.fixture
+async def session(anyio_backend, engine):
+ async with engine.begin() as conn:
+ await conn.begin_nested()
+ yield AsyncSession(conn, expire_on_commit=False, autoflush=False)
+ await conn.rollback()
+
+
+@pytest.fixture(autouse=True)
+async def patch_session(anyio_backend, session, engine, monkeypatch):
+ monkeypatch.setattr(hircine.db, "session", lambda: session)
+
+
+@pytest.fixture(scope="session", autouse=True)
+async def metadata(engine, anyio_backend):
+ await database.initialize(engine)
+
+
+@pytest.fixture
+def schema_execute():
+ async def _execute(endpoint, variables=None):
+ return await schema.execute(endpoint, variable_values=variables)
+
+ return _execute
+
+
+@pytest.fixture
+def execute(schema_execute):
+ def wrapper(q):
+ async def _execute():
+ return await schema_execute(q)
+
+ return _execute
+
+ return wrapper
+
+
+@pytest.fixture
+def execute_add(schema_execute):
+ def wrapper(q):
+ async def _execute(input):
+ return await schema_execute(q, {"input": input})
+
+ return _execute
+
+ return wrapper
+
+
+@pytest.fixture
+def execute_update(schema_execute):
+ def wrapper(q):
+ async def _execute(ids, input):
+ return await schema_execute(q, {"ids": ids, "input": input})
+
+ return _execute
+
+ return wrapper
+
+
+@pytest.fixture
+def execute_update_single(schema_execute):
+ def wrapper(q):
+ async def _execute(id, input):
+ return await schema_execute(q, {"id": id, "input": input})
+
+ return _execute
+
+ return wrapper
+
+
+@pytest.fixture
+def execute_delete(schema_execute):
+ def wrapper(q):
+ async def _execute(ids):
+ return await schema_execute(q, {"ids": ids})
+
+ return _execute
+
+ return wrapper
+
+
+@pytest.fixture
+def execute_id(schema_execute):
+ def wrapper(q):
+ async def _execute(id):
+ return await schema_execute(q, {"id": id})
+
+ return _execute
+
+ return wrapper
+
+
+@pytest.fixture
+def execute_filter(schema_execute):
+ def wrapper(q):
+ async def _execute(filter=None):
+ return await schema_execute(q, {"filter": filter} if filter else None)
+
+ return _execute
+
+ return wrapper
+
+
+@pytest.fixture
+def execute_sort(schema_execute):
+ def wrapper(q):
+ async def _execute(sort=None):
+ return await schema_execute(q, {"sort": sort} if sort else None)
+
+ return _execute
+
+ return wrapper
+
+
+class DB:
+ @staticmethod
+ async def add(model):
+ async with database.session() as s:
+ s.add(model)
+ await s.commit()
+ return model
+
+ @staticmethod
+ async def add_all(*models):
+ async with database.session() as s:
+ s.add_all(models)
+ await s.commit()
+ return models
+
+ @staticmethod
+ async def get(modelcls, id, full=False):
+ async with database.session() as s:
+ options = modelcls.load_full() if full else []
+ model = await s.get(modelcls, id, options=options)
+ return model
+
+ @staticmethod
+ async def delete(modelcls, id):
+ async with database.session() as s:
+ model = await s.get(modelcls, id)
+ await s.delete(model)
+ await s.commit()
+ return
+
+
+class Response:
+ def __init__(self, response, key=None):
+ assert response.errors is None
+
+ if key is None:
+ assert response.data is not None
+ assert len(response.data) == 1
+ key = next(iter(response.data.keys()))
+
+ assert key in response.data
+ self.data = response.data.get(key)
+ self.errors = response.errors
+
+ def __getattr__(self, name):
+ assert name in self.data
+ return self.data.get(name)
+
+ def assert_is(self, typename):
+ assert self.data["__typename"] == typename
+
+
+@pytest.fixture
+def gen_artist():
+ def _gen():
+ yield models.Artist(id=1, name="alan smithee")
+ yield models.Artist(id=2, name="david agnew")
+ yield models.Artist(id=3, name="robin bland")
+ yield models.Artist(id=4, name="robin smith")
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_character():
+ def _gen():
+ yield models.Character(id=1, name="greta giraffe")
+ yield models.Character(id=2, name="bob bear")
+ yield models.Character(id=3, name="rico rhinoceros")
+ yield models.Character(id=4, name="ziggy zebra")
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_circle():
+ def _gen():
+ yield models.Circle(id=1, name="archimedes")
+ yield models.Circle(id=2, name="bankoff")
+ yield models.Circle(id=3, name="carlyle")
+ yield models.Circle(id=4, name="ford")
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_namespace():
+ def _gen():
+ yield models.Namespace(id=1, name="animal", sort_name="animal")
+ yield models.Namespace(id=2, name="human", sort_name="human")
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_tag():
+ def _gen():
+ yield models.Tag(
+ id=1, name="small", description="barely visible", namespaces=[]
+ )
+ yield models.Tag(
+ id=2,
+ name="medium",
+ description="mostly average",
+ namespaces=[],
+ )
+ yield models.Tag(id=3, name="big", description="impressive", namespaces=[])
+ yield models.Tag(
+ id=4, name="massive", description="what is THAT", namespaces=[]
+ )
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_world():
+ def _gen():
+ yield models.World(id=1, name="animal friends")
+ yield models.World(id=2, name="criminanimals")
+ yield models.World(id=3, name="in the nude")
+ yield models.World(id=4, name="wall street")
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_image():
+ def _gen():
+ yield models.Image(
+ id=1, hash="1bb05614b44bf177589632a51ce216a2", width=3024, height=2106
+ )
+ yield models.Image(
+ id=2, hash="77dfd96aee1bc8c36ab7095fcf18f7ff", width=3024, height=2094
+ )
+ yield models.Image(
+ id=3, hash="109aac22f29bd361fbfb19f975a1b7f0", width=3019, height=2089
+ )
+ yield models.Image(
+ id=4, hash="e18fc95f00a087ff001ecd8675eddd14", width=3024, height=2097
+ )
+ yield models.Image(
+ id=5, hash="0e2cd2f176e792a3777710978768bc90", width=1607, height=2259
+ )
+ yield models.Image(
+ id=6, hash="64e50730eb842750ebe5417a524b83e6", width=1556, height=2264
+ )
+ yield models.Image(
+ id=7, hash="d906ef54788cae72e1a511c9775e6d68", width=1525, height=2259
+ )
+ yield models.Image(
+ id=8, hash="0f8ead4a60df09a1dd071617b5d5583b", width=1545, height=2264
+ )
+ yield models.Image(
+ id=9, hash="912ccb4350fb17ea1248e26ecfb5d983", width=1607, height=2259
+ )
+ yield models.Image(
+ id=10, hash="108edee1b417f022a6d1f999bd32d16d", width=1546, height=2224
+ )
+ yield models.Image(
+ id=11, hash="97c0903cb0962741174f264aaa7015d4", width=1528, height=2257
+ )
+ yield models.Image(
+ id=12, hash="b5490ad31d2a8910087ba932073b4e52", width=1543, height=2271
+ )
+ yield models.Image(
+ id=13, hash="c9ab7febcb81974a992ed1de60c728ba", width=1611, height=2257
+ )
+ yield models.Image(
+ id=14, hash="bcfdf22ec17a09cd4f6a0af86e966e8f", width=1553, height=2265
+ )
+ yield models.Image(
+ id=15, hash="1f58f4b08bf6f4ca92bd29cbce26241e", width=1526, height=2258
+ )
+ yield models.Image(
+ id=16, hash="f87d7e55203b5e7cf9c801db48624ef0", width=1645, height=2262
+ )
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_page(gen_image):
+ def _gen():
+ yield models.Page(id=1, index=1, path="001.png", image=next(gen_image))
+ yield models.Page(id=2, index=2, path="002.png", image=next(gen_image))
+ yield models.Page(id=3, index=3, path="003.png", image=next(gen_image))
+ yield models.Page(id=4, index=4, path="004.png", image=next(gen_image))
+ yield models.Page(id=5, index=1, path="00.jpg", image=next(gen_image))
+ yield models.Page(id=6, index=2, path="01.jpg", image=next(gen_image))
+ yield models.Page(id=7, index=3, path="02.jpg", image=next(gen_image))
+ yield models.Page(id=8, index=4, path="03.jpg", image=next(gen_image))
+ yield models.Page(id=9, index=1, path="1.jpg", image=next(gen_image))
+ yield models.Page(id=10, index=2, path="2.jpg", image=next(gen_image))
+ yield models.Page(id=11, index=3, path="10.jpg", image=next(gen_image))
+ yield models.Page(id=12, index=4, path="11.jpg", image=next(gen_image))
+ yield models.Page(id=13, index=1, path="010.png", image=next(gen_image))
+ yield models.Page(id=14, index=2, path="011.png", image=next(gen_image))
+ yield models.Page(id=15, index=3, path="012.png", image=next(gen_image))
+ yield models.Page(id=16, index=4, path="013.png", image=next(gen_image))
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_jumbled_pages(gen_image):
+ def _gen():
+ yield models.Page(id=101, index=3, path="3.png", image=next(gen_image))
+ yield models.Page(id=52, index=9, path="9.png", image=next(gen_image))
+ yield models.Page(id=13, index=2, path="2.png", image=next(gen_image))
+ yield models.Page(id=258, index=10, path="10.png", image=next(gen_image))
+ yield models.Page(id=7, index=7, path="7.jpg", image=next(gen_image))
+ yield models.Page(id=25, index=5, path="5.jpg", image=next(gen_image))
+ yield models.Page(id=150, index=1, path="1.jpg", image=next(gen_image))
+ yield models.Page(id=69, index=4, path="4.jpg", image=next(gen_image))
+ yield models.Page(id=219, index=6, path="6.jpg", image=next(gen_image))
+ yield models.Page(id=34, index=8, path="8.jpg", image=next(gen_image))
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_jumbled_archive(gen_jumbled_pages):
+ def _gen():
+ pages = [next(gen_jumbled_pages) for _ in range(10)]
+ yield models.Archive(
+ id=100,
+ hash="4e1243bd22c66e76c2ba9eddc1f91394",
+ path="comics/jumbled.zip",
+ size=32559235,
+ mtime=dt(2002, 1, 23).astimezone(),
+ cover=pages[0].image,
+ pages=pages,
+ page_count=len(pages),
+ )
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_archive(gen_page):
+ def _gen():
+ pages = [next(gen_page) for _ in range(4)]
+ yield models.Archive(
+ id=1,
+ hash="1d394f66c49ccb1d3c30870904d31bd4",
+ path="comics/archive-01.zip",
+ size=7340032,
+ mtime=dt(2016, 5, 10).astimezone(),
+ cover=pages[0].image,
+ pages=pages,
+ page_count=len(pages),
+ )
+
+ pages = [next(gen_page) for _ in range(4)]
+ yield models.Archive(
+ id=2,
+ hash="d7d8929b2e606200e863d390f71b53bb",
+ path="comics/archive-02.zip",
+ size=11335106,
+ mtime=dt(2008, 10, 2, tzinfo=tz(timedelta(hours=+6))),
+ cover=pages[0].image,
+ pages=pages,
+ page_count=len(pages),
+ )
+
+ pages = [next(gen_page) for _ in range(4)]
+ yield models.Archive(
+ id=3,
+ hash="02669dbe08c4a5f4820c10b3ff2178fa",
+ path="comics/sub/archive-new.zip",
+ size=51841969,
+ mtime=dt(2005, 11, 17, tzinfo=tz(timedelta(hours=+2))),
+ cover=pages[0].image,
+ pages=pages,
+ page_count=len(pages),
+ )
+
+ pages = [next(gen_page) for _ in range(4)]
+ yield models.Archive(
+ id=4,
+ hash="6b2ecf5ceb8befd6d0c1cd353a3df709",
+ path="comics/archive-03.zip",
+ size=13568769,
+ mtime=dt(1999, 5, 8, tzinfo=tz(timedelta(hours=-2))),
+ cover=pages[0].image,
+ pages=pages,
+ page_count=len(pages),
+ )
+
+ return _gen()
+
+
+@pytest.fixture
+def gen_comic(
+ gen_archive,
+ gen_artist,
+ gen_character,
+ gen_circle,
+ gen_world,
+ gen_tag,
+ gen_namespace,
+):
+ def _gen():
+ artists = {a.id: a for a in gen_artist}
+ characters = {c.id: c for c in gen_character}
+
+ namespaces = {ns.id: ns for ns in gen_namespace}
+ tags = {t.id: t for t in gen_tag}
+
+ def tag(nid, tid):
+ return models.ComicTag(namespace=namespaces[nid], tag=tags[tid])
+
+ archive = next(gen_archive)
+ yield models.Comic(
+ id=1,
+ title="Arid Savannah Adventures",
+ url="file:///home/savannah/adventures",
+ category=Category.MANGA,
+ censorship=Censorship.NONE,
+ date=date(2010, 7, 5),
+ direction=Direction.LEFT_TO_RIGHT,
+ favourite=True,
+ language=Language.EN,
+ layout=Layout.SINGLE,
+ rating=Rating.SAFE,
+ archive=archive,
+ artists=[artists[1], artists[2]],
+ characters=list(characters.values()),
+ circles=[next(gen_circle)],
+ worlds=[next(gen_world)],
+ cover=archive.cover,
+ pages=archive.pages,
+ tags=[
+ tag(1, 1),
+ tag(1, 2),
+ tag(1, 3),
+ tag(1, 4),
+ ],
+ )
+
+ archive = next(gen_archive)
+ yield models.Comic(
+ id=2,
+ title="This Giraffe Stole My Wallet",
+ original_title="Diese Giraffe hat mein Geldbeutel geklaut",
+ url="ftp://crimes.local/giraffes.zip",
+ category=Category.MANGA,
+ censorship=Censorship.BAR,
+ date=date(2002, 2, 17),
+ direction=Direction.LEFT_TO_RIGHT,
+ favourite=False,
+ language=Language.EN,
+ layout=Layout.SINGLE,
+ rating=Rating.QUESTIONABLE,
+ archive=archive,
+ artists=[artists[3]],
+ characters=[characters[1]],
+ circles=[next(gen_circle)],
+ worlds=[next(gen_world)],
+ cover=archive.cover,
+ pages=archive.pages,
+ tags=[
+ tag(1, 3),
+ tag(2, 1),
+ ],
+ )
+
+ archive = next(gen_archive)
+ yield models.Comic(
+ id=3,
+ title="サイのスパ",
+ category=Category.ARTBOOK,
+ censorship=Censorship.MOSAIC,
+ date=date(2017, 5, 3),
+ direction=Direction.RIGHT_TO_LEFT,
+ favourite=False,
+ language=Language.JA,
+ layout=Layout.DOUBLE_OFFSET,
+ rating=Rating.EXPLICIT,
+ archive=archive,
+ artists=[artists[1], artists[4]],
+ characters=[characters[3]],
+ circles=[next(gen_circle)],
+ worlds=[next(gen_world)],
+ cover=archive.cover,
+ pages=archive.pages,
+ tags=[
+ tag(1, 4),
+ ],
+ )
+
+ archive = next(gen_archive)
+ yield models.Comic(
+ id=4,
+ title="In the Company of Vultures",
+ category=Category.DOUJINSHI,
+ date=date(2023, 3, 10),
+ direction=Direction.LEFT_TO_RIGHT,
+ favourite=False,
+ language=Language.EN,
+ layout=Layout.SINGLE,
+ rating=Rating.SAFE,
+ archive=archive,
+ artists=[artists[4]],
+ characters=[characters[4]],
+ circles=[next(gen_circle)],
+ worlds=[next(gen_world)],
+ cover=archive.cover,
+ pages=archive.pages,
+ tags=[
+ tag(2, 1),
+ tag(2, 2),
+ tag(2, 3),
+ ],
+ )
+
+ return _gen()
+
+
+@pytest.fixture
+def empty_comic(gen_archive):
+ archive = next(gen_archive)
+ yield models.Comic(
+ id=100,
+ title="Hic Sunt Dracones",
+ archive=archive,
+ cover=archive.cover,
+ pages=archive.pages,
+ )
diff --git a/tests/plugins/test_plugins.py b/tests/plugins/test_plugins.py
new file mode 100644
index 0000000..dd7042e
--- /dev/null
+++ b/tests/plugins/test_plugins.py
@@ -0,0 +1,9 @@
+import hircine.plugins
+
+
+def test_plugin_transformer_decorator(empty_plugins):
+ @hircine.plugins.transformer
+ def ignore(generator, info):
+ return
+
+ assert hircine.plugins.transformers == [ignore]
diff --git a/tests/scanner/data/contents/archive.zip b/tests/scanner/data/contents/archive.zip
new file mode 100644
index 0000000..990eb98
--- /dev/null
+++ b/tests/scanner/data/contents/archive.zip
Binary files differ
diff --git a/tests/scanner/test_scanner.py b/tests/scanner/test_scanner.py
new file mode 100644
index 0000000..45a966f
--- /dev/null
+++ b/tests/scanner/test_scanner.py
@@ -0,0 +1,311 @@
+import configparser
+import os
+import shutil
+from datetime import datetime, timezone
+from pathlib import Path
+from zipfile import ZipFile
+
+import hircine.thumbnailer
+import pytest
+from conftest import DB
+from hircine.config import DirectoryStructure
+from hircine.db.models import Archive, Image, Page
+from hircine.scanner import Scanner, Status
+from hircine.thumbnailer import object_path
+
+
+def pageset(pages):
+ return set([(page.path, page.archive_id, page.image.hash) for page in pages])
+
+
+@pytest.fixture
+def archive(data):
+ stat = os.stat(data("contents/archive.zip"))
+ mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
+
+ cover = Image(
+ id=1,
+ hash="4ac228082aaf8bedc0fbd4859c5324c2acf0d1c63f9097d55e9be88d0804eaa4",
+ width=0,
+ height=0,
+ )
+
+ archive = Archive(
+ id=1,
+ path=data("contents/archive.zip"),
+ hash="8aa2fd72954fb9103776114172d345ad4446babf292e876a892cfbed1c283523",
+ size=stat.st_size,
+ mtime=mtime,
+ cover=cover,
+ pages=[
+ Page(
+ id=1,
+ archive_id=1,
+ index=1,
+ path="01.png",
+ image=cover,
+ ),
+ Page(
+ id=2,
+ archive_id=1,
+ index=2,
+ path="02.png",
+ image=Image(
+ id=2,
+ hash="9b2c7a9c1f3d1c5a07fa1492d9d91ace5122262559c7f513e3b97464d2edb753",
+ width=0,
+ height=0,
+ ),
+ ),
+ Page(
+ id=3,
+ archive_id=1,
+ index=3,
+ path="03.png",
+ image=Image(
+ id=3,
+ hash="ed132e79daf9e93970d14d9443b7870f1aefd12aa9d3fba8cab0096984754ff5",
+ width=0,
+ height=0,
+ ),
+ ),
+ ],
+ page_count=3,
+ )
+
+ yield archive
+
+
+@pytest.fixture
+def scanner(data, monkeypatch):
+ monkeypatch.setattr(
+ hircine.thumbnailer.Thumbnailer, "process", lambda s, a, b: (0, 0)
+ )
+
+ dirs = DirectoryStructure(scan=data("contents/"), objects=data("objects/"))
+ yield Scanner(configparser.ConfigParser(), dirs)
+
+
+@pytest.mark.anyio
+async def test_scanner_adds_new_archive(archive, scanner, capsys):
+ await scanner.scan()
+ added_archive = await DB.get(Archive, 1, full=True)
+
+ assert added_archive.hash == archive.hash
+ assert pageset(added_archive.pages) == pageset(archive.pages)
+
+ captured = capsys.readouterr()
+ assert captured.out == "[+] archive.zip\n"
+
+
+@pytest.mark.anyio
+async def test_scanner_dedups_archive_contents(archive, scanner, capsys):
+ archive = await DB.add(archive)
+
+ dedup_path = archive.path + ".dedup"
+ with ZipFile(archive.path, "r") as zin:
+ with ZipFile(dedup_path, "w") as zout:
+ for info in zin.infolist():
+ base, ext = os.path.splitext(info.filename)
+
+ if base == "03":
+ continue
+
+ if ext == ".png":
+ zout.writestr(f"0{base}.png", zin.read(info))
+ else:
+ zout.writestr(info.filename, zin.read(info))
+
+ await scanner.scan()
+ added_archive = await DB.get(Archive, 2, full=True)
+
+ assert (
+ added_archive.hash
+ == "fc2ea810eddc231824aef44db62d5f3de89b3747e4aea6b5728c1532aabdeccd"
+ )
+
+ pages = set()
+ for page in archive.pages:
+ if page.path == "03.png":
+ continue
+
+ pages.add((f"0{page.path}", 2, page.image.hash))
+
+ assert pageset(added_archive.pages) == pages
+
+ captured = capsys.readouterr()
+ assert captured.out == "[+] archive.zip.dedup\n"
+
+
+@pytest.mark.anyio
+async def test_scanner_skips_same_mtime(archive, scanner, capsys):
+ archive = await DB.add(archive)
+ await scanner.scan()
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+
+
+@pytest.mark.anyio
+async def test_scanner_finds_existing_before_duplicate(archive, scanner, capsys):
+ stat = os.stat(archive.path)
+ mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
+
+ before = await DB.add(archive)
+
+ copy_path = before.path + ".copy"
+ shutil.copyfile(Path(before.path), copy_path)
+
+ await scanner.scan()
+
+ after = await DB.get(Archive, before.id, full=True)
+ assert after.hash == before.hash
+ assert after.path == before.path
+ assert after.mtime == mtime
+ assert pageset(after.pages) == pageset(before.pages)
+
+ captured = capsys.readouterr()
+ assert captured.out == "[I] archive.zip.copy\n"
+
+
+@pytest.mark.anyio
+async def test_scanner_skips_non_zip(data, scanner, capsys):
+ Path(data("contents/archive.zip")).unlink()
+ Path(data("contents/non_zip.txt")).touch()
+ await scanner.scan()
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+
+
+@pytest.mark.anyio
+async def test_scanner_skips_link(data, scanner, capsys):
+ Path(data("contents/archive.zip")).rename(data("archive.zip"))
+ os.symlink(data("archive.zip"), data("contents/archive.zip"))
+ await scanner.scan()
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+
+
+@pytest.mark.anyio
+async def test_scanner_updates_mtime(archive, scanner, capsys):
+ Path(archive.path).touch()
+ stat = os.stat(archive.path)
+ mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
+
+ archive = await DB.add(archive)
+ await scanner.scan()
+
+ updated_archive = await DB.get(Archive, archive.id, full=True)
+ assert updated_archive.hash == archive.hash
+ assert updated_archive.path == archive.path
+ assert updated_archive.mtime == mtime
+ assert pageset(updated_archive.pages) == pageset(archive.pages)
+
+ captured = capsys.readouterr()
+ assert captured.out == "[*] archive.zip\n"
+
+
+@pytest.mark.anyio
+async def test_scanner_updates_path(archive, scanner, capsys):
+ new_path = archive.path + ".new"
+
+ Path(archive.path).rename(new_path)
+ stat = os.stat(new_path)
+ mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
+
+ archive = await DB.add(archive)
+ await scanner.scan()
+
+ updated_archive = await DB.get(Archive, archive.id, full=True)
+ assert updated_archive.hash == archive.hash
+ assert updated_archive.path == new_path
+ assert updated_archive.mtime == mtime
+ assert pageset(updated_archive.pages) == pageset(archive.pages)
+
+ captured = capsys.readouterr()
+ assert captured.out == "[>] archive.zip -> archive.zip.new\n"
+
+
+@pytest.mark.anyio
+async def test_scanner_reports_missing(archive, scanner):
+ archive = await DB.add(archive)
+ Path(archive.path).unlink()
+ await scanner.scan()
+
+ assert scanner.registry.orphans == {archive.hash: (archive.id, archive.path)}
+
+
+@pytest.mark.anyio
+async def test_scanner_reports_duplicate(archive, scanner, capsys):
+ archive = await DB.add(archive)
+ copy_path = archive.path + ".copy"
+ shutil.copyfile(Path(archive.path), copy_path)
+ await scanner.scan()
+
+ assert list(scanner.registry.duplicates) == [
+ [
+ (archive.path, Status.UNCHANGED),
+ (copy_path, Status.IGNORED),
+ ]
+ ]
+
+ captured = capsys.readouterr()
+ assert captured.out == "[I] archive.zip.copy\n"
+
+
+@pytest.mark.anyio
+async def test_scanner_ignores_empty_archive(archive, scanner, capsys):
+ Path(archive.path).unlink()
+
+ empty_path = archive.path + ".empty"
+ ZipFile(empty_path, "w").close()
+
+ await scanner.scan()
+
+ assert scanner.registry.marked == {}
+
+ captured = capsys.readouterr()
+ assert captured.out == ""
+
+
+@pytest.mark.anyio
+async def test_scanner_reports_conflict(archive, scanner, capsys):
+ archive = await DB.add(archive)
+ ZipFile(archive.path, "w").close()
+
+ await scanner.scan()
+
+ assert scanner.registry.conflicts == {
+ archive.path: (
+ archive.hash,
+ "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262",
+ )
+ }
+
+ captured = capsys.readouterr()
+ assert captured.out == "[!] archive.zip\n"
+
+
+@pytest.mark.anyio
+async def test_scanner_reprocess(archive, data, scanner, capsys):
+ await scanner.scan()
+
+ captured = capsys.readouterr()
+ assert captured.out == "[+] archive.zip\n"
+
+ old_stat = os.stat(data(object_path("objects/", archive.cover.hash, "full")))
+ old_mtime = datetime.fromtimestamp(old_stat.st_mtime, tz=timezone.utc)
+
+ scanner.reprocess = True
+
+ await scanner.scan()
+
+ new_stat = os.stat(data(object_path("objects/", archive.cover.hash, "full")))
+ new_mtime = datetime.fromtimestamp(new_stat.st_mtime, tz=timezone.utc)
+
+ assert new_mtime > old_mtime
+
+ captured = capsys.readouterr()
+ assert captured.out == "[~] archive.zip\n"
diff --git a/tests/scrapers/test_scraper.py b/tests/scrapers/test_scraper.py
new file mode 100644
index 0000000..6f6f29d
--- /dev/null
+++ b/tests/scrapers/test_scraper.py
@@ -0,0 +1,55 @@
+from hircine.scraper import Scraper, ScrapeWarning
+
+
+class MockScraper(Scraper):
+ is_available = True
+
+ def scrape(self):
+ yield lambda: "foo"
+ yield "bar"
+
+
+class WarningScraper(Scraper):
+ is_available = True
+
+ def warn(self, str):
+ raise ScrapeWarning("Invalid input")
+
+ def scrape(self):
+ yield lambda: "foo"
+ yield lambda: self.warn("bar")
+ yield "baz"
+
+
+class ParserlessScraper(Scraper):
+ is_available = True
+
+ def scrape(self):
+ yield "literal"
+
+
+def test_scraper_collects():
+ generator = MockScraper(None).collect()
+
+ assert set(generator) == set(["foo", "bar"])
+
+
+def test_scraper_collects_with_transformer():
+ generator = MockScraper(None).collect([lambda gen, info: map(str.upper, gen)])
+
+ assert set(generator) == set(["FOO", "BAR"])
+
+
+def test_scraper_collects_warnings():
+ scraper = WarningScraper(None)
+ generator = scraper.collect()
+
+ assert set(generator) == set(["foo", "baz"])
+ assert scraper.get_warnings() == ["Invalid input"]
+
+
+def test_scraper_collects_literal():
+ scraper = ParserlessScraper(None)
+ generator = scraper.collect()
+
+ assert set(generator) == set(["literal"])
diff --git a/tests/scrapers/test_scraper_utils.py b/tests/scrapers/test_scraper_utils.py
new file mode 100644
index 0000000..193cf2a
--- /dev/null
+++ b/tests/scrapers/test_scraper_utils.py
@@ -0,0 +1,28 @@
+from hircine.scraper.utils import parse_dict
+
+
+def test_parse_dict():
+ dict = {
+ "scalar": "foo",
+ "list": ["bar", "baz"],
+ "dict": {"nested_scalar": "qux", "nested_list": ["plugh", "xyzzy"]},
+ }
+
+ def id(type):
+ return lambda item: f"{type}_{item}"
+
+ parsers = {
+ "scalar": id("scalar"),
+ "list": id("list"),
+ "dict": {"nested_scalar": id("scalar"), "nested_list": id("list")},
+ "missing": id("missing"),
+ }
+
+ assert [f() for f in parse_dict(parsers, dict)] == [
+ "scalar_foo",
+ "list_bar",
+ "list_baz",
+ "scalar_qux",
+ "list_plugh",
+ "list_xyzzy",
+ ]
diff --git a/tests/scrapers/test_types.py b/tests/scrapers/test_types.py
new file mode 100644
index 0000000..ed937e7
--- /dev/null
+++ b/tests/scrapers/test_types.py
@@ -0,0 +1,131 @@
+from datetime import date
+
+import pytest
+from hircine.api.types import ScrapedComic
+from hircine.scraper import ScrapeWarning
+from hircine.scraper.types import (
+ Artist,
+ Category,
+ Character,
+ Circle,
+ Date,
+ Language,
+ OriginalTitle,
+ Rating,
+ Tag,
+ Title,
+ World,
+)
+
+
+@pytest.mark.parametrize(
+ "input,options,want",
+ [
+ ("foo", {}, Tag(namespace="none", tag="foo")),
+ ("foo:bar", {}, Tag(namespace="foo", tag="bar")),
+ ("foo:bar:baz", {}, Tag(namespace="foo", tag="bar:baz")),
+ ("foo/bar", {"delimiter": "/"}, Tag(namespace="foo", tag="bar")),
+ ],
+ ids=[
+ "tag only",
+ "tag and namespace",
+ "tag with delimiter",
+ "custom delimiter",
+ ],
+)
+def test_tag_from_string(input, options, want):
+ assert Tag.from_string(input, **options) == want
+
+
+@pytest.mark.parametrize(
+ "input,want",
+ [
+ ("1998-02-07", Date(value=date(1998, 2, 7))),
+ ("2018-07-18T19:15", Date(value=date(2018, 7, 18))),
+ (
+ "2003-12-30T10:37Z",
+ Date(value=date(2003, 12, 30)),
+ ),
+ ],
+)
+def test_date_from_iso(input, want):
+ assert Date.from_iso(input) == want
+
+
+@pytest.mark.parametrize(
+ "input",
+ [
+ ("text"),
+ ("1997 02 07"),
+ ("1997/02/07"),
+ ],
+)
+def test_date_from_iso_fails(input):
+ with pytest.raises(ScrapeWarning, match="Could not parse date:"):
+ Date.from_iso(input)
+
+
+@pytest.mark.parametrize(
+ "input,want",
+ [
+ ("886806000", Date(value=date(1998, 2, 7))),
+ (886806000, Date(value=date(1998, 2, 7))),
+ ],
+)
+def test_date_from_timestamp(input, want):
+ assert Date.from_timestamp(input) == want
+
+
+@pytest.mark.parametrize(
+ "input",
+ [
+ ("text"),
+ ],
+)
+def test_date_from_timestamp_fails(input):
+ with pytest.raises(ScrapeWarning, match="Could not parse date:"):
+ Date.from_timestamp(input)
+
+
+@pytest.mark.parametrize(
+ "item,attr,empty",
+ [
+ (Title(""), "title", None),
+ (OriginalTitle(""), "original_title", None),
+ (Language(None), "language", None),
+ (Date(None), "date", None),
+ (Rating(None), "rating", None),
+ (Category(None), "category", None),
+ (Tag("", ""), "tags", []),
+ (Tag(namespace="", tag=""), "tags", []),
+ (Tag(namespace=None, tag=""), "tags", []),
+ (Tag(namespace="foo", tag=""), "tags", []),
+ (Artist(""), "artists", []),
+ (Character(""), "characters", []),
+ (Circle(""), "circles", []),
+ (World(""), "worlds", []),
+ ],
+ ids=[
+ "title",
+ "original title",
+ "language",
+ "date",
+ "rating",
+ "category",
+ "tag (both empty, positional)",
+ "tag (both empty)",
+ "tag (namespace None, tag empty)",
+ "tag (tag empty)",
+ "artist",
+ "character",
+ "circle",
+ "world",
+ ],
+)
+def test_scraped_comic_silently_ignores_empty(item, attr, empty):
+ def gen():
+ yield item
+
+ comic = ScrapedComic.from_generator(gen())
+
+ assert getattr(comic, attr) == empty
diff --git a/tests/thumbnailer/data/example_palette.png b/tests/thumbnailer/data/example_palette.png
new file mode 100644
index 0000000..6bf25e1
--- /dev/null
+++ b/tests/thumbnailer/data/example_palette.png
Binary files differ
diff --git a/tests/thumbnailer/data/example_rgb.png b/tests/thumbnailer/data/example_rgb.png
new file mode 100644
index 0000000..a245642
--- /dev/null
+++ b/tests/thumbnailer/data/example_rgb.png
Binary files differ
diff --git a/tests/thumbnailer/test_thumbnailer.py b/tests/thumbnailer/test_thumbnailer.py
new file mode 100644
index 0000000..62bf127
--- /dev/null
+++ b/tests/thumbnailer/test_thumbnailer.py
@@ -0,0 +1,74 @@
+import os
+from pathlib import Path
+
+import pytest
+from hircine.thumbnailer import Thumbnailer, ThumbnailParameters
+from PIL import Image
+
+mock_params = ThumbnailParameters(bounds=(1440, 2880), options={})
+
+
+def test_thumbnailer_object():
+ thumb = Thumbnailer("objects/", params={})
+ assert thumb.object("abcdef", "foo") == os.path.join("objects/", "ab/cdef_foo.webp")
+
+
+@pytest.mark.parametrize(
+ "extension, can_process",
+ [
+ (".png", True),
+ (".jpeg", True),
+ (".jpg", True),
+ (".gif", True),
+ (".bmp", True),
+ (".json", False),
+ (".txt", False),
+ ],
+ ids=["png", "jpeg", "jpg", "gif", "bmp", "json", "txt"],
+)
+def test_thumbnailer_can_process(extension, can_process):
+ assert Thumbnailer.can_process(extension) == can_process
+
+
+def test_thumbnailer_process(data):
+ thumb = Thumbnailer(data("objects/"), params={"mock": mock_params})
+
+ with open(data("example_rgb.png"), "rb") as f:
+ size = Image.open(f, mode="r").size
+ reported_size = thumb.process(f, "abcdef")
+
+ assert reported_size == size
+
+ output = thumb.object("abcdef", "mock")
+
+ assert os.path.exists(output)
+
+
+def test_thumbnailer_converts_non_rgb(data):
+ thumb = Thumbnailer(data("objects/"), params={"mock": mock_params})
+
+ with open(data("example_palette.png"), "rb") as f:
+ size = Image.open(f, mode="r").size
+ reported_size = thumb.process(f, "abcdef")
+
+ assert reported_size == size
+
+ output = thumb.object("abcdef", "mock")
+
+ assert os.path.exists(output)
+
+ output_image = Image.open(output)
+ assert output_image.mode == "RGB"
+
+
+def test_thumbnailer_process_ignores_existing(data):
+ thumb = Thumbnailer(data("objects/"), params={"mock": mock_params})
+
+ output = Path(thumb.object("abcdef", "mock"))
+ os.makedirs(os.path.dirname(output))
+ output.touch()
+
+ with open(data("example_palette.png"), "rb") as f:
+ thumb.process(f, "abcdef")
+
+ assert output.stat().st_size == 0